Files
History-client/lib/useEditorState.ts
2026-04-13 21:47:28 +07:00

318 lines
11 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
// 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][] }
| { 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;
type?: string | null;
geometry_preset?: "point" | "line" | "polygon" | "circle-area" | null;
time_start?: number | null;
time_end?: number | null;
binding?: string[];
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[];
};
// Kiểu thay đổi dùng để gửi payload lưu dữ liệu.
export type Change =
| { action: "create"; feature: Feature }
| { action: "update"; id: FeatureProperties["id"]; geometry: Geometry }
| { action: "delete"; id: FeatureProperties["id"] };
// 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 = <T,>(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<FeatureProperties["id"], Geometry>();
for (const f of fc.features) {
map.set(f.properties.id, deepClone(f.geometry));
}
return map;
}
// Tính diff giữa draft hiện tại và baseline để sinh payload thay đổi.
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, { action: "create", feature: deepClone(f) });
} else if (!geometryEquals(initialGeom, 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, { 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;
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;
}
}
// 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<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]);
// Đẩ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];
if (isSameUndo(last, action)) return prev; // tránh trùng lặp liên tiếp
return [...prev, action];
});
}
// 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({
...draftRef.current,
features: [...draftRef.current.features, featureClone],
});
pushUndo({ type: "create", id: featureClone.properties.id });
}
// 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<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 });
}
// 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
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 });
}
// 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;
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 });
}
// 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) => {
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;
});
}
// 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));
}
// 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);
}
return {
draft,
changes,
undoStack,
changeCount,
createFeature,
patchFeatureProperties,
updateFeature,
deleteFeature,
undo,
buildPayload,
clearChanges,
hasPersistedFeature,
};
}