diff --git a/api/geometries.ts b/api/geometries.ts index a1200f2..1ff9be4 100644 --- a/api/geometries.ts +++ b/api/geometries.ts @@ -13,6 +13,7 @@ export type GeometriesBBoxQuery = { export type GeometryCreatePayload = { geometry: Geometry; + type?: string | null; time_start?: number | null; time_end?: number | null; binding?: string[]; @@ -22,6 +23,7 @@ export type GeometryCreatePayload = { export type GeometryUpdatePayload = { geometry: Geometry; + type?: string | null; time_start?: number | null; time_end?: number | null; binding?: string[]; diff --git a/app/editor/page.tsx b/app/editor/page.tsx new file mode 100644 index 0000000..429e361 --- /dev/null +++ b/app/editor/page.tsx @@ -0,0 +1,985 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Map from "@/components/Map"; +import Editor from "@/components/Editor"; +import BackgroundLayersPanel from "@/components/BackgroundLayersPanel"; +import TimelineBar from "@/components/TimelineBar"; +import SelectedGeometryPanel from "@/components/SelectedGeometryPanel"; +import { createEntity, Entity, fetchEntities, searchEntitiesByName } from "@/api/entities"; +import { ApiError } from "@/api/http"; +import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries"; +import { + Feature, + FeatureCollection, + useEditorState, +} from "@/lib/useEditorState"; +import { + BACKGROUND_LAYER_OPTIONS, + BackgroundLayerId, + BackgroundLayerVisibility, + DEFAULT_BACKGROUND_LAYER_VISIBILITY, + HIDDEN_BACKGROUND_LAYER_VISIBILITY, +} from "@/lib/backgroundLayers"; +import { DEFAULT_ENTITY_TYPE_ID, ENTITY_TYPE_OPTIONS } from "@/lib/entityTypeOptions"; + +const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; +const WORLD_BBOX = { + minLng: -180, + minLat: -90, + maxLng: 180, + maxLat: 90, +} as const; +const CURRENT_YEAR = new Date().getUTCFullYear(); +const FALLBACK_TIMELINE_RANGE: TimelineRange = { + min: CURRENT_YEAR - 5000, + max: CURRENT_YEAR + 100, +}; +const TIMELINE_DEBOUNCE_MS = 180; +const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1"; + +type TimelineRange = { + min: number; + max: number; +}; + +type EntityFormState = { + name: string; + slug: string; + type_id: string; +}; + +type GeometryMetaFormState = { + time_start: string; + time_end: string; + binding: string; +}; + +export default function Page() { + const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle">("idle"); + const [initialData, setInitialData] = useState(EMPTY_FC); + const [isSaving, setIsSaving] = useState(false); + const [entities, setEntities] = useState([]); + const [entityStatus, setEntityStatus] = useState(null); + const [selectedFeatureId, setSelectedFeatureId] = useState(null); + const [entityForm, setEntityForm] = useState({ + name: "", + slug: "", + type_id: DEFAULT_ENTITY_TYPE_ID, + }); + const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState([]); + const [geometryMetaForm, setGeometryMetaForm] = useState({ + time_start: "", + time_end: "", + binding: "", + }); + const [isEntitySubmitting, setIsEntitySubmitting] = useState(false); + const [entityFormStatus, setEntityFormStatus] = useState(null); + const [entitySearchQuery, setEntitySearchQuery] = useState(""); + const [entitySearchResults, setEntitySearchResults] = useState([]); + const [selectedSearchEntityId, setSelectedSearchEntityId] = useState(null); + const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false); + const [timelineRange, setTimelineRange] = useState(FALLBACK_TIMELINE_RANGE); + const [timelineWindowStart, setTimelineWindowStart] = useState(FALLBACK_TIMELINE_RANGE.min); + const [timelineWindowEnd, setTimelineWindowEnd] = useState(FALLBACK_TIMELINE_RANGE.max); + const [timelineYear, setTimelineYear] = useState(() => + clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max) + ); + const [timelineDraftYear, setTimelineDraftYear] = useState(() => + clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max) + ); + const [isTimelineReady, setIsTimelineReady] = useState(false); + const [isTimelineLoading, setIsTimelineLoading] = useState(false); + const [timelineStatus, setTimelineStatus] = useState(null); + const [backgroundVisibility, setBackgroundVisibility] = useState( + () => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }) + ); + const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); + const timelineFetchRequestRef = useRef(0); + const entitySearchRequestRef = useRef(0); + const skipSelectedFeatureSyncRef = useRef(false); + + const editor = useEditorState(initialData); + const selectedFeature = + selectedFeatureId === null + ? null + : editor.draft.features.find((feature) => + String(feature.properties.id) === String(selectedFeatureId) + ) || null; + + useEffect(() => { + let disposed = false; + + async function loadEntities() { + try { + const rows = await fetchEntities(); + if (disposed) return; + + setEntities(rows); + setEntityStatus(rows.length ? null : "Chưa có entity. Cần tạo entity trước khi Save geometry."); + } catch (err) { + if (disposed) return; + console.error("Load entities failed", err); + setEntityStatus("Không tải được danh sách entity."); + } + } + + loadEntities(); + + return () => { + disposed = true; + }; + }, []); + + useEffect(() => { + if (!selectedFeature) { + setEntitySearchResults([]); + setSelectedSearchEntityId(null); + setIsEntitySearchLoading(false); + return; + } + + const keyword = entitySearchQuery.trim(); + if (!keyword.length) { + setEntitySearchResults([]); + setSelectedSearchEntityId(null); + setIsEntitySearchLoading(false); + return; + } + + let disposed = false; + const requestId = ++entitySearchRequestRef.current; + const timeoutId = window.setTimeout(async () => { + setIsEntitySearchLoading(true); + try { + const rows = await searchEntitiesByName(keyword, { limit: 30 }); + if (disposed || requestId !== entitySearchRequestRef.current) return; + + setEntitySearchResults(rows); + setSelectedSearchEntityId((prev) => + prev && rows.some((entity) => entity.id === prev) + ? prev + : rows[0]?.id || null + ); + } catch (err) { + if (disposed || requestId !== entitySearchRequestRef.current) return; + console.error("Search entity by name failed", err); + setEntitySearchResults([]); + setSelectedSearchEntityId(null); + } finally { + if (!disposed && requestId === entitySearchRequestRef.current) { + setIsEntitySearchLoading(false); + } + } + }, 220); + + return () => { + disposed = true; + window.clearTimeout(timeoutId); + }; + }, [entitySearchQuery, selectedFeature]); + + useEffect(() => { + if (selectedFeatureId === null) return; + const stillExists = editor.draft.features.some((feature) => + String(feature.properties.id) === String(selectedFeatureId) + ); + if (!stillExists) { + setSelectedFeatureId(null); + } + }, [editor.draft, selectedFeatureId]); + + useEffect(() => { + if (skipSelectedFeatureSyncRef.current) { + skipSelectedFeatureSyncRef.current = false; + return; + } + + if (!selectedFeature) { + setEntityForm({ + name: "", + slug: "", + type_id: DEFAULT_ENTITY_TYPE_ID, + }); + setSelectedGeometryEntityIds([]); + setGeometryMetaForm({ + time_start: "", + time_end: "", + binding: "", + }); + setEntitySearchQuery(""); + setEntitySearchResults([]); + setSelectedSearchEntityId(null); + setEntityFormStatus(null); + return; + } + + 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); + setGeometryMetaForm({ + time_start: selectedFeature.properties.time_start != null + ? String(selectedFeature.properties.time_start) + : "", + time_end: selectedFeature.properties.time_end != null + ? String(selectedFeature.properties.time_end) + : "", + binding: normalizeFeatureBindingIds(selectedFeature).join(", "), + }); + setEntitySearchQuery(""); + setEntitySearchResults([]); + setSelectedSearchEntityId(null); + if (!featureEntityIds.length) { + setEntityFormStatus("Geometry mới phải được gắn ít nhất 1 entity trước khi Save."); + } else { + setEntityFormStatus(null); + } + }, [selectedFeature, entities]); + + useEffect(() => { + let disposed = false; + + async function loadTimelineBounds() { + setIsTimelineLoading(true); + try { + const data = await fetchGeometriesByBBox({ + ...WORLD_BBOX, + }); + if (disposed) return; + + const range = deriveTimelineRange(data); + const initialYear = clampYear(CURRENT_YEAR, range); + + setTimelineRange(range); + setTimelineWindowStart(range.min); + setTimelineWindowEnd(range.max); + setTimelineYear(initialYear); + setTimelineDraftYear(initialYear); + setTimelineStatus(null); + } catch (err) { + if (disposed) return; + console.error("Load timeline bounds failed", err); + const fallbackYear = clampYear(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE); + setTimelineRange(FALLBACK_TIMELINE_RANGE); + setTimelineWindowStart(FALLBACK_TIMELINE_RANGE.min); + setTimelineWindowEnd(FALLBACK_TIMELINE_RANGE.max); + setTimelineYear(fallbackYear); + setTimelineDraftYear(fallbackYear); + setTimelineStatus("Không thể lấy phạm vi thời gian, đang dùng mốc mặc định."); + } finally { + if (!disposed) { + setIsTimelineLoading(false); + setIsTimelineReady(true); + } + } + } + + loadTimelineBounds(); + + return () => { + disposed = true; + }; + }, []); + + useEffect(() => { + if (!isTimelineReady) return; + + const timeoutId = window.setTimeout(() => { + if (timelineDraftYear !== timelineYear) { + setTimelineYear(timelineDraftYear); + } + }, TIMELINE_DEBOUNCE_MS); + + return () => window.clearTimeout(timeoutId); + }, [timelineDraftYear, timelineYear, isTimelineReady]); + + useEffect(() => { + setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); + setIsBackgroundVisibilityReady(true); + }, []); + + useEffect(() => { + const lower = Math.min(timelineWindowStart, timelineWindowEnd); + const upper = Math.max(timelineWindowStart, timelineWindowEnd); + setTimelineDraftYear((prev) => clampYearValue(prev, lower, upper)); + }, [timelineWindowStart, timelineWindowEnd]); + + useEffect(() => { + if (!isTimelineReady) return; + + let disposed = false; + const requestId = ++timelineFetchRequestRef.current; + + async function loadByTimeline() { + setIsTimelineLoading(true); + setTimelineStatus(null); + + try { + const data = await fetchGeometriesByBBox({ + ...WORLD_BBOX, + time: timelineYear, + }); + + if (disposed || requestId !== timelineFetchRequestRef.current) return; + setInitialData(data); + } catch (err) { + if (err instanceof ApiError) { + console.error("Load timeline data failed", err.body); + } else { + console.error("Load timeline data failed", err); + } + + if (!disposed && requestId === timelineFetchRequestRef.current) { + setTimelineStatus("Không tải được geometry tại mốc thời gian đã chọn."); + } + } finally { + if (!disposed && requestId === timelineFetchRequestRef.current) { + setIsTimelineLoading(false); + } + } + } + + loadByTimeline(); + + return () => { + disposed = true; + }; + }, [timelineYear, isTimelineReady]); + + const handleSave = async () => { + const payload = editor.buildPayload(); + if (!payload.length) return; + + const invalid = payload.find((change) => { + if (change.action === "delete") return false; + const draftFeature = change.action === "create" + ? change.feature + : editor.draft.features.find((feature) => + String(feature.properties.id) === String(change.id) + ); + if (!draftFeature) return false; + const entityIds = normalizeFeatureEntityIds(draftFeature); + return entityIds.length === 0; + }); + + if (invalid) { + const invalidId = invalid.action === "create" + ? invalid.feature.properties.id + : invalid.id; + setSelectedFeatureId(invalidId); + setEntityStatus("Không thể Save: mỗi geometry phải có ít nhất 1 entity."); + return; + } + + setIsSaving(true); + setEntityStatus(null); + try { + await saveGeometryBatchChanges(payload); + editor.clearChanges(); + await reloadCurrentTimelineData(); + } catch (err) { + if (err instanceof ApiError) { + console.error("Save failed", err.body); + setEntityStatus(`Save thất bại: ${err.body}`); + return; + } + console.error("Save error", err); + setEntityStatus("Save thất bại."); + } finally { + setIsSaving(false); + } + }; + + const updateBackgroundVisibility = ( + updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility + ) => { + setBackgroundVisibility((prev) => { + const next = updater(prev); + persistBackgroundLayerVisibility(next); + return next; + }); + }; + + const handleToggleBackgroundLayer = (id: BackgroundLayerId) => { + updateBackgroundVisibility((prev) => ({ + ...prev, + [id]: !prev[id], + })); + }; + + const handleShowAllBackgroundLayers = () => { + updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY })); + }; + + const handleHideAllBackgroundLayers = () => { + updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })); + }; + + const handleTimelineWindowStartChange = (nextYear: number) => { + const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max); + setTimelineWindowStart(Math.min(next, timelineWindowEnd)); + }; + + const handleTimelineWindowEndChange = (nextYear: number) => { + const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max); + setTimelineWindowEnd(Math.max(next, timelineWindowStart)); + }; + + const handleTimelineYearChange = (nextYear: number) => { + const lower = Math.min(timelineWindowStart, timelineWindowEnd); + const upper = Math.max(timelineWindowStart, timelineWindowEnd); + setTimelineDraftYear(clampYearValue(Math.trunc(nextYear), lower, upper)); + }; + + const handleEntityFormChange = (key: keyof EntityFormState, value: string) => { + setEntityForm((prev) => ({ ...prev, [key]: value })); + }; + + const handleGeometryMetaFormChange = (key: "time_start" | "time_end" | "binding", value: string) => { + setGeometryMetaForm((prev) => ({ ...prev, [key]: value })); + }; + + const handleEntityIdsChange = (values: string[]) => { + setSelectedGeometryEntityIds(uniqueEntityIds(values)); + }; + + const handleAddSelectedSearchEntity = () => { + const entityId = selectedSearchEntityId ? selectedSearchEntityId.trim() : ""; + if (!entityId.length) { + setEntityFormStatus("Hãy chọn một entity từ kết quả search trước."); + return; + } + + const next = uniqueEntityIds([...selectedGeometryEntityIds, entityId]); + setSelectedGeometryEntityIds(next); + setSelectedSearchEntityId(null); + setEntityFormStatus(null); + }; + + const reloadEntities = async () => { + const rows = await fetchEntities(); + setEntities(rows); + return rows; + }; + + const reloadCurrentTimelineData = async () => { + const data = await fetchGeometriesByBBox({ + ...WORLD_BBOX, + time: timelineYear, + }); + 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 timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start"); + const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end"); + if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) { + throw new Error("time_start phải <= time_end."); + } + return { timeStart, timeEnd }; + }; + + const resolveGeometryTypeFromEntityIds = ( + entityIds: string[], + entityRows: Entity[] = entities + ): string | null => { + const primaryEntityId = entityIds[0] || null; + if (!primaryEntityId) return null; + const primaryEntity = entityRows.find((entity) => entity.id === primaryEntityId) || null; + return primaryEntity?.type_id || null; + }; + + const patchSelectedFeatureLocally = ( + feature: Feature, + entityIds: string[], + timeStart: number | null, + timeEnd: number | null, + bindingIds: string[], + entityRows: Entity[] = entities + ) => { + const primaryEntityId = entityIds[0] || null; + const primaryEntity = primaryEntityId + ? entityRows.find((entity) => entity.id === primaryEntityId) || null + : null; + const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds, entityRows) || feature.properties.type || null; + const entityNames = entityIds + .map((id) => entityRows.find((entity) => entity.id === id)?.name || "") + .filter((name) => name.length > 0); + + editor.patchFeatureProperties(feature.properties.id, { + type: nextGeometryType, + entity_id: primaryEntityId, + entity_ids: entityIds, + entity_name: primaryEntity?.name || null, + entity_names: entityNames, + entity_type_id: primaryEntity?.type_id || null, + time_start: timeStart, + time_end: timeEnd, + binding: bindingIds, + }); + + setSelectedGeometryEntityIds(entityIds); + setGeometryMetaForm({ + time_start: timeStart != null ? String(timeStart) : "", + time_end: timeEnd != null ? String(timeEnd) : "", + binding: bindingIds.join(", "), + }); + }; + + const handleApplyEntitiesForSelectedGeometry = async () => { + if (!selectedFeature) { + setEntityFormStatus("Hãy chọn một geometry trước."); + return; + } + + const entityIds = uniqueEntityIds(selectedGeometryEntityIds); + if (!entityIds.length) { + setEntityFormStatus("Geometry phải có ít nhất 1 entity."); + return; + } + + let timeStart: number | null; + let timeEnd: number | null; + let bindingIds: string[]; + try { + ({ timeStart, timeEnd } = parseGeometryMetaFormRange()); + bindingIds = parseBindingInput(geometryMetaForm.binding); + } catch (err) { + setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ."); + return; + } + + setIsEntitySubmitting(true); + setEntityFormStatus(null); + try { + const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds) || selectedFeature.properties.type || null; + if (editor.hasPersistedFeature(selectedFeature.properties.id)) { + 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."); + return; + } + + await updateGeometry(selectedFeature.properties.id, { + geometry: selectedFeature.geometry, + type: nextGeometryType ?? undefined, + time_start: timeStart, + time_end: timeEnd, + binding: bindingIds, + entity_ids: entityIds, + }); + + await reloadCurrentTimelineData(); + setEntityFormStatus("Đã cập nhật entities + metadata geometry."); + } else { + patchSelectedFeatureLocally(selectedFeature, entityIds, timeStart, timeEnd, bindingIds); + setEntityFormStatus("Đã cập nhật local. Bấm Save để lưu geometry mới."); + } + } catch (err) { + if (err instanceof ApiError) { + setEntityFormStatus(`Lưu thất bại: ${err.body}`); + } else { + setEntityFormStatus("Lưu thất bại."); + } + } finally { + setIsEntitySubmitting(false); + } + }; + + const handleCreateEntityAndAttach = 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(); + if (!name) { + setEntityFormStatus("Tên entity là bắt buộc."); + return; + } + + let timeStart: number | null; + let timeEnd: number | null; + let bindingIds: string[]; + try { + ({ timeStart, timeEnd } = parseGeometryMetaFormRange()); + bindingIds = parseBindingInput(geometryMetaForm.binding); + } catch (err) { + setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ."); + return; + } + + setIsEntitySubmitting(true); + setEntityFormStatus(null); + try { + const created = await createEntity({ + name, + slug: entityForm.slug.trim() || null, + type_id: entityForm.type_id || DEFAULT_ENTITY_TYPE_ID, + }); + + const rows = await reloadEntities(); + const nextEntityIds = uniqueEntityIds([ + ...selectedGeometryEntityIds, + created.id, + ]); + const nextGeometryType = resolveGeometryTypeFromEntityIds(nextEntityIds, rows) || selectedFeature.properties.type || null; + + if (editor.hasPersistedFeature(selectedFeature.properties.id)) { + await updateGeometry(selectedFeature.properties.id, { + geometry: selectedFeature.geometry, + 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 { + setIsEntitySubmitting(false); + } + }; + + const timelineDisabled = !isTimelineReady || isSaving || editor.changeCount > 0; + const timelineStatusText = + editor.changeCount > 0 + ? "Lưu hoặc undo hết thay đổi trước khi đổi mốc thời gian." + : isSaving + ? "Đang lưu thay đổi..." + : timelineStatus; + + const handleCreateFeature = (feature: Feature) => { + editor.createFeature(feature); + setSelectedFeatureId(feature.properties.id); + }; + + return ( +
+ + +
+ {isBackgroundVisibilityReady ? ( + + ) : ( +
+ )} + +
+ + + } + /> +
+ ); +} + +function deriveTimelineRange(collection: FeatureCollection): TimelineRange { + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + + for (const feature of collection.features) { + const { time_start, time_end } = feature.properties; + + if (isYearNumber(time_start)) { + min = Math.min(min, time_start); + max = Math.max(max, time_start); + } + + if (isYearNumber(time_end)) { + min = Math.min(min, time_end); + max = Math.max(max, time_end); + } + } + + if (!Number.isFinite(min) || !Number.isFinite(max)) { + return FALLBACK_TIMELINE_RANGE; + } + + return { + min: Math.floor(min), + max: Math.ceil(max), + }; +} + +function clampYear(year: number, range: TimelineRange): number { + return clampYearValue(year, range.min, range.max); +} + +function clampYearValue(year: number, minYear: number, maxYear: number): number { + const lower = Math.min(minYear, maxYear); + const upper = Math.max(minYear, maxYear); + if (year < lower) return lower; + if (year > upper) return upper; + return year; +} + +function isYearNumber(value: number | null | undefined): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function getDefaultTypeIdForFeature(feature: Feature): string { + const preset = feature.properties.geometry_preset; + if (preset === "line") return "defense_line"; + if (preset === "point") return "city"; + if (preset === "circle-area") return "war"; + if (preset === "polygon") return DEFAULT_ENTITY_TYPE_ID; + + const geometryType = feature.geometry.type; + if (geometryType === "LineString" || geometryType === "MultiLineString") { + return "defense_line"; + } + if (geometryType === "Point" || geometryType === "MultiPoint") { + return "city"; + } + return DEFAULT_ENTITY_TYPE_ID; +} + +function parseOptionalYearInput(raw: string, fieldName: string): number | null { + const value = raw.trim(); + if (!value.length) return null; + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new Error(`${fieldName} phải là số.`); + } + return Math.trunc(parsed); +} + +function normalizeFeatureEntityIds(feature: Feature): string[] { + const fromArray = Array.isArray(feature.properties.entity_ids) + ? feature.properties.entity_ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0) + : []; + + if (fromArray.length) { + return uniqueEntityIds(fromArray); + } + + const single = feature.properties.entity_id; + if (typeof single === "string" && single.trim().length > 0) { + return [single.trim()]; + } + + return []; +} + +function normalizeFeatureBindingIds(feature: Feature): string[] { + const rawBinding = feature.properties.binding; + if (!Array.isArray(rawBinding)) return []; + return uniqueEntityIds(rawBinding + .map((id) => { + if (typeof id !== "string" && typeof id !== "number") return ""; + return String(id).trim(); + }) + .filter((id) => id.length > 0)); +} + +function parseBindingInput(raw: string): string[] { + if (!raw.trim().length) return []; + return uniqueEntityIds( + raw + .split(/[,\n]/) + .map((item) => item.trim()) + .filter((item) => item.length > 0) + ); +} + +function formatBindingIdsForDisplay(feature: Feature): string { + const bindingIds = normalizeFeatureBindingIds(feature); + if (!bindingIds.length) return "Không có"; + return bindingIds.join(", "); +} + +function uniqueEntityIds(ids: string[]): string[] { + const deduped: string[] = []; + const seen = new Set(); + for (const rawId of ids) { + const id = rawId.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + deduped.push(id); + } + return deduped; +} + +function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): string { + const entityIds = normalizeFeatureEntityIds(feature); + if (!entityIds.length) return "Chưa gắn"; + + const names = entityIds + .map((id) => entities.find((entity) => entity.id === id)?.name || id) + .filter((name) => name.trim().length > 0); + return names.join(", "); +} + +function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility { + if (typeof window === "undefined") { + return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; + } + + try { + const raw = window.localStorage.getItem(BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY); + if (!raw) { + return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; + } + + const parsed = JSON.parse(raw) as unknown; + const normalized = normalizeBackgroundLayerVisibility(parsed); + return normalized || { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; + } catch (err) { + console.warn("Load background layer visibility from storage failed", err); + return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; + } +} + +function persistBackgroundLayerVisibility(visibility: BackgroundLayerVisibility) { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY, + JSON.stringify(visibility) + ); + } catch (err) { + console.warn("Persist background layer visibility failed", err); + } +} + +function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null { + if (!raw || typeof raw !== "object") return null; + + const source = raw as Record; + const next: BackgroundLayerVisibility = { + ...DEFAULT_BACKGROUND_LAYER_VISIBILITY, + }; + + for (const layer of BACKGROUND_LAYER_OPTIONS) { + const value = source[layer.id]; + if (typeof value === "boolean") { + next[layer.id] = value; + } + } + + return next; +} diff --git a/app/page.tsx b/app/page.tsx index 04510b1..d25c226 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,27 +1,20 @@ "use client"; +import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import Map from "@/components/Map"; -import Editor from "@/components/Editor"; import BackgroundLayersPanel from "@/components/BackgroundLayersPanel"; import TimelineBar from "@/components/TimelineBar"; -import SelectedGeometryPanel from "@/components/SelectedGeometryPanel"; -import { createEntity, Entity, fetchEntities, searchEntitiesByName } from "@/api/entities"; +import { fetchGeometriesByBBox } from "@/api/geometries"; import { ApiError } from "@/api/http"; -import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries"; -import { - Feature, - FeatureCollection, - useEditorState, -} from "@/lib/useEditorState"; +import { findEntityTypeOption } from "@/lib/entityTypeOptions"; import { BACKGROUND_LAYER_OPTIONS, - BackgroundLayerId, BackgroundLayerVisibility, DEFAULT_BACKGROUND_LAYER_VISIBILITY, HIDDEN_BACKGROUND_LAYER_VISIBILITY, } from "@/lib/backgroundLayers"; -import { DEFAULT_ENTITY_TYPE_ID, ENTITY_TYPE_OPTIONS } from "@/lib/entityTypeOptions"; +import { Feature, FeatureCollection } from "@/lib/useEditorState"; const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; const WORLD_BBOX = { @@ -43,43 +36,9 @@ type TimelineRange = { max: number; }; -type EntityFormState = { - name: string; - slug: string; - type_id: string; -}; - -type GeometryMetaFormState = { - time_start: string; - time_end: string; - binding: string; -}; - export default function Page() { - const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle">("idle"); const [initialData, setInitialData] = useState(EMPTY_FC); - const [isSaving, setIsSaving] = useState(false); - const [entities, setEntities] = useState([]); - const [selectedEntityId, setSelectedEntityId] = useState(null); - const [entityStatus, setEntityStatus] = useState(null); const [selectedFeatureId, setSelectedFeatureId] = useState(null); - const [entityForm, setEntityForm] = useState({ - name: "", - slug: "", - type_id: DEFAULT_ENTITY_TYPE_ID, - }); - const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState([]); - const [geometryMetaForm, setGeometryMetaForm] = useState({ - time_start: "", - time_end: "", - binding: "", - }); - const [isEntitySubmitting, setIsEntitySubmitting] = useState(false); - const [entityFormStatus, setEntityFormStatus] = useState(null); - const [entitySearchQuery, setEntitySearchQuery] = useState(""); - const [entitySearchResults, setEntitySearchResults] = useState([]); - const [selectedSearchEntityId, setSelectedSearchEntityId] = useState(null); - const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false); const [timelineRange, setTimelineRange] = useState(FALLBACK_TIMELINE_RANGE); const [timelineWindowStart, setTimelineWindowStart] = useState(FALLBACK_TIMELINE_RANGE.min); const [timelineWindowEnd, setTimelineWindowEnd] = useState(FALLBACK_TIMELINE_RANGE.max); @@ -93,170 +52,27 @@ export default function Page() { const [isTimelineLoading, setIsTimelineLoading] = useState(false); const [timelineStatus, setTimelineStatus] = useState(null); const [backgroundVisibility, setBackgroundVisibility] = useState( - () => loadBackgroundLayerVisibilityFromStorage() + () => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }) ); + const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); const timelineFetchRequestRef = useRef(0); - const entitySearchRequestRef = useRef(0); - const skipSelectedFeatureSyncRef = useRef(false); - const editor = useEditorState(initialData); - const selectedEntity = - entities.find((entity) => entity.id === selectedEntityId) || null; const selectedFeature = selectedFeatureId === null ? null - : editor.draft.features.find((feature) => + : initialData.features.find((feature) => String(feature.properties.id) === String(selectedFeatureId) ) || null; - useEffect(() => { - let disposed = false; - - async function loadEntities() { - try { - const rows = await fetchEntities(); - if (disposed) return; - - setEntities(rows); - setSelectedEntityId((prev) => { - if (prev && rows.some((entity) => entity.id === prev)) { - return prev; - } - return rows[0]?.id || null; - }); - setEntityStatus(rows.length ? null : "Chưa có entity. Cần tạo entity trước khi Save geometry."); - } catch (err) { - if (disposed) return; - console.error("Load entities failed", err); - setEntityStatus("Không tải được danh sách entity."); - } - } - - loadEntities(); - - return () => { - disposed = true; - }; - }, []); - - useEffect(() => { - if (!selectedFeature) { - setEntitySearchResults([]); - setSelectedSearchEntityId(null); - setIsEntitySearchLoading(false); - return; - } - - const keyword = entitySearchQuery.trim(); - if (!keyword.length) { - setEntitySearchResults([]); - setSelectedSearchEntityId(null); - setIsEntitySearchLoading(false); - return; - } - - let disposed = false; - const requestId = ++entitySearchRequestRef.current; - const timeoutId = window.setTimeout(async () => { - setIsEntitySearchLoading(true); - try { - const rows = await searchEntitiesByName(keyword, { limit: 30 }); - if (disposed || requestId !== entitySearchRequestRef.current) return; - - setEntitySearchResults(rows); - setSelectedSearchEntityId((prev) => - prev && rows.some((entity) => entity.id === prev) - ? prev - : rows[0]?.id || null - ); - setEntities((prev) => mergeEntitiesById(prev, rows)); - } catch (err) { - if (disposed || requestId !== entitySearchRequestRef.current) return; - console.error("Search entity by name failed", err); - setEntitySearchResults([]); - setSelectedSearchEntityId(null); - } finally { - if (!disposed && requestId === entitySearchRequestRef.current) { - setIsEntitySearchLoading(false); - } - } - }, 220); - - return () => { - disposed = true; - window.clearTimeout(timeoutId); - }; - }, [entitySearchQuery, selectedFeature]); - useEffect(() => { if (selectedFeatureId === null) return; - const stillExists = editor.draft.features.some((feature) => + const stillExists = initialData.features.some((feature) => String(feature.properties.id) === String(selectedFeatureId) ); if (!stillExists) { setSelectedFeatureId(null); } - }, [editor.draft, selectedFeatureId]); - - useEffect(() => { - if (skipSelectedFeatureSyncRef.current) { - skipSelectedFeatureSyncRef.current = false; - return; - } - - if (!selectedFeature) { - setEntityForm({ - name: "", - slug: "", - type_id: DEFAULT_ENTITY_TYPE_ID, - }); - setSelectedGeometryEntityIds([]); - setGeometryMetaForm({ - time_start: "", - time_end: "", - binding: "", - }); - setEntitySearchQuery(""); - setEntitySearchResults([]); - setSelectedSearchEntityId(null); - setEntityFormStatus(null); - return; - } - - 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.entity_type_id || - DEFAULT_ENTITY_TYPE_ID; - - setEntityForm({ - name: "", - slug: linkedEntity?.slug || "", - type_id: nextTypeId, - }); - setSelectedGeometryEntityIds(featureEntityIds); - setGeometryMetaForm({ - time_start: selectedFeature.properties.time_start != null - ? String(selectedFeature.properties.time_start) - : "", - time_end: selectedFeature.properties.time_end != null - ? String(selectedFeature.properties.time_end) - : "", - binding: normalizeFeatureBindingIds(selectedFeature).join(", "), - }); - setEntitySearchQuery(""); - setEntitySearchResults([]); - setSelectedSearchEntityId(null); - if (!featureEntityIds.length) { - setEntityFormStatus("Geometry mới phải được gắn ít nhất 1 entity trước khi Save."); - } else { - setEntityFormStatus(null); - } - }, [selectedFeature, entities]); + }, [initialData, selectedFeatureId]); useEffect(() => { let disposed = false; @@ -316,8 +132,9 @@ export default function Page() { }, [timelineDraftYear, timelineYear, isTimelineReady]); useEffect(() => { - persistBackgroundLayerVisibility(backgroundVisibility); - }, [backgroundVisibility]); + setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); + setIsBackgroundVisibilityReady(true); + }, []); useEffect(() => { const lower = Math.min(timelineWindowStart, timelineWindowEnd); @@ -367,63 +184,29 @@ export default function Page() { }; }, [timelineYear, isTimelineReady]); - const handleSave = async () => { - const payload = editor.buildPayload(); - if (!payload.length) return; - - const invalid = payload.find((change) => { - if (change.type === "delete") return false; - const draftFeature = change.type === "create" - ? change.feature - : editor.draft.features.find((feature) => - String(feature.properties.id) === String(change.id) - ); - if (!draftFeature) return false; - const entityIds = normalizeFeatureEntityIds(draftFeature); - return entityIds.length === 0; + const updateBackgroundVisibility = ( + updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility + ) => { + setBackgroundVisibility((prev) => { + const next = updater(prev); + persistBackgroundLayerVisibility(next); + return next; }); - - if (invalid) { - const invalidId = invalid.type === "create" - ? invalid.feature.properties.id - : invalid.id; - setSelectedFeatureId(invalidId); - setEntityStatus("Không thể Save: mỗi geometry phải có ít nhất 1 entity."); - return; - } - - setIsSaving(true); - setEntityStatus(null); - try { - await saveGeometryBatchChanges(payload); - editor.clearChanges(); - await reloadCurrentTimelineData(); - } catch (err) { - if (err instanceof ApiError) { - console.error("Save failed", err.body); - setEntityStatus(`Save thất bại: ${err.body}`); - return; - } - console.error("Save error", err); - setEntityStatus("Save thất bại."); - } finally { - setIsSaving(false); - } }; - const handleToggleBackgroundLayer = (id: BackgroundLayerId) => { - setBackgroundVisibility((prev) => ({ + const handleToggleBackgroundLayer = (id: (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"]) => { + updateBackgroundVisibility((prev) => ({ ...prev, [id]: !prev[id], })); }; const handleShowAllBackgroundLayers = () => { - setBackgroundVisibility({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }); + updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY })); }; const handleHideAllBackgroundLayers = () => { - setBackgroundVisibility({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }); + updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })); }; const handleTimelineWindowStartChange = (nextYear: number) => { @@ -442,291 +225,28 @@ export default function Page() { setTimelineDraftYear(clampYearValue(Math.trunc(nextYear), lower, upper)); }; - const handleEntityFormChange = (key: keyof EntityFormState, value: string) => { - setEntityForm((prev) => ({ ...prev, [key]: value })); - }; - - const handleGeometryMetaFormChange = (key: "time_start" | "time_end" | "binding", value: string) => { - setGeometryMetaForm((prev) => ({ ...prev, [key]: value })); - }; - - const handleEntityIdsChange = (values: string[]) => { - setSelectedGeometryEntityIds(uniqueEntityIds(values)); - }; - - const handleAddSelectedSearchEntity = () => { - const entityId = selectedSearchEntityId ? selectedSearchEntityId.trim() : ""; - if (!entityId.length) { - setEntityFormStatus("Hãy chọn một entity từ kết quả search trước."); - return; - } - - const next = uniqueEntityIds([...selectedGeometryEntityIds, entityId]); - setSelectedGeometryEntityIds(next); - setSelectedSearchEntityId(null); - setEntityFormStatus(null); - }; - - const reloadEntities = async () => { - const rows = await fetchEntities(); - setEntities(rows); - setSelectedEntityId((prev) => { - if (prev && rows.some((entity) => entity.id === prev)) { - return prev; - } - return rows[0]?.id || null; - }); - return rows; - }; - - const reloadCurrentTimelineData = async () => { - const data = await fetchGeometriesByBBox({ - ...WORLD_BBOX, - time: timelineYear, - }); - 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 timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start"); - const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end"); - if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) { - throw new Error("time_start phải <= time_end."); - } - return { timeStart, timeEnd }; - }; - - const patchSelectedFeatureLocally = ( - feature: Feature, - entityIds: string[], - timeStart: number | null, - timeEnd: number | null, - bindingIds: string[], - entityRows: Entity[] = entities - ) => { - const primaryEntityId = entityIds[0] || null; - const primaryEntity = primaryEntityId - ? entityRows.find((entity) => entity.id === primaryEntityId) || null - : null; - const entityNames = entityIds - .map((id) => entityRows.find((entity) => entity.id === id)?.name || "") - .filter((name) => name.length > 0); - - editor.patchFeatureProperties(feature.properties.id, { - entity_id: primaryEntityId, - entity_ids: entityIds, - entity_name: primaryEntity?.name || null, - entity_names: entityNames, - entity_type_id: primaryEntity?.type_id || null, - time_start: timeStart, - time_end: timeEnd, - binding: bindingIds, - }); - - setSelectedGeometryEntityIds(entityIds); - setGeometryMetaForm({ - time_start: timeStart != null ? String(timeStart) : "", - time_end: timeEnd != null ? String(timeEnd) : "", - binding: bindingIds.join(", "), - }); - }; - - const handleApplyEntitiesForSelectedGeometry = async () => { - if (!selectedFeature) { - setEntityFormStatus("Hãy chọn một geometry trước."); - return; - } - - const entityIds = uniqueEntityIds(selectedGeometryEntityIds); - if (!entityIds.length) { - setEntityFormStatus("Geometry phải có ít nhất 1 entity."); - return; - } - - let timeStart: number | null; - let timeEnd: number | null; - let bindingIds: string[]; - try { - ({ timeStart, timeEnd } = parseGeometryMetaFormRange()); - bindingIds = parseBindingInput(geometryMetaForm.binding); - } catch (err) { - setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ."); - return; - } - - setIsEntitySubmitting(true); - setEntityFormStatus(null); - try { - if (editor.hasPersistedFeature(selectedFeature.properties.id)) { - 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."); - return; - } - - await updateGeometry(selectedFeature.properties.id, { - geometry: selectedFeature.geometry, - time_start: timeStart, - time_end: timeEnd, - binding: bindingIds, - entity_ids: entityIds, - }); - - await reloadCurrentTimelineData(); - setEntityFormStatus("Đã cập nhật entities + metadata geometry."); - } else { - patchSelectedFeatureLocally(selectedFeature, entityIds, timeStart, timeEnd, bindingIds); - setEntityFormStatus("Đã cập nhật local. Bấm Save để lưu geometry mới."); - } - } catch (err) { - if (err instanceof ApiError) { - setEntityFormStatus(`Lưu thất bại: ${err.body}`); - } else { - setEntityFormStatus("Lưu thất bại."); - } - } finally { - setIsEntitySubmitting(false); - } - }; - - const handleCreateEntityAndAttach = 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(); - if (!name) { - setEntityFormStatus("Tên entity là bắt buộc."); - return; - } - - let timeStart: number | null; - let timeEnd: number | null; - let bindingIds: string[]; - try { - ({ timeStart, timeEnd } = parseGeometryMetaFormRange()); - bindingIds = parseBindingInput(geometryMetaForm.binding); - } catch (err) { - setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ."); - return; - } - - setIsEntitySubmitting(true); - setEntityFormStatus(null); - try { - const created = await createEntity({ - name, - slug: entityForm.slug.trim() || null, - type_id: entityForm.type_id || DEFAULT_ENTITY_TYPE_ID, - }); - - const rows = await reloadEntities(); - const nextEntityIds = uniqueEntityIds([ - ...selectedGeometryEntityIds, - created.id, - ]); - - if (editor.hasPersistedFeature(selectedFeature.properties.id)) { - await updateGeometry(selectedFeature.properties.id, { - geometry: selectedFeature.geometry, - 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."); - } - - setSelectedEntityId(created.id); - 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 { - setIsEntitySubmitting(false); - } - }; - - const timelineDisabled = !isTimelineReady || isSaving || editor.changeCount > 0; - const timelineStatusText = - editor.changeCount > 0 - ? "Lưu hoặc undo hết thay đổi trước khi đổi mốc thời gian." - : isSaving - ? "Đang lưu thay đổi..." - : timelineStatus; - - const handleCreateFeature = (feature: Feature) => { - editor.createFeature(feature); - setSelectedFeatureId(feature.properties.id); - }; + const timelineDisabled = !isTimelineReady; + const selectedType = resolveSelectedTypeSummary(selectedFeature); + const selectedTimeSummary = formatSelectedTimeSummary(selectedFeature); + const selectedEntitySummary = formatSelectedEntitySummary(selectedFeature); + const selectedBindingSummary = formatSelectedBindingSummary(selectedFeature); return (
- -
- + {isBackgroundVisibilityReady ? ( + + ) : ( +
+ )}
@@ -748,39 +268,57 @@ export default function Page() { onShowAll={handleShowAllBackgroundLayers} onHideAll={handleHideAllBackgroundLayers} topContent={ - +
+
+ Viewer +
+
+ Click trực tiếp trên map để chọn geometry. +
+
+ {selectedFeature ? `ID: ${String(selectedFeature.properties.id)}` : "Chưa chọn geometry"} +
+
+ Type: {selectedType} +
+
+ Time: {selectedTimeSummary} +
+
+ Entities: {selectedEntitySummary} +
+
+ Binding: {selectedBindingSummary} +
+ + + Mở Editor + +
} />
@@ -831,93 +369,68 @@ function isYearNumber(value: number | null | undefined): value is number { return typeof value === "number" && Number.isFinite(value); } -function parseOptionalYearInput(raw: string, fieldName: string): number | null { - const value = raw.trim(); - if (!value.length) return null; - const parsed = Number(value); - if (!Number.isFinite(parsed)) { - throw new Error(`${fieldName} phải là số.`); - } - return Math.trunc(parsed); +function formatSelectedTimeSummary(feature: Feature | null): string { + if (!feature) return "Không có"; + const start = isYearNumber(feature.properties.time_start) + ? String(feature.properties.time_start) + : "?"; + const end = isYearNumber(feature.properties.time_end) + ? String(feature.properties.time_end) + : "?"; + return `${start} → ${end}`; } -function normalizeFeatureEntityIds(feature: Feature): string[] { - const fromArray = Array.isArray(feature.properties.entity_ids) - ? feature.properties.entity_ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0) +function formatSelectedEntitySummary(feature: Feature | null): string { + if (!feature) return "Không có"; + + const names = Array.isArray(feature.properties.entity_names) + ? feature.properties.entity_names.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : []; + if (names.length) return names.join(", "); - if (fromArray.length) { - return uniqueEntityIds(fromArray); + if (typeof feature.properties.entity_name === "string" && feature.properties.entity_name.trim().length > 0) { + return feature.properties.entity_name.trim(); } - const single = feature.properties.entity_id; - if (typeof single === "string" && single.trim().length > 0) { - return [single.trim()]; + const ids = Array.isArray(feature.properties.entity_ids) + ? feature.properties.entity_ids.filter((item): item is string => typeof item === "string" && item.trim().length > 0) + : []; + if (ids.length) return ids.join(", "); + + if (typeof feature.properties.entity_id === "string" && feature.properties.entity_id.trim().length > 0) { + return feature.properties.entity_id.trim(); } - return []; + return "Chưa gắn"; } -function normalizeFeatureBindingIds(feature: Feature): string[] { - const rawBinding = feature.properties.binding; - if (!Array.isArray(rawBinding)) return []; - return uniqueEntityIds(rawBinding - .map((id) => { - if (typeof id !== "string" && typeof id !== "number") return ""; - return String(id).trim(); - }) - .filter((id) => id.length > 0)); -} - -function parseBindingInput(raw: string): string[] { - if (!raw.trim().length) return []; - return uniqueEntityIds( - raw - .split(/[,\n]/) - .map((item) => item.trim()) +function formatSelectedBindingSummary(feature: Feature | null): string { + if (!feature) return "Không có"; + const binding = Array.isArray(feature.properties.binding) + ? feature.properties.binding + .map((item) => { + if (typeof item !== "string" && typeof item !== "number") return ""; + return String(item).trim(); + }) .filter((item) => item.length > 0) - ); + : []; + if (!binding.length) return "Không có"; + return binding.join(", "); } -function formatBindingIdsForDisplay(feature: Feature): string { - const bindingIds = normalizeFeatureBindingIds(feature); - if (!bindingIds.length) return "Không có"; - return bindingIds.join(", "); +function resolveSelectedTypeSummary(feature: Feature | null): string { + if (!feature) return "Không có"; + const typeId = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id); + if (!typeId) return "Không có"; + const option = findEntityTypeOption(typeId); + if (!option) return typeId; + return `${option.label} (${option.groupLabel})`; } -function uniqueEntityIds(ids: string[]): string[] { - const deduped: string[] = []; - const seen = new Set(); - for (const rawId of ids) { - const id = rawId.trim(); - if (!id || seen.has(id)) continue; - seen.add(id); - deduped.push(id); - } - return deduped; -} - -function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): string { - const entityIds = normalizeFeatureEntityIds(feature); - if (!entityIds.length) return "Chưa gắn"; - - const names = entityIds - .map((id) => entities.find((entity) => entity.id === id)?.name || id) - .filter((name) => name.trim().length > 0); - return names.join(", "); -} - -function mergeEntitiesById(current: Entity[], incoming: Entity[]): Entity[] { - if (!incoming.length) return current; - - const map = new globalThis.Map(); - for (const entity of current) { - map.set(entity.id, entity); - } - for (const entity of incoming) { - map.set(entity.id, entity); - } - return Array.from(map.values()); +function normalizeTypeId(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + return normalized.length ? normalized : null; } function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility { diff --git a/components/Editor.tsx b/components/Editor.tsx index 04d59f4..e709d7e 100644 --- a/components/Editor.tsx +++ b/components/Editor.tsx @@ -3,18 +3,10 @@ import { UndoAction } from "@/lib/useEditorState"; type Mode = "draw" | "select" | "idle" | "add-point" | "add-line" | "add-path" | "add-circle"; -type EntityOption = { - id: string; - name: string; - geometry_count?: number; -}; type Props = { mode: Mode; setMode: (mode: Mode) => void; - entities: EntityOption[]; - selectedEntityId: string | null; - onSelectEntityId: (entityId: string | null) => void; entityStatus?: string | null; onUndo: () => void; onSave: () => void; @@ -26,9 +18,6 @@ type Props = { export default function Editor({ mode, setMode, - entities, - selectedEntityId, - onSelectEntityId, entityStatus, onUndo, onSave, @@ -148,48 +137,21 @@ export default function Editor({
) : null} -
-
- Entity mặc định cho geometry mới -
- -
- Geometry mới tạo sẽ gắn sẵn entity này, bạn có thể thêm nhiều entity ở panel bên phải. + {entityStatus}
- {entityStatus ? ( -
- {entityStatus} -
- ) : null} -
+ ) : null}
- {isEntitySearchLoading ? ( -
- Đang tìm entity... -
- ) : null} -
Geometry phải có ít nhất 1 entity để Save.
- onEntityFormChange("name", event.target.value)} - placeholder="Tên entity mới" - disabled={isEntitySubmitting} - style={entityInputStyle} - /> - onEntityFormChange("slug", event.target.value)} - placeholder="Slug" - disabled={isEntitySubmitting} - style={entityInputStyle} - />
-
- Entity type presets +
+ Metadata geometry (áp dụng cho cả 2 luồng bên dưới)
- Chọn nhanh theo nhóm hình học bạn cần vẽ. + `time_start`, `time_end`, `binding` sẽ được dùng cho cả luồng gắn entity có sẵn + và luồng tạo entity mới. +
+ onGeometryMetaFormChange("time_start", event.target.value)} + placeholder="time_start" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + onGeometryMetaFormChange("time_end", event.target.value)} + placeholder="time_end" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + onGeometryMetaFormChange("binding", event.target.value)} + placeholder="binding ids (vd: geo-id-1, geo-id-2)" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> +
+ +
+
+ Luồng 1: Gắn entity có sẵn +
+
+ 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. +
+ onEntitySearchQueryChange(event.target.value)} + placeholder="Search entity theo name..." + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + + + {isEntitySearchLoading ? ( +
+ Đang tìm entity... +
+ ) : null} + +
+ +
+
+ Luồng 2: Tạo entity mới rồi gắn +
+
+ 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.
- {visibleGroupedEntityTypeOptions.map((group) => { - const color = GROUP_COLORS[group.id]; - return ( -
-
- - {group.label} - - - {group.geometryLabel} - -
-
- {group.options.map((option) => { - const active = option.value === entityForm.type_id; - return ( - - ); - })} -
-
- ); - })} -
+ onEntityFormChange("name", event.target.value)} + placeholder="Tên entity mới" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + onEntityFormChange("slug", event.target.value)} + placeholder="Slug" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> +
+ Chọn loại entity +
onGeometryMetaFormChange("time_start", event.target.value)} - placeholder="time_start" - disabled={isEntitySubmitting} - style={entityInputStyle} - /> - onGeometryMetaFormChange("time_end", event.target.value)} - placeholder="time_end" - disabled={isEntitySubmitting} - style={entityInputStyle} - /> - onGeometryMetaFormChange("binding", event.target.value)} - placeholder="binding ids (vd: geo-id-1, geo-id-2)" - disabled={isEntitySubmitting} - style={entityInputStyle} - /> - - - +
{changeCount > 0 ? (
@@ -438,80 +424,42 @@ const secondaryActionButtonStyle: CSSProperties = { color: "#ffffff", }; -const GROUP_COLORS: Record< - EntityTypeGroupId, - { - background: string; - border: string; - text: string; - mutedText: string; - chipBackground: string; - chipBorder: string; - activeBackground: string; - activeBorder: string; - activeText: string; +function resolveFeatureGeometryPreset(feature: Feature): EntityGeometryPreset { + const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset); + if (explicitPreset) return explicitPreset; + + const semanticType = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id); + if (semanticType) { + const option = findEntityTypeOption(semanticType); + if (option) return option.geometryPreset; } -> = { - split: { - background: "#3f1d24", - border: "#7f1d1d", - text: "#fecaca", - mutedText: "#fda4af", - chipBackground: "#4c1d27", - chipBorder: "#7f1d1d", - activeBackground: "#7f1d1d", - activeBorder: "#fecaca", - activeText: "#ffffff", - }, - route: { - background: "#082f49", - border: "#0c4a6e", - text: "#bae6fd", - mutedText: "#7dd3fc", - chipBackground: "#0c4a6e", - chipBorder: "#155e75", - activeBackground: "#0369a1", - activeBorder: "#bae6fd", - activeText: "#f0f9ff", - }, - area_polygon: { - background: "#1f3a2a", - border: "#166534", - text: "#bbf7d0", - mutedText: "#86efac", - chipBackground: "#14532d", - chipBorder: "#166534", - activeBackground: "#15803d", - activeBorder: "#bbf7d0", - activeText: "#f0fdf4", - }, - area_circle: { - background: "#3f2b05", - border: "#92400e", - text: "#fde68a", - mutedText: "#fcd34d", - chipBackground: "#78350f", - chipBorder: "#92400e", - activeBackground: "#b45309", - activeBorder: "#fde68a", - activeText: "#fffbeb", - }, - point: { - background: "#2e1065", - border: "#6d28d9", - text: "#ddd6fe", - mutedText: "#c4b5fd", - chipBackground: "#4c1d95", - chipBorder: "#6d28d9", - activeBackground: "#7c3aed", - activeBorder: "#ddd6fe", - activeText: "#f5f3ff", - }, -}; -type GeometryBucket = "point" | "line" | "polygon"; + return mapGeometryTypeToPreset(feature.geometry.type); +} -function toGeometryBucket(geometryType: Feature["geometry"]["type"]): GeometryBucket { +function normalizeGeometryPreset(value: unknown): EntityGeometryPreset | null { + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + if ( + normalized === "point" || + normalized === "line" || + normalized === "polygon" || + normalized === "circle-area" + ) { + return normalized; + } + return null; +} + +function normalizeTypeId(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + return normalized.length ? normalized : null; +} + +function mapGeometryTypeToPreset( + geometryType: Feature["geometry"]["type"] +): EntityGeometryPreset { if (geometryType === "Point" || geometryType === "MultiPoint") { return "point"; } @@ -521,26 +469,28 @@ function toGeometryBucket(geometryType: Feature["geometry"]["type"]): GeometryBu return "polygon"; } -function toTypeBucket(option: EntityTypeOption): GeometryBucket { - if (option.geometryPreset === "point") { - return "point"; - } - if (option.geometryPreset === "line") { - return "line"; - } - return "polygon"; -} - -function getAllowedGroupIdsForGeometry( - geometryType: Feature["geometry"]["type"] +function getAllowedGroupIdsForPreset( + geometryPreset: EntityGeometryPreset ): EntityTypeGroupId[] { - if (geometryType === "Point" || geometryType === "MultiPoint") { + if (geometryPreset === "point") { return ["point"]; } - if (geometryType === "LineString" || geometryType === "MultiLineString") { - return ["split", "route"]; + if (geometryPreset === "line") { + return ["line"]; } - return ["area_polygon", "area_circle"]; + if (geometryPreset === "circle-area") { + return ["circle"]; + } + + return ["polygon"]; +} + +function formatGeometryPresetLabel(preset: EntityGeometryPreset | null): string { + if (preset === "point") return "point - Điểm"; + if (preset === "line") return "line - Tuyến"; + if (preset === "circle-area") return "circle - Tròn"; + if (preset === "polygon") return "polygon - Đa giác"; + return "unknown"; } diff --git a/lib/backgroundLayers.ts b/lib/backgroundLayers.ts index 9bfbf32..817ef63 100644 --- a/lib/backgroundLayers.ts +++ b/lib/backgroundLayers.ts @@ -13,6 +13,7 @@ export const BACKGROUND_LAYER_OPTIONS = [ export type BackgroundLayerId = (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"]; export type BackgroundLayerVisibility = Record; +// Tạo map visibility mặc định cho toàn bộ background layers. function buildBackgroundLayerVisibility(value: boolean): BackgroundLayerVisibility { return BACKGROUND_LAYER_OPTIONS.reduce((acc, option) => { acc[option.id] = value; diff --git a/lib/circleEngine.ts b/lib/circleEngine.ts index 4b67ee3..c81ba60 100644 --- a/lib/circleEngine.ts +++ b/lib/circleEngine.ts @@ -11,6 +11,7 @@ const EMPTY_PREVIEW: GeoJSON.FeatureCollection = { features: [], }; +// Khởi tạo engine vẽ circle bằng thao tác kéo chuột từ tâm ra biên. export function initCircle( map: maplibregl.Map, getMode: ModeGetter, @@ -21,12 +22,14 @@ export function initCircle( let isDragging = false; let dragPanDisabledByCircle = false; + // Xóa dữ liệu preview circle trên map. const clearPreview = () => { (map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData( EMPTY_PREVIEW ); }; + // Bật lại drag pan nếu trước đó bị tắt khi đang kéo vẽ circle. const releaseDragPan = () => { if (!dragPanDisabledByCircle) return; dragPanDisabledByCircle = false; @@ -35,6 +38,7 @@ export function initCircle( } }; + // Reset toàn bộ trạng thái vẽ circle tạm thời. const resetDrawingState = () => { center = null; radiusMeters = 0; @@ -43,6 +47,7 @@ export function initCircle( releaseDragPan(); }; + // Cập nhật polygon preview theo tâm và bán kính hiện tại. const updatePreview = () => { if (!center || radiusMeters < MIN_RADIUS_METERS) { clearPreview(); @@ -65,6 +70,7 @@ export function initCircle( }); }; + // Bắt đầu phiên vẽ circle khi nhấn chuột trái. const onMouseDown = (e: maplibregl.MapMouseEvent) => { if (getMode() !== "add-circle") return; if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return; @@ -82,6 +88,7 @@ export function initCircle( } }; + // Cập nhật bán kính theo vị trí chuột trong lúc kéo. const onMouseMove = (e: maplibregl.MapMouseEvent) => { const canvas = map.getCanvas(); if (getMode() !== "add-circle") { @@ -101,6 +108,7 @@ export function initCircle( updatePreview(); }; + // Hoàn tất circle và trả geometry cho callback. const finishCircle = () => { if (!isDragging || !center) { resetDrawingState(); @@ -120,12 +128,14 @@ export function initCircle( resetDrawingState(); }; + // Kết thúc thao tác kéo bằng mouseup chuột trái. const onMouseUp = (e: maplibregl.MapMouseEvent) => { if (getMode() !== "add-circle") return; if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return; finishCircle(); }; + // Hủy phiên vẽ circle khi nhấn Escape. const onKeyDown = (e: KeyboardEvent) => { if (getMode() !== "add-circle") return; if (e.key !== "Escape") return; @@ -150,6 +160,7 @@ export function initCircle( }; } +// Tạo vòng polygon xấp xỉ hình tròn từ tâm, bán kính và số phân đoạn. function buildCircleRing( center: [number, number], radiusMeters: number, @@ -163,6 +174,7 @@ function buildCircleRing( return ring; } +// Tính khoảng cách hai điểm theo công thức Haversine (đơn vị mét). function distanceMeters(a: [number, number], b: [number, number]): number { const lat1 = toRad(a[1]); const lat2 = toRad(b[1]); @@ -178,6 +190,7 @@ function distanceMeters(a: [number, number], b: [number, number]): number { return EARTH_RADIUS_METERS * c; // Khoảng cách cung tròn: d = R * c. } +// Tính tọa độ điểm đích từ tâm, khoảng cách và góc phương vị. function destinationPoint( center: [number, number], distance: number, @@ -205,22 +218,26 @@ function destinationPoint( return [normalizeLng(toDeg(lng2)), toDeg(lat2)]; } +// Chuẩn hóa kinh độ về miền [-180, 180]. function normalizeLng(lng: number): number { let normalized = ((lng + 540) % 360) - 180; // Wrap về khoảng [-180, 180). if (normalized === -180) normalized = 180; return normalized; } +// Kẹp giá trị trong đoạn [min, max]. function clamp(value: number, min: number, max: number): number { if (value < min) return min; if (value > max) return max; return value; } +// Đổi đơn vị góc từ độ sang radian. function toRad(value: number): number { return (value * Math.PI) / 180; // Đổi độ sang radian. } +// Đổi đơn vị góc từ radian sang độ. function toDeg(value: number): number { return (value * 180) / Math.PI; // Đổi radian sang độ. } diff --git a/lib/drawingEngine.ts b/lib/drawingEngine.ts index c3c632b..5157715 100644 --- a/lib/drawingEngine.ts +++ b/lib/drawingEngine.ts @@ -3,6 +3,7 @@ import { Geometry } from "@/lib/useEditorState"; type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; +// Khởi tạo engine vẽ polygon tự do theo chuỗi click. export function initDrawing( map: maplibregl.Map, getMode: ModeGetter, @@ -10,9 +11,7 @@ export function initDrawing( ) { let coords: [number, number][] = []; - /** - * Close polygon ring if not closed. - */ + // Đóng vòng polygon nếu điểm cuối chưa trùng điểm đầu. function closePolygon(c: [number, number][]) { if (c.length < 3) return c; const first = c[0]; @@ -24,9 +23,7 @@ export function initDrawing( return c; } - /** - * Update preview layer while drawing. - */ + // Cập nhật layer preview trong lúc đang vẽ. function update(c: [number, number][]) { const closed = closePolygon(c); @@ -45,6 +42,7 @@ export function initDrawing( }); } + // Ghi nhận đỉnh polygon mới khi click map. function onClick(e: maplibregl.MapLayerMouseEvent) { if (getMode() !== "draw") return; @@ -52,6 +50,7 @@ export function initDrawing( update(coords); } + // Render preview polygon với điểm chuột hiện tại. function onMove(e: maplibregl.MapLayerMouseEvent) { if (getMode() !== "draw" || coords.length === 0) return; @@ -62,9 +61,7 @@ export function initDrawing( update(preview); } - /** - * Finalize polygon, emit geometry to caller, reset preview. - */ + // Hoàn tất polygon, trả geometry ra ngoài và reset preview. function finishDrawing() { if (getMode() !== "draw" || coords.length < 3) return; @@ -83,6 +80,7 @@ export function initDrawing( }); } + // Lắng nghe Enter để chốt polygon. function onKeyDown(e: KeyboardEvent) { if (e.key === "Enter") { finishDrawing(); diff --git a/lib/editingEngine.ts b/lib/editingEngine.ts index f2e9b81..e51b640 100644 --- a/lib/editingEngine.ts +++ b/lib/editingEngine.ts @@ -13,6 +13,7 @@ export type EditingAPI = { bindEditEvents: (map: maplibregl.Map) => void; }; +// Tạo engine chỉnh sửa polygon đã có (kéo đỉnh, thêm đỉnh, commit/cancel). export function createEditingEngine(options: { mapRef: React.MutableRefObject; onUpdate: (id: string | number, geometry: Geometry) => void; @@ -22,6 +23,7 @@ export function createEditingEngine(options: { const dragStateRef = { current: null as { idx: number } | null }; const modifierRef = { current: { ctrl: false, meta: false } }; + // Hủy trạng thái chỉnh sửa hiện tại và dọn hai source edit. const clearEditing = () => { editingRef.current = null; dragStateRef.current = null; @@ -32,6 +34,7 @@ export function createEditingEngine(options: { (map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(empty); }; + // Đồng bộ polygon tạm và các handle point lên map source. const updateEditSources = () => { const editing = editingRef.current; const map = mapRef.current; @@ -62,6 +65,7 @@ export function createEditingEngine(options: { (map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles); }; + // Chốt chỉnh sửa và emit geometry mới cho caller. const finishEditing = () => { const editing = editingRef.current; if (!editing) return; @@ -73,10 +77,12 @@ export function createEditingEngine(options: { clearEditing(); }; + // Thoát chế độ chỉnh sửa mà không lưu thay đổi. const cancelEditing = () => { clearEditing(); }; + // Bắt đầu chỉnh sửa từ feature polygon được chọn. const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => { if (feature.geometry.type !== "Polygon") return; const coords = (feature.geometry.coordinates?.[0] ?? []) as [number, number][]; @@ -92,6 +98,7 @@ export function createEditingEngine(options: { updateEditSources(); }; + // Kiểm tra trạng thái nhấn phím modifier để bật thao tác chèn đỉnh. const isModifierPressed = (e?: maplibregl.MapLayerMouseEvent | maplibregl.MapMouseEvent) => { const oe = e?.originalEvent as MouseEvent | undefined; return ( @@ -102,7 +109,9 @@ export function createEditingEngine(options: { ); }; + // Gắn toàn bộ sự kiện phục vụ chỉnh sửa hình. const bindEditEvents = (map: maplibregl.Map) => { + // Bắt đầu kéo một handle point. const onHandleDown = (e: maplibregl.MapLayerMouseEvent) => { if (!editingRef.current) return; const feature = e.features?.[0]; @@ -114,6 +123,7 @@ export function createEditingEngine(options: { map.dragPan.disable(); }; + // Cập nhật vị trí đỉnh trong lúc kéo chuột. const onHandleMove = (e: maplibregl.MapMouseEvent) => { const drag = dragStateRef.current; const editing = editingRef.current; @@ -123,12 +133,14 @@ export function createEditingEngine(options: { updateEditSources(); }; + // Kết thúc kéo đỉnh và khôi phục trạng thái tương tác map. const stopDragging = () => { dragStateRef.current = null; map.getCanvas().style.cursor = ""; map.dragPan.enable(); }; + // Bắt phím điều khiển phiên chỉnh sửa (Enter/Escape + modifier flags). const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Control") { modifierRef.current.ctrl = true; @@ -143,6 +155,7 @@ export function createEditingEngine(options: { } }; + // Hạ cờ modifier khi nhả phím. const onKeyUp = (e: KeyboardEvent) => { if (e.key === "Control") { modifierRef.current.ctrl = false; @@ -151,6 +164,7 @@ export function createEditingEngine(options: { } }; + // Chèn thêm một đỉnh mới vào ring tại vị trí gần điểm click nhất. const onInsertHandle = (e: maplibregl.MapLayerMouseEvent) => { if (!editingRef.current) return; if (!isModifierPressed(e)) return; @@ -177,6 +191,7 @@ export function createEditingEngine(options: { updateEditSources(); }; + // Ngắt kéo nếu con trỏ rời canvas. const onCanvasLeave = () => { stopDragging(); }; diff --git a/lib/entityTypeOptions.ts b/lib/entityTypeOptions.ts index 8530754..1d9a6b7 100644 --- a/lib/entityTypeOptions.ts +++ b/lib/entityTypeOptions.ts @@ -1,8 +1,7 @@ export type EntityTypeGroupId = - | "split" - | "route" - | "area_polygon" - | "area_circle" + | "line" + | "polygon" + | "circle" | "point"; export type EntityGeometryPreset = "line" | "polygon" | "circle-area" | "point"; @@ -24,43 +23,36 @@ export type EntityTypeOption = { export const ENTITY_TYPE_GROUPS: EntityTypeGroup[] = [ { - id: "split", - label: "Split", + id: "line", + label: "line - Tuyến", geometryLabel: "Line", - description: "Tuyến chia cắt/phòng thủ.", + description: "Các tuyến line/path (gấp khúc).", }, { - id: "route", - label: "Route", - geometryLabel: "Line", - description: "Các tuyến di chuyển theo hướng.", - }, - { - id: "area_polygon", - label: "Area - Đa giác", + id: "polygon", + label: "polygon - Đa giác", geometryLabel: "Polygon", description: "Vùng lãnh thổ dạng đa giác.", }, { - id: "area_circle", - label: "Area - Tròn", - geometryLabel: "Polygon tròn", + id: "circle", + label: "circle - Tròn", + geometryLabel: "Circle", description: "Vùng sự kiện theo bán kính ảnh hưởng.", }, { id: "point", - label: "Point", + label: "point - Điểm", geometryLabel: "Point", description: "Địa điểm đơn lẻ.", }, ]; const GROUP_BY_ID: Record = { - split: ENTITY_TYPE_GROUPS[0], - route: ENTITY_TYPE_GROUPS[1], - area_polygon: ENTITY_TYPE_GROUPS[2], - area_circle: ENTITY_TYPE_GROUPS[3], - point: ENTITY_TYPE_GROUPS[4], + line: ENTITY_TYPE_GROUPS[0], + polygon: ENTITY_TYPE_GROUPS[1], + circle: ENTITY_TYPE_GROUPS[2], + point: ENTITY_TYPE_GROUPS[3], }; const RAW_ENTITY_TYPE_OPTIONS: Array<{ @@ -69,25 +61,25 @@ const RAW_ENTITY_TYPE_OPTIONS: Array<{ groupId: EntityTypeGroupId; geometryPreset: EntityGeometryPreset; }> = [ - { value: "defense_line", label: "Defense Line", groupId: "split", geometryPreset: "line" }, + { value: "defense_line", label: "Defense Line", groupId: "line", geometryPreset: "line" }, - { value: "attack_route", label: "Attack Route", groupId: "route", geometryPreset: "line" }, - { value: "retreat_route", label: "Retreat Route", groupId: "route", geometryPreset: "line" }, - { value: "invasion_route", label: "Invasion Route", groupId: "route", geometryPreset: "line" }, - { value: "migration_route", label: "Migration Route", groupId: "route", geometryPreset: "line" }, - { value: "refugee_route", label: "Refugee Route", groupId: "route", geometryPreset: "line" }, - { value: "trade_route", label: "Trade Route", groupId: "route", geometryPreset: "line" }, - { value: "shipping_route", label: "Shipping Route", groupId: "route", geometryPreset: "line" }, + { value: "attack_route", label: "Attack Route", groupId: "line", geometryPreset: "line" }, + { value: "retreat_route", label: "Retreat Route", groupId: "line", geometryPreset: "line" }, + { value: "invasion_route", label: "Invasion Route", groupId: "line", geometryPreset: "line" }, + { value: "migration_route", label: "Migration Route", groupId: "line", geometryPreset: "line" }, + { value: "refugee_route", label: "Refugee Route", groupId: "line", geometryPreset: "line" }, + { value: "trade_route", label: "Trade Route", groupId: "line", geometryPreset: "line" }, + { value: "shipping_route", label: "Shipping Route", groupId: "line", geometryPreset: "line" }, - { value: "country", label: "Country", groupId: "area_polygon", geometryPreset: "polygon" }, - { value: "state", label: "State", groupId: "area_polygon", geometryPreset: "polygon" }, - { value: "empire", label: "Empire", groupId: "area_polygon", geometryPreset: "polygon" }, - { value: "kingdom", label: "Kingdom", groupId: "area_polygon", geometryPreset: "polygon" }, + { value: "country", label: "Country", groupId: "polygon", geometryPreset: "polygon" }, + { value: "state", label: "State", groupId: "polygon", geometryPreset: "polygon" }, + { value: "empire", label: "Empire", groupId: "polygon", geometryPreset: "polygon" }, + { value: "kingdom", label: "Kingdom", groupId: "polygon", geometryPreset: "polygon" }, - { value: "war", label: "War", groupId: "area_circle", geometryPreset: "circle-area" }, - { value: "battle", label: "Battle", groupId: "area_circle", geometryPreset: "circle-area" }, - { value: "civilization", label: "Civilization", groupId: "area_circle", geometryPreset: "circle-area" }, - { value: "rebellion_zone", label: "Rebellion Zone", groupId: "area_circle", geometryPreset: "circle-area" }, + { value: "war", label: "War", groupId: "circle", geometryPreset: "circle-area" }, + { value: "battle", label: "Battle", groupId: "circle", geometryPreset: "circle-area" }, + { value: "civilization", label: "Civilization", groupId: "circle", geometryPreset: "circle-area" }, + { value: "rebellion_zone", label: "Rebellion Zone", groupId: "circle", geometryPreset: "circle-area" }, { value: "person_deathplace", label: "Person Deathplace", groupId: "point", geometryPreset: "point" }, { value: "person_birthplace", label: "Person Birthplace", groupId: "point", geometryPreset: "point" }, @@ -109,6 +101,7 @@ export const ENTITY_TYPE_OPTIONS: EntityTypeOption[] = RAW_ENTITY_TYPE_OPTIONS.m export const DEFAULT_ENTITY_TYPE_ID = "country"; +// Gom option theo group để render select phân nhóm. export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE_OPTIONS): Array<{ id: EntityTypeGroupId; label: string; @@ -122,8 +115,8 @@ export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE })).filter((group) => group.options.length > 0); } +// Tìm option theo type id, trả null nếu không tồn tại. export function findEntityTypeOption(typeId: string | null | undefined): EntityTypeOption | null { if (!typeId) return null; return ENTITY_TYPE_OPTIONS.find((option) => option.value === typeId) || null; } - diff --git a/lib/lineEngine.ts b/lib/lineEngine.ts index a1a0693..9dfc077 100644 --- a/lib/lineEngine.ts +++ b/lib/lineEngine.ts @@ -8,6 +8,7 @@ const EMPTY_PREVIEW: GeoJSON.FeatureCollection = { features: [], }; +// Khởi tạo engine vẽ line (gấp khúc, không mũi tên). export function initLine( map: maplibregl.Map, getMode: ModeGetter, @@ -15,17 +16,20 @@ export function initLine( ) { let coords: [number, number][] = []; + // Xóa dữ liệu preview line. const clearPreview = () => { (map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData( EMPTY_PREVIEW ); }; + // Hủy phiên vẽ line hiện tại. const cancelLine = () => { coords = []; clearPreview(); }; + // Cập nhật line preview theo danh sách tọa độ tạm. const updatePreview = (lineCoords: [number, number][]) => { if (lineCoords.length < 2) { clearPreview(); @@ -47,6 +51,7 @@ export function initLine( }); }; + // Chốt line khi đủ số đỉnh tối thiểu. const finishLine = () => { if (getMode() !== "add-line" || coords.length < 2) return; @@ -59,12 +64,14 @@ export function initLine( cancelLine(); }; + // Xóa đỉnh cuối cùng trong line đang vẽ. const removeLastVertex = () => { if (!coords.length) return; coords = coords.slice(0, -1); updatePreview(coords); }; + // Thêm một đỉnh line khi click map. const onClick = (e: maplibregl.MapLayerMouseEvent) => { if (getMode() !== "add-line") return; @@ -72,6 +79,7 @@ export function initLine( updatePreview(coords); }; + // Cập nhật preview động theo vị trí chuột. const onMove = (e: maplibregl.MapLayerMouseEvent) => { const canvas = map.getCanvas(); @@ -90,6 +98,7 @@ export function initLine( updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]); }; + // Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ line. const onKeyDown = (e: KeyboardEvent) => { if (getMode() !== "add-line") return; diff --git a/lib/pathEngine.ts b/lib/pathEngine.ts index 06a5ee5..0c38b63 100644 --- a/lib/pathEngine.ts +++ b/lib/pathEngine.ts @@ -8,6 +8,7 @@ const EMPTY_PREVIEW: GeoJSON.FeatureCollection = { features: [], }; +// Khởi tạo engine vẽ path (gấp khúc, sẽ render có mũi tên ở layer path). export function initPath( map: maplibregl.Map, getMode: ModeGetter, @@ -15,12 +16,14 @@ export function initPath( ) { let coords: [number, number][] = []; + // Xóa dữ liệu preview path. const clearPreview = () => { (map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData( EMPTY_PREVIEW ); }; + // Cập nhật path preview theo danh sách tọa độ tạm. const updatePreview = (lineCoords: [number, number][]) => { if (lineCoords.length < 2) { clearPreview(); @@ -42,6 +45,7 @@ export function initPath( }); }; + // Chốt path khi đủ số đỉnh tối thiểu. const finishPath = () => { if (getMode() !== "add-path" || coords.length < 2) return; @@ -55,17 +59,20 @@ export function initPath( clearPreview(); }; + // Hủy phiên vẽ path hiện tại. const cancelPath = () => { coords = []; clearPreview(); }; + // Xóa đỉnh cuối cùng của path đang vẽ. const removeLastVertex = () => { if (coords.length === 0) return; coords = coords.slice(0, -1); updatePreview(coords); }; + // Thêm một đỉnh path khi click map. const onClick = (e: maplibregl.MapLayerMouseEvent) => { if (getMode() !== "add-path") return; @@ -73,6 +80,7 @@ export function initPath( updatePreview(coords); }; + // Cập nhật preview path động theo vị trí chuột. const onMove = (e: maplibregl.MapLayerMouseEvent) => { const canvas = map.getCanvas(); @@ -92,6 +100,7 @@ export function initPath( updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]); }; + // Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ path. const onKeyDown = (e: KeyboardEvent) => { if (getMode() !== "add-path") return; diff --git a/lib/pointEngine.ts b/lib/pointEngine.ts index 2f6bcc7..754fb25 100644 --- a/lib/pointEngine.ts +++ b/lib/pointEngine.ts @@ -3,14 +3,13 @@ import { Geometry } from "@/lib/useEditorState"; type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; +// Khởi tạo engine thêm point bằng click đơn. export function initPoint( map: maplibregl.Map, getMode: ModeGetter, onComplete: (geometry: Geometry) => void ) { - /** - * Add a new point when in add-point mode. - */ + // Thêm point mới khi đang ở chế độ add-point. function onClick(e: maplibregl.MapLayerMouseEvent) { if (getMode() !== "add-point") return; @@ -22,6 +21,7 @@ export function initPoint( onComplete?.(geometry); } + // Cập nhật trạng thái con trỏ theo mode add-point. function onMove() { const canvas = map.getCanvas(); if (getMode() === "add-point") { diff --git a/lib/selectingEngine.ts b/lib/selectingEngine.ts index 0c05702..100d41d 100644 --- a/lib/selectingEngine.ts +++ b/lib/selectingEngine.ts @@ -2,11 +2,12 @@ import maplibregl from "maplibre-gl"; type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; +// Khởi tạo engine chọn feature và context menu edit/delete. export function initSelect( map: maplibregl.Map, getMode: ModeGetter, - onDelete: (id: string | number) => void, - onEdit: (feature: maplibregl.MapGeoJSONFeature) => void, + onDelete?: (id: string | number) => void, + onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void, onSelectId?: (id: string | number | null) => void ) { const SELECTABLE_LAYERS = [ @@ -17,12 +18,11 @@ export function initSelect( "places-symbol", ] as const; const selectedIds = new Set(); + const hasContextActions = Boolean(onDelete || onEdit); let contextMenu: HTMLDivElement | null = null; let docClickHandler: ((ev: MouseEvent) => void) | null = null; - /** - * Clear feature-state highlight for all selected features. - */ + // Bỏ highlight feature-state của toàn bộ đối tượng đang chọn. function clearSelection() { if (!selectedIds.size) return; selectedIds.forEach((id) => { @@ -32,9 +32,7 @@ export function initSelect( onSelectId?.(null); } - /** - * Select (or toggle) a feature. Holding Alt enables additive/toggle selection. - */ + // Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn. function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) { const id = feature.id ?? feature.properties?.id; if (id === undefined || id === null) return; @@ -56,6 +54,7 @@ export function initSelect( onSelectId?.(selectedIds.size === 1 ? id : null); } + // Chọn feature theo click trái, hỗ trợ additive bằng Alt. function onClick(e: maplibregl.MapLayerMouseEvent) { if (getMode() !== "select") return; @@ -72,9 +71,8 @@ export function initSelect( selectFeature(features[0], additive); } - /** - * Show context menu (edit/delete) on right click. - */ + // Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải. + // Mở menu thao tác khi click phải lên feature. function onRightClick(e: maplibregl.MapLayerMouseEvent) { if (getMode() !== "select") return; @@ -103,6 +101,7 @@ export function initSelect( ); } + // Đổi cursor pointer khi hover lên đối tượng có thể chọn. function onMove(e: maplibregl.MapLayerMouseEvent) { if (getMode() !== "select") return; @@ -115,15 +114,20 @@ export function initSelect( map.on("click", onClick); map.on("mousemove", onMove); - map.on("contextmenu", onRightClick); + if (hasContextActions) { + map.on("contextmenu", onRightClick); + } return () => { map.off("click", onClick); map.off("mousemove", onMove); - map.off("contextmenu", onRightClick); + if (hasContextActions) { + map.off("contextmenu", onRightClick); + } hideContextMenu(); }; + // Ẩn và dọn dẹp context menu hiện tại. function hideContextMenu() { if (contextMenu) { contextMenu.remove(); @@ -135,9 +139,7 @@ export function initSelect( } } - /** - * Render a minimal context menu near cursor. - */ + // Render menu ngữ cảnh tối giản gần vị trí con trỏ. function showContextMenu( x: number, y: number, @@ -159,6 +161,7 @@ export function initSelect( menu.style.fontSize = "14px"; menu.style.padding = "4px 0"; + // Tạo một item thao tác trong context menu. const createItem = (label: string, onClick: () => void) => { const item = document.createElement("div"); item.textContent = label; @@ -174,30 +177,38 @@ export function initSelect( }; const selectedCount = selectedIds.size || 1; + let hasMenuItems = false; - if (selectedCount === 1 && clickedFeature.geometry?.type === "Polygon") { + if (selectedCount === 1 && clickedFeature.geometry?.type === "Polygon" && onEdit) { const single = clickedFeature; menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single))); + hasMenuItems = true; } - menu.appendChild( - createItem( - selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa", - () => { - const ids = selectedIds.size - ? Array.from(selectedIds) - : [clickedFeature.id ?? clickedFeature.properties?.id]; - ids.forEach((eachId) => { - if (eachId !== undefined && eachId !== null) onDelete(eachId); - }); - clearSelection(); - } - ) - ); + if (onDelete) { + menu.appendChild( + createItem( + selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa", + () => { + const ids = selectedIds.size + ? Array.from(selectedIds) + : [clickedFeature.id ?? clickedFeature.properties?.id]; + ids.forEach((eachId) => { + if (eachId !== undefined && eachId !== null) onDelete(eachId); + }); + clearSelection(); + } + ) + ); + hasMenuItems = true; + } + + if (!hasMenuItems) return; document.body.appendChild(menu); contextMenu = menu; + // Đóng menu khi click ra ngoài vùng menu. const onDocClick = (ev: MouseEvent) => { if (!menu.contains(ev.target as Node)) { hideContextMenu(); diff --git a/lib/useEditorState.ts b/lib/useEditorState.ts index 3514cf3..eb959d3 100644 --- a/lib/useEditorState.ts +++ b/lib/useEditorState.ts @@ -1,8 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; -/** - * Basic GeoJSON geometry union (no GeometryCollection). - */ +// Kiểu union các GeoJSON geometry cơ bản (không gồm GeometryCollection). export type Geometry = | { type: "Point"; coordinates: [number, number] } | { type: "MultiPoint"; coordinates: [number, number][] } @@ -13,10 +11,11 @@ export type Geometry = export type FeatureProperties = { id: string | number; + type?: string | null; + geometry_preset?: "point" | "line" | "polygon" | "circle-area" | null; time_start?: number | null; time_end?: number | null; binding?: string[]; - line_mode?: string | null; entity_id?: string | null; entity_ids?: string[]; entity_name?: string | null; @@ -35,29 +34,28 @@ export type FeatureCollection = { features: Feature[]; }; -/** - * Change map entry for saving. - */ +// Kiểu thay đổi dùng để gửi payload lưu dữ liệu. export type Change = - | { type: "create"; feature: Feature } - | { type: "update"; id: FeatureProperties["id"]; geometry: Geometry } - | { type: "delete"; id: FeatureProperties["id"] }; + | { action: "create"; feature: Feature } + | { action: "update"; id: FeatureProperties["id"]; geometry: Geometry } + | { action: "delete"; id: FeatureProperties["id"] }; -/** - * Minimal undo record. - */ +// Kiểu bản ghi undo tối thiểu. export type UndoAction = | { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry } | { type: "delete"; feature: Feature } | { type: "create"; id: FeatureProperties["id"] }; +// Deep clone dữ liệu JSON-serializable để tránh mutate tham chiếu cũ. const deepClone = (obj: T): T => JSON.parse(JSON.stringify(obj)); +// So sánh hai geometry theo nội dung tuần tự JSON. function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boolean { if (!a || !b) return false; return JSON.stringify(a) === JSON.stringify(b); } +// Tạo baseline map id -> geometry từ dữ liệu đã lưu. function buildInitialMap(fc: FeatureCollection) { const map = new Map(); for (const f of fc.features) { @@ -66,6 +64,7 @@ function buildInitialMap(fc: FeatureCollection) { return map; } +// Tính diff giữa draft hiện tại và baseline để sinh payload thay đổi. function diffDraftToInitial( draft: FeatureCollection, initialMap: Map @@ -81,22 +80,23 @@ function diffDraftToInitial( seen.add(id); const initialGeom = initialMap.get(id); if (!initialGeom) { - next.set(id, { type: "create", feature: deepClone(f) }); + next.set(id, { action: "create", feature: deepClone(f) }); } else if (!geometryEquals(initialGeom, f.geometry)) { - next.set(id, { type: "update", id, geometry: deepClone(f.geometry) }); + next.set(id, { action: "update", id, geometry: deepClone(f.geometry) }); } } // deletions for (const [id] of initialMap.entries()) { if (!seen.has(id)) { - next.set(id, { type: "delete", id }); + next.set(id, { action: "delete", id }); } } return next; } +// Kiểm tra 2 undo action có cùng nội dung hay không (để tránh push trùng). function isSameUndo(a: UndoAction | undefined, b: UndoAction) { if (!a) return false; if (a.type !== b.type) return false; @@ -124,12 +124,10 @@ function isSameUndo(a: UndoAction | undefined, b: UndoAction) { } } -/** - * Central state for the editor. - * - draft: source of truth for UI rendering - * - changes: map of pending changes for save - * - undoStack: minimal actions to revert last step - */ +// State trung tâm của editor: +// - draft: dữ liệu nguồn để render UI +// - changes: map các thay đổi chờ lưu +// - undoStack: lịch sử thao tác tối thiểu để hoàn tác export function useEditorState(initialData: FeatureCollection) { const [draft, setDraft] = useState(() => deepClone(initialData)); const [undoStack, setUndoStack] = useState([]); @@ -168,6 +166,7 @@ export function useEditorState(initialData: FeatureCollection) { draftRef.current = draft; }, [draft]); + // Đẩy undo action mới vào stack nếu khác action gần nhất. function pushUndo(action: UndoAction) { setUndoStack((prev) => { const last = prev[prev.length - 1]; @@ -176,9 +175,7 @@ export function useEditorState(initialData: FeatureCollection) { }); } - /** - * Add new feature to draft and record "create". - */ + // Thêm feature mới vào draft và ghi nhận thao tác "create". function createFeature(feature: Feature) { const featureClone = deepClone(feature); commitDraft({ @@ -188,9 +185,7 @@ export function useEditorState(initialData: FeatureCollection) { pushUndo({ type: "create", id: featureClone.properties.id }); } - /** - * Patch non-geometry properties on a feature (used for entity/time metadata). - */ + // Cập nhật các thuộc tính không phải geometry của feature (entity/time metadata). function patchFeatureProperties( id: FeatureProperties["id"], patch: Partial @@ -209,9 +204,7 @@ export function useEditorState(initialData: FeatureCollection) { commitDraft({ ...draftRef.current, features: nextFeatures }); } - /** - * Update geometry of an existing feature and record change. - */ + // Cập nhật geometry của feature hiện có và ghi nhận thay đổi. function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) { const idx = draftRef.current.features.findIndex((f) => f.properties.id === id); if (idx === -1) return; // nothing to update @@ -231,9 +224,7 @@ export function useEditorState(initialData: FeatureCollection) { commitDraft({ ...draftRef.current, features: nextFeatures }); } - /** - * Remove a feature from draft and record delete. - */ + // Xóa feature khỏi draft và ghi nhận thao tác delete. function deleteFeature(id: FeatureProperties["id"]) { const idx = draftRef.current.features.findIndex((f) => f.properties.id === id); if (idx === -1) return; @@ -247,9 +238,7 @@ export function useEditorState(initialData: FeatureCollection) { commitDraft({ ...draftRef.current, features: nextFeatures }); } - /** - * Undo last action, reverting both draft and change map. - */ + // Hoàn tác thao tác gần nhất, đồng bộ lại cả draft và danh sách thay đổi. function undo() { let applied = false; // guards against React StrictMode double invoke of setState updater setUndoStack((prev) => { @@ -294,22 +283,19 @@ export function useEditorState(initialData: FeatureCollection) { }); } - /** - * Build payload array for API save. - */ + // Dựng mảng payload gửi API save từ map changes hiện tại. function buildPayload(): Change[] { return Array.from(changes.values()).map((c) => deepClone(c)); } - /** - * Clear pending changes after successful save. - */ + // Xóa thay đổi đang chờ sau khi lưu thành công. function clearChanges() { setUndoStack([]); initialMapRef.current = buildInitialMap(draftRef.current); setBaselineVersion((v) => v + 1); } + // Kiểm tra feature id đã tồn tại ở baseline đã lưu hay chưa. function hasPersistedFeature(id: FeatureProperties["id"]) { return initialMapRef.current.has(id); }