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/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 43a4b10..c0849fe 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,14 @@ 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,123 +29,120 @@ import { EntityTypeGroupId, findEntityTypeOption, } from "@/lib/entityTypeOptions"; +import { + EntityFormState, + PendingEntityCreate, + useEditorSessionState, +} from "@/lib/useEditorSessionState"; +import { + getDefaultTypeIdForFeature, + normalizeFeatureBindingIds, + normalizeFeatureEntityIds, + uniqueEntityIds, +} from "@/lib/editor/snapshot/editorSnapshot"; +import { + buildClientEntityId, + formatEntityNamesForDisplay, + mergeEntitiesWithPending, + mergeEntitySearchResults, +} from "@/lib/editor/entity/entityBinding"; +import { + formatBindingIdsForDisplay, +} from "@/lib/editor/geometry/geometryMetadata"; +import { + loadBackgroundLayerVisibilityFromStorage, + 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: CURRENT_YEAR - 5000, - max: CURRENT_YEAR + 100, -}; -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_FEATURE_COLLECTION, + defaultEditorUserId: DEFAULT_EDITOR_USER_ID, + fallbackTimelineRange: FIXED_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); + // 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 timelineYearRef = useRef(timelineYear); - const editorUserIdRef = useRef(DEFAULT_EDITOR_USER_ID); const editor = useEditorState(initialData); const editorUserId = normalizeEditorUserId(editorUserIdInput); @@ -196,87 +182,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_FEATURE_COLLECTION, + 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 +241,17 @@ export default function Page() { } } - loadSection(); + loadSections(); return () => { disposed = true; }; - }, [openSectionForEditing]); + }, [ + setAvailableSections, + setEntityStatus, + setIsOpeningSection, + setSelectedSectionId, + ]); useEffect(() => { let disposed = false; @@ -314,7 +275,7 @@ export default function Page() { return () => { disposed = true; }; - }, []); + }, [setEntityStatus, setPersistedEntities]); useEffect(() => { if (!selectedFeature) { @@ -390,7 +351,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 +368,7 @@ export default function Page() { if (!stillExists) { setSelectedFeatureId(null); } - }, [editor.draft, selectedFeatureId]); + }, [editor.draft, selectedFeatureId, setSelectedFeatureId]); useEffect(() => { if (!selectedFeature) { @@ -432,7 +400,15 @@ export default function Page() { setEntitySearchResults([]); setSelectedSearchEntityId(null); setEntityFormStatus(null); - }, [selectedFeature]); + }, [ + selectedFeature, + setEntityFormStatus, + setEntitySearchQuery, + setEntitySearchResults, + setGeometryMetaForm, + setSelectedGeometryEntityIds, + setSelectedSearchEntityId, + ]); useEffect(() => { if (!selectedFeature) return; @@ -456,56 +432,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 +442,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 +466,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 +484,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 +522,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(clampYearToFixedRange(Math.trunc(nextYear))); }; const handleEntityFormChange = (key: keyof EntityFormState, value: string) => { @@ -687,119 +551,17 @@ 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[]; - try { - ({ timeStart, timeEnd } = parseGeometryMetaFormRange()); - bindingIds = parseBindingInput(geometryMetaForm.binding); - } catch (err) { - setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ."); - return; - } - - setIsEntitySubmitting(true); - setEntityFormStatus(null); - try { - patchSelectedGeometryMetadataLocally(selectedFeature, timeStart, timeEnd, bindingIds); - 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 { - patchSelectedFeatureEntitiesLocally(selectedFeature, 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(); @@ -869,141 +631,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 +655,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 +674,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 +703,6 @@ export default function Page() {
)} @@ -1131,298 +757,15 @@ export default function Page() { ); } -async function resolveDefaultSection(existingSections?: Section[], userId = DEFAULT_EDITOR_USER_ID): Promise
{ - 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); -} - -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); -} - 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 +774,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/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 d25c226..291a2d4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -9,52 +9,45 @@ 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"; +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: CURRENT_YEAR - 5000, - max: CURRENT_YEAR + 100, -}; -const TIMELINE_DEBOUNCE_MS = 180; -const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1"; - -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); - const [timelineRange, setTimelineRange] = useState(FALLBACK_TIMELINE_RANGE); - const [timelineWindowStart, setTimelineWindowStart] = useState(FALLBACK_TIMELINE_RANGE.min); - const [timelineWindowEnd, setTimelineWindowEnd] = useState(FALLBACK_TIMELINE_RANGE.max); + // 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) ); - const [isTimelineReady, setIsTimelineReady] = useState(false); + // 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 = @@ -75,53 +68,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 +75,7 @@ export default function Page() { }, TIMELINE_DEBOUNCE_MS); return () => window.clearTimeout(timeoutId); - }, [timelineDraftYear, timelineYear, isTimelineReady]); + }, [timelineDraftYear, timelineYear]); useEffect(() => { setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); @@ -137,14 +83,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 +120,7 @@ export default function Page() { return () => { disposed = true; }; - }, [timelineYear, isTimelineReady]); + }, [timelineYear]); const updateBackgroundVisibility = ( updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility @@ -194,7 +132,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 +147,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(clampYearToFixedRange(Math.trunc(nextYear))); }; - const timelineDisabled = !isTimelineReady; const selectedType = resolveSelectedTypeSummary(selectedFeature); const selectedTimeSummary = formatSelectedTimeSummary(selectedFeature); const selectedEntitySummary = formatSelectedEntitySummary(selectedFeature); @@ -248,16 +173,10 @@ export default function Page() {
)}
@@ -325,46 +244,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); -} - -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); } @@ -432,53 +311,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/app/submited/page.tsx b/app/submitted/page.tsx similarity index 100% rename from app/submited/page.tsx rename to app/submitted/page.tsx diff --git a/components/Editor.tsx b/components/Editor.tsx index e221aa3..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", @@ -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..0ed2795 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -5,18 +5,40 @@ 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"; +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; @@ -31,91 +53,10 @@ type MapProps = { fitBoundsKey?: string | number | null; }; -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, +type EngineBinding = { + cleanup: () => void; + cancel?: () => void; + clearSelection?: () => void; }; export default function Map({ @@ -133,29 +74,76 @@ 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]); 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 +172,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]); @@ -240,7 +234,7 @@ export default function Map({ } } - const visibleDraft = respectBindingFilter + const visibleDraft = respectBindingFilterRef.current ? filterDraftByBinding(fc, selectedFeatureIdRef.current) : fc; const { polygons, points } = splitDraftFeatures(visibleDraft); @@ -257,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, @@ -408,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", @@ -791,11 +789,11 @@ export default function Map({ addPointSymbolLayer(map); // init drawing - const cleanup = initDrawing( + const drawingEngine = initDrawing( map, () => modeRef.current, (geometry: Geometry) => { - const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); + const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { @@ -813,7 +811,7 @@ export default function Map({ } ); - const cleanupSelect = initSelect( + const selectEngine = initSelect( map, () => modeRef.current, allowGeometryEditing @@ -834,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: { @@ -852,11 +850,11 @@ export default function Map({ } ); - const cleanupLine = initLine( + const lineEngine = initLine( map, () => modeRef.current, (geometry: Geometry) => { - const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); + const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { @@ -874,11 +872,11 @@ export default function Map({ } ); - const cleanupPath = initPath( + const pathEngine = initPath( map, () => modeRef.current, (geometry: Geometry) => { - const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); + const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { @@ -896,11 +894,11 @@ export default function Map({ } ); - const cleanupCircle = initCircle( + const circleEngine = initCircle( map, () => modeRef.current, (geometry: Geometry) => { - const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); + const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { @@ -918,13 +916,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), ]; @@ -934,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 () => { @@ -941,6 +951,7 @@ export default function Map({ cleanupFn(); } mapCleanupFnsRef.current = []; + engineBindingsRef.current = {}; if (mapRef.current === map) { mapRef.current = null; } @@ -976,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 }; @@ -1505,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); } }); } @@ -1567,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 2d768f2..e44710c 100644 --- a/components/TimelineBar.tsx +++ b/components/TimelineBar.tsx @@ -1,12 +1,8 @@ "use client"; +import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/lib/timeline"; + 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; @@ -15,33 +11,24 @@ type Props = { }; 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 = clampYearValue(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(clampYearValue(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} - aria-label="Timeline year" +
+ > + 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, + }} + /> + 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)}
- -
); } -function clampYear(year: number, minYear: number, maxYear: number): number { - if (year < minYear) return minYear; - if (year > maxYear) return maxYear; - return year; -} - function formatYear(year: number): string { if (year < 0) { return `${Math.abs(year)} TCN`; } 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..bd4a486 --- /dev/null +++ b/lib/editor/draft/useDraftState.ts @@ -0,0 +1,31 @@ +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) { + // 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) => { + 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..17f1d9a --- /dev/null +++ b/lib/editor/draft/useUndoStack.ts @@ -0,0 +1,81 @@ +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; + // Stack thao tác undo (append-only, pop khi undo). + 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..ad20a30 --- /dev/null +++ b/lib/editor/session/useBackgroundSessionState.ts @@ -0,0 +1,21 @@ +import { useState } from "react"; +import { + BackgroundLayerVisibility, + HIDDEN_BACKGROUND_LAYER_VISIBILITY, +} 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 { + backgroundVisibility, + setBackgroundVisibility, + isBackgroundVisibilityReady, + setIsBackgroundVisibilityReady, + }; +} diff --git a/lib/editor/session/useEntitySessionState.ts b/lib/editor/session/useEntitySessionState.ts new file mode 100644 index 0000000..967f2c6 --- /dev/null +++ b/lib/editor/session/useEntitySessionState.ts @@ -0,0 +1,80 @@ +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() { + // 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 { + 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..1a0bf5c --- /dev/null +++ b/lib/editor/session/useSectionSessionState.ts @@ -0,0 +1,85 @@ +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) { + // 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) => { + 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]); + + // 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 { + 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..a166be9 --- /dev/null +++ b/lib/editor/session/useTimelineState.ts @@ -0,0 +1,42 @@ +import { useState } from "react"; +import type { TimelineRange } from "@/lib/editor/session/sessionTypes"; +import { clampYearValue } from "@/lib/timeline"; + +type Options = { + currentYear: number; + fallbackTimelineRange: TimelineRange; +}; + +export function useTimelineState(options: Options) { + // Năm timeline "đã chốt" để fetch dữ liệu. + const [timelineYear, setTimelineYear] = useState(() => + clampYearValue( + options.currentYear, + options.fallbackTimelineRange.min, + options.fallbackTimelineRange.max + ) + ); + // Năm timeline đang chỉnh (debounce rồi đẩy sang timelineYear). + const [timelineDraftYear, setTimelineDraftYear] = useState(() => + clampYearValue( + options.currentYear, + options.fallbackTimelineRange.min, + 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 { + timelineYear, + setTimelineYear, + timelineDraftYear, + setTimelineDraftYear, + isTimelineLoading, + setIsTimelineLoading, + timelineStatus, + setTimelineStatus, + }; +} 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..7db4bab 100644 --- a/lib/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; @@ -148,7 +147,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 +157,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 78% rename from lib/drawingEngine.ts rename to lib/engine/drawingEngine.ts index 5157715..60e1d22 100644 --- a/lib/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( @@ -11,6 +10,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 +82,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 +113,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/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/lineEngine.ts b/lib/engine/lineEngine.ts similarity index 96% rename from lib/lineEngine.ts rename to lib/engine/lineEngine.ts index 9dfc077..aa79c5f 100644 --- a/lib/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", @@ -124,7 +123,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 +132,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 95% rename from lib/pathEngine.ts rename to lib/engine/pathEngine.ts index 0c38b63..0046a51 100644 --- a/lib/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", @@ -126,13 +125,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 91% rename from lib/pointEngine.ts rename to lib/engine/pointEngine.ts index 754fb25..fdac124 100644 --- a/lib/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/selectingEngine.ts b/lib/engine/selectingEngine.ts similarity index 96% rename from lib/selectingEngine.ts rename to lib/engine/selectingEngine.ts index 789c4f6..70b8f24 100644 --- a/lib/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( @@ -31,11 +30,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 +145,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/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 new file mode 100644 index 0000000..f04dc81 --- /dev/null +++ b/lib/useEditorSessionState.ts @@ -0,0 +1,51 @@ +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) { + // 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({ + 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..d843601 100644 --- a/lib/useEditorState.ts +++ b/lib/useEditorState.ts @@ -1,166 +1,91 @@ -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 + // Map baseline (id -> feature) để diff draft hiện tại ra changes. 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. + // Version counter để ép diff recalculation sau khi reset/clear baseline. 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 +93,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 +102,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 +118,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; +};