"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Map from "@/components/Map"; import Editor from "@/components/Editor"; import BackgroundLayersPanel from "@/components/BackgroundLayersPanel"; import TimelineBar from "@/components/TimelineBar"; import SelectedGeometryPanel from "@/components/SelectedGeometryPanel"; 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, HIDDEN_BACKGROUND_LAYER_VISIBILITY, } from "@/lib/backgroundLayers"; import { DEFAULT_ENTITY_TYPE_ID, ENTITY_TYPE_OPTIONS, EntityTypeGroupId, findEntityTypeOption, } from "@/lib/entityTypeOptions"; 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 [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState([]); const [geometryMetaForm, setGeometryMetaForm] = useState({ time_start: "", time_end: "", binding: "", }); const [isEntitySubmitting, setIsEntitySubmitting] = useState(false); const [entityFormStatus, setEntityFormStatus] = useState(null); const [entitySearchQuery, setEntitySearchQuery] = useState(""); const [entitySearchResults, setEntitySearchResults] = useState([]); const [selectedSearchEntityId, setSelectedSearchEntityId] = useState(null); const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false); const [timelineRange, setTimelineRange] = useState(FALLBACK_TIMELINE_RANGE); const [timelineWindowStart, setTimelineWindowStart] = useState(FALLBACK_TIMELINE_RANGE.min); const [timelineWindowEnd, setTimelineWindowEnd] = useState(FALLBACK_TIMELINE_RANGE.max); const [timelineYear, setTimelineYear] = useState(() => clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max) ); const [timelineDraftYear, setTimelineDraftYear] = useState(() => clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max) ); const [isTimelineReady, setIsTimelineReady] = useState(false); const [isTimelineLoading, setIsTimelineLoading] = useState(false); const [timelineStatus, setTimelineStatus] = useState(null); const [backgroundVisibility, setBackgroundVisibility] = useState( () => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }) ); const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); const timelineFetchRequestRef = useRef(0); const entitySearchRequestRef = useRef(0); const timelineYearRef = useRef(timelineYear); const editorUserIdRef = useRef(DEFAULT_EDITOR_USER_ID); const editor = useEditorState(initialData); const editorUserId = normalizeEditorUserId(editorUserIdInput); const entities = useMemo( () => mergeEntitiesWithPending(persistedEntities, pendingEntityCreates), [persistedEntities, pendingEntityCreates] ); const selectedFeature = selectedFeatureId === null ? null : editor.draft.features.find((feature) => String(feature.properties.id) === String(selectedFeatureId) ) || null; const createdGeometries = useMemo(() => { const rows: Array<{ id: string | number; geometryType: string; semanticType?: string | null; entityNames: string[]; }> = []; for (const change of editor.changes.values()) { if (change.action !== "create") continue; const feature = change.feature; const entityNames = normalizeFeatureEntityIds(feature) .map((entityId) => entities.find((entity) => entity.id === entityId)?.name || entityId); rows.push({ id: feature.properties.id, geometryType: feature.geometry.type, semanticType: feature.properties.type || getDefaultTypeIdForFeature(feature), entityNames, }); } 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); }, []); useEffect(() => { let disposed = false; async function loadSection() { 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."); } } } catch (err) { if (disposed) return; if (err instanceof ApiError) { setEntityStatus(`Không tải được section editor: ${err.body || err.message}`); } else { console.error("Load section editor failed", err); setEntityStatus("Không tải được section editor."); } } finally { if (!disposed) { setIsOpeningSection(false); } } } loadSection(); return () => { disposed = true; }; }, [openSectionForEditing]); useEffect(() => { let disposed = false; async function loadEntities() { try { const rows = await fetchEntities(); if (disposed) return; setPersistedEntities(rows); setEntityStatus(null); } catch (err) { if (disposed) return; console.error("Load entities failed", err); setEntityStatus("Không tải được danh sách entity."); } } loadEntities(); return () => { disposed = true; }; }, []); useEffect(() => { if (!selectedFeature) { setEntitySearchResults([]); setSelectedSearchEntityId(null); setIsEntitySearchLoading(false); return; } const keyword = entitySearchQuery.trim(); if (!keyword.length) { setEntitySearchResults([]); setSelectedSearchEntityId(null); setIsEntitySearchLoading(false); return; } let disposed = false; const requestId = ++entitySearchRequestRef.current; const timeoutId = window.setTimeout(async () => { setIsEntitySearchLoading(true); try { const rows = await searchEntitiesByName(keyword, { limit: 30 }); if (disposed || requestId !== entitySearchRequestRef.current) return; const pendingMatches = pendingEntityCreates .filter((entity) => entity.name.toLowerCase().includes(keyword.toLowerCase()) || (entity.slug || "").toLowerCase().includes(keyword.toLowerCase()) ) .map((entity) => ({ id: entity.id, name: entity.name, slug: entity.slug, type_id: entity.type_id, status: entity.status, geometry_count: 0, })); const mergedRows = mergeEntitySearchResults(rows, pendingMatches); setEntitySearchResults(mergedRows); setSelectedSearchEntityId((prev) => prev && mergedRows.some((entity) => entity.id === prev) ? prev : mergedRows[0]?.id || null ); } catch (err) { if (disposed || requestId !== entitySearchRequestRef.current) return; console.error("Search entity by name failed", err); const pendingMatches = pendingEntityCreates .filter((entity) => entity.name.toLowerCase().includes(keyword.toLowerCase()) || (entity.slug || "").toLowerCase().includes(keyword.toLowerCase()) ) .map((entity) => ({ id: entity.id, name: entity.name, slug: entity.slug, type_id: entity.type_id, status: entity.status, geometry_count: 0, })); setEntitySearchResults(pendingMatches); setSelectedSearchEntityId(pendingMatches[0]?.id || null); } finally { if (!disposed && requestId === entitySearchRequestRef.current) { setIsEntitySearchLoading(false); } } }, 220); return () => { disposed = true; window.clearTimeout(timeoutId); }; }, [entitySearchQuery, selectedFeature, pendingEntityCreates]); useEffect(() => { if (selectedFeatureId === null) return; const stillExists = editor.draft.features.some((feature) => String(feature.properties.id) === String(selectedFeatureId) ); if (!stillExists) { setSelectedFeatureId(null); } }, [editor.draft, selectedFeatureId]); useEffect(() => { if (!selectedFeature) { setSelectedGeometryEntityIds([]); setGeometryMetaForm({ time_start: "", time_end: "", binding: "", }); setEntitySearchQuery(""); setEntitySearchResults([]); setSelectedSearchEntityId(null); setEntityFormStatus(null); return; } const featureEntityIds = normalizeFeatureEntityIds(selectedFeature); setSelectedGeometryEntityIds(featureEntityIds); setGeometryMetaForm({ time_start: selectedFeature.properties.time_start != null ? String(selectedFeature.properties.time_start) : "", time_end: selectedFeature.properties.time_end != null ? String(selectedFeature.properties.time_end) : "", binding: normalizeFeatureBindingIds(selectedFeature).join(", "), }); setEntitySearchQuery(""); setEntitySearchResults([]); setSelectedSearchEntityId(null); setEntityFormStatus(null); }, [selectedFeature]); useEffect(() => { if (!selectedFeature) return; const allowedGroupIds = getAllowedEntityTypeGroupIdsForFeature(selectedFeature); const fallbackOption = ENTITY_TYPE_OPTIONS.find((option) => allowedGroupIds.includes(option.groupId) ); if (!fallbackOption) return; setEntityForm((prev) => { const currentOption = findEntityTypeOption(prev.type_id); const isCurrentAllowed = currentOption ? allowedGroupIds.includes(currentOption.groupId) : false; if (isCurrentAllowed || prev.type_id === fallbackOption.value) { return prev; } return { ...prev, type_id: fallbackOption.value, }; }); }, [selectedFeature]); 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); } }, TIMELINE_DEBOUNCE_MS); return () => window.clearTimeout(timeoutId); }, [timelineDraftYear, timelineYear, isTimelineReady]); useEffect(() => { setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); setIsBackgroundVisibilityReady(true); }, []); 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; async function loadByTimeline() { setIsTimelineLoading(true); setTimelineStatus(null); try { const data = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear, }); if (disposed || requestId !== timelineFetchRequestRef.current) return; if (!lastSectionSnapshot?.editor_feature_collection) { setInitialData(data); } } catch (err) { if (err instanceof ApiError) { console.error("Load timeline data failed", err.body); } else { console.error("Load 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."); } } finally { if (!disposed && requestId === timelineFetchRequestRef.current) { setIsTimelineLoading(false); } } } loadByTimeline(); 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); } }; const updateBackgroundVisibility = ( updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility ) => { setBackgroundVisibility((prev) => { const next = updater(prev); persistBackgroundLayerVisibility(next); return next; }); }; const handleToggleBackgroundLayer = (id: BackgroundLayerId) => { updateBackgroundVisibility((prev) => ({ ...prev, [id]: !prev[id], })); }; const handleShowAllBackgroundLayers = () => { updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY })); }; const handleHideAllBackgroundLayers = () => { 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)); }; const handleEntityFormChange = (key: keyof EntityFormState, value: string) => { setEntityForm((prev) => ({ ...prev, [key]: value })); }; const handleGeometryMetaFormChange = (key: "time_start" | "time_end" | "binding", value: string) => { setGeometryMetaForm((prev) => ({ ...prev, [key]: value })); }; const handleEntityIdsChange = (values: string[]) => { setSelectedGeometryEntityIds(uniqueEntityIds(values)); }; const handleAddSelectedSearchEntity = () => { const entityId = selectedSearchEntityId ? selectedSearchEntityId.trim() : ""; if (!entityId.length) { setEntityFormStatus("Hãy chọn một entity từ kết quả search trước."); return; } const next = uniqueEntityIds([...selectedGeometryEntityIds, entityId]); setSelectedGeometryEntityIds(next); setSelectedSearchEntityId(null); 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 handleCreateEntityOnly = async () => { const name = entityForm.name.trim(); if (!name) { setEntityFormStatus("Tên entity là bắt buộc."); return; } const slug = entityForm.slug.trim() || null; const typeId = entityForm.type_id || DEFAULT_ENTITY_TYPE_ID; const normalizedName = name.toLowerCase(); const duplicatedName = entities.some((entity) => entity.name.trim().toLowerCase() === normalizedName); if (duplicatedName) { setEntityFormStatus("Tên entity đã tồn tại."); return; } if (slug) { const normalizedSlug = slug.toLowerCase(); const duplicatedSlug = entities.some((entity) => (entity.slug || "").trim().toLowerCase() === normalizedSlug ); if (duplicatedSlug) { setEntityFormStatus("Slug entity đã tồn tại."); return; } } const entityId = buildClientEntityId(); const pendingCreate: PendingEntityCreate = { id: entityId, name, slug, type_id: typeId, status: 1, }; setIsEntitySubmitting(true); setEntityFormStatus(null); try { setPendingEntityCreates((prev) => [pendingCreate, ...prev]); setCreatedEntities((prev) => { if (prev.some((item) => item.id === pendingCreate.id)) return prev; return [ { id: pendingCreate.id, name: pendingCreate.name, type_id: pendingCreate.type_id || null, }, ...prev, ]; }); setEntityForm((prev) => ({ ...prev, name: "", slug: "", })); setEntityStatus(null); setEntityFormStatus("Đã thêm entity mới vào danh sách chờ Commit."); if (selectedFeature) { setEntitySearchQuery(pendingCreate.name); setSelectedSearchEntityId(pendingCreate.id); } } finally { setIsEntitySubmitting(false); } }; 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 timelineStatusText = pendingSaveCount > 0 ? "Commit hoặc Undo hết thay đổi trước khi đổi mốc thời gian." : isSaving ? "Đang lưu thay đổi..." : timelineStatus; const handleCreateFeature = (feature: Feature) => { editor.createFeature(feature); setSelectedFeatureId(feature.properties.id); }; return (
{isBackgroundVisibilityReady ? ( ) : (
)}
} />
); } 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); if (defaultTypeOption) { return [defaultTypeOption.groupId]; } 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; }