332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
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;
|
|
binding?: string[];
|
|
line_mode?: string | 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 = <T,>(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<FeatureProperties["id"], Geometry>();
|
|
for (const f of fc.features) {
|
|
map.set(f.properties.id, deepClone(f.geometry));
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function diffDraftToInitial(
|
|
draft: FeatureCollection,
|
|
initialMap: Map<FeatureProperties["id"], Geometry>
|
|
) {
|
|
const next = new Map<FeatureProperties["id"], Change>();
|
|
|
|
// track which initial ids are still present
|
|
const seen = new Set<FeatureProperties["id"]>();
|
|
|
|
// 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<UndoAction, { type: "create" }>;
|
|
return a.id === next.id;
|
|
}
|
|
case "delete": {
|
|
const next = b as Extract<UndoAction, { type: "delete" }>;
|
|
return (
|
|
a.feature.properties.id === next.feature.properties.id &&
|
|
geometryEquals(a.feature.geometry, next.feature.geometry)
|
|
);
|
|
}
|
|
case "update": {
|
|
const next = b as Extract<UndoAction, { type: "update" }>;
|
|
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<FeatureCollection>(() => deepClone(initialData));
|
|
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
|
|
|
|
// baseline to know what is "saved" state
|
|
const initialMapRef = useRef<Map<FeatureProperties["id"], Geometry>>(
|
|
buildInitialMap(initialData)
|
|
);
|
|
const draftRef = useRef<FeatureCollection>(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<FeatureProperties>
|
|
) {
|
|
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,
|
|
};
|
|
}
|