pre updating version control
This commit is contained in:
@@ -6,7 +6,9 @@ export const API_BASE_URL =
|
|||||||
export const API_ENDPOINTS = {
|
export const API_ENDPOINTS = {
|
||||||
geometries: `${API_BASE_URL}/geometries`,
|
geometries: `${API_BASE_URL}/geometries`,
|
||||||
geometriesBatch: `${API_BASE_URL}/geometries/batch`,
|
geometriesBatch: `${API_BASE_URL}/geometries/batch`,
|
||||||
|
geometriesBatchCombined: `${API_BASE_URL}/geometries/batch/combined`,
|
||||||
entities: `${API_BASE_URL}/entities`,
|
entities: `${API_BASE_URL}/entities`,
|
||||||
|
entitiesBatch: `${API_BASE_URL}/entities/batch`,
|
||||||
vectorTiles: `${API_BASE_URL}/tiles/{z}/{x}/{y}`,
|
vectorTiles: `${API_BASE_URL}/tiles/{z}/{x}/{y}`,
|
||||||
rasterTiles: `${API_BASE_URL}/raster-tiles/{z}/{x}/{y}`,
|
rasterTiles: `${API_BASE_URL}/raster-tiles/{z}/{x}/{y}`,
|
||||||
vectorTilesMetadata: `${API_BASE_URL}/tiles/metadata/info`,
|
vectorTilesMetadata: `${API_BASE_URL}/tiles/metadata/info`,
|
||||||
|
|||||||
@@ -21,6 +21,43 @@ export type CreateEntityPayload = {
|
|||||||
status?: number | null;
|
status?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EntityBatchCreateChange =
|
||||||
|
| ({
|
||||||
|
action: "create";
|
||||||
|
entity: CreateEntityPayload & { id?: string };
|
||||||
|
})
|
||||||
|
| ({
|
||||||
|
action: "create";
|
||||||
|
id?: string;
|
||||||
|
} & CreateEntityPayload);
|
||||||
|
|
||||||
|
export type EntityBatchUpdateChange =
|
||||||
|
| ({
|
||||||
|
action: "update";
|
||||||
|
id: string;
|
||||||
|
entity: Partial<CreateEntityPayload> & { id?: string };
|
||||||
|
})
|
||||||
|
| ({
|
||||||
|
action: "update";
|
||||||
|
id: string;
|
||||||
|
} & Partial<CreateEntityPayload>);
|
||||||
|
|
||||||
|
export type EntityBatchDeleteChange = {
|
||||||
|
action: "delete";
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntityBatchChange =
|
||||||
|
| EntityBatchCreateChange
|
||||||
|
| EntityBatchUpdateChange
|
||||||
|
| EntityBatchDeleteChange;
|
||||||
|
|
||||||
|
export type EntityBatchSaveResponse = {
|
||||||
|
success: boolean;
|
||||||
|
applied: number;
|
||||||
|
created_entity_ids: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export async function fetchEntities(query?: { q?: string }): Promise<Entity[]> {
|
export async function fetchEntities(query?: { q?: string }): Promise<Entity[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (query?.q) {
|
if (query?.q) {
|
||||||
@@ -61,3 +98,11 @@ export async function updateEntity(id: string, payload: CreateEntityPayload): Pr
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveEntityBatchChanges(changes: EntityBatchChange[]): Promise<EntityBatchSaveResponse> {
|
||||||
|
return requestJson<EntityBatchSaveResponse>(API_ENDPOINTS.entitiesBatch, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ changes }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { API_ENDPOINTS } from "@/api/config";
|
import { API_ENDPOINTS } from "@/api/config";
|
||||||
|
import { EntityBatchChange } from "@/api/entities";
|
||||||
import { requestJson } from "@/api/http";
|
import { requestJson } from "@/api/http";
|
||||||
import { Change, FeatureCollection, Geometry } from "@/lib/useEditorState";
|
import { Change, FeatureCollection, Geometry } from "@/lib/useEditorState";
|
||||||
|
|
||||||
@@ -40,6 +41,14 @@ export type BatchSaveResponse = {
|
|||||||
applied: number;
|
applied: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CombinedBatchSaveResponse = {
|
||||||
|
success: boolean;
|
||||||
|
applied: number;
|
||||||
|
entity_applied: number;
|
||||||
|
geometry_applied: number;
|
||||||
|
created_entity_ids: string[];
|
||||||
|
};
|
||||||
|
|
||||||
function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
|
function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
minLng: String(params.minLng),
|
minLng: String(params.minLng),
|
||||||
@@ -72,6 +81,20 @@ export async function saveGeometryBatchChanges(changes: Change[]): Promise<Batch
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveCombinedGeometryEntityBatchChanges(
|
||||||
|
entityChanges: EntityBatchChange[],
|
||||||
|
geometryChanges: Change[]
|
||||||
|
): Promise<CombinedBatchSaveResponse> {
|
||||||
|
return requestJson<CombinedBatchSaveResponse>(API_ENDPOINTS.geometriesBatchCombined, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
entity_changes: entityChanges,
|
||||||
|
geometry_changes: geometryChanges,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function createGeometry(payload: GeometryCreatePayload): Promise<GeometryCreateResponse> {
|
export async function createGeometry(payload: GeometryCreatePayload): Promise<GeometryCreateResponse> {
|
||||||
return requestJson<GeometryCreateResponse>(API_ENDPOINTS.geometries, {
|
return requestJson<GeometryCreateResponse>(API_ENDPOINTS.geometries, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Map from "@/components/Map";
|
import Map from "@/components/Map";
|
||||||
import Editor from "@/components/Editor";
|
import Editor from "@/components/Editor";
|
||||||
import BackgroundLayersPanel from "@/components/BackgroundLayersPanel";
|
import BackgroundLayersPanel from "@/components/BackgroundLayersPanel";
|
||||||
import TimelineBar from "@/components/TimelineBar";
|
import TimelineBar from "@/components/TimelineBar";
|
||||||
import SelectedGeometryPanel from "@/components/SelectedGeometryPanel";
|
import SelectedGeometryPanel from "@/components/SelectedGeometryPanel";
|
||||||
import { createEntity, Entity, fetchEntities, searchEntitiesByName } from "@/api/entities";
|
import { Entity, EntityBatchChange, fetchEntities, searchEntitiesByName } from "@/api/entities";
|
||||||
import { ApiError } from "@/api/http";
|
import { ApiError } from "@/api/http";
|
||||||
import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries";
|
import { fetchGeometriesByBBox, saveCombinedGeometryEntityBatchChanges, updateGeometry } from "@/api/geometries";
|
||||||
import {
|
import {
|
||||||
Feature,
|
Feature,
|
||||||
FeatureCollection,
|
FeatureCollection,
|
||||||
@@ -21,7 +21,12 @@ import {
|
|||||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||||
} from "@/lib/backgroundLayers";
|
} from "@/lib/backgroundLayers";
|
||||||
import { DEFAULT_ENTITY_TYPE_ID, ENTITY_TYPE_OPTIONS } from "@/lib/entityTypeOptions";
|
import {
|
||||||
|
DEFAULT_ENTITY_TYPE_ID,
|
||||||
|
ENTITY_TYPE_OPTIONS,
|
||||||
|
EntityTypeGroupId,
|
||||||
|
findEntityTypeOption,
|
||||||
|
} from "@/lib/entityTypeOptions";
|
||||||
|
|
||||||
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
|
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
|
||||||
const WORLD_BBOX = {
|
const WORLD_BBOX = {
|
||||||
@@ -55,11 +60,30 @@ type GeometryMetaFormState = {
|
|||||||
binding: string;
|
binding: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BindingGeometrySearchOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingEntityCreate = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string | null;
|
||||||
|
type_id: string;
|
||||||
|
status: number;
|
||||||
|
};
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle">("idle");
|
const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle">("idle");
|
||||||
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
|
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [entities, setEntities] = useState<Entity[]>([]);
|
const [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
|
||||||
|
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
|
||||||
|
const [createdEntities, setCreatedEntities] = useState<Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type_id?: string | null;
|
||||||
|
}>>([]);
|
||||||
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
||||||
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
|
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
|
||||||
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
||||||
@@ -79,6 +103,9 @@ export default function Page() {
|
|||||||
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
|
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
|
||||||
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
|
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
|
||||||
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
|
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
|
||||||
|
const [bindingGeometrySearchQuery, setBindingGeometrySearchQuery] = useState("");
|
||||||
|
const [bindingGeometrySearchResults, setBindingGeometrySearchResults] = useState<BindingGeometrySearchOption[]>([]);
|
||||||
|
const [selectedBindingGeometryId, setSelectedBindingGeometryId] = useState<string | null>(null);
|
||||||
const [timelineRange, setTimelineRange] = useState<TimelineRange>(FALLBACK_TIMELINE_RANGE);
|
const [timelineRange, setTimelineRange] = useState<TimelineRange>(FALLBACK_TIMELINE_RANGE);
|
||||||
const [timelineWindowStart, setTimelineWindowStart] = useState<number>(FALLBACK_TIMELINE_RANGE.min);
|
const [timelineWindowStart, setTimelineWindowStart] = useState<number>(FALLBACK_TIMELINE_RANGE.min);
|
||||||
const [timelineWindowEnd, setTimelineWindowEnd] = useState<number>(FALLBACK_TIMELINE_RANGE.max);
|
const [timelineWindowEnd, setTimelineWindowEnd] = useState<number>(FALLBACK_TIMELINE_RANGE.max);
|
||||||
@@ -97,9 +124,12 @@ export default function Page() {
|
|||||||
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
||||||
const timelineFetchRequestRef = useRef(0);
|
const timelineFetchRequestRef = useRef(0);
|
||||||
const entitySearchRequestRef = useRef(0);
|
const entitySearchRequestRef = useRef(0);
|
||||||
const skipSelectedFeatureSyncRef = useRef(false);
|
|
||||||
|
|
||||||
const editor = useEditorState(initialData);
|
const editor = useEditorState(initialData);
|
||||||
|
const entities = useMemo(
|
||||||
|
() => mergeEntitiesWithPending(persistedEntities, pendingEntityCreates),
|
||||||
|
[persistedEntities, pendingEntityCreates]
|
||||||
|
);
|
||||||
const selectedFeature =
|
const selectedFeature =
|
||||||
selectedFeatureId === null
|
selectedFeatureId === null
|
||||||
? null
|
? null
|
||||||
@@ -107,6 +137,31 @@ export default function Page() {
|
|||||||
String(feature.properties.id) === String(selectedFeatureId)
|
String(feature.properties.id) === String(selectedFeatureId)
|
||||||
) || null;
|
) || null;
|
||||||
|
|
||||||
|
const createdGeometries = useMemo(() => {
|
||||||
|
const rows: Array<{
|
||||||
|
id: string | number;
|
||||||
|
geometryType: string;
|
||||||
|
semanticType?: string | null;
|
||||||
|
entityNames: string[];
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const change of editor.changes.values()) {
|
||||||
|
if (change.action !== "create") continue;
|
||||||
|
const feature = change.feature;
|
||||||
|
const entityNames = normalizeFeatureEntityIds(feature)
|
||||||
|
.map((entityId) => entities.find((entity) => entity.id === entityId)?.name || entityId);
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
id: feature.properties.id,
|
||||||
|
geometryType: feature.geometry.type,
|
||||||
|
semanticType: feature.properties.type || getDefaultTypeIdForFeature(feature),
|
||||||
|
entityNames,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}, [editor.changes, entities]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
@@ -115,8 +170,10 @@ export default function Page() {
|
|||||||
const rows = await fetchEntities();
|
const rows = await fetchEntities();
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
|
||||||
setEntities(rows);
|
setPersistedEntities(rows);
|
||||||
setEntityStatus(rows.length ? null : "Chưa có entity. Cần tạo entity trước khi Save geometry.");
|
setEntityStatus(rows.length
|
||||||
|
? null
|
||||||
|
: "Chưa có entity. Cần tạo entity trước khi Save geometry.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
console.error("Load entities failed", err);
|
console.error("Load entities failed", err);
|
||||||
@@ -155,17 +212,45 @@ export default function Page() {
|
|||||||
const rows = await searchEntitiesByName(keyword, { limit: 30 });
|
const rows = await searchEntitiesByName(keyword, { limit: 30 });
|
||||||
if (disposed || requestId !== entitySearchRequestRef.current) return;
|
if (disposed || requestId !== entitySearchRequestRef.current) return;
|
||||||
|
|
||||||
setEntitySearchResults(rows);
|
const pendingMatches = pendingEntityCreates
|
||||||
|
.filter((entity) =>
|
||||||
|
entity.name.toLowerCase().includes(keyword.toLowerCase()) ||
|
||||||
|
(entity.slug || "").toLowerCase().includes(keyword.toLowerCase())
|
||||||
|
)
|
||||||
|
.map<Entity>((entity) => ({
|
||||||
|
id: entity.id,
|
||||||
|
name: entity.name,
|
||||||
|
slug: entity.slug,
|
||||||
|
type_id: entity.type_id,
|
||||||
|
status: entity.status,
|
||||||
|
geometry_count: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mergedRows = mergeEntitySearchResults(rows, pendingMatches);
|
||||||
|
setEntitySearchResults(mergedRows);
|
||||||
setSelectedSearchEntityId((prev) =>
|
setSelectedSearchEntityId((prev) =>
|
||||||
prev && rows.some((entity) => entity.id === prev)
|
prev && mergedRows.some((entity) => entity.id === prev)
|
||||||
? prev
|
? prev
|
||||||
: rows[0]?.id || null
|
: mergedRows[0]?.id || null
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (disposed || requestId !== entitySearchRequestRef.current) return;
|
if (disposed || requestId !== entitySearchRequestRef.current) return;
|
||||||
console.error("Search entity by name failed", err);
|
console.error("Search entity by name failed", err);
|
||||||
setEntitySearchResults([]);
|
const pendingMatches = pendingEntityCreates
|
||||||
setSelectedSearchEntityId(null);
|
.filter((entity) =>
|
||||||
|
entity.name.toLowerCase().includes(keyword.toLowerCase()) ||
|
||||||
|
(entity.slug || "").toLowerCase().includes(keyword.toLowerCase())
|
||||||
|
)
|
||||||
|
.map<Entity>((entity) => ({
|
||||||
|
id: entity.id,
|
||||||
|
name: entity.name,
|
||||||
|
slug: entity.slug,
|
||||||
|
type_id: entity.type_id,
|
||||||
|
status: entity.status,
|
||||||
|
geometry_count: 0,
|
||||||
|
}));
|
||||||
|
setEntitySearchResults(pendingMatches);
|
||||||
|
setSelectedSearchEntityId(pendingMatches[0]?.id || null);
|
||||||
} finally {
|
} finally {
|
||||||
if (!disposed && requestId === entitySearchRequestRef.current) {
|
if (!disposed && requestId === entitySearchRequestRef.current) {
|
||||||
setIsEntitySearchLoading(false);
|
setIsEntitySearchLoading(false);
|
||||||
@@ -177,7 +262,52 @@ export default function Page() {
|
|||||||
disposed = true;
|
disposed = true;
|
||||||
window.clearTimeout(timeoutId);
|
window.clearTimeout(timeoutId);
|
||||||
};
|
};
|
||||||
}, [entitySearchQuery, selectedFeature]);
|
}, [entitySearchQuery, selectedFeature, pendingEntityCreates]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedFeature) {
|
||||||
|
setBindingGeometrySearchResults([]);
|
||||||
|
setSelectedBindingGeometryId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = bindingGeometrySearchQuery.trim().toLowerCase();
|
||||||
|
if (!keyword.length) {
|
||||||
|
setBindingGeometrySearchResults([]);
|
||||||
|
setSelectedBindingGeometryId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBindingIds = new Set(parseBindingInput(geometryMetaForm.binding));
|
||||||
|
const rows = editor.draft.features
|
||||||
|
.filter((feature) => String(feature.properties.id) !== String(selectedFeature.properties.id))
|
||||||
|
.map((feature) => {
|
||||||
|
const id = String(feature.properties.id);
|
||||||
|
const typeLabel = feature.properties.type || feature.geometry.type;
|
||||||
|
const entityLabel = Array.isArray(feature.properties.entity_names) && feature.properties.entity_names.length
|
||||||
|
? feature.properties.entity_names.join(", ")
|
||||||
|
: (feature.properties.entity_name || "");
|
||||||
|
const label = `#${id} [${feature.geometry.type}] ${typeLabel}${entityLabel ? ` - ${entityLabel}` : ""}`;
|
||||||
|
const searchable = `${id} ${feature.geometry.type} ${typeLabel} ${entityLabel}`.toLowerCase();
|
||||||
|
return { id, label, searchable };
|
||||||
|
})
|
||||||
|
.filter((item) => !currentBindingIds.has(item.id))
|
||||||
|
.filter((item) => item.searchable.includes(keyword))
|
||||||
|
.slice(0, 40)
|
||||||
|
.map((item) => ({ id: item.id, label: item.label }));
|
||||||
|
|
||||||
|
setBindingGeometrySearchResults(rows);
|
||||||
|
setSelectedBindingGeometryId((prev) =>
|
||||||
|
prev && rows.some((item) => item.id === prev)
|
||||||
|
? prev
|
||||||
|
: rows[0]?.id || null
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
bindingGeometrySearchQuery,
|
||||||
|
geometryMetaForm.binding,
|
||||||
|
editor.draft,
|
||||||
|
selectedFeature,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedFeatureId === null) return;
|
if (selectedFeatureId === null) return;
|
||||||
@@ -190,17 +320,7 @@ export default function Page() {
|
|||||||
}, [editor.draft, selectedFeatureId]);
|
}, [editor.draft, selectedFeatureId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (skipSelectedFeatureSyncRef.current) {
|
|
||||||
skipSelectedFeatureSyncRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedFeature) {
|
if (!selectedFeature) {
|
||||||
setEntityForm({
|
|
||||||
name: "",
|
|
||||||
slug: "",
|
|
||||||
type_id: DEFAULT_ENTITY_TYPE_ID,
|
|
||||||
});
|
|
||||||
setSelectedGeometryEntityIds([]);
|
setSelectedGeometryEntityIds([]);
|
||||||
setGeometryMetaForm({
|
setGeometryMetaForm({
|
||||||
time_start: "",
|
time_start: "",
|
||||||
@@ -210,26 +330,14 @@ export default function Page() {
|
|||||||
setEntitySearchQuery("");
|
setEntitySearchQuery("");
|
||||||
setEntitySearchResults([]);
|
setEntitySearchResults([]);
|
||||||
setSelectedSearchEntityId(null);
|
setSelectedSearchEntityId(null);
|
||||||
|
setBindingGeometrySearchQuery("");
|
||||||
|
setBindingGeometrySearchResults([]);
|
||||||
|
setSelectedBindingGeometryId(null);
|
||||||
setEntityFormStatus(null);
|
setEntityFormStatus(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const featureEntityIds = normalizeFeatureEntityIds(selectedFeature);
|
const featureEntityIds = normalizeFeatureEntityIds(selectedFeature);
|
||||||
const primaryEntityId = featureEntityIds[0] || null;
|
|
||||||
const linkedEntity = primaryEntityId
|
|
||||||
? entities.find((entity) => entity.id === primaryEntityId) || null
|
|
||||||
: null;
|
|
||||||
const nextTypeId =
|
|
||||||
linkedEntity?.type_id ||
|
|
||||||
selectedFeature.properties.type ||
|
|
||||||
selectedFeature.properties.entity_type_id ||
|
|
||||||
getDefaultTypeIdForFeature(selectedFeature);
|
|
||||||
|
|
||||||
setEntityForm({
|
|
||||||
name: "",
|
|
||||||
slug: linkedEntity?.slug || "",
|
|
||||||
type_id: nextTypeId,
|
|
||||||
});
|
|
||||||
setSelectedGeometryEntityIds(featureEntityIds);
|
setSelectedGeometryEntityIds(featureEntityIds);
|
||||||
setGeometryMetaForm({
|
setGeometryMetaForm({
|
||||||
time_start: selectedFeature.properties.time_start != null
|
time_start: selectedFeature.properties.time_start != null
|
||||||
@@ -243,12 +351,39 @@ export default function Page() {
|
|||||||
setEntitySearchQuery("");
|
setEntitySearchQuery("");
|
||||||
setEntitySearchResults([]);
|
setEntitySearchResults([]);
|
||||||
setSelectedSearchEntityId(null);
|
setSelectedSearchEntityId(null);
|
||||||
|
setBindingGeometrySearchQuery("");
|
||||||
|
setBindingGeometrySearchResults([]);
|
||||||
|
setSelectedBindingGeometryId(null);
|
||||||
if (!featureEntityIds.length) {
|
if (!featureEntityIds.length) {
|
||||||
setEntityFormStatus("Geometry mới phải được gắn ít nhất 1 entity trước khi Save.");
|
setEntityFormStatus("Geometry mới phải được gắn ít nhất 1 entity trước khi Save.");
|
||||||
} else {
|
} else {
|
||||||
setEntityFormStatus(null);
|
setEntityFormStatus(null);
|
||||||
}
|
}
|
||||||
}, [selectedFeature, entities]);
|
}, [selectedFeature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedFeature) return;
|
||||||
|
|
||||||
|
const allowedGroupIds = getAllowedEntityTypeGroupIdsForFeature(selectedFeature);
|
||||||
|
const fallbackOption = ENTITY_TYPE_OPTIONS.find((option) =>
|
||||||
|
allowedGroupIds.includes(option.groupId)
|
||||||
|
);
|
||||||
|
if (!fallbackOption) return;
|
||||||
|
|
||||||
|
setEntityForm((prev) => {
|
||||||
|
const currentOption = findEntityTypeOption(prev.type_id);
|
||||||
|
const isCurrentAllowed = currentOption
|
||||||
|
? allowedGroupIds.includes(currentOption.groupId)
|
||||||
|
: false;
|
||||||
|
if (isCurrentAllowed || prev.type_id === fallbackOption.value) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
type_id: fallbackOption.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [selectedFeature]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -361,10 +496,21 @@ export default function Page() {
|
|||||||
}, [timelineYear, isTimelineReady]);
|
}, [timelineYear, isTimelineReady]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const payload = editor.buildPayload();
|
const geometryChanges = editor.buildPayload();
|
||||||
if (!payload.length) return;
|
const entityChanges: EntityBatchChange[] = pendingEntityCreates.map((entity) => ({
|
||||||
|
action: "create",
|
||||||
|
entity: {
|
||||||
|
id: entity.id,
|
||||||
|
name: entity.name,
|
||||||
|
slug: entity.slug,
|
||||||
|
type_id: entity.type_id,
|
||||||
|
status: entity.status,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const invalid = payload.find((change) => {
|
if (!geometryChanges.length && !entityChanges.length) return;
|
||||||
|
|
||||||
|
const invalid = geometryChanges.find((change) => {
|
||||||
if (change.action === "delete") return false;
|
if (change.action === "delete") return false;
|
||||||
const draftFeature = change.action === "create"
|
const draftFeature = change.action === "create"
|
||||||
? change.feature
|
? change.feature
|
||||||
@@ -388,9 +534,16 @@ export default function Page() {
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setEntityStatus(null);
|
setEntityStatus(null);
|
||||||
try {
|
try {
|
||||||
await saveGeometryBatchChanges(payload);
|
await saveCombinedGeometryEntityBatchChanges(entityChanges, geometryChanges);
|
||||||
|
if (geometryChanges.length) {
|
||||||
editor.clearChanges();
|
editor.clearChanges();
|
||||||
await reloadCurrentTimelineData();
|
await reloadCurrentTimelineData();
|
||||||
|
}
|
||||||
|
if (entityChanges.length) {
|
||||||
|
setPendingEntityCreates([]);
|
||||||
|
await reloadEntities();
|
||||||
|
setEntityFormStatus("Đã lưu batch entities + geometries.");
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
console.error("Save failed", err.body);
|
console.error("Save failed", err.body);
|
||||||
@@ -470,9 +623,31 @@ export default function Page() {
|
|||||||
setEntityFormStatus(null);
|
setEntityFormStatus(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddSelectedBindingGeometry = () => {
|
||||||
|
const geometryId = selectedBindingGeometryId ? selectedBindingGeometryId.trim() : "";
|
||||||
|
if (!geometryId.length) {
|
||||||
|
setEntityFormStatus("Hãy chọn một geometry từ kết quả search binding trước.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedFeature && String(selectedFeature.properties.id) === geometryId) {
|
||||||
|
setEntityFormStatus("Không thể tự bind geometry với chính nó.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBindingIds = parseBindingInput(geometryMetaForm.binding);
|
||||||
|
const nextBindingIds = uniqueEntityIds([...currentBindingIds, geometryId]);
|
||||||
|
setGeometryMetaForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
binding: nextBindingIds.join(", "),
|
||||||
|
}));
|
||||||
|
setSelectedBindingGeometryId(null);
|
||||||
|
setEntityFormStatus(null);
|
||||||
|
};
|
||||||
|
|
||||||
const reloadEntities = async () => {
|
const reloadEntities = async () => {
|
||||||
const rows = await fetchEntities();
|
const rows = await fetchEntities();
|
||||||
setEntities(rows);
|
setPersistedEntities(rows);
|
||||||
return rows;
|
return rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -484,24 +659,6 @@ export default function Page() {
|
|||||||
setInitialData(data);
|
setInitialData(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetSelectedGeometryInputsAfterCreate = () => {
|
|
||||||
skipSelectedFeatureSyncRef.current = true;
|
|
||||||
setEntitySearchQuery("");
|
|
||||||
setEntitySearchResults([]);
|
|
||||||
setSelectedSearchEntityId(null);
|
|
||||||
setSelectedGeometryEntityIds([]);
|
|
||||||
setGeometryMetaForm({
|
|
||||||
time_start: "",
|
|
||||||
time_end: "",
|
|
||||||
binding: "",
|
|
||||||
});
|
|
||||||
setEntityForm({
|
|
||||||
name: "",
|
|
||||||
slug: "",
|
|
||||||
type_id: DEFAULT_ENTITY_TYPE_ID,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseGeometryMetaFormRange = () => {
|
const parseGeometryMetaFormRange = () => {
|
||||||
const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start");
|
const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start");
|
||||||
const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end");
|
const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end");
|
||||||
@@ -586,6 +743,12 @@ export default function Page() {
|
|||||||
try {
|
try {
|
||||||
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds) || selectedFeature.properties.type || null;
|
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds) || selectedFeature.properties.type || null;
|
||||||
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
|
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
|
||||||
|
const pendingEntityIdSet = new Set(pendingEntityCreates.map((entity) => entity.id));
|
||||||
|
const hasPendingEntity = entityIds.some((entityId) => pendingEntityIdSet.has(entityId));
|
||||||
|
if (hasPendingEntity) {
|
||||||
|
setEntityFormStatus("Danh sách gắn có entity chưa lưu. Hãy bấm Save trước, rồi áp dụng lại.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (editor.changeCount > 0) {
|
if (editor.changeCount > 0) {
|
||||||
setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi cập nhật geometry đã tồn tại.");
|
setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi cập nhật geometry đã tồn tại.");
|
||||||
return;
|
return;
|
||||||
@@ -617,89 +780,79 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateEntityAndAttach = async () => {
|
const handleCreateEntityOnly = async () => {
|
||||||
if (!selectedFeature) {
|
|
||||||
setEntityFormStatus("Hãy chọn một geometry trước.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editor.hasPersistedFeature(selectedFeature.properties.id) && editor.changeCount > 0) {
|
|
||||||
setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi gắn entity cho geometry đã tồn tại.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = entityForm.name.trim();
|
const name = entityForm.name.trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
setEntityFormStatus("Tên entity là bắt buộc.");
|
setEntityFormStatus("Tên entity là bắt buộc.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let timeStart: number | null;
|
const slug = entityForm.slug.trim() || null;
|
||||||
let timeEnd: number | null;
|
const typeId = entityForm.type_id || DEFAULT_ENTITY_TYPE_ID;
|
||||||
let bindingIds: string[];
|
const normalizedName = name.toLowerCase();
|
||||||
try {
|
const duplicatedName = entities.some((entity) => entity.name.trim().toLowerCase() === normalizedName);
|
||||||
({ timeStart, timeEnd } = parseGeometryMetaFormRange());
|
if (duplicatedName) {
|
||||||
bindingIds = parseBindingInput(geometryMetaForm.binding);
|
setEntityFormStatus("Tên entity đã tồn tại.");
|
||||||
} catch (err) {
|
|
||||||
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (slug) {
|
||||||
|
const normalizedSlug = slug.toLowerCase();
|
||||||
|
const duplicatedSlug = entities.some((entity) =>
|
||||||
|
(entity.slug || "").trim().toLowerCase() === normalizedSlug
|
||||||
|
);
|
||||||
|
if (duplicatedSlug) {
|
||||||
|
setEntityFormStatus("Slug entity đã tồn tại.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityId = buildClientEntityId();
|
||||||
|
const pendingCreate: PendingEntityCreate = {
|
||||||
|
id: entityId,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
type_id: typeId,
|
||||||
|
status: 1,
|
||||||
|
};
|
||||||
|
|
||||||
setIsEntitySubmitting(true);
|
setIsEntitySubmitting(true);
|
||||||
setEntityFormStatus(null);
|
setEntityFormStatus(null);
|
||||||
try {
|
try {
|
||||||
const created = await createEntity({
|
setPendingEntityCreates((prev) => [pendingCreate, ...prev]);
|
||||||
name,
|
setCreatedEntities((prev) => {
|
||||||
slug: entityForm.slug.trim() || null,
|
if (prev.some((item) => item.id === pendingCreate.id)) return prev;
|
||||||
type_id: entityForm.type_id || DEFAULT_ENTITY_TYPE_ID,
|
return [
|
||||||
|
{
|
||||||
|
id: pendingCreate.id,
|
||||||
|
name: pendingCreate.name,
|
||||||
|
type_id: pendingCreate.type_id || null,
|
||||||
|
},
|
||||||
|
...prev,
|
||||||
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = await reloadEntities();
|
setEntityForm((prev) => ({
|
||||||
const nextEntityIds = uniqueEntityIds([
|
...prev,
|
||||||
...selectedGeometryEntityIds,
|
name: "",
|
||||||
created.id,
|
slug: "",
|
||||||
]);
|
}));
|
||||||
const nextGeometryType = resolveGeometryTypeFromEntityIds(nextEntityIds, rows) || selectedFeature.properties.type || null;
|
setEntityStatus(null);
|
||||||
|
setEntityFormStatus("Đã thêm entity mới vào danh sách chờ Save.");
|
||||||
|
|
||||||
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
|
if (selectedFeature) {
|
||||||
await updateGeometry(selectedFeature.properties.id, {
|
setEntitySearchQuery(pendingCreate.name);
|
||||||
geometry: selectedFeature.geometry,
|
setSelectedSearchEntityId(pendingCreate.id);
|
||||||
type: nextGeometryType ?? undefined,
|
|
||||||
time_start: timeStart,
|
|
||||||
time_end: timeEnd,
|
|
||||||
binding: bindingIds,
|
|
||||||
entity_ids: nextEntityIds,
|
|
||||||
});
|
|
||||||
await reloadCurrentTimelineData();
|
|
||||||
setEntityFormStatus("Đã tạo entity và gắn vào geometry.");
|
|
||||||
} else {
|
|
||||||
patchSelectedFeatureLocally(
|
|
||||||
selectedFeature,
|
|
||||||
nextEntityIds,
|
|
||||||
timeStart,
|
|
||||||
timeEnd,
|
|
||||||
bindingIds,
|
|
||||||
rows
|
|
||||||
);
|
|
||||||
setEntityFormStatus("Đã tạo entity và gắn local. Bấm Save để lưu geometry mới.");
|
|
||||||
}
|
|
||||||
|
|
||||||
resetSelectedGeometryInputsAfterCreate();
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
setEntityFormStatus(`Tạo/gắn entity thất bại: ${err.body}`);
|
|
||||||
} else {
|
|
||||||
setEntityFormStatus("Tạo/gắn entity thất bại.");
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsEntitySubmitting(false);
|
setIsEntitySubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const timelineDisabled = !isTimelineReady || isSaving || editor.changeCount > 0;
|
const pendingSaveCount = editor.changeCount + pendingEntityCreates.length;
|
||||||
|
const timelineDisabled = !isTimelineReady || isSaving || pendingSaveCount > 0;
|
||||||
const timelineStatusText =
|
const timelineStatusText =
|
||||||
editor.changeCount > 0
|
pendingSaveCount > 0
|
||||||
? "Lưu hoặc undo hết thay đổi trước khi đổi mốc thời gian."
|
? "Lưu hết thay đổi trước khi đổi mốc thời gian."
|
||||||
: isSaving
|
: isSaving
|
||||||
? "Đang lưu thay đổi..."
|
? "Đang lưu thay đổi..."
|
||||||
: timelineStatus;
|
: timelineStatus;
|
||||||
@@ -718,8 +871,10 @@ export default function Page() {
|
|||||||
onUndo={editor.undo}
|
onUndo={editor.undo}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
changesCount={editor.changeCount}
|
changesCount={pendingSaveCount}
|
||||||
undoStack={editor.undoStack}
|
undoStack={editor.undoStack}
|
||||||
|
createdEntities={createdEntities}
|
||||||
|
createdGeometries={createdGeometries}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
||||||
@@ -785,8 +940,14 @@ export default function Page() {
|
|||||||
entityTypeOptions={ENTITY_TYPE_OPTIONS}
|
entityTypeOptions={ENTITY_TYPE_OPTIONS}
|
||||||
geometryMetaForm={geometryMetaForm}
|
geometryMetaForm={geometryMetaForm}
|
||||||
onGeometryMetaFormChange={handleGeometryMetaFormChange}
|
onGeometryMetaFormChange={handleGeometryMetaFormChange}
|
||||||
|
bindingGeometrySearchQuery={bindingGeometrySearchQuery}
|
||||||
|
onBindingGeometrySearchQueryChange={setBindingGeometrySearchQuery}
|
||||||
|
bindingGeometrySearchResults={bindingGeometrySearchResults}
|
||||||
|
selectedBindingGeometryId={selectedBindingGeometryId}
|
||||||
|
onSelectBindingGeometryId={setSelectedBindingGeometryId}
|
||||||
|
onAddSelectedBindingGeometry={handleAddSelectedBindingGeometry}
|
||||||
isEntitySubmitting={isEntitySubmitting}
|
isEntitySubmitting={isEntitySubmitting}
|
||||||
onCreateEntityAndAttach={handleCreateEntityAndAttach}
|
onCreateEntityOnly={handleCreateEntityOnly}
|
||||||
onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry}
|
onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry}
|
||||||
changeCount={editor.changeCount}
|
changeCount={editor.changeCount}
|
||||||
entityFormStatus={entityFormStatus}
|
entityFormStatus={entityFormStatus}
|
||||||
@@ -858,6 +1019,15 @@ function getDefaultTypeIdForFeature(feature: Feature): string {
|
|||||||
return DEFAULT_ENTITY_TYPE_ID;
|
return DEFAULT_ENTITY_TYPE_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAllowedEntityTypeGroupIdsForFeature(feature: Feature): EntityTypeGroupId[] {
|
||||||
|
const defaultTypeId = getDefaultTypeIdForFeature(feature);
|
||||||
|
const defaultTypeOption = findEntityTypeOption(defaultTypeId);
|
||||||
|
if (defaultTypeOption) {
|
||||||
|
return [defaultTypeOption.groupId];
|
||||||
|
}
|
||||||
|
return ["polygon"];
|
||||||
|
}
|
||||||
|
|
||||||
function parseOptionalYearInput(raw: string, fieldName: string): number | null {
|
function parseOptionalYearInput(raw: string, fieldName: string): number | null {
|
||||||
const value = raw.trim();
|
const value = raw.trim();
|
||||||
if (!value.length) return null;
|
if (!value.length) return null;
|
||||||
@@ -934,6 +1104,64 @@ function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): stri
|
|||||||
return names.join(", ");
|
return names.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeEntitiesWithPending(
|
||||||
|
persistedEntities: Entity[],
|
||||||
|
pendingCreates: PendingEntityCreate[]
|
||||||
|
): Entity[] {
|
||||||
|
if (!pendingCreates.length) {
|
||||||
|
return persistedEntities;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const pendingAsEntities: Entity[] = [];
|
||||||
|
for (const pending of pendingCreates) {
|
||||||
|
if (seen.has(pending.id)) continue;
|
||||||
|
seen.add(pending.id);
|
||||||
|
pendingAsEntities.push({
|
||||||
|
id: pending.id,
|
||||||
|
name: pending.name,
|
||||||
|
slug: pending.slug,
|
||||||
|
type_id: pending.type_id,
|
||||||
|
status: pending.status,
|
||||||
|
geometry_count: 0,
|
||||||
|
created_at: undefined,
|
||||||
|
updated_at: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPersisted = persistedEntities.filter((entity) => !seen.has(entity.id));
|
||||||
|
return [...pendingAsEntities, ...nextPersisted];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeEntitySearchResults(
|
||||||
|
remoteRows: Entity[],
|
||||||
|
localRows: Entity[]
|
||||||
|
): Entity[] {
|
||||||
|
const merged: Entity[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const row of localRows) {
|
||||||
|
if (!row.id || seen.has(row.id)) continue;
|
||||||
|
seen.add(row.id);
|
||||||
|
merged.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of remoteRows) {
|
||||||
|
if (!row.id || seen.has(row.id)) continue;
|
||||||
|
seen.add(row.id);
|
||||||
|
merged.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClientEntityId(): string {
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `entity-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
|
function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ type Props = {
|
|||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
changesCount: number;
|
changesCount: number;
|
||||||
undoStack: UndoAction[];
|
undoStack: UndoAction[];
|
||||||
|
createdEntities: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type_id?: string | null;
|
||||||
|
}>;
|
||||||
|
createdGeometries: Array<{
|
||||||
|
id: string | number;
|
||||||
|
geometryType: string;
|
||||||
|
semanticType?: string | null;
|
||||||
|
entityNames: string[];
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Editor({
|
export default function Editor({
|
||||||
@@ -24,6 +35,8 @@ export default function Editor({
|
|||||||
isSaving,
|
isSaving,
|
||||||
changesCount,
|
changesCount,
|
||||||
undoStack,
|
undoStack,
|
||||||
|
createdEntities,
|
||||||
|
createdGeometries,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const toggleMode = (newMode: Mode) => {
|
const toggleMode = (newMode: Mode) => {
|
||||||
if (mode === newMode) {
|
if (mode === newMode) {
|
||||||
@@ -61,6 +74,8 @@ export default function Editor({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: "220px",
|
width: "220px",
|
||||||
|
height: "100vh",
|
||||||
|
overflowY: "auto",
|
||||||
background: "#111",
|
background: "#111",
|
||||||
color: "white",
|
color: "white",
|
||||||
padding: "12px",
|
padding: "12px",
|
||||||
@@ -210,6 +225,70 @@ export default function Editor({
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "16px",
|
||||||
|
padding: "10px",
|
||||||
|
background: "#0b1220",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: "8px", fontWeight: 600, fontSize: "14px" }}>
|
||||||
|
Mới tạo trong phiên
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: "13px", color: "#cbd5e1", marginBottom: "6px" }}>
|
||||||
|
Entities ({createdEntities.length})
|
||||||
|
</div>
|
||||||
|
{createdEntities.length === 0 ? (
|
||||||
|
<div style={{ color: "#64748b", fontSize: "12px", marginBottom: "10px" }}>
|
||||||
|
Chưa tạo entity mới
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px", marginBottom: "10px" }}>
|
||||||
|
{createdEntities.map((entity) => (
|
||||||
|
<li
|
||||||
|
key={entity.id}
|
||||||
|
style={{
|
||||||
|
padding: "4px 0",
|
||||||
|
borderBottom: "1px solid #1f2937",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
}}
|
||||||
|
title={entity.id}
|
||||||
|
>
|
||||||
|
{entity.name} ({entity.type_id || "country"})
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ fontSize: "13px", color: "#cbd5e1", marginBottom: "6px" }}>
|
||||||
|
Geometries mới chưa lưu ({createdGeometries.length})
|
||||||
|
</div>
|
||||||
|
{createdGeometries.length === 0 ? (
|
||||||
|
<div style={{ color: "#64748b", fontSize: "12px" }}>
|
||||||
|
Chưa có geometry mới chờ save
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px" }}>
|
||||||
|
{createdGeometries.map((geometry) => (
|
||||||
|
<li
|
||||||
|
key={String(geometry.id)}
|
||||||
|
style={{
|
||||||
|
padding: "4px 0",
|
||||||
|
borderBottom: "1px solid #1f2937",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{geometry.id} [{geometry.geometryType}] {geometry.semanticType ? `- ${geometry.semanticType}` : ""}
|
||||||
|
{geometry.entityNames.length ? ` | ${geometry.entityNames.join(", ")}` : ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,17 @@ type Props = {
|
|||||||
entityTypeOptions: EntityTypeOption[];
|
entityTypeOptions: EntityTypeOption[];
|
||||||
geometryMetaForm: GeometryMetaFormState;
|
geometryMetaForm: GeometryMetaFormState;
|
||||||
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
|
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
|
||||||
|
bindingGeometrySearchQuery: string;
|
||||||
|
onBindingGeometrySearchQueryChange: (value: string) => void;
|
||||||
|
bindingGeometrySearchResults: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}>;
|
||||||
|
selectedBindingGeometryId: string | null;
|
||||||
|
onSelectBindingGeometryId: (value: string | null) => void;
|
||||||
|
onAddSelectedBindingGeometry: () => void;
|
||||||
isEntitySubmitting: boolean;
|
isEntitySubmitting: boolean;
|
||||||
onCreateEntityAndAttach: () => void;
|
onCreateEntityOnly: () => void;
|
||||||
onApplyEntitiesForSelectedGeometry: () => void;
|
onApplyEntitiesForSelectedGeometry: () => void;
|
||||||
changeCount: number;
|
changeCount: number;
|
||||||
entityFormStatus: string | null;
|
entityFormStatus: string | null;
|
||||||
@@ -68,8 +77,14 @@ export default function SelectedGeometryPanel({
|
|||||||
entityTypeOptions,
|
entityTypeOptions,
|
||||||
geometryMetaForm,
|
geometryMetaForm,
|
||||||
onGeometryMetaFormChange,
|
onGeometryMetaFormChange,
|
||||||
|
bindingGeometrySearchQuery,
|
||||||
|
onBindingGeometrySearchQueryChange,
|
||||||
|
bindingGeometrySearchResults,
|
||||||
|
selectedBindingGeometryId,
|
||||||
|
onSelectBindingGeometryId,
|
||||||
|
onAddSelectedBindingGeometry,
|
||||||
isEntitySubmitting,
|
isEntitySubmitting,
|
||||||
onCreateEntityAndAttach,
|
onCreateEntityOnly,
|
||||||
onApplyEntitiesForSelectedGeometry,
|
onApplyEntitiesForSelectedGeometry,
|
||||||
changeCount,
|
changeCount,
|
||||||
entityFormStatus,
|
entityFormStatus,
|
||||||
@@ -84,13 +99,13 @@ export default function SelectedGeometryPanel({
|
|||||||
const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) =>
|
const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) =>
|
||||||
allowedGroupIds.includes(group.id)
|
allowedGroupIds.includes(group.id)
|
||||||
);
|
);
|
||||||
|
const groupedEntityTypeOptionsForCreate = selectedFeature
|
||||||
|
? visibleGroupedEntityTypeOptions
|
||||||
|
: groupedEntityTypeOptions;
|
||||||
const selectedTypeOption = findEntityTypeOption(entityForm.type_id);
|
const selectedTypeOption = findEntityTypeOption(entityForm.type_id);
|
||||||
const hasCurrentVisibleTypeOption = visibleGroupedEntityTypeOptions.some((group) =>
|
const hasCurrentVisibleTypeOption = groupedEntityTypeOptionsForCreate.some((group) =>
|
||||||
group.options.some((option) => option.value === entityForm.type_id)
|
group.options.some((option) => option.value === entityForm.type_id)
|
||||||
);
|
);
|
||||||
const isGeometryCompatible = selectedTypeOption
|
|
||||||
? allowedGroupIds.includes(selectedTypeOption.groupId)
|
|
||||||
: true;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -102,12 +117,12 @@ export default function SelectedGeometryPanel({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontWeight: 700, marginBottom: "8px", fontSize: "14px" }}>
|
<div style={{ fontWeight: 700, marginBottom: "8px", fontSize: "14px" }}>
|
||||||
Selected Geometry
|
Entity & Geometry
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!selectedFeature ? (
|
{!selectedFeature ? (
|
||||||
<div style={{ color: "#94a3b8", fontSize: "13px" }}>
|
<div style={{ color: "#94a3b8", fontSize: "13px" }}>
|
||||||
Vào mode Select và chọn 1 geometry để điền entity.
|
Chưa chọn geometry. Tạo entity mới ở khối bên dưới, hoặc vào mode Select để bind entity cho geometry.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
|
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
|
||||||
@@ -187,11 +202,10 @@ export default function SelectedGeometryPanel({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
|
<div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
|
||||||
Metadata geometry (áp dụng cho cả 2 luồng bên dưới)
|
Metadata geometry (chỉ áp dụng khi bind entity)
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
|
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
|
||||||
`time_start`, `time_end`, `binding` sẽ được dùng cho cả luồng gắn entity có sẵn
|
`time_start`, `time_end`, `binding` chỉ được áp dụng khi bấm nút bind entity cho geometry.
|
||||||
và luồng tạo entity mới.
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
value={geometryMetaForm.time_start}
|
value={geometryMetaForm.time_start}
|
||||||
@@ -214,6 +228,38 @@ export default function SelectedGeometryPanel({
|
|||||||
disabled={isEntitySubmitting}
|
disabled={isEntitySubmitting}
|
||||||
style={entityInputStyle}
|
style={entityInputStyle}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
value={bindingGeometrySearchQuery}
|
||||||
|
onChange={(event) =>
|
||||||
|
onBindingGeometrySearchQueryChange(event.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Search geometry để thêm vào binding..."
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={selectedBindingGeometryId || ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
onSelectBindingGeometryId(event.target.value ? event.target.value : null)
|
||||||
|
}
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
>
|
||||||
|
<option value="">-- Chọn geometry từ kết quả search binding --</option>
|
||||||
|
{bindingGeometrySearchResults.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddSelectedBindingGeometry}
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={secondaryActionButtonStyle}
|
||||||
|
>
|
||||||
|
Thêm geometry đã chọn vào binding
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -227,7 +273,7 @@ export default function SelectedGeometryPanel({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
|
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
|
||||||
Luồng 1: Gắn entity có sẵn
|
Bind entity có sẵn
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
|
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
|
||||||
Dùng khi entity đã tồn tại. Tìm kiếm, thêm vào danh sách rồi bấm nút áp dụng.
|
Dùng khi entity đã tồn tại. Tìm kiếm, thêm vào danh sách rồi bấm nút áp dụng.
|
||||||
@@ -285,6 +331,14 @@ export default function SelectedGeometryPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{changeCount > 0 ? (
|
||||||
|
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
|
||||||
|
Geometry mới sẽ lưu entity khi bấm Save.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
@@ -293,14 +347,20 @@ export default function SelectedGeometryPanel({
|
|||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
padding: "8px",
|
padding: "8px",
|
||||||
background: "#0f172a",
|
background: "#0f172a",
|
||||||
|
marginTop: "10px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
|
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
|
||||||
Luồng 2: Tạo entity mới rồi gắn
|
Tạo entity mới (độc lập)
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
|
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
|
||||||
Dùng khi chưa có entity phù hợp. Điền form bên dưới rồi bấm nút tạo + gắn.
|
Chỉ tạo entity, không tự bind vào geometry.
|
||||||
</div>
|
</div>
|
||||||
|
{selectedFeature ? (
|
||||||
|
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
|
||||||
|
Type đang bị giới hạn theo geometry: <b>{formatGeometryPresetLabel(featureGeometryPreset)}</b>.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
value={entityForm.name}
|
value={entityForm.name}
|
||||||
@@ -325,12 +385,12 @@ export default function SelectedGeometryPanel({
|
|||||||
disabled={isEntitySubmitting}
|
disabled={isEntitySubmitting}
|
||||||
style={entityInputStyle}
|
style={entityInputStyle}
|
||||||
>
|
>
|
||||||
{!hasCurrentVisibleTypeOption && entityForm.type_id ? (
|
{!selectedFeature && !hasCurrentVisibleTypeOption && entityForm.type_id ? (
|
||||||
<option value={entityForm.type_id}>
|
<option value={entityForm.type_id}>
|
||||||
Custom Type ({entityForm.type_id})
|
Custom Type ({entityForm.type_id})
|
||||||
</option>
|
</option>
|
||||||
) : null}
|
) : null}
|
||||||
{visibleGroupedEntityTypeOptions.map((group) => (
|
{groupedEntityTypeOptionsForCreate.map((group) => (
|
||||||
<optgroup
|
<optgroup
|
||||||
key={group.id}
|
key={group.id}
|
||||||
label={`${group.label} (${group.geometryLabel})`}
|
label={`${group.label} (${group.geometryLabel})`}
|
||||||
@@ -354,14 +414,8 @@ export default function SelectedGeometryPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isGeometryCompatible && selectedTypeOption ? (
|
|
||||||
<div style={{ color: "#fbbf24", fontSize: "12px" }}>
|
|
||||||
Type <b>{selectedTypeOption.label}</b> thuộc nhóm <b>{selectedTypeOption.groupLabel}</b>, nhưng geometry hiện tại là <b>{formatGeometryPresetLabel(featureGeometryPreset)}</b>.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onCreateEntityAndAttach}
|
onClick={onCreateEntityOnly}
|
||||||
disabled={isEntitySubmitting}
|
disabled={isEntitySubmitting}
|
||||||
style={{
|
style={{
|
||||||
border: "none",
|
border: "none",
|
||||||
@@ -374,24 +428,16 @@ export default function SelectedGeometryPanel({
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Tạo entity mới + gắn + áp metadata
|
Tạo entity mới
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{changeCount > 0 ? (
|
|
||||||
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
|
|
||||||
Geometry mới sẽ lưu entity khi bấm Save.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{entityFormStatus ? (
|
{entityFormStatus ? (
|
||||||
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
|
<div style={{ color: "#93c5fd", fontSize: "12px", marginTop: "8px" }}>
|
||||||
{entityFormStatus}
|
{entityFormStatus}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user