"use client"; import { useEffect, useMemo, useRef } 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 { fetchSections, SectionCommit, } from "@/api/sections"; import { Feature, FeatureCollection, useEditorState, } from "@/lib/useEditorState"; import { 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"; import { EntityFormState, PendingEntityCreate, TimelineRange, useEditorSessionState, } from "@/lib/useEditorSessionState"; import { getDefaultTypeIdForFeature, normalizeFeatureBindingIds, normalizeFeatureEntityIds, uniqueEntityIds, } from "@/lib/editor/snapshot/editorSnapshot"; import { buildClientEntityId, buildFeatureEntityPatch, formatEntityNamesForDisplay, mergeEntitiesWithPending, mergeEntitySearchResults, } from "@/lib/editor/entity/entityBinding"; import { buildGeometryMetadataPatch, formatBindingIdsForDisplay, } from "@/lib/editor/geometry/geometryMetadata"; import { loadBackgroundLayerVisibilityFromStorage, persistBackgroundLayerVisibility, } from "@/lib/editor/background/backgroundVisibilityStorage"; import { useSectionCommands } from "@/lib/editor/section/useSectionCommands"; const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; const WORLD_BBOX = { minLng: -180, minLat: -90, maxLng: 180, maxLat: 90, } as const; const CURRENT_YEAR = new Date().getUTCFullYear(); const FALLBACK_TIMELINE_RANGE: TimelineRange = { min: -2000, max: 2000, }; const TIMELINE_DEBOUNCE_MS = 180; const DEFAULT_EDITOR_USER_ID = "local-editor"; export default function Page() { const { mode, setMode, initialData, setInitialData, isSaving, setIsSaving, isSubmitting, setIsSubmitting, isOpeningSection, setIsOpeningSection, availableSections, setAvailableSections, selectedSectionId, setSelectedSectionId, newSectionTitle, setNewSectionTitle, commitTitle, setCommitTitle, commitNote, setCommitNote, editorUserIdInput, setEditorUserIdInput, activeSection, setActiveSection, sectionState, setSectionState, sectionCommits, setSectionCommits, lastSectionSnapshot, setLastSectionSnapshot, persistedEntities, setPersistedEntities, pendingEntityCreates, setPendingEntityCreates, createdEntities, setCreatedEntities, entityStatus, setEntityStatus, selectedFeatureId, setSelectedFeatureId, entityForm, setEntityForm, selectedGeometryEntityIds, setSelectedGeometryEntityIds, geometryMetaForm, setGeometryMetaForm, isEntitySubmitting, setIsEntitySubmitting, entityFormStatus, setEntityFormStatus, entitySearchQuery, setEntitySearchQuery, entitySearchResults, setEntitySearchResults, selectedSearchEntityId, setSelectedSearchEntityId, isEntitySearchLoading, setIsEntitySearchLoading, timelineYear, setTimelineYear, timelineDraftYear, setTimelineDraftYear, isTimelineLoading, setIsTimelineLoading, timelineStatus, setTimelineStatus, backgroundVisibility, setBackgroundVisibility, isBackgroundVisibilityReady, setIsBackgroundVisibilityReady, } = useEditorSessionState({ emptyFeatureCollection: EMPTY_FC, defaultEditorUserId: DEFAULT_EDITOR_USER_ID, fallbackTimelineRange: FALLBACK_TIMELINE_RANGE, currentYear: CURRENT_YEAR, }); const timelineFetchRequestRef = useRef(0); const entitySearchRequestRef = useRef(0); 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]); const sectionCommands = useSectionCommands({ editor, editorUserId, emptyFeatureCollection: EMPTY_FC, activeSection, sectionState, selectedSectionId, newSectionTitle, pendingSaveCount: editor.changeCount + pendingEntityCreates.length, pendingEntityCreates, lastSectionSnapshot, commitTitle, commitNote, setActiveSection, setSelectedSectionId, setSectionState, setLastSectionSnapshot, setInitialData, setSectionCommits, setPendingEntityCreates, setCreatedEntities, setEntityFormStatus, setSelectedFeatureId, setEntityStatus, setIsSaving, setIsSubmitting, setIsOpeningSection, setAvailableSections, setNewSectionTitle, setCommitTitle, setCommitNote, }); useEffect(() => { let disposed = false; async function loadSections() { try { setIsOpeningSection(true); const sections = await fetchSections(); if (disposed) return; setAvailableSections(sections); setSelectedSectionId(sections[0]?.id || ""); setEntityStatus(null); } catch (err) { if (disposed) return; if (err instanceof ApiError) { setEntityStatus(`Không tải được danh sách section: ${err.body || err.message}`); } else { console.error("Load sections failed", err); setEntityStatus("Không tải được danh sách section."); } } finally { if (!disposed) { setIsOpeningSection(false); } } } loadSections(); return () => { disposed = true; }; }, [ setAvailableSections, setEntityStatus, setIsOpeningSection, setSelectedSectionId, ]); 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; }; }, [setEntityStatus, setPersistedEntities]); 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, setEntitySearchResults, setIsEntitySearchLoading, setSelectedSearchEntityId, ]); 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, setSelectedFeatureId]); 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, setEntityFormStatus, setEntitySearchQuery, setEntitySearchResults, setGeometryMetaForm, setSelectedGeometryEntityIds, setSelectedSearchEntityId, ]); 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, setEntityForm]); useEffect(() => { const timeoutId = window.setTimeout(() => { if (timelineDraftYear !== timelineYear) { setTimelineYear(timelineDraftYear); } }, TIMELINE_DEBOUNCE_MS); return () => window.clearTimeout(timeoutId); }, [timelineDraftYear, timelineYear, setTimelineYear]); useEffect(() => { setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); setIsBackgroundVisibilityReady(true); }, [setBackgroundVisibility, setIsBackgroundVisibilityReady]); useEffect(() => { if (activeSection) return; let disposed = false; const requestId = ++timelineFetchRequestRef.current; async function loadGlobalByTimeline() { setIsTimelineLoading(true); setTimelineStatus(null); try { const data = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear, }); if (disposed || requestId !== timelineFetchRequestRef.current) return; setInitialData(data); } catch (err) { if (err instanceof ApiError) { console.error("Load global timeline data failed", err.body); } else { console.error("Load global timeline data failed", err); } if (!disposed && requestId === timelineFetchRequestRef.current) { setTimelineStatus("Không tải được geometry global tại mốc thời gian đã chọn."); } } finally { if (!disposed && requestId === timelineFetchRequestRef.current) { setIsTimelineLoading(false); } } } loadGlobalByTimeline(); return () => { disposed = true; }; }, [ timelineYear, activeSection, setInitialData, setIsTimelineLoading, setTimelineStatus, ]); 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 handleTimelineYearChange = (nextYear: number) => { setTimelineDraftYear(clampYear(Math.trunc(nextYear), FALLBACK_TIMELINE_RANGE)); }; 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 handleApplyGeometryMetadata = async () => { if (!selectedFeature) { setEntityFormStatus("Hãy chọn một geometry trước."); return; } let metadata; try { metadata = buildGeometryMetadataPatch(geometryMetaForm); } catch (err) { setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ."); return; } setIsEntitySubmitting(true); setEntityFormStatus(null); try { editor.patchFeatureProperties(selectedFeature.properties.id, metadata.patch); setGeometryMetaForm(metadata.formState); setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng."); } finally { setIsEntitySubmitting(false); } }; const handleApplyEntitiesForSelectedGeometry = async () => { if (!selectedFeature) { setEntityFormStatus("Hãy chọn một geometry trước."); return; } const entityIds = uniqueEntityIds(selectedGeometryEntityIds); setIsEntitySubmitting(true); setEntityFormStatus(null); try { editor.patchFeatureProperties( selectedFeature.properties.id, buildFeatureEntityPatch(selectedFeature, entityIds, entities) ); setSelectedGeometryEntityIds(entityIds); setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng."); } catch (err) { if (err instanceof ApiError) { setEntityFormStatus(`Lưu thất bại: ${err.body}`); } else { setEntityFormStatus("Lưu thất bại."); } } finally { setIsEntitySubmitting(false); } }; const 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 pendingSaveCount = editor.changeCount + pendingEntityCreates.length; const headCommit = sectionState?.head_commit_id ? sectionCommits.find((commit) => commit.id === sectionState.head_commit_id) || null : null; 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." : isSaving ? "Đang lưu thay đổi..." : timelineStatus; const handleCreateFeature = (feature: Feature) => { editor.createFeature(feature); setSelectedFeatureId(feature.properties.id); }; return (
{isBackgroundVisibilityReady ? ( ) : (
)}
} />
); } function normalizeEditorUserId(value: string): string { const normalized = value.trim(); return normalized || DEFAULT_EDITOR_USER_ID; } function clampYear(year: number, range: TimelineRange): number { return clampYearValue(year, range.min, range.max); } function clampYearValue(year: number, minYear: number, maxYear: number): number { const lower = Math.min(minYear, maxYear); const upper = Math.max(minYear, maxYear); if (year < lower) return lower; if (year > upper) return upper; return year; } function formatCommitTitle(commit: SectionCommit): string { return commit.title?.trim() || `Commit #${commit.commit_no}`; } function getAllowedEntityTypeGroupIdsForFeature(feature: Feature): EntityTypeGroupId[] { const defaultTypeId = getDefaultTypeIdForFeature(feature); const defaultTypeOption = findEntityTypeOption(defaultTypeId); if (defaultTypeOption) { return [defaultTypeOption.groupId]; } return ["polygon"]; }