import { useEffect, useMemo, useRef, useState } from "react"; /** * Basic GeoJSON geometry union (no GeometryCollection). */ export type Geometry = | { type: "Point"; coordinates: [number, number] } | { type: "MultiPoint"; coordinates: [number, number][] } | { type: "LineString"; coordinates: [number, number][] } | { type: "MultiLineString"; coordinates: [number, number][][] } | { type: "Polygon"; coordinates: [number, number][][] } | { type: "MultiPolygon"; coordinates: [number, number][][][] }; export type FeatureProperties = { id: string | number; time_start?: number | null; time_end?: number | null; entity_id?: string | null; entity_ids?: string[]; entity_name?: string | null; entity_names?: string[]; entity_type_id?: string | null; }; export type Feature = { type: "Feature"; properties: FeatureProperties; geometry: Geometry; }; export type FeatureCollection = { type: "FeatureCollection"; features: Feature[]; }; /** * Change map entry for saving. */ export type Change = | { type: "create"; feature: Feature } | { type: "update"; id: FeatureProperties["id"]; geometry: Geometry } | { type: "delete"; id: FeatureProperties["id"] }; /** * Minimal undo record. */ export type UndoAction = | { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry } | { type: "delete"; feature: Feature } | { type: "create"; id: FeatureProperties["id"] }; const deepClone = (obj: T): T => JSON.parse(JSON.stringify(obj)); function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boolean { if (!a || !b) return false; return JSON.stringify(a) === JSON.stringify(b); } function buildInitialMap(fc: FeatureCollection) { const map = new Map(); for (const f of fc.features) { map.set(f.properties.id, deepClone(f.geometry)); } return map; } function diffDraftToInitial( draft: FeatureCollection, initialMap: Map ) { const next = new Map(); // track which initial ids are still present const seen = new Set(); // additions & updates for (const f of draft.features) { const id = f.properties.id; seen.add(id); const initialGeom = initialMap.get(id); if (!initialGeom) { next.set(id, { type: "create", feature: deepClone(f) }); } else if (!geometryEquals(initialGeom, f.geometry)) { next.set(id, { type: "update", id, geometry: deepClone(f.geometry) }); } } // deletions for (const [id] of initialMap.entries()) { if (!seen.has(id)) { next.set(id, { type: "delete", id }); } } return next; } function isSameUndo(a: UndoAction | undefined, b: UndoAction) { if (!a) return false; if (a.type !== b.type) return false; switch (a.type) { case "create": { const next = b as Extract; return a.id === next.id; } case "delete": { const next = b as Extract; return ( a.feature.properties.id === next.feature.properties.id && geometryEquals(a.feature.geometry, next.feature.geometry) ); } case "update": { const next = b as Extract; return ( a.id === next.id && geometryEquals(a.prevGeometry, next.prevGeometry) ); } default: return false; } } /** * 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 */ export function useEditorState(initialData: FeatureCollection) { const [draft, setDraft] = useState(() => deepClone(initialData)); const [undoStack, setUndoStack] = useState([]); // baseline to know what is "saved" state const initialMapRef = useRef>( buildInitialMap(initialData) ); const draftRef = useRef(deepClone(initialData)); // central entrypoint: keep draftRef + React state in sync const commitDraft = (nextDraft: FeatureCollection) => { const cloned = deepClone(nextDraft); draftRef.current = cloned; setDraft(cloned); }; // reset when initialData changes (e.g., after first load or after refresh) useEffect(() => { commitDraft(deepClone(initialData)); setUndoStack([]); initialMapRef.current = buildInitialMap(initialData); }, [initialData]); // derive pending changes on every render: source of truth for save + changeCount. // read baseline from state captured in closure to avoid ref access during render. const [baselineVersion, setBaselineVersion] = useState(0); const changes = useMemo(() => { const baseline = initialMapRef.current; return diffDraftToInitial(draft, baseline); // eslint-disable-next-line react-hooks/exhaustive-deps }, [draft, baselineVersion]); const changeCount = useMemo(() => changes.size, [changes]); useEffect(() => { draftRef.current = draft; }, [draft]); function pushUndo(action: UndoAction) { setUndoStack((prev) => { const last = prev[prev.length - 1]; if (isSameUndo(last, action)) return prev; // tránh trùng lặp liên tiếp return [...prev, action]; }); } /** * Add new feature to draft and record "create". */ function createFeature(feature: Feature) { const featureClone = deepClone(feature); commitDraft({ ...draftRef.current, features: [...draftRef.current.features, featureClone], }); pushUndo({ type: "create", id: featureClone.properties.id }); } /** * Patch non-geometry properties on a feature (used for entity/time metadata). */ function patchFeatureProperties( id: FeatureProperties["id"], patch: Partial ) { const idx = draftRef.current.features.findIndex((f) => f.properties.id === id); if (idx === -1) return; const nextFeatures = [...draftRef.current.features]; nextFeatures[idx] = { ...nextFeatures[idx], properties: { ...nextFeatures[idx].properties, ...deepClone(patch), }, }; commitDraft({ ...draftRef.current, features: nextFeatures }); } /** * Update geometry of an existing feature and record change. */ 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 const prevFeature = draftRef.current.features[idx]; const prevGeometry = prevFeature.geometry; const updatedFeature = { ...prevFeature, geometry: deepClone(newGeometry), }; pushUndo({ type: "update", id, prevGeometry: deepClone(prevGeometry) }); const nextFeatures = [...draftRef.current.features]; nextFeatures[idx] = updatedFeature; commitDraft({ ...draftRef.current, features: nextFeatures }); } /** * Remove a feature from draft and record delete. */ function deleteFeature(id: FeatureProperties["id"]) { const idx = draftRef.current.features.findIndex((f) => f.properties.id === id); if (idx === -1) return; const feature = draftRef.current.features[idx]; // store undo pushUndo({ type: "delete", feature: deepClone(feature) }); const nextFeatures = [...draftRef.current.features]; nextFeatures.splice(idx, 1); commitDraft({ ...draftRef.current, features: nextFeatures }); } /** * Undo last action, reverting both draft and change map. */ function undo() { let applied = false; // guards against React StrictMode double invoke of setState updater setUndoStack((prev) => { if (applied) return prev; if (!prev.length) return prev; applied = true; const last = prev[prev.length - 1]; const remaining = prev.slice(0, -1); switch (last.type) { case "create": { commitDraft({ ...draftRef.current, features: draftRef.current.features.filter((f) => f.properties.id !== last.id), }); break; } case "delete": { const feature = deepClone(last.feature); commitDraft({ ...draftRef.current, features: [...draftRef.current.features, feature], }); break; } case "update": { const { id, prevGeometry } = last; const idx = draftRef.current.features.findIndex((f) => f.properties.id === id); if (idx === -1) return remaining; const updated = { ...draftRef.current.features[idx], geometry: deepClone(prevGeometry), }; const nextFeatures = [...draftRef.current.features]; nextFeatures[idx] = updated; commitDraft({ ...draftRef.current, features: nextFeatures }); break; } } return remaining; }); } /** * Build payload array for API save. */ function buildPayload(): Change[] { return Array.from(changes.values()).map((c) => deepClone(c)); } /** * Clear pending changes after successful save. */ function clearChanges() { setUndoStack([]); initialMapRef.current = buildInitialMap(draftRef.current); setBaselineVersion((v) => v + 1); } function hasPersistedFeature(id: FeatureProperties["id"]) { return initialMapRef.current.has(id); } return { draft, changes, undoStack, changeCount, createFeature, patchFeatureProperties, updateFeature, deleteFeature, undo, buildPayload, clearChanges, hasPersistedFeature, }; }