"use client"; import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from "react"; import { useParams, useRouter } from "next/navigation"; import { useShallow } from "zustand/react/shallow"; import Map, { type MapHandle } from "@/uhm/components/Map"; import Editor from "@/uhm/components/Editor"; import BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel"; import TimelineBar from "@/uhm/components/ui/TimelineBar"; import SelectedGeometryPanel from "@/uhm/components/editor/SelectedGeometryPanel"; import ReplayTimelineSidebar from "@/uhm/components/editor/ReplayTimelineSidebar"; import ReplayEffectsSidebar from "@/uhm/components/editor/ReplayEffectsSidebar"; import PreviewLayout from "@/uhm/components/preview/PreviewLayout"; import WikiSidebarPanel from "@/uhm/components/wiki/WikiSidebarPanel"; import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel"; import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel"; import GeometryBindingPanel from "@/uhm/components/editor/GeometryBindingPanel"; import ImageOverlayPanel from "@/uhm/components/editor/ImageOverlayPanel"; import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities"; import { ApiError } from "@/uhm/api/http"; import { fetchCurrentUser } from "@/uhm/api/auth"; import { searchWikisByTitle, type Wiki } from "@/uhm/api/wikis"; import { searchGeometriesByEntityName, fetchGeometriesByBBox, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries"; import { WORLD_BBOX } from "@/uhm/lib/map/geo/constants"; import { Feature, FeatureCollection, useEditorState, } from "@/uhm/lib/editor/state/useEditorState"; import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes"; import { getDefaultTypeIdForFeature, normalizeFeatureEntityIds, uniqueEntityIds, } from "@/uhm/lib/editor/snapshot/editorSnapshot"; import { getDirectGeometryChildIds, normalizeFeatureBoundWith, wouldCreateGeometryBoundWithCycle, } from "@/uhm/lib/editor/geometry/geometryBinding"; import { buildClientEntityId, mergeEntitySearchResults, } from "@/uhm/lib/editor/entity/entityBinding"; import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding"; import { newId } from "@/uhm/lib/utils/id"; import { loadBackgroundLayerVisibilityFromStorage, } from "@/uhm/lib/editor/background/backgroundVisibilityStorage"; import { deepClone } from "@/uhm/lib/editor/draft/draftDiff"; import { useProjectCommands } from "@/uhm/lib/editor/project/useProjectCommands"; import { useReplayPreview } from "@/uhm/lib/replay/useReplayPreview"; import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants"; import { getViewportImageCoordinates, moveImageOverlayCoordinatesByPixels, scaleImageOverlayCoordinatesByFactor, type MapImageOverlay, } from "@/uhm/components/map/imageOverlay"; import { FIXED_TIMELINE_RANGE, clampYearToFixedRange, normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline"; import { useFeatureCommands } from "@/uhm/lib/editor/geometry/useFeatureCommands"; import { deleteSubmission } from "@/uhm/api/projects"; import type { EntitySnapshot } from "@/uhm/types/entities"; import type { WikiSnapshot } from "@/uhm/types/wiki"; import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects"; import { EditorStoreProvider, useEditorStore, useEditorStoreApi, } from "@/uhm/store/editorStore"; import { EditorSearchResults } from "@/uhm/components/editor/EditorSearchResults"; import { ResizeHandle } from "@/uhm/components/ui/ResizeHandle"; import { clampNumber, formatCommitTitle, isFeatureVisibleAtYear, normalizeEntitiesForCompare, normalizeEntityWikiLinksForCompare, normalizeGeoSearchBoundWith, normalizeGeoSearchGeometry, normalizeReplaysForCompare, normalizeWikisForCompare, } from "@/uhm/lib/editor/editorPageUtils"; const CURRENT_YEAR = new Date().getUTCFullYear(); const DEFAULT_EDITOR_USER_ID = "local-editor"; type ReplayPreviewSession = { replay: BattleReplay | null; replays: BattleReplay[]; draft: FeatureCollection; entities: Entity[]; wikis: WikiSnapshot[]; entityWikiLinks: EntityWikiLinkSnapshot[]; selectedStageId: number | null; selectedStepIndex: number | null; timelineYear: number; timelineFilterEnabled: boolean; mapViewState: ReturnType; }; type PreviewRelationIndex = { entitiesById: Record; entityGeometriesById: Record; entityWikisById: Record; geometryEntityIds: Record; wikiEntityIdsById: Record; wikiEntityIdsBySlug: Record; wikiById: Record; wikiBySlug: Record; }; export default function Page() { return ( ); } function EditorPageContent() { const params = useParams(); const router = useRouter(); const editorStoreApi = useEditorStoreApi(); const projectId = String(params.id || ""); // Ref chặn auto-open lặp lại cùng project khi component re-render. const openedProjectIdRef = useRef(null); // Ref giữ timeout flash message của form entity để clear đúng timer cũ. const entityFormStatusTimeoutRef = useRef(null); // Ref giữ timeout flash message của panel geometry binding. const geoBindingStatusTimeoutRef = useRef(null); // Ref tracking entity tạo local để cleanup khỏi catalog nếu undo/xóa khỏi snapshot. const localCreatedEntityIdsRef = useRef>(new Set()); // Ref nhớ geometry vừa chọn để không xóa status khi chỉ patch metadata cùng geometry. const lastSelectedFeatureIdRef = useRef(null); // Ref bridge sang Map imperative API (getMap/getViewState) cho replay preview. const mapHandleRef = useRef(null); const editorOriginalMapViewStateRef = useRef | null>(null); // State chính của editor nằm trong zustand store để các panel con đọc cùng source-of-truth. const { mode, internalSetMode, baselineFeatureCollection, isSaving, isSubmitting, isOpeningSection, setIsOpeningSection, commitTitle, setCommitTitle, activeSection, projectState, sectionCommits, baselineSnapshot, entityCatalog, setEntityCatalog, snapshotEntityRows, setSnapshotEntityRows, entityStatus, setEntityStatus, selectedFeatureIds, setSelectedFeatureIds, entityForm, setEntityForm, selectedGeometryEntityIds, setSelectedGeometryEntityIds, geometryMetaForm, setGeometryMetaForm, setIsEntitySubmitting, setEntityFormStatus, entitySearchResults, setEntitySearchResults, isEntitySearchLoading, setIsEntitySearchLoading, timelineDraftYear, setTimelineDraftYear, backgroundVisibility, setBackgroundVisibility, isBackgroundVisibilityReady, setIsBackgroundVisibilityReady, snapshotWikis, setSnapshotWikis, snapshotEntityWikiLinks, setSnapshotEntityWikiLinks, blockedPendingSubmissionId, setBlockedPendingSubmissionId, searchKind, setSearchKind, searchQuery, setSearchQuery, searchQueryDraft, setSearchQueryDraft, wikiSearchResults, setWikiSearchResults, isWikiSearching, setIsWikiSearching, geoSearchResults, setGeoSearchResults, isGeoSearching, setIsGeoSearching, setRequestedActiveWikiId, leftPanelWidth, setLeftPanelWidth, rightPanelWidth, setRightPanelWidth, timelineFilterEnabled, setTimelineFilterEnabled, geometryBindingFilterEnabled, setGeoBindingStatus, geometryFocusRequest, setGeometryFocusRequest, replayFeatureId, setReplayFeatureId, hideOutside, setHideOutside, geometryVisibility, setGeometryVisibility, } = useEditorStore(useShallow((state) => ({ mode: state.mode, internalSetMode: state.setMode, baselineFeatureCollection: state.baselineFeatureCollection, isSaving: state.isSaving, isSubmitting: state.isSubmitting, isOpeningSection: state.isOpeningSection, setIsOpeningSection: state.setIsOpeningSection, commitTitle: state.commitTitle, setCommitTitle: state.setCommitTitle, activeSection: state.activeSection, projectState: state.projectState, sectionCommits: state.sectionCommits, baselineSnapshot: state.baselineSnapshot, entityCatalog: state.entityCatalog, setEntityCatalog: state.setEntityCatalog, snapshotEntityRows: state.snapshotEntityRows, setSnapshotEntityRows: state.setSnapshotEntityRows, entityStatus: state.entityStatus, setEntityStatus: state.setEntityStatus, selectedFeatureIds: state.selectedFeatureIds, setSelectedFeatureIds: state.setSelectedFeatureIds, entityForm: state.entityForm, setEntityForm: state.setEntityForm, selectedGeometryEntityIds: state.selectedGeometryEntityIds, setSelectedGeometryEntityIds: state.setSelectedGeometryEntityIds, geometryMetaForm: state.geometryMetaForm, setGeometryMetaForm: state.setGeometryMetaForm, setIsEntitySubmitting: state.setIsEntitySubmitting, setEntityFormStatus: state.setEntityFormStatus, entitySearchResults: state.entitySearchResults, setEntitySearchResults: state.setEntitySearchResults, isEntitySearchLoading: state.isEntitySearchLoading, setIsEntitySearchLoading: state.setIsEntitySearchLoading, timelineDraftYear: state.timelineDraftYear, setTimelineDraftYear: state.setTimelineDraftYear, backgroundVisibility: state.backgroundVisibility, setBackgroundVisibility: state.setBackgroundVisibility, isBackgroundVisibilityReady: state.isBackgroundVisibilityReady, setIsBackgroundVisibilityReady: state.setIsBackgroundVisibilityReady, snapshotWikis: state.snapshotWikis, setSnapshotWikis: state.setSnapshotWikis, snapshotEntityWikiLinks: state.snapshotEntityWikiLinks, setSnapshotEntityWikiLinks: state.setSnapshotEntityWikiLinks, blockedPendingSubmissionId: state.blockedPendingSubmissionId, setBlockedPendingSubmissionId: state.setBlockedPendingSubmissionId, searchKind: state.searchKind, setSearchKind: state.setSearchKind, searchQuery: state.searchQuery, setSearchQuery: state.setSearchQuery, searchQueryDraft: state.searchQueryDraft, setSearchQueryDraft: state.setSearchQueryDraft, wikiSearchResults: state.wikiSearchResults, setWikiSearchResults: state.setWikiSearchResults, isWikiSearching: state.isWikiSearching, setIsWikiSearching: state.setIsWikiSearching, geoSearchResults: state.geoSearchResults, setGeoSearchResults: state.setGeoSearchResults, isGeoSearching: state.isGeoSearching, setIsGeoSearching: state.setIsGeoSearching, setRequestedActiveWikiId: state.setRequestedActiveWikiId, leftPanelWidth: state.leftPanelWidth, setLeftPanelWidth: state.setLeftPanelWidth, rightPanelWidth: state.rightPanelWidth, setRightPanelWidth: state.setRightPanelWidth, timelineFilterEnabled: state.timelineFilterEnabled, setTimelineFilterEnabled: state.setTimelineFilterEnabled, geometryBindingFilterEnabled: state.geometryBindingFilterEnabled, setGeoBindingStatus: state.setGeoBindingStatus, geometryFocusRequest: state.geometryFocusRequest, setGeometryFocusRequest: state.setGeometryFocusRequest, replayFeatureId: state.replayFeatureId, setReplayFeatureId: state.setReplayFeatureId, hideOutside: state.hideOutside, setHideOutside: state.setHideOutside, geometryVisibility: state.geometryVisibility, setGeometryVisibility: state.setGeometryVisibility, }))); // Counter để bỏ qua response cũ khi user gõ search liên tục. const entitySearchRequestRef = useRef(0); const wikiSearchRequestRef = useRef(0); const geoSearchRequestRef = useRef(0); // Refs mirror snapshot arrays để undo callbacks luôn đọc state mới nhất. const snapshotEntityRowsRef = useRef(snapshotEntityRows); const snapshotWikisRef = useRef(snapshotWikis); const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks); useEffect(() => { snapshotEntityRowsRef.current = snapshotEntityRows; }, [snapshotEntityRows]); useEffect(() => { snapshotWikisRef.current = snapshotWikis; }, [snapshotWikis]); useEffect(() => { snapshotEntityWikiLinksRef.current = snapshotEntityWikiLinks; }, [snapshotEntityWikiLinks]); // Hook quản lý draft/changes/undo cho main editor và replay editor. const editor = useEditorState(baselineFeatureCollection, { snapshotUndo: { snapshotEntityRowsRef, setSnapshotEntityRows, snapshotWikisRef, setSnapshotWikis, snapshotEntityWikiLinksRef, setSnapshotEntityWikiLinks, }, initialReplays: baselineSnapshot?.replays, mode: mode, }); // Setter bọc undo cho thao tác cập nhật wiki snapshot. const setSnapshotWikisUndoable = useCallback( (next: SetStateAction) => { editor.setSnapshotWikis(next, "Cập nhật wiki"); }, [editor] ); // Setter bọc undo cho thao tác cập nhật binding entity-wiki. const setSnapshotEntityWikiLinksUndoable = useCallback( (next: SetStateAction) => { editor.setSnapshotEntityWikiLinks(next, "Cập nhật entity-wiki"); }, [editor] ); // Xóa wiki là một thay đổi snapshot kép: wiki row + các binding entity-wiki trỏ tới wiki đó. const removeSnapshotWikiUndoable = useCallback( (wikiId: string) => { const id = String(wikiId || "").trim(); if (!id) return; editor.setSnapshotWikisAndEntityWikiLinks( (prev) => prev.filter((wiki) => wiki.id !== id), (prev) => prev.filter((link) => String(link.wiki_id) !== id), `Xóa wiki #${id}` ); }, [editor] ); // Chuyển entity snapshot local thành entity catalog row để search/binding dùng chung. const snapshotEntityRowsAsEntities = useMemo(() => { const rows = snapshotEntityRows || []; return rows .filter((e) => e && e.operation !== "delete") .map((e) => ({ id: String(e.id || ""), name: String(e.name || "").trim() || String(e.id || ""), description: e.description ?? null, time_start: normalizeTimelineYearValue(e.time_start), time_end: normalizeTimelineYearValue(e.time_end), geometry_count: 0, })) .filter((e) => e.id.length > 0 && e.name.length > 0); }, [snapshotEntityRows]); // Entity list hợp nhất giữa backend catalog và snapshot local. const entities = useMemo( () => mergeEntitySearchResults(entityCatalog, snapshotEntityRowsAsEntities), [entityCatalog, snapshotEntityRowsAsEntities] ); // State vị trí stage/step đang chọn trong replay editor. const [replaySelection, setReplaySelection] = useState<{ stageId: number | null; stepIndex: number | null; }>({ stageId: null, stepIndex: null, }); // State snapshot đóng băng của replay preview, tách khỏi draft đang edit. const [previewSession, setPreviewSession] = useState(null); // State yêu cầu autoplay sau khi chuyển vào preview mode. const [previewAutoplayMode, setPreviewAutoplayMode] = useState<"start" | "selection" | null>(null); const [viewMode, setViewMode] = useState<"local" | "global">("local"); const [globalGeometries, setGlobalGeometries] = useState({ type: "FeatureCollection", features: [], }); const [isGlobalLoading, setIsGlobalLoading] = useState(false); // State ảnh overlay local-only để vẽ trace theo ảnh mẫu. const [imageOverlay, setImageOverlay] = useState(null); // Bật/tắt điều khiển ảnh overlay bằng phím mũi tên và W/S. const [imageOverlayKeyboardEnabled, setImageOverlayKeyboardEnabled] = useState(false); // Ref giữ object URL hiện tại để revoke khi đổi/xóa ảnh, tránh leak bộ nhớ. const imageOverlayObjectUrlRef = useRef(null); // Cập nhật stage/step được chọn trong sidebar replay. const handleReplaySelectionChange = useCallback((stageId: number | null, stepIndex: number | null) => { setReplaySelection({ stageId, stepIndex }); }, []); // Helper đọc MapLibre instance hiện tại cho replay dispatcher. const getCurrentMapInstance = useCallback(() => mapHandleRef.current?.getMap() ?? null, []); // Helper đọc camera/view hiện tại để lưu vào replay preview. const getCurrentMapViewState = useCallback(() => mapHandleRef.current?.getViewState() ?? null, []); const restoreEditorOriginalMapState = useCallback(() => { const map = getCurrentMapInstance(); const savedViewState = editorOriginalMapViewStateRef.current; if (map && savedViewState) { mapHandleRef.current?.setGlobeProjection(savedViewState.projection === "globe"); map.easeTo({ center: savedViewState.center, zoom: savedViewState.zoom, pitch: savedViewState.pitch, bearing: savedViewState.bearing, duration: 650, }); } editorOriginalMapViewStateRef.current = null; }, [getCurrentMapInstance]); const isReplayEditMode = mode === "replay"; const isViewerPreviewMode = mode === "preview"; const isReplayPreviewMode = mode === "replay_preview"; const isAnyPreviewMode = isViewerPreviewMode || isReplayPreviewMode; const previewReturnModeRef = useRef("select"); const replayPreviewReturnRef = useRef<{ mode: "replay" | "preview"; session: ReplayPreviewSession | null; }>({ mode: "replay", session: null }); // Ref mirror entity list cho debounce search không phụ thuộc closure cũ. const entitiesRef = useRef(entities); useEffect(() => { entitiesRef.current = entities; }, [entities]); useEffect(() => { return () => { if (imageOverlayObjectUrlRef.current) { URL.revokeObjectURL(imageOverlayObjectUrlRef.current); imageOverlayObjectUrlRef.current = null; } }; }, []); useEffect(() => { if (!imageOverlayKeyboardEnabled) return; const handleKeyDown = (event: KeyboardEvent) => { if (isTypingTarget(event.target)) return; const key = event.key.toLowerCase(); const step = event.shiftKey ? 9.6 : 2.8; let handled = true; setImageOverlay((prev) => { if (!prev) return prev; const map = getCurrentMapInstance(); if (!map) return prev; if (key === "w") { return { ...prev, coordinates: moveImageOverlayCoordinatesByPixels(map, prev.coordinates, 0, -step) }; } if (key === "s") { return { ...prev, coordinates: moveImageOverlayCoordinatesByPixels(map, prev.coordinates, 0, step) }; } if (key === "a") { return { ...prev, coordinates: moveImageOverlayCoordinatesByPixels(map, prev.coordinates, -step, 0) }; } if (key === "d") { return { ...prev, coordinates: moveImageOverlayCoordinatesByPixels(map, prev.coordinates, step, 0) }; } if (key === "q") { return { ...prev, coordinates: scaleImageOverlayCoordinatesByFactor(map, prev.coordinates, 1.012, prev.aspectRatio), }; } if (key === "e") { return { ...prev, coordinates: scaleImageOverlayCoordinatesByFactor(map, prev.coordinates, 0.988, prev.aspectRatio), }; } handled = false; return prev; }); if (handled) { event.preventDefault(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [getCurrentMapInstance, imageOverlayKeyboardEnabled]); useEffect(() => { const localCreatedIds = localCreatedEntityIdsRef.current; if (!localCreatedIds.size) return; const snapshotIds = new Set((snapshotEntityRows || []).map((entity) => String(entity.id || ""))); setEntityCatalog((prev) => { let changed = false; const next = (prev || []).filter((entity) => { const id = String(entity?.id || ""); const shouldDrop = localCreatedIds.has(id) && !snapshotIds.has(id); if (shouldDrop) { changed = true; localCreatedIds.delete(id); return false; } return true; }); return changed ? next : prev; }); }, [snapshotEntityRows, setEntityCatalog]); // Clamp năm timeline vào range cố định trước khi đưa vào store. const handleTimelineYearChange = useCallback((nextYear: number) => { setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear))); }, [setTimelineDraftYear]); // Preview specific UI states const [previewActiveEntityId, setPreviewActiveEntityId] = useState(null); const [previewEntityFocusToken, setPreviewEntityFocusToken] = useState(0); const [previewSidebarWidth, setPreviewSidebarWidth] = useState(() => { if (typeof window !== "undefined") { const saved = localStorage.getItem("public-wiki-sidebar-width"); if (saved) { const parsed = parseInt(saved, 10); if (!Number.isNaN(parsed) && parsed >= 320 && parsed <= 800) { return parsed; } } } return 420; }); const [isLargeScreen, setIsLargeScreen] = useState(false); const previewLayoutRef = useRef(null); // Responsive listener for preview sidebar/viewport offsets useEffect(() => { if (typeof window === "undefined") return; const handleResize = () => { setIsLargeScreen(window.innerWidth >= 1024); }; handleResize(); window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []); // Hook điều phối phát replay preview và các side effect lên map/UI. const replayPreview = useReplayPreview({ replay: previewSession?.replay || null, draft: previewSession?.draft || EMPTY_FEATURE_COLLECTION, getMapInstance: getCurrentMapInstance, initialTimelineYear: previewSession?.timelineYear ?? timelineDraftYear, initialTimelineFilterEnabled: previewSession?.timelineFilterEnabled ?? timelineFilterEnabled, initialMapViewState: previewSession?.mapViewState ?? null, selectedStageId: previewSession?.selectedStageId ?? replaySelection.stageId, selectedStepIndex: previewSession?.selectedStepIndex ?? replaySelection.stepIndex, onSelectStep: () => { }, setMapProjection: useCallback((type: "globe" | "mercator") => { mapHandleRef.current?.setGlobeProjection(type === "globe"); }, []), }); const { hiddenGeometryIds: replayPreviewHiddenGeometryIds, timelineYear: replayPreviewTimelineYear, timelineFilterEnabled: replayPreviewTimelineFilterEnabled, activeWikiId: replayPreviewActiveWikiId, } = replayPreview; // Draft hiển thị trong preview có thể ẩn bớt geometry theo action replay. const replayPreviewDraft = useMemo(() => { const sourceDraft = previewSession?.draft || EMPTY_FEATURE_COLLECTION; if (!isReplayPreviewMode || replayPreviewHiddenGeometryIds.length === 0) { return sourceDraft; } const hiddenIds = new Set(replayPreviewHiddenGeometryIds); return { ...sourceDraft, features: sourceDraft.features.filter( (feature) => !hiddenIds.has(String(feature.properties.id)) ), }; }, [isReplayPreviewMode, previewSession?.draft, replayPreviewHiddenGeometryIds]); const [previewWikiCache, setPreviewWikiCache] = useState>({}); const previewRelations = useMemo(() => { return buildPreviewRelationIndex({ draft: previewSession?.draft || EMPTY_FEATURE_COLLECTION, entities: previewSession?.entities || [], wikis: previewSession?.wikis || [], entityWikiLinks: previewSession?.entityWikiLinks || [], wikiCache: previewWikiCache, projectId, }); }, [previewSession?.draft, previewSession?.entities, previewSession?.wikis, previewSession?.entityWikiLinks, previewWikiCache, projectId]); const replayPreviewActiveEntityId = useMemo(() => { const activeWikiEntityIds = replayPreviewActiveWikiId ? previewRelations.wikiEntityIdsById[String(replayPreviewActiveWikiId)] || [] : []; if ( previewActiveEntityId && (!activeWikiEntityIds.length || activeWikiEntityIds.includes(previewActiveEntityId)) ) { return previewActiveEntityId; } return activeWikiEntityIds[0] || previewActiveEntityId; }, [previewActiveEntityId, previewRelations.wikiEntityIdsById, replayPreviewActiveWikiId]); const replayPreviewActiveEntityGeometries = useMemo(() => { return replayPreviewActiveEntityId ? previewRelations.entityGeometriesById[replayPreviewActiveEntityId] || EMPTY_FEATURE_COLLECTION : EMPTY_FEATURE_COLLECTION; }, [replayPreviewActiveEntityId, previewRelations.entityGeometriesById]); const activeTimelineYear = isReplayPreviewMode ? replayPreviewTimelineYear : isViewerPreviewMode ? previewSession?.timelineYear ?? timelineDraftYear : timelineDraftYear; const activeTimelineFilterEnabled = isReplayPreviewMode ? replayPreviewTimelineFilterEnabled : isViewerPreviewMode ? previewSession?.timelineFilterEnabled ?? timelineFilterEnabled : timelineFilterEnabled; // Render draft is the only FeatureCollection that decides what appears on the map. // It may be timeline-filtered, replay-filtered, or preview-filtered, but it is not the edit source. // Fetch global geometries when viewMode is "global", timeline year changes, or timeline filter state changes useEffect(() => { if (viewMode !== "global") { return; } let disposed = false; setIsGlobalLoading(true); const timeVal = activeTimelineFilterEnabled ? clampYearToFixedRange(Math.trunc(activeTimelineYear)) : undefined; const loadGlobalData = async () => { try { // 1. Fetch all geometries in a single fast query const baseFc = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timeVal, timeRange: activeTimelineFilterEnabled ? 0 : undefined, }); if (disposed) return; setGlobalGeometries(baseFc); // 2. Concurrently fetch per-entity to build the geometry-to-entity mapping const geoToEntities: Record = {}; const concurrency = 6; const items = [...entities]; let nextIndex = 0; await Promise.all( Array.from({ length: Math.min(concurrency, items.length) }, async () => { while (true) { if (disposed) return; const idx = nextIndex++; if (idx >= items.length) return; const entity = items[idx]; try { const fc = await fetchGeometriesByBBox({ ...WORLD_BBOX, entity_id: entity.id, time: timeVal, timeRange: activeTimelineFilterEnabled ? 0 : undefined, }); if (disposed) return; for (const feature of fc.features) { const gid = String(feature.properties?.id); if (!geoToEntities[gid]) { geoToEntities[gid] = { entity_id: entity.id, entity_name: entity.name, entity_ids: [entity.id], }; } else { if (!geoToEntities[gid].entity_ids.includes(entity.id)) { geoToEntities[gid].entity_ids.push(entity.id); } } } } catch (e) { console.error(`Error loading geometry mapping for entity ${entity.id}`, e); } } }) ); if (disposed) return; // 3. Update the global geometries with the enriched properties setGlobalGeometries((prev) => { return { ...prev, features: prev.features.map((feature) => { const gid = String(feature.properties?.id); const mapping = geoToEntities[gid]; if (mapping) { return { ...feature, properties: { ...feature.properties, entity_id: mapping.entity_id, entity_name: mapping.entity_name, entity_ids: mapping.entity_ids, }, }; } return feature; }), }; }); } catch (err) { console.error("Load global geometries failed", err); } finally { if (!disposed) { setIsGlobalLoading(false); } } }; loadGlobalData(); return () => { disposed = true; }; }, [viewMode, activeTimelineYear, activeTimelineFilterEnabled, entities]); // Render draft is the only FeatureCollection that decides what appears on the map. // It may be timeline-filtered, replay-filtered, or preview-filtered, but it is not the edit source. const mapRenderDraft = useMemo(() => { const activeDraft = isReplayEditMode ? editor.replayDraft : editor.mainDraft; const filteredDraft = activeTimelineFilterEnabled ? { ...activeDraft, features: activeDraft.features.filter((feature) => isFeatureVisibleAtYear(feature, clampYearToFixedRange(Math.trunc(activeTimelineYear))) ), } : activeDraft; if (viewMode === "local") { return filteredDraft; } // We want to ignore any database geometries whose IDs are present in either the active local features // or the baseline features (since those are owned by the local session/commit context). const localFeatureIds = new Set(); for (const f of filteredDraft.features) { if (f.properties?.id != null) { localFeatureIds.add(String(f.properties.id)); } } for (const f of baselineFeatureCollection.features) { if (f.properties?.id != null) { localFeatureIds.add(String(f.properties.id)); } } const mergedFeatures = [...filteredDraft.features]; // Add global features that are not owned/modified/deleted by the local session for (const globalFeature of globalGeometries.features) { const globalId = globalFeature.properties?.id != null ? String(globalFeature.properties.id) : null; if (globalId === null || !localFeatureIds.has(globalId)) { mergedFeatures.push(globalFeature); } } return { ...filteredDraft, features: mergedFeatures, }; }, [ activeTimelineFilterEnabled, activeTimelineYear, editor.mainDraft, editor.replayDraft, isReplayEditMode, viewMode, baselineFeatureCollection.features, globalGeometries.features, ]); const activeMapDraft = useMemo(() => { if (isAnyPreviewMode) { return isReplayPreviewMode ? replayPreviewDraft : (previewSession?.draft || EMPTY_FEATURE_COLLECTION); } return mapRenderDraft; }, [isAnyPreviewMode, isReplayPreviewMode, replayPreviewDraft, previewSession?.draft, mapRenderDraft]); const localFeatureIds = useMemo(() => { const ids = new Set(); for (const feature of editor.mainDraft.features) { if (feature.properties?.id !== undefined && feature.properties.id !== null) { ids.add(feature.properties.id); } } for (const feature of baselineFeatureCollection.features) { if (feature.properties?.id !== undefined && feature.properties.id !== null) { ids.add(feature.properties.id); } } return Array.from(ids); }, [baselineFeatureCollection.features, editor.mainDraft.features]); // Danh sách feature đang chọn, map từ selectedFeatureIds sang draft hiện tại. const selectedFeatures = useMemo(() => { if (!selectedFeatureIds || selectedFeatureIds.length === 0) return []; return selectedFeatureIds .map(id => editor.draft.features.find(f => String(f.properties.id) === String(id))) .filter(Boolean) as Feature[]; }, [selectedFeatureIds, editor.draft.features]); // Multi-edit chỉ hợp lệ khi các geometry được chọn cùng shape type. const isMultiEditValid = useMemo(() => { if (selectedFeatures.length <= 1) return true; const firstShape = selectedFeatures[0].geometry.type; return selectedFeatures.every(f => f.geometry.type === firstShape); }, [selectedFeatures]); // Feature đại diện cho panel phải; null khi multi-edit không cùng loại. const selectedFeature = selectedFeatures.length > 0 && isMultiEditValid ? selectedFeatures[0] : null; const selectedGeometryTime = useMemo(() => { if (!selectedFeature) return null; return { time_start: normalizeTimelineYearValue(selectedFeature.properties.time_start), time_end: normalizeTimelineYearValue(selectedFeature.properties.time_end), }; }, [selectedFeature]); // Choices cho panel bind geometry, gồm cả marker geometry mới tạo local. const geometryChoices = useMemo(() => { const createdGeometryIds = new Set(); for (const [id, change] of editor.changes.entries()) { if (change.action === "create") createdGeometryIds.add(String(id)); } const mapRenderGeometryIds = new Set( mapRenderDraft.features.map((feature) => String(feature.properties.id)) ); const rows = (editor.draft.features || []) .filter((f) => f && f.properties && (typeof f.properties.id === "string" || typeof f.properties.id === "number")) .map((f) => { const id = String(f.properties.id); const semantic = String(f.properties.type || getDefaultTypeIdForFeature(f) || "").trim(); const label = semantic.length ? `${semantic} (${f.geometry.type})` : "Geometry"; const timeStart = normalizeTimelineYearValue(f.properties.time_start); const timeEnd = normalizeTimelineYearValue(f.properties.time_end); const hasStart = timeStart !== null; const hasEnd = timeEnd !== null; const timeStatus: "missing" | "partial" | "complete" = !hasStart && !hasEnd ? "missing" : !hasStart || !hasEnd ? "partial" : "complete"; const isTimelineVisible = mapRenderGeometryIds.has(id); const timelineStatus: "off" | "visible" | "filteredOut" = !activeTimelineFilterEnabled ? "off" : isTimelineVisible ? "visible" : "filteredOut"; return { id, label, time_start: timeStart, time_end: timeEnd, isTimelineVisible, isOrphan: normalizeFeatureEntityIds(f).length === 0, timeStatus, timelineStatus, isNew: createdGeometryIds.has(id) || !editor.hasPersistedFeature(f.properties.id), }; }); rows.sort((a, b) => a.id.localeCompare(b.id)); return rows; }, [activeTimelineFilterEnabled, editor, mapRenderDraft.features]); // Child ids bound to the selected geometry via child.properties.bound_with. const selectedGeometryChildIds = useMemo(() => { if (!selectedFeature) return []; return getDirectGeometryChildIds(editor.draft, selectedFeature.properties.id); }, [editor.draft, selectedFeature]); // Choices wiki dùng trong replay actions và binding panel. const wikiChoices = useMemo(() => { return (snapshotWikis || []) .filter((wiki) => wiki && wiki.operation !== "delete") .map((wiki) => ({ id: String(wiki.id || ""), label: (wiki.title || "").trim() || "Untitled wiki", })) .filter((wiki) => wiki.id.length > 0); }, [snapshotWikis]); // Dirty flag cho wiki snapshot so với baseline commit. const wikiDirty = useMemo(() => { const prev = normalizeWikisForCompare(baselineSnapshot?.wikis); const next = normalizeWikisForCompare(snapshotWikis); try { return JSON.stringify(prev) !== JSON.stringify(next); } catch { return true; } }, [baselineSnapshot?.wikis, snapshotWikis]); // Dirty flag cho entity snapshot so với baseline commit. const entitiesDirty = useMemo(() => { const prev = normalizeEntitiesForCompare(baselineSnapshot?.entities); const next = normalizeEntitiesForCompare(snapshotEntityRows); try { return JSON.stringify(prev) !== JSON.stringify(next); } catch { return true; } }, [baselineSnapshot?.entities, snapshotEntityRows]); // Dirty flag cho binding entity-wiki so với baseline commit. const entityWikiDirty = useMemo(() => { const prev = normalizeEntityWikiLinksForCompare(baselineSnapshot?.entity_wiki); const next = normalizeEntityWikiLinksForCompare(snapshotEntityWikiLinks); try { return JSON.stringify(prev) !== JSON.stringify(next); } catch { return true; } }, [snapshotEntityWikiLinks, baselineSnapshot?.entity_wiki]); // Dirty flag cho replay scripts so với baseline commit. const replayDirty = useMemo(() => { const prev = normalizeReplaysForCompare(baselineSnapshot?.replays); const next = normalizeReplaysForCompare(editor.effectiveReplays); try { return JSON.stringify(prev) !== JSON.stringify(next); } catch { return true; } }, [baselineSnapshot?.replays, editor.effectiveReplays]); // Tổng số nhóm thay đổi chưa commit, dùng để enable/disable commit UI. const pendingSaveCount = editor.changeCount + (wikiDirty ? 1 : 0) + (entitiesDirty ? 1 : 0) + (entityWikiDirty ? 1 : 0) + (replayDirty ? 1 : 0); // Stages của replay đang active, fallback [] để sidebar an toàn. const activeReplayStages = useMemo( () => editor.activeReplayDraft?.detail || [], [editor.activeReplayDraft?.detail] ); // Commands thao tác project/commit/submission dựa trên draft + store hiện tại. const sectionCommands = useProjectCommands({ editor, store: editorStoreApi, emptyFeatureCollection: EMPTY_FEATURE_COLLECTION, pendingSaveCount, }); const { openSectionForEditing, commitSection, submitCurrentSection, restoreCommit, } = sectionCommands; const openViewerPreview = useCallback(() => { if (mode === "preview" || mode === "replay_preview" || mode === "replay") return; previewReturnModeRef.current = mode === "idle" ? "select" : mode; editorOriginalMapViewStateRef.current = getCurrentMapViewState(); setPreviewSession({ replay: null, replays: deepClone(editor.effectiveReplays), draft: deepClone(editor.mainDraft), entities: deepClone(entities), wikis: deepClone(snapshotWikis), entityWikiLinks: deepClone(snapshotEntityWikiLinks), selectedStageId: null, selectedStepIndex: null, timelineYear: timelineDraftYear, timelineFilterEnabled, mapViewState: getCurrentMapViewState(), }); setPreviewAutoplayMode(null); setSelectedFeatureIds([]); internalSetMode("preview"); }, [ editor.effectiveReplays, editor.mainDraft, entities, getCurrentMapViewState, internalSetMode, mode, snapshotEntityWikiLinks, snapshotWikis, timelineDraftYear, timelineFilterEnabled, setSelectedFeatureIds, ]); const exitViewerPreview = useCallback(() => { restoreEditorOriginalMapState(); setPreviewAutoplayMode(null); setPreviewSession(null); setSelectedFeatureIds([]); internalSetMode(previewReturnModeRef.current || "select"); }, [internalSetMode, restoreEditorOriginalMapState, setSelectedFeatureIds]); // Thoát replay preview. Nếu replay được mở từ preview thường thì quay lại preview thường. const exitReplayPreview = useCallback(() => { setPreviewAutoplayMode(null); const returnState = replayPreviewReturnRef.current; replayPreviewReturnRef.current = { mode: "replay", session: null }; if (returnState.mode === "preview" && returnState.session) { setPreviewSession(deepClone(returnState.session)); setSelectedFeatureIds([]); internalSetMode("preview"); return; } restoreEditorOriginalMapState(); setPreviewSession(null); setSelectedFeatureIds([]); internalSetMode("replay"); }, [internalSetMode, restoreEditorOriginalMapState, setSelectedFeatureIds]); // Đóng băng draft/replay hiện tại thành session preview để phát thử. const openReplayPreview = useCallback((autoplayMode: "start" | "selection") => { if (!editor.activeReplayDraft) return; replayPreviewReturnRef.current = { mode: "replay", session: null }; editorOriginalMapViewStateRef.current = getCurrentMapViewState(); setPreviewSession({ replay: deepClone(editor.activeReplayDraft), replays: deepClone(editor.effectiveReplays), draft: deepClone(editor.replayDraft), entities: deepClone(entities), wikis: deepClone(snapshotWikis), entityWikiLinks: deepClone(snapshotEntityWikiLinks), selectedStageId: replaySelection.stageId, selectedStepIndex: replaySelection.stepIndex, timelineYear: timelineDraftYear, timelineFilterEnabled, mapViewState: getCurrentMapViewState(), }); setPreviewAutoplayMode(autoplayMode); setSelectedFeatureIds([]); internalSetMode("replay_preview"); }, [ editor.activeReplayDraft, editor.effectiveReplays, editor.replayDraft, entities, getCurrentMapViewState, internalSetMode, replaySelection.stageId, replaySelection.stepIndex, setSelectedFeatureIds, snapshotEntityWikiLinks, snapshotWikis, timelineDraftYear, timelineFilterEnabled, ]); const viewerPreviewSelectedReplay = useMemo(() => { if (!isViewerPreviewMode || !selectedFeatureIds.length) return null; const selectedGeometryId = String(selectedFeatureIds[0] ?? "").trim(); if (!selectedGeometryId.length) return null; return (previewSession?.replays || []).find( (replay) => String(replay?.geometry_id || "").trim() === selectedGeometryId && hasPlayableReplaySteps(replay) ) || null; }, [isViewerPreviewMode, previewSession?.replays, selectedFeatureIds]); const openSelectedViewerReplayPreview = useCallback(() => { if (!isViewerPreviewMode || !previewSession || !viewerPreviewSelectedReplay) return; const returnSession = deepClone(previewSession); const selectedReplay = deepClone(viewerPreviewSelectedReplay); replayPreviewReturnRef.current = { mode: "preview", session: returnSession, }; setPreviewSession({ ...returnSession, replay: selectedReplay, draft: buildReplayPreviewDraftFromSource(returnSession.draft, selectedReplay), selectedStageId: null, selectedStepIndex: null, timelineYear: activeTimelineYear, timelineFilterEnabled: activeTimelineFilterEnabled, mapViewState: getCurrentMapViewState(), }); setPreviewAutoplayMode("start"); setSelectedFeatureIds([]); internalSetMode("replay_preview"); }, [ activeTimelineFilterEnabled, activeTimelineYear, getCurrentMapViewState, internalSetMode, isViewerPreviewMode, previewSession, viewerPreviewSelectedReplay, setSelectedFeatureIds, ]); const handlePreviewModeChange = useCallback((nextMode: EditorMode) => { if (nextMode === "preview") { if (isReplayPreviewMode) { exitReplayPreview(); } } else if (nextMode === "replay_preview") { if (isViewerPreviewMode && viewerPreviewSelectedReplay) { openSelectedViewerReplayPreview(); } } }, [isReplayPreviewMode, isViewerPreviewMode, exitReplayPreview, openSelectedViewerReplayPreview, viewerPreviewSelectedReplay]); const handleMapFeatureClick = useCallback((payload: any) => { previewLayoutRef.current?.handleFeatureClick(payload); }, []); const handleMapHoverPopupContent = useCallback((payload: any) => { return previewLayoutRef.current?.getHoverPopupContent(payload) ?? null; }, []); const handleMapPlayPreviewReplay = useCallback(() => { if (viewerPreviewSelectedReplay) { previewLayoutRef.current?.handlePlaySelectedReplay(viewerPreviewSelectedReplay); } }, [viewerPreviewSelectedReplay]); // State machine chuyển mode editor, xử lý riêng preview/replay để không mất draft. const setMode = useCallback((m: EditorMode, featureId?: string | number) => { if (m === "preview" || m === "replay_preview") { return; } if (mode === "preview") { setPreviewAutoplayMode(null); setPreviewSession(null); setSelectedFeatureIds([]); internalSetMode(m); return; } if (mode === "replay_preview") { setPreviewAutoplayMode(null); setPreviewSession(null); setSelectedFeatureIds([]); if (m === "replay") { internalSetMode("replay"); return; } editor.closeReplayContext(); setSelectedFeatureIds([]); setReplayFeatureId(null); setHideOutside(false); setReplaySelection({ stageId: null, stepIndex: null }); internalSetMode(m); return; } if (m === "replay" && featureId) { // QUY TẮC: Geo chọn đầu tiên là geo main. const finalSelectedIds = Array.from(new Set([...selectedFeatureIds, featureId])); const triggerId = selectedFeatureIds.length > 0 ? selectedFeatureIds[0] : featureId; setReplayFeatureId(triggerId); setReplaySelection({ stageId: null, stepIndex: null }); editor.switchReplayContext(triggerId, finalSelectedIds); setSelectedFeatureIds([]); } else if (m !== "replay") { if (mode === "replay") { editor.closeReplayContext(); setSelectedFeatureIds([]); } setReplayFeatureId(null); setHideOutside(false); setReplaySelection({ stageId: null, stepIndex: null }); } internalSetMode(m); }, [ editor, internalSetMode, mode, selectedFeatureIds, setHideOutside, setReplayFeatureId, setSelectedFeatureIds, ]); useEffect(() => { if (!activeReplayStages.length) { if (replaySelection.stageId != null || replaySelection.stepIndex != null) { setReplaySelection({ stageId: null, stepIndex: null }); } return; } const targetStage = activeReplayStages.find((stage) => stage.id === replaySelection.stageId) || activeReplayStages[0]; const nextStageId = targetStage.id; let nextStepIndex: number | null = null; if (targetStage.steps.length > 0) { if ( replaySelection.stageId === targetStage.id && replaySelection.stepIndex != null && replaySelection.stepIndex >= 0 && replaySelection.stepIndex < targetStage.steps.length ) { nextStepIndex = replaySelection.stepIndex; } else { nextStepIndex = 0; } } if ( nextStageId !== replaySelection.stageId || nextStepIndex !== replaySelection.stepIndex ) { setReplaySelection({ stageId: nextStageId, stepIndex: nextStepIndex, }); } }, [activeReplayStages, replaySelection.stageId, replaySelection.stepIndex]); const replayMarkerGeometryId = useMemo(() => { if (isReplayPreviewMode) { const id = String(previewSession?.replay?.geometry_id || replayFeatureId || "").trim(); return id.length ? id : null; } if (isReplayEditMode && replayFeatureId) { return String(replayFeatureId); } return null; }, [isReplayEditMode, isReplayPreviewMode, previewSession?.replay?.geometry_id, replayFeatureId]); const effectiveGeometryVisibility = useMemo(() => { const visibility: Record = { ...geometryVisibility }; if ((isReplayEditMode || isReplayPreviewMode) && replayMarkerGeometryId) { visibility[replayMarkerGeometryId] = false; if (isReplayEditMode && hideOutside) { const currentReplayFeatureIds = new Set(editor.draft.features.map(f => String(f.properties.id))); Object.keys(visibility).forEach(fid => { if (fid === replayMarkerGeometryId) { visibility[fid] = false; } else { visibility[fid] = currentReplayFeatureIds.has(fid); } }); } } return visibility; }, [ editor.draft.features, geometryVisibility, hideOutside, isReplayEditMode, isReplayPreviewMode, replayMarkerGeometryId, ]); // Load project editor payload, xử lý auth và pending-submission lock. const openProject = useCallback(async () => { if (!projectId) return; try { setIsOpeningSection(true); setEntityStatus(null); setBlockedPendingSubmissionId(null); await openSectionForEditing(projectId); setEntityStatus(null); } catch (err) { if (err instanceof ApiError) { // Only bounce to login when the session is truly unauthenticated. // Token refresh is handled centrally; if we still get 401 here, refresh likely failed/expired. if (err.status === 401) { router.replace("/signin"); return; } // Pending submission blocks editor in BE. We parse the pending id to offer delete/unlock. if (err.status === 409) { try { const payload = JSON.parse(err.body || "{}"); if (payload?.pending_submission_id) { setBlockedPendingSubmissionId(String(payload.pending_submission_id)); setEntityStatus("Project đang có submission PENDING. Hãy xoa submission đó để unlock editor."); return; } } catch { // fallthrough } } setEntityStatus(`Mở project thất bại: ${err.body || err.message}`); } else { console.error("Open project failed", err); setEntityStatus("Mở project thất bại."); } } finally { setIsOpeningSection(false); } }, [openSectionForEditing, projectId, router, setBlockedPendingSubmissionId, setEntityStatus, setIsOpeningSection]); // Xóa pending submission để backend cho phép mở editor lại. const unlockByDeletingPendingSubmission = useCallback(async () => { if (!blockedPendingSubmissionId) return; const confirmed = window.confirm("Bạn chắc chắn muốn xóa Submition? - việc này không làm hỏng project của bạn"); if (!confirmed) return; try { setIsOpeningSection(true); setEntityStatus(null); await deleteSubmission(blockedPendingSubmissionId); setBlockedPendingSubmissionId(null); await openProject(); } catch (err) { if (err instanceof ApiError) { setEntityStatus(`Khong the xoa submission: ${err.body || err.message}`); } else { setEntityStatus("Khong the xoa submission."); } } finally { setIsOpeningSection(false); } }, [blockedPendingSubmissionId, openProject, setBlockedPendingSubmissionId, setEntityStatus, setIsOpeningSection]); useEffect(() => { let disposed = false; async function ensureAuthenticated() { try { await fetchCurrentUser(); } catch (err) { if (disposed) return; if (err instanceof ApiError && err.status === 401) { // Only redirect when refresh token/session is no longer usable. router.replace("/signin"); return; } console.error("Ensure authenticated failed", err); } } ensureAuthenticated(); return () => { disposed = true; }; }, [router]); useEffect(() => { if (!projectId) return; if (openedProjectIdRef.current === projectId) return; openProject() .then(() => { openedProjectIdRef.current = projectId; }) .catch(() => { // allow retry if openProject threw outside its try/catch (should be rare) openedProjectIdRef.current = null; }); }, [openProject, projectId]); useEffect(() => { let disposed = false; async function loadEntities() { try { const rows = await fetchEntities(); if (disposed) return; setEntityCatalog((prev) => { const byId = new globalThis.Map(); for (const row of prev || []) { if (!row?.id) continue; byId.set(String(row.id), row); } for (const row of rows || []) { if (!row?.id) continue; // Prefer the freshest backend payload on conflicts. byId.set(String(row.id), row); } return Array.from(byId.values()); }); 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; }; }, [setEntityCatalog, setEntityStatus]); useEffect(() => { if (searchKind !== "entity") { setEntitySearchResults([]); setIsEntitySearchLoading(false); return; } const keyword = searchQuery.trim(); if (!keyword.length) { setEntitySearchResults([]); setIsEntitySearchLoading(false); return; } let disposed = false; const requestId = ++entitySearchRequestRef.current; const timeoutId = window.setTimeout(async () => { const keywordLower = keyword.toLowerCase(); const localMatches = entitiesRef.current .filter((entity) => entity.name.toLowerCase().includes(keywordLower) || (entity.description || "").toLowerCase().includes(keywordLower) ) .map((entity) => ({ ...entity, geometry_count: typeof entity.geometry_count === "number" ? entity.geometry_count : 0, })); setIsEntitySearchLoading(true); try { const rows = await searchEntitiesByName(keyword, { limit: 30 }); if (disposed || requestId !== entitySearchRequestRef.current) return; // Centralize: merge search results into the shared entity catalog so UI stays consistent. setEntityCatalog((prev) => { const byId = new globalThis.Map(); for (const row of prev || []) { if (!row?.id) continue; byId.set(String(row.id), row); } for (const row of rows || []) { if (!row?.id) continue; byId.set(String(row.id), row); } return Array.from(byId.values()); }); const mergedRows = mergeEntitySearchResults(rows, localMatches); setEntitySearchResults(mergedRows); } catch (err) { if (disposed || requestId !== entitySearchRequestRef.current) return; console.error("Search entity by name failed", err); setEntitySearchResults(localMatches); } finally { if (!disposed && requestId === entitySearchRequestRef.current) { setIsEntitySearchLoading(false); } } }, 220); return () => { disposed = true; window.clearTimeout(timeoutId); }; }, [ searchKind, searchQuery, setEntityCatalog, setEntitySearchResults, setIsEntitySearchLoading, ]); useEffect(() => { if (searchKind !== "wiki") { setWikiSearchResults([]); setIsWikiSearching(false); return; } const keyword = searchQuery.trim(); if (!keyword.length) { setWikiSearchResults([]); setIsWikiSearching(false); return; } let disposed = false; const requestId = ++wikiSearchRequestRef.current; const timeoutId = window.setTimeout(async () => { setIsWikiSearching(true); try { const rows = await searchWikisByTitle(keyword, { limit: 12 }); if (disposed || requestId !== wikiSearchRequestRef.current) return; setWikiSearchResults(rows); } catch (err) { if (disposed || requestId !== wikiSearchRequestRef.current) return; console.error("Search wikis failed", err); setWikiSearchResults([]); } finally { if (!disposed && requestId === wikiSearchRequestRef.current) { setIsWikiSearching(false); } } }, 250); return () => { disposed = true; window.clearTimeout(timeoutId); }; }, [searchKind, searchQuery, setIsWikiSearching, setWikiSearchResults]); useEffect(() => { if (searchKind !== "geo") { setGeoSearchResults([]); setIsGeoSearching(false); return; } const keyword = searchQuery.trim(); if (!keyword.length) { setGeoSearchResults([]); setIsGeoSearching(false); return; } let disposed = false; const requestId = ++geoSearchRequestRef.current; const timeoutId = window.setTimeout(async () => { setIsGeoSearching(true); try { const res = await searchGeometriesByEntityName(keyword, { limit: 24 }); if (disposed || requestId !== geoSearchRequestRef.current) return; setGeoSearchResults(res.items || []); } catch (err) { if (disposed || requestId !== geoSearchRequestRef.current) return; console.error("Search geometries by entity name failed", err); setGeoSearchResults([]); } finally { if (!disposed && requestId === geoSearchRequestRef.current) { setIsGeoSearching(false); } } }, 260); return () => { disposed = true; window.clearTimeout(timeoutId); }; }, [searchKind, searchQuery, setGeoSearchResults, setIsGeoSearching]); useEffect(() => { if (!selectedFeatureIds || selectedFeatureIds.length === 0) return; const renderedFeatureIds = new Set( activeMapDraft.features.map((feature) => String(feature.properties.id)) ); const stillExistIds = selectedFeatureIds.filter(id => renderedFeatureIds.has(String(id)) ); if (stillExistIds.length !== selectedFeatureIds.length) { setSelectedFeatureIds(stillExistIds); } }, [activeMapDraft.features, selectedFeatureIds, setSelectedFeatureIds]); useEffect(() => { if (!selectedFeature) { setSelectedGeometryEntityIds([]); setGeometryMetaForm({ type_key: "", time_start: "", time_end: "", }); setEntityFormStatus(null); lastSelectedFeatureIdRef.current = null; return; } const featureEntityIds = normalizeFeatureEntityIds(selectedFeature); const nextTypeKey = typeof selectedFeature.properties.type === "string" && selectedFeature.properties.type.trim().length ? selectedFeature.properties.type : getDefaultTypeIdForFeature(selectedFeature); const currentId = String(selectedFeature.properties.id); const timeStart = normalizeTimelineYearValue(selectedFeature.properties.time_start); const timeEnd = normalizeTimelineYearValue(selectedFeature.properties.time_end); setSelectedGeometryEntityIds(featureEntityIds); setGeometryMetaForm({ type_key: nextTypeKey, time_start: timeStart != null ? String(timeStart) : "", time_end: timeEnd != null ? String(timeEnd) : "", }); // Only clear status when switching to a different geometry, not when patching metadata/bindings // on the same selected geometry (otherwise messages will blink). if (lastSelectedFeatureIdRef.current !== currentId) { setEntityFormStatus(null); } lastSelectedFeatureIdRef.current = currentId; }, [ selectedFeature, setEntityFormStatus, setGeometryMetaForm, setSelectedGeometryEntityIds, ]); // Hiển thị status form entity trong thời gian ngắn, tự clear timer cũ. const flashEntityFormStatus = useCallback((msg: string | null, timeoutMs = 3000) => { if (entityFormStatusTimeoutRef.current) { window.clearTimeout(entityFormStatusTimeoutRef.current); entityFormStatusTimeoutRef.current = null; } setEntityFormStatus(msg); if (msg && timeoutMs > 0) { entityFormStatusTimeoutRef.current = window.setTimeout(() => { setEntityFormStatus(null); entityFormStatusTimeoutRef.current = null; }, timeoutMs); } }, [setEntityFormStatus]); // Hiển thị status binding geometry trong thời gian ngắn, tự clear timer cũ. const flashGeoBindingStatus = useCallback((msg: string | null, timeoutMs = 3000) => { if (geoBindingStatusTimeoutRef.current) { window.clearTimeout(geoBindingStatusTimeoutRef.current); geoBindingStatusTimeoutRef.current = null; } setGeoBindingStatus(msg); if (msg && timeoutMs > 0) { geoBindingStatusTimeoutRef.current = window.setTimeout(() => { setGeoBindingStatus(null); geoBindingStatusTimeoutRef.current = null; }, timeoutMs); } }, [setGeoBindingStatus]); useEffect(() => { setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); setIsBackgroundVisibilityReady(true); }, [setBackgroundVisibility, setIsBackgroundVisibilityReady]); // Thêm entity backend vào snapshot project dưới dạng reference. const handleAddEntityRefToProject = useCallback((entity: Entity) => { const id = String(entity.id || "").trim(); if (!id) return; editor.setSnapshotEntityRows((prev) => { if (prev.some((e) => String(e.id) === id)) return prev; return [ { id, source: "ref", operation: "reference", name: entity.name, description: entity.description ?? null, time_start: normalizeTimelineYearValue(entity.time_start), time_end: normalizeTimelineYearValue(entity.time_end), }, ...prev, ]; }, `Thêm entity ref #${id}`); // Keep entity catalog centralized as a single in-memory list. setEntityCatalog((prev) => { const byId = new globalThis.Map(); for (const row of prev || []) { if (!row?.id) continue; byId.set(String(row.id), row); } byId.set(id, entity); return Array.from(byId.values()); }); }, [editor, setEntityCatalog]); // Cập nhật metadata entity trong snapshot project, có undo qua editor state. const handleUpdateEntityInProject = useCallback((entityId: string, payload: { name: string; description: string | null; time_start: string; time_end: string }) => { const id = String(entityId || "").trim(); if (!id) return; const nextName = String(payload?.name || "").trim(); if (!nextName.length) { flashEntityFormStatus("Ten entity la bat buoc."); return; } const nextDescription = payload?.description == null ? null : String(payload.description); let nextTimeStart: number | undefined; let nextTimeEnd: number | undefined; try { nextTimeStart = parseOptionalEntityYearInput(payload.time_start, "time_start"); nextTimeEnd = parseOptionalEntityYearInput(payload.time_end, "time_end"); if (nextTimeStart != null && nextTimeEnd != null && nextTimeStart > nextTimeEnd) { flashEntityFormStatus("time_start phải <= time_end."); return; } } catch (err) { flashEntityFormStatus(err instanceof Error ? err.message : "Năm entity không hợp lệ."); return; } editor.setSnapshotEntityRows((prev) => prev.map((e) => { if (!e || String(e.id) !== id) return e; const source = e.source === "inline" ? "inline" : "ref"; const operation = source === "ref" ? "reference" : e.operation === "create" ? "create" : "update"; return { ...e, id, source, operation, name: nextName, description: nextDescription, time_start: nextTimeStart, time_end: nextTimeEnd, }; }), `Cap nhat entity #${id}`); flashEntityFormStatus("Da cap nhat entity. Commit khi san sang.", 3000); }, [editor, flashEntityFormStatus]); // Bind/unbind entity vào toàn bộ selected geometry hợp lệ. const handleToggleBindEntityForSelectedGeometry = useCallback((entityId: string, nextChecked: boolean) => { if (!selectedFeatures || selectedFeatures.length === 0) { flashEntityFormStatus("Chưa chọn geometry để bind entity."); return; } if (!isMultiEditValid) { flashEntityFormStatus("Không thể bind entity cho nhiều geometry khác loại."); return; } const id = String(entityId || "").trim(); if (!id) return; const nextEntityIds = (() => { const prev = selectedGeometryEntityIds; const has = prev.includes(id); if (nextChecked) { if (has) return prev; return uniqueEntityIds([...prev, id]); } if (!has) return prev; return prev.filter((x) => x !== id); })(); setIsEntitySubmitting(true); flashEntityFormStatus(null, 0); try { editor.patchFeaturePropertiesBatch( selectedFeatures.map((feature) => ({ id: feature.properties.id, patch: buildFeatureEntityPatch(feature, nextEntityIds, entities), })), nextChecked ? "Bind entity vào GEO" : "Unbind entity khỏi GEO" ); setSelectedGeometryEntityIds(nextEntityIds); flashEntityFormStatus( nextChecked ? "Đã bind entity vào geometry. Commit khi sẵn sàng." : "Đã unbind entity khỏi geometry. Commit khi sẵn sàng.", 3000 ); } finally { setIsEntitySubmitting(false); } }, [ editor, entities, flashEntityFormStatus, selectedFeatures, isMultiEditValid, selectedGeometryEntityIds, setIsEntitySubmitting, setSelectedGeometryEntityIds, ]); const handleDeleteEntity = useCallback((entityId: string) => { const id = String(entityId || "").trim(); if (!id) return; const confirmed = window.confirm(`Bạn có chắc chắn muốn xóa thực thể này khỏi dự án? Hành động này cũng sẽ gỡ bỏ tất cả liên kết hình học và wiki của thực thể.`); if (!confirmed) return; editor.deleteEntityAndRelations(id, `Xóa thực thể #${id}`); setSelectedGeometryEntityIds((prev) => prev.filter((x) => x !== id)); flashEntityFormStatus(`Đã xóa thực thể #${id}.`, 3000); }, [editor, flashEntityFormStatus, setSelectedGeometryEntityIds]); // Bind/unbind geometry con vào selected geometry qua field child.bound_with. const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => { if (!selectedFeatures || selectedFeatures.length === 0) { flashGeoBindingStatus("Chưa chọn geometry để bind."); return; } if (selectedFeatures.length !== 1 || !selectedFeature) { flashGeoBindingStatus("Chỉ bind geometry-geometry khi chọn đúng một geometry cha."); return; } if (!isMultiEditValid) { flashGeoBindingStatus("Không thể bind geometry cho nhiều geometry khác loại."); return; } const id = String(geoId || "").trim(); if (!id) return; const parentId = String(selectedFeature.properties.id); if (parentId === id) return; const childFeature = editor.draft.features.find((f) => String(f.properties.id) === id); if (!childFeature) { flashGeoBindingStatus("Không tìm thấy geometry con."); return; } if (nextChecked && wouldCreateGeometryBoundWithCycle(editor.draft.features, id, parentId)) { flashGeoBindingStatus("Không thể bind vì sẽ tạo vòng lặp bound_with."); return; } setIsEntitySubmitting(true); flashGeoBindingStatus(null, 0); try { const currentParentId = normalizeFeatureBoundWith(childFeature); const nextBoundWith = nextChecked ? parentId : currentParentId === parentId ? null : currentParentId; editor.patchFeaturePropertiesBatch( [{ id: childFeature.properties.id, patch: { bound_with: nextBoundWith }, }], nextChecked ? "Bind geometry vào GEO" : "Unbind geometry khỏi GEO" ); flashGeoBindingStatus( nextChecked ? "Đã set bound_with cho geometry con. Commit khi sẵn sàng." : "Đã gỡ bound_with khỏi geometry con. Commit khi sẵn sàng.", 3000 ); } finally { setIsEntitySubmitting(false); } }, [ editor, flashGeoBindingStatus, selectedFeature, selectedFeatures, isMultiEditValid, setIsEntitySubmitting, ]); // Bind nhiều geometries con vào target geometry. const handleBindGeometries = useCallback((targetId: string | number, sourceIds: (string | number)[]) => { const idStr = String(targetId).trim(); if (!idStr) return; const targetFeature = editor.draft.features.find((f) => String(f.properties.id) === idStr); if (!targetFeature) { flashGeoBindingStatus("Không tìm thấy geometry đích."); return; } const sourceFeatures = sourceIds .map((sourceId) => editor.draft.features.find((f) => String(f.properties.id) === String(sourceId))) .filter((feature): feature is Feature => Boolean(feature)) .filter((feature) => String(feature.properties.id) !== idStr) .filter((feature) => !wouldCreateGeometryBoundWithCycle(editor.draft.features, feature.properties.id, idStr)); if (!sourceFeatures.length) { flashGeoBindingStatus("Không có geometry con hợp lệ để bind."); return; } editor.patchFeaturePropertiesBatch( sourceFeatures.map((feature) => ({ id: feature.properties.id, patch: { bound_with: idStr }, })), "Bind các geometry đã chọn vào GEO" ); setSelectedFeatureIds([targetFeature.properties.id]); flashGeoBindingStatus(`Đã set bound_with cho ${sourceFeatures.length} geometry con. Commit khi sẵn sàng.`, 3000); }, [editor, flashGeoBindingStatus, setSelectedFeatureIds]); // Focus/zoom tới geometry từ binding panel; nếu geo có time_start thì kéo year filter về năm đó. const handleFocusGeometryFromBindingPanel = useCallback((geoId: string) => { const id = String(geoId || "").trim(); if (!id) return; const feature = editor.draft.features.find((item) => String(item.properties.id) === id) || null; if (!feature) { flashGeoBindingStatus("Không tìm thấy geometry để zoom."); return; } const geoTimeStart = normalizeTimelineYearValue(feature.properties.time_start); if (geoTimeStart !== null) { setTimelineDraftYear(clampYearToFixedRange(Math.trunc(geoTimeStart))); } setSelectedFeatureIds([feature.properties.id]); setGeometryFocusRequest((prev) => ({ key: (prev?.key ?? 0) + 1, collection: { type: "FeatureCollection", features: [feature], }, })); }, [ editor.draft.features, flashGeoBindingStatus, setGeometryFocusRequest, setSelectedFeatureIds, setTimelineDraftYear, ]); const handleHideGeometryLocal = useCallback((geoId: string | number) => { const id = String(geoId || "").trim(); if (!id) return; setGeometryVisibility((prev) => ({ ...prev, [id]: false, })); setSelectedFeatureIds((prev) => prev.filter((item) => String(item) !== id)); }, [setGeometryVisibility, setSelectedFeatureIds]); // Thêm wiki backend vào snapshot project dưới dạng reference. const handleAddWikiRefToProject = useCallback((wiki: Wiki) => { const id = String(wiki.id || "").trim(); if (!id) return; const title = (wiki.title || "").trim() || "Untitled wiki"; editor.setSnapshotWikis((prev) => { if (prev.some((w) => w.id === id)) return prev; return [ { id, source: "ref", operation: "reference", title, doc: null, }, ...prev, ]; }, `Thêm wiki ref #${id}`); setRequestedActiveWikiId(id); }, [editor, setRequestedActiveWikiId]); // Tạo image overlay từ file local, mặc định phủ theo viewport map hiện tại. const handlePickImageOverlay = useCallback((file: File | null) => { if (!file) return; if (!file.type.startsWith("image/")) { setEntityStatus("File overlay phải là ảnh."); return; } const map = getCurrentMapInstance(); if (!map) { setEntityStatus("Map chưa sẵn sàng để thêm ảnh overlay."); return; } const nextUrl = URL.createObjectURL(file); void readImageAspectRatio(nextUrl) .then((aspectRatio) => { const previousUrl = imageOverlayObjectUrlRef.current; imageOverlayObjectUrlRef.current = nextUrl; setImageOverlay((prev) => ({ url: nextUrl, name: file.name || "Trace image", opacity: prev?.opacity ?? 0.55, aspectRatio, coordinates: getViewportImageCoordinates(map, aspectRatio), })); if (previousUrl) { URL.revokeObjectURL(previousUrl); } }) .catch((err) => { console.error("Read image size failed", err); URL.revokeObjectURL(nextUrl); setEntityStatus("Không đọc được kích thước ảnh overlay."); }); }, [getCurrentMapInstance, setEntityStatus]); // Đọc ảnh trực tiếp từ clipboard và dùng làm overlay trace. const handlePasteImageOverlay = useCallback(async () => { if (typeof navigator === "undefined" || !navigator.clipboard?.read) { setEntityStatus("Trình duyệt không hỗ trợ paste ảnh từ clipboard."); return; } try { const items = await navigator.clipboard.read(); for (const item of items) { const imageType = item.types.find((type) => type.startsWith("image/")); if (!imageType) continue; const blob = await item.getType(imageType); const extension = imageType.split("/")[1] || "png"; const file = new File([blob], `clipboard-image.${extension}`, { type: imageType }); handlePickImageOverlay(file); return; } setEntityStatus("Clipboard không có ảnh để paste."); } catch (err) { console.error("Paste image overlay failed", err); setEntityStatus("Không paste được ảnh. Hãy cấp quyền clipboard hoặc dùng nút Thêm ảnh."); } }, [handlePickImageOverlay, setEntityStatus]); // Chỉnh opacity của image overlay mà không đổi vị trí/ảnh. const handleImageOverlayOpacityChange = useCallback((opacity: number) => { const nextOpacity = Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 0.55; setImageOverlay((prev) => prev ? { ...prev, opacity: nextOpacity } : prev); }, []); // Xóa image overlay khỏi map và revoke object URL local. const handleRemoveImageOverlay = useCallback(() => { if (imageOverlayObjectUrlRef.current) { URL.revokeObjectURL(imageOverlayObjectUrlRef.current); imageOverlayObjectUrlRef.current = null; } setImageOverlay(null); setImageOverlayKeyboardEnabled(false); }, []); // Import geometry từ kết quả search GEO vào draft hiện tại và bind entity liên quan. const handleImportGeoFromSearch = useCallback(( entityItem: EntityGeometriesSearchItem, geo: EntityGeometrySearchGeo ) => { const geoId = String(geo?.id || "").trim(); if (!geoId) return; const importedEntity: Entity = { id: entityItem.entity_id, name: (entityItem.name || "").trim() || entityItem.entity_id, description: (entityItem.description || "").trim() || null, geometry_count: 0, }; const existing = editor.draft.features.find((f) => String(f.properties.id) === geoId) || null; if (existing) { // Keep entity store consistent: importing/selecting a geo implies the entity should exist in snapshot + catalog. handleAddEntityRefToProject(importedEntity); setSelectedFeatureIds([existing.properties.id]); flashEntityFormStatus("Đã chọn geometry từ kết quả search.", 3000); return; } const geometry = normalizeGeoSearchGeometry(geo.draw_geometry); if (!geometry) { flashEntityFormStatus("Không import được: draw_geometry không hợp lệ.", 3000); return; } const boundWith = normalizeGeoSearchBoundWith(geo.bound_with); const typeKey = geo.type || null; const feature: Feature = { type: "Feature", properties: { id: geoId, source: "ref", type: typeKey, time_start: normalizeTimelineYearValue(geo.time_start), time_end: normalizeTimelineYearValue(geo.time_end), bound_with: boundWith, entity_id: entityItem.entity_id, entity_ids: [entityItem.entity_id], entity_name: (entityItem.name || "").trim() || entityItem.entity_id, entity_names: [(entityItem.name || "").trim() || entityItem.entity_id], }, geometry, }; editor.createFeatureWithSnapshotEntityRows( feature, (prev) => { if (prev.some((e) => String(e.id) === importedEntity.id)) return prev; return [ { id: importedEntity.id, source: "ref", operation: "reference", name: importedEntity.name, description: importedEntity.description ?? null, }, ...prev, ]; }, `Import GEO #${geoId}` ); setEntityCatalog((prev) => { const byId = new globalThis.Map(); for (const row of prev || []) { if (!row?.id) continue; byId.set(String(row.id), row); } byId.set(importedEntity.id, importedEntity); return Array.from(byId.values()); }); setSelectedFeatureIds([feature.properties.id]); flashEntityFormStatus("Đã import geometry từ search GEO. Commit khi sẵn sàng.", 3000); }, [ editor, flashEntityFormStatus, handleAddEntityRefToProject, setEntityCatalog, setSelectedFeatureIds, ]); // Add geometry đang xem từ global mode vào draft local, kèm entity refs đã map được. const handleAddGlobalGeometryToProject = useCallback((feature: Feature) => { const geoId = String(feature?.properties?.id || "").trim(); if (!geoId) return; const existing = editor.mainDraft.features.find((item) => String(item.properties.id) === geoId) || null; if (existing) { setSelectedFeatureIds([existing.properties.id]); flashEntityFormStatus("Geometry này đã nằm trong project.", 3000); return; } if (isGlobalLoading) { flashEntityFormStatus("Đang tải global geometry và entity mapping, thử lại sau.", 3000); return; } const entityRefs = buildEntityRefsForFeature(feature, entities); const entityIds = entityRefs.map((entity) => String(entity.id)); const featureClone = deepClone(feature); const nextFeature: Feature = { ...featureClone, properties: { ...featureClone.properties, id: geoId, source: "ref", ...buildFeatureEntityPatch(featureClone, entityIds, entityRefs), }, }; const entitySnapshots = entityRefs.map(toEntityRefSnapshot); editor.createFeatureWithSnapshotEntityRows( nextFeature, (prev) => mergeSnapshotEntityRefs(prev, entitySnapshots), `Add global GEO #${geoId}` ); if (entityRefs.length) { setEntityCatalog((prev) => mergeEntityCatalogById(prev, entityRefs)); } setSelectedFeatureIds([nextFeature.properties.id]); flashEntityFormStatus( entityRefs.length ? `Đã add geometry global vào project kèm ${entityRefs.length} entity. Commit khi sẵn sàng.` : "Đã add geometry global vào project. Geometry này chưa có entity mapping.", 3000 ); }, [ editor, entities, flashEntityFormStatus, isGlobalLoading, setEntityCatalog, setSelectedFeatureIds, ]); // Commands thao tác metadata/entity binding cho feature đang chọn. const featureCommands = useFeatureCommands({ editor, selectedFeatures, geometryMetaForm, setGeometryMetaForm, selectedGeometryEntityIds, setSelectedGeometryEntityIds, entities, setIsEntitySubmitting, setEntityFormStatus, }); const handleRerollGeometryId = useCallback((oldId: string | number) => { const feature = editor.draft.features.find((item) => String(item.properties.id) === String(oldId)); if (!feature || feature.properties.source === "ref") { flashEntityFormStatus("Không thể đổi ID geometry ref vì đây là identity từ backend."); return; } const nextId = newId(); editor.changeFeatureId(oldId, nextId); setSelectedFeatureIds((prev) => prev.map((id) => String(id) === String(oldId) ? nextId : id)); }, [editor, flashEntityFormStatus, setSelectedFeatureIds]); const handleRerollEntityId = useCallback((oldId: string, nextId: string) => { const activeEntity = entities.find(e => e.id === oldId); if (!activeEntity) return; // 1. Update snapshotEntityRows editor.setSnapshotEntityRows((prev) => prev.map((e) => { if (e && String(e.id) === oldId) { return { ...e, id: nextId }; } return e; }), `Reroll Entity ID #${oldId} -> #${nextId}`); // 2. Update entityCatalog setEntityCatalog((prev) => prev.map((e) => { if (e && String(e.id) === oldId) { return { ...e, id: nextId }; } return e; })); // 3. Update selectedGeometryEntityIds setSelectedGeometryEntityIds((prev) => prev.map((id) => id === oldId ? nextId : id)); // 4. Update features bound to this entity ID const featuresToPatch = editor.draft.features.filter((feature) => { const entityIds = feature.properties.entity_ids || []; return feature.properties.entity_id === oldId || entityIds.includes(oldId); }); if (featuresToPatch.length > 0) { editor.patchFeaturePropertiesBatch( featuresToPatch.map((feature) => { const prevEntityIds = feature.properties.entity_ids || []; const nextEntityIds = prevEntityIds.map((id) => id === oldId ? nextId : id); return { id: feature.properties.id, patch: buildFeatureEntityPatch(feature, nextEntityIds, [ ...entities.filter(e => e.id !== oldId), { id: nextId, name: activeEntity.name, time_start: activeEntity.time_start ?? null, time_end: activeEntity.time_end ?? null } ]) }; }), "Cập nhật entity ID mới cho các GEO" ); } }, [editor, entities, setEntityCatalog, setSelectedGeometryEntityIds]); // Tạo entity inline chỉ trong snapshot local, chưa gọi backend cho tới khi commit. const handleCreateEntityOnly = async () => { const name = entityForm.name.trim(); if (!name) { setEntityFormStatus("Tên entity là bắt buộc."); return; } const description = entityForm.description.trim() || null; let timeStart: number | undefined; let timeEnd: number | undefined; try { timeStart = parseOptionalEntityYearInput(entityForm.time_start, "time_start"); timeEnd = parseOptionalEntityYearInput(entityForm.time_end, "time_end"); if (timeStart != null && timeEnd != null && timeStart > timeEnd) { setEntityFormStatus("time_start phải <= time_end."); return; } } catch (err) { setEntityFormStatus(err instanceof Error ? err.message : "Năm entity không hợp lệ."); return; } 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; } const entityId = buildClientEntityId(); const createdEntity: Entity = { id: entityId, name, description, time_start: timeStart ?? null, time_end: timeEnd ?? null, geometry_count: 0, }; setIsEntitySubmitting(true); setEntityFormStatus(null); try { editor.setSnapshotEntityRows((prev) => { if (prev.some((e) => String(e.id) === entityId)) return prev; return [ { id: entityId, source: "inline", operation: "create", name, description, time_start: timeStart, time_end: timeEnd, }, ...prev, ]; }, `Tạo entity #${entityId}`); localCreatedEntityIdsRef.current.add(entityId); setEntityCatalog((prev) => { const byId = new globalThis.Map(); for (const row of prev || []) { if (!row?.id) continue; byId.set(String(row.id), row); } byId.set(entityId, createdEntity); return Array.from(byId.values()); }); setEntityForm((prev) => ({ ...prev, name: "", description: "", time_start: "", time_end: "", })); setEntityStatus(null); setEntityFormStatus("Đã tạo entity mới (local). Commit khi sẵn sàng."); } finally { setIsEntitySubmitting(false); } }; // Commit head hiện tại để hiển thị label lịch sử. const headCommit = projectState?.head_commit_id ? sectionCommits.find((commit) => commit.id === projectState.head_commit_id) || null : null; const handleDeleteFeature = useCallback((id: string | number | (string | number)[]) => { if (Array.isArray(id)) { editor.deleteFeatures(id); } else { editor.deleteFeature(id); } }, [editor]); // Tạo geometry từ map engine rồi select ngay geometry mới. const handleCreateFeature = useCallback((feature: Feature) => { editor.createFeature(feature); setSelectedFeatureIds([feature.properties.id]); }, [editor]); // Base draft for label lookup only. It must not decide which geometry is rendered. const labelContextBaseDraft = useMemo(() => { const baseDraft = isAnyPreviewMode ? previewSession?.draft || EMPTY_FEATURE_COLLECTION : editor.draft; if (viewMode === "local") { return baseDraft; } const localFeatureIds = new Set(); for (const f of baseDraft.features) { if (f.properties?.id != null) { localFeatureIds.add(String(f.properties.id)); } } for (const f of baselineFeatureCollection.features) { if (f.properties?.id != null) { localFeatureIds.add(String(f.properties.id)); } } const mergedFeatures = [...baseDraft.features]; for (const globalFeature of globalGeometries.features) { const globalId = globalFeature.properties?.id != null ? String(globalFeature.properties.id) : null; if (globalId === null || !localFeatureIds.has(globalId)) { mergedFeatures.push(globalFeature); } } return { ...baseDraft, features: mergedFeatures, }; }, [viewMode, isAnyPreviewMode, previewSession?.draft, editor.draft, baselineFeatureCollection.features, globalGeometries.features]); // Enriched label context may contain geometries that mapRenderDraft filtered out. // Map rendering must still use mapRenderDraft above. const mapLabelContextDraft = useMemo(() => { const entitiesForLabel = isAnyPreviewMode ? previewSession?.entities || [] : entities; return buildEntityLabelContextDraft(labelContextBaseDraft, entitiesForLabel); }, [entities, isAnyPreviewMode, labelContextBaseDraft, previewSession?.entities]); if (blockedPendingSubmissionId) { return (

Editor đang bị khóa

Project này đang có submission ở trạng thái PENDING (id: {blockedPendingSubmissionId}). Theo quy trình làm việc, khi submission đang pending thì không được tạo submission/commit mới và không được vào editor.
); } if (isOpeningSection || !activeSection) { return (
{!activeSection && !isOpeningSection ? (

Lỗi tải Project

{entityStatus || "Không thể tải thông tin dự án. Vui lòng thử lại hoặc quay lại danh sách."}
) : ( <>
Đang tải dữ liệu bản đồ...
)}
); } return (
{!isReplayEditMode && !isAnyPreviewMode ? ( <> { setLeftPanelWidth((prev) => clampNumber(prev + deltaX, 220, 520)); }} /> ) : isReplayEditMode ? ( <> setMode("select")} isPreviewPlaying={false} previewPlaybackSpeed={1} onPlayPreviewFromStart={() => openReplayPreview("start")} onPlayPreviewFromSelection={() => openReplayPreview("selection")} onStopPreview={() => { }} onResetPreview={() => { }} /> { setLeftPanelWidth((prev) => clampNumber(prev + deltaX, 220, 520)); }} /> ) : null}
{isBackgroundVisibilityReady ? ( ) : (
)} {isAnyPreviewMode && previewSession ? ( ) : ( )}
{!isReplayEditMode && !isAnyPreviewMode ? ( <> { setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720)); }} /> { setSearchKind(next); setSearchQuery(""); setSearchQueryDraft(""); }} searchQuery={searchQuery} onSearchQueryChange={setSearchQuery} onLocalSearchQueryChange={setSearchQueryDraft} searchQueryDraft={searchQueryDraft} entitySearchResults={entitySearchResults} isEntitySearchLoading={isEntitySearchLoading} onAddEntityRefToProject={handleAddEntityRefToProject} wikiSearchResults={wikiSearchResults} isWikiSearching={isWikiSearching} onAddWikiRefToProject={handleAddWikiRefToProject} geoSearchResults={geoSearchResults} isGeoSearching={isGeoSearching} onImportGeoFromSearch={handleImportGeoFromSearch} /> {selectedFeatures.length > 0 ? ( { editor.deleteFeatures(ids); setSelectedFeatureIds([]); }} onDeselectAll={() => setSelectedFeatureIds([])} changeCount={editor.changeCount} onReplayEdit={(id) => setMode("replay", id)} onRerollGeometryId={handleRerollGeometryId} /> ) : null}
} /> ) : isReplayEditMode ? ( <> { setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720)); }} /> String(id))} currentTimelineYear={timelineDraftYear} geometryChoices={geometryChoices} wikiChoices={wikiChoices} getCurrentMapViewState={getCurrentMapViewState} onMutateReplay={editor.mutateActiveReplay} /> ) : null}
); } function hasPlayableReplaySteps(replay: BattleReplay | null | undefined) { return Boolean( replay?.detail?.some((stage) => Array.isArray(stage?.steps) && stage.steps.length > 0) ); } function buildReplayPreviewDraftFromSource(sourceDraft: FeatureCollection, replay: BattleReplay): FeatureCollection { const targetIds = normalizeReplayPreviewTargetGeometryIds(replay); return { type: "FeatureCollection", features: targetIds .map((id) => sourceDraft.features.find((feature) => String(feature.properties.id) === id) || null ) .filter((feature): feature is Feature => Boolean(feature)) .map((feature) => ({ ...deepClone(feature), properties: { ...deepClone(feature.properties), bound_with: null, }, })), }; } function normalizeReplayPreviewTargetGeometryIds(replay: BattleReplay) { const orderedIds: string[] = []; const seen = new Set(); const pushId = (rawId: string | number | null | undefined) => { if (rawId == null) return; const id = String(rawId).trim(); if (!id.length || seen.has(id)) return; seen.add(id); orderedIds.push(id); }; pushId(replay.geometry_id); for (const rawId of replay.target_geometry_ids || []) pushId(rawId); return orderedIds; } function readImageAspectRatio(url: string): Promise { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => { const width = image.naturalWidth || image.width; const height = image.naturalHeight || image.height; if (!width || !height) { reject(new Error("Image has invalid dimensions.")); return; } resolve(width / height); }; image.onerror = () => reject(new Error("Image load failed.")); image.src = url; }); } function buildPreviewRelationIndex(options: { draft: FeatureCollection; entities: Entity[]; wikis: WikiSnapshot[]; entityWikiLinks: EntityWikiLinkSnapshot[]; wikiCache: Record; projectId: string; }): PreviewRelationIndex { const next: PreviewRelationIndex = { entitiesById: {}, entityGeometriesById: {}, entityWikisById: {}, geometryEntityIds: {}, wikiEntityIdsById: {}, wikiEntityIdsBySlug: {}, wikiById: {}, wikiBySlug: {}, }; for (const entity of options.entities || []) { const id = String(entity?.id || "").trim(); if (!id) continue; next.entitiesById[id] = entity; } for (const wikiSnapshot of options.wikis || []) { if (!wikiSnapshot || wikiSnapshot.operation === "delete") continue; const wiki = snapshotWikiToWiki(wikiSnapshot, options.wikiCache, options.projectId); if (!wiki?.id) continue; next.wikiById[wiki.id] = wiki; const slug = String(wiki.slug || "").trim(); if (slug) next.wikiBySlug[slug] = wiki; } for (const feature of options.draft.features || []) { const geometryId = String(feature.properties.id); for (const entityId of normalizeFeatureEntityIds(feature)) { if (!next.entitiesById[entityId]) { next.entitiesById[entityId] = { id: entityId, name: entityId }; } pushUniqueString(next.geometryEntityIds, geometryId, entityId); if (!next.entityGeometriesById[entityId]) { next.entityGeometriesById[entityId] = { type: "FeatureCollection", features: [] }; } if (!next.entityGeometriesById[entityId].features.some((item) => String(item.properties.id) === geometryId)) { next.entityGeometriesById[entityId].features.push(feature); } } } for (const link of options.entityWikiLinks || []) { if (!link || link.operation === "delete") continue; const entityId = String(link.entity_id || "").trim(); const wikiId = String(link.wiki_id || "").trim(); const entity = next.entitiesById[entityId] || null; const wiki = next.wikiById[wikiId] || null; if (!entity || !wiki) continue; if (!next.entityWikisById[entityId]) next.entityWikisById[entityId] = []; if (!next.entityWikisById[entityId].some((item) => item.id === wiki.id)) { next.entityWikisById[entityId].push(wiki); } pushUniqueString(next.wikiEntityIdsById, wiki.id, entityId); const slug = String(wiki.slug || "").trim(); if (slug) pushUniqueString(next.wikiEntityIdsBySlug, slug, entityId); } normalizeRelationArrays(next.geometryEntityIds); normalizeRelationArrays(next.wikiEntityIdsById); normalizeRelationArrays(next.wikiEntityIdsBySlug); return next; } function snapshotWikiToWiki(snapshot: WikiSnapshot, wikiCache: Record, projectId: string): Wiki { if (typeof snapshot.doc === "string") { return { id: snapshot.id, project_id: projectId, title: snapshot.title, slug: snapshot.slug ?? null, content: snapshot.doc || "", }; } return wikiCache[snapshot.id] || { id: snapshot.id, project_id: projectId, title: snapshot.title, slug: snapshot.slug ?? null, content: "", }; } function pushUniqueString(target: Record, key: string, value: string) { if (!target[key]) { target[key] = [value]; return; } if (!target[key].includes(value)) { target[key].push(value); } } function normalizeRelationArrays(target: Record) { for (const key of Object.keys(target)) { target[key] = Array.from(new Set(target[key])); } } function isTypingTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; const tagName = target.tagName.toLowerCase(); return tagName === "input" || tagName === "textarea" || tagName === "select" || target.isContentEditable; } function buildEntityLabelContextDraft(draft: FeatureCollection, entities: Entity[]): FeatureCollection { if (!draft.features.length) return draft; const entityById = new globalThis.Map(); for (const entity of entities || []) { const id = String(entity?.id || "").trim(); if (!id) continue; entityById.set(id, entity); } return { ...draft, features: draft.features.map((feature) => { const entityIds = normalizeFeatureEntityIds(feature); if (!entityIds.length) return feature; const candidates = entityIds.map((id) => { const entity = entityById.get(id) || null; const name = String(entity?.name || id).trim(); if (!name) return null; return { id, name, time_start: normalizeTimelineYearValue(entity?.time_start), time_end: normalizeTimelineYearValue(entity?.time_end), }; }).filter((candidate) => candidate !== null); return { ...feature, properties: { ...feature.properties, entity_id: entityIds[0] || null, entity_ids: entityIds, entity_name: candidates[0]?.name || null, entity_names: candidates.map((candidate) => candidate.name), entity_label_candidates: candidates, }, }; }), }; } function buildEntityRefsForFeature(feature: Feature, entities: Entity[]): Entity[] { const entityIds = normalizeFeatureEntityIds(feature); if (!entityIds.length) return []; const entityById = new globalThis.Map(); for (const entity of entities || []) { const id = String(entity?.id || "").trim(); if (!id) continue; entityById.set(id, entity); } const entityNames = Array.isArray(feature.properties.entity_names) ? feature.properties.entity_names : []; const primaryName = typeof feature.properties.entity_name === "string" ? feature.properties.entity_name.trim() : ""; return entityIds.map((id, index) => { const catalogEntity = entityById.get(id); if (catalogEntity) return catalogEntity; const name = String(entityNames[index] || (index === 0 ? primaryName : "") || id).trim() || id; return { id, name, description: null, time_start: null, time_end: null, geometry_count: 0, }; }); } function toEntityRefSnapshot(entity: Entity): EntitySnapshot { return { id: String(entity.id), source: "ref", operation: "reference", name: entity.name, description: entity.description ?? null, time_start: normalizeTimelineYearValue(entity.time_start), time_end: normalizeTimelineYearValue(entity.time_end), }; } function mergeSnapshotEntityRefs(prev: EntitySnapshot[], refs: EntitySnapshot[]): EntitySnapshot[] { if (!refs.length) return prev; const refsById = new globalThis.Map(); for (const ref of refs) { const id = String(ref?.id || "").trim(); if (!id) continue; refsById.set(id, ref); } if (!refsById.size) return prev; let changed = false; const seen = new Set(); const next = (prev || []).map((row) => { const id = String(row?.id || "").trim(); if (!id || !refsById.has(id)) return row; seen.add(id); if (row.operation !== "delete") return row; changed = true; return refsById.get(id) || row; }); const missing = Array.from(refsById.values()).filter((ref) => !seen.has(String(ref.id))); if (missing.length) changed = true; return changed ? [...missing, ...next] : prev; } function mergeEntityCatalogById(prev: Entity[], refs: Entity[]): Entity[] { if (!refs.length) return prev; const byId = new globalThis.Map(); for (const entity of prev || []) { const id = String(entity?.id || "").trim(); if (!id) continue; byId.set(id, entity); } for (const entity of refs) { const id = String(entity?.id || "").trim(); if (!id) continue; byId.set(id, entity); } return Array.from(byId.values()); } function parseOptionalEntityYearInput(value: string, fieldName: string): number | undefined { const trimmed = String(value || "").trim(); if (!trimmed.length) return undefined; const parsed = Number(trimmed); if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { throw new Error(`${fieldName} phải là số nguyên.`); } return parsed; }