refactor
This commit is contained in:
58
lib/editor/background/backgroundVisibilityStorage.ts
Normal file
58
lib/editor/background/backgroundVisibilityStorage.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
BACKGROUND_LAYER_OPTIONS,
|
||||
BackgroundLayerVisibility,
|
||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||
} from "@/lib/backgroundLayers";
|
||||
|
||||
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
|
||||
|
||||
export 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 };
|
||||
}
|
||||
}
|
||||
|
||||
export 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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
|
||||
55
lib/editor/draft/draftDiff.ts
Normal file
55
lib/editor/draft/draftDiff.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
Feature,
|
||||
FeatureCollection,
|
||||
FeatureProperties,
|
||||
Geometry,
|
||||
} from "@/types/geo";
|
||||
import type { Change } from "@/lib/editor/draft/editorTypes";
|
||||
|
||||
export const deepClone = <T,>(obj: T): T => JSON.parse(JSON.stringify(obj));
|
||||
|
||||
export function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boolean {
|
||||
if (!a || !b) return false;
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
export function featureEquals(a: Feature | undefined, b: Feature | undefined): boolean {
|
||||
if (!a || !b) return false;
|
||||
return JSON.stringify(a.geometry) === JSON.stringify(b.geometry) &&
|
||||
JSON.stringify(a.properties) === JSON.stringify(b.properties);
|
||||
}
|
||||
|
||||
export function buildInitialMap(fc: FeatureCollection) {
|
||||
const map = new Map<FeatureProperties["id"], Feature>();
|
||||
for (const feature of fc.features) {
|
||||
map.set(feature.properties.id, deepClone(feature));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function diffDraftToInitial(
|
||||
draft: FeatureCollection,
|
||||
initialMap: Map<FeatureProperties["id"], Feature>
|
||||
) {
|
||||
const next = new Map<FeatureProperties["id"], Change>();
|
||||
const seen = new Set<FeatureProperties["id"]>();
|
||||
|
||||
for (const feature of draft.features) {
|
||||
const id = feature.properties.id;
|
||||
seen.add(id);
|
||||
const initialFeature = initialMap.get(id);
|
||||
if (!initialFeature) {
|
||||
next.set(id, { action: "create", feature: deepClone(feature) });
|
||||
} else if (!featureEquals(initialFeature, feature)) {
|
||||
next.set(id, { action: "update", id, geometry: deepClone(feature.geometry) });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id] of initialMap.entries()) {
|
||||
if (!seen.has(id)) {
|
||||
next.set(id, { action: "delete", id });
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
15
lib/editor/draft/editorTypes.ts
Normal file
15
lib/editor/draft/editorTypes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type {
|
||||
Feature,
|
||||
FeatureProperties,
|
||||
Geometry,
|
||||
GeometryChange,
|
||||
} from "@/types/geo";
|
||||
|
||||
export type Change = GeometryChange;
|
||||
|
||||
export type UndoAction =
|
||||
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
|
||||
| { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties }
|
||||
| { type: "delete"; feature: Feature }
|
||||
| { type: "create"; id: FeatureProperties["id"] };
|
||||
|
||||
29
lib/editor/draft/useDraftState.ts
Normal file
29
lib/editor/draft/useDraftState.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { FeatureCollection } from "@/types/geo";
|
||||
import { deepClone } from "@/lib/editor/draft/draftDiff";
|
||||
|
||||
export function useDraftState(initialData: FeatureCollection) {
|
||||
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
|
||||
const draftRef = useRef<FeatureCollection>(deepClone(initialData));
|
||||
|
||||
const commitDraft = useCallback((nextDraft: FeatureCollection) => {
|
||||
const cloned = deepClone(nextDraft);
|
||||
draftRef.current = cloned;
|
||||
setDraft(cloned);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
draftRef.current = draft;
|
||||
}, [draft]);
|
||||
|
||||
const resetDraft = useCallback((nextDraft: FeatureCollection) => {
|
||||
commitDraft(nextDraft);
|
||||
}, [commitDraft]);
|
||||
|
||||
return {
|
||||
draft,
|
||||
draftRef,
|
||||
commitDraft,
|
||||
resetDraft,
|
||||
};
|
||||
}
|
||||
80
lib/editor/draft/useUndoStack.ts
Normal file
80
lib/editor/draft/useUndoStack.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { UndoAction } from "@/lib/editor/draft/editorTypes";
|
||||
import { geometryEquals } from "@/lib/editor/draft/draftDiff";
|
||||
|
||||
type Options = {
|
||||
applyUndoAction: (action: UndoAction) => boolean;
|
||||
};
|
||||
|
||||
export function useUndoStack(options: Options) {
|
||||
const { applyUndoAction } = options;
|
||||
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
|
||||
|
||||
const pushUndo = useCallback((action: UndoAction) => {
|
||||
setUndoStack((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (isSameUndo(last, action)) return prev;
|
||||
return [...prev, action];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
let applied = false;
|
||||
setUndoStack((prev) => {
|
||||
if (applied) return prev;
|
||||
if (!prev.length) return prev;
|
||||
|
||||
const last = prev[prev.length - 1];
|
||||
const remaining = prev.slice(0, -1);
|
||||
applied = true;
|
||||
|
||||
const didApply = applyUndoAction(last);
|
||||
return didApply ? remaining : prev;
|
||||
});
|
||||
}, [applyUndoAction]);
|
||||
|
||||
const clearUndo = useCallback(() => {
|
||||
setUndoStack([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
undoStack,
|
||||
pushUndo,
|
||||
undo,
|
||||
clearUndo,
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
case "properties": {
|
||||
const next = b as Extract<UndoAction, { type: "properties" }>;
|
||||
return (
|
||||
a.id === next.id &&
|
||||
JSON.stringify(a.prevProperties) === JSON.stringify(next.prevProperties)
|
||||
);
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
109
lib/editor/entity/entityBinding.ts
Normal file
109
lib/editor/entity/entityBinding.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { Entity } from "@/types/entities";
|
||||
import type { Feature, FeatureProperties } from "@/types/geo";
|
||||
import type { PendingEntityCreate } from "@/lib/editor/session/sessionTypes";
|
||||
import { normalizeFeatureEntityIds } from "@/lib/editor/snapshot/editorSnapshot";
|
||||
|
||||
export function mergeEntitiesWithPending(
|
||||
persistedEntities: Entity[],
|
||||
pendingCreates: PendingEntityCreate[]
|
||||
): Entity[] {
|
||||
if (!pendingCreates.length) {
|
||||
return persistedEntities;
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const pendingAsEntities: Entity[] = [];
|
||||
for (const pending of pendingCreates) {
|
||||
if (seen.has(pending.id)) continue;
|
||||
seen.add(pending.id);
|
||||
pendingAsEntities.push({
|
||||
id: pending.id,
|
||||
name: pending.name,
|
||||
slug: pending.slug,
|
||||
type_id: pending.type_id,
|
||||
status: pending.status,
|
||||
geometry_count: 0,
|
||||
created_at: undefined,
|
||||
updated_at: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const nextPersisted = persistedEntities.filter((entity) => !seen.has(entity.id));
|
||||
return [...pendingAsEntities, ...nextPersisted];
|
||||
}
|
||||
|
||||
export function mergeEntitySearchResults(
|
||||
remoteRows: Entity[],
|
||||
localRows: Entity[]
|
||||
): Entity[] {
|
||||
const merged: Entity[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const row of localRows) {
|
||||
if (!row.id || seen.has(row.id)) continue;
|
||||
seen.add(row.id);
|
||||
merged.push(row);
|
||||
}
|
||||
|
||||
for (const row of remoteRows) {
|
||||
if (!row.id || seen.has(row.id)) continue;
|
||||
seen.add(row.id);
|
||||
merged.push(row);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export 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(", ");
|
||||
}
|
||||
|
||||
export function buildClientEntityId(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `entity-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function buildFeatureEntityPatch(
|
||||
feature: Feature,
|
||||
entityIds: string[],
|
||||
entities: Entity[]
|
||||
): Partial<FeatureProperties> {
|
||||
const primaryEntityId = entityIds[0] || null;
|
||||
const primaryEntity = primaryEntityId
|
||||
? entities.find((entity) => entity.id === primaryEntityId) || null
|
||||
: null;
|
||||
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds, entities) ||
|
||||
feature.properties.type ||
|
||||
null;
|
||||
const entityNames = entityIds
|
||||
.map((id) => entities.find((entity) => entity.id === id)?.name || "")
|
||||
.filter((name) => name.length > 0);
|
||||
|
||||
return {
|
||||
type: nextGeometryType,
|
||||
entity_id: primaryEntityId,
|
||||
entity_ids: entityIds,
|
||||
entity_name: primaryEntity?.name || null,
|
||||
entity_names: entityNames,
|
||||
entity_type_id: primaryEntity?.type_id || null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGeometryTypeFromEntityIds(
|
||||
entityIds: string[],
|
||||
entities: Entity[]
|
||||
): string | null {
|
||||
const primaryEntityId = entityIds[0] || null;
|
||||
if (!primaryEntityId) return null;
|
||||
const primaryEntity = entities.find((entity) => entity.id === primaryEntityId) || null;
|
||||
return primaryEntity?.type_id || null;
|
||||
}
|
||||
|
||||
50
lib/editor/geometry/geometryMetadata.ts
Normal file
50
lib/editor/geometry/geometryMetadata.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Feature, FeatureProperties } from "@/types/geo";
|
||||
import type { GeometryMetaFormState } from "@/lib/editor/session/sessionTypes";
|
||||
import {
|
||||
normalizeFeatureBindingIds,
|
||||
parseBindingInput,
|
||||
} from "@/lib/editor/snapshot/editorSnapshot";
|
||||
|
||||
export type GeometryMetadataPatch = {
|
||||
patch: Partial<FeatureProperties>;
|
||||
formState: GeometryMetaFormState;
|
||||
};
|
||||
|
||||
export function buildGeometryMetadataPatch(form: GeometryMetaFormState): GeometryMetadataPatch {
|
||||
const timeStart = parseOptionalYearInput(form.time_start, "time_start");
|
||||
const timeEnd = parseOptionalYearInput(form.time_end, "time_end");
|
||||
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
|
||||
throw new Error("time_start phải <= time_end.");
|
||||
}
|
||||
|
||||
const bindingIds = parseBindingInput(form.binding);
|
||||
return {
|
||||
patch: {
|
||||
time_start: timeStart,
|
||||
time_end: timeEnd,
|
||||
binding: bindingIds,
|
||||
},
|
||||
formState: {
|
||||
time_start: timeStart != null ? String(timeStart) : "",
|
||||
time_end: timeEnd != null ? String(timeEnd) : "",
|
||||
binding: bindingIds.join(", "),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function formatBindingIdsForDisplay(feature: Feature): string {
|
||||
const bindingIds = normalizeFeatureBindingIds(feature);
|
||||
if (!bindingIds.length) return "Không có";
|
||||
return bindingIds.join(", ");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
267
lib/editor/section/useSectionCommands.ts
Normal file
267
lib/editor/section/useSectionCommands.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { ApiError } from "@/api/http";
|
||||
import {
|
||||
createSection,
|
||||
createSectionCommit,
|
||||
fetchSectionCommits,
|
||||
fetchSections,
|
||||
openSectionEditor,
|
||||
restoreSectionCommit,
|
||||
submitSection,
|
||||
} from "@/api/sections";
|
||||
import { buildEditorSnapshot, normalizeEditorSnapshot } from "@/lib/editor/snapshot/editorSnapshot";
|
||||
import type { Change } from "@/lib/editor/draft/editorTypes";
|
||||
import type { CreatedEntitySummary, PendingEntityCreate } from "@/lib/editor/session/sessionTypes";
|
||||
import type { Feature, FeatureCollection, FeatureId } from "@/types/geo";
|
||||
import type { EditorSnapshot, Section, SectionCommit, SectionState } from "@/types/sections";
|
||||
|
||||
type EditorDraftApi = {
|
||||
draft: FeatureCollection;
|
||||
buildPayload: () => Change[];
|
||||
clearChanges: () => void;
|
||||
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
editor: EditorDraftApi;
|
||||
editorUserId: string;
|
||||
emptyFeatureCollection: FeatureCollection;
|
||||
activeSection: Section | null;
|
||||
sectionState: SectionState | null;
|
||||
selectedSectionId: string;
|
||||
newSectionTitle: string;
|
||||
pendingSaveCount: number;
|
||||
pendingEntityCreates: PendingEntityCreate[];
|
||||
lastSectionSnapshot: EditorSnapshot | null;
|
||||
commitTitle: string;
|
||||
commitNote: string;
|
||||
setActiveSection: Dispatch<SetStateAction<Section | null>>;
|
||||
setSelectedSectionId: Dispatch<SetStateAction<string>>;
|
||||
setSectionState: Dispatch<SetStateAction<SectionState | null>>;
|
||||
setLastSectionSnapshot: Dispatch<SetStateAction<EditorSnapshot | null>>;
|
||||
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
|
||||
setSectionCommits: Dispatch<SetStateAction<SectionCommit[]>>;
|
||||
setPendingEntityCreates: Dispatch<SetStateAction<PendingEntityCreate[]>>;
|
||||
setCreatedEntities: Dispatch<SetStateAction<CreatedEntitySummary[]>>;
|
||||
setSelectedFeatureId: Dispatch<SetStateAction<FeatureId | null>>;
|
||||
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
|
||||
setEntityStatus: Dispatch<SetStateAction<string | null>>;
|
||||
setIsSaving: Dispatch<SetStateAction<boolean>>;
|
||||
setIsSubmitting: Dispatch<SetStateAction<boolean>>;
|
||||
setIsOpeningSection: Dispatch<SetStateAction<boolean>>;
|
||||
setAvailableSections: Dispatch<SetStateAction<Section[]>>;
|
||||
setNewSectionTitle: Dispatch<SetStateAction<string>>;
|
||||
setCommitTitle: Dispatch<SetStateAction<string>>;
|
||||
setCommitNote: Dispatch<SetStateAction<string>>;
|
||||
};
|
||||
|
||||
export function useSectionCommands(options: Options) {
|
||||
const openSectionForEditing = useCallback(async (sectionId: string) => {
|
||||
const editorPayload = await openSectionEditor(sectionId, options.editorUserId);
|
||||
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
|
||||
const commits = await fetchSectionCommits(sectionId);
|
||||
const nextInitialData = snapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
||||
|
||||
options.setActiveSection(editorPayload.section);
|
||||
options.setSelectedSectionId(editorPayload.section.id);
|
||||
options.setSectionState(editorPayload.state);
|
||||
options.setLastSectionSnapshot(snapshot);
|
||||
options.setInitialData(nextInitialData);
|
||||
options.setSectionCommits(commits);
|
||||
options.setPendingEntityCreates([]);
|
||||
options.setCreatedEntities([]);
|
||||
options.setSelectedFeatureId(null);
|
||||
options.setEntityFormStatus(null);
|
||||
}, [options]);
|
||||
|
||||
const commitSection = useCallback(async () => {
|
||||
if (!options.activeSection || !options.sectionState) {
|
||||
options.setEntityStatus("Chưa mở được section editor.");
|
||||
return;
|
||||
}
|
||||
|
||||
const geometryChanges = options.editor.buildPayload();
|
||||
options.setIsSaving(true);
|
||||
options.setEntityStatus(null);
|
||||
try {
|
||||
const snapshot = buildEditorSnapshot({
|
||||
section: options.activeSection,
|
||||
draft: options.editor.draft,
|
||||
changes: geometryChanges,
|
||||
pendingEntities: options.pendingEntityCreates,
|
||||
previousSnapshot: options.lastSectionSnapshot,
|
||||
hasPersistedFeature: options.editor.hasPersistedFeature,
|
||||
});
|
||||
const result = await createSectionCommit(options.activeSection.id, {
|
||||
snapshot,
|
||||
created_by: options.editorUserId,
|
||||
expected_version: options.sectionState.version,
|
||||
expected_head_commit_id: options.sectionState.head_commit_id,
|
||||
title: options.commitTitle.trim() || `Commit ${new Date().toLocaleString()}`,
|
||||
note: options.commitNote.trim() || null,
|
||||
});
|
||||
|
||||
options.setSectionState(result.state);
|
||||
options.setLastSectionSnapshot(snapshot);
|
||||
options.setInitialData(options.editor.draft);
|
||||
options.editor.clearChanges();
|
||||
options.setPendingEntityCreates([]);
|
||||
options.setCreatedEntities([]);
|
||||
options.setCommitTitle("");
|
||||
options.setCommitNote("");
|
||||
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
|
||||
options.setEntityFormStatus(`Đã tạo commit #${result.commit.commit_no}.`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
console.error("Commit failed", err.body);
|
||||
options.setEntityStatus(`Commit thất bại: ${err.body}`);
|
||||
return;
|
||||
}
|
||||
console.error("Commit error", err);
|
||||
options.setEntityStatus("Commit thất bại.");
|
||||
} finally {
|
||||
options.setIsSaving(false);
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
const openSelectedSection = useCallback(async () => {
|
||||
const sectionId = options.selectedSectionId.trim();
|
||||
if (!sectionId) {
|
||||
options.setEntityStatus("Hãy chọn section để mở.");
|
||||
return;
|
||||
}
|
||||
if (options.pendingSaveCount > 0) {
|
||||
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Mở section khác sẽ bỏ các thay đổi này. Tiếp tục?");
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
options.setIsOpeningSection(true);
|
||||
options.setEntityStatus(null);
|
||||
try {
|
||||
await openSectionForEditing(sectionId);
|
||||
options.setEntityStatus("Đã mở section để chỉnh sửa.");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
options.setEntityStatus(`Mở section thất bại: ${err.body}`);
|
||||
} else {
|
||||
options.setEntityStatus("Mở section thất bại.");
|
||||
}
|
||||
} finally {
|
||||
options.setIsOpeningSection(false);
|
||||
}
|
||||
}, [openSectionForEditing, options]);
|
||||
|
||||
const createAndOpenSection = useCallback(async () => {
|
||||
const title = options.newSectionTitle.trim();
|
||||
if (!title) {
|
||||
options.setEntityStatus("Tên section là bắt buộc.");
|
||||
return;
|
||||
}
|
||||
if (options.pendingSaveCount > 0) {
|
||||
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Tạo section mới sẽ bỏ các thay đổi này. Tiếp tục?");
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
options.setIsOpeningSection(true);
|
||||
options.setEntityStatus(null);
|
||||
try {
|
||||
const section = await createSection({
|
||||
title,
|
||||
user_id: options.editorUserId,
|
||||
created_by: options.editorUserId,
|
||||
});
|
||||
const sections = await fetchSections();
|
||||
options.setAvailableSections(sections);
|
||||
options.setNewSectionTitle("");
|
||||
await openSectionForEditing(section.id);
|
||||
options.setEntityStatus("Đã tạo và mở section mới.");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
options.setEntityStatus(`Tạo section thất bại: ${err.body}`);
|
||||
} else {
|
||||
options.setEntityStatus("Tạo section thất bại.");
|
||||
}
|
||||
} finally {
|
||||
options.setIsOpeningSection(false);
|
||||
}
|
||||
}, [openSectionForEditing, options]);
|
||||
|
||||
const submitCurrentSection = useCallback(async () => {
|
||||
if (!options.activeSection || !options.sectionState?.head_commit_id) {
|
||||
options.setEntityStatus("Section hiện tại chưa có head để submit.");
|
||||
return;
|
||||
}
|
||||
if (options.pendingSaveCount > 0) {
|
||||
options.setEntityStatus("Hãy Commit các thay đổi trước khi Submit.");
|
||||
return;
|
||||
}
|
||||
|
||||
options.setIsSubmitting(true);
|
||||
options.setEntityStatus(null);
|
||||
try {
|
||||
const submission = await submitSection(options.activeSection.id, {
|
||||
submitted_by: options.editorUserId,
|
||||
});
|
||||
options.setSectionState((prev) => prev ? { ...prev, status: "submitted" } : prev);
|
||||
options.setEntityStatus(`Đã submit section, submission ${submission.id}.`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
options.setEntityStatus(`Submit thất bại: ${err.body}`);
|
||||
} else {
|
||||
options.setEntityStatus("Submit thất bại.");
|
||||
}
|
||||
} finally {
|
||||
options.setIsSubmitting(false);
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
const restoreCommit = useCallback(async (commitId: string) => {
|
||||
if (!options.activeSection || !options.sectionState) {
|
||||
options.setEntityStatus("Chưa mở được section editor.");
|
||||
return;
|
||||
}
|
||||
if (options.pendingSaveCount > 0) {
|
||||
options.setEntityStatus("Hãy Commit hoặc Undo thay đổi hiện tại trước khi Restore.");
|
||||
return;
|
||||
}
|
||||
|
||||
options.setIsSaving(true);
|
||||
options.setEntityStatus(null);
|
||||
try {
|
||||
const result = await restoreSectionCommit(options.activeSection.id, {
|
||||
commit_id: commitId,
|
||||
created_by: options.editorUserId,
|
||||
expected_version: options.sectionState.version,
|
||||
expected_head_commit_id: options.sectionState.head_commit_id,
|
||||
});
|
||||
const editorPayload = await openSectionEditor(options.activeSection.id, options.editorUserId);
|
||||
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
|
||||
options.setSectionState(result.state);
|
||||
options.setLastSectionSnapshot(snapshot);
|
||||
if (snapshot?.editor_feature_collection) {
|
||||
options.setInitialData(snapshot.editor_feature_collection);
|
||||
}
|
||||
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
|
||||
options.setEntityFormStatus(`Đã restore thành commit #${result.commit.commit_no}.`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
options.setEntityStatus(`Restore thất bại: ${err.body}`);
|
||||
} else {
|
||||
options.setEntityStatus("Restore thất bại.");
|
||||
}
|
||||
} finally {
|
||||
options.setIsSaving(false);
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
return {
|
||||
openSectionForEditing,
|
||||
commitSection,
|
||||
openSelectedSection,
|
||||
createAndOpenSection,
|
||||
submitCurrentSection,
|
||||
restoreCommit,
|
||||
};
|
||||
}
|
||||
|
||||
44
lib/editor/session/sessionTypes.ts
Normal file
44
lib/editor/session/sessionTypes.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { EntityGeometryPreset } from "@/lib/entityTypeOptions";
|
||||
|
||||
export type EditorMode =
|
||||
| "idle"
|
||||
| "draw"
|
||||
| "select"
|
||||
| "add-point"
|
||||
| "add-line"
|
||||
| "add-path"
|
||||
| "add-circle";
|
||||
|
||||
export type TimelineRange = {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
|
||||
export type EntityFormState = {
|
||||
name: string;
|
||||
slug: string;
|
||||
type_id: string;
|
||||
};
|
||||
|
||||
export type GeometryMetaFormState = {
|
||||
time_start: string;
|
||||
time_end: string;
|
||||
binding: string;
|
||||
};
|
||||
|
||||
export type PendingEntityCreate = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string | null;
|
||||
type_id: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export type CreatedEntitySummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
type_id?: string | null;
|
||||
};
|
||||
|
||||
export type GeometryPreset = EntityGeometryPreset;
|
||||
|
||||
20
lib/editor/session/useBackgroundSessionState.ts
Normal file
20
lib/editor/session/useBackgroundSessionState.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BackgroundLayerVisibility,
|
||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||
} from "@/lib/backgroundLayers";
|
||||
|
||||
export function useBackgroundSessionState() {
|
||||
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
||||
);
|
||||
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
||||
|
||||
return {
|
||||
backgroundVisibility,
|
||||
setBackgroundVisibility,
|
||||
isBackgroundVisibilityReady,
|
||||
setIsBackgroundVisibilityReady,
|
||||
};
|
||||
}
|
||||
|
||||
66
lib/editor/session/useEntitySessionState.ts
Normal file
66
lib/editor/session/useEntitySessionState.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useState } from "react";
|
||||
import type { Entity } from "@/types/entities";
|
||||
import type { FeatureId } from "@/types/geo";
|
||||
import { DEFAULT_ENTITY_TYPE_ID } from "@/lib/entityTypeOptions";
|
||||
import type {
|
||||
CreatedEntitySummary,
|
||||
EntityFormState,
|
||||
GeometryMetaFormState,
|
||||
PendingEntityCreate,
|
||||
} from "@/lib/editor/session/sessionTypes";
|
||||
|
||||
export function useEntitySessionState() {
|
||||
const [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
|
||||
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
|
||||
const [createdEntities, setCreatedEntities] = useState<CreatedEntitySummary[]>([]);
|
||||
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
||||
const [selectedFeatureId, setSelectedFeatureId] = useState<FeatureId | null>(null);
|
||||
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
||||
name: "",
|
||||
slug: "",
|
||||
type_id: DEFAULT_ENTITY_TYPE_ID,
|
||||
});
|
||||
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
|
||||
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
|
||||
time_start: "",
|
||||
time_end: "",
|
||||
binding: "",
|
||||
});
|
||||
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
|
||||
const [entityFormStatus, setEntityFormStatus] = useState<string | null>(null);
|
||||
const [entitySearchQuery, setEntitySearchQuery] = useState("");
|
||||
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
|
||||
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
|
||||
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
|
||||
|
||||
return {
|
||||
persistedEntities,
|
||||
setPersistedEntities,
|
||||
pendingEntityCreates,
|
||||
setPendingEntityCreates,
|
||||
createdEntities,
|
||||
setCreatedEntities,
|
||||
entityStatus,
|
||||
setEntityStatus,
|
||||
selectedFeatureId,
|
||||
setSelectedFeatureId,
|
||||
entityForm,
|
||||
setEntityForm,
|
||||
selectedGeometryEntityIds,
|
||||
setSelectedGeometryEntityIds,
|
||||
geometryMetaForm,
|
||||
setGeometryMetaForm,
|
||||
isEntitySubmitting,
|
||||
setIsEntitySubmitting,
|
||||
entityFormStatus,
|
||||
setEntityFormStatus,
|
||||
entitySearchQuery,
|
||||
setEntitySearchQuery,
|
||||
entitySearchResults,
|
||||
setEntitySearchResults,
|
||||
selectedSearchEntityId,
|
||||
setSelectedSearchEntityId,
|
||||
isEntitySearchLoading,
|
||||
setIsEntitySearchLoading,
|
||||
};
|
||||
}
|
||||
74
lib/editor/session/useSectionSessionState.ts
Normal file
74
lib/editor/session/useSectionSessionState.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { EditorSnapshot, Section, SectionCommit, SectionState } from "@/types/sections";
|
||||
|
||||
type Options = {
|
||||
defaultEditorUserId: string;
|
||||
};
|
||||
|
||||
type SectionTask = "idle" | "saving" | "submitting" | "opening-section";
|
||||
|
||||
export function useSectionSessionState(options: Options) {
|
||||
const [sectionTask, setSectionTask] = useState<SectionTask>("idle");
|
||||
const setTaskFlag = useCallback((task: Exclude<SectionTask, "idle">, next: SetStateAction<boolean>) => {
|
||||
setSectionTask((prev) => {
|
||||
const currentValue = prev === task;
|
||||
const nextValue = typeof next === "function" ? next(currentValue) : next;
|
||||
if (nextValue) return task;
|
||||
return prev === task ? "idle" : prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isSaving = sectionTask === "saving";
|
||||
const isSubmitting = sectionTask === "submitting";
|
||||
const isOpeningSection = sectionTask === "opening-section";
|
||||
const setIsSaving: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
|
||||
setTaskFlag("saving", next);
|
||||
}, [setTaskFlag]);
|
||||
const setIsSubmitting: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
|
||||
setTaskFlag("submitting", next);
|
||||
}, [setTaskFlag]);
|
||||
const setIsOpeningSection: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
|
||||
setTaskFlag("opening-section", next);
|
||||
}, [setTaskFlag]);
|
||||
|
||||
const [availableSections, setAvailableSections] = useState<Section[]>([]);
|
||||
const [selectedSectionId, setSelectedSectionId] = useState("");
|
||||
const [newSectionTitle, setNewSectionTitle] = useState("");
|
||||
const [commitTitle, setCommitTitle] = useState("");
|
||||
const [commitNote, setCommitNote] = useState("");
|
||||
const [editorUserIdInput, setEditorUserIdInput] = useState(options.defaultEditorUserId);
|
||||
const [activeSection, setActiveSection] = useState<Section | null>(null);
|
||||
const [sectionState, setSectionState] = useState<SectionState | null>(null);
|
||||
const [sectionCommits, setSectionCommits] = useState<SectionCommit[]>([]);
|
||||
const [lastSectionSnapshot, setLastSectionSnapshot] = useState<EditorSnapshot | null>(null);
|
||||
|
||||
return {
|
||||
isSaving,
|
||||
setIsSaving,
|
||||
isSubmitting,
|
||||
setIsSubmitting,
|
||||
isOpeningSection,
|
||||
setIsOpeningSection,
|
||||
availableSections,
|
||||
setAvailableSections,
|
||||
selectedSectionId,
|
||||
setSelectedSectionId,
|
||||
newSectionTitle,
|
||||
setNewSectionTitle,
|
||||
commitTitle,
|
||||
setCommitTitle,
|
||||
commitNote,
|
||||
setCommitNote,
|
||||
editorUserIdInput,
|
||||
setEditorUserIdInput,
|
||||
activeSection,
|
||||
setActiveSection,
|
||||
sectionState,
|
||||
setSectionState,
|
||||
sectionCommits,
|
||||
setSectionCommits,
|
||||
lastSectionSnapshot,
|
||||
setLastSectionSnapshot,
|
||||
};
|
||||
}
|
||||
45
lib/editor/session/useTimelineState.ts
Normal file
45
lib/editor/session/useTimelineState.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useState } from "react";
|
||||
import type { TimelineRange } from "@/lib/editor/session/sessionTypes";
|
||||
|
||||
type Options = {
|
||||
currentYear: number;
|
||||
fallbackTimelineRange: TimelineRange;
|
||||
};
|
||||
|
||||
export function useTimelineState(options: Options) {
|
||||
const [timelineYear, setTimelineYear] = useState<number>(() =>
|
||||
clampYearValue(
|
||||
options.currentYear,
|
||||
options.fallbackTimelineRange.min,
|
||||
options.fallbackTimelineRange.max
|
||||
)
|
||||
);
|
||||
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
|
||||
clampYearValue(
|
||||
options.currentYear,
|
||||
options.fallbackTimelineRange.min,
|
||||
options.fallbackTimelineRange.max
|
||||
)
|
||||
);
|
||||
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
||||
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
||||
|
||||
return {
|
||||
timelineYear,
|
||||
setTimelineYear,
|
||||
timelineDraftYear,
|
||||
setTimelineDraftYear,
|
||||
isTimelineLoading,
|
||||
setIsTimelineLoading,
|
||||
timelineStatus,
|
||||
setTimelineStatus,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
254
lib/editor/snapshot/editorSnapshot.ts
Normal file
254
lib/editor/snapshot/editorSnapshot.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { DEFAULT_ENTITY_TYPE_ID } from "@/lib/entityTypeOptions";
|
||||
import type { Change } from "@/lib/editor/draft/editorTypes";
|
||||
import type { PendingEntityCreate } from "@/lib/editor/session/sessionTypes";
|
||||
import type { EntitySnapshot } from "@/types/entities";
|
||||
import type { Feature, FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/types/geo";
|
||||
import type { EditorSnapshot, Section } from "@/types/sections";
|
||||
|
||||
export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const snapshot = raw as EditorSnapshot;
|
||||
if (
|
||||
snapshot.editor_feature_collection &&
|
||||
snapshot.editor_feature_collection.type === "FeatureCollection" &&
|
||||
Array.isArray(snapshot.editor_feature_collection.features)
|
||||
) {
|
||||
return snapshot;
|
||||
}
|
||||
return {
|
||||
...snapshot,
|
||||
editor_feature_collection: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEditorSnapshot(options: {
|
||||
section: Section;
|
||||
draft: FeatureCollection;
|
||||
changes: Change[];
|
||||
pendingEntities: PendingEntityCreate[];
|
||||
previousSnapshot: EditorSnapshot | null;
|
||||
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
||||
}): EditorSnapshot {
|
||||
const changedIds = new Set(options.changes.map((change) =>
|
||||
String(change.action === "create" ? change.feature.properties.id : change.id)
|
||||
));
|
||||
const deletedIds = new Set(
|
||||
options.changes
|
||||
.filter((change): change is Extract<Change, { action: "delete" }> => change.action === "delete")
|
||||
.map((change) => String(change.id))
|
||||
);
|
||||
const currentDraftIds = new Set(options.draft.features.map((feature) => String(feature.properties.id)));
|
||||
const previousFeatures = new globalThis.Map<string, Feature>();
|
||||
for (const feature of options.previousSnapshot?.editor_feature_collection?.features || []) {
|
||||
previousFeatures.set(String(feature.properties.id), feature);
|
||||
if (!currentDraftIds.has(String(feature.properties.id))) {
|
||||
deletedIds.add(String(feature.properties.id));
|
||||
}
|
||||
}
|
||||
|
||||
const previousGeometryOps = new globalThis.Map<string, GeometrySnapshot["operation"]>();
|
||||
for (const item of options.previousSnapshot?.geometries || []) {
|
||||
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
|
||||
const operation = item.operation;
|
||||
if (id && operation) previousGeometryOps.set(id, operation);
|
||||
}
|
||||
|
||||
const pendingEntityIds = new Set(options.pendingEntities.map((entity) => entity.id));
|
||||
const entityRows = new globalThis.Map<string, EntitySnapshot>();
|
||||
for (const item of options.previousSnapshot?.entities || []) {
|
||||
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
|
||||
if (id) entityRows.set(id, { ...item });
|
||||
}
|
||||
for (const entity of options.pendingEntities) {
|
||||
entityRows.set(entity.id, {
|
||||
id: entity.id,
|
||||
operation: "create",
|
||||
name: entity.name,
|
||||
slug: entity.slug,
|
||||
description: null,
|
||||
type_id: entity.type_id,
|
||||
status: entity.status,
|
||||
is_deleted: 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const feature of options.draft.features) {
|
||||
for (const entityId of normalizeFeatureEntityIds(feature)) {
|
||||
if (entityRows.has(entityId)) continue;
|
||||
entityRows.set(entityId, {
|
||||
id: entityId,
|
||||
operation: "reference",
|
||||
name: feature.properties.entity_names?.[0] || feature.properties.entity_name || entityId,
|
||||
slug: null,
|
||||
description: null,
|
||||
type_id: feature.properties.entity_type_id || feature.properties.type || DEFAULT_ENTITY_TYPE_ID,
|
||||
status: 1,
|
||||
is_deleted: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const geometries: GeometrySnapshot[] = options.draft.features.map((feature) => {
|
||||
const id = String(feature.properties.id);
|
||||
const previousOperation = previousGeometryOps.get(id);
|
||||
const previousFeature = previousFeatures.get(id);
|
||||
const changedFromPreviousSnapshot = previousFeature
|
||||
? JSON.stringify(previousFeature) !== JSON.stringify(feature)
|
||||
: false;
|
||||
const operation: GeometrySnapshot["operation"] = previousOperation === "create"
|
||||
? "create"
|
||||
: !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id))
|
||||
? "create"
|
||||
: changedIds.has(id) || changedFromPreviousSnapshot
|
||||
? "update"
|
||||
: "reference";
|
||||
const bbox = getFeatureBBox(feature);
|
||||
return {
|
||||
id,
|
||||
operation,
|
||||
type: feature.properties.type || getDefaultTypeIdForFeature(feature),
|
||||
draw_geometry: feature.geometry,
|
||||
binding: normalizeFeatureBindingIds(feature),
|
||||
time_start: feature.properties.time_start ?? null,
|
||||
time_end: feature.properties.time_end ?? null,
|
||||
bbox: bbox
|
||||
? {
|
||||
min_lng: bbox.minLng,
|
||||
min_lat: bbox.minLat,
|
||||
max_lng: bbox.maxLng,
|
||||
max_lat: bbox.maxLat,
|
||||
}
|
||||
: null,
|
||||
is_deleted: 0,
|
||||
};
|
||||
});
|
||||
|
||||
for (const id of deletedIds) {
|
||||
geometries.push({
|
||||
id,
|
||||
operation: "delete",
|
||||
is_deleted: 1,
|
||||
});
|
||||
}
|
||||
|
||||
const linkScopes: LinkScopeSnapshot[] = options.draft.features
|
||||
.map((feature) => ({
|
||||
geometry_id: String(feature.properties.id),
|
||||
operation: "replace" as const,
|
||||
entity_ids: normalizeFeatureEntityIds(feature),
|
||||
}))
|
||||
.filter((scope) => scope.entity_ids.length > 0);
|
||||
|
||||
return {
|
||||
schema_version: 1,
|
||||
section: {
|
||||
id: options.section.id,
|
||||
title: options.section.title,
|
||||
},
|
||||
editor_feature_collection: JSON.parse(JSON.stringify(options.draft)) as FeatureCollection,
|
||||
entities: Array.from(entityRows.values()).map((entity) => {
|
||||
const id = String(entity.id || "");
|
||||
if (pendingEntityIds.has(id)) return entity;
|
||||
return entity;
|
||||
}),
|
||||
geometries,
|
||||
link_scopes: linkScopes,
|
||||
};
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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 [];
|
||||
}
|
||||
|
||||
export 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));
|
||||
}
|
||||
|
||||
export function parseBindingInput(raw: string): string[] {
|
||||
if (!raw.trim().length) return [];
|
||||
return uniqueEntityIds(
|
||||
raw
|
||||
.split(/[,\n]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function uniqueEntityIds(ids: string[]): string[] {
|
||||
const deduped: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const rawId of ids) {
|
||||
const id = rawId.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push(id);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function getFeatureBBox(feature: Feature): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null {
|
||||
const points = collectCoordinatePairs(feature.geometry.coordinates);
|
||||
if (!points.length) return null;
|
||||
let minLng = Number.POSITIVE_INFINITY;
|
||||
let minLat = Number.POSITIVE_INFINITY;
|
||||
let maxLng = Number.NEGATIVE_INFINITY;
|
||||
let maxLat = Number.NEGATIVE_INFINITY;
|
||||
for (const [lng, lat] of points) {
|
||||
minLng = Math.min(minLng, lng);
|
||||
minLat = Math.min(minLat, lat);
|
||||
maxLng = Math.max(maxLng, lng);
|
||||
maxLat = Math.max(maxLat, lat);
|
||||
}
|
||||
return { minLng, minLat, maxLng, maxLat };
|
||||
}
|
||||
|
||||
function collectCoordinatePairs(value: unknown): Array<[number, number]> {
|
||||
if (!Array.isArray(value)) return [];
|
||||
if (
|
||||
value.length >= 2 &&
|
||||
typeof value[0] === "number" &&
|
||||
typeof value[1] === "number" &&
|
||||
Number.isFinite(value[0]) &&
|
||||
Number.isFinite(value[1])
|
||||
) {
|
||||
return [[value[0], value[1]]];
|
||||
}
|
||||
return value.flatMap((item) => collectCoordinatePairs(item));
|
||||
}
|
||||
@@ -148,7 +148,7 @@ export function initCircle(
|
||||
map.on("mouseup", onMouseUp);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
const cleanup = () => {
|
||||
map.off("mousedown", onMouseDown);
|
||||
map.off("mousemove", onMouseMove);
|
||||
map.off("mouseup", onMouseUp);
|
||||
@@ -158,6 +158,11 @@ export function initCircle(
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
cancel: resetDrawingState,
|
||||
};
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -11,6 +11,18 @@ export function initDrawing(
|
||||
) {
|
||||
let coords: [number, number][] = [];
|
||||
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
});
|
||||
};
|
||||
|
||||
const cancelDrawing = () => {
|
||||
coords = [];
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
// Đó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;
|
||||
@@ -71,19 +83,30 @@ export function initDrawing(
|
||||
};
|
||||
|
||||
onComplete(geometry);
|
||||
|
||||
coords = [];
|
||||
|
||||
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
});
|
||||
cancelDrawing();
|
||||
}
|
||||
|
||||
// Lắng nghe Enter để chốt polygon.
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (getMode() !== "draw") return;
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
finishDrawing();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelDrawing();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
coords = coords.slice(0, -1);
|
||||
if (coords.length) {
|
||||
update(coords);
|
||||
} else {
|
||||
clearPreview();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,9 +114,15 @@ export function initDrawing(
|
||||
map.on("mousemove", onMove);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
const cleanup = () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
cancelDrawing();
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
cancel: cancelDrawing,
|
||||
};
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export function initLine(
|
||||
map.on("mousemove", onMove);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
const cleanup = () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
@@ -133,4 +133,9 @@ export function initLine(
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
cancel: cancelLine,
|
||||
};
|
||||
}
|
||||
@@ -126,13 +126,18 @@ export function initPath(
|
||||
map.on("mousemove", onMove);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
const cleanup = () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
clearPreview();
|
||||
cancelPath();
|
||||
if (map.getCanvas().style.cursor === "crosshair") {
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
cancel: cancelPath,
|
||||
};
|
||||
}
|
||||
@@ -31,11 +31,13 @@ export function initSelect(
|
||||
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
|
||||
|
||||
// Bỏ highlight feature-state của toàn bộ đối tượng đang chọn.
|
||||
function clearSelection() {
|
||||
function clearSelection(emit = true) {
|
||||
if (!selectedIds.size) return;
|
||||
selectedIds.forEach((id) => setSelectionStateForId(id, false));
|
||||
selectedIds.clear();
|
||||
onSelectId?.(null);
|
||||
if (emit) {
|
||||
onSelectId?.(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn.
|
||||
@@ -144,15 +146,21 @@ export function initSelect(
|
||||
map.on("contextmenu", onRightClick);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const cleanup = () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
if (hasContextActions) {
|
||||
map.off("contextmenu", onRightClick);
|
||||
}
|
||||
clearSelection(false);
|
||||
hideContextMenu();
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
clearSelection,
|
||||
};
|
||||
|
||||
// Ẩn và dọn dẹp context menu hiện tại.
|
||||
function hideContextMenu() {
|
||||
if (contextMenu) {
|
||||
49
lib/useEditorSessionState.ts
Normal file
49
lib/useEditorSessionState.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import type { FeatureCollection } from "@/types/geo";
|
||||
import { useBackgroundSessionState } from "@/lib/editor/session/useBackgroundSessionState";
|
||||
import { useEntitySessionState } from "@/lib/editor/session/useEntitySessionState";
|
||||
import { useSectionSessionState } from "@/lib/editor/session/useSectionSessionState";
|
||||
import { useTimelineState } from "@/lib/editor/session/useTimelineState";
|
||||
import type { EditorMode, TimelineRange } from "@/lib/editor/session/sessionTypes";
|
||||
|
||||
export type {
|
||||
CreatedEntitySummary,
|
||||
EditorMode,
|
||||
EntityFormState,
|
||||
GeometryMetaFormState,
|
||||
PendingEntityCreate,
|
||||
TimelineRange,
|
||||
} from "@/lib/editor/session/sessionTypes";
|
||||
|
||||
type Options = {
|
||||
emptyFeatureCollection: FeatureCollection;
|
||||
defaultEditorUserId: string;
|
||||
fallbackTimelineRange: TimelineRange;
|
||||
currentYear: number;
|
||||
};
|
||||
|
||||
export function useEditorSessionState(options: Options) {
|
||||
const [mode, setMode] = useState<EditorMode>("idle");
|
||||
const [initialData, setInitialData] = useState<FeatureCollection>(options.emptyFeatureCollection);
|
||||
|
||||
const section = useSectionSessionState({
|
||||
defaultEditorUserId: options.defaultEditorUserId,
|
||||
});
|
||||
const entity = useEntitySessionState();
|
||||
const timeline = useTimelineState({
|
||||
currentYear: options.currentYear,
|
||||
fallbackTimelineRange: options.fallbackTimelineRange,
|
||||
});
|
||||
const background = useBackgroundSessionState();
|
||||
|
||||
return {
|
||||
mode,
|
||||
setMode,
|
||||
initialData,
|
||||
setInitialData,
|
||||
...section,
|
||||
...entity,
|
||||
...timeline,
|
||||
...background,
|
||||
};
|
||||
}
|
||||
@@ -1,166 +1,89 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type {
|
||||
Feature,
|
||||
FeatureCollection,
|
||||
FeatureProperties,
|
||||
Geometry,
|
||||
} from "@/types/geo";
|
||||
import { buildInitialMap, deepClone, diffDraftToInitial } from "@/lib/editor/draft/draftDiff";
|
||||
import { useDraftState } from "@/lib/editor/draft/useDraftState";
|
||||
import { useUndoStack } from "@/lib/editor/draft/useUndoStack";
|
||||
import type { Change, UndoAction } from "@/lib/editor/draft/editorTypes";
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
function featureEquals(a: Feature | undefined, b: Feature | undefined): boolean {
|
||||
if (!a || !b) return false;
|
||||
return JSON.stringify(a.geometry) === JSON.stringify(b.geometry) &&
|
||||
JSON.stringify(a.properties) === JSON.stringify(b.properties);
|
||||
}
|
||||
|
||||
// Tạo baseline map id -> geometry từ dữ liệu đã lưu.
|
||||
function buildInitialMap(fc: FeatureCollection) {
|
||||
const map = new Map<FeatureProperties["id"], Feature>();
|
||||
for (const f of fc.features) {
|
||||
map.set(f.properties.id, deepClone(f));
|
||||
}
|
||||
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"], Feature>
|
||||
) {
|
||||
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 initialFeature = initialMap.get(id);
|
||||
if (!initialFeature) {
|
||||
next.set(id, { action: "create", feature: deepClone(f) });
|
||||
} else if (!featureEquals(initialFeature, f)) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/types/geo";
|
||||
export type { Change, UndoAction } from "@/lib/editor/draft/editorTypes";
|
||||
|
||||
// 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[]>([]);
|
||||
const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData);
|
||||
|
||||
// baseline to know what is "saved" state
|
||||
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
|
||||
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 applyUndoAction = useCallback((action: UndoAction): boolean => {
|
||||
switch (action.type) {
|
||||
case "create": {
|
||||
commitDraft({
|
||||
...draftRef.current,
|
||||
features: draftRef.current.features.filter((feature) =>
|
||||
feature.properties.id !== action.id
|
||||
),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "delete": {
|
||||
const feature = deepClone(action.feature);
|
||||
commitDraft({
|
||||
...draftRef.current,
|
||||
features: [...draftRef.current.features, feature],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "update": {
|
||||
const idx = draftRef.current.features.findIndex((feature) =>
|
||||
feature.properties.id === action.id
|
||||
);
|
||||
if (idx === -1) return false;
|
||||
const nextFeatures = [...draftRef.current.features];
|
||||
nextFeatures[idx] = {
|
||||
...nextFeatures[idx],
|
||||
geometry: deepClone(action.prevGeometry),
|
||||
};
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
return true;
|
||||
}
|
||||
case "properties": {
|
||||
const idx = draftRef.current.features.findIndex((feature) =>
|
||||
feature.properties.id === action.id
|
||||
);
|
||||
if (idx === -1) return false;
|
||||
const nextFeatures = [...draftRef.current.features];
|
||||
nextFeatures[idx] = {
|
||||
...nextFeatures[idx],
|
||||
properties: deepClone(action.prevProperties),
|
||||
};
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [commitDraft, draftRef]);
|
||||
|
||||
const { undoStack, pushUndo, undo, clearUndo } = useUndoStack({ applyUndoAction });
|
||||
|
||||
useEffect(() => {
|
||||
resetDraft(deepClone(initialData));
|
||||
clearUndo();
|
||||
initialMapRef.current = buildInitialMap(initialData);
|
||||
setBaselineVersion((version) => version + 1);
|
||||
}, [clearUndo, initialData, resetDraft]);
|
||||
|
||||
const changes = useMemo(() => {
|
||||
const baseline = initialMapRef.current;
|
||||
return diffDraftToInitial(draft, baseline);
|
||||
@@ -168,20 +91,6 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
}, [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({
|
||||
@@ -191,15 +100,15 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
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);
|
||||
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
||||
if (idx === -1) return;
|
||||
|
||||
const nextFeatures = [...draftRef.current.features];
|
||||
const prevProperties = deepClone(nextFeatures[idx].properties);
|
||||
nextFeatures[idx] = {
|
||||
...nextFeatures[idx],
|
||||
properties: {
|
||||
@@ -207,101 +116,53 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
...deepClone(patch),
|
||||
},
|
||||
};
|
||||
|
||||
if (JSON.stringify(prevProperties) === JSON.stringify(nextFeatures[idx].properties)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pushUndo({ type: "properties", id, prevProperties });
|
||||
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 idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
||||
if (idx === -1) return;
|
||||
|
||||
const prevFeature = draftRef.current.features[idx];
|
||||
const prevGeometry = prevFeature.geometry;
|
||||
|
||||
const updatedFeature = {
|
||||
const prevGeometry = deepClone(prevFeature.geometry);
|
||||
const nextFeatures = [...draftRef.current.features];
|
||||
nextFeatures[idx] = {
|
||||
...prevFeature,
|
||||
geometry: deepClone(newGeometry),
|
||||
};
|
||||
|
||||
pushUndo({ type: "update", id, prevGeometry: deepClone(prevGeometry) });
|
||||
|
||||
const nextFeatures = [...draftRef.current.features];
|
||||
nextFeatures[idx] = updatedFeature;
|
||||
pushUndo({ type: "update", id, prevGeometry });
|
||||
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);
|
||||
const idx = draftRef.current.features.findIndex((feature) => feature.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);
|
||||
|
||||
pushUndo({ type: "delete", feature: deepClone(feature) });
|
||||
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));
|
||||
return Array.from(changes.values()).map((change) => deepClone(change));
|
||||
}
|
||||
|
||||
// Xóa thay đổi đang chờ sau khi lưu thành công.
|
||||
function clearChanges() {
|
||||
setUndoStack([]);
|
||||
clearUndo();
|
||||
initialMapRef.current = buildInitialMap(draftRef.current);
|
||||
setBaselineVersion((v) => v + 1);
|
||||
setBaselineVersion((version) => version + 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user