From 3ca7098831800e3633fdd4726f1a6b48f16fc865 Mon Sep 17 00:00:00 2001 From: taDuc Date: Mon, 20 Apr 2026 23:27:38 +0700 Subject: [PATCH] refactor --- api/entities.ts | 13 +- api/geometries.ts | 12 +- api/http.ts | 9 +- api/sections.ts | 105 +- app/editor/page.tsx | 1186 +++-------------- app/page.tsx | 177 +-- components/Editor.tsx | 2 + components/Map.tsx | 69 +- components/TimelineBar.tsx | 220 +-- .../background/backgroundVisibilityStorage.ts | 58 + lib/editor/draft/draftDiff.ts | 55 + lib/editor/draft/editorTypes.ts | 15 + lib/editor/draft/useDraftState.ts | 29 + lib/editor/draft/useUndoStack.ts | 80 ++ lib/editor/entity/entityBinding.ts | 109 ++ lib/editor/geometry/geometryMetadata.ts | 50 + lib/editor/section/useSectionCommands.ts | 267 ++++ lib/editor/session/sessionTypes.ts | 44 + .../session/useBackgroundSessionState.ts | 20 + lib/editor/session/useEntitySessionState.ts | 66 + lib/editor/session/useSectionSessionState.ts | 74 + lib/editor/session/useTimelineState.ts | 45 + lib/editor/snapshot/editorSnapshot.ts | 254 ++++ lib/{ => engine}/circleEngine.ts | 7 +- lib/{ => engine}/drawingEngine.ts | 45 +- lib/{ => engine}/editingEngine.ts | 0 lib/{ => engine}/lineEngine.ts | 7 +- lib/{ => engine}/pathEngine.ts | 9 +- lib/{ => engine}/pointEngine.ts | 0 lib/{ => engine}/selectingEngine.ts | 14 +- lib/useEditorSessionState.ts | 49 + lib/useEditorState.ts | 329 ++--- types/api.ts | 15 + types/entities.ts | 26 + types/geo.ts | 70 + types/sections.ts | 104 ++ 36 files changed, 1939 insertions(+), 1695 deletions(-) create mode 100644 lib/editor/background/backgroundVisibilityStorage.ts create mode 100644 lib/editor/draft/draftDiff.ts create mode 100644 lib/editor/draft/editorTypes.ts create mode 100644 lib/editor/draft/useDraftState.ts create mode 100644 lib/editor/draft/useUndoStack.ts create mode 100644 lib/editor/entity/entityBinding.ts create mode 100644 lib/editor/geometry/geometryMetadata.ts create mode 100644 lib/editor/section/useSectionCommands.ts create mode 100644 lib/editor/session/sessionTypes.ts create mode 100644 lib/editor/session/useBackgroundSessionState.ts create mode 100644 lib/editor/session/useEntitySessionState.ts create mode 100644 lib/editor/session/useSectionSessionState.ts create mode 100644 lib/editor/session/useTimelineState.ts create mode 100644 lib/editor/snapshot/editorSnapshot.ts rename lib/{ => engine}/circleEngine.ts (98%) rename lib/{ => engine}/drawingEngine.ts (80%) rename lib/{ => engine}/editingEngine.ts (100%) rename lib/{ => engine}/lineEngine.ts (97%) rename lib/{ => engine}/pathEngine.ts (97%) rename lib/{ => engine}/pointEngine.ts (100%) rename lib/{ => engine}/selectingEngine.ts (97%) create mode 100644 lib/useEditorSessionState.ts create mode 100644 types/api.ts create mode 100644 types/entities.ts create mode 100644 types/geo.ts create mode 100644 types/sections.ts diff --git a/api/entities.ts b/api/entities.ts index 3eb6c1b..8f251ab 100644 --- a/api/entities.ts +++ b/api/entities.ts @@ -1,17 +1,8 @@ import { API_ENDPOINTS } from "@/api/config"; import { requestJson } from "@/api/http"; +import type { Entity } from "@/types/entities"; -export type Entity = { - id: string; - name: string; - slug?: string | null; - description?: string | null; - type_id?: string | null; - status?: number | null; - geometry_count?: number; - created_at?: string; - updated_at?: string; -}; +export type { Entity } from "@/types/entities"; export async function fetchEntities(query?: { q?: string }): Promise { const params = new URLSearchParams(); diff --git a/api/geometries.ts b/api/geometries.ts index d668925..bbcdf61 100644 --- a/api/geometries.ts +++ b/api/geometries.ts @@ -1,15 +1,9 @@ import { API_ENDPOINTS } from "@/api/config"; import { requestJson } from "@/api/http"; -import { FeatureCollection } from "@/lib/useEditorState"; +import type { GeometriesBBoxQuery } from "@/types/api"; +import type { FeatureCollection } from "@/types/geo"; -export type GeometriesBBoxQuery = { - minLng: number; - minLat: number; - maxLng: number; - maxLat: number; - time?: number; - entity_id?: string; -}; +export type { GeometriesBBoxQuery } from "@/types/api"; function buildBBoxQueryString(params: GeometriesBBoxQuery): string { const query = new URLSearchParams({ diff --git a/api/http.ts b/api/http.ts index 73172d2..67b43d7 100644 --- a/api/http.ts +++ b/api/http.ts @@ -1,3 +1,5 @@ +import type { ApiEnvelope } from "@/types/api"; + export class ApiError extends Error { status: number; body: string; @@ -12,13 +14,6 @@ export class ApiError extends Error { } } -type ApiEnvelope = { - status: "success" | "error" | string; - data: T; - message: string; - errors: unknown[]; -}; - export async function requestJson(input: RequestInfo | URL, init?: RequestInit): Promise { const res = await fetch(input, init); const payload = await parseJsonResponse(res); diff --git a/api/sections.ts b/api/sections.ts index 2a8d165..5fb98aa 100644 --- a/api/sections.ts +++ b/api/sections.ts @@ -1,81 +1,26 @@ import { API_ENDPOINTS } from "@/api/config"; import { jsonRequestInit, requestJson } from "@/api/http"; +import type { + CreateCommitInput, + CreateSectionInput, + EditorLoadResponse, + RestoreCommitInput, + Section, + SectionCommit, + SectionState, + SectionSubmission, +} from "@/types/sections"; -export type SectionState = { - section_id?: string; - status: "editing" | "submitted" | "approved" | "rejected"; - head_commit_id: string | null; - version: number; - locked_by: string | null; - locked_at: string | null; - lock_expires_at: string | null; - updated_at?: string; -}; - -export type Section = { - id: string; - title: string; - description: string | null; - user_id: string | null; - created_by: string | null; - created_at: string; - updated_at: string; - state: Omit; -}; - -export type SectionCommit = { - id: string; - section_id: string; - parent_commit_id: string | null; - commit_no: number; - kind: "manual" | "restore"; - restored_from_commit_id: string | null; - created_by: string; - created_at: string; - title: string | null; - note: string | null; - snapshot_hash: string | null; - snapshot?: unknown; -}; - -export type SectionSubmission = { - id: string; - section_id: string; - commit_id: string; - submitted_by: string; - submitted_at: string; - status: "pending" | "approved" | "rejected" | "conflicted"; - reviewed_by: string | null; - reviewed_at: string | null; - review_note: string | null; - snapshot_hash: string | null; - snapshot?: unknown; -}; - -export type EditorLoadResponse = { - section: Section; - state: SectionState; - commit: SectionCommit | null; - snapshot: unknown; -}; - -export type CreateSectionInput = { - id?: string; - title: string; - description?: string | null; - user_id?: string; - created_by?: string; -}; - -export type CreateCommitInput = { - snapshot: unknown; - created_by?: string; - user_id?: string; - expected_version?: number; - expected_head_commit_id?: string | null; - title?: string | null; - note?: string | null; -}; +export type { + CreateCommitInput, + CreateSectionInput, + EditorLoadResponse, + RestoreCommitInput, + Section, + SectionCommit, + SectionState, + SectionSubmission, +} from "@/types/sections"; export async function fetchSections(): Promise { return requestJson(API_ENDPOINTS.sections); @@ -126,15 +71,7 @@ export async function fetchSectionCommits( export async function restoreSectionCommit( sectionId: string, - input: { - commit_id: string; - created_by?: string; - user_id?: string; - expected_version?: number; - expected_head_commit_id?: string | null; - title?: string | null; - note?: string | null; - } + input: RestoreCommitInput ): Promise<{ commit: SectionCommit; state: SectionState }> { return requestJson<{ commit: SectionCommit; state: SectionState }>( sectionUrl(sectionId, "restore"), diff --git a/app/editor/page.tsx b/app/editor/page.tsx index 43a4b10..a9d1f65 100644 --- a/app/editor/page.tsx +++ b/app/editor/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef } from "react"; import Map from "@/components/Map"; import Editor from "@/components/Editor"; import BackgroundLayersPanel from "@/components/BackgroundLayersPanel"; @@ -10,25 +10,15 @@ import { Entity, fetchEntities, searchEntitiesByName } from "@/api/entities"; import { ApiError } from "@/api/http"; import { fetchGeometriesByBBox } from "@/api/geometries"; import { - createSection, - createSectionCommit, fetchSections, - fetchSectionCommits, - openSectionEditor, - restoreSectionCommit, - Section, SectionCommit, - SectionState, - submitSection, } from "@/api/sections"; import { - Change, Feature, FeatureCollection, useEditorState, } from "@/lib/useEditorState"; import { - BACKGROUND_LAYER_OPTIONS, BackgroundLayerId, BackgroundLayerVisibility, DEFAULT_BACKGROUND_LAYER_VISIBILITY, @@ -40,6 +30,34 @@ import { EntityTypeGroupId, findEntityTypeOption, } from "@/lib/entityTypeOptions"; +import { + EntityFormState, + PendingEntityCreate, + TimelineRange, + useEditorSessionState, +} from "@/lib/useEditorSessionState"; +import { + getDefaultTypeIdForFeature, + normalizeFeatureBindingIds, + normalizeFeatureEntityIds, + uniqueEntityIds, +} from "@/lib/editor/snapshot/editorSnapshot"; +import { + buildClientEntityId, + buildFeatureEntityPatch, + formatEntityNamesForDisplay, + mergeEntitiesWithPending, + mergeEntitySearchResults, +} from "@/lib/editor/entity/entityBinding"; +import { + buildGeometryMetadataPatch, + formatBindingIdsForDisplay, +} from "@/lib/editor/geometry/geometryMetadata"; +import { + loadBackgroundLayerVisibilityFromStorage, + persistBackgroundLayerVisibility, +} from "@/lib/editor/background/backgroundVisibilityStorage"; +import { useSectionCommands } from "@/lib/editor/section/useSectionCommands"; const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; const WORLD_BBOX = { @@ -50,113 +68,92 @@ const WORLD_BBOX = { } as const; const CURRENT_YEAR = new Date().getUTCFullYear(); const FALLBACK_TIMELINE_RANGE: TimelineRange = { - min: CURRENT_YEAR - 5000, - max: CURRENT_YEAR + 100, + min: -2000, + max: 2000, }; const TIMELINE_DEBOUNCE_MS = 180; -const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1"; -const DEFAULT_SECTION_TITLE = "Main editor section"; const DEFAULT_EDITOR_USER_ID = "local-editor"; -type TimelineRange = { - min: number; - max: number; -}; - -type EntityFormState = { - name: string; - slug: string; - type_id: string; -}; - -type GeometryMetaFormState = { - time_start: string; - time_end: string; - binding: string; -}; - -type PendingEntityCreate = { - id: string; - name: string; - slug: string | null; - type_id: string; - status: number; -}; - -type EditorSnapshot = { - schema_version: number; - section: { - id: string; - title: string; - }; - editor_feature_collection?: FeatureCollection; - entities?: Array>; - geometries?: Array>; - link_scopes?: Array>; -}; - export default function Page() { - const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle">("idle"); - const [initialData, setInitialData] = useState(EMPTY_FC); - const [isSaving, setIsSaving] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [isOpeningSection, setIsOpeningSection] = useState(false); - const [availableSections, setAvailableSections] = useState([]); - const [selectedSectionId, setSelectedSectionId] = useState(""); - const [newSectionTitle, setNewSectionTitle] = useState(""); - const [commitTitle, setCommitTitle] = useState(""); - const [commitNote, setCommitNote] = useState(""); - const [editorUserIdInput, setEditorUserIdInput] = useState(DEFAULT_EDITOR_USER_ID); - const [activeSection, setActiveSection] = useState
(null); - const [sectionState, setSectionState] = useState(null); - const [sectionCommits, setSectionCommits] = useState([]); - const [lastSectionSnapshot, setLastSectionSnapshot] = useState(null); - const [persistedEntities, setPersistedEntities] = useState([]); - const [pendingEntityCreates, setPendingEntityCreates] = useState([]); - const [createdEntities, setCreatedEntities] = useState>([]); - const [entityStatus, setEntityStatus] = useState(null); - const [selectedFeatureId, setSelectedFeatureId] = useState(null); - const [entityForm, setEntityForm] = useState({ - name: "", - slug: "", - type_id: DEFAULT_ENTITY_TYPE_ID, + const { + mode, + setMode, + initialData, + setInitialData, + 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, + 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, + timelineYear, + setTimelineYear, + timelineDraftYear, + setTimelineDraftYear, + isTimelineLoading, + setIsTimelineLoading, + timelineStatus, + setTimelineStatus, + backgroundVisibility, + setBackgroundVisibility, + isBackgroundVisibilityReady, + setIsBackgroundVisibilityReady, + } = useEditorSessionState({ + emptyFeatureCollection: EMPTY_FC, + defaultEditorUserId: DEFAULT_EDITOR_USER_ID, + fallbackTimelineRange: FALLBACK_TIMELINE_RANGE, + currentYear: CURRENT_YEAR, }); - const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState([]); - const [geometryMetaForm, setGeometryMetaForm] = useState({ - time_start: "", - time_end: "", - binding: "", - }); - const [isEntitySubmitting, setIsEntitySubmitting] = useState(false); - const [entityFormStatus, setEntityFormStatus] = useState(null); - const [entitySearchQuery, setEntitySearchQuery] = useState(""); - const [entitySearchResults, setEntitySearchResults] = useState([]); - const [selectedSearchEntityId, setSelectedSearchEntityId] = useState(null); - const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false); - const [timelineRange, setTimelineRange] = useState(FALLBACK_TIMELINE_RANGE); - const [timelineWindowStart, setTimelineWindowStart] = useState(FALLBACK_TIMELINE_RANGE.min); - const [timelineWindowEnd, setTimelineWindowEnd] = useState(FALLBACK_TIMELINE_RANGE.max); - const [timelineYear, setTimelineYear] = useState(() => - clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max) - ); - const [timelineDraftYear, setTimelineDraftYear] = useState(() => - clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max) - ); - const [isTimelineReady, setIsTimelineReady] = useState(false); - const [isTimelineLoading, setIsTimelineLoading] = useState(false); - const [timelineStatus, setTimelineStatus] = useState(null); - const [backgroundVisibility, setBackgroundVisibility] = useState( - () => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }) - ); - const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); const timelineFetchRequestRef = useRef(0); const entitySearchRequestRef = useRef(0); - const timelineYearRef = useRef(timelineYear); - const editorUserIdRef = useRef(DEFAULT_EDITOR_USER_ID); const editor = useEditorState(initialData); const editorUserId = normalizeEditorUserId(editorUserIdInput); @@ -196,87 +193,57 @@ export default function Page() { return rows; }, [editor.changes, entities]); - useEffect(() => { - timelineYearRef.current = timelineYear; - }, [timelineYear]); - - useEffect(() => { - editorUserIdRef.current = editorUserId; - }, [editorUserId]); - - const openSectionForEditing = useCallback(async (sectionId: string) => { - const editorPayload = await openSectionEditor(sectionId, editorUserIdRef.current); - const snapshot = normalizeEditorSnapshot(editorPayload.snapshot); - const commits = await fetchSectionCommits(sectionId); - - let nextInitialData = snapshot?.editor_feature_collection || null; - if (!nextInitialData) { - nextInitialData = await fetchGeometriesByBBox({ - ...WORLD_BBOX, - time: timelineYearRef.current, - }); - } - - setActiveSection(editorPayload.section); - setSelectedSectionId(editorPayload.section.id); - setSectionState(editorPayload.state); - setLastSectionSnapshot(snapshot); - setInitialData(nextInitialData); - setSectionCommits(commits); - setPendingEntityCreates([]); - setCreatedEntities([]); - setSelectedFeatureId(null); - setEntityFormStatus(null); - }, []); + const sectionCommands = useSectionCommands({ + editor, + editorUserId, + emptyFeatureCollection: EMPTY_FC, + activeSection, + sectionState, + selectedSectionId, + newSectionTitle, + pendingSaveCount: editor.changeCount + pendingEntityCreates.length, + pendingEntityCreates, + lastSectionSnapshot, + commitTitle, + commitNote, + setActiveSection, + setSelectedSectionId, + setSectionState, + setLastSectionSnapshot, + setInitialData, + setSectionCommits, + setPendingEntityCreates, + setCreatedEntities, + setEntityFormStatus, + setSelectedFeatureId, + setEntityStatus, + setIsSaving, + setIsSubmitting, + setIsOpeningSection, + setAvailableSections, + setNewSectionTitle, + setCommitTitle, + setCommitNote, + }); useEffect(() => { let disposed = false; - async function loadSection() { + async function loadSections() { try { setIsOpeningSection(true); const sections = await fetchSections(); if (disposed) return; setAvailableSections(sections); - - const section = await resolveDefaultSection(sections, editorUserIdRef.current); - if (disposed) return; - setAvailableSections((prev) => - prev.some((item) => item.id === section.id) - ? prev - : [section, ...prev] - ); - - try { - await openSectionForEditing(section.id); - } catch (err) { - if (!(err instanceof ApiError) || err.status !== 409) { - throw err; - } - - const fallbackSection = await resolveOpenableSectionAfterConflict( - sections, - section.id, - editorUserIdRef.current - ); - if (disposed) return; - setAvailableSections((prev) => - prev.some((item) => item.id === fallbackSection.id) - ? prev - : [fallbackSection, ...prev] - ); - await openSectionForEditing(fallbackSection.id); - if (!disposed) { - setEntityStatus("Section mặc định đang bị lock, đã mở section khác để chỉnh sửa."); - } - } + setSelectedSectionId(sections[0]?.id || ""); + setEntityStatus(null); } catch (err) { if (disposed) return; if (err instanceof ApiError) { - setEntityStatus(`Không tải được section editor: ${err.body || err.message}`); + setEntityStatus(`Không tải được danh sách section: ${err.body || err.message}`); } else { - console.error("Load section editor failed", err); - setEntityStatus("Không tải được section editor."); + console.error("Load sections failed", err); + setEntityStatus("Không tải được danh sách section."); } } finally { if (!disposed) { @@ -285,12 +252,17 @@ export default function Page() { } } - loadSection(); + loadSections(); return () => { disposed = true; }; - }, [openSectionForEditing]); + }, [ + setAvailableSections, + setEntityStatus, + setIsOpeningSection, + setSelectedSectionId, + ]); useEffect(() => { let disposed = false; @@ -314,7 +286,7 @@ export default function Page() { return () => { disposed = true; }; - }, []); + }, [setEntityStatus, setPersistedEntities]); useEffect(() => { if (!selectedFeature) { @@ -390,7 +362,14 @@ export default function Page() { disposed = true; window.clearTimeout(timeoutId); }; - }, [entitySearchQuery, selectedFeature, pendingEntityCreates]); + }, [ + entitySearchQuery, + selectedFeature, + pendingEntityCreates, + setEntitySearchResults, + setIsEntitySearchLoading, + setSelectedSearchEntityId, + ]); useEffect(() => { if (selectedFeatureId === null) return; @@ -400,7 +379,7 @@ export default function Page() { if (!stillExists) { setSelectedFeatureId(null); } - }, [editor.draft, selectedFeatureId]); + }, [editor.draft, selectedFeatureId, setSelectedFeatureId]); useEffect(() => { if (!selectedFeature) { @@ -432,7 +411,15 @@ export default function Page() { setEntitySearchResults([]); setSelectedSearchEntityId(null); setEntityFormStatus(null); - }, [selectedFeature]); + }, [ + selectedFeature, + setEntityFormStatus, + setEntitySearchQuery, + setEntitySearchResults, + setGeometryMetaForm, + setSelectedGeometryEntityIds, + setSelectedSearchEntityId, + ]); useEffect(() => { if (!selectedFeature) return; @@ -456,56 +443,9 @@ export default function Page() { type_id: fallbackOption.value, }; }); - }, [selectedFeature]); + }, [selectedFeature, setEntityForm]); useEffect(() => { - let disposed = false; - - async function loadTimelineBounds() { - setIsTimelineLoading(true); - try { - const data = await fetchGeometriesByBBox({ - ...WORLD_BBOX, - }); - if (disposed) return; - - const range = deriveTimelineRange(data); - const initialYear = clampYear(CURRENT_YEAR, range); - - setTimelineRange(range); - setTimelineWindowStart(range.min); - setTimelineWindowEnd(range.max); - setTimelineYear(initialYear); - setTimelineDraftYear(initialYear); - setTimelineStatus(null); - } catch (err) { - if (disposed) return; - console.error("Load timeline bounds failed", err); - const fallbackYear = clampYear(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE); - setTimelineRange(FALLBACK_TIMELINE_RANGE); - setTimelineWindowStart(FALLBACK_TIMELINE_RANGE.min); - setTimelineWindowEnd(FALLBACK_TIMELINE_RANGE.max); - setTimelineYear(fallbackYear); - setTimelineDraftYear(fallbackYear); - setTimelineStatus("Không thể lấy phạm vi thời gian, đang dùng mốc mặc định."); - } finally { - if (!disposed) { - setIsTimelineLoading(false); - setIsTimelineReady(true); - } - } - } - - loadTimelineBounds(); - - return () => { - disposed = true; - }; - }, []); - - useEffect(() => { - if (!isTimelineReady) return; - const timeoutId = window.setTimeout(() => { if (timelineDraftYear !== timelineYear) { setTimelineYear(timelineDraftYear); @@ -513,26 +453,20 @@ export default function Page() { }, TIMELINE_DEBOUNCE_MS); return () => window.clearTimeout(timeoutId); - }, [timelineDraftYear, timelineYear, isTimelineReady]); + }, [timelineDraftYear, timelineYear, setTimelineYear]); useEffect(() => { setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); setIsBackgroundVisibilityReady(true); - }, []); + }, [setBackgroundVisibility, setIsBackgroundVisibilityReady]); useEffect(() => { - const lower = Math.min(timelineWindowStart, timelineWindowEnd); - const upper = Math.max(timelineWindowStart, timelineWindowEnd); - setTimelineDraftYear((prev) => clampYearValue(prev, lower, upper)); - }, [timelineWindowStart, timelineWindowEnd]); - - useEffect(() => { - if (!isTimelineReady) return; + if (activeSection) return; let disposed = false; const requestId = ++timelineFetchRequestRef.current; - async function loadByTimeline() { + async function loadGlobalByTimeline() { setIsTimelineLoading(true); setTimelineStatus(null); @@ -543,18 +477,16 @@ export default function Page() { }); if (disposed || requestId !== timelineFetchRequestRef.current) return; - if (!lastSectionSnapshot?.editor_feature_collection) { - setInitialData(data); - } + setInitialData(data); } catch (err) { if (err instanceof ApiError) { - console.error("Load timeline data failed", err.body); + console.error("Load global timeline data failed", err.body); } else { - console.error("Load timeline data failed", err); + console.error("Load global timeline data failed", err); } if (!disposed && requestId === timelineFetchRequestRef.current) { - setTimelineStatus("Không tải được geometry tại mốc thời gian đã chọn."); + setTimelineStatus("Không tải được geometry global tại mốc thời gian đã chọn."); } } finally { if (!disposed && requestId === timelineFetchRequestRef.current) { @@ -563,63 +495,18 @@ export default function Page() { } } - loadByTimeline(); + loadGlobalByTimeline(); return () => { disposed = true; }; - }, [timelineYear, isTimelineReady, lastSectionSnapshot]); - - const handleCommitSection = async () => { - if (!activeSection || !sectionState) { - setEntityStatus("Chưa mở được section editor."); - return; - } - - const geometryChanges = editor.buildPayload(); - - setIsSaving(true); - setEntityStatus(null); - try { - const snapshot = buildEditorSnapshot({ - section: activeSection, - draft: editor.draft, - changes: geometryChanges, - pendingEntities: pendingEntityCreates, - previousSnapshot: lastSectionSnapshot, - hasPersistedFeature: editor.hasPersistedFeature, - }); - const result = await createSectionCommit(activeSection.id, { - snapshot, - created_by: editorUserId, - expected_version: sectionState.version, - expected_head_commit_id: sectionState.head_commit_id, - title: commitTitle.trim() || `Commit ${new Date().toLocaleString()}`, - note: commitNote.trim() || null, - }); - - setSectionState(result.state); - setLastSectionSnapshot(snapshot); - setInitialData(editor.draft); - editor.clearChanges(); - setPendingEntityCreates([]); - setCreatedEntities([]); - setCommitTitle(""); - setCommitNote(""); - setSectionCommits(await fetchSectionCommits(activeSection.id)); - setEntityFormStatus(`Đã tạo commit #${result.commit.commit_no}.`); - } catch (err) { - if (err instanceof ApiError) { - console.error("Commit failed", err.body); - setEntityStatus(`Commit thất bại: ${err.body}`); - return; - } - console.error("Commit error", err); - setEntityStatus("Commit thất bại."); - } finally { - setIsSaving(false); - } - }; + }, [ + timelineYear, + activeSection, + setInitialData, + setIsTimelineLoading, + setTimelineStatus, + ]); const updateBackgroundVisibility = ( updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility @@ -646,20 +533,8 @@ export default function Page() { updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })); }; - const handleTimelineWindowStartChange = (nextYear: number) => { - const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max); - setTimelineWindowStart(Math.min(next, timelineWindowEnd)); - }; - - const handleTimelineWindowEndChange = (nextYear: number) => { - const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max); - setTimelineWindowEnd(Math.max(next, timelineWindowStart)); - }; - const handleTimelineYearChange = (nextYear: number) => { - const lower = Math.min(timelineWindowStart, timelineWindowEnd); - const upper = Math.max(timelineWindowStart, timelineWindowEnd); - setTimelineDraftYear(clampYearValue(Math.trunc(nextYear), lower, upper)); + setTimelineDraftYear(clampYear(Math.trunc(nextYear), FALLBACK_TIMELINE_RANGE)); }; const handleEntityFormChange = (key: keyof EntityFormState, value: string) => { @@ -687,82 +562,15 @@ export default function Page() { setEntityFormStatus(null); }; - const parseGeometryMetaFormRange = () => { - const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start"); - const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end"); - if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) { - throw new Error("time_start phải <= time_end."); - } - return { timeStart, timeEnd }; - }; - - const resolveGeometryTypeFromEntityIds = ( - entityIds: string[], - entityRows: Entity[] = entities - ): string | null => { - const primaryEntityId = entityIds[0] || null; - if (!primaryEntityId) return null; - const primaryEntity = entityRows.find((entity) => entity.id === primaryEntityId) || null; - return primaryEntity?.type_id || null; - }; - - const patchSelectedGeometryMetadataLocally = ( - feature: Feature, - timeStart: number | null, - timeEnd: number | null, - bindingIds: string[] - ) => { - editor.patchFeatureProperties(feature.properties.id, { - time_start: timeStart, - time_end: timeEnd, - binding: bindingIds, - }); - - setGeometryMetaForm({ - time_start: timeStart != null ? String(timeStart) : "", - time_end: timeEnd != null ? String(timeEnd) : "", - binding: bindingIds.join(", "), - }); - }; - - const patchSelectedFeatureEntitiesLocally = ( - feature: Feature, - entityIds: string[], - entityRows: Entity[] = entities - ) => { - const primaryEntityId = entityIds[0] || null; - const primaryEntity = primaryEntityId - ? entityRows.find((entity) => entity.id === primaryEntityId) || null - : null; - const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds, entityRows) || feature.properties.type || null; - const entityNames = entityIds - .map((id) => entityRows.find((entity) => entity.id === id)?.name || "") - .filter((name) => name.length > 0); - - editor.patchFeatureProperties(feature.properties.id, { - type: nextGeometryType, - entity_id: primaryEntityId, - entity_ids: entityIds, - entity_name: primaryEntity?.name || null, - entity_names: entityNames, - entity_type_id: primaryEntity?.type_id || null, - }); - - setSelectedGeometryEntityIds(entityIds); - }; - const handleApplyGeometryMetadata = async () => { if (!selectedFeature) { setEntityFormStatus("Hãy chọn một geometry trước."); return; } - let timeStart: number | null; - let timeEnd: number | null; - let bindingIds: string[]; + let metadata; try { - ({ timeStart, timeEnd } = parseGeometryMetaFormRange()); - bindingIds = parseBindingInput(geometryMetaForm.binding); + metadata = buildGeometryMetadataPatch(geometryMetaForm); } catch (err) { setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ."); return; @@ -771,7 +579,8 @@ export default function Page() { setIsEntitySubmitting(true); setEntityFormStatus(null); try { - patchSelectedGeometryMetadataLocally(selectedFeature, timeStart, timeEnd, bindingIds); + editor.patchFeatureProperties(selectedFeature.properties.id, metadata.patch); + setGeometryMetaForm(metadata.formState); setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng."); } finally { setIsEntitySubmitting(false); @@ -788,7 +597,11 @@ export default function Page() { setIsEntitySubmitting(true); setEntityFormStatus(null); try { - patchSelectedFeatureEntitiesLocally(selectedFeature, entityIds); + editor.patchFeatureProperties( + selectedFeature.properties.id, + buildFeatureEntityPatch(selectedFeature, entityIds, entities) + ); + setSelectedGeometryEntityIds(entityIds); setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng."); } catch (err) { if (err instanceof ApiError) { @@ -869,141 +682,11 @@ export default function Page() { } }; - const handleOpenSelectedSection = async () => { - const sectionId = selectedSectionId.trim(); - if (!sectionId) { - setEntityStatus("Hãy chọn section để mở."); - return; - } - if (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; - } - - setIsOpeningSection(true); - setEntityStatus(null); - try { - await openSectionForEditing(sectionId); - setEntityStatus("Đã mở section để chỉnh sửa."); - } catch (err) { - if (err instanceof ApiError) { - setEntityStatus(`Mở section thất bại: ${err.body}`); - } else { - setEntityStatus("Mở section thất bại."); - } - } finally { - setIsOpeningSection(false); - } - }; - - const handleCreateAndOpenSection = async () => { - const title = newSectionTitle.trim(); - if (!title) { - setEntityStatus("Tên section là bắt buộc."); - return; - } - if (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; - } - - setIsOpeningSection(true); - setEntityStatus(null); - try { - const section = await createSection({ - title, - user_id: editorUserId, - created_by: editorUserId, - }); - const sections = await fetchSections(); - setAvailableSections(sections); - setNewSectionTitle(""); - await openSectionForEditing(section.id); - setEntityStatus("Đã tạo và mở section mới."); - } catch (err) { - if (err instanceof ApiError) { - setEntityStatus(`Tạo section thất bại: ${err.body}`); - } else { - setEntityStatus("Tạo section thất bại."); - } - } finally { - setIsOpeningSection(false); - } - }; - - const handleSubmitSection = async () => { - if (!activeSection || !sectionState?.head_commit_id) { - setEntityStatus("Section hiện tại chưa có head để submit."); - return; - } - if (pendingSaveCount > 0) { - setEntityStatus("Hãy Commit các thay đổi trước khi Submit."); - return; - } - - setIsSubmitting(true); - setEntityStatus(null); - try { - const submission = await submitSection(activeSection.id, { - submitted_by: editorUserId, - }); - setSectionState((prev) => prev ? { ...prev, status: "submitted" } : prev); - setEntityStatus(`Đã submit section, submission ${submission.id}.`); - } catch (err) { - if (err instanceof ApiError) { - setEntityStatus(`Submit thất bại: ${err.body}`); - } else { - setEntityStatus("Submit thất bại."); - } - } finally { - setIsSubmitting(false); - } - }; - - const handleRestoreCommit = async (commitId: string) => { - if (!activeSection || !sectionState) { - setEntityStatus("Chưa mở được section editor."); - return; - } - if (pendingSaveCount > 0) { - setEntityStatus("Hãy Commit hoặc Undo thay đổi hiện tại trước khi Restore."); - return; - } - - setIsSaving(true); - setEntityStatus(null); - try { - const result = await restoreSectionCommit(activeSection.id, { - commit_id: commitId, - created_by: editorUserId, - expected_version: sectionState.version, - expected_head_commit_id: sectionState.head_commit_id, - }); - const editorPayload = await openSectionEditor(activeSection.id, editorUserId); - const snapshot = normalizeEditorSnapshot(editorPayload.snapshot); - setSectionState(result.state); - setLastSectionSnapshot(snapshot); - if (snapshot?.editor_feature_collection) { - setInitialData(snapshot.editor_feature_collection); - } - setSectionCommits(await fetchSectionCommits(activeSection.id)); - setEntityFormStatus(`Đã restore thành commit #${result.commit.commit_no}.`); - } catch (err) { - if (err instanceof ApiError) { - setEntityStatus(`Restore thất bại: ${err.body}`); - } else { - setEntityStatus("Restore thất bại."); - } - } finally { - setIsSaving(false); - } - }; - const pendingSaveCount = editor.changeCount + pendingEntityCreates.length; const headCommit = sectionState?.head_commit_id ? sectionCommits.find((commit) => commit.id === sectionState.head_commit_id) || null : null; - const timelineDisabled = !isTimelineReady || isSaving || pendingSaveCount > 0; + const timelineDisabled = isSaving || pendingSaveCount > 0; const timelineStatusText = pendingSaveCount > 0 ? "Commit hoặc Undo hết thay đổi trước khi đổi mốc thời gian." @@ -1023,9 +706,9 @@ export default function Page() { setMode={setMode} entityStatus={entityStatus} onUndo={editor.undo} - onCommit={handleCommitSection} - onSubmit={handleSubmitSection} - onRestoreCommit={handleRestoreCommit} + onCommit={sectionCommands.commitSection} + onSubmit={sectionCommands.submitCurrentSection} + onRestoreCommit={sectionCommands.restoreCommit} isSaving={isSaving} isSubmitting={isSubmitting} isOpeningSection={isOpeningSection} @@ -1042,8 +725,8 @@ export default function Page() { onNewSectionTitleChange={setNewSectionTitle} onCommitTitleChange={setCommitTitle} onCommitNoteChange={setCommitNote} - onOpenSection={handleOpenSelectedSection} - onCreateSection={handleCreateAndOpenSection} + onOpenSection={sectionCommands.openSelectedSection} + onCreateSection={sectionCommands.createAndOpenSection} commitCount={sectionCommits.length} hasHeadCommit={Boolean(sectionState?.head_commit_id)} headCommitId={sectionState?.head_commit_id || null} @@ -1071,12 +754,6 @@ export default function Page() {
)} { - const sections = existingSections || await fetchSections(); - - const existingDefault = sections.find((section) => section.title === DEFAULT_SECTION_TITLE); - if (existingDefault) { - return existingDefault; - } - - return createSection({ - title: DEFAULT_SECTION_TITLE, - user_id: userId, - created_by: userId, - description: "Default section used by the map editor UI.", - }); -} - -async function resolveOpenableSectionAfterConflict( - sections: Section[], - blockedSectionId: string, - userId: string -): Promise
{ - const now = Date.now(); - const fallback = sections.find((section) => - section.id !== blockedSectionId && - section.state.status === "editing" && - ( - !section.state.locked_by || - section.state.locked_by === userId || - ( - section.state.lock_expires_at !== null && - Date.parse(section.state.lock_expires_at) <= now - ) - ) - ); - if (fallback) return fallback; - - return createSection({ - title: `${DEFAULT_SECTION_TITLE} (${userId})`, - user_id: userId, - created_by: userId, - description: "Fallback section created because the default section is locked.", - }); -} - function normalizeEditorUserId(value: string): string { const normalized = value.trim(); return normalized || DEFAULT_EDITOR_USER_ID; } -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, - }; -} - -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") - .map((change) => String(change.id)) - ); - const currentDraftIds = new Set(options.draft.features.map((feature) => String(feature.properties.id))); - const previousFeatures = new globalThis.Map(); - 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(); - for (const item of options.previousSnapshot?.geometries || []) { - const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : ""; - const operation = typeof item.operation === "string" ? item.operation : ""; - if (id && operation) previousGeometryOps.set(id, operation); - } - - const pendingEntityIds = new Set(options.pendingEntities.map((entity) => entity.id)); - const entityRows = new globalThis.Map>(); - 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: Array> = 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 = 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 = options.draft.features - .map((feature) => ({ - geometry_id: String(feature.properties.id), - operation: "replace", - 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, - }; -} - -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)); -} - -function deriveTimelineRange(collection: FeatureCollection): TimelineRange { - let min = Number.POSITIVE_INFINITY; - let max = Number.NEGATIVE_INFINITY; - - for (const feature of collection.features) { - const { time_start, time_end } = feature.properties; - - if (isYearNumber(time_start)) { - min = Math.min(min, time_start); - max = Math.max(max, time_start); - } - - if (isYearNumber(time_end)) { - min = Math.min(min, time_end); - max = Math.max(max, time_end); - } - } - - if (!Number.isFinite(min) || !Number.isFinite(max)) { - return FALLBACK_TIMELINE_RANGE; - } - - return { - min: Math.floor(min), - max: Math.ceil(max), - }; -} - function clampYear(year: number, range: TimelineRange): number { return clampYearValue(year, range.min, range.max); } @@ -1398,31 +825,10 @@ function clampYearValue(year: number, minYear: number, maxYear: number): number return year; } -function isYearNumber(value: number | null | undefined): value is number { - return typeof value === "number" && Number.isFinite(value); -} - function formatCommitTitle(commit: SectionCommit): string { return commit.title?.trim() || `Commit #${commit.commit_no}`; } -function getDefaultTypeIdForFeature(feature: Feature): string { - const preset = feature.properties.geometry_preset; - if (preset === "line") return "defense_line"; - if (preset === "point") return "city"; - if (preset === "circle-area") return "war"; - if (preset === "polygon") return DEFAULT_ENTITY_TYPE_ID; - - const geometryType = feature.geometry.type; - if (geometryType === "LineString" || geometryType === "MultiLineString") { - return "defense_line"; - } - if (geometryType === "Point" || geometryType === "MultiPoint") { - return "city"; - } - return DEFAULT_ENTITY_TYPE_ID; -} - function getAllowedEntityTypeGroupIdsForFeature(feature: Feature): EntityTypeGroupId[] { const defaultTypeId = getDefaultTypeIdForFeature(feature); const defaultTypeOption = findEntityTypeOption(defaultTypeId); @@ -1431,187 +837,3 @@ function getAllowedEntityTypeGroupIdsForFeature(feature: Feature): EntityTypeGro } return ["polygon"]; } - -function parseOptionalYearInput(raw: string, fieldName: string): number | null { - const value = raw.trim(); - if (!value.length) return null; - const parsed = Number(value); - if (!Number.isFinite(parsed)) { - throw new Error(`${fieldName} phải là số.`); - } - return Math.trunc(parsed); -} - -function normalizeFeatureEntityIds(feature: Feature): string[] { - const fromArray = Array.isArray(feature.properties.entity_ids) - ? feature.properties.entity_ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0) - : []; - - if (fromArray.length) { - return uniqueEntityIds(fromArray); - } - - const single = feature.properties.entity_id; - if (typeof single === "string" && single.trim().length > 0) { - return [single.trim()]; - } - - return []; -} - -function normalizeFeatureBindingIds(feature: Feature): string[] { - const rawBinding = feature.properties.binding; - if (!Array.isArray(rawBinding)) return []; - return uniqueEntityIds(rawBinding - .map((id) => { - if (typeof id !== "string" && typeof id !== "number") return ""; - return String(id).trim(); - }) - .filter((id) => id.length > 0)); -} - -function parseBindingInput(raw: string): string[] { - if (!raw.trim().length) return []; - return uniqueEntityIds( - raw - .split(/[,\n]/) - .map((item) => item.trim()) - .filter((item) => item.length > 0) - ); -} - -function formatBindingIdsForDisplay(feature: Feature): string { - const bindingIds = normalizeFeatureBindingIds(feature); - if (!bindingIds.length) return "Không có"; - return bindingIds.join(", "); -} - -function uniqueEntityIds(ids: string[]): string[] { - const deduped: string[] = []; - const seen = new Set(); - for (const rawId of ids) { - const id = rawId.trim(); - if (!id || seen.has(id)) continue; - seen.add(id); - deduped.push(id); - } - return deduped; -} - -function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): string { - const entityIds = normalizeFeatureEntityIds(feature); - if (!entityIds.length) return "Chưa gắn"; - - const names = entityIds - .map((id) => entities.find((entity) => entity.id === id)?.name || id) - .filter((name) => name.trim().length > 0); - return names.join(", "); -} - -function mergeEntitiesWithPending( - persistedEntities: Entity[], - pendingCreates: PendingEntityCreate[] -): Entity[] { - if (!pendingCreates.length) { - return persistedEntities; - } - - const seen = new Set(); - 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]; -} - -function mergeEntitySearchResults( - remoteRows: Entity[], - localRows: Entity[] -): Entity[] { - const merged: Entity[] = []; - const seen = new Set(); - - 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; -} - -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)}`; -} - -function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility { - if (typeof window === "undefined") { - return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; - } - - try { - const raw = window.localStorage.getItem(BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY); - if (!raw) { - return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; - } - - const parsed = JSON.parse(raw) as unknown; - const normalized = normalizeBackgroundLayerVisibility(parsed); - return normalized || { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; - } catch (err) { - console.warn("Load background layer visibility from storage failed", err); - return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; - } -} - -function persistBackgroundLayerVisibility(visibility: BackgroundLayerVisibility) { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem( - BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY, - JSON.stringify(visibility) - ); - } catch (err) { - console.warn("Persist background layer visibility failed", err); - } -} - -function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null { - if (!raw || typeof raw !== "object") return null; - - const source = raw as Record; - const next: BackgroundLayerVisibility = { - ...DEFAULT_BACKGROUND_LAYER_VISIBILITY, - }; - - for (const layer of BACKGROUND_LAYER_OPTIONS) { - const value = source[layer.id]; - if (typeof value === "boolean") { - next[layer.id] = value; - } - } - - return next; -} diff --git a/app/page.tsx b/app/page.tsx index d25c226..12481b5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -9,12 +9,16 @@ import { fetchGeometriesByBBox } from "@/api/geometries"; import { ApiError } from "@/api/http"; import { findEntityTypeOption } from "@/lib/entityTypeOptions"; import { - BACKGROUND_LAYER_OPTIONS, + BackgroundLayerId, BackgroundLayerVisibility, DEFAULT_BACKGROUND_LAYER_VISIBILITY, HIDDEN_BACKGROUND_LAYER_VISIBILITY, } from "@/lib/backgroundLayers"; import { Feature, FeatureCollection } from "@/lib/useEditorState"; +import { + loadBackgroundLayerVisibilityFromStorage, + persistBackgroundLayerVisibility, +} from "@/lib/editor/background/backgroundVisibilityStorage"; const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; const WORLD_BBOX = { @@ -25,11 +29,10 @@ const WORLD_BBOX = { } as const; const CURRENT_YEAR = new Date().getUTCFullYear(); const FALLBACK_TIMELINE_RANGE: TimelineRange = { - min: CURRENT_YEAR - 5000, - max: CURRENT_YEAR + 100, + min: -2000, + max: 2000, }; const TIMELINE_DEBOUNCE_MS = 180; -const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1"; type TimelineRange = { min: number; @@ -39,16 +42,12 @@ type TimelineRange = { export default function Page() { const [initialData, setInitialData] = useState(EMPTY_FC); const [selectedFeatureId, setSelectedFeatureId] = useState(null); - const [timelineRange, setTimelineRange] = useState(FALLBACK_TIMELINE_RANGE); - const [timelineWindowStart, setTimelineWindowStart] = useState(FALLBACK_TIMELINE_RANGE.min); - const [timelineWindowEnd, setTimelineWindowEnd] = useState(FALLBACK_TIMELINE_RANGE.max); const [timelineYear, setTimelineYear] = useState(() => clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max) ); const [timelineDraftYear, setTimelineDraftYear] = useState(() => clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max) ); - const [isTimelineReady, setIsTimelineReady] = useState(false); const [isTimelineLoading, setIsTimelineLoading] = useState(false); const [timelineStatus, setTimelineStatus] = useState(null); const [backgroundVisibility, setBackgroundVisibility] = useState( @@ -75,53 +74,6 @@ export default function Page() { }, [initialData, selectedFeatureId]); useEffect(() => { - let disposed = false; - - async function loadTimelineBounds() { - setIsTimelineLoading(true); - try { - const data = await fetchGeometriesByBBox({ - ...WORLD_BBOX, - }); - if (disposed) return; - - const range = deriveTimelineRange(data); - const initialYear = clampYear(CURRENT_YEAR, range); - - setTimelineRange(range); - setTimelineWindowStart(range.min); - setTimelineWindowEnd(range.max); - setTimelineYear(initialYear); - setTimelineDraftYear(initialYear); - setTimelineStatus(null); - } catch (err) { - if (disposed) return; - console.error("Load timeline bounds failed", err); - const fallbackYear = clampYear(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE); - setTimelineRange(FALLBACK_TIMELINE_RANGE); - setTimelineWindowStart(FALLBACK_TIMELINE_RANGE.min); - setTimelineWindowEnd(FALLBACK_TIMELINE_RANGE.max); - setTimelineYear(fallbackYear); - setTimelineDraftYear(fallbackYear); - setTimelineStatus("Không thể lấy phạm vi thời gian, đang dùng mốc mặc định."); - } finally { - if (!disposed) { - setIsTimelineLoading(false); - setIsTimelineReady(true); - } - } - } - - loadTimelineBounds(); - - return () => { - disposed = true; - }; - }, []); - - useEffect(() => { - if (!isTimelineReady) return; - const timeoutId = window.setTimeout(() => { if (timelineDraftYear !== timelineYear) { setTimelineYear(timelineDraftYear); @@ -129,7 +81,7 @@ export default function Page() { }, TIMELINE_DEBOUNCE_MS); return () => window.clearTimeout(timeoutId); - }, [timelineDraftYear, timelineYear, isTimelineReady]); + }, [timelineDraftYear, timelineYear]); useEffect(() => { setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); @@ -137,14 +89,6 @@ export default function Page() { }, []); useEffect(() => { - const lower = Math.min(timelineWindowStart, timelineWindowEnd); - const upper = Math.max(timelineWindowStart, timelineWindowEnd); - setTimelineDraftYear((prev) => clampYearValue(prev, lower, upper)); - }, [timelineWindowStart, timelineWindowEnd]); - - useEffect(() => { - if (!isTimelineReady) return; - let disposed = false; const requestId = ++timelineFetchRequestRef.current; @@ -182,7 +126,7 @@ export default function Page() { return () => { disposed = true; }; - }, [timelineYear, isTimelineReady]); + }, [timelineYear]); const updateBackgroundVisibility = ( updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility @@ -194,7 +138,7 @@ export default function Page() { }); }; - const handleToggleBackgroundLayer = (id: (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"]) => { + const handleToggleBackgroundLayer = (id: BackgroundLayerId) => { updateBackgroundVisibility((prev) => ({ ...prev, [id]: !prev[id], @@ -209,23 +153,10 @@ export default function Page() { updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })); }; - const handleTimelineWindowStartChange = (nextYear: number) => { - const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max); - setTimelineWindowStart(Math.min(next, timelineWindowEnd)); - }; - - const handleTimelineWindowEndChange = (nextYear: number) => { - const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max); - setTimelineWindowEnd(Math.max(next, timelineWindowStart)); - }; - const handleTimelineYearChange = (nextYear: number) => { - const lower = Math.min(timelineWindowStart, timelineWindowEnd); - const upper = Math.max(timelineWindowStart, timelineWindowEnd); - setTimelineDraftYear(clampYearValue(Math.trunc(nextYear), lower, upper)); + setTimelineDraftYear(clampYear(Math.trunc(nextYear), FALLBACK_TIMELINE_RANGE)); }; - const timelineDisabled = !isTimelineReady; const selectedType = resolveSelectedTypeSummary(selectedFeature); const selectedTimeSummary = formatSelectedTimeSummary(selectedFeature); const selectedEntitySummary = formatSelectedEntitySummary(selectedFeature); @@ -248,16 +179,10 @@ export default function Page() {
)}
@@ -325,34 +250,6 @@ export default function Page() { ); } -function deriveTimelineRange(collection: FeatureCollection): TimelineRange { - let min = Number.POSITIVE_INFINITY; - let max = Number.NEGATIVE_INFINITY; - - for (const feature of collection.features) { - const { time_start, time_end } = feature.properties; - - if (isYearNumber(time_start)) { - min = Math.min(min, time_start); - max = Math.max(max, time_start); - } - - if (isYearNumber(time_end)) { - min = Math.min(min, time_end); - max = Math.max(max, time_end); - } - } - - if (!Number.isFinite(min) || !Number.isFinite(max)) { - return FALLBACK_TIMELINE_RANGE; - } - - return { - min: Math.floor(min), - max: Math.ceil(max), - }; -} - function clampYear(year: number, range: TimelineRange): number { return clampYearValue(year, range.min, range.max); } @@ -432,53 +329,3 @@ function normalizeTypeId(value: unknown): string | null { const normalized = value.trim().toLowerCase(); return normalized.length ? normalized : null; } - -function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility { - if (typeof window === "undefined") { - return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; - } - - try { - const raw = window.localStorage.getItem(BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY); - if (!raw) { - return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; - } - - const parsed = JSON.parse(raw) as unknown; - const normalized = normalizeBackgroundLayerVisibility(parsed); - return normalized || { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; - } catch (err) { - console.warn("Load background layer visibility from storage failed", err); - return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; - } -} - -function persistBackgroundLayerVisibility(visibility: BackgroundLayerVisibility) { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem( - BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY, - JSON.stringify(visibility) - ); - } catch (err) { - console.warn("Persist background layer visibility failed", err); - } -} - -function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null { - if (!raw || typeof raw !== "object") return null; - - const source = raw as Record; - const next: BackgroundLayerVisibility = { - ...DEFAULT_BACKGROUND_LAYER_VISIBILITY, - }; - - for (const layer of BACKGROUND_LAYER_OPTIONS) { - const value = source[layer.id]; - if (typeof value === "boolean") { - next[layer.id] = value; - } - } - - return next; -} diff --git a/components/Editor.tsx b/components/Editor.tsx index e221aa3..f06ea1e 100644 --- a/components/Editor.tsx +++ b/components/Editor.tsx @@ -627,6 +627,8 @@ function formatUndoLabel(action: UndoAction) { return `Xóa #${action.feature.properties.id}`; case "update": return `Chỉnh sửa #${action.id}`; + case "properties": + return `Cập nhật thuộc tính #${action.id}`; default: return "Tác vụ"; } diff --git a/components/Map.tsx b/components/Map.tsx index aafe2b6..b394504 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -5,13 +5,13 @@ import maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import { getRasterTileTemplateUrl, getVectorTileTemplateUrl } from "@/api/tiles"; -import { initDrawing } from "@/lib/drawingEngine"; -import { initSelect } from "@/lib/selectingEngine"; -import { initPoint } from "@/lib/pointEngine"; -import { initLine } from "@/lib/lineEngine"; -import { initPath } from "@/lib/pathEngine"; -import { initCircle } from "@/lib/circleEngine"; -import { createEditingEngine } from "@/lib/editingEngine"; +import { initDrawing } from "@/lib/engine/drawingEngine"; +import { initSelect } from "@/lib/engine/selectingEngine"; +import { initPoint } from "@/lib/engine/pointEngine"; +import { initLine } from "@/lib/engine/lineEngine"; +import { initPath } from "@/lib/engine/pathEngine"; +import { initCircle } from "@/lib/engine/circleEngine"; +import { createEditingEngine } from "@/lib/engine/editingEngine"; import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState"; import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/lib/backgroundLayers"; @@ -31,6 +31,12 @@ type MapProps = { fitBoundsKey?: string | number | null; }; +type EngineBinding = { + cleanup: () => void; + cancel?: () => void; + clearSelection?: () => void; +}; + const DEFAULT_POINT_ICON_ID = "point-icon-default"; const POINT_ICON_URL = "/point.png"; const PATH_ARROW_ICON_ID = "path-arrow-icon"; @@ -148,14 +154,28 @@ export default function Map({ const editingEngineRef = useRef | null>(null); const fitBoundsAppliedRef = useRef(false); const mapCleanupFnsRef = useRef void>>([]); + const engineBindingsRef = useRef>>({}); + const previousModeRef = useRef(mode); useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { + const previousMode = previousModeRef.current; + if (previousMode !== mode) { + engineBindingsRef.current[previousMode]?.cancel?.(); + previousModeRef.current = mode; + } + const map = mapRef.current; if (!map || !map.isStyleLoaded()) return; + if (mode !== "draw") { + (map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({ + type: "FeatureCollection", + features: [], + }); + } if (mode !== "add-line") { (map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({ type: "FeatureCollection", @@ -184,6 +204,12 @@ export default function Map({ selectedFeatureIdRef.current = selectedFeatureId; }, [selectedFeatureId]); + useEffect(() => { + if (mode !== "select" || selectedFeatureId === null) { + editingEngineRef.current?.clearEditing(); + } + }, [mode, selectedFeatureId]); + useEffect(() => { fitBoundsAppliedRef.current = false; }, [fitBoundsKey]); @@ -791,7 +817,7 @@ export default function Map({ addPointSymbolLayer(map); // init drawing - const cleanup = initDrawing( + const drawingEngine = initDrawing( map, () => modeRef.current, (geometry: Geometry) => { @@ -813,7 +839,7 @@ export default function Map({ } ); - const cleanupSelect = initSelect( + const selectEngine = initSelect( map, () => modeRef.current, allowGeometryEditing @@ -852,7 +878,7 @@ export default function Map({ } ); - const cleanupLine = initLine( + const lineEngine = initLine( map, () => modeRef.current, (geometry: Geometry) => { @@ -874,7 +900,7 @@ export default function Map({ } ); - const cleanupPath = initPath( + const pathEngine = initPath( map, () => modeRef.current, (geometry: Geometry) => { @@ -896,7 +922,7 @@ export default function Map({ } ); - const cleanupCircle = initCircle( + const circleEngine = initCircle( map, () => modeRef.current, (geometry: Geometry) => { @@ -918,13 +944,21 @@ export default function Map({ } ); + engineBindingsRef.current = { + draw: drawingEngine, + select: selectEngine, + "add-line": lineEngine, + "add-path": pathEngine, + "add-circle": circleEngine, + }; + mapCleanupFnsRef.current = [ - cleanupCircle, - cleanupPath, - cleanupLine, + circleEngine.cleanup, + pathEngine.cleanup, + lineEngine.cleanup, cleanupPoint, - cleanupSelect, - cleanup, + selectEngine.cleanup, + drawingEngine.cleanup, () => map.off("zoom", syncZoomLevel), ]; @@ -941,6 +975,7 @@ export default function Map({ cleanupFn(); } mapCleanupFnsRef.current = []; + engineBindingsRef.current = {}; if (mapRef.current === map) { mapRef.current = null; } diff --git a/components/TimelineBar.tsx b/components/TimelineBar.tsx index 2d768f2..4daefe4 100644 --- a/components/TimelineBar.tsx +++ b/components/TimelineBar.tsx @@ -1,12 +1,6 @@ "use client"; type Props = { - minYear: number; - maxYear: number; - windowStartYear: number; - windowEndYear: number; - onWindowStartYearChange: (year: number) => void; - onWindowEndYearChange: (year: number) => void; year: number; onYearChange: (year: number) => void; isLoading: boolean; @@ -14,34 +8,28 @@ type Props = { statusText?: string | null; }; +const FIXED_TIMELINE_START_YEAR = -2000; +const FIXED_TIMELINE_END_YEAR = 2000; + export default function TimelineBar({ - minYear, - maxYear, - windowStartYear, - windowEndYear, - onWindowStartYearChange, - onWindowEndYearChange, year, onYearChange, isLoading, disabled, statusText, }: Props) { - const lower = Math.min(minYear, maxYear); - const upper = Math.max(minYear, maxYear); - const globalLocked = lower === upper; - const effectiveDisabled = disabled || globalLocked; - const safeWindowStart = clampYear(windowStartYear, lower, upper); - const safeWindowEnd = clampYear(windowEndYear, safeWindowStart, upper); - const windowLocked = safeWindowStart === safeWindowEnd; - const safeYear = clampYear(year, safeWindowStart, safeWindowEnd); - const pointDisabled = effectiveDisabled || windowLocked; - const windowStartPercent = toPercent(safeWindowStart, lower, upper); - const windowEndPercent = toPercent(safeWindowEnd, lower, upper); + const lower = FIXED_TIMELINE_START_YEAR; + const upper = FIXED_TIMELINE_END_YEAR; + const effectiveDisabled = disabled; + const safeYear = clampYear(year, lower, upper); const helperText = isLoading ? "Đang tải geometry theo mốc thời gian..." - : statusText || (windowLocked ? "Khoảng lớn đang thu về một mốc duy nhất." : "Kéo mốc nhỏ để query trong khoảng lớn."); + : statusText || "Kéo thanh hoặc nhập số năm để query chính xác."; + + const handleYearChange = (nextYear: number) => { + onYearChange(clampYear(Math.trunc(nextYear), lower, upper)); + }; return (
-
- Khoảng thời gian lớn -
-
-
-
- - onWindowStartYearChange( - Math.min(Number(event.target.value), safeWindowEnd) - ) - } - disabled={effectiveDisabled} - aria-label="Timeline window start" - /> - - onWindowEndYearChange( - Math.max(Number(event.target.value), safeWindowStart) - ) - } - disabled={effectiveDisabled} - aria-label="Timeline window end" - /> -
-
- {formatYear(safeWindowStart)} - {formatYear(safeWindowEnd)} -
-
Mốc thời gian chi tiết
+
onYearChange(Number(event.target.value))} - disabled={pointDisabled} + onChange={(event) => handleYearChange(Number(event.target.value))} + disabled={effectiveDisabled} aria-label="Timeline year" style={{ width: "100%", accentColor: "#22c55e", - cursor: pointDisabled ? "not-allowed" : "pointer", - opacity: pointDisabled ? 0.6 : 1, + cursor: effectiveDisabled ? "not-allowed" : "pointer", + opacity: effectiveDisabled ? 0.6 : 1, }} /> + handleYearChange(Number(event.target.value))} + disabled={effectiveDisabled} + aria-label="Timeline exact year" + style={{ + width: "100%", + border: "1px solid rgba(148, 163, 184, 0.45)", + borderRadius: "6px", + padding: "6px 8px", + background: "rgba(15, 23, 42, 0.7)", + color: "#f8fafc", + fontSize: "13px", + outline: "none", + }} + /> +
- {formatYear(safeWindowStart)} + {formatYear(lower)} {helperText} - {formatYear(safeWindowEnd)} + {formatYear(upper)}
- -
); } @@ -262,8 +145,3 @@ function formatYear(year: number): string { } return `${year}`; } - -function toPercent(value: number, minValue: number, maxValue: number): number { - if (maxValue <= minValue) return 0; - return ((value - minValue) / (maxValue - minValue)) * 100; -} diff --git a/lib/editor/background/backgroundVisibilityStorage.ts b/lib/editor/background/backgroundVisibilityStorage.ts new file mode 100644 index 0000000..8327d1a --- /dev/null +++ b/lib/editor/background/backgroundVisibilityStorage.ts @@ -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; + const next: BackgroundLayerVisibility = { + ...DEFAULT_BACKGROUND_LAYER_VISIBILITY, + }; + + for (const layer of BACKGROUND_LAYER_OPTIONS) { + const value = source[layer.id]; + if (typeof value === "boolean") { + next[layer.id] = value; + } + } + + return next; +} + diff --git a/lib/editor/draft/draftDiff.ts b/lib/editor/draft/draftDiff.ts new file mode 100644 index 0000000..d683b50 --- /dev/null +++ b/lib/editor/draft/draftDiff.ts @@ -0,0 +1,55 @@ +import type { + Feature, + FeatureCollection, + FeatureProperties, + Geometry, +} from "@/types/geo"; +import type { Change } from "@/lib/editor/draft/editorTypes"; + +export const deepClone = (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(); + for (const feature of fc.features) { + map.set(feature.properties.id, deepClone(feature)); + } + return map; +} + +export function diffDraftToInitial( + draft: FeatureCollection, + initialMap: Map +) { + const next = new Map(); + const seen = new Set(); + + 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; +} diff --git a/lib/editor/draft/editorTypes.ts b/lib/editor/draft/editorTypes.ts new file mode 100644 index 0000000..3c854f0 --- /dev/null +++ b/lib/editor/draft/editorTypes.ts @@ -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"] }; + diff --git a/lib/editor/draft/useDraftState.ts b/lib/editor/draft/useDraftState.ts new file mode 100644 index 0000000..4bedb52 --- /dev/null +++ b/lib/editor/draft/useDraftState.ts @@ -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(() => deepClone(initialData)); + const draftRef = useRef(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, + }; +} diff --git a/lib/editor/draft/useUndoStack.ts b/lib/editor/draft/useUndoStack.ts new file mode 100644 index 0000000..9d36c8f --- /dev/null +++ b/lib/editor/draft/useUndoStack.ts @@ -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([]); + + 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; + 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) + ); + } + case "properties": { + const next = b as Extract; + return ( + a.id === next.id && + JSON.stringify(a.prevProperties) === JSON.stringify(next.prevProperties) + ); + } + default: + return false; + } +} diff --git a/lib/editor/entity/entityBinding.ts b/lib/editor/entity/entityBinding.ts new file mode 100644 index 0000000..bf57841 --- /dev/null +++ b/lib/editor/entity/entityBinding.ts @@ -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(); + 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(); + + 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 { + 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; +} + diff --git a/lib/editor/geometry/geometryMetadata.ts b/lib/editor/geometry/geometryMetadata.ts new file mode 100644 index 0000000..22db13b --- /dev/null +++ b/lib/editor/geometry/geometryMetadata.ts @@ -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; + 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); +} + diff --git a/lib/editor/section/useSectionCommands.ts b/lib/editor/section/useSectionCommands.ts new file mode 100644 index 0000000..7893e5e --- /dev/null +++ b/lib/editor/section/useSectionCommands.ts @@ -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>; + setSelectedSectionId: Dispatch>; + setSectionState: Dispatch>; + setLastSectionSnapshot: Dispatch>; + setInitialData: Dispatch>; + setSectionCommits: Dispatch>; + setPendingEntityCreates: Dispatch>; + setCreatedEntities: Dispatch>; + setSelectedFeatureId: Dispatch>; + setEntityFormStatus: Dispatch>; + setEntityStatus: Dispatch>; + setIsSaving: Dispatch>; + setIsSubmitting: Dispatch>; + setIsOpeningSection: Dispatch>; + setAvailableSections: Dispatch>; + setNewSectionTitle: Dispatch>; + setCommitTitle: Dispatch>; + setCommitNote: Dispatch>; +}; + +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, + }; +} + diff --git a/lib/editor/session/sessionTypes.ts b/lib/editor/session/sessionTypes.ts new file mode 100644 index 0000000..4f776cd --- /dev/null +++ b/lib/editor/session/sessionTypes.ts @@ -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; + diff --git a/lib/editor/session/useBackgroundSessionState.ts b/lib/editor/session/useBackgroundSessionState.ts new file mode 100644 index 0000000..2684d3b --- /dev/null +++ b/lib/editor/session/useBackgroundSessionState.ts @@ -0,0 +1,20 @@ +import { useState } from "react"; +import { + BackgroundLayerVisibility, + HIDDEN_BACKGROUND_LAYER_VISIBILITY, +} from "@/lib/backgroundLayers"; + +export function useBackgroundSessionState() { + const [backgroundVisibility, setBackgroundVisibility] = useState( + () => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }) + ); + const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); + + return { + backgroundVisibility, + setBackgroundVisibility, + isBackgroundVisibilityReady, + setIsBackgroundVisibilityReady, + }; +} + diff --git a/lib/editor/session/useEntitySessionState.ts b/lib/editor/session/useEntitySessionState.ts new file mode 100644 index 0000000..7c50ac5 --- /dev/null +++ b/lib/editor/session/useEntitySessionState.ts @@ -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([]); + const [pendingEntityCreates, setPendingEntityCreates] = useState([]); + const [createdEntities, setCreatedEntities] = useState([]); + const [entityStatus, setEntityStatus] = useState(null); + const [selectedFeatureId, setSelectedFeatureId] = useState(null); + const [entityForm, setEntityForm] = useState({ + name: "", + slug: "", + type_id: DEFAULT_ENTITY_TYPE_ID, + }); + const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState([]); + const [geometryMetaForm, setGeometryMetaForm] = useState({ + time_start: "", + time_end: "", + binding: "", + }); + const [isEntitySubmitting, setIsEntitySubmitting] = useState(false); + const [entityFormStatus, setEntityFormStatus] = useState(null); + const [entitySearchQuery, setEntitySearchQuery] = useState(""); + const [entitySearchResults, setEntitySearchResults] = useState([]); + const [selectedSearchEntityId, setSelectedSearchEntityId] = useState(null); + const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false); + + 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, + }; +} diff --git a/lib/editor/session/useSectionSessionState.ts b/lib/editor/session/useSectionSessionState.ts new file mode 100644 index 0000000..5a8f13c --- /dev/null +++ b/lib/editor/session/useSectionSessionState.ts @@ -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("idle"); + const setTaskFlag = useCallback((task: Exclude, next: SetStateAction) => { + 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> = useCallback((next) => { + setTaskFlag("saving", next); + }, [setTaskFlag]); + const setIsSubmitting: Dispatch> = useCallback((next) => { + setTaskFlag("submitting", next); + }, [setTaskFlag]); + const setIsOpeningSection: Dispatch> = useCallback((next) => { + setTaskFlag("opening-section", next); + }, [setTaskFlag]); + + const [availableSections, setAvailableSections] = useState([]); + 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
(null); + const [sectionState, setSectionState] = useState(null); + const [sectionCommits, setSectionCommits] = useState([]); + const [lastSectionSnapshot, setLastSectionSnapshot] = useState(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, + }; +} diff --git a/lib/editor/session/useTimelineState.ts b/lib/editor/session/useTimelineState.ts new file mode 100644 index 0000000..82f3e08 --- /dev/null +++ b/lib/editor/session/useTimelineState.ts @@ -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(() => + clampYearValue( + options.currentYear, + options.fallbackTimelineRange.min, + options.fallbackTimelineRange.max + ) + ); + const [timelineDraftYear, setTimelineDraftYear] = useState(() => + clampYearValue( + options.currentYear, + options.fallbackTimelineRange.min, + options.fallbackTimelineRange.max + ) + ); + const [isTimelineLoading, setIsTimelineLoading] = useState(false); + const [timelineStatus, setTimelineStatus] = useState(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; +} diff --git a/lib/editor/snapshot/editorSnapshot.ts b/lib/editor/snapshot/editorSnapshot.ts new file mode 100644 index 0000000..322f1a1 --- /dev/null +++ b/lib/editor/snapshot/editorSnapshot.ts @@ -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") + .map((change) => String(change.id)) + ); + const currentDraftIds = new Set(options.draft.features.map((feature) => String(feature.properties.id))); + const previousFeatures = new globalThis.Map(); + 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(); + 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(); + 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(); + 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)); +} diff --git a/lib/circleEngine.ts b/lib/engine/circleEngine.ts similarity index 98% rename from lib/circleEngine.ts rename to lib/engine/circleEngine.ts index c81ba60..dbceaee 100644 --- a/lib/circleEngine.ts +++ b/lib/engine/circleEngine.ts @@ -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. diff --git a/lib/drawingEngine.ts b/lib/engine/drawingEngine.ts similarity index 80% rename from lib/drawingEngine.ts rename to lib/engine/drawingEngine.ts index 5157715..5eb4820 100644 --- a/lib/drawingEngine.ts +++ b/lib/engine/drawingEngine.ts @@ -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, }; } diff --git a/lib/editingEngine.ts b/lib/engine/editingEngine.ts similarity index 100% rename from lib/editingEngine.ts rename to lib/engine/editingEngine.ts diff --git a/lib/lineEngine.ts b/lib/engine/lineEngine.ts similarity index 97% rename from lib/lineEngine.ts rename to lib/engine/lineEngine.ts index 9dfc077..f09629e 100644 --- a/lib/lineEngine.ts +++ b/lib/engine/lineEngine.ts @@ -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, + }; } diff --git a/lib/pathEngine.ts b/lib/engine/pathEngine.ts similarity index 97% rename from lib/pathEngine.ts rename to lib/engine/pathEngine.ts index 0c38b63..b243fea 100644 --- a/lib/pathEngine.ts +++ b/lib/engine/pathEngine.ts @@ -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, + }; } diff --git a/lib/pointEngine.ts b/lib/engine/pointEngine.ts similarity index 100% rename from lib/pointEngine.ts rename to lib/engine/pointEngine.ts diff --git a/lib/selectingEngine.ts b/lib/engine/selectingEngine.ts similarity index 97% rename from lib/selectingEngine.ts rename to lib/engine/selectingEngine.ts index 789c4f6..c760991 100644 --- a/lib/selectingEngine.ts +++ b/lib/engine/selectingEngine.ts @@ -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) { diff --git a/lib/useEditorSessionState.ts b/lib/useEditorSessionState.ts new file mode 100644 index 0000000..2961545 --- /dev/null +++ b/lib/useEditorSessionState.ts @@ -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("idle"); + const [initialData, setInitialData] = useState(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, + }; +} diff --git a/lib/useEditorState.ts b/lib/useEditorState.ts index 5d1f42e..7faf752 100644 --- a/lib/useEditorState.ts +++ b/lib/useEditorState.ts @@ -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 = (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(); - 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 -) { - 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 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; - 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; - } -} +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(() => deepClone(initialData)); - const [undoStack, setUndoStack] = useState([]); + const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData); - // 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 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 ) { - 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); } diff --git a/types/api.ts b/types/api.ts new file mode 100644 index 0000000..a721c2c --- /dev/null +++ b/types/api.ts @@ -0,0 +1,15 @@ +export type ApiEnvelope = { + status: "success" | "error" | string; + data: T; + message: string; + errors: unknown[]; +}; + +export type GeometriesBBoxQuery = { + minLng: number; + minLat: number; + maxLng: number; + maxLat: number; + time?: number; + entity_id?: string; +}; diff --git a/types/entities.ts b/types/entities.ts new file mode 100644 index 0000000..aeae4f4 --- /dev/null +++ b/types/entities.ts @@ -0,0 +1,26 @@ +export type Entity = { + id: string; + name: string; + slug?: string | null; + description?: string | null; + type_id?: string | null; + status?: number | null; + geometry_count?: number; + created_at?: string; + updated_at?: string; +}; + +export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference" | "replace"; + +export type EntitySnapshot = { + id: string; + operation: EntitySnapshotOperation; + name?: string; + slug?: string | null; + description?: string | null; + type_id?: string | null; + status?: number | null; + is_deleted?: number; + base_updated_at?: string; + base_hash?: string; +}; diff --git a/types/geo.ts b/types/geo.ts new file mode 100644 index 0000000..48c5699 --- /dev/null +++ b/types/geo.ts @@ -0,0 +1,70 @@ +import type { EntityGeometryPreset } from "@/lib/entityTypeOptions"; + +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 FeatureId = string | number; + +export type FeatureProperties = { + id: FeatureId; + type?: string | null; + geometry_preset?: EntityGeometryPreset | 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[]; +}; + +export type GeometrySnapshotOperation = "create" | "update" | "delete" | "reference" | "replace"; + +export type GeometrySnapshot = { + id: string; + operation: GeometrySnapshotOperation; + type?: string | null; + draw_geometry?: Geometry; + geometry?: Geometry; + binding?: string[]; + time_start?: number | null; + time_end?: number | null; + bbox?: { + min_lng: number; + min_lat: number; + max_lng: number; + max_lat: number; + } | null; + is_deleted?: number; + base_updated_at?: string; + base_hash?: string; +}; + +export type LinkScopeSnapshot = { + geometry_id: string; + operation: "replace" | "reference"; + entity_ids: string[]; + base_links_hash?: string; +}; + +export type GeometryChange = + | { action: "create"; feature: Feature } + | { action: "update"; id: FeatureId; geometry: Geometry } + | { action: "delete"; id: FeatureId }; diff --git a/types/sections.ts b/types/sections.ts new file mode 100644 index 0000000..cbb638e --- /dev/null +++ b/types/sections.ts @@ -0,0 +1,104 @@ +import type { EntitySnapshot } from "@/types/entities"; +import type { FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/types/geo"; + +export type SectionStatus = "editing" | "submitted" | "approved" | "rejected"; +export type SectionCommitKind = "manual" | "restore"; +export type SectionSubmissionStatus = "pending" | "approved" | "rejected" | "conflicted"; + +export type SectionState = { + section_id?: string; + status: SectionStatus; + head_commit_id: string | null; + version: number; + locked_by: string | null; + locked_at: string | null; + lock_expires_at: string | null; + updated_at?: string; +}; + +export type Section = { + id: string; + title: string; + description: string | null; + user_id: string | null; + created_by: string | null; + created_at: string; + updated_at: string; + state: Omit; +}; + +export type SectionCommit = { + id: string; + section_id: string; + parent_commit_id: string | null; + commit_no: number; + kind: SectionCommitKind; + restored_from_commit_id: string | null; + created_by: string; + created_at: string; + title: string | null; + note: string | null; + snapshot_hash: string | null; + snapshot?: EditorSnapshot | null; +}; + +export type SectionSubmission = { + id: string; + section_id: string; + commit_id: string; + submitted_by: string; + submitted_at: string; + status: SectionSubmissionStatus; + reviewed_by: string | null; + reviewed_at: string | null; + review_note: string | null; + snapshot_hash: string | null; + snapshot?: EditorSnapshot | null; +}; + +export type EditorSnapshot = { + schema_version: number; + section: { + id: string; + title: string; + }; + editor_feature_collection?: FeatureCollection; + entities?: EntitySnapshot[]; + geometries?: GeometrySnapshot[]; + link_scopes?: LinkScopeSnapshot[]; +}; + +export type EditorLoadResponse = { + section: Section; + state: SectionState; + commit: SectionCommit | null; + snapshot: EditorSnapshot | null; +}; + +export type CreateSectionInput = { + id?: string; + title: string; + description?: string | null; + user_id?: string; + created_by?: string; +}; + +export type CreateCommitInput = { + snapshot: EditorSnapshot; + created_by?: string; + user_id?: string; + expected_version?: number; + expected_head_commit_id?: string | null; + title?: string | null; + note?: string | null; +}; + +export type RestoreCommitInput = { + commit_id: string; + created_by?: string; + user_id?: string; + expected_version?: number; + expected_head_commit_id?: string | null; + title?: string | null; + note?: string | null; +};