From 3ca7098831800e3633fdd4726f1a6b48f16fc865 Mon Sep 17 00:00:00 2001 From: taDuc Date: Mon, 20 Apr 2026 23:27:38 +0700 Subject: [PATCH 1/3] 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; +}; From 845bfd41a1c13983e3b6e2e5dfc16988dc41d455 Mon Sep 17 00:00:00 2001 From: taDuc Date: Tue, 21 Apr 2026 16:07:21 +0700 Subject: [PATCH 2/3] finish refactor pre merge --- app/editor/featureCommands.ts | 114 ++ app/editor/page.tsx | 107 +- app/globals.css | 2 +- app/layout.tsx | 6 +- app/page.tsx | 48 +- app/submited/page.tsx | 1316 +--------------- app/submitted/page.tsx | 1317 +++++++++++++++++ components/Editor.tsx | 12 +- components/Map.tsx | 285 ++-- components/SelectedGeometryPanel.tsx | 13 +- components/TimelineBar.tsx | 45 +- lib/editor/draft/useDraftState.ts | 2 + lib/editor/draft/useUndoStack.ts | 1 + .../session/useBackgroundSessionState.ts | 3 +- lib/editor/session/useEntitySessionState.ts | 14 + lib/editor/session/useSectionSessionState.ts | 11 + lib/editor/session/useTimelineState.ts | 13 +- lib/engine/circleEngine.ts | 3 +- lib/engine/drawingEngine.ts | 3 +- lib/engine/engineTypes.ts | 4 + lib/engine/lineEngine.ts | 3 +- lib/engine/pathEngine.ts | 3 +- lib/engine/pointEngine.ts | 3 +- lib/engine/selectingEngine.ts | 3 +- lib/geo/constants.ts | 14 + lib/map/constants.ts | 13 + lib/map/style.ts | 76 + lib/timeline.ts | 25 + lib/useEditorSessionState.ts | 2 + lib/useEditorState.ts | 2 + 30 files changed, 1832 insertions(+), 1631 deletions(-) create mode 100644 app/editor/featureCommands.ts create mode 100644 app/submitted/page.tsx create mode 100644 lib/engine/engineTypes.ts create mode 100644 lib/geo/constants.ts create mode 100644 lib/map/constants.ts create mode 100644 lib/map/style.ts create mode 100644 lib/timeline.ts diff --git a/app/editor/featureCommands.ts b/app/editor/featureCommands.ts new file mode 100644 index 0000000..850ea19 --- /dev/null +++ b/app/editor/featureCommands.ts @@ -0,0 +1,114 @@ +"use client"; + +import { useCallback } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import type { Entity } from "@/types/entities"; +import type { Feature, FeatureProperties } from "@/types/geo"; +import { ApiError } from "@/api/http"; +import { buildFeatureEntityPatch } from "@/lib/editor/entity/entityBinding"; +import { buildGeometryMetadataPatch } from "@/lib/editor/geometry/geometryMetadata"; +import { uniqueEntityIds } from "@/lib/editor/snapshot/editorSnapshot"; +import type { GeometryMetaFormState } from "@/lib/editor/session/sessionTypes"; + +type EditorDraftApi = { + patchFeatureProperties: (id: FeatureProperties["id"], patch: Partial) => void; +}; + +type Options = { + editor: EditorDraftApi; + selectedFeature: Feature | null; + geometryMetaForm: GeometryMetaFormState; + setGeometryMetaForm: Dispatch>; + selectedGeometryEntityIds: string[]; + setSelectedGeometryEntityIds: Dispatch>; + entities: Entity[]; + setIsEntitySubmitting: Dispatch>; + setEntityFormStatus: Dispatch>; +}; + +export function useFeatureCommands(options: Options) { + const { + editor, + selectedFeature, + geometryMetaForm, + setGeometryMetaForm, + selectedGeometryEntityIds, + setSelectedGeometryEntityIds, + entities, + setIsEntitySubmitting, + setEntityFormStatus, + } = options; + + const applyGeometryMetadata = useCallback(async () => { + if (!selectedFeature) { + setEntityFormStatus("Hãy chọn một geometry trước."); + return; + } + + let metadata; + try { + metadata = buildGeometryMetadataPatch(geometryMetaForm); + } catch (err) { + setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ."); + return; + } + + setIsEntitySubmitting(true); + setEntityFormStatus(null); + try { + 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); + } + }, [ + editor, + geometryMetaForm, + selectedFeature, + setEntityFormStatus, + setGeometryMetaForm, + setIsEntitySubmitting, + ]); + + const applyEntitiesToSelectedGeometry = useCallback(async () => { + if (!selectedFeature) { + setEntityFormStatus("Hãy chọn một geometry trước."); + return; + } + + const entityIds = uniqueEntityIds(selectedGeometryEntityIds); + setIsEntitySubmitting(true); + setEntityFormStatus(null); + try { + 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) { + setEntityFormStatus(`Lưu thất bại: ${err.body}`); + } else { + setEntityFormStatus("Lưu thất bại."); + } + } finally { + setIsEntitySubmitting(false); + } + }, [ + editor, + entities, + selectedFeature, + selectedGeometryEntityIds, + setEntityFormStatus, + setIsEntitySubmitting, + setSelectedGeometryEntityIds, + ]); + + return { + applyGeometryMetadata, + applyEntitiesToSelectedGeometry, + }; +} + diff --git a/app/editor/page.tsx b/app/editor/page.tsx index a9d1f65..c0849fe 100644 --- a/app/editor/page.tsx +++ b/app/editor/page.tsx @@ -15,7 +15,6 @@ import { } from "@/api/sections"; import { Feature, - FeatureCollection, useEditorState, } from "@/lib/useEditorState"; import { @@ -33,7 +32,6 @@ import { import { EntityFormState, PendingEntityCreate, - TimelineRange, useEditorSessionState, } from "@/lib/useEditorSessionState"; import { @@ -44,13 +42,11 @@ import { } 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 { @@ -58,20 +54,11 @@ import { persistBackgroundLayerVisibility, } from "@/lib/editor/background/backgroundVisibilityStorage"; import { useSectionCommands } from "@/lib/editor/section/useSectionCommands"; +import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/lib/geo/constants"; +import { FIXED_TIMELINE_RANGE, clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/lib/timeline"; +import { useFeatureCommands } from "./featureCommands"; -const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; -const WORLD_BBOX = { - minLng: -180, - minLat: -90, - maxLng: 180, - maxLat: 90, -} as const; const CURRENT_YEAR = new Date().getUTCFullYear(); -const FALLBACK_TIMELINE_RANGE: TimelineRange = { - min: -2000, - max: 2000, -}; -const TIMELINE_DEBOUNCE_MS = 180; const DEFAULT_EDITOR_USER_ID = "local-editor"; export default function Page() { @@ -147,12 +134,14 @@ export default function Page() { isBackgroundVisibilityReady, setIsBackgroundVisibilityReady, } = useEditorSessionState({ - emptyFeatureCollection: EMPTY_FC, + emptyFeatureCollection: EMPTY_FEATURE_COLLECTION, defaultEditorUserId: DEFAULT_EDITOR_USER_ID, - fallbackTimelineRange: FALLBACK_TIMELINE_RANGE, + fallbackTimelineRange: FIXED_TIMELINE_RANGE, currentYear: CURRENT_YEAR, }); + // Counter để bỏ qua response cũ khi user đổi timeline/section liên tục. const timelineFetchRequestRef = useRef(0); + // Counter để bỏ qua response cũ khi user gõ search entity liên tục. const entitySearchRequestRef = useRef(0); const editor = useEditorState(initialData); @@ -196,7 +185,7 @@ export default function Page() { const sectionCommands = useSectionCommands({ editor, editorUserId, - emptyFeatureCollection: EMPTY_FC, + emptyFeatureCollection: EMPTY_FEATURE_COLLECTION, activeSection, sectionState, selectedSectionId, @@ -534,7 +523,7 @@ export default function Page() { }; const handleTimelineYearChange = (nextYear: number) => { - setTimelineDraftYear(clampYear(Math.trunc(nextYear), FALLBACK_TIMELINE_RANGE)); + setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear))); }; const handleEntityFormChange = (key: keyof EntityFormState, value: string) => { @@ -562,57 +551,17 @@ export default function Page() { setEntityFormStatus(null); }; - const handleApplyGeometryMetadata = async () => { - if (!selectedFeature) { - setEntityFormStatus("Hãy chọn một geometry trước."); - return; - } - - let metadata; - try { - metadata = buildGeometryMetadataPatch(geometryMetaForm); - } catch (err) { - setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ."); - return; - } - - setIsEntitySubmitting(true); - setEntityFormStatus(null); - try { - 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); - } - }; - - const handleApplyEntitiesForSelectedGeometry = async () => { - if (!selectedFeature) { - setEntityFormStatus("Hãy chọn một geometry trước."); - return; - } - - const entityIds = uniqueEntityIds(selectedGeometryEntityIds); - setIsEntitySubmitting(true); - setEntityFormStatus(null); - try { - 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) { - setEntityFormStatus(`Lưu thất bại: ${err.body}`); - } else { - setEntityFormStatus("Lưu thất bại."); - } - } finally { - setIsEntitySubmitting(false); - } - }; + const featureCommands = useFeatureCommands({ + editor, + selectedFeature, + geometryMetaForm, + setGeometryMetaForm, + selectedGeometryEntityIds, + setSelectedGeometryEntityIds, + entities, + setIsEntitySubmitting, + setEntityFormStatus, + }); const handleCreateEntityOnly = async () => { const name = entityForm.name.trim(); @@ -797,8 +746,8 @@ export default function Page() { onGeometryMetaFormChange={handleGeometryMetaFormChange} isEntitySubmitting={isEntitySubmitting} onCreateEntityOnly={handleCreateEntityOnly} - onApplyGeometryMetadata={handleApplyGeometryMetadata} - onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry} + onApplyGeometryMetadata={featureCommands.applyGeometryMetadata} + onApplyEntitiesForSelectedGeometry={featureCommands.applyEntitiesToSelectedGeometry} changeCount={editor.changeCount} entityFormStatus={entityFormStatus} /> @@ -813,18 +762,6 @@ function normalizeEditorUserId(value: string): string { return normalized || DEFAULT_EDITOR_USER_ID; } -function clampYear(year: number, range: TimelineRange): number { - return clampYearValue(year, range.min, range.max); -} - -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; -} - function formatCommitTitle(commit: SectionCommit): string { return commit.title?.trim() || `Commit #${commit.commit_no}`; } diff --git a/app/globals.css b/app/globals.css index a2dc41e..0065625 100644 --- a/app/globals.css +++ b/app/globals.css @@ -22,5 +22,5 @@ body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-sans), system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; } diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..dc5d315 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Ultimate History Map", + description: "Map editor and viewer for historical entities, geometries, and timelines.", }; export default function RootLayout({ @@ -23,7 +23,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + diff --git a/app/page.tsx b/app/page.tsx index 12481b5..291a2d4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -19,41 +19,35 @@ import { loadBackgroundLayerVisibilityFromStorage, persistBackgroundLayerVisibility, } from "@/lib/editor/background/backgroundVisibilityStorage"; +import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/lib/geo/constants"; +import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/lib/timeline"; -const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; -const WORLD_BBOX = { - minLng: -180, - minLat: -90, - maxLng: 180, - maxLat: 90, -} as const; const CURRENT_YEAR = new Date().getUTCFullYear(); -const FALLBACK_TIMELINE_RANGE: TimelineRange = { - min: -2000, - max: 2000, -}; -const TIMELINE_DEBOUNCE_MS = 180; - -type TimelineRange = { - min: number; - max: number; -}; export default function Page() { - const [initialData, setInitialData] = useState(EMPTY_FC); + // Dữ liệu GeoJSON hiện đang hiển thị trên map (theo timelineYear). + const [initialData, setInitialData] = useState(EMPTY_FEATURE_COLLECTION); + // ID geometry đang được chọn trên map (null nếu chưa chọn). const [selectedFeatureId, setSelectedFeatureId] = useState(null); + // Năm timeline "đã chốt" để trigger fetch dữ liệu. const [timelineYear, setTimelineYear] = useState(() => - clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max) + clampYearToFixedRange(CURRENT_YEAR) ); + // Năm timeline đang kéo/nhập (debounce rồi mới đẩy sang timelineYear). const [timelineDraftYear, setTimelineDraftYear] = useState(() => - clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max) + clampYearToFixedRange(CURRENT_YEAR) ); + // Cờ loading khi fetch geometries theo timeline. const [isTimelineLoading, setIsTimelineLoading] = useState(false); + // Thông báo trạng thái/lỗi khi load timeline. const [timelineStatus, setTimelineStatus] = useState(null); + // Trạng thái bật/tắt các layer nền. const [backgroundVisibility, setBackgroundVisibility] = useState( () => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }) ); + // Đảm bảo đã load backgroundVisibility từ storage trước khi render map. const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); + // Counter để bỏ qua response cũ khi user đổi timeline liên tục. const timelineFetchRequestRef = useRef(0); const selectedFeature = @@ -154,7 +148,7 @@ export default function Page() { }; const handleTimelineYearChange = (nextYear: number) => { - setTimelineDraftYear(clampYear(Math.trunc(nextYear), FALLBACK_TIMELINE_RANGE)); + setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear))); }; const selectedType = resolveSelectedTypeSummary(selectedFeature); @@ -250,18 +244,6 @@ export default function Page() { ); } -function clampYear(year: number, range: TimelineRange): number { - return clampYearValue(year, range.min, range.max); -} - -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; -} - function isYearNumber(value: number | null | undefined): value is number { return typeof value === "number" && Number.isFinite(value); } diff --git a/app/submited/page.tsx b/app/submited/page.tsx index 1088df4..69df5ce 100644 --- a/app/submited/page.tsx +++ b/app/submited/page.tsx @@ -1,1317 +1,5 @@ "use client"; -import { type CSSProperties, useEffect, useMemo, useState } from "react"; -import Map from "@/components/Map"; -import { ApiError } from "@/api/http"; -import { - approveSubmission, - fetchSections, - fetchSectionSubmissions, - rejectSubmission, - Section, - SectionSubmission, -} from "@/api/sections"; -import { HIDDEN_BACKGROUND_LAYER_VISIBILITY } from "@/lib/backgroundLayers"; -import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState"; +// Backward-compat route (typo). Keep this thin wrapper so canonical code lives in `/submitted`. +export { default } from "../submitted/page"; -type SubmissionStatusFilter = "pending" | "all"; -type ReviewAction = "approve" | "reject"; - -type SubmissionRow = { - section: Section; - submission: SectionSubmission; -}; - -type SnapshotSummary = { - featureCount: number; - entityCount: number; - geometryCount: number; - linkScopeCount: number; - entityOperations: Record; - geometryOperations: Record; - linkOperations: Record; -}; - -const DEFAULT_REVIEWER_ID = "admin"; -const EMPTY_FEATURE_COLLECTION: FeatureCollection = { - type: "FeatureCollection", - features: [], -}; -const REVIEW_BACKGROUND_LAYER_VISIBILITY = { - ...HIDDEN_BACKGROUND_LAYER_VISIBILITY, - "bg-countries-fill": true, - "bg-country-borders-line": true, -}; - -const STATUS_LABELS: Record = { - pending: "Chờ duyệt", - approved: "Đã duyệt", - rejected: "Từ chối", - conflicted: "Conflict", -}; - -export default function SubmittedPage() { - const [rows, setRows] = useState([]); - const [selectedSubmissionId, setSelectedSubmissionId] = useState(null); - const [statusFilter, setStatusFilter] = useState("pending"); - const [reviewerId, setReviewerId] = useState(DEFAULT_REVIEWER_ID); - const [reviewNote, setReviewNote] = useState(""); - const [isLoading, setIsLoading] = useState(true); - const [reviewingSubmissionId, setReviewingSubmissionId] = useState(null); - const [selectedPreviewFeatureId, setSelectedPreviewFeatureId] = useState(null); - const [isJsonPopupOpen, setIsJsonPopupOpen] = useState(false); - const [statusMessage, setStatusMessage] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - - const loadSubmissions = async () => { - setIsLoading(true); - setErrorMessage(null); - setStatusMessage(null); - - try { - const sections = await fetchSections(); - const submissionGroups = await Promise.all( - sections.map(async (section) => { - const submissions = await fetchSectionSubmissions(section.id, { - includeSnapshot: true, - }); - return submissions.map((submission) => ({ - section, - submission, - })); - }) - ); - const nextRows = submissionGroups - .flat() - .sort((a, b) => - Date.parse(b.submission.submitted_at) - Date.parse(a.submission.submitted_at) - ); - - setRows(nextRows); - setSelectedSubmissionId((current) => { - if (current && nextRows.some((row) => row.submission.id === current)) { - return current; - } - return nextRows.find((row) => row.submission.status === "pending")?.submission.id - || nextRows[0]?.submission.id - || null; - }); - } catch (err) { - setErrorMessage(getErrorMessage(err, "Không tải được danh sách section đã submit.")); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - loadSubmissions(); - }, []); - - const visibleRows = useMemo(() => { - if (statusFilter === "all") return rows; - return rows.filter((row) => row.submission.status === "pending"); - }, [rows, statusFilter]); - - const selectedRow = useMemo(() => { - if (!selectedSubmissionId) return visibleRows[0] || null; - return ( - visibleRows.find((row) => row.submission.id === selectedSubmissionId) || - rows.find((row) => row.submission.id === selectedSubmissionId) || - visibleRows[0] || - null - ); - }, [rows, selectedSubmissionId, visibleRows]); - - const pendingCount = rows.filter((row) => row.submission.status === "pending").length; - const reviewedCount = rows.length - pendingCount; - const selectedSummary = selectedRow - ? summarizeSnapshot(selectedRow.submission.snapshot) - : null; - const selectedFeatureCollection = selectedRow - ? extractSnapshotFeatureCollection(selectedRow.submission.snapshot) - : EMPTY_FEATURE_COLLECTION; - const selectedPreviewFeature = selectedFeatureCollection.features.find((feature) => - String(feature.properties.id) === String(selectedPreviewFeatureId) - ) || null; - - useEffect(() => { - setSelectedPreviewFeatureId(null); - setIsJsonPopupOpen(false); - }, [selectedRow?.submission.id]); - - const handleReview = async (action: ReviewAction) => { - if (!selectedRow) return; - const actor = reviewerId.trim(); - if (!actor) { - setErrorMessage("Reviewer là bắt buộc."); - return; - } - if (selectedRow.submission.status !== "pending") { - setErrorMessage("Submission này không còn ở trạng thái chờ duyệt."); - return; - } - - const label = action === "approve" ? "duyệt" : "từ chối"; - const confirmed = window.confirm(`Xác nhận ${label} section "${selectedRow.section.title}"?`); - if (!confirmed) return; - - setReviewingSubmissionId(selectedRow.submission.id); - setErrorMessage(null); - setStatusMessage(null); - - try { - const input = { - reviewed_by: actor, - review_note: reviewNote.trim() || null, - }; - const updatedSubmission = action === "approve" - ? await approveSubmission(selectedRow.submission.id, input) - : await rejectSubmission(selectedRow.submission.id, input); - - setRows((currentRows) => - currentRows.map((row) => - row.submission.id === updatedSubmission.id - ? { - ...row, - section: { - ...row.section, - state: { - ...row.section.state, - status: updatedSubmission.status === "approved" - ? "approved" - : updatedSubmission.status === "rejected" - ? "rejected" - : row.section.state.status, - }, - }, - submission: updatedSubmission, - } - : row - ) - ); - setReviewNote(""); - setStatusMessage( - action === "approve" - ? "Đã duyệt submission và apply vào published data." - : "Đã từ chối submission." - ); - } catch (err) { - setErrorMessage(getErrorMessage(err, `Không thể ${label} submission.`)); - } finally { - setReviewingSubmissionId(null); - } - }; - - const isReviewingSelected = reviewingSubmissionId === selectedRow?.submission.id; - const canReviewSelected = Boolean( - selectedRow && - selectedRow.submission.status === "pending" && - !isReviewingSelected - ); - - return ( -
-
-
-

Review sections

-

Submitted sections

-

- Xem các section đã submit, kiểm tra snapshot và duyệt dữ liệu vào published map. -

-
-
- Mở editor - -
-
- -
- - - -
- - {statusMessage ?
{statusMessage}
: null} - {errorMessage ?
{errorMessage}
: null} - -
- - - -
- -
-
-
-

Sections

- {visibleRows.length} item -
- - {isLoading ? ( -
Đang tải submissions...
- ) : visibleRows.length === 0 ? ( -
- Không có submission nào trong bộ lọc hiện tại. -
- ) : ( -
- {visibleRows.map((row) => { - const selected = selectedRow?.submission.id === row.submission.id; - return ( - - ); - })} -
- )} -
- -
- {!selectedRow ? ( -
Chọn một section để xem chi tiết.
- ) : ( - <> -
-
-

{selectedRow.section.title}

-

- Section status: {selectedRow.section.state.status} · Version {selectedRow.section.state.version} -

-
- -
- -
- - - - - - -
- -
- -
- - {selectedRow.submission.review_note ? ( -
- Review note -

{selectedRow.submission.review_note}

-
- ) : null} - - {selectedSummary ? ( - <> -

Map preview

- {selectedFeatureCollection.features.length > 0 ? ( -
-
- -
-
- Selected geometry - {selectedPreviewFeature ? ( -
- - - - - - -
- ) : ( -

- Chọn một geometry trên preview để xem metadata. -

- )} -
-
- ) : ( -
- Submission này không có geometry renderable trong phần cần duyệt. - Nếu đây là thay đổi delete-only, hãy xem phần operation summary bên dưới. -
- )} - -

Snapshot

-
- - - - -
-
- - - -
- - ) : ( -
Submission này không có snapshot để hiển thị.
- )} - -
- - -
- - setIsJsonPopupOpen(false)} - /> - - )} -
-
-
- ); -} - -function Metric({ label, value }: { label: string; value: number }) { - return ( -
- {value} - {label} -
- ); -} - -function Info({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} - -function JsonDataPopup({ - open, - title, - data, - onClose, - }: { - open: boolean; - title: string; - data: unknown; - onClose: () => void; -}) { - useEffect(() => { - if (!open) return; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - onClose(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [open, onClose]); - - if (!open) return null; - - return ( -
-
event.stopPropagation()} - style={styles.jsonDialog} - > -
-
-

{title}

-

Raw data của submission đang duyệt.

-
- -
-
{JSON.stringify(data, null, 2)}
-
-
- ); -} - -function StatusBadge({ status }: { status: SectionSubmission["status"] }) { - return ( - - {STATUS_LABELS[status] || status} - - ); -} - -function OperationBlock({ - title, - operations, -}: { - title: string; - operations: Record; -}) { - const entries = Object.entries(operations); - return ( -
- {title} - {entries.length === 0 ? ( -

Không có operation.

- ) : ( -
- {entries.map(([operation, count]) => ( - - {operation}: {count} - - ))} -
- )} -
- ); -} - -function extractSnapshotFeatureCollection(rawSnapshot: unknown): FeatureCollection { - if (!rawSnapshot || typeof rawSnapshot !== "object" || Array.isArray(rawSnapshot)) { - return EMPTY_FEATURE_COLLECTION; - } - - const snapshot = rawSnapshot as Record; - const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; - const reviewGeometryIds = getReviewGeometryIds(snapshot); - const renderableGeometryIds = new Set(); - for (const item of geometries) { - if (!item || typeof item !== "object" || Array.isArray(item)) continue; - const source = item as Record; - const id = normalizeSnapshotId(source.id); - if (!id || !reviewGeometryIds.has(id)) continue; - if (normalizeOperationName(source.operation) === "delete") continue; - renderableGeometryIds.add(id); - } - - const editorCollection = snapshot.editor_feature_collection; - if (isFeatureCollection(editorCollection)) { - return { - type: "FeatureCollection", - features: editorCollection.features - .filter((feature) => renderableGeometryIds.has(String(feature.properties.id))) - .map(cloneFeature), - }; - } - - const features = geometries - .map((item): Feature | null => { - if (!item || typeof item !== "object" || Array.isArray(item)) return null; - const source = item as Record; - - const id = normalizeSnapshotId(source.id); - if (!id || !renderableGeometryIds.has(id)) return null; - const geometry = source.draw_geometry || source.geometry; - if ((typeof id !== "string" && typeof id !== "number") || !isGeometry(geometry)) { - return null; - } - - return { - type: "Feature", - properties: { - id, - type: typeof source.type === "string" ? source.type : null, - geometry_preset: null, - entity_id: null, - entity_ids: [], - entity_name: null, - entity_type_id: null, - binding: normalizeStringArray(source.binding), - time_start: normalizeOptionalNumber(source.time_start), - time_end: normalizeOptionalNumber(source.time_end), - }, - geometry: cloneGeometry(geometry), - }; - }) - .filter((feature): feature is Feature => feature !== null); - - return { - type: "FeatureCollection", - features, - }; -} - -function summarizeSnapshot(rawSnapshot: unknown): SnapshotSummary | null { - if (!rawSnapshot || typeof rawSnapshot !== "object" || Array.isArray(rawSnapshot)) { - return null; - } - - const snapshot = rawSnapshot as Record; - const entities = Array.isArray(snapshot.entities) ? snapshot.entities : []; - const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; - const linkScopes = Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []; - const reviewGeometryIds = getReviewGeometryIds(snapshot); - const reviewEntities = entities.filter((item) => - normalizeOperationName(getRecord(item)?.operation) !== "reference" - ); - const reviewGeometries = geometries.filter((item) => { - const record = getRecord(item); - if (!record) return false; - const id = normalizeSnapshotId(record.id); - return Boolean(id && reviewGeometryIds.has(id)); - }); - - return { - featureCount: extractSnapshotFeatureCollection(rawSnapshot).features.length, - entityCount: reviewEntities.length, - geometryCount: reviewGeometries.length, - linkScopeCount: linkScopes.length, - entityOperations: countOperations(reviewEntities), - geometryOperations: countOperations(reviewGeometries), - linkOperations: countOperations(linkScopes), - }; -} - -function getReviewGeometryIds(snapshot: Record): Set { - const ids = new Set(); - const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; - const linkScopes = Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []; - - for (const item of geometries) { - const record = getRecord(item); - if (!record) continue; - const id = normalizeSnapshotId(record.id); - if (!id) continue; - const operation = normalizeOperationName(record.operation); - if (operation && operation !== "reference") { - ids.add(id); - } - } - - for (const item of linkScopes) { - const record = getRecord(item); - if (!record) continue; - const geometryId = normalizeSnapshotId(record.geometry_id); - if (geometryId) ids.add(geometryId); - } - - return ids; -} - -function getRecord(value: unknown): Record | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - return value as Record; -} - -function normalizeSnapshotId(value: unknown): string | null { - if (typeof value !== "string" && typeof value !== "number") return null; - const normalized = String(value).trim(); - return normalized.length ? normalized : null; -} - -function normalizeOperationName(value: unknown): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - -function isFeatureCollection(value: unknown): value is FeatureCollection { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const source = value as Record; - return source.type === "FeatureCollection" && - Array.isArray(source.features) && - source.features.every(isFeature); -} - -function isFeature(value: unknown): value is Feature { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const source = value as Record; - const properties = source.properties; - if (source.type !== "Feature" || !isGeometry(source.geometry)) return false; - if (!properties || typeof properties !== "object" || Array.isArray(properties)) return false; - const id = (properties as Record).id; - return typeof id === "string" || typeof id === "number"; -} - -function isGeometry(value: unknown): value is Geometry { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const source = value as Record; - return typeof source.type === "string" && Array.isArray(source.coordinates); -} - -function cloneFeature(feature: Feature): Feature { - return JSON.parse(JSON.stringify(feature)) as Feature; -} - -function cloneGeometry(geometry: Geometry): Geometry { - return JSON.parse(JSON.stringify(geometry)) as Geometry; -} - -function normalizeStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value - .map((item) => { - if (typeof item !== "string" && typeof item !== "number") return ""; - return String(item).trim(); - }) - .filter((item) => item.length > 0); -} - -function normalizeOptionalNumber(value: unknown): number | null { - if (value === null || value === undefined || value === "") return null; - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; -} - -function formatFeatureEntities(feature: Feature): string { - const named = normalizeStringArray(feature.properties.entity_names); - if (named.length > 0) return named.join(", "); - if (feature.properties.entity_name) return feature.properties.entity_name; - const ids = normalizeStringArray(feature.properties.entity_ids); - if (ids.length > 0) return ids.join(", "); - return feature.properties.entity_id || "Chưa gắn"; -} - -function formatFeatureTimeRange(feature: Feature): string { - const start = feature.properties.time_start; - const end = feature.properties.time_end; - if (start == null && end == null) return "Không giới hạn"; - if (start != null && end != null) return `${start} - ${end}`; - if (start != null) return `Từ ${start}`; - return `Đến ${end}`; -} - -function formatFeatureBinding(feature: Feature): string { - const binding = normalizeStringArray(feature.properties.binding); - return binding.length > 0 ? binding.join(", ") : "Không có"; -} - -function countOperations(items: unknown[]): Record { - return items.reduce>((acc, item) => { - const source = item && typeof item === "object" && !Array.isArray(item) - ? item as Record - : {}; - const operation = typeof source.operation === "string" && source.operation.trim() - ? source.operation.trim() - : "unknown"; - acc[operation] = (acc[operation] || 0) + 1; - return acc; - }, {}); -} - -function formatDateTime(value: string | null | undefined): string { - if (!value) return "Chưa có"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return new Intl.DateTimeFormat("vi-VN", { - dateStyle: "medium", - timeStyle: "short", - }).format(date); -} - -function getErrorMessage(err: unknown, fallback: string): string { - if (err instanceof ApiError) { - const parsed = parseErrorBody(err.body); - return parsed || err.body || fallback; - } - if (err instanceof Error) return err.message; - return fallback; -} - -function parseErrorBody(body: string): string | null { - try { - const parsed = JSON.parse(body) as unknown; - if (parsed && typeof parsed === "object" && "error" in parsed) { - const error = (parsed as { error?: unknown }).error; - return typeof error === "string" ? error : null; - } - } catch { - return null; - } - return null; -} - -function getStatusStyle(status: SectionSubmission["status"]): CSSProperties { - if (status === "approved") { - return { - background: "#d9f5df", - color: "#17652a", - borderColor: "#8bd19a", - }; - } - if (status === "rejected") { - return { - background: "#ffe2dd", - color: "#8a1f12", - borderColor: "#ef9a8d", - }; - } - if (status === "conflicted") { - return { - background: "#fff1c7", - color: "#755400", - borderColor: "#dfbf59", - }; - } - return { - background: "#e2f1ff", - color: "#0c4f85", - borderColor: "#8bbde4", - }; -} - -const styles: Record = { - page: { - minHeight: "100vh", - padding: "32px", - background: "#f7f8f3", - color: "#1f241f", - }, - header: { - display: "flex", - justifyContent: "space-between", - gap: "20px", - alignItems: "flex-start", - flexWrap: "wrap", - marginBottom: "22px", - }, - kicker: { - margin: "0 0 8px", - color: "#4f6f39", - fontSize: "13px", - fontWeight: 700, - textTransform: "uppercase", - }, - title: { - margin: 0, - fontSize: "36px", - lineHeight: 1.15, - letterSpacing: 0, - }, - subtitle: { - maxWidth: "680px", - margin: "10px 0 0", - color: "#596055", - lineHeight: 1.5, - }, - headerActions: { - display: "flex", - gap: "10px", - flexWrap: "wrap", - justifyContent: "flex-end", - }, - primaryButton: { - minHeight: "38px", - border: "1px solid #284f2f", - borderRadius: "8px", - padding: "8px 14px", - background: "#2f6d3a", - color: "#ffffff", - fontWeight: 700, - cursor: "pointer", - }, - secondaryLink: { - minHeight: "38px", - border: "1px solid #c7cebc", - borderRadius: "8px", - padding: "8px 14px", - background: "#ffffff", - color: "#2a3326", - fontWeight: 700, - textDecoration: "none", - }, - statsGrid: { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", - gap: "12px", - marginBottom: "16px", - }, - metric: { - border: "1px solid #d5dbc8", - borderRadius: "8px", - padding: "14px", - background: "#ffffff", - }, - metricValue: { - display: "block", - fontSize: "28px", - lineHeight: 1.1, - fontWeight: 800, - color: "#254b2b", - }, - metricLabel: { - display: "block", - marginTop: "6px", - color: "#5a6356", - fontSize: "13px", - }, - successBanner: { - border: "1px solid #9dcc9c", - borderRadius: "8px", - padding: "12px 14px", - marginBottom: "12px", - background: "#e6f6e5", - color: "#1d5b25", - }, - errorBanner: { - border: "1px solid #ef9a8d", - borderRadius: "8px", - padding: "12px 14px", - marginBottom: "12px", - background: "#ffe2dd", - color: "#8a1f12", - }, - toolbar: { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 220px), 1fr))", - gap: "12px", - marginBottom: "16px", - alignItems: "end", - }, - fieldLabel: { - display: "grid", - gap: "6px", - color: "#424a3f", - fontSize: "13px", - fontWeight: 700, - }, - fieldLabelWide: { - display: "grid", - gap: "6px", - color: "#424a3f", - fontSize: "13px", - fontWeight: 700, - }, - input: { - width: "100%", - minHeight: "38px", - border: "1px solid #c9d0bd", - borderRadius: "8px", - padding: "8px 10px", - background: "#ffffff", - color: "#202820", - outline: "none", - }, - contentGrid: { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 360px), 1fr))", - gap: "16px", - alignItems: "start", - }, - listPanel: { - border: "1px solid #d5dbc8", - borderRadius: "8px", - background: "#ffffff", - minHeight: "520px", - overflow: "hidden", - }, - detailPanel: { - border: "1px solid #d5dbc8", - borderRadius: "8px", - background: "#ffffff", - minHeight: "520px", - padding: "18px", - }, - panelHeader: { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - padding: "14px", - borderBottom: "1px solid #e3e7da", - }, - panelTitle: { - margin: 0, - fontSize: "18px", - }, - mutedText: { - margin: 0, - color: "#66705f", - fontSize: "13px", - lineHeight: 1.45, - }, - emptyState: { - padding: "18px", - color: "#66705f", - lineHeight: 1.5, - }, - submissionList: { - display: "grid", - gap: "8px", - padding: "10px", - maxHeight: "calc(100vh - 310px)", - overflow: "auto", - }, - submissionItem: { - width: "100%", - display: "grid", - gap: "6px", - textAlign: "left", - border: "1px solid #e1e5d9", - borderRadius: "8px", - padding: "12px", - background: "#ffffff", - color: "#202820", - cursor: "pointer", - }, - submissionItemSelected: { - border: "1px solid #71a66a", - background: "#f0f8ee", - }, - itemTopLine: { - display: "flex", - gap: "8px", - justifyContent: "space-between", - alignItems: "center", - }, - itemTitle: { - minWidth: 0, - overflowWrap: "anywhere", - }, - itemMeta: { - color: "#5f695a", - fontSize: "13px", - overflowWrap: "anywhere", - }, - statusBadge: { - flex: "0 0 auto", - display: "inline-flex", - alignItems: "center", - minHeight: "24px", - border: "1px solid", - borderRadius: "8px", - padding: "2px 8px", - fontSize: "12px", - fontWeight: 800, - whiteSpace: "nowrap", - }, - detailHeader: { - display: "flex", - justifyContent: "space-between", - gap: "16px", - alignItems: "flex-start", - marginBottom: "16px", - }, - detailTitle: { - margin: "0 0 6px", - fontSize: "24px", - lineHeight: 1.25, - overflowWrap: "anywhere", - }, - metaGrid: { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", - gap: "10px", - marginBottom: "16px", - }, - infoCell: { - border: "1px solid #e1e5d9", - borderRadius: "8px", - padding: "10px", - background: "#fbfcf8", - minWidth: 0, - }, - infoLabel: { - display: "block", - color: "#66705f", - fontSize: "12px", - fontWeight: 700, - marginBottom: "5px", - }, - infoValue: { - display: "block", - color: "#202820", - fontSize: "13px", - overflowWrap: "anywhere", - }, - noteBox: { - border: "1px solid #e1e5d9", - borderRadius: "8px", - padding: "12px", - marginBottom: "16px", - background: "#fbfcf8", - }, - noteText: { - margin: "6px 0 0", - color: "#40483c", - lineHeight: 1.5, - overflowWrap: "anywhere", - }, - dataActions: { - display: "flex", - justifyContent: "flex-end", - marginBottom: "16px", - }, - secondaryButton: { - minHeight: "38px", - border: "1px solid #c7cebc", - borderRadius: "8px", - padding: "8px 14px", - background: "#ffffff", - color: "#2a3326", - fontWeight: 800, - cursor: "pointer", - }, - sectionHeading: { - margin: "18px 0 10px", - fontSize: "18px", - }, - summaryGrid: { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", - gap: "10px", - marginBottom: "12px", - }, - operationGrid: { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", - gap: "10px", - }, - operationBlock: { - border: "1px solid #e1e5d9", - borderRadius: "8px", - padding: "12px", - background: "#fbfcf8", - }, - operationRows: { - display: "flex", - flexWrap: "wrap", - gap: "8px", - marginTop: "10px", - }, - operationPill: { - border: "1px solid #cfd8c2", - borderRadius: "8px", - padding: "4px 8px", - background: "#ffffff", - color: "#394136", - fontSize: "13px", - }, - previewGrid: { - display: "grid", - gridTemplateColumns: "minmax(min(100%, 420px), 1fr) minmax(min(100%, 260px), 320px)", - gap: "12px", - marginBottom: "16px", - alignItems: "stretch", - }, - mapPreview: { - height: "420px", - minHeight: "320px", - border: "1px solid #d5dbc8", - borderRadius: "8px", - overflow: "hidden", - background: "#0b1220", - }, - featurePanel: { - border: "1px solid #e1e5d9", - borderRadius: "8px", - padding: "12px", - background: "#fbfcf8", - minWidth: 0, - }, - featureDetails: { - display: "grid", - gap: "8px", - marginTop: "10px", - }, - reviewActions: { - display: "flex", - justifyContent: "flex-end", - gap: "10px", - marginTop: "22px", - paddingTop: "16px", - borderTop: "1px solid #e3e7da", - }, - dangerButton: { - minHeight: "40px", - border: "1px solid #a93322", - borderRadius: "8px", - padding: "8px 16px", - background: "#b94330", - color: "#ffffff", - fontWeight: 800, - cursor: "pointer", - }, - approveButton: { - minHeight: "40px", - border: "1px solid #276131", - borderRadius: "8px", - padding: "8px 16px", - background: "#2f7a3b", - color: "#ffffff", - fontWeight: 800, - cursor: "pointer", - }, - jsonOverlay: { - position: "fixed", - inset: 0, - zIndex: 1000, - padding: "24px", - background: "rgba(31, 36, 31, 0.72)", - display: "flex", - alignItems: "center", - justifyContent: "center", - }, - jsonDialog: { - width: "min(980px, calc(100vw - 48px))", - maxHeight: "min(760px, calc(100vh - 48px))", - border: "1px solid #9da891", - borderRadius: "8px", - background: "#ffffff", - color: "#1f241f", - boxShadow: "0 24px 70px rgba(0, 0, 0, 0.32)", - display: "flex", - flexDirection: "column", - overflow: "hidden", - }, - jsonDialogHeader: { - display: "flex", - alignItems: "flex-start", - justifyContent: "space-between", - gap: "16px", - padding: "14px 16px", - borderBottom: "1px solid #dfe5d6", - background: "#f7f8f3", - }, - jsonDialogTitle: { - margin: "0 0 5px", - fontSize: "18px", - overflowWrap: "anywhere", - }, - jsonDialogSubtitle: { - margin: 0, - color: "#66705f", - fontSize: "13px", - }, - jsonCloseButton: { - minHeight: "34px", - border: "1px solid #c7cebc", - borderRadius: "8px", - padding: "7px 12px", - background: "#ffffff", - color: "#2a3326", - fontWeight: 800, - cursor: "pointer", - }, - jsonPre: { - margin: 0, - padding: "16px", - overflow: "auto", - background: "#10140f", - color: "#edf7e7", - fontSize: "12px", - lineHeight: 1.55, - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", - tabSize: 2, - }, -}; diff --git a/app/submitted/page.tsx b/app/submitted/page.tsx new file mode 100644 index 0000000..1088df4 --- /dev/null +++ b/app/submitted/page.tsx @@ -0,0 +1,1317 @@ +"use client"; + +import { type CSSProperties, useEffect, useMemo, useState } from "react"; +import Map from "@/components/Map"; +import { ApiError } from "@/api/http"; +import { + approveSubmission, + fetchSections, + fetchSectionSubmissions, + rejectSubmission, + Section, + SectionSubmission, +} from "@/api/sections"; +import { HIDDEN_BACKGROUND_LAYER_VISIBILITY } from "@/lib/backgroundLayers"; +import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState"; + +type SubmissionStatusFilter = "pending" | "all"; +type ReviewAction = "approve" | "reject"; + +type SubmissionRow = { + section: Section; + submission: SectionSubmission; +}; + +type SnapshotSummary = { + featureCount: number; + entityCount: number; + geometryCount: number; + linkScopeCount: number; + entityOperations: Record; + geometryOperations: Record; + linkOperations: Record; +}; + +const DEFAULT_REVIEWER_ID = "admin"; +const EMPTY_FEATURE_COLLECTION: FeatureCollection = { + type: "FeatureCollection", + features: [], +}; +const REVIEW_BACKGROUND_LAYER_VISIBILITY = { + ...HIDDEN_BACKGROUND_LAYER_VISIBILITY, + "bg-countries-fill": true, + "bg-country-borders-line": true, +}; + +const STATUS_LABELS: Record = { + pending: "Chờ duyệt", + approved: "Đã duyệt", + rejected: "Từ chối", + conflicted: "Conflict", +}; + +export default function SubmittedPage() { + const [rows, setRows] = useState([]); + const [selectedSubmissionId, setSelectedSubmissionId] = useState(null); + const [statusFilter, setStatusFilter] = useState("pending"); + const [reviewerId, setReviewerId] = useState(DEFAULT_REVIEWER_ID); + const [reviewNote, setReviewNote] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [reviewingSubmissionId, setReviewingSubmissionId] = useState(null); + const [selectedPreviewFeatureId, setSelectedPreviewFeatureId] = useState(null); + const [isJsonPopupOpen, setIsJsonPopupOpen] = useState(false); + const [statusMessage, setStatusMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + const loadSubmissions = async () => { + setIsLoading(true); + setErrorMessage(null); + setStatusMessage(null); + + try { + const sections = await fetchSections(); + const submissionGroups = await Promise.all( + sections.map(async (section) => { + const submissions = await fetchSectionSubmissions(section.id, { + includeSnapshot: true, + }); + return submissions.map((submission) => ({ + section, + submission, + })); + }) + ); + const nextRows = submissionGroups + .flat() + .sort((a, b) => + Date.parse(b.submission.submitted_at) - Date.parse(a.submission.submitted_at) + ); + + setRows(nextRows); + setSelectedSubmissionId((current) => { + if (current && nextRows.some((row) => row.submission.id === current)) { + return current; + } + return nextRows.find((row) => row.submission.status === "pending")?.submission.id + || nextRows[0]?.submission.id + || null; + }); + } catch (err) { + setErrorMessage(getErrorMessage(err, "Không tải được danh sách section đã submit.")); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadSubmissions(); + }, []); + + const visibleRows = useMemo(() => { + if (statusFilter === "all") return rows; + return rows.filter((row) => row.submission.status === "pending"); + }, [rows, statusFilter]); + + const selectedRow = useMemo(() => { + if (!selectedSubmissionId) return visibleRows[0] || null; + return ( + visibleRows.find((row) => row.submission.id === selectedSubmissionId) || + rows.find((row) => row.submission.id === selectedSubmissionId) || + visibleRows[0] || + null + ); + }, [rows, selectedSubmissionId, visibleRows]); + + const pendingCount = rows.filter((row) => row.submission.status === "pending").length; + const reviewedCount = rows.length - pendingCount; + const selectedSummary = selectedRow + ? summarizeSnapshot(selectedRow.submission.snapshot) + : null; + const selectedFeatureCollection = selectedRow + ? extractSnapshotFeatureCollection(selectedRow.submission.snapshot) + : EMPTY_FEATURE_COLLECTION; + const selectedPreviewFeature = selectedFeatureCollection.features.find((feature) => + String(feature.properties.id) === String(selectedPreviewFeatureId) + ) || null; + + useEffect(() => { + setSelectedPreviewFeatureId(null); + setIsJsonPopupOpen(false); + }, [selectedRow?.submission.id]); + + const handleReview = async (action: ReviewAction) => { + if (!selectedRow) return; + const actor = reviewerId.trim(); + if (!actor) { + setErrorMessage("Reviewer là bắt buộc."); + return; + } + if (selectedRow.submission.status !== "pending") { + setErrorMessage("Submission này không còn ở trạng thái chờ duyệt."); + return; + } + + const label = action === "approve" ? "duyệt" : "từ chối"; + const confirmed = window.confirm(`Xác nhận ${label} section "${selectedRow.section.title}"?`); + if (!confirmed) return; + + setReviewingSubmissionId(selectedRow.submission.id); + setErrorMessage(null); + setStatusMessage(null); + + try { + const input = { + reviewed_by: actor, + review_note: reviewNote.trim() || null, + }; + const updatedSubmission = action === "approve" + ? await approveSubmission(selectedRow.submission.id, input) + : await rejectSubmission(selectedRow.submission.id, input); + + setRows((currentRows) => + currentRows.map((row) => + row.submission.id === updatedSubmission.id + ? { + ...row, + section: { + ...row.section, + state: { + ...row.section.state, + status: updatedSubmission.status === "approved" + ? "approved" + : updatedSubmission.status === "rejected" + ? "rejected" + : row.section.state.status, + }, + }, + submission: updatedSubmission, + } + : row + ) + ); + setReviewNote(""); + setStatusMessage( + action === "approve" + ? "Đã duyệt submission và apply vào published data." + : "Đã từ chối submission." + ); + } catch (err) { + setErrorMessage(getErrorMessage(err, `Không thể ${label} submission.`)); + } finally { + setReviewingSubmissionId(null); + } + }; + + const isReviewingSelected = reviewingSubmissionId === selectedRow?.submission.id; + const canReviewSelected = Boolean( + selectedRow && + selectedRow.submission.status === "pending" && + !isReviewingSelected + ); + + return ( +
+
+
+

Review sections

+

Submitted sections

+

+ Xem các section đã submit, kiểm tra snapshot và duyệt dữ liệu vào published map. +

+
+
+ Mở editor + +
+
+ +
+ + + +
+ + {statusMessage ?
{statusMessage}
: null} + {errorMessage ?
{errorMessage}
: null} + +
+ + + +
+ +
+
+
+

Sections

+ {visibleRows.length} item +
+ + {isLoading ? ( +
Đang tải submissions...
+ ) : visibleRows.length === 0 ? ( +
+ Không có submission nào trong bộ lọc hiện tại. +
+ ) : ( +
+ {visibleRows.map((row) => { + const selected = selectedRow?.submission.id === row.submission.id; + return ( + + ); + })} +
+ )} +
+ +
+ {!selectedRow ? ( +
Chọn một section để xem chi tiết.
+ ) : ( + <> +
+
+

{selectedRow.section.title}

+

+ Section status: {selectedRow.section.state.status} · Version {selectedRow.section.state.version} +

+
+ +
+ +
+ + + + + + +
+ +
+ +
+ + {selectedRow.submission.review_note ? ( +
+ Review note +

{selectedRow.submission.review_note}

+
+ ) : null} + + {selectedSummary ? ( + <> +

Map preview

+ {selectedFeatureCollection.features.length > 0 ? ( +
+
+ +
+
+ Selected geometry + {selectedPreviewFeature ? ( +
+ + + + + + +
+ ) : ( +

+ Chọn một geometry trên preview để xem metadata. +

+ )} +
+
+ ) : ( +
+ Submission này không có geometry renderable trong phần cần duyệt. + Nếu đây là thay đổi delete-only, hãy xem phần operation summary bên dưới. +
+ )} + +

Snapshot

+
+ + + + +
+
+ + + +
+ + ) : ( +
Submission này không có snapshot để hiển thị.
+ )} + +
+ + +
+ + setIsJsonPopupOpen(false)} + /> + + )} +
+
+
+ ); +} + +function Metric({ label, value }: { label: string; value: number }) { + return ( +
+ {value} + {label} +
+ ); +} + +function Info({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function JsonDataPopup({ + open, + title, + data, + onClose, + }: { + open: boolean; + title: string; + data: unknown; + onClose: () => void; +}) { + useEffect(() => { + if (!open) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+
event.stopPropagation()} + style={styles.jsonDialog} + > +
+
+

{title}

+

Raw data của submission đang duyệt.

+
+ +
+
{JSON.stringify(data, null, 2)}
+
+
+ ); +} + +function StatusBadge({ status }: { status: SectionSubmission["status"] }) { + return ( + + {STATUS_LABELS[status] || status} + + ); +} + +function OperationBlock({ + title, + operations, +}: { + title: string; + operations: Record; +}) { + const entries = Object.entries(operations); + return ( +
+ {title} + {entries.length === 0 ? ( +

Không có operation.

+ ) : ( +
+ {entries.map(([operation, count]) => ( + + {operation}: {count} + + ))} +
+ )} +
+ ); +} + +function extractSnapshotFeatureCollection(rawSnapshot: unknown): FeatureCollection { + if (!rawSnapshot || typeof rawSnapshot !== "object" || Array.isArray(rawSnapshot)) { + return EMPTY_FEATURE_COLLECTION; + } + + const snapshot = rawSnapshot as Record; + const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; + const reviewGeometryIds = getReviewGeometryIds(snapshot); + const renderableGeometryIds = new Set(); + for (const item of geometries) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const source = item as Record; + const id = normalizeSnapshotId(source.id); + if (!id || !reviewGeometryIds.has(id)) continue; + if (normalizeOperationName(source.operation) === "delete") continue; + renderableGeometryIds.add(id); + } + + const editorCollection = snapshot.editor_feature_collection; + if (isFeatureCollection(editorCollection)) { + return { + type: "FeatureCollection", + features: editorCollection.features + .filter((feature) => renderableGeometryIds.has(String(feature.properties.id))) + .map(cloneFeature), + }; + } + + const features = geometries + .map((item): Feature | null => { + if (!item || typeof item !== "object" || Array.isArray(item)) return null; + const source = item as Record; + + const id = normalizeSnapshotId(source.id); + if (!id || !renderableGeometryIds.has(id)) return null; + const geometry = source.draw_geometry || source.geometry; + if ((typeof id !== "string" && typeof id !== "number") || !isGeometry(geometry)) { + return null; + } + + return { + type: "Feature", + properties: { + id, + type: typeof source.type === "string" ? source.type : null, + geometry_preset: null, + entity_id: null, + entity_ids: [], + entity_name: null, + entity_type_id: null, + binding: normalizeStringArray(source.binding), + time_start: normalizeOptionalNumber(source.time_start), + time_end: normalizeOptionalNumber(source.time_end), + }, + geometry: cloneGeometry(geometry), + }; + }) + .filter((feature): feature is Feature => feature !== null); + + return { + type: "FeatureCollection", + features, + }; +} + +function summarizeSnapshot(rawSnapshot: unknown): SnapshotSummary | null { + if (!rawSnapshot || typeof rawSnapshot !== "object" || Array.isArray(rawSnapshot)) { + return null; + } + + const snapshot = rawSnapshot as Record; + const entities = Array.isArray(snapshot.entities) ? snapshot.entities : []; + const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; + const linkScopes = Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []; + const reviewGeometryIds = getReviewGeometryIds(snapshot); + const reviewEntities = entities.filter((item) => + normalizeOperationName(getRecord(item)?.operation) !== "reference" + ); + const reviewGeometries = geometries.filter((item) => { + const record = getRecord(item); + if (!record) return false; + const id = normalizeSnapshotId(record.id); + return Boolean(id && reviewGeometryIds.has(id)); + }); + + return { + featureCount: extractSnapshotFeatureCollection(rawSnapshot).features.length, + entityCount: reviewEntities.length, + geometryCount: reviewGeometries.length, + linkScopeCount: linkScopes.length, + entityOperations: countOperations(reviewEntities), + geometryOperations: countOperations(reviewGeometries), + linkOperations: countOperations(linkScopes), + }; +} + +function getReviewGeometryIds(snapshot: Record): Set { + const ids = new Set(); + const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; + const linkScopes = Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []; + + for (const item of geometries) { + const record = getRecord(item); + if (!record) continue; + const id = normalizeSnapshotId(record.id); + if (!id) continue; + const operation = normalizeOperationName(record.operation); + if (operation && operation !== "reference") { + ids.add(id); + } + } + + for (const item of linkScopes) { + const record = getRecord(item); + if (!record) continue; + const geometryId = normalizeSnapshotId(record.geometry_id); + if (geometryId) ids.add(geometryId); + } + + return ids; +} + +function getRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +function normalizeSnapshotId(value: unknown): string | null { + if (typeof value !== "string" && typeof value !== "number") return null; + const normalized = String(value).trim(); + return normalized.length ? normalized : null; +} + +function normalizeOperationName(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function isFeatureCollection(value: unknown): value is FeatureCollection { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const source = value as Record; + return source.type === "FeatureCollection" && + Array.isArray(source.features) && + source.features.every(isFeature); +} + +function isFeature(value: unknown): value is Feature { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const source = value as Record; + const properties = source.properties; + if (source.type !== "Feature" || !isGeometry(source.geometry)) return false; + if (!properties || typeof properties !== "object" || Array.isArray(properties)) return false; + const id = (properties as Record).id; + return typeof id === "string" || typeof id === "number"; +} + +function isGeometry(value: unknown): value is Geometry { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const source = value as Record; + return typeof source.type === "string" && Array.isArray(source.coordinates); +} + +function cloneFeature(feature: Feature): Feature { + return JSON.parse(JSON.stringify(feature)) as Feature; +} + +function cloneGeometry(geometry: Geometry): Geometry { + return JSON.parse(JSON.stringify(geometry)) as Geometry; +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + if (typeof item !== "string" && typeof item !== "number") return ""; + return String(item).trim(); + }) + .filter((item) => item.length > 0); +} + +function normalizeOptionalNumber(value: unknown): number | null { + if (value === null || value === undefined || value === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function formatFeatureEntities(feature: Feature): string { + const named = normalizeStringArray(feature.properties.entity_names); + if (named.length > 0) return named.join(", "); + if (feature.properties.entity_name) return feature.properties.entity_name; + const ids = normalizeStringArray(feature.properties.entity_ids); + if (ids.length > 0) return ids.join(", "); + return feature.properties.entity_id || "Chưa gắn"; +} + +function formatFeatureTimeRange(feature: Feature): string { + const start = feature.properties.time_start; + const end = feature.properties.time_end; + if (start == null && end == null) return "Không giới hạn"; + if (start != null && end != null) return `${start} - ${end}`; + if (start != null) return `Từ ${start}`; + return `Đến ${end}`; +} + +function formatFeatureBinding(feature: Feature): string { + const binding = normalizeStringArray(feature.properties.binding); + return binding.length > 0 ? binding.join(", ") : "Không có"; +} + +function countOperations(items: unknown[]): Record { + return items.reduce>((acc, item) => { + const source = item && typeof item === "object" && !Array.isArray(item) + ? item as Record + : {}; + const operation = typeof source.operation === "string" && source.operation.trim() + ? source.operation.trim() + : "unknown"; + acc[operation] = (acc[operation] || 0) + 1; + return acc; + }, {}); +} + +function formatDateTime(value: string | null | undefined): string { + if (!value) return "Chưa có"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("vi-VN", { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +function getErrorMessage(err: unknown, fallback: string): string { + if (err instanceof ApiError) { + const parsed = parseErrorBody(err.body); + return parsed || err.body || fallback; + } + if (err instanceof Error) return err.message; + return fallback; +} + +function parseErrorBody(body: string): string | null { + try { + const parsed = JSON.parse(body) as unknown; + if (parsed && typeof parsed === "object" && "error" in parsed) { + const error = (parsed as { error?: unknown }).error; + return typeof error === "string" ? error : null; + } + } catch { + return null; + } + return null; +} + +function getStatusStyle(status: SectionSubmission["status"]): CSSProperties { + if (status === "approved") { + return { + background: "#d9f5df", + color: "#17652a", + borderColor: "#8bd19a", + }; + } + if (status === "rejected") { + return { + background: "#ffe2dd", + color: "#8a1f12", + borderColor: "#ef9a8d", + }; + } + if (status === "conflicted") { + return { + background: "#fff1c7", + color: "#755400", + borderColor: "#dfbf59", + }; + } + return { + background: "#e2f1ff", + color: "#0c4f85", + borderColor: "#8bbde4", + }; +} + +const styles: Record = { + page: { + minHeight: "100vh", + padding: "32px", + background: "#f7f8f3", + color: "#1f241f", + }, + header: { + display: "flex", + justifyContent: "space-between", + gap: "20px", + alignItems: "flex-start", + flexWrap: "wrap", + marginBottom: "22px", + }, + kicker: { + margin: "0 0 8px", + color: "#4f6f39", + fontSize: "13px", + fontWeight: 700, + textTransform: "uppercase", + }, + title: { + margin: 0, + fontSize: "36px", + lineHeight: 1.15, + letterSpacing: 0, + }, + subtitle: { + maxWidth: "680px", + margin: "10px 0 0", + color: "#596055", + lineHeight: 1.5, + }, + headerActions: { + display: "flex", + gap: "10px", + flexWrap: "wrap", + justifyContent: "flex-end", + }, + primaryButton: { + minHeight: "38px", + border: "1px solid #284f2f", + borderRadius: "8px", + padding: "8px 14px", + background: "#2f6d3a", + color: "#ffffff", + fontWeight: 700, + cursor: "pointer", + }, + secondaryLink: { + minHeight: "38px", + border: "1px solid #c7cebc", + borderRadius: "8px", + padding: "8px 14px", + background: "#ffffff", + color: "#2a3326", + fontWeight: 700, + textDecoration: "none", + }, + statsGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", + gap: "12px", + marginBottom: "16px", + }, + metric: { + border: "1px solid #d5dbc8", + borderRadius: "8px", + padding: "14px", + background: "#ffffff", + }, + metricValue: { + display: "block", + fontSize: "28px", + lineHeight: 1.1, + fontWeight: 800, + color: "#254b2b", + }, + metricLabel: { + display: "block", + marginTop: "6px", + color: "#5a6356", + fontSize: "13px", + }, + successBanner: { + border: "1px solid #9dcc9c", + borderRadius: "8px", + padding: "12px 14px", + marginBottom: "12px", + background: "#e6f6e5", + color: "#1d5b25", + }, + errorBanner: { + border: "1px solid #ef9a8d", + borderRadius: "8px", + padding: "12px 14px", + marginBottom: "12px", + background: "#ffe2dd", + color: "#8a1f12", + }, + toolbar: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 220px), 1fr))", + gap: "12px", + marginBottom: "16px", + alignItems: "end", + }, + fieldLabel: { + display: "grid", + gap: "6px", + color: "#424a3f", + fontSize: "13px", + fontWeight: 700, + }, + fieldLabelWide: { + display: "grid", + gap: "6px", + color: "#424a3f", + fontSize: "13px", + fontWeight: 700, + }, + input: { + width: "100%", + minHeight: "38px", + border: "1px solid #c9d0bd", + borderRadius: "8px", + padding: "8px 10px", + background: "#ffffff", + color: "#202820", + outline: "none", + }, + contentGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 360px), 1fr))", + gap: "16px", + alignItems: "start", + }, + listPanel: { + border: "1px solid #d5dbc8", + borderRadius: "8px", + background: "#ffffff", + minHeight: "520px", + overflow: "hidden", + }, + detailPanel: { + border: "1px solid #d5dbc8", + borderRadius: "8px", + background: "#ffffff", + minHeight: "520px", + padding: "18px", + }, + panelHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + padding: "14px", + borderBottom: "1px solid #e3e7da", + }, + panelTitle: { + margin: 0, + fontSize: "18px", + }, + mutedText: { + margin: 0, + color: "#66705f", + fontSize: "13px", + lineHeight: 1.45, + }, + emptyState: { + padding: "18px", + color: "#66705f", + lineHeight: 1.5, + }, + submissionList: { + display: "grid", + gap: "8px", + padding: "10px", + maxHeight: "calc(100vh - 310px)", + overflow: "auto", + }, + submissionItem: { + width: "100%", + display: "grid", + gap: "6px", + textAlign: "left", + border: "1px solid #e1e5d9", + borderRadius: "8px", + padding: "12px", + background: "#ffffff", + color: "#202820", + cursor: "pointer", + }, + submissionItemSelected: { + border: "1px solid #71a66a", + background: "#f0f8ee", + }, + itemTopLine: { + display: "flex", + gap: "8px", + justifyContent: "space-between", + alignItems: "center", + }, + itemTitle: { + minWidth: 0, + overflowWrap: "anywhere", + }, + itemMeta: { + color: "#5f695a", + fontSize: "13px", + overflowWrap: "anywhere", + }, + statusBadge: { + flex: "0 0 auto", + display: "inline-flex", + alignItems: "center", + minHeight: "24px", + border: "1px solid", + borderRadius: "8px", + padding: "2px 8px", + fontSize: "12px", + fontWeight: 800, + whiteSpace: "nowrap", + }, + detailHeader: { + display: "flex", + justifyContent: "space-between", + gap: "16px", + alignItems: "flex-start", + marginBottom: "16px", + }, + detailTitle: { + margin: "0 0 6px", + fontSize: "24px", + lineHeight: 1.25, + overflowWrap: "anywhere", + }, + metaGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", + gap: "10px", + marginBottom: "16px", + }, + infoCell: { + border: "1px solid #e1e5d9", + borderRadius: "8px", + padding: "10px", + background: "#fbfcf8", + minWidth: 0, + }, + infoLabel: { + display: "block", + color: "#66705f", + fontSize: "12px", + fontWeight: 700, + marginBottom: "5px", + }, + infoValue: { + display: "block", + color: "#202820", + fontSize: "13px", + overflowWrap: "anywhere", + }, + noteBox: { + border: "1px solid #e1e5d9", + borderRadius: "8px", + padding: "12px", + marginBottom: "16px", + background: "#fbfcf8", + }, + noteText: { + margin: "6px 0 0", + color: "#40483c", + lineHeight: 1.5, + overflowWrap: "anywhere", + }, + dataActions: { + display: "flex", + justifyContent: "flex-end", + marginBottom: "16px", + }, + secondaryButton: { + minHeight: "38px", + border: "1px solid #c7cebc", + borderRadius: "8px", + padding: "8px 14px", + background: "#ffffff", + color: "#2a3326", + fontWeight: 800, + cursor: "pointer", + }, + sectionHeading: { + margin: "18px 0 10px", + fontSize: "18px", + }, + summaryGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", + gap: "10px", + marginBottom: "12px", + }, + operationGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", + gap: "10px", + }, + operationBlock: { + border: "1px solid #e1e5d9", + borderRadius: "8px", + padding: "12px", + background: "#fbfcf8", + }, + operationRows: { + display: "flex", + flexWrap: "wrap", + gap: "8px", + marginTop: "10px", + }, + operationPill: { + border: "1px solid #cfd8c2", + borderRadius: "8px", + padding: "4px 8px", + background: "#ffffff", + color: "#394136", + fontSize: "13px", + }, + previewGrid: { + display: "grid", + gridTemplateColumns: "minmax(min(100%, 420px), 1fr) minmax(min(100%, 260px), 320px)", + gap: "12px", + marginBottom: "16px", + alignItems: "stretch", + }, + mapPreview: { + height: "420px", + minHeight: "320px", + border: "1px solid #d5dbc8", + borderRadius: "8px", + overflow: "hidden", + background: "#0b1220", + }, + featurePanel: { + border: "1px solid #e1e5d9", + borderRadius: "8px", + padding: "12px", + background: "#fbfcf8", + minWidth: 0, + }, + featureDetails: { + display: "grid", + gap: "8px", + marginTop: "10px", + }, + reviewActions: { + display: "flex", + justifyContent: "flex-end", + gap: "10px", + marginTop: "22px", + paddingTop: "16px", + borderTop: "1px solid #e3e7da", + }, + dangerButton: { + minHeight: "40px", + border: "1px solid #a93322", + borderRadius: "8px", + padding: "8px 16px", + background: "#b94330", + color: "#ffffff", + fontWeight: 800, + cursor: "pointer", + }, + approveButton: { + minHeight: "40px", + border: "1px solid #276131", + borderRadius: "8px", + padding: "8px 16px", + background: "#2f7a3b", + color: "#ffffff", + fontWeight: 800, + cursor: "pointer", + }, + jsonOverlay: { + position: "fixed", + inset: 0, + zIndex: 1000, + padding: "24px", + background: "rgba(31, 36, 31, 0.72)", + display: "flex", + alignItems: "center", + justifyContent: "center", + }, + jsonDialog: { + width: "min(980px, calc(100vw - 48px))", + maxHeight: "min(760px, calc(100vh - 48px))", + border: "1px solid #9da891", + borderRadius: "8px", + background: "#ffffff", + color: "#1f241f", + boxShadow: "0 24px 70px rgba(0, 0, 0, 0.32)", + display: "flex", + flexDirection: "column", + overflow: "hidden", + }, + jsonDialogHeader: { + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + gap: "16px", + padding: "14px 16px", + borderBottom: "1px solid #dfe5d6", + background: "#f7f8f3", + }, + jsonDialogTitle: { + margin: "0 0 5px", + fontSize: "18px", + overflowWrap: "anywhere", + }, + jsonDialogSubtitle: { + margin: 0, + color: "#66705f", + fontSize: "13px", + }, + jsonCloseButton: { + minHeight: "34px", + border: "1px solid #c7cebc", + borderRadius: "8px", + padding: "7px 12px", + background: "#ffffff", + color: "#2a3326", + fontWeight: 800, + cursor: "pointer", + }, + jsonPre: { + margin: 0, + padding: "16px", + overflow: "auto", + background: "#10140f", + color: "#edf7e7", + fontSize: "12px", + lineHeight: 1.55, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + tabSize: 2, + }, +}; diff --git a/components/Editor.tsx b/components/Editor.tsx index f06ea1e..3588b3a 100644 --- a/components/Editor.tsx +++ b/components/Editor.tsx @@ -3,12 +3,11 @@ import { useState } from "react"; import CommitTreePopup from "@/components/CommitTreePopup"; import { UndoAction } from "@/lib/useEditorState"; - -type Mode = "draw" | "select" | "idle" | "add-point" | "add-line" | "add-path" | "add-circle"; +import type { EditorMode } from "@/lib/editor/session/sessionTypes"; type Props = { - mode: Mode; - setMode: (mode: Mode) => void; + mode: EditorMode; + setMode: (mode: EditorMode) => void; entityStatus?: string | null; onUndo: () => void; onCommit: () => void; @@ -103,12 +102,13 @@ export default function Editor({ createdEntities, createdGeometries, }: Props) { + // Bật/tắt popup cây commit. const [isCommitTreeOpen, setIsCommitTreeOpen] = useState(false); const formatCommitTitle = (commit: Props["commits"][number]) => commit.title?.trim() || `Commit #${commit.commit_no}`; - const toggleMode = (newMode: Mode) => { + const toggleMode = (newMode: EditorMode) => { if (mode === newMode) { setMode("idle"); // bấm lại → tắt } else { @@ -129,7 +129,7 @@ export default function Editor({ return labels.reverse(); })(); - const getButtonStyle = (btnMode: Mode) => ({ + const getButtonStyle = (btnMode: EditorMode) => ({ width: "100%", padding: "8px", marginBottom: "6px", diff --git a/components/Map.tsx b/components/Map.tsx index b394504..0ed2795 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -14,9 +14,31 @@ 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"; +import type { EditorMode } from "@/lib/editor/session/sessionTypes"; +import { EMPTY_FEATURE_COLLECTION } from "@/lib/geo/constants"; +import { + DEFAULT_POINT_ICON_ID, + FEATURE_STATE_SOURCE_IDS, + MAP_MAX_ZOOM, + MAP_MIN_ZOOM, + PATH_ARROW_ICON_ID, + PATH_ARROW_SOURCE_ID, + POINT_ICON_URL, + RASTER_BASE_INSERT_BEFORE_LAYER_ID, + RASTER_BASE_LAYER_ID, + RASTER_BASE_SOURCE_ID, +} from "@/lib/map/constants"; +import { + COUNTRY_FILL_COLOR_EXPRESSION, + LINE_COLOR_BY_TYPE, + PATH_RENDER_BY_TYPE, + POLYGON_FILL_BY_TYPE, + POLYGON_OPACITY_BY_TYPE, + POLYGON_STROKE_BY_TYPE, +} from "@/lib/map/style"; type MapProps = { - mode: "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; + mode: EditorMode; draft: FeatureCollection; backgroundVisibility: BackgroundLayerVisibility; selectedFeatureId: string | number | null; @@ -37,93 +59,6 @@ type EngineBinding = { clearSelection?: () => void; }; -const DEFAULT_POINT_ICON_ID = "point-icon-default"; -const POINT_ICON_URL = "/point.png"; -const PATH_ARROW_ICON_ID = "path-arrow-icon"; -const MAP_MIN_ZOOM = 1; -const MAP_MAX_ZOOM = 10; -const RASTER_BASE_SOURCE_ID = "rasterBase"; -const RASTER_BASE_LAYER_ID = "raster-base-layer"; -const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line"; -const PATH_ARROW_SOURCE_ID = "path-arrow-shapes"; -const FEATURE_STATE_SOURCE_IDS = ["countries", "places", PATH_ARROW_SOURCE_ID] as const; -const EMPTY_FEATURE_COLLECTION: FeatureCollection = { - type: "FeatureCollection", - features: [], -}; -const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [ - "coalesce", - ["get", "MAPCOLOR7"], - ["get", "MAPCOLOR9"], - ["get", "scalerank"], - 0, -]; -const COUNTRY_FILL_COLOR_EXPRESSION: maplibregl.ExpressionSpecification = [ - "match", - COUNTRY_COLOR_KEY_EXPRESSION, - 1, "#ef4444", - 2, "#f97316", - 3, "#f59e0b", - 4, "#22c55e", - 5, "#06b6d4", - 6, "#3b82f6", - 7, "#8b5cf6", - 8, "#a855f7", - 9, "#d946ef", - 10, "#14b8a6", - "#64748b", -]; - -const POLYGON_FILL_BY_TYPE: Record = { - country: "#2563eb", - state: "#0ea5e9", - empire: "#f59e0b", - kingdom: "#d97706", - war: "#dc2626", - battle: "#f43f5e", - civilization: "#14b8a6", - rebellion_zone: "#7c3aed", -}; - -const POLYGON_STROKE_BY_TYPE: Record = { - country: "#1e3a8a", - state: "#0c4a6e", - empire: "#7c2d12", - kingdom: "#9a3412", - war: "#7f1d1d", - battle: "#9f1239", - civilization: "#134e4a", - rebellion_zone: "#4c1d95", -}; - -const POLYGON_OPACITY_BY_TYPE: Record = { - war: 0.3, - battle: 0.34, - civilization: 0.38, - rebellion_zone: 0.32, -}; - -const LINE_COLOR_BY_TYPE: Record = { - defense_line: "#f97316", - attack_route: "#ef4444", - retreat_route: "#94a3b8", - invasion_route: "#b91c1c", - migration_route: "#0ea5e9", - refugee_route: "#06b6d4", - trade_route: "#eab308", - shipping_route: "#2563eb", -}; - -const PATH_RENDER_BY_TYPE: Record = { - attack_route: true, - retreat_route: true, - invasion_route: true, - migration_route: true, - refugee_route: true, - trade_route: true, - shipping_route: true, -}; - export default function Map({ mode, draft, @@ -139,24 +74,57 @@ export default function Map({ fitToDraftBounds = false, fitBoundsKey = null, }: MapProps) { + // DOM container của map (dùng ref để tránh collision khi render nhiều map). + const containerRef = useRef(null); + // Nếu init map fail (throw trong onLoad), show overlay thay vì crash âm thầm. + const [fatalInitError, setFatalInitError] = useState(null); + + // Mirror các flags props để tránh phải re-create map khi props thay đổi. + const fitToDraftBoundsRef = useRef(fitToDraftBounds); + const respectBindingFilterRef = useRef(respectBindingFilter); + + // Instance maplibre (được tạo 1 lần khi component mount). const mapRef = useRef(null); + // Mirror của props mode để event handlers/engines đọc giá trị mới nhất (tránh stale closure). const modeRef = useRef(mode); + // Mirror của draft để engines đọc dữ liệu mới nhất trong callbacks. const draftRef = useRef(draft); + // Mirror của backgroundVisibility để sync visibility khi map đã load. const backgroundVisibilityRef = useRef(backgroundVisibility); + // Mirror của selectedFeatureId để filter/select trên map (không phụ thuộc re-render). const selectedFeatureIdRef = useRef(selectedFeatureId); + // Mirror của callback onSelectFeatureId. const onSelectFeatureIdRef = useRef(onSelectFeatureId); + // Mirror của callback onCreateFeature. const onCreateRef = useRef(onCreateFeature); + // Mirror của callback onDeleteFeature. const onDeleteRef = useRef(onDeleteFeature); + // Mirror của callback onUpdateFeature. const onUpdateRef = useRef(onUpdateFeature); + // Zoom hiện tại để render UI zoom control. const [zoomLevel, setZoomLevel] = useState(2); + // Min/max zoom dùng cho slider và clamp thao tác zoom. const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM }); + // Engine chỉnh sửa polygon (kéo đỉnh/insert đỉnh), chỉ khởi tạo 1 lần. const editingEngineRef = useRef | null>(null); + // Đánh dấu đã fitBounds cho fitBoundsKey hiện tại (tránh fit lặp). const fitBoundsAppliedRef = useRef(false); + // Danh sách cleanup fns để dọn listeners/engines khi unmount map. const mapCleanupFnsRef = useRef void>>([]); + // Các engine bindings theo mode để gọi cancel/cleanup khi đổi mode. const engineBindingsRef = useRef>>({}); + // Lưu mode trước đó để cancel engine đúng lúc khi switch mode. const previousModeRef = useRef(mode); + useEffect(() => { + fitToDraftBoundsRef.current = fitToDraftBounds; + }, [fitToDraftBounds]); + + useEffect(() => { + respectBindingFilterRef.current = respectBindingFilter; + }, [respectBindingFilter]); + useEffect(() => { modeRef.current = mode; }, [mode]); @@ -266,7 +234,7 @@ export default function Map({ } } - const visibleDraft = respectBindingFilter + const visibleDraft = respectBindingFilterRef.current ? filterDraftByBinding(fc, selectedFeatureIdRef.current) : fc; const { polygons, points } = splitDraftFeatures(visibleDraft); @@ -283,14 +251,17 @@ export default function Map({ if (mapRef.current !== map) return; setSelectedFeatureState(map, selectedId, true); }); - if (fitToDraftBounds && !fitBoundsAppliedRef.current) { + if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) { fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft); } - }, [fitToDraftBounds, respectBindingFilter]); + }, []); useEffect(() => { + const container = containerRef.current; + if (!container) return; + const map = new maplibregl.Map({ - container: "map", + container, attributionControl: false, minZoom: MAP_MIN_ZOOM, maxZoom: MAP_MAX_ZOOM, @@ -434,24 +405,25 @@ export default function Map({ mapRef.current = map; map.on("load", async () => { - const syncZoomLevel = () => { - setZoomLevel(roundZoom(map.getZoom())); - }; + try { + const syncZoomLevel = () => { + setZoomLevel(roundZoom(map.getZoom())); + }; - applyBackgroundLayerVisibility(map, backgroundVisibilityRef.current); - const hasPathArrowIcon = ensurePathArrowIcon(map); - setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM }); - syncZoomLevel(); - map.on("zoom", syncZoomLevel); + applyBackgroundLayerVisibility(map, backgroundVisibilityRef.current); + const hasPathArrowIcon = ensurePathArrowIcon(map); + setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM }); + syncZoomLevel(); + map.on("zoom", syncZoomLevel); - // preview (drawing) - map.addSource("draw-preview", { - type: "geojson", - data: { - type: "FeatureCollection", - features: [], - }, - }); + // preview (drawing) + map.addSource("draw-preview", { + type: "geojson", + data: { + type: "FeatureCollection", + features: [], + }, + }); map.addLayer({ id: "draw-preview-fill", @@ -821,7 +793,7 @@ export default function Map({ map, () => modeRef.current, (geometry: Geometry) => { - const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); + const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { @@ -860,7 +832,7 @@ export default function Map({ map, () => modeRef.current, (geometry: Geometry) => { - const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); + const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { @@ -882,7 +854,7 @@ export default function Map({ map, () => modeRef.current, (geometry: Geometry) => { - const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); + const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { @@ -904,7 +876,7 @@ export default function Map({ map, () => modeRef.current, (geometry: Geometry) => { - const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); + const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { @@ -926,7 +898,7 @@ export default function Map({ map, () => modeRef.current, (geometry: Geometry) => { - const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); + const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { @@ -968,6 +940,10 @@ export default function Map({ if (allowGeometryEditing) { editingEngineRef.current?.bindEditEvents(map); } + } catch (err) { + console.error("Map initialization failed", err); + setFatalInitError(err instanceof Error ? err.message : "Map initialization failed."); + } }); return () => { @@ -1011,7 +987,39 @@ export default function Map({ return (
-
+
+ + {fatalInitError ? ( +
+
+
+ Map khong khoi tao duoc +
+
+ {fatalInitError} +
+
+
+ ) : null}
f.geometry.type !== "Point"), + features: fc.features.filter((f) => + f.geometry.type !== "Point" && f.geometry.type !== "MultiPoint" + ), } as FeatureCollection; const points = { type: "FeatureCollection", - features: fc.features.filter((f) => f.geometry.type === "Point"), + features: fc.features.filter((f) => + f.geometry.type === "Point" || f.geometry.type === "MultiPoint" + ), } as FeatureCollection; return { polygons, points }; @@ -1540,22 +1552,27 @@ function createPathArrowImageData(): ImageData | null { function addPointSymbolLayer(map: maplibregl.Map) { void ensurePointAssetIcon(map).then((hasPointIcon) => { - if (!hasPointIcon || !map.getSource("places") || map.getLayer("places-symbol")) return; + try { + if (!hasPointIcon || !map.getSource("places") || map.getLayer("places-symbol")) return; - map.addLayer({ - id: "places-symbol", - type: "symbol", - source: "places", - layout: { - "icon-image": DEFAULT_POINT_ICON_ID, - "icon-size": 0.06, - "icon-anchor": "center", - "icon-allow-overlap": true, - }, - }); + map.addLayer({ + id: "places-symbol", + type: "symbol", + source: "places", + layout: { + "icon-image": DEFAULT_POINT_ICON_ID, + "icon-size": 0.06, + "icon-anchor": "center", + "icon-allow-overlap": true, + }, + }); - if (map.getLayer("places-circle")) { - map.setLayoutProperty("places-circle", "visibility", "none"); + if (map.getLayer("places-circle")) { + map.setLayoutProperty("places-circle", "visibility", "none"); + } + } catch (err) { + // Map might have been removed while icon was loading. + console.warn("Add point symbol layer skipped", err); } }); } @@ -1602,6 +1619,14 @@ function roundZoom(value: number): number { return Math.round(value * 10) / 10; } +function buildClientFeatureId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + // Fallback đảm bảo tránh collision khi user tạo nhiều feature trong cùng 1ms. + return `feature-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; +} + function clampNumber(value: number, min: number, max: number): number { if (value < min) return min; if (value > max) return max; diff --git a/components/SelectedGeometryPanel.tsx b/components/SelectedGeometryPanel.tsx index 6f7da6f..5d347b5 100644 --- a/components/SelectedGeometryPanel.tsx +++ b/components/SelectedGeometryPanel.tsx @@ -10,18 +10,7 @@ import { findEntityTypeOption, groupEntityTypeOptions, } from "@/lib/entityTypeOptions"; - -type EntityFormState = { - name: string; - slug: string; - type_id: string; -}; - -type GeometryMetaFormState = { - time_start: string; - time_end: string; - binding: string; -}; +import type { EntityFormState, GeometryMetaFormState } from "@/lib/editor/session/sessionTypes"; type Props = { selectedFeature: Feature | null; diff --git a/components/TimelineBar.tsx b/components/TimelineBar.tsx index 4daefe4..e44710c 100644 --- a/components/TimelineBar.tsx +++ b/components/TimelineBar.tsx @@ -1,5 +1,7 @@ "use client"; +import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/lib/timeline"; + type Props = { year: number; onYearChange: (year: number) => void; @@ -8,9 +10,6 @@ type Props = { statusText?: string | null; }; -const FIXED_TIMELINE_START_YEAR = -2000; -const FIXED_TIMELINE_END_YEAR = 2000; - export default function TimelineBar({ year, onYearChange, @@ -21,14 +20,14 @@ export default function TimelineBar({ const lower = FIXED_TIMELINE_START_YEAR; const upper = FIXED_TIMELINE_END_YEAR; const effectiveDisabled = disabled; - const safeYear = clampYear(year, lower, upper); + const safeYear = clampYearValue(year, lower, upper); const helperText = isLoading ? "Đang tải geometry theo mốc thời gian..." : 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)); + onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper)); }; return ( @@ -75,22 +74,22 @@ export default function TimelineBar({ gap: "10px", }} > - handleYearChange(Number(event.target.value))} - disabled={effectiveDisabled} - aria-label="Timeline year" - style={{ - width: "100%", - accentColor: "#22c55e", - cursor: effectiveDisabled ? "not-allowed" : "pointer", - opacity: effectiveDisabled ? 0.6 : 1, - }} - /> + disabled={effectiveDisabled} + aria-label="Timeline year" + style={{ + width: "100%", + accentColor: "#22c55e", + cursor: effectiveDisabled ? "not-allowed" : "pointer", + opacity: effectiveDisabled ? 0.6 : 1, + }} + /> maxYear) return maxYear; - return year; -} - function formatYear(year: number): string { if (year < 0) { return `${Math.abs(year)} TCN`; diff --git a/lib/editor/draft/useDraftState.ts b/lib/editor/draft/useDraftState.ts index 4bedb52..bd4a486 100644 --- a/lib/editor/draft/useDraftState.ts +++ b/lib/editor/draft/useDraftState.ts @@ -3,7 +3,9 @@ import type { FeatureCollection } from "@/types/geo"; import { deepClone } from "@/lib/editor/draft/draftDiff"; export function useDraftState(initialData: FeatureCollection) { + // Draft hiện tại (React state) để UI re-render khi dữ liệu thay đổi. const [draft, setDraft] = useState(() => deepClone(initialData)); + // Draft ref để đọc giá trị mới nhất trong event handlers/engines mà không cần deps. const draftRef = useRef(deepClone(initialData)); const commitDraft = useCallback((nextDraft: FeatureCollection) => { diff --git a/lib/editor/draft/useUndoStack.ts b/lib/editor/draft/useUndoStack.ts index 9d36c8f..17f1d9a 100644 --- a/lib/editor/draft/useUndoStack.ts +++ b/lib/editor/draft/useUndoStack.ts @@ -8,6 +8,7 @@ type Options = { export function useUndoStack(options: Options) { const { applyUndoAction } = options; + // Stack thao tác undo (append-only, pop khi undo). const [undoStack, setUndoStack] = useState([]); const pushUndo = useCallback((action: UndoAction) => { diff --git a/lib/editor/session/useBackgroundSessionState.ts b/lib/editor/session/useBackgroundSessionState.ts index 2684d3b..ad20a30 100644 --- a/lib/editor/session/useBackgroundSessionState.ts +++ b/lib/editor/session/useBackgroundSessionState.ts @@ -5,9 +5,11 @@ import { } from "@/lib/backgroundLayers"; export function useBackgroundSessionState() { + // Trạng thái bật/tắt layer nền (khởi tạo default hidden; sẽ load từ storage ở page). const [backgroundVisibility, setBackgroundVisibility] = useState( () => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }) ); + // Đảm bảo đã load visibility trước khi render map thật. const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); return { @@ -17,4 +19,3 @@ export function useBackgroundSessionState() { setIsBackgroundVisibilityReady, }; } - diff --git a/lib/editor/session/useEntitySessionState.ts b/lib/editor/session/useEntitySessionState.ts index 7c50ac5..967f2c6 100644 --- a/lib/editor/session/useEntitySessionState.ts +++ b/lib/editor/session/useEntitySessionState.ts @@ -10,27 +10,41 @@ import type { } from "@/lib/editor/session/sessionTypes"; export function useEntitySessionState() { + // Entities đã persisted từ backend (dùng cho search/binding). const [persistedEntities, setPersistedEntities] = useState([]); + // Entities tạo mới trong phiên nhưng chưa commit lên backend. const [pendingEntityCreates, setPendingEntityCreates] = useState([]); + // Tóm tắt entities đã tạo (để hiển thị nhanh ở sidebar). const [createdEntities, setCreatedEntities] = useState([]); + // Thông báo trạng thái/lỗi liên quan entity/session. const [entityStatus, setEntityStatus] = useState(null); + // Feature đang được chọn để thao tác bind entities/metadata. const [selectedFeatureId, setSelectedFeatureId] = useState(null); + // Form tạo entity mới (độc lập). const [entityForm, setEntityForm] = useState({ name: "", slug: "", type_id: DEFAULT_ENTITY_TYPE_ID, }); + // Danh sách entity IDs đang chọn để bind vào geometry hiện tại. const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState([]); + // Form metadata geometry (time range + binding ids). const [geometryMetaForm, setGeometryMetaForm] = useState({ time_start: "", time_end: "", binding: "", }); + // Cờ loading khi apply entity/metadata (local submit). const [isEntitySubmitting, setIsEntitySubmitting] = useState(false); + // Thông báo trạng thái/lỗi cho form entity/metadata. const [entityFormStatus, setEntityFormStatus] = useState(null); + // Keyword search entity theo name. const [entitySearchQuery, setEntitySearchQuery] = useState(""); + // Kết quả search entity để user chọn. const [entitySearchResults, setEntitySearchResults] = useState([]); + // Entity ID đang được chọn trong dropdown kết quả search. const [selectedSearchEntityId, setSelectedSearchEntityId] = useState(null); + // Cờ loading khi search entity. const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false); return { diff --git a/lib/editor/session/useSectionSessionState.ts b/lib/editor/session/useSectionSessionState.ts index 5a8f13c..1a0bf5c 100644 --- a/lib/editor/session/useSectionSessionState.ts +++ b/lib/editor/session/useSectionSessionState.ts @@ -9,6 +9,7 @@ type Options = { type SectionTask = "idle" | "saving" | "submitting" | "opening-section"; export function useSectionSessionState(options: Options) { + // Single state machine cho các tác vụ async của section (saving/submitting/opening). const [sectionTask, setSectionTask] = useState("idle"); const setTaskFlag = useCallback((task: Exclude, next: SetStateAction) => { setSectionTask((prev) => { @@ -32,15 +33,25 @@ export function useSectionSessionState(options: Options) { setTaskFlag("opening-section", next); }, [setTaskFlag]); + // Danh sách sections để user chọn mở. const [availableSections, setAvailableSections] = useState([]); + // Section ID đang được chọn trong dropdown. const [selectedSectionId, setSelectedSectionId] = useState(""); + // Title section mới (để create). const [newSectionTitle, setNewSectionTitle] = useState(""); + // Input title cho commit. const [commitTitle, setCommitTitle] = useState(""); + // Input note cho commit. const [commitNote, setCommitNote] = useState(""); + // User ID dùng để gắn vào commit/submit/lock. const [editorUserIdInput, setEditorUserIdInput] = useState(options.defaultEditorUserId); + // Section đang mở để edit (null nếu chưa mở). const [activeSection, setActiveSection] = useState
(null); + // Trạng thái section (version/head/status/lock). const [sectionState, setSectionState] = useState(null); + // Danh sách commits của section đang mở. const [sectionCommits, setSectionCommits] = useState([]); + // Snapshot gần nhất đã load (để build snapshot diff/metadata). const [lastSectionSnapshot, setLastSectionSnapshot] = useState(null); return { diff --git a/lib/editor/session/useTimelineState.ts b/lib/editor/session/useTimelineState.ts index 82f3e08..a166be9 100644 --- a/lib/editor/session/useTimelineState.ts +++ b/lib/editor/session/useTimelineState.ts @@ -1,5 +1,6 @@ import { useState } from "react"; import type { TimelineRange } from "@/lib/editor/session/sessionTypes"; +import { clampYearValue } from "@/lib/timeline"; type Options = { currentYear: number; @@ -7,6 +8,7 @@ type Options = { }; export function useTimelineState(options: Options) { + // Năm timeline "đã chốt" để fetch dữ liệu. const [timelineYear, setTimelineYear] = useState(() => clampYearValue( options.currentYear, @@ -14,6 +16,7 @@ export function useTimelineState(options: Options) { options.fallbackTimelineRange.max ) ); + // Năm timeline đang chỉnh (debounce rồi đẩy sang timelineYear). const [timelineDraftYear, setTimelineDraftYear] = useState(() => clampYearValue( options.currentYear, @@ -21,7 +24,9 @@ export function useTimelineState(options: Options) { options.fallbackTimelineRange.max ) ); + // Cờ loading khi fetch theo timeline. const [isTimelineLoading, setIsTimelineLoading] = useState(false); + // Thông báo trạng thái/lỗi khi fetch theo timeline. const [timelineStatus, setTimelineStatus] = useState(null); return { @@ -35,11 +40,3 @@ export function useTimelineState(options: Options) { 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/engine/circleEngine.ts b/lib/engine/circleEngine.ts index dbceaee..7db4bab 100644 --- a/lib/engine/circleEngine.ts +++ b/lib/engine/circleEngine.ts @@ -1,7 +1,6 @@ import maplibregl from "maplibre-gl"; import { Geometry } from "@/lib/useEditorState"; - -type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; +import type { ModeGetter } from "@/lib/engine/engineTypes"; const EARTH_RADIUS_METERS = 6371008.8; const CIRCLE_SEGMENTS = 72; diff --git a/lib/engine/drawingEngine.ts b/lib/engine/drawingEngine.ts index 5eb4820..60e1d22 100644 --- a/lib/engine/drawingEngine.ts +++ b/lib/engine/drawingEngine.ts @@ -1,7 +1,6 @@ import maplibregl from "maplibre-gl"; import { Geometry } from "@/lib/useEditorState"; - -type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; +import type { ModeGetter } from "@/lib/engine/engineTypes"; // Khởi tạo engine vẽ polygon tự do theo chuỗi click. export function initDrawing( diff --git a/lib/engine/engineTypes.ts b/lib/engine/engineTypes.ts new file mode 100644 index 0000000..a689d23 --- /dev/null +++ b/lib/engine/engineTypes.ts @@ -0,0 +1,4 @@ +import type { EditorMode } from "@/lib/editor/session/sessionTypes"; + +export type ModeGetter = () => EditorMode; + diff --git a/lib/engine/lineEngine.ts b/lib/engine/lineEngine.ts index f09629e..aa79c5f 100644 --- a/lib/engine/lineEngine.ts +++ b/lib/engine/lineEngine.ts @@ -1,7 +1,6 @@ import maplibregl from "maplibre-gl"; import { Geometry } from "@/lib/useEditorState"; - -type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; +import type { ModeGetter } from "@/lib/engine/engineTypes"; const EMPTY_PREVIEW: GeoJSON.FeatureCollection = { type: "FeatureCollection", diff --git a/lib/engine/pathEngine.ts b/lib/engine/pathEngine.ts index b243fea..0046a51 100644 --- a/lib/engine/pathEngine.ts +++ b/lib/engine/pathEngine.ts @@ -1,7 +1,6 @@ import maplibregl from "maplibre-gl"; import { Geometry } from "@/lib/useEditorState"; - -type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; +import type { ModeGetter } from "@/lib/engine/engineTypes"; const EMPTY_PREVIEW: GeoJSON.FeatureCollection = { type: "FeatureCollection", diff --git a/lib/engine/pointEngine.ts b/lib/engine/pointEngine.ts index 754fb25..fdac124 100644 --- a/lib/engine/pointEngine.ts +++ b/lib/engine/pointEngine.ts @@ -1,7 +1,6 @@ import maplibregl from "maplibre-gl"; import { Geometry } from "@/lib/useEditorState"; - -type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; +import type { ModeGetter } from "@/lib/engine/engineTypes"; // Khởi tạo engine thêm point bằng click đơn. export function initPoint( diff --git a/lib/engine/selectingEngine.ts b/lib/engine/selectingEngine.ts index c760991..70b8f24 100644 --- a/lib/engine/selectingEngine.ts +++ b/lib/engine/selectingEngine.ts @@ -1,6 +1,5 @@ import maplibregl from "maplibre-gl"; - -type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; +import type { ModeGetter } from "@/lib/engine/engineTypes"; // Khởi tạo engine chọn feature và context menu edit/delete. export function initSelect( diff --git a/lib/geo/constants.ts b/lib/geo/constants.ts new file mode 100644 index 0000000..93cbf72 --- /dev/null +++ b/lib/geo/constants.ts @@ -0,0 +1,14 @@ +import type { FeatureCollection } from "@/types/geo"; + +export const WORLD_BBOX = { + minLng: -180, + minLat: -90, + maxLng: 180, + maxLat: 90, +} as const; + +export const EMPTY_FEATURE_COLLECTION: FeatureCollection = { + type: "FeatureCollection", + features: [], +}; + diff --git a/lib/map/constants.ts b/lib/map/constants.ts new file mode 100644 index 0000000..f090651 --- /dev/null +++ b/lib/map/constants.ts @@ -0,0 +1,13 @@ +export const DEFAULT_POINT_ICON_ID = "point-icon-default"; +export const POINT_ICON_URL = "/point.png"; +export const PATH_ARROW_ICON_ID = "path-arrow-icon"; + +export const MAP_MIN_ZOOM = 1; +export const MAP_MAX_ZOOM = 10; + +export const RASTER_BASE_SOURCE_ID = "rasterBase"; +export const RASTER_BASE_LAYER_ID = "raster-base-layer"; +export const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line"; + +export const PATH_ARROW_SOURCE_ID = "path-arrow-shapes"; +export const FEATURE_STATE_SOURCE_IDS = ["countries", "places", PATH_ARROW_SOURCE_ID] as const; diff --git a/lib/map/style.ts b/lib/map/style.ts new file mode 100644 index 0000000..31b420c --- /dev/null +++ b/lib/map/style.ts @@ -0,0 +1,76 @@ +import maplibregl from "maplibre-gl"; + +export const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [ + "coalesce", + ["get", "MAPCOLOR7"], + ["get", "MAPCOLOR9"], + ["get", "scalerank"], + 0, +]; + +export const COUNTRY_FILL_COLOR_EXPRESSION: maplibregl.ExpressionSpecification = [ + "match", + COUNTRY_COLOR_KEY_EXPRESSION, + 1, "#ef4444", + 2, "#f97316", + 3, "#f59e0b", + 4, "#22c55e", + 5, "#06b6d4", + 6, "#3b82f6", + 7, "#8b5cf6", + 8, "#a855f7", + 9, "#d946ef", + 10, "#14b8a6", + "#64748b", +]; + +export const POLYGON_FILL_BY_TYPE: Record = { + country: "#2563eb", + state: "#0ea5e9", + empire: "#f59e0b", + kingdom: "#d97706", + war: "#dc2626", + battle: "#f43f5e", + civilization: "#14b8a6", + rebellion_zone: "#7c3aed", +}; + +export const POLYGON_STROKE_BY_TYPE: Record = { + country: "#1e3a8a", + state: "#0c4a6e", + empire: "#7c2d12", + kingdom: "#9a3412", + war: "#7f1d1d", + battle: "#9f1239", + civilization: "#134e4a", + rebellion_zone: "#4c1d95", +}; + +export const POLYGON_OPACITY_BY_TYPE: Record = { + war: 0.3, + battle: 0.34, + civilization: 0.38, + rebellion_zone: 0.32, +}; + +export const LINE_COLOR_BY_TYPE: Record = { + defense_line: "#f97316", + attack_route: "#ef4444", + retreat_route: "#94a3b8", + invasion_route: "#b91c1c", + migration_route: "#0ea5e9", + refugee_route: "#06b6d4", + trade_route: "#eab308", + shipping_route: "#2563eb", +}; + +export const PATH_RENDER_BY_TYPE: Record = { + attack_route: true, + retreat_route: true, + invasion_route: true, + migration_route: true, + refugee_route: true, + trade_route: true, + shipping_route: true, +}; + diff --git a/lib/timeline.ts b/lib/timeline.ts new file mode 100644 index 0000000..cbce653 --- /dev/null +++ b/lib/timeline.ts @@ -0,0 +1,25 @@ +import type { TimelineRange } from "@/lib/editor/session/sessionTypes"; + +// Single source of truth for the app-wide timeline range. +export const FIXED_TIMELINE_START_YEAR = -2000; +export const FIXED_TIMELINE_END_YEAR = 2000; + +export const FIXED_TIMELINE_RANGE: TimelineRange = { + min: FIXED_TIMELINE_START_YEAR, + max: FIXED_TIMELINE_END_YEAR, +}; + +// UI debounce when user drags timeline before triggering data fetch. +export const TIMELINE_DEBOUNCE_MS = 180; + +export 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; +} + +export function clampYearToFixedRange(year: number): number { + return clampYearValue(year, FIXED_TIMELINE_START_YEAR, FIXED_TIMELINE_END_YEAR); +} diff --git a/lib/useEditorSessionState.ts b/lib/useEditorSessionState.ts index 2961545..f04dc81 100644 --- a/lib/useEditorSessionState.ts +++ b/lib/useEditorSessionState.ts @@ -23,7 +23,9 @@ type Options = { }; export function useEditorSessionState(options: Options) { + // Mode thao tác map/editor hiện tại. const [mode, setMode] = useState("idle"); + // FeatureCollection "gốc" của session hiện tại (global timeline hoặc section snapshot). const [initialData, setInitialData] = useState(options.emptyFeatureCollection); const section = useSectionSessionState({ diff --git a/lib/useEditorState.ts b/lib/useEditorState.ts index 7faf752..d843601 100644 --- a/lib/useEditorState.ts +++ b/lib/useEditorState.ts @@ -20,9 +20,11 @@ export type { Change, UndoAction } from "@/lib/editor/draft/editorTypes"; export function useEditorState(initialData: FeatureCollection) { const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData); + // Map baseline (id -> feature) để diff draft hiện tại ra changes. const initialMapRef = useRef>( buildInitialMap(initialData) ); + // Version counter để ép diff recalculation sau khi reset/clear baseline. const [baselineVersion, setBaselineVersion] = useState(0); const applyUndoAction = useCallback((action: UndoAction): boolean => { From dfebae31a1ee9c5ae9b0748d3d0dfd20ec093d10 Mon Sep 17 00:00:00 2001 From: taDuc Date: Tue, 21 Apr 2026 16:09:48 +0700 Subject: [PATCH 3/3] remove typo submited route --- app/submited/page.tsx | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 app/submited/page.tsx diff --git a/app/submited/page.tsx b/app/submited/page.tsx deleted file mode 100644 index 69df5ce..0000000 --- a/app/submited/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -"use client"; - -// Backward-compat route (typo). Keep this thin wrapper so canonical code lives in `/submitted`. -export { default } from "../submitted/page"; -