From 8f0e912d9e31ebf5d9e2a7251fe7d9d2804e9efe Mon Sep 17 00:00:00 2001 From: taDuc Date: Thu, 21 May 2026 18:39:50 +0700 Subject: [PATCH 1/3] feat: implement geometry binding functionality within the map interaction engine --- src/app/editor/[id]/page.tsx | 31 ++++ src/uhm/components/Map.tsx | 8 +- src/uhm/components/map/useMapInteraction.ts | 13 +- src/uhm/lib/map/engines/selectingEngine.ts | 149 +++++++++++++------- 4 files changed, 147 insertions(+), 54 deletions(-) diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 8a7ed8e..6fbfba1 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -1540,6 +1540,36 @@ function EditorPageContent() { setIsEntitySubmitting, ]); + // Bind nhiều geometries 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 prevBindingIds = normalizeFeatureBindingIds(targetFeature); + + // Merge prevBindingIds with sourceIds (which are strings of selected features) + // filter out targetId itself (we can't bind a geometry to itself) + const newSources = sourceIds.map(String).filter((x) => x !== idStr); + const merged = Array.from(new Set([...prevBindingIds, ...newSources])); + + editor.patchFeaturePropertiesBatch( + [{ + id: targetFeature.properties.id, + patch: { binding: merged }, + }], + "Bind các geometry đã chọn vào GEO" + ); + + setSelectedFeatureIds([targetFeature.properties.id]); + flashGeoBindingStatus(`Đã bind ${newSources.length} geometry vào GEO này. 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(); @@ -2019,6 +2049,7 @@ function EditorPageContent() { focusPadding={96} imageOverlay={imageOverlay} onImageOverlayChange={setImageOverlay} + onBindGeometries={handleBindGeometries} /> ) : (
diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index 2e5961c..7d25088 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -57,6 +57,7 @@ type MapProps = { focusPadding?: number | import("maplibre-gl").PaddingOptions; imageOverlay?: MapImageOverlay | null; onImageOverlayChange?: (overlay: MapImageOverlay) => void; + onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void; }; const Map = forwardRef(function Map({ @@ -85,6 +86,7 @@ const Map = forwardRef(function Map({ focusPadding, imageOverlay = null, onImageOverlayChange, + onBindGeometries, }, ref) { // Ref giữ mode mới nhất cho MapLibre handlers được register một lần. const modeRef = useRef(mode); @@ -108,7 +110,9 @@ const Map = forwardRef(function Map({ const imageOverlayRef = useRef(imageOverlay); // Ref callback update overlay mới nhất để interaction không stale. const onImageOverlayChangeRef = useRef(onImageOverlayChange); - + // Ref callback bind geometry mới nhất để interaction không stale. + const onBindGeometriesRef = useRef(onBindGeometries); + useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { draftRef.current = draft; }, [draft]); useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]); @@ -120,6 +124,7 @@ const Map = forwardRef(function Map({ useEffect(() => { onUpdateRef.current = onUpdateFeature; }, [onUpdateFeature]); useEffect(() => { imageOverlayRef.current = imageOverlay; }, [imageOverlay]); useEffect(() => { onImageOverlayChangeRef.current = onImageOverlayChange; }, [onImageOverlayChange]); + useEffect(() => { onBindGeometriesRef.current = onBindGeometries; }, [onBindGeometries]); // Hook sở hữu lifecycle MapLibre instance và các control camera/projection. const { @@ -164,6 +169,7 @@ const Map = forwardRef(function Map({ onHideRef, onUpdateRef, onHoverFeatureChangeRef, + onBindGeometriesRef, }); // Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer. diff --git a/src/uhm/components/map/useMapInteraction.ts b/src/uhm/components/map/useMapInteraction.ts index f51aa9f..e581ab3 100644 --- a/src/uhm/components/map/useMapInteraction.ts +++ b/src/uhm/components/map/useMapInteraction.ts @@ -16,6 +16,7 @@ type EngineBinding = { cleanup: () => void; cancel?: () => void; clearSelection?: (skipNotify?: boolean) => void; + syncSelection?: (ids: (string | number)[]) => void; }; type UseMapInteractionProps = { @@ -32,6 +33,7 @@ type UseMapInteractionProps = { onHideRef: React.MutableRefObject<((id: string | number) => void) | undefined>; onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>; onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>; + onBindGeometriesRef?: React.MutableRefObject<((targetId: string | number, sourceIds: (string | number)[]) => void) | undefined>; }; export function useMapInteraction({ @@ -48,6 +50,7 @@ export function useMapInteraction({ onHideRef, onUpdateRef, onHoverFeatureChangeRef, + onBindGeometriesRef, }: UseMapInteractionProps) { const editingEngineRef = useRef | null>(null); const engineBindingsRef = useRef>>({}); @@ -72,6 +75,13 @@ export function useMapInteraction({ } }, [mode, selectedFeatureIds]); + useEffect(() => { + const selectEngine = engineBindingsRef.current.select; + if (selectEngine?.syncSelection) { + selectEngine.syncSelection(selectedFeatureIds); + } + }, [selectedFeatureIds]); + useEffect(() => { const previousMode = previousModeRef.current; if (previousMode !== mode) { @@ -170,7 +180,8 @@ export function useMapInteraction({ : undefined, (ids) => onSelectFeatureIdsRef.current?.(ids), (id: string | number) => onSetModeRef.current?.("replay", id), - () => Boolean(editingEngineRef.current?.editingRef.current) + () => Boolean(editingEngineRef.current?.editingRef.current), + (targetId, sourceIds) => onBindGeometriesRef?.current?.(targetId, sourceIds) ); const cleanupPoint = initPoint( diff --git a/src/uhm/lib/map/engines/selectingEngine.ts b/src/uhm/lib/map/engines/selectingEngine.ts index fb643c9..c37d700 100644 --- a/src/uhm/lib/map/engines/selectingEngine.ts +++ b/src/uhm/lib/map/engines/selectingEngine.ts @@ -11,7 +11,8 @@ export function initSelect( onHide?: (id: string | number) => void, onSelectIds?: (ids: (string | number)[]) => void, onReplayEdit?: (id: string | number) => void, - isEditSessionActive?: () => boolean + isEditSessionActive?: () => boolean, + onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void ) { const FEATURE_STATE_SOURCES = [ @@ -20,7 +21,7 @@ export function initSelect( "path-arrow-shapes", ] as const; const selectedIds = new Set(); - const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit); + const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit || onBindGeometries); let contextMenu: HTMLDivElement | null = null; let docClickHandler: ((ev: MouseEvent) => void) | null = null; @@ -97,8 +98,13 @@ export function initSelect( const id = feature.id ?? feature.properties?.id; if (id === undefined || id === null) return; - // if right-clicked item not selected, make it the sole selection - if (!selectedIds.has(id)) { + const isRightClickedItemAlreadySelected = selectedIds.has(id); + const hasSelection = selectedIds.size > 0; + + // If the right-clicked item is not selected, and there is no active selection, + // make it the sole selection. If there is an active selection, do not clear it + // so we can bind the active selection to this target geometry. + if (!isRightClickedItemAlreadySelected && !hasSelection) { clearSelection(); selectFeature(feature, false); } @@ -106,7 +112,9 @@ export function initSelect( showContextMenu( e.originalEvent?.clientX ?? e.point.x, e.originalEvent?.clientY ?? e.point.y, - feature + feature, + isRightClickedItemAlreadySelected, + hasSelection ); } @@ -161,6 +169,21 @@ export function initSelect( return 0; } + // Đồng bộ selection state từ React. + function syncSelection(ids: (string | number)[]) { + const nextSet = new Set(ids); + selectedIds.forEach((id) => { + if (!nextSet.has(id)) { + setSelectionStateForId(id, false); + } + }); + selectedIds.clear(); + ids.forEach((id) => { + setSelectionStateForId(id, true); + selectedIds.add(id); + }); + } + map.on("click", onClick); map.on("mousemove", onMove); if (hasContextActions) { @@ -180,6 +203,7 @@ export function initSelect( return { cleanup, clearSelection, + syncSelection, }; // Ẩn và dọn dẹp context menu hiện tại. @@ -198,7 +222,9 @@ export function initSelect( function showContextMenu( x: number, y: number, - clickedFeature: maplibregl.MapGeoJSONFeature + clickedFeature: maplibregl.MapGeoJSONFeature, + isRightClickedItemAlreadySelected: boolean, + hasSelection: boolean ) { hideContextMenu(); @@ -231,67 +257,86 @@ export function initSelect( return item; }; - const selectedCount = selectedIds.size || 1; + const selectedCount = selectedIds.size; let hasMenuItems = false; - if ( - selectedCount === 1 && - clickedFeature.source === "countries" && - clickedFeature.geometry?.type === "Polygon" && - onEdit - ) { - const single = clickedFeature; - menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single))); - hasMenuItems = true; - } - - if (selectedCount === 1 && onDuplicate) { - const featureId = clickedFeature.id ?? clickedFeature.properties?.id; - if (featureId !== undefined && featureId !== null) { - menu.appendChild(createItem("Duplicate", () => onDuplicate(featureId))); + if (!isRightClickedItemAlreadySelected && hasSelection) { + if (onBindGeometries) { + const targetId = clickedFeature.id ?? clickedFeature.properties?.id; + if (targetId !== undefined && targetId !== null) { + const sourceIds = Array.from(selectedIds); + menu.appendChild( + createItem( + `Bind ${selectedCount} geo đang chọn vào geo này`, + () => { + onBindGeometries(targetId, sourceIds); + } + ) + ); + hasMenuItems = true; + } + } + } else { + const effectiveCount = selectedCount || 1; + if ( + effectiveCount === 1 && + clickedFeature.source === "countries" && + clickedFeature.geometry?.type === "Polygon" && + onEdit + ) { + const single = clickedFeature; + menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single))); hasMenuItems = true; } - } - if (selectedCount === 1 && onHide) { - const featureId = clickedFeature.id ?? clickedFeature.properties?.id; - if (featureId !== undefined && featureId !== null) { - menu.appendChild(createItem("Hide", () => onHide(featureId))); - hasMenuItems = true; + if (effectiveCount === 1 && onDuplicate) { + const featureId = clickedFeature.id ?? clickedFeature.properties?.id; + if (featureId !== undefined && featureId !== null) { + menu.appendChild(createItem("Duplicate", () => onDuplicate(featureId))); + hasMenuItems = true; + } } - } - if (onReplayEdit) { - const featureId = clickedFeature.id ?? clickedFeature.properties?.id; - if (featureId) { + if (effectiveCount === 1 && onHide) { + const featureId = clickedFeature.id ?? clickedFeature.properties?.id; + if (featureId !== undefined && featureId !== null) { + menu.appendChild(createItem("Hide", () => onHide(featureId))); + hasMenuItems = true; + } + } + + if (onReplayEdit) { + const featureId = clickedFeature.id ?? clickedFeature.properties?.id; + if (featureId) { + menu.appendChild( + createItem( + effectiveCount > 1 ? `Vào replay (${effectiveCount} geo)` : "Vào replay", + () => onReplayEdit(featureId) + ) + ); + hasMenuItems = true; + } + } + + if (onDelete) { menu.appendChild( createItem( - selectedCount > 1 ? `Vào replay (${selectedCount} geo)` : "Vào replay", - () => onReplayEdit(featureId) + effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa", + () => { + const ids = selectedIds.size + ? Array.from(selectedIds) + : [clickedFeature.id ?? clickedFeature.properties?.id]; + ids.forEach((eachId) => { + if (eachId !== undefined && eachId !== null) onDelete(eachId); + }); + clearSelection(); + } ) ); hasMenuItems = true; } } - if (onDelete) { - menu.appendChild( - createItem( - selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa", - () => { - const ids = selectedIds.size - ? Array.from(selectedIds) - : [clickedFeature.id ?? clickedFeature.properties?.id]; - ids.forEach((eachId) => { - if (eachId !== undefined && eachId !== null) onDelete(eachId); - }); - clearSelection(); - } - ) - ); - hasMenuItems = true; - } - if (!hasMenuItems) return; document.body.appendChild(menu); From 3b4ff71b9ae596d763201bb4c0b75d561a7b6e87 Mon Sep 17 00:00:00 2001 From: taDuc Date: Fri, 22 May 2026 16:42:51 +0700 Subject: [PATCH 2/3] refactor: enhance selectingEngine with multi-delete support and structured context menu items --- src/app/editor/[id]/page.tsx | 19 +- src/app/editor/[id]/page.tsx.orig | 2305 +++++++++++++++++ src/uhm/components/Map.tsx | 2 +- .../editor/SelectedGeometryPanel.tsx | 292 ++- src/uhm/components/map/useMapInteraction.ts | 4 +- src/uhm/lib/editor/state/useEditorState.ts | 28 + src/uhm/lib/map/engines/selectingEngine.ts | 162 +- .../lib/map/engines/selectingEngine.ts.orig | 357 +++ 8 files changed, 2989 insertions(+), 180 deletions(-) create mode 100644 src/app/editor/[id]/page.tsx.orig create mode 100644 src/uhm/lib/map/engines/selectingEngine.ts.orig diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 4682fcf..1eed43c 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -742,10 +742,12 @@ function EditorPageContent() { 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, selectedFeatureIds); + editor.switchReplayContext(triggerId, finalSelectedIds); setSelectedFeatureIds([]); } else if (m !== "replay") { if (mode === "replay") { @@ -2037,7 +2039,13 @@ function EditorPageContent() { selectedFeatureIds={selectedFeatureIds} onSelectFeatureIds={setSelectedFeatureIds} onCreateFeature={handleCreateFeature} - onDeleteFeature={editor.deleteFeature} + onDeleteFeature={(id) => { + if (Array.isArray(id)) { + editor.deleteFeatures(id); + } else { + editor.deleteFeature(id); + } + }} onHideFeature={handleHideGeometryLocal} onUpdateFeature={editor.updateFeature} backgroundVisibility={backgroundVisibility} @@ -2190,10 +2198,15 @@ function EditorPageContent() { - {selectedFeature ? ( + {selectedFeatures.length > 0 ? ( { + editor.deleteFeatures(ids); + setSelectedFeatureIds([]); + }} + onDeselectAll={() => setSelectedFeatureIds([])} changeCount={editor.changeCount} onReplayEdit={(id) => setMode("replay", id)} /> diff --git a/src/app/editor/[id]/page.tsx.orig b/src/app/editor/[id]/page.tsx.orig new file mode 100644 index 0000000..4682fcf --- /dev/null +++ b/src/app/editor/[id]/page.tsx.orig @@ -0,0 +1,2305 @@ +"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 ReplayPreviewOverlay from "@/uhm/components/editor/ReplayPreviewOverlay"; +import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar"; +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 { fetchWikiById, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis"; +import { searchGeometriesByEntityName, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries"; +import { + Feature, + FeatureCollection, + useEditorState, +} from "@/uhm/lib/editor/state/useEditorState"; +import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes"; +import { + getDefaultTypeIdForFeature, + normalizeFeatureBindingIds, + normalizeFeatureEntityIds, + uniqueEntityIds, +} from "@/uhm/lib/editor/snapshot/editorSnapshot"; +import { + buildClientEntityId, + mergeEntitySearchResults, +} from "@/uhm/lib/editor/entity/entityBinding"; +import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding"; +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 } from "@/uhm/lib/utils/timeline"; +import { useFeatureCommands } from "./featureCommands"; +import { deleteSubmission } from "@/uhm/api/projects"; +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 "./EditorSearchResults"; +import { ResizeHandle } from "./ResizeHandle"; +import { + clampNumber, + formatCommitTitle, + isFeatureVisibleAtYear, + normalizeEntitiesForCompare, + normalizeEntityWikiLinksForCompare, + normalizeGeoSearchBindingIds, + normalizeGeoSearchGeometry, + normalizeReplaysForCompare, + normalizeWikisForCompare, +} from "./editorPageUtils"; + +const CURRENT_YEAR = new Date().getUTCFullYear(); +const DEFAULT_EDITOR_USER_ID = "local-editor"; + +type ReplayPreviewSession = { + replay: BattleReplay; + draft: FeatureCollection; + wikis: WikiSnapshot[]; + selectedStageId: number | null; + selectedStepIndex: number | null; + timelineYear: number; + timelineFilterEnabled: boolean; + mapViewState: ReturnType; +}; + +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); + // State chính của editor nằm trong zustand store để các panel con đọc cùng source-of-truth. + const { + mode, + internalSetMode, + initialData, + isSaving, + isSubmitting, + isOpeningSection, + setIsOpeningSection, + commitTitle, + setCommitTitle, + activeSection, + projectState, + sectionCommits, + baselineSnapshot, + entityCatalog, + setEntityCatalog, + snapshotEntities, + setSnapshotEntities, + 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, + initialData: state.initialData, + 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, + snapshotEntities: state.snapshotEntities, + setSnapshotEntities: state.setSnapshotEntities, + 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 snapshotEntitiesRef = useRef(snapshotEntities); + const snapshotWikisRef = useRef(snapshotWikis); + const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks); + useEffect(() => { + snapshotEntitiesRef.current = snapshotEntities; + }, [snapshotEntities]); + 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(initialData, { + snapshotUndo: { + snapshotEntitiesRef, + setSnapshotEntities, + 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] + ); + // Chuyển entity snapshot local thành entity catalog row để search/binding dùng chung. + const snapshotEntitiesAsEntities = useMemo(() => { + const rows = snapshotEntities || []; + 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: e.time_start ?? null, + time_end: e.time_end ?? null, + geometry_count: 0, + })) + .filter((e) => e.id.length > 0 && e.name.length > 0); + }, [snapshotEntities]); + + // Entity list hợp nhất giữa backend catalog và snapshot local. + const entities = useMemo( + () => mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities), + [entityCatalog, snapshotEntitiesAsEntities] + ); + // 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); + // Cache wiki đã fetch trong preview để không gọi API lặp lại. + const [previewWikiCache, setPreviewWikiCache] = useState>({}); + // State lỗi riêng cho wiki preview sidebar. + const [previewWikiError, setPreviewWikiError] = useState(null); + // State loading riêng cho wiki preview sidebar. + const [isPreviewWikiLoading, setIsPreviewWikiLoading] = 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 isReplayEditMode = mode === "replay"; + const isReplayPreviewMode = mode === "replay_preview"; + // 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((snapshotEntities || []).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; + }); + }, [snapshotEntities, 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]); + + // 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: () => {}, + }); + + // 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 || replayPreview.hiddenGeometryIds.length === 0) { + return sourceDraft; + } + const hiddenIds = new Set(replayPreview.hiddenGeometryIds); + return { + ...sourceDraft, + features: sourceDraft.features.filter( + (feature) => !hiddenIds.has(String(feature.properties.id)) + ), + }; + }, [isReplayPreviewMode, previewSession?.draft, replayPreview.hiddenGeometryIds]); + + const activeTimelineYear = isReplayPreviewMode + ? replayPreview.timelineYear + : timelineDraftYear; + const activeTimelineFilterEnabled = isReplayPreviewMode + ? replayPreview.timelineFilterEnabled + : timelineFilterEnabled; + + // Timeline filter: only affects persisted snapshot features. + // New features created in the current session remain visible regardless of time range. + // Draft cuối cùng đưa vào map sau khi áp filter timeline. + const timelineVisibleDraft = useMemo(() => { + const activeDraft = isReplayPreviewMode + ? replayPreviewDraft + : isReplayEditMode + ? editor.replayDraft + : editor.mainDraft; + + if (!activeTimelineFilterEnabled) return activeDraft; + const year = clampYearToFixedRange(Math.trunc(activeTimelineYear)); + return { + ...activeDraft, + features: activeDraft.features.filter((feature) => { + if (!editor.hasPersistedFeature(feature.properties.id)) return true; + return isFeatureVisibleAtYear(feature, year); + }), + }; + }, [ + activeTimelineFilterEnabled, + activeTimelineYear, + editor, + isReplayEditMode, + isReplayPreviewMode, + replayPreviewDraft, + ]); + + // 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: selectedFeature.properties.time_start ?? null, + time_end: selectedFeature.properties.time_end ?? null, + }; + }, [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 timelineVisibleGeometryIds = new Set( + timelineVisibleDraft.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})` : f.geometry.type; + return { + id, + label, + time_start: f.properties.time_start ?? null, + time_end: f.properties.time_end ?? null, + isTimelineVisible: timelineVisibleGeometryIds.has(id), + isNew: createdGeometryIds.has(id) || !editor.hasPersistedFeature(f.properties.id), + }; + }); + rows.sort((a, b) => a.id.localeCompare(b.id)); + return rows; + }, [editor, timelineVisibleDraft.features]); + + // Binding ids của geometry đại diện đang chọn. + const selectedGeometryBindingIds = useMemo(() => { + if (!selectedFeature) return []; + return normalizeFeatureBindingIds(selectedFeature); + }, [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"} (${String(wiki.id || "")})`, + })) + .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(snapshotEntities); + try { + return JSON.stringify(prev) !== JSON.stringify(next); + } catch { + return true; + } + }, [baselineSnapshot?.entities, snapshotEntities]); + + // 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; + + // Thoát preview và quay về replay edit mode. + const exitReplayPreview = useCallback(() => { + replayPreview.resetPreview(); + setPreviewAutoplayMode(null); + setPreviewSession(null); + internalSetMode("replay"); + }, [internalSetMode, replayPreview.resetPreview]); + + // Đó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; + + setPreviewSession({ + replay: deepClone(editor.activeReplayDraft), + draft: deepClone(editor.replayDraft), + wikis: deepClone(snapshotWikis), + selectedStageId: replaySelection.stageId, + selectedStepIndex: replaySelection.stepIndex, + timelineYear: timelineDraftYear, + timelineFilterEnabled, + mapViewState: getCurrentMapViewState(), + }); + setPreviewAutoplayMode(autoplayMode); + setSelectedFeatureIds([]); + internalSetMode("replay_preview"); + }, [ + editor.activeReplayDraft, + editor.replayDraft, + getCurrentMapViewState, + internalSetMode, + replaySelection.stageId, + replaySelection.stepIndex, + setSelectedFeatureIds, + snapshotWikis, + timelineDraftYear, + timelineFilterEnabled, + ]); + + // State machine chuyển mode editor, xử lý riêng replay/replay_preview để không mất draft. + const setMode = useCallback((m: EditorMode, featureId?: string | number) => { + if (m === "replay_preview") { + return; + } + + if (mode === "replay_preview") { + replayPreview.resetPreview(); + setPreviewAutoplayMode(null); + setPreviewSession(null); + + 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 triggerId = selectedFeatureIds.length > 0 ? selectedFeatureIds[0] : featureId; + setReplayFeatureId(triggerId); + setReplaySelection({ stageId: null, stepIndex: null }); + editor.switchReplayContext(triggerId, selectedFeatureIds); + 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, + replayPreview.resetPreview, + 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]); + + useEffect(() => { + if (!isReplayPreviewMode || !previewSession || !previewAutoplayMode) return; + if (previewAutoplayMode === "selection") { + replayPreview.playFromSelection(); + } else { + replayPreview.playFromStart(); + } + setPreviewAutoplayMode(null); + }, [ + isReplayPreviewMode, + previewAutoplayMode, + previewSession, + replayPreview.playFromSelection, + replayPreview.playFromStart, + ]); + + useEffect(() => { + setPreviewWikiCache({}); + setPreviewWikiError(null); + setIsPreviewWikiLoading(false); + }, [previewSession]); + + // Label ngắn cho overlay preview tại step đang phát. + const replayPreviewActiveStepLabel = useMemo(() => { + if ( + replayPreview.activeCursor.stageId == null || + replayPreview.activeCursor.stepIndex == null + ) { + return null; + } + return `Stage #${replayPreview.activeCursor.stageId} · Step ${replayPreview.activeCursor.stepIndex + 1}`; + }, [replayPreview.activeCursor.stageId, replayPreview.activeCursor.stepIndex]); + + const replayPreviewWikiRows = previewSession?.wikis || []; + // Wiki snapshot đang được step preview yêu cầu mở. + const replayPreviewActiveWikiSnapshot = useMemo(() => { + if (!replayPreview.activeWikiId) return null; + return replayPreviewWikiRows.find((item) => item.id === replayPreview.activeWikiId) || null; + }, [replayPreview.activeWikiId, replayPreviewWikiRows]); + + useEffect(() => { + if (!isReplayPreviewMode || !replayPreview.sidebarOpen) { + setPreviewWikiError(null); + setIsPreviewWikiLoading(false); + return; + } + + const activeWikiId = String(replayPreview.activeWikiId || "").trim(); + if (!activeWikiId.length) { + setPreviewWikiError(null); + setIsPreviewWikiLoading(false); + return; + } + + const localWiki = replayPreviewWikiRows.find((item) => item.id === activeWikiId) || null; + if (!localWiki) { + setPreviewWikiError("Không tìm thấy wiki trong snapshot preview."); + setIsPreviewWikiLoading(false); + return; + } + + if (typeof localWiki.doc === "string") { + setPreviewWikiError(null); + setIsPreviewWikiLoading(false); + return; + } + + if (previewWikiCache[activeWikiId]) { + setPreviewWikiError(null); + setIsPreviewWikiLoading(false); + return; + } + + let disposed = false; + setPreviewWikiError(null); + setIsPreviewWikiLoading(true); + void fetchWikiById(activeWikiId) + .then((row) => { + if (disposed) return; + setPreviewWikiCache((prev) => ({ ...prev, [activeWikiId]: row })); + }) + .catch((err) => { + if (disposed) return; + setPreviewWikiError(err instanceof Error ? err.message : "Không tải được wiki preview."); + }) + .finally(() => { + if (!disposed) { + setIsPreviewWikiLoading(false); + } + }); + + return () => { + disposed = true; + }; + }, [ + isReplayPreviewMode, + previewWikiCache, + replayPreview.activeWikiId, + replayPreview.sidebarOpen, + replayPreviewWikiRows, + ]); + + // Wiki đầy đủ cho sidebar preview, ưu tiên doc có sẵn trong snapshot rồi mới dùng cache API. + const replayPreviewActiveWiki = useMemo(() => { + const snapshotWiki = replayPreviewActiveWikiSnapshot; + if (!snapshotWiki) return null; + if (typeof snapshotWiki.doc === "string") { + return { + id: snapshotWiki.id, + project_id: projectId, + title: snapshotWiki.title, + slug: snapshotWiki.slug ?? null, + content: snapshotWiki.doc || "", + }; + } + return previewWikiCache[snapshotWiki.id] || null; + }, [previewWikiCache, projectId, replayPreviewActiveWikiSnapshot]); + + // Điều hướng link wiki nội bộ trong preview nhưng chỉ trong phạm vi snapshot preview. + const handleReplayPreviewWikiLinkRequest = useCallback(({ slug }: { slug: string; rect: DOMRect }) => { + const nextSlug = String(slug || "").trim(); + if (!nextSlug.length) return; + const match = replayPreviewWikiRows.find((item) => String(item.slug || "").trim() === nextSlug) || null; + if (!match) { + setPreviewWikiError(`Wiki /wiki/${nextSlug} không có trong snapshot preview.`); + return; + } + setPreviewWikiError(null); + replayPreview.openWikiPanelById(match.id); + }, [replayPreview.openWikiPanelById, replayPreviewWikiRows]); + + // Visibility cuối cùng theo type/layer, có override riêng cho replay edit/preview. + const effectiveGeometryVisibility = useMemo(() => { + const visibility: Record = { ...geometryVisibility }; + + if ((isReplayEditMode || isReplayPreviewMode) && replayFeatureId) { + // Ẩn chính geo được chọn làm replay (marker kịch bản) + visibility[String(replayFeatureId)] = false; + + if (isReplayEditMode && hideOutside) { + // Trong mode replay, ta chỉ hiển thị những gì có trong draft của replay đó + const currentReplayFeatureIds = new Set(editor.draft.features.map(f => String(f.properties.id))); + + // Ẩn tất cả các geo KHÔNG nằm trong draft replay hiện tại + Object.keys(visibility).forEach(fid => { + if (fid === String(replayFeatureId)) { + visibility[fid] = false; + } else { + visibility[fid] = currentReplayFeatureIds.has(fid); + } + }); + } + } + + return visibility; + }, [ + editor.draft.features, + geometryVisibility, + hideOutside, + isReplayEditMode, + isReplayPreviewMode, + replayFeatureId, + ]); + + // 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("Xoa submission PENDING de unlock editor? Hanh dong nay khong the hoan tac."); + 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 stillExistIds = selectedFeatureIds.filter(id => + timelineVisibleDraft.features.some(feature => String(feature.properties.id) === String(id)) + ); + if (stillExistIds.length !== selectedFeatureIds.length) { + setSelectedFeatureIds(stillExistIds); + } + }, [timelineVisibleDraft, selectedFeatureIds, setSelectedFeatureIds]); + + useEffect(() => { + if (!selectedFeature) { + setSelectedGeometryEntityIds([]); + setGeometryMetaForm({ + type_key: "", + time_start: "", + time_end: "", + binding: "", + }); + 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); + setSelectedGeometryEntityIds(featureEntityIds); + setGeometryMetaForm({ + type_key: nextTypeKey, + 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(", "), + }); + // 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.setSnapshotEntities((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: entity.time_start ?? null, + time_end: entity.time_end ?? null, + }, + ...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.setSnapshotEntities((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, + ]); + + // Bind/unbind geometry id vào trường binding của selected geometry. + const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => { + if (!selectedFeatures || selectedFeatures.length === 0) { + flashGeoBindingStatus("Chưa chọn geometry để bind."); + 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; + if (selectedFeatures.some(f => String(f.properties.id) === id)) return; + + + + setIsEntitySubmitting(true); + flashGeoBindingStatus(null, 0); + try { + const bindingPatches = selectedFeatures.map((feature) => { + const prevBindingIds = normalizeFeatureBindingIds(feature); + const has = prevBindingIds.includes(id); + const nextBindingIds = (() => { + if (nextChecked) { + if (has) return prevBindingIds; + return [...prevBindingIds, id]; + } + if (!has) return prevBindingIds; + return prevBindingIds.filter((x) => x !== id); + })(); + return { + id: feature.properties.id, + patch: { binding: nextBindingIds }, + }; + }); + editor.patchFeaturePropertiesBatch( + bindingPatches, + nextChecked ? "Bind geometry vào GEO" : "Unbind geometry khỏi GEO" + ); + + // Assume selectedFeature (the first one) reflects the representative binding in UI + const firstFeaturePrevBindings = normalizeFeatureBindingIds(selectedFeatures[0]); + const firstFeatureHas = firstFeaturePrevBindings.includes(id); + const nextBindingIdsForUI = (() => { + if (nextChecked) return firstFeatureHas ? firstFeaturePrevBindings : [...firstFeaturePrevBindings, id]; + return firstFeatureHas ? firstFeaturePrevBindings.filter(x => x !== id) : firstFeaturePrevBindings; + })(); + setGeometryMetaForm((prev) => ({ ...prev, binding: nextBindingIdsForUI.join(", ") })); + flashGeoBindingStatus( + nextChecked + ? "Đã bind geometry vào binding. Commit khi sẵn sàng." + : "Đã gỡ binding geometry. Commit khi sẵn sàng.", + 3000 + ); + } finally { + setIsEntitySubmitting(false); + } + }, [ + editor, + flashGeoBindingStatus, + selectedFeatures, + isMultiEditValid, + setGeometryMetaForm, + setIsEntitySubmitting, + ]); + + // Bind nhiều geometries 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 prevBindingIds = normalizeFeatureBindingIds(targetFeature); + + // Merge prevBindingIds with sourceIds (which are strings of selected features) + // filter out targetId itself (we can't bind a geometry to itself) + const newSources = sourceIds.map(String).filter((x) => x !== idStr); + const merged = Array.from(new Set([...prevBindingIds, ...newSources])); + + editor.patchFeaturePropertiesBatch( + [{ + id: targetFeature.properties.id, + patch: { binding: merged }, + }], + "Bind các geometry đã chọn vào GEO" + ); + + setSelectedFeatureIds([targetFeature.properties.id]); + flashGeoBindingStatus(`Đã bind ${newSources.length} geometry vào GEO này. 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 = feature.properties.time_start; + if (typeof geoTimeStart === "number" && Number.isFinite(geoTimeStart)) { + 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; + + // Ensure the geometry stays selectable even if it doesn't match the current timeline year. + setTimelineFilterEnabled(false); + + 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 bindingIds = normalizeGeoSearchBindingIds(geo.binding); + const typeKey = geo.type || null; + + const feature: Feature = { + type: "Feature", + properties: { + id: geoId, + type: typeKey, + time_start: typeof geo.time_start === "number" ? geo.time_start : null, + time_end: typeof geo.time_end === "number" ? geo.time_end : null, + binding: bindingIds.length ? bindingIds : undefined, + 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.createFeatureWithSnapshotEntities( + 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, + setTimelineFilterEnabled, + ]); + + // Commands thao tác metadata/entity binding cho feature đang chọn. + const featureCommands = useFeatureCommands({ + editor, + selectedFeatures, + geometryMetaForm, + setGeometryMetaForm, + selectedGeometryEntityIds, + setSelectedGeometryEntityIds, + entities, + setIsEntitySubmitting, + setEntityFormStatus, + }); + + // 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.setSnapshotEntities((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; + + // Tạo geometry từ map engine rồi select ngay geometry mới. + const handleCreateFeature = (feature: Feature) => { + editor.createFeature(feature); + setSelectedFeatureIds([feature.properties.id]); + }; + + // Draft nguồn dùng để render label trong map khi preview đang dùng draft đóng băng. + const mapLabelSourceDraft = isReplayPreviewMode + ? previewSession?.draft || EMPTY_FEATURE_COLLECTION + : editor.draft; + const mapLabelContextDraft = useMemo( + () => buildEntityLabelContextDraft(mapLabelSourceDraft, entities), + [entities, mapLabelSourceDraft] + ); + + return ( +
+ {!isReplayEditMode && !isReplayPreviewMode ? ( + <> + + + { + 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} + + {blockedPendingSubmissionId ? ( +
+
+

Editor dang bi khoa

+
+ Project nay dang co submission o trang thai PENDING (id:{" "} + {blockedPendingSubmissionId}). Theo BE moi, khi + submission dang pending thi khong duoc tao submission/commit moi va khong duoc vao editor. +
+
+ + +
+
+
+ ) : null} + + {!blockedPendingSubmissionId ? ( +
+ {isBackgroundVisibilityReady ? ( + + ) : ( +
+ )} + {isReplayPreviewMode ? ( + + ) : null} + {isReplayPreviewMode && replayPreview.sidebarOpen ? ( + + ) : null} + {!isReplayPreviewMode || replayPreview.timelineVisible ? ( + + ) : null} +
+ ) : null} + + {!isReplayEditMode && !isReplayPreviewMode ? ( + <> + { + // dragging handle (between map and right panel): moving right increases right panel width + 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} + /> + + + + + + + + + {selectedFeature ? ( + setMode("replay", id)} + /> + ) : null} +
+ } + /> + + ) : isReplayEditMode ? ( + <> + { + setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720)); + }} + /> + String(id))} + currentTimelineYear={timelineDraftYear} + geometryChoices={geometryChoices} + wikiChoices={wikiChoices} + getCurrentMapViewState={getCurrentMapViewState} + onMutateReplay={editor.mutateActiveReplay} + /> + + ) : null} +
+ ); +} + +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 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: entity?.time_start ?? null, + time_end: entity?.time_end ?? null, + }; + }).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 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; +} diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index 7d25088..707df34 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -42,7 +42,7 @@ type MapProps = { labelContextDraft?: FeatureCollection; labelTimelineYear?: number | null; onCreateFeature?: (feature: FeatureCollection["features"][number]) => void; - onDeleteFeature?: (id: string | number) => void; + onDeleteFeature?: (id: string | number | (string | number)[]) => void; onHideFeature?: (id: string | number) => void; onUpdateFeature?: (id: string | number, geometry: Geometry) => void; allowGeometryEditing?: boolean; diff --git a/src/uhm/components/editor/SelectedGeometryPanel.tsx b/src/uhm/components/editor/SelectedGeometryPanel.tsx index 212bef3..bbaa7cb 100644 --- a/src/uhm/components/editor/SelectedGeometryPanel.tsx +++ b/src/uhm/components/editor/SelectedGeometryPanel.tsx @@ -18,6 +18,8 @@ type Props = { onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>; changeCount: number; onReplayEdit?: (id: string | number) => void; + onDeleteFeatures?: (ids: (string | number)[]) => void; + onDeselectAll?: () => void; }; export default function SelectedGeometryPanel({ @@ -25,6 +27,8 @@ export default function SelectedGeometryPanel({ onApplyGeometryMetadata, changeCount, onReplayEdit, + onDeleteFeatures, + onDeselectAll, }: Props) { const { geometryMetaForm, @@ -74,6 +78,13 @@ export default function SelectedGeometryPanel({ const visibleGeoApplyFeedback = geoApplyFeedback && geoApplyFeedback.signature === geoMetaSignature ? geoApplyFeedback : null; + const isBulkMode = selectedFeatures.length >= 2; + const isMultiEditValid = useMemo(() => { + if (selectedFeatures.length <= 1) return true; + const firstShape = selectedFeatures[0].geometry.type; + return selectedFeatures.every((f) => f.geometry.type === firstShape); + }, [selectedFeatures]); + if (!selectedFeatures || selectedFeatures.length === 0) return null; const representativeFeature = selectedFeatures[0]; @@ -99,7 +110,7 @@ export default function SelectedGeometryPanel({ >
- Geometry property + {isBulkMode ? `Đang chọn ${selectedFeatures.length} Geometries` : "Geometry property"}
+ + +
+
+ )} +
Các giá trị này thuộc về GEO đang chọn, không phụ thuộc entity.
-
- Loại GEO -
- + setGeometryMetaForm((prev) => ({ + ...prev, + type_key: event.target.value, + })) + } + disabled={isEntitySubmitting} + style={entityInputStyle} > - {group.options.map((option) => ( - + ) : null} + {groupedGeoTypeOptions.map((group) => ( + + {group.options.map((option) => ( + + ))} + ))} - - ))} - - {selectedTypeOption ? ( -
- Đang chọn: {selectedTypeOption.label} ({selectedTypeOption.groupLabel}) -
- ) : geometryMetaForm.type_key ? ( -
- Đang chọn: {geometryMetaForm.type_key} -
- ) : null} - - setGeometryMetaForm((prev) => ({ - ...prev, - time_start: event.target.value, - })) - } - placeholder="time_start" - disabled={isEntitySubmitting} - style={entityInputStyle} - /> - - setGeometryMetaForm((prev) => ({ - ...prev, - time_end: event.target.value, - })) - } - placeholder="time_end" - disabled={isEntitySubmitting} - style={entityInputStyle} - /> - {/* onGeometryMetaFormChange("binding", event.target.value)}*/} - {/* placeholder="binding (geometry ids, comma separated)"*/} - {/* disabled={isEntitySubmitting}*/} - {/* style={entityInputStyle}*/} - {/*/>*/} - - {onReplayEdit && selectedFeatures.length > 0 && ( - + + {selectedTypeOption ? ( +
+ Đang chọn: {selectedTypeOption.label} ({selectedTypeOption.groupLabel}) +
+ ) : geometryMetaForm.type_key ? ( +
+ Đang chọn: {geometryMetaForm.type_key} +
+ ) : null} + + setGeometryMetaForm((prev) => ({ + ...prev, + time_start: event.target.value, + })) + } + placeholder="time_start" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + + setGeometryMetaForm((prev) => ({ + ...prev, + time_end: event.target.value, + })) + } + placeholder="time_end" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + + {onReplayEdit && !isBulkMode && selectedFeatures.length > 0 && ( + + )} + {visibleGeoApplyFeedback ? ( +
+ {visibleGeoApplyFeedback.text} +
+ ) : null} + )} - {visibleGeoApplyFeedback ? ( -
- {visibleGeoApplyFeedback.text} -
- ) : null} {changeCount > 0 ? ( @@ -254,7 +338,7 @@ export default function SelectedGeometryPanel({ Thay đổi sẽ vào lịch sử khi Commit. ) : null} - + )} ); diff --git a/src/uhm/components/map/useMapInteraction.ts b/src/uhm/components/map/useMapInteraction.ts index e581ab3..c5226d9 100644 --- a/src/uhm/components/map/useMapInteraction.ts +++ b/src/uhm/components/map/useMapInteraction.ts @@ -29,7 +29,7 @@ type UseMapInteractionProps = { onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>; onSetModeRef: React.MutableRefObject<((mode: EditorMode, featureId?: string | number) => void) | undefined>; onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>; - onDeleteRef: React.MutableRefObject<((id: string | number) => void) | undefined>; + onDeleteRef: React.MutableRefObject<((id: string | number | (string | number)[]) => void) | undefined>; onHideRef: React.MutableRefObject<((id: string | number) => void) | undefined>; onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>; onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>; @@ -144,7 +144,7 @@ export function useMapInteraction({ map, () => modeRef.current, allowGeometryEditing - ? (id: string | number) => { + ? (id: string | number | (string | number)[]) => { editingEngineRef.current?.clearEditing(); onSelectFeatureIdsRef.current?.([]); onDeleteRef.current?.(id); diff --git a/src/uhm/lib/editor/state/useEditorState.ts b/src/uhm/lib/editor/state/useEditorState.ts index 2e2a784..ac1e454 100644 --- a/src/uhm/lib/editor/state/useEditorState.ts +++ b/src/uhm/lib/editor/state/useEditorState.ts @@ -540,6 +540,33 @@ export function useEditorState( commitMainDraft({ ...mainDraftRef.current, features: nextFeatures }); } + function deleteFeatures(ids: Array) { + if (mode === "replay") { + return; + } + + const idsSet = new Set(ids.map(String)); + const nextFeatures: Feature[] = []; + const undoActions: UndoAction[] = []; + + for (const feature of mainDraftRef.current.features) { + if (idsSet.has(String(feature.properties.id))) { + undoActions.push({ type: "delete", feature: deepClone(feature) }); + } else { + nextFeatures.push(feature); + } + } + + if (undoActions.length === 0) return; + + pushMainUndo( + undoActions.length === 1 + ? undoActions[0] + : { type: "group", label: `Xóa ${undoActions.length} geometry`, actions: undoActions } + ); + commitMainDraft({ ...mainDraftRef.current, features: nextFeatures }); + } + function buildPayload(): Change[] { return Array.from(changes.values()).map((change) => deepClone(change)); } @@ -695,6 +722,7 @@ export function useEditorState( patchFeaturePropertiesBatch, updateFeature, deleteFeature, + deleteFeatures, undo, buildPayload, clearChanges, diff --git a/src/uhm/lib/map/engines/selectingEngine.ts b/src/uhm/lib/map/engines/selectingEngine.ts index c37d700..4fdbfb2 100644 --- a/src/uhm/lib/map/engines/selectingEngine.ts +++ b/src/uhm/lib/map/engines/selectingEngine.ts @@ -5,7 +5,7 @@ import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes"; export function initSelect( map: maplibregl.Map, getMode: ModeGetter, - onDelete?: (id: string | number) => void, + onDelete?: (id: string | number | (string | number)[]) => void, onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void, onDuplicate?: (id: string | number) => void, onHide?: (id: string | number) => void, @@ -44,10 +44,13 @@ export function initSelect( clearSelection(); } - if (additive && selectedIds.has(id)) { + const idToRemove = Array.from(selectedIds).find(sid => String(sid) === String(id)); + const isAlreadySelected = idToRemove !== undefined; + + if (additive && isAlreadySelected) { // Alt + click on an already selected feature removes it from the selection - setSelectionStateForId(id, false); - selectedIds.delete(id); + setSelectionStateForId(idToRemove, false); + selectedIds.delete(idToRemove); onSelectIds?.(Array.from(selectedIds)); return; } @@ -98,7 +101,7 @@ export function initSelect( const id = feature.id ?? feature.properties?.id; if (id === undefined || id === null) return; - const isRightClickedItemAlreadySelected = selectedIds.has(id); + const isRightClickedItemAlreadySelected = Array.from(selectedIds).some(sid => String(sid) === String(id)); const hasSelection = selectedIds.size > 0; // If the right-clicked item is not selected, and there is no active selection, @@ -258,26 +261,30 @@ export function initSelect( }; const selectedCount = selectedIds.size; - let hasMenuItems = false; + const effectiveCount = selectedCount || 1; + const targetId = clickedFeature.id ?? clickedFeature.properties?.id; + const isClickOutsideSelection = !isRightClickedItemAlreadySelected && hasSelection; - if (!isRightClickedItemAlreadySelected && hasSelection) { - if (onBindGeometries) { - const targetId = clickedFeature.id ?? clickedFeature.properties?.id; - if (targetId !== undefined && targetId !== null) { - const sourceIds = Array.from(selectedIds); - menu.appendChild( - createItem( - `Bind ${selectedCount} geo đang chọn vào geo này`, - () => { - onBindGeometries(targetId, sourceIds); - } - ) - ); - hasMenuItems = true; - } - } - } else { - const effectiveCount = selectedCount || 1; + type MenuItem = { + label: string; + onClick: () => void; + group: "edit" | "bind" | "replay" | "delete"; + }; + + const items: MenuItem[] = []; + + if (isClickOutsideSelection && onBindGeometries && targetId !== undefined && targetId !== null) { + const sourceIds = Array.from(selectedIds); + items.push({ + group: "bind", + label: `Bind ${selectedCount} geo đang chọn vào geo này`, + onClick: () => { + onBindGeometries(targetId, sourceIds); + }, + }); + } + + if (!isClickOutsideSelection) { if ( effectiveCount === 1 && clickedFeature.source === "countries" && @@ -285,59 +292,74 @@ export function initSelect( onEdit ) { const single = clickedFeature; - menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single))); - hasMenuItems = true; + items.push({ + group: "edit", + label: "Chỉnh sửa", + onClick: () => onEdit(single), + }); } - if (effectiveCount === 1 && onDuplicate) { - const featureId = clickedFeature.id ?? clickedFeature.properties?.id; - if (featureId !== undefined && featureId !== null) { - menu.appendChild(createItem("Duplicate", () => onDuplicate(featureId))); - hasMenuItems = true; - } + if (effectiveCount === 1 && onDuplicate && targetId !== undefined && targetId !== null) { + items.push({ + group: "edit", + label: "Duplicate", + onClick: () => onDuplicate(targetId), + }); } - if (effectiveCount === 1 && onHide) { - const featureId = clickedFeature.id ?? clickedFeature.properties?.id; - if (featureId !== undefined && featureId !== null) { - menu.appendChild(createItem("Hide", () => onHide(featureId))); - hasMenuItems = true; - } - } - - if (onReplayEdit) { - const featureId = clickedFeature.id ?? clickedFeature.properties?.id; - if (featureId) { - menu.appendChild( - createItem( - effectiveCount > 1 ? `Vào replay (${effectiveCount} geo)` : "Vào replay", - () => onReplayEdit(featureId) - ) - ); - hasMenuItems = true; - } - } - - if (onDelete) { - menu.appendChild( - createItem( - effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa", - () => { - const ids = selectedIds.size - ? Array.from(selectedIds) - : [clickedFeature.id ?? clickedFeature.properties?.id]; - ids.forEach((eachId) => { - if (eachId !== undefined && eachId !== null) onDelete(eachId); - }); - clearSelection(); - } - ) - ); - hasMenuItems = true; + if (effectiveCount === 1 && onHide && targetId !== undefined && targetId !== null) { + items.push({ + group: "edit", + label: "Hide", + onClick: () => onHide(targetId), + }); } } - if (!hasMenuItems) return; + if (onReplayEdit) { + const replayId = targetId; + if (replayId !== undefined && replayId !== null) { + const totalCount = isClickOutsideSelection ? selectedIds.size + 1 : effectiveCount; + items.push({ + group: "replay", + label: totalCount > 1 ? `Vào replay (${totalCount} geo)` : "Vào replay", + onClick: () => onReplayEdit(replayId), + }); + } + } + + if (onDelete) { + items.push({ + group: "delete", + label: effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa", + onClick: () => { + const ids = selectedIds.size + ? Array.from(selectedIds) + : [targetId]; + if (ids.length === 1) { + onDelete(ids[0]); + } else { + onDelete(ids); + } + clearSelection(); + }, + }); + } + + if (items.length === 0) return; + + let lastGroup: string | null = null; + items.forEach((item) => { + if (lastGroup !== null && lastGroup !== item.group) { + const separator = document.createElement("div"); + separator.style.height = "1px"; + separator.style.background = "#374151"; + separator.style.margin = "4px 0"; + menu.appendChild(separator); + } + menu.appendChild(createItem(item.label, item.onClick)); + lastGroup = item.group; + }); document.body.appendChild(menu); contextMenu = menu; diff --git a/src/uhm/lib/map/engines/selectingEngine.ts.orig b/src/uhm/lib/map/engines/selectingEngine.ts.orig new file mode 100644 index 0000000..4ffd708 --- /dev/null +++ b/src/uhm/lib/map/engines/selectingEngine.ts.orig @@ -0,0 +1,357 @@ +import maplibregl from "maplibre-gl"; +import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes"; + +// Khởi tạo engine chọn feature và context menu edit/delete. +export function initSelect( + map: maplibregl.Map, + getMode: ModeGetter, + onDelete?: (id: string | number) => void, + onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void, + onDuplicate?: (id: string | number) => void, + onHide?: (id: string | number) => void, + onSelectIds?: (ids: (string | number)[]) => void, + onReplayEdit?: (id: string | number) => void, + isEditSessionActive?: () => boolean, + onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void +) { + + const FEATURE_STATE_SOURCES = [ + "countries", + "places", + "path-arrow-shapes", + ] as const; + const selectedIds = new Set(); + const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit || onBindGeometries); + let contextMenu: HTMLDivElement | null = null; + let docClickHandler: ((ev: MouseEvent) => void) | null = null; + + // Bỏ highlight feature-state của toàn bộ đối tượng đang chọn. + function clearSelection(emit = true) { + if (!selectedIds.size) return; + selectedIds.forEach((id) => setSelectionStateForId(id, false)); + selectedIds.clear(); + if (emit) { + onSelectIds?.([]); + } + } + + // Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn. + function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) { + const id = feature.id ?? feature.properties?.id; + if (id === undefined || id === null) return; + + if (!additive) { + clearSelection(); + } + + const idToRemove = Array.from(selectedIds).find(sid => String(sid) === String(id)); + const isAlreadySelected = idToRemove !== undefined; + + if (additive && isAlreadySelected) { + // Alt + click on an already selected feature removes it from the selection + setSelectionStateForId(idToRemove, false); + selectedIds.delete(idToRemove); + onSelectIds?.(Array.from(selectedIds)); + return; + } + + setSelectionStateForId(id, true); + selectedIds.add(id); + onSelectIds?.(Array.from(selectedIds)); + } + + // Chọn feature theo click trái, hỗ trợ additive bằng Alt. + function onClick(e: maplibregl.MapLayerMouseEvent) { + if (getMode() !== "select" && getMode() !== "replay") return; + if (isEditSessionActive?.()) return; + const selectableLayers = getSelectableLayers(); + if (!selectableLayers.length) return; + + const features = map.queryRenderedFeatures(e.point, { + layers: selectableLayers, + }) as maplibregl.MapGeoJSONFeature[]; + + if (!features.length) { + clearSelection(); + return; + } + + const additive = !!e.originalEvent?.altKey; + selectFeature(pickPreferredFeature(features), additive); + } + + // Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải. + // Mở menu thao tác khi click phải lên feature. + function onRightClick(e: maplibregl.MapLayerMouseEvent) { + if (getMode() !== "select" && getMode() !== "replay") return; + const selectableLayers = getSelectableLayers(); + if (!selectableLayers.length) return; + + e.preventDefault(); // block browser menu + if (getMode() === "replay") return; + if (isEditSessionActive?.()) return; + + const features = map.queryRenderedFeatures(e.point, { + layers: selectableLayers, + }) as maplibregl.MapGeoJSONFeature[]; + + if (!features.length) return; + + const feature = pickPreferredFeature(features); + const id = feature.id ?? feature.properties?.id; + if (id === undefined || id === null) return; + + const isRightClickedItemAlreadySelected = Array.from(selectedIds).some(sid => String(sid) === String(id)); + const hasSelection = selectedIds.size > 0; + + // If the right-clicked item is not selected, and there is no active selection, + // make it the sole selection. If there is an active selection, do not clear it + // so we can bind the active selection to this target geometry. + if (!isRightClickedItemAlreadySelected && !hasSelection) { + clearSelection(); + selectFeature(feature, false); + } + + showContextMenu( + e.originalEvent?.clientX ?? e.point.x, + e.originalEvent?.clientY ?? e.point.y, + feature, + isRightClickedItemAlreadySelected, + hasSelection + ); + } + + // Đổi cursor pointer khi hover lên đối tượng có thể chọn. + function onMove(e: maplibregl.MapLayerMouseEvent) { + if (getMode() !== "select" && getMode() !== "replay") return; + const selectableLayers = getSelectableLayers(); + if (!selectableLayers.length) { + map.getCanvas().style.cursor = ""; + return; + } + + const features = map.queryRenderedFeatures(e.point, { + layers: selectableLayers, + }); + + map.getCanvas().style.cursor = features.length ? "pointer" : ""; + } + + function getSelectableLayers(): string[] { + const style = map.getStyle(); + if (!style || !style.layers) return []; + return style.layers + .filter((layer) => + "source" in layer && + typeof layer.source === "string" && + FEATURE_STATE_SOURCES.includes(layer.source as (typeof FEATURE_STATE_SOURCES)[number]) + ) + .map((layer) => layer.id); + } + + function setSelectionStateForId(id: string | number, selected: boolean) { + for (const source of FEATURE_STATE_SOURCES) { + if (!map.getSource(source)) continue; + map.setFeatureState({ source, id }, { selected }); + } + } + + function pickPreferredFeature(features: maplibregl.MapGeoJSONFeature[]) { + return [...features].sort((a, b) => featureSelectPriority(b) - featureSelectPriority(a))[0]; + } + + function featureSelectPriority(feature: maplibregl.MapGeoJSONFeature) { + const layerId = typeof feature.layer?.id === "string" ? feature.layer.id : ""; + const geometryType = feature.geometry?.type; + const source = typeof feature.source === "string" ? feature.source : ""; + + if (layerId.endsWith("-hit")) return 400; + if (source === "path-arrow-shapes") return 300; + if (geometryType === "LineString" || geometryType === "MultiLineString") return 200; + if (geometryType === "Point" || geometryType === "MultiPoint") return 100; + return 0; + } + + // Đồng bộ selection state từ React. + function syncSelection(ids: (string | number)[]) { + const nextSet = new Set(ids); + selectedIds.forEach((id) => { + if (!nextSet.has(id)) { + setSelectionStateForId(id, false); + } + }); + selectedIds.clear(); + ids.forEach((id) => { + setSelectionStateForId(id, true); + selectedIds.add(id); + }); + } + + map.on("click", onClick); + map.on("mousemove", onMove); + if (hasContextActions) { + map.on("contextmenu", onRightClick); + } + + const cleanup = () => { + map.off("click", onClick); + map.off("mousemove", onMove); + if (hasContextActions) { + map.off("contextmenu", onRightClick); + } + clearSelection(false); + hideContextMenu(); + }; + + return { + cleanup, + clearSelection, + syncSelection, + }; + + // Ẩn và dọn dẹp context menu hiện tại. + function hideContextMenu() { + if (contextMenu) { + contextMenu.remove(); + contextMenu = null; + } + if (docClickHandler) { + document.removeEventListener("click", docClickHandler); + docClickHandler = null; + } + } + + // Render menu ngữ cảnh tối giản gần vị trí con trỏ. + function showContextMenu( + x: number, + y: number, + clickedFeature: maplibregl.MapGeoJSONFeature, + isRightClickedItemAlreadySelected: boolean, + hasSelection: boolean + ) { + hideContextMenu(); + + const menu = document.createElement("div"); + menu.style.position = "fixed"; + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + menu.style.background = "#0f172a"; + menu.style.color = "white"; + menu.style.border = "1px solid #1f2937"; + menu.style.borderRadius = "6px"; + menu.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)"; + menu.style.zIndex = "9999"; + menu.style.minWidth = "120px"; + menu.style.fontSize = "14px"; + menu.style.padding = "4px 0"; + + // Tạo một item thao tác trong context menu. + const createItem = (label: string, onClick: () => void) => { + const item = document.createElement("div"); + item.textContent = label; + item.style.padding = "8px 12px"; + item.style.cursor = "pointer"; + item.onmouseenter = () => (item.style.background = "#1f2937"); + item.onmouseleave = () => (item.style.background = "transparent"); + item.onclick = () => { + onClick(); + hideContextMenu(); + }; + return item; + }; + + const selectedCount = selectedIds.size; + let hasMenuItems = false; + + const effectiveCount = selectedCount || 1; + const targetId = clickedFeature.id ?? clickedFeature.properties?.id; + const isClickOutsideSelection = !isRightClickedItemAlreadySelected && hasSelection; + + if (isClickOutsideSelection && onBindGeometries && targetId !== undefined && targetId !== null) { + const sourceIds = Array.from(selectedIds); + menu.appendChild( + createItem( + `Bind ${selectedCount} geo đang chọn vào geo này`, + () => { + onBindGeometries(targetId, sourceIds); + } + ) + ); + hasMenuItems = true; + + const separator = document.createElement("div"); + separator.style.height = "1px"; + separator.style.background = "#374151"; + separator.style.margin = "4px 0"; + menu.appendChild(separator); + } + + if (!isClickOutsideSelection) { + if ( + effectiveCount === 1 && + clickedFeature.source === "countries" && + clickedFeature.geometry?.type === "Polygon" && + onEdit + ) { + const single = clickedFeature; + menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single))); + hasMenuItems = true; + } + + if (effectiveCount === 1 && onDuplicate && targetId !== undefined && targetId !== null) { + menu.appendChild(createItem("Duplicate", () => onDuplicate(targetId))); + hasMenuItems = true; + } + + if (effectiveCount === 1 && onHide && targetId !== undefined && targetId !== null) { + menu.appendChild(createItem("Hide", () => onHide(targetId))); + hasMenuItems = true; + } + } + + if (onReplayEdit) { + const replayId = isClickOutsideSelection ? Array.from(selectedIds)[0] : targetId; + if (replayId !== undefined && replayId !== null) { + menu.appendChild( + createItem( + effectiveCount > 1 ? `Vào replay (${effectiveCount} geo)` : "Vào replay", + () => onReplayEdit(replayId) + ) + ); + hasMenuItems = true; + } + } + + if (onDelete) { + menu.appendChild( + createItem( + effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa", + () => { + const ids = selectedIds.size + ? Array.from(selectedIds) + : [targetId]; + ids.forEach((eachId) => { + if (eachId !== undefined && eachId !== null) onDelete(eachId); + }); + clearSelection(); + } + ) + ); + hasMenuItems = true; + } + + if (!hasMenuItems) return; + + document.body.appendChild(menu); + contextMenu = menu; + + // Đóng menu khi click ra ngoài vùng menu. + const onDocClick = (ev: MouseEvent) => { + if (!menu.contains(ev.target as Node)) { + hideContextMenu(); + } + }; + docClickHandler = onDocClick; + setTimeout(() => document.addEventListener("click", onDocClick), 0); + } +} From 282b365287d07e5ff20bc18c8b45425ac4f03d2e Mon Sep 17 00:00:00 2001 From: taDuc Date: Sat, 23 May 2026 12:23:01 +0700 Subject: [PATCH 3/3] refactor: undo feature cover every single part of editor --- src/app/editor/[id]/editorPageUtils.ts | 13 +- src/app/editor/[id]/page.tsx | 226 +- src/app/editor/[id]/page.tsx.orig | 2305 ----------------- src/app/page.tsx | 19 +- src/app/user/projects/page.tsx | 52 +- src/interface/project.ts | 8 +- src/uhm/components/Map.tsx | 27 +- .../editor/EntityWikiBindingsPanel.tsx | 16 +- .../editor/GeometryBindingPanel.tsx | 364 ++- .../editor/ProjectEntityRefsPanel.tsx | 8 +- src/uhm/components/editor/UndoListPanel.tsx | 1 + src/uhm/components/map/useMapInteraction.ts | 12 +- src/uhm/components/map/useMapSync.ts | 52 +- src/uhm/components/ui/TimelineBar.module.css | 12 +- src/uhm/components/ui/TimelineBar.tsx | 19 +- src/uhm/components/wiki/WikiSidebarPanel.tsx | 9 +- src/uhm/doc/commit_snapshot.ts | 19 +- src/uhm/doc/developer_guide.md | 22 +- src/uhm/doc/editor_data_roles.md | 114 + src/uhm/doc/editor_features.md | 32 +- src/uhm/doc/editor_manual_test_checklist.md | 131 + src/uhm/doc/editor_operations.md | 200 ++ src/uhm/doc/editor_replay_actions.md | 194 ++ src/uhm/doc/editor_snapshot_contract.md | 244 ++ src/uhm/doc/editor_state_replay.md | 11 +- src/uhm/doc/editor_states.md | 88 +- src/uhm/doc/export_json_replay.md | 2 + src/uhm/doc/goong_apis_in_use.md | 140 +- src/uhm/doc/goong_map_web_structure.md | 14 +- src/uhm/doc/goong_proxy_backend_guide.md | 178 +- src/uhm/doc/map_engine.md | 59 +- src/uhm/doc/map_styling.md | 23 +- src/uhm/doc/project_workflow.md | 14 +- src/uhm/doc/wiki_system.md | 4 +- src/uhm/lib/editor/draft/editorTypes.ts | 3 +- src/uhm/lib/editor/draft/useDraftState.ts | 6 +- src/uhm/lib/editor/draft/useUndoStack.ts | 4 + .../lib/editor/project/useProjectCommands.ts | 82 +- .../editor/session/useEntitySessionState.ts | 6 +- src/uhm/lib/editor/snapshot/editorSnapshot.ts | 93 +- .../lib/editor/state/useEditorSessionState.ts | 8 +- src/uhm/lib/editor/state/useEditorState.ts | 216 +- .../lib/map/engines/selectingEngine.ts.orig | 357 --- src/uhm/lib/map/styles/shared/pointStyle.ts | 33 +- .../lib/map/styles/shared/styleBuilders.ts | 21 +- src/uhm/lib/utils/timeline.ts | 15 + src/uhm/store/editorStore.tsx | 19 +- 47 files changed, 2184 insertions(+), 3311 deletions(-) delete mode 100644 src/app/editor/[id]/page.tsx.orig create mode 100644 src/uhm/doc/editor_data_roles.md create mode 100644 src/uhm/doc/editor_manual_test_checklist.md create mode 100644 src/uhm/doc/editor_operations.md create mode 100644 src/uhm/doc/editor_replay_actions.md create mode 100644 src/uhm/doc/editor_snapshot_contract.md delete mode 100644 src/uhm/lib/map/engines/selectingEngine.ts.orig diff --git a/src/app/editor/[id]/editorPageUtils.ts b/src/app/editor/[id]/editorPageUtils.ts index dcf6dc7..110e3c6 100644 --- a/src/app/editor/[id]/editorPageUtils.ts +++ b/src/app/editor/[id]/editorPageUtils.ts @@ -3,6 +3,7 @@ import type { EntitySnapshot } from "@/uhm/types/entities"; import type { Feature, Geometry } from "@/uhm/types/geo"; import type { BattleReplay } from "@/uhm/types/projects"; import type { WikiSnapshot } from "@/uhm/types/wiki"; +import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline"; // Giới hạn kích thước panel khi drag resize để tránh layout bị vỡ. export function clampNumber(value: number, min: number, max: number): number { @@ -18,10 +19,10 @@ export function formatCommitTitle(commit: ProjectCommit): string { // Kiểm tra feature có nằm trong năm timeline đang active hay không. export function isFeatureVisibleAtYear(feature: Feature, year: number): boolean { - const start = feature.properties.time_start; - const end = feature.properties.time_end; - if (typeof start === "number" && Number.isFinite(start) && year < start) return false; - if (typeof end === "number" && Number.isFinite(end) && year > end) return false; + const start = normalizeTimelineYearValue(feature.properties.time_start); + const end = normalizeTimelineYearValue(feature.properties.time_end); + if (start !== null && year < start) return false; + if (end !== null && year > end) return false; return true; } @@ -57,8 +58,8 @@ export function normalizeEntitiesForCompare(input: EntitySnapshot[] | null | und source: e.source, name: typeof e.name === "string" ? e.name.trim() : "", description: e.description == null ? null : String(e.description), - time_start: typeof e.time_start === "number" ? e.time_start : null, - time_end: typeof e.time_end === "number" ? e.time_end : null, + time_start: normalizeTimelineYearValue(e.time_start), + time_end: normalizeTimelineYearValue(e.time_end), })) .sort((a, b) => a.id.localeCompare(b.id)); } diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 1eed43c..37e4497 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -52,7 +52,7 @@ import { scaleImageOverlayCoordinatesByFactor, type MapImageOverlay, } from "@/uhm/components/map/imageOverlay"; -import { FIXED_TIMELINE_RANGE, clampYearToFixedRange } from "@/uhm/lib/utils/timeline"; +import { FIXED_TIMELINE_RANGE, clampYearToFixedRange, normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline"; import { useFeatureCommands } from "./featureCommands"; import { deleteSubmission } from "@/uhm/api/projects"; import type { WikiSnapshot } from "@/uhm/types/wiki"; @@ -126,7 +126,7 @@ function EditorPageContent() { const { mode, internalSetMode, - initialData, + baselineFeatureCollection, isSaving, isSubmitting, isOpeningSection, @@ -139,8 +139,8 @@ function EditorPageContent() { baselineSnapshot, entityCatalog, setEntityCatalog, - snapshotEntities, - setSnapshotEntities, + snapshotEntityRows, + setSnapshotEntityRows, entityStatus, setEntityStatus, selectedFeatureIds, @@ -203,7 +203,7 @@ function EditorPageContent() { } = useEditorStore(useShallow((state) => ({ mode: state.mode, internalSetMode: state.setMode, - initialData: state.initialData, + baselineFeatureCollection: state.baselineFeatureCollection, isSaving: state.isSaving, isSubmitting: state.isSubmitting, isOpeningSection: state.isOpeningSection, @@ -216,8 +216,8 @@ function EditorPageContent() { baselineSnapshot: state.baselineSnapshot, entityCatalog: state.entityCatalog, setEntityCatalog: state.setEntityCatalog, - snapshotEntities: state.snapshotEntities, - setSnapshotEntities: state.setSnapshotEntities, + snapshotEntityRows: state.snapshotEntityRows, + setSnapshotEntityRows: state.setSnapshotEntityRows, entityStatus: state.entityStatus, setEntityStatus: state.setEntityStatus, selectedFeatureIds: state.selectedFeatureIds, @@ -284,12 +284,12 @@ function EditorPageContent() { const geoSearchRequestRef = useRef(0); // Refs mirror snapshot arrays để undo callbacks luôn đọc state mới nhất. - const snapshotEntitiesRef = useRef(snapshotEntities); + const snapshotEntityRowsRef = useRef(snapshotEntityRows); const snapshotWikisRef = useRef(snapshotWikis); const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks); useEffect(() => { - snapshotEntitiesRef.current = snapshotEntities; - }, [snapshotEntities]); + snapshotEntityRowsRef.current = snapshotEntityRows; + }, [snapshotEntityRows]); useEffect(() => { snapshotWikisRef.current = snapshotWikis; }, [snapshotWikis]); @@ -298,10 +298,10 @@ function EditorPageContent() { }, [snapshotEntityWikiLinks]); // Hook quản lý draft/changes/undo cho main editor và replay editor. - const editor = useEditorState(initialData, { + const editor = useEditorState(baselineFeatureCollection, { snapshotUndo: { - snapshotEntitiesRef, - setSnapshotEntities, + snapshotEntityRowsRef, + setSnapshotEntityRows, snapshotWikisRef, setSnapshotWikis, snapshotEntityWikiLinksRef, @@ -324,26 +324,39 @@ function EditorPageContent() { }, [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 snapshotEntitiesAsEntities = useMemo(() => { - const rows = snapshotEntities || []; + 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: e.time_start ?? null, - time_end: e.time_end ?? 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); - }, [snapshotEntities]); + }, [snapshotEntityRows]); // Entity list hợp nhất giữa backend catalog và snapshot local. const entities = useMemo( - () => mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities), - [entityCatalog, snapshotEntitiesAsEntities] + () => mergeEntitySearchResults(entityCatalog, snapshotEntityRowsAsEntities), + [entityCatalog, snapshotEntityRowsAsEntities] ); // State vị trí stage/step đang chọn trong replay editor. const [replaySelection, setReplaySelection] = useState<{ @@ -450,7 +463,7 @@ function EditorPageContent() { const localCreatedIds = localCreatedEntityIdsRef.current; if (!localCreatedIds.size) return; - const snapshotIds = new Set((snapshotEntities || []).map((entity) => String(entity.id || ""))); + const snapshotIds = new Set((snapshotEntityRows || []).map((entity) => String(entity.id || ""))); setEntityCatalog((prev) => { let changed = false; const next = (prev || []).filter((entity) => { @@ -465,7 +478,7 @@ function EditorPageContent() { }); return changed ? next : prev; }); - }, [snapshotEntities, setEntityCatalog]); + }, [snapshotEntityRows, setEntityCatalog]); // Clamp năm timeline vào range cố định trước khi đưa vào store. const handleTimelineYearChange = useCallback((nextYear: number) => { @@ -484,33 +497,45 @@ function EditorPageContent() { selectedStepIndex: previewSession?.selectedStepIndex ?? replaySelection.stepIndex, onSelectStep: () => {}, }); + const { + hiddenGeometryIds: replayPreviewHiddenGeometryIds, + timelineYear: replayPreviewTimelineYear, + timelineFilterEnabled: replayPreviewTimelineFilterEnabled, + resetPreview: resetReplayPreview, + playFromSelection: playReplayPreviewFromSelection, + playFromStart: playReplayPreviewFromStart, + activeCursor: replayPreviewActiveCursor, + activeWikiId: replayPreviewActiveWikiId, + sidebarOpen: replayPreviewSidebarOpen, + openWikiPanelById: openReplayPreviewWikiPanelById, + } = 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 || replayPreview.hiddenGeometryIds.length === 0) { + if (!isReplayPreviewMode || replayPreviewHiddenGeometryIds.length === 0) { return sourceDraft; } - const hiddenIds = new Set(replayPreview.hiddenGeometryIds); + const hiddenIds = new Set(replayPreviewHiddenGeometryIds); return { ...sourceDraft, features: sourceDraft.features.filter( (feature) => !hiddenIds.has(String(feature.properties.id)) ), }; - }, [isReplayPreviewMode, previewSession?.draft, replayPreview.hiddenGeometryIds]); + }, [isReplayPreviewMode, previewSession?.draft, replayPreviewHiddenGeometryIds]); const activeTimelineYear = isReplayPreviewMode - ? replayPreview.timelineYear + ? replayPreviewTimelineYear : timelineDraftYear; const activeTimelineFilterEnabled = isReplayPreviewMode - ? replayPreview.timelineFilterEnabled + ? replayPreviewTimelineFilterEnabled : timelineFilterEnabled; - // Timeline filter: only affects persisted snapshot features. + // 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. // New features created in the current session remain visible regardless of time range. - // Draft cuối cùng đưa vào map sau khi áp filter timeline. - const timelineVisibleDraft = useMemo(() => { + const mapRenderDraft = useMemo(() => { const activeDraft = isReplayPreviewMode ? replayPreviewDraft : isReplayEditMode @@ -555,8 +580,8 @@ function EditorPageContent() { const selectedGeometryTime = useMemo(() => { if (!selectedFeature) return null; return { - time_start: selectedFeature.properties.time_start ?? null, - time_end: selectedFeature.properties.time_end ?? null, + time_start: normalizeTimelineYearValue(selectedFeature.properties.time_start), + time_end: normalizeTimelineYearValue(selectedFeature.properties.time_end), }; }, [selectedFeature]); @@ -566,8 +591,8 @@ function EditorPageContent() { for (const [id, change] of editor.changes.entries()) { if (change.action === "create") createdGeometryIds.add(String(id)); } - const timelineVisibleGeometryIds = new Set( - timelineVisibleDraft.features.map((feature) => String(feature.properties.id)) + const mapRenderGeometryIds = new Set( + mapRenderDraft.features.map((feature) => String(feature.properties.id)) ); const rows = (editor.draft.features || []) @@ -575,19 +600,38 @@ function EditorPageContent() { .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})` : f.geometry.type; + 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: f.properties.time_start ?? null, - time_end: f.properties.time_end ?? null, - isTimelineVisible: timelineVisibleGeometryIds.has(id), + 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; - }, [editor, timelineVisibleDraft.features]); + }, [activeTimelineFilterEnabled, editor, mapRenderDraft.features]); // Binding ids của geometry đại diện đang chọn. const selectedGeometryBindingIds = useMemo(() => { @@ -620,13 +664,13 @@ function EditorPageContent() { // Dirty flag cho entity snapshot so với baseline commit. const entitiesDirty = useMemo(() => { const prev = normalizeEntitiesForCompare(baselineSnapshot?.entities); - const next = normalizeEntitiesForCompare(snapshotEntities); + const next = normalizeEntitiesForCompare(snapshotEntityRows); try { return JSON.stringify(prev) !== JSON.stringify(next); } catch { return true; } - }, [baselineSnapshot?.entities, snapshotEntities]); + }, [baselineSnapshot?.entities, snapshotEntityRows]); // Dirty flag cho binding entity-wiki so với baseline commit. const entityWikiDirty = useMemo(() => { @@ -679,11 +723,11 @@ function EditorPageContent() { // Thoát preview và quay về replay edit mode. const exitReplayPreview = useCallback(() => { - replayPreview.resetPreview(); + resetReplayPreview(); setPreviewAutoplayMode(null); setPreviewSession(null); internalSetMode("replay"); - }, [internalSetMode, replayPreview.resetPreview]); + }, [internalSetMode, resetReplayPreview]); // Đóng băng draft/replay hiện tại thành session preview để phát thử. const openReplayPreview = useCallback((autoplayMode: "start" | "selection") => { @@ -722,7 +766,7 @@ function EditorPageContent() { } if (mode === "replay_preview") { - replayPreview.resetPreview(); + resetReplayPreview(); setPreviewAutoplayMode(null); setPreviewSession(null); @@ -763,7 +807,7 @@ function EditorPageContent() { editor, internalSetMode, mode, - replayPreview.resetPreview, + resetReplayPreview, selectedFeatureIds, setHideOutside, setReplayFeatureId, @@ -811,17 +855,17 @@ function EditorPageContent() { useEffect(() => { if (!isReplayPreviewMode || !previewSession || !previewAutoplayMode) return; if (previewAutoplayMode === "selection") { - replayPreview.playFromSelection(); + playReplayPreviewFromSelection(); } else { - replayPreview.playFromStart(); + playReplayPreviewFromStart(); } setPreviewAutoplayMode(null); }, [ isReplayPreviewMode, + playReplayPreviewFromSelection, + playReplayPreviewFromStart, previewAutoplayMode, previewSession, - replayPreview.playFromSelection, - replayPreview.playFromStart, ]); useEffect(() => { @@ -833,29 +877,32 @@ function EditorPageContent() { // Label ngắn cho overlay preview tại step đang phát. const replayPreviewActiveStepLabel = useMemo(() => { if ( - replayPreview.activeCursor.stageId == null || - replayPreview.activeCursor.stepIndex == null + replayPreviewActiveCursor.stageId == null || + replayPreviewActiveCursor.stepIndex == null ) { return null; } - return `Stage #${replayPreview.activeCursor.stageId} · Step ${replayPreview.activeCursor.stepIndex + 1}`; - }, [replayPreview.activeCursor.stageId, replayPreview.activeCursor.stepIndex]); + return `Stage #${replayPreviewActiveCursor.stageId} · Step ${replayPreviewActiveCursor.stepIndex + 1}`; + }, [replayPreviewActiveCursor.stageId, replayPreviewActiveCursor.stepIndex]); - const replayPreviewWikiRows = previewSession?.wikis || []; + const replayPreviewWikiRows = useMemo( + () => previewSession?.wikis || [], + [previewSession?.wikis] + ); // Wiki snapshot đang được step preview yêu cầu mở. const replayPreviewActiveWikiSnapshot = useMemo(() => { - if (!replayPreview.activeWikiId) return null; - return replayPreviewWikiRows.find((item) => item.id === replayPreview.activeWikiId) || null; - }, [replayPreview.activeWikiId, replayPreviewWikiRows]); + if (!replayPreviewActiveWikiId) return null; + return replayPreviewWikiRows.find((item) => item.id === replayPreviewActiveWikiId) || null; + }, [replayPreviewActiveWikiId, replayPreviewWikiRows]); useEffect(() => { - if (!isReplayPreviewMode || !replayPreview.sidebarOpen) { + if (!isReplayPreviewMode || !replayPreviewSidebarOpen) { setPreviewWikiError(null); setIsPreviewWikiLoading(false); return; } - const activeWikiId = String(replayPreview.activeWikiId || "").trim(); + const activeWikiId = String(replayPreviewActiveWikiId || "").trim(); if (!activeWikiId.length) { setPreviewWikiError(null); setIsPreviewWikiLoading(false); @@ -905,8 +952,8 @@ function EditorPageContent() { }, [ isReplayPreviewMode, previewWikiCache, - replayPreview.activeWikiId, - replayPreview.sidebarOpen, + replayPreviewActiveWikiId, + replayPreviewSidebarOpen, replayPreviewWikiRows, ]); @@ -936,8 +983,8 @@ function EditorPageContent() { return; } setPreviewWikiError(null); - replayPreview.openWikiPanelById(match.id); - }, [replayPreview.openWikiPanelById, replayPreviewWikiRows]); + openReplayPreviewWikiPanelById(match.id); + }, [openReplayPreviewWikiPanelById, replayPreviewWikiRows]); // Visibility cuối cùng theo type/layer, có override riêng cho replay edit/preview. const effectiveGeometryVisibility = useMemo(() => { @@ -1259,12 +1306,12 @@ function EditorPageContent() { useEffect(() => { if (!selectedFeatureIds || selectedFeatureIds.length === 0) return; const stillExistIds = selectedFeatureIds.filter(id => - timelineVisibleDraft.features.some(feature => String(feature.properties.id) === String(id)) + editor.draft.features.some(feature => String(feature.properties.id) === String(id)) ); if (stillExistIds.length !== selectedFeatureIds.length) { setSelectedFeatureIds(stillExistIds); } - }, [timelineVisibleDraft, selectedFeatureIds, setSelectedFeatureIds]); + }, [editor.draft.features, selectedFeatureIds, setSelectedFeatureIds]); useEffect(() => { if (!selectedFeature) { @@ -1285,15 +1332,13 @@ function EditorPageContent() { ? 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: selectedFeature.properties.time_start != null - ? String(selectedFeature.properties.time_start) - : "", - time_end: selectedFeature.properties.time_end != null - ? String(selectedFeature.properties.time_end) - : "", + time_start: timeStart != null ? String(timeStart) : "", + time_end: timeEnd != null ? String(timeEnd) : "", binding: normalizeFeatureBindingIds(selectedFeature).join(", "), }); // Only clear status when switching to a different geometry, not when patching metadata/bindings @@ -1348,7 +1393,7 @@ function EditorPageContent() { const handleAddEntityRefToProject = useCallback((entity: Entity) => { const id = String(entity.id || "").trim(); if (!id) return; - editor.setSnapshotEntities((prev) => { + editor.setSnapshotEntityRows((prev) => { if (prev.some((e) => String(e.id) === id)) return prev; return [ { @@ -1357,8 +1402,8 @@ function EditorPageContent() { operation: "reference", name: entity.name, description: entity.description ?? null, - time_start: entity.time_start ?? null, - time_end: entity.time_end ?? null, + time_start: normalizeTimelineYearValue(entity.time_start), + time_end: normalizeTimelineYearValue(entity.time_end), }, ...prev, ]; @@ -1399,7 +1444,7 @@ function EditorPageContent() { return; } - editor.setSnapshotEntities((prev) => prev.map((e) => { + editor.setSnapshotEntityRows((prev) => prev.map((e) => { if (!e || String(e.id) !== id) return e; const source = e.source === "inline" ? "inline" : "ref"; const operation = @@ -1583,8 +1628,8 @@ function EditorPageContent() { return; } - const geoTimeStart = feature.properties.time_start; - if (typeof geoTimeStart === "number" && Number.isFinite(geoTimeStart)) { + const geoTimeStart = normalizeTimelineYearValue(feature.properties.time_start); + if (geoTimeStart !== null) { setTimelineDraftYear(clampYearToFixedRange(Math.trunc(geoTimeStart))); } @@ -1756,8 +1801,8 @@ function EditorPageContent() { properties: { id: geoId, type: typeKey, - time_start: typeof geo.time_start === "number" ? geo.time_start : null, - time_end: typeof geo.time_end === "number" ? geo.time_end : null, + time_start: normalizeTimelineYearValue(geo.time_start), + time_end: normalizeTimelineYearValue(geo.time_end), binding: bindingIds.length ? bindingIds : undefined, entity_id: entityItem.entity_id, entity_ids: [entityItem.entity_id], @@ -1767,7 +1812,7 @@ function EditorPageContent() { geometry, }; - editor.createFeatureWithSnapshotEntities( + editor.createFeatureWithSnapshotEntityRows( feature, (prev) => { if (prev.some((e) => String(e.id) === importedEntity.id)) return prev; @@ -1859,7 +1904,7 @@ function EditorPageContent() { setIsEntitySubmitting(true); setEntityFormStatus(null); try { - editor.setSnapshotEntities((prev) => { + editor.setSnapshotEntityRows((prev) => { if (prev.some((e) => String(e.id) === entityId)) return prev; return [ { @@ -1910,13 +1955,15 @@ function EditorPageContent() { setSelectedFeatureIds([feature.properties.id]); }; - // Draft nguồn dùng để render label trong map khi preview đang dùng draft đóng băng. - const mapLabelSourceDraft = isReplayPreviewMode + // Base draft for label lookup only. It must not decide which geometry is rendered. + const labelContextBaseDraft = isReplayPreviewMode ? previewSession?.draft || EMPTY_FEATURE_COLLECTION : editor.draft; + // Enriched label context may contain geometries that mapRenderDraft filtered out. + // Map rendering must still use mapRenderDraft above. const mapLabelContextDraft = useMemo( - () => buildEntityLabelContextDraft(mapLabelSourceDraft, entities), - [entities, mapLabelSourceDraft] + () => buildEntityLabelContextDraft(labelContextBaseDraft, entities), + [entities, labelContextBaseDraft] ); return ( @@ -2033,7 +2080,7 @@ function EditorPageContent() { ref={mapHandleRef} mode={mode} onSetMode={setMode} - draft={timelineVisibleDraft} + renderDraft={mapRenderDraft} labelContextDraft={mapLabelContextDraft} labelTimelineYear={activeTimelineFilterEnabled ? activeTimelineYear : null} selectedFeatureIds={selectedFeatureIds} @@ -2050,7 +2097,7 @@ function EditorPageContent() { onUpdateFeature={editor.updateFeature} backgroundVisibility={backgroundVisibility} geometryVisibility={effectiveGeometryVisibility} - respectBindingFilter={isReplayEditMode || isReplayPreviewMode ? false : geometryBindingFilterEnabled} + applyGeometryBindingFilter={isReplayEditMode || isReplayPreviewMode ? false : geometryBindingFilterEnabled} highlightFeatures={null} focusFeatureCollection={geometryFocusRequest?.collection || null} focusRequestKey={geometryFocusRequest?.key ?? null} @@ -2193,6 +2240,7 @@ function EditorPageContent() { candidate !== null); diff --git a/src/app/editor/[id]/page.tsx.orig b/src/app/editor/[id]/page.tsx.orig deleted file mode 100644 index 4682fcf..0000000 --- a/src/app/editor/[id]/page.tsx.orig +++ /dev/null @@ -1,2305 +0,0 @@ -"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 ReplayPreviewOverlay from "@/uhm/components/editor/ReplayPreviewOverlay"; -import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar"; -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 { fetchWikiById, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis"; -import { searchGeometriesByEntityName, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries"; -import { - Feature, - FeatureCollection, - useEditorState, -} from "@/uhm/lib/editor/state/useEditorState"; -import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes"; -import { - getDefaultTypeIdForFeature, - normalizeFeatureBindingIds, - normalizeFeatureEntityIds, - uniqueEntityIds, -} from "@/uhm/lib/editor/snapshot/editorSnapshot"; -import { - buildClientEntityId, - mergeEntitySearchResults, -} from "@/uhm/lib/editor/entity/entityBinding"; -import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding"; -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 } from "@/uhm/lib/utils/timeline"; -import { useFeatureCommands } from "./featureCommands"; -import { deleteSubmission } from "@/uhm/api/projects"; -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 "./EditorSearchResults"; -import { ResizeHandle } from "./ResizeHandle"; -import { - clampNumber, - formatCommitTitle, - isFeatureVisibleAtYear, - normalizeEntitiesForCompare, - normalizeEntityWikiLinksForCompare, - normalizeGeoSearchBindingIds, - normalizeGeoSearchGeometry, - normalizeReplaysForCompare, - normalizeWikisForCompare, -} from "./editorPageUtils"; - -const CURRENT_YEAR = new Date().getUTCFullYear(); -const DEFAULT_EDITOR_USER_ID = "local-editor"; - -type ReplayPreviewSession = { - replay: BattleReplay; - draft: FeatureCollection; - wikis: WikiSnapshot[]; - selectedStageId: number | null; - selectedStepIndex: number | null; - timelineYear: number; - timelineFilterEnabled: boolean; - mapViewState: ReturnType; -}; - -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); - // State chính của editor nằm trong zustand store để các panel con đọc cùng source-of-truth. - const { - mode, - internalSetMode, - initialData, - isSaving, - isSubmitting, - isOpeningSection, - setIsOpeningSection, - commitTitle, - setCommitTitle, - activeSection, - projectState, - sectionCommits, - baselineSnapshot, - entityCatalog, - setEntityCatalog, - snapshotEntities, - setSnapshotEntities, - 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, - initialData: state.initialData, - 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, - snapshotEntities: state.snapshotEntities, - setSnapshotEntities: state.setSnapshotEntities, - 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 snapshotEntitiesRef = useRef(snapshotEntities); - const snapshotWikisRef = useRef(snapshotWikis); - const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks); - useEffect(() => { - snapshotEntitiesRef.current = snapshotEntities; - }, [snapshotEntities]); - 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(initialData, { - snapshotUndo: { - snapshotEntitiesRef, - setSnapshotEntities, - 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] - ); - // Chuyển entity snapshot local thành entity catalog row để search/binding dùng chung. - const snapshotEntitiesAsEntities = useMemo(() => { - const rows = snapshotEntities || []; - 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: e.time_start ?? null, - time_end: e.time_end ?? null, - geometry_count: 0, - })) - .filter((e) => e.id.length > 0 && e.name.length > 0); - }, [snapshotEntities]); - - // Entity list hợp nhất giữa backend catalog và snapshot local. - const entities = useMemo( - () => mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities), - [entityCatalog, snapshotEntitiesAsEntities] - ); - // 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); - // Cache wiki đã fetch trong preview để không gọi API lặp lại. - const [previewWikiCache, setPreviewWikiCache] = useState>({}); - // State lỗi riêng cho wiki preview sidebar. - const [previewWikiError, setPreviewWikiError] = useState(null); - // State loading riêng cho wiki preview sidebar. - const [isPreviewWikiLoading, setIsPreviewWikiLoading] = 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 isReplayEditMode = mode === "replay"; - const isReplayPreviewMode = mode === "replay_preview"; - // 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((snapshotEntities || []).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; - }); - }, [snapshotEntities, 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]); - - // 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: () => {}, - }); - - // 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 || replayPreview.hiddenGeometryIds.length === 0) { - return sourceDraft; - } - const hiddenIds = new Set(replayPreview.hiddenGeometryIds); - return { - ...sourceDraft, - features: sourceDraft.features.filter( - (feature) => !hiddenIds.has(String(feature.properties.id)) - ), - }; - }, [isReplayPreviewMode, previewSession?.draft, replayPreview.hiddenGeometryIds]); - - const activeTimelineYear = isReplayPreviewMode - ? replayPreview.timelineYear - : timelineDraftYear; - const activeTimelineFilterEnabled = isReplayPreviewMode - ? replayPreview.timelineFilterEnabled - : timelineFilterEnabled; - - // Timeline filter: only affects persisted snapshot features. - // New features created in the current session remain visible regardless of time range. - // Draft cuối cùng đưa vào map sau khi áp filter timeline. - const timelineVisibleDraft = useMemo(() => { - const activeDraft = isReplayPreviewMode - ? replayPreviewDraft - : isReplayEditMode - ? editor.replayDraft - : editor.mainDraft; - - if (!activeTimelineFilterEnabled) return activeDraft; - const year = clampYearToFixedRange(Math.trunc(activeTimelineYear)); - return { - ...activeDraft, - features: activeDraft.features.filter((feature) => { - if (!editor.hasPersistedFeature(feature.properties.id)) return true; - return isFeatureVisibleAtYear(feature, year); - }), - }; - }, [ - activeTimelineFilterEnabled, - activeTimelineYear, - editor, - isReplayEditMode, - isReplayPreviewMode, - replayPreviewDraft, - ]); - - // 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: selectedFeature.properties.time_start ?? null, - time_end: selectedFeature.properties.time_end ?? null, - }; - }, [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 timelineVisibleGeometryIds = new Set( - timelineVisibleDraft.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})` : f.geometry.type; - return { - id, - label, - time_start: f.properties.time_start ?? null, - time_end: f.properties.time_end ?? null, - isTimelineVisible: timelineVisibleGeometryIds.has(id), - isNew: createdGeometryIds.has(id) || !editor.hasPersistedFeature(f.properties.id), - }; - }); - rows.sort((a, b) => a.id.localeCompare(b.id)); - return rows; - }, [editor, timelineVisibleDraft.features]); - - // Binding ids của geometry đại diện đang chọn. - const selectedGeometryBindingIds = useMemo(() => { - if (!selectedFeature) return []; - return normalizeFeatureBindingIds(selectedFeature); - }, [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"} (${String(wiki.id || "")})`, - })) - .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(snapshotEntities); - try { - return JSON.stringify(prev) !== JSON.stringify(next); - } catch { - return true; - } - }, [baselineSnapshot?.entities, snapshotEntities]); - - // 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; - - // Thoát preview và quay về replay edit mode. - const exitReplayPreview = useCallback(() => { - replayPreview.resetPreview(); - setPreviewAutoplayMode(null); - setPreviewSession(null); - internalSetMode("replay"); - }, [internalSetMode, replayPreview.resetPreview]); - - // Đó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; - - setPreviewSession({ - replay: deepClone(editor.activeReplayDraft), - draft: deepClone(editor.replayDraft), - wikis: deepClone(snapshotWikis), - selectedStageId: replaySelection.stageId, - selectedStepIndex: replaySelection.stepIndex, - timelineYear: timelineDraftYear, - timelineFilterEnabled, - mapViewState: getCurrentMapViewState(), - }); - setPreviewAutoplayMode(autoplayMode); - setSelectedFeatureIds([]); - internalSetMode("replay_preview"); - }, [ - editor.activeReplayDraft, - editor.replayDraft, - getCurrentMapViewState, - internalSetMode, - replaySelection.stageId, - replaySelection.stepIndex, - setSelectedFeatureIds, - snapshotWikis, - timelineDraftYear, - timelineFilterEnabled, - ]); - - // State machine chuyển mode editor, xử lý riêng replay/replay_preview để không mất draft. - const setMode = useCallback((m: EditorMode, featureId?: string | number) => { - if (m === "replay_preview") { - return; - } - - if (mode === "replay_preview") { - replayPreview.resetPreview(); - setPreviewAutoplayMode(null); - setPreviewSession(null); - - 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 triggerId = selectedFeatureIds.length > 0 ? selectedFeatureIds[0] : featureId; - setReplayFeatureId(triggerId); - setReplaySelection({ stageId: null, stepIndex: null }); - editor.switchReplayContext(triggerId, selectedFeatureIds); - 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, - replayPreview.resetPreview, - 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]); - - useEffect(() => { - if (!isReplayPreviewMode || !previewSession || !previewAutoplayMode) return; - if (previewAutoplayMode === "selection") { - replayPreview.playFromSelection(); - } else { - replayPreview.playFromStart(); - } - setPreviewAutoplayMode(null); - }, [ - isReplayPreviewMode, - previewAutoplayMode, - previewSession, - replayPreview.playFromSelection, - replayPreview.playFromStart, - ]); - - useEffect(() => { - setPreviewWikiCache({}); - setPreviewWikiError(null); - setIsPreviewWikiLoading(false); - }, [previewSession]); - - // Label ngắn cho overlay preview tại step đang phát. - const replayPreviewActiveStepLabel = useMemo(() => { - if ( - replayPreview.activeCursor.stageId == null || - replayPreview.activeCursor.stepIndex == null - ) { - return null; - } - return `Stage #${replayPreview.activeCursor.stageId} · Step ${replayPreview.activeCursor.stepIndex + 1}`; - }, [replayPreview.activeCursor.stageId, replayPreview.activeCursor.stepIndex]); - - const replayPreviewWikiRows = previewSession?.wikis || []; - // Wiki snapshot đang được step preview yêu cầu mở. - const replayPreviewActiveWikiSnapshot = useMemo(() => { - if (!replayPreview.activeWikiId) return null; - return replayPreviewWikiRows.find((item) => item.id === replayPreview.activeWikiId) || null; - }, [replayPreview.activeWikiId, replayPreviewWikiRows]); - - useEffect(() => { - if (!isReplayPreviewMode || !replayPreview.sidebarOpen) { - setPreviewWikiError(null); - setIsPreviewWikiLoading(false); - return; - } - - const activeWikiId = String(replayPreview.activeWikiId || "").trim(); - if (!activeWikiId.length) { - setPreviewWikiError(null); - setIsPreviewWikiLoading(false); - return; - } - - const localWiki = replayPreviewWikiRows.find((item) => item.id === activeWikiId) || null; - if (!localWiki) { - setPreviewWikiError("Không tìm thấy wiki trong snapshot preview."); - setIsPreviewWikiLoading(false); - return; - } - - if (typeof localWiki.doc === "string") { - setPreviewWikiError(null); - setIsPreviewWikiLoading(false); - return; - } - - if (previewWikiCache[activeWikiId]) { - setPreviewWikiError(null); - setIsPreviewWikiLoading(false); - return; - } - - let disposed = false; - setPreviewWikiError(null); - setIsPreviewWikiLoading(true); - void fetchWikiById(activeWikiId) - .then((row) => { - if (disposed) return; - setPreviewWikiCache((prev) => ({ ...prev, [activeWikiId]: row })); - }) - .catch((err) => { - if (disposed) return; - setPreviewWikiError(err instanceof Error ? err.message : "Không tải được wiki preview."); - }) - .finally(() => { - if (!disposed) { - setIsPreviewWikiLoading(false); - } - }); - - return () => { - disposed = true; - }; - }, [ - isReplayPreviewMode, - previewWikiCache, - replayPreview.activeWikiId, - replayPreview.sidebarOpen, - replayPreviewWikiRows, - ]); - - // Wiki đầy đủ cho sidebar preview, ưu tiên doc có sẵn trong snapshot rồi mới dùng cache API. - const replayPreviewActiveWiki = useMemo(() => { - const snapshotWiki = replayPreviewActiveWikiSnapshot; - if (!snapshotWiki) return null; - if (typeof snapshotWiki.doc === "string") { - return { - id: snapshotWiki.id, - project_id: projectId, - title: snapshotWiki.title, - slug: snapshotWiki.slug ?? null, - content: snapshotWiki.doc || "", - }; - } - return previewWikiCache[snapshotWiki.id] || null; - }, [previewWikiCache, projectId, replayPreviewActiveWikiSnapshot]); - - // Điều hướng link wiki nội bộ trong preview nhưng chỉ trong phạm vi snapshot preview. - const handleReplayPreviewWikiLinkRequest = useCallback(({ slug }: { slug: string; rect: DOMRect }) => { - const nextSlug = String(slug || "").trim(); - if (!nextSlug.length) return; - const match = replayPreviewWikiRows.find((item) => String(item.slug || "").trim() === nextSlug) || null; - if (!match) { - setPreviewWikiError(`Wiki /wiki/${nextSlug} không có trong snapshot preview.`); - return; - } - setPreviewWikiError(null); - replayPreview.openWikiPanelById(match.id); - }, [replayPreview.openWikiPanelById, replayPreviewWikiRows]); - - // Visibility cuối cùng theo type/layer, có override riêng cho replay edit/preview. - const effectiveGeometryVisibility = useMemo(() => { - const visibility: Record = { ...geometryVisibility }; - - if ((isReplayEditMode || isReplayPreviewMode) && replayFeatureId) { - // Ẩn chính geo được chọn làm replay (marker kịch bản) - visibility[String(replayFeatureId)] = false; - - if (isReplayEditMode && hideOutside) { - // Trong mode replay, ta chỉ hiển thị những gì có trong draft của replay đó - const currentReplayFeatureIds = new Set(editor.draft.features.map(f => String(f.properties.id))); - - // Ẩn tất cả các geo KHÔNG nằm trong draft replay hiện tại - Object.keys(visibility).forEach(fid => { - if (fid === String(replayFeatureId)) { - visibility[fid] = false; - } else { - visibility[fid] = currentReplayFeatureIds.has(fid); - } - }); - } - } - - return visibility; - }, [ - editor.draft.features, - geometryVisibility, - hideOutside, - isReplayEditMode, - isReplayPreviewMode, - replayFeatureId, - ]); - - // 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("Xoa submission PENDING de unlock editor? Hanh dong nay khong the hoan tac."); - 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 stillExistIds = selectedFeatureIds.filter(id => - timelineVisibleDraft.features.some(feature => String(feature.properties.id) === String(id)) - ); - if (stillExistIds.length !== selectedFeatureIds.length) { - setSelectedFeatureIds(stillExistIds); - } - }, [timelineVisibleDraft, selectedFeatureIds, setSelectedFeatureIds]); - - useEffect(() => { - if (!selectedFeature) { - setSelectedGeometryEntityIds([]); - setGeometryMetaForm({ - type_key: "", - time_start: "", - time_end: "", - binding: "", - }); - 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); - setSelectedGeometryEntityIds(featureEntityIds); - setGeometryMetaForm({ - type_key: nextTypeKey, - 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(", "), - }); - // 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.setSnapshotEntities((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: entity.time_start ?? null, - time_end: entity.time_end ?? null, - }, - ...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.setSnapshotEntities((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, - ]); - - // Bind/unbind geometry id vào trường binding của selected geometry. - const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => { - if (!selectedFeatures || selectedFeatures.length === 0) { - flashGeoBindingStatus("Chưa chọn geometry để bind."); - 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; - if (selectedFeatures.some(f => String(f.properties.id) === id)) return; - - - - setIsEntitySubmitting(true); - flashGeoBindingStatus(null, 0); - try { - const bindingPatches = selectedFeatures.map((feature) => { - const prevBindingIds = normalizeFeatureBindingIds(feature); - const has = prevBindingIds.includes(id); - const nextBindingIds = (() => { - if (nextChecked) { - if (has) return prevBindingIds; - return [...prevBindingIds, id]; - } - if (!has) return prevBindingIds; - return prevBindingIds.filter((x) => x !== id); - })(); - return { - id: feature.properties.id, - patch: { binding: nextBindingIds }, - }; - }); - editor.patchFeaturePropertiesBatch( - bindingPatches, - nextChecked ? "Bind geometry vào GEO" : "Unbind geometry khỏi GEO" - ); - - // Assume selectedFeature (the first one) reflects the representative binding in UI - const firstFeaturePrevBindings = normalizeFeatureBindingIds(selectedFeatures[0]); - const firstFeatureHas = firstFeaturePrevBindings.includes(id); - const nextBindingIdsForUI = (() => { - if (nextChecked) return firstFeatureHas ? firstFeaturePrevBindings : [...firstFeaturePrevBindings, id]; - return firstFeatureHas ? firstFeaturePrevBindings.filter(x => x !== id) : firstFeaturePrevBindings; - })(); - setGeometryMetaForm((prev) => ({ ...prev, binding: nextBindingIdsForUI.join(", ") })); - flashGeoBindingStatus( - nextChecked - ? "Đã bind geometry vào binding. Commit khi sẵn sàng." - : "Đã gỡ binding geometry. Commit khi sẵn sàng.", - 3000 - ); - } finally { - setIsEntitySubmitting(false); - } - }, [ - editor, - flashGeoBindingStatus, - selectedFeatures, - isMultiEditValid, - setGeometryMetaForm, - setIsEntitySubmitting, - ]); - - // Bind nhiều geometries 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 prevBindingIds = normalizeFeatureBindingIds(targetFeature); - - // Merge prevBindingIds with sourceIds (which are strings of selected features) - // filter out targetId itself (we can't bind a geometry to itself) - const newSources = sourceIds.map(String).filter((x) => x !== idStr); - const merged = Array.from(new Set([...prevBindingIds, ...newSources])); - - editor.patchFeaturePropertiesBatch( - [{ - id: targetFeature.properties.id, - patch: { binding: merged }, - }], - "Bind các geometry đã chọn vào GEO" - ); - - setSelectedFeatureIds([targetFeature.properties.id]); - flashGeoBindingStatus(`Đã bind ${newSources.length} geometry vào GEO này. 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 = feature.properties.time_start; - if (typeof geoTimeStart === "number" && Number.isFinite(geoTimeStart)) { - 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; - - // Ensure the geometry stays selectable even if it doesn't match the current timeline year. - setTimelineFilterEnabled(false); - - 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 bindingIds = normalizeGeoSearchBindingIds(geo.binding); - const typeKey = geo.type || null; - - const feature: Feature = { - type: "Feature", - properties: { - id: geoId, - type: typeKey, - time_start: typeof geo.time_start === "number" ? geo.time_start : null, - time_end: typeof geo.time_end === "number" ? geo.time_end : null, - binding: bindingIds.length ? bindingIds : undefined, - 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.createFeatureWithSnapshotEntities( - 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, - setTimelineFilterEnabled, - ]); - - // Commands thao tác metadata/entity binding cho feature đang chọn. - const featureCommands = useFeatureCommands({ - editor, - selectedFeatures, - geometryMetaForm, - setGeometryMetaForm, - selectedGeometryEntityIds, - setSelectedGeometryEntityIds, - entities, - setIsEntitySubmitting, - setEntityFormStatus, - }); - - // 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.setSnapshotEntities((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; - - // Tạo geometry từ map engine rồi select ngay geometry mới. - const handleCreateFeature = (feature: Feature) => { - editor.createFeature(feature); - setSelectedFeatureIds([feature.properties.id]); - }; - - // Draft nguồn dùng để render label trong map khi preview đang dùng draft đóng băng. - const mapLabelSourceDraft = isReplayPreviewMode - ? previewSession?.draft || EMPTY_FEATURE_COLLECTION - : editor.draft; - const mapLabelContextDraft = useMemo( - () => buildEntityLabelContextDraft(mapLabelSourceDraft, entities), - [entities, mapLabelSourceDraft] - ); - - return ( -
- {!isReplayEditMode && !isReplayPreviewMode ? ( - <> - - - { - 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} - - {blockedPendingSubmissionId ? ( -
-
-

Editor dang bi khoa

-
- Project nay dang co submission o trang thai PENDING (id:{" "} - {blockedPendingSubmissionId}). Theo BE moi, khi - submission dang pending thi khong duoc tao submission/commit moi va khong duoc vao editor. -
-
- - -
-
-
- ) : null} - - {!blockedPendingSubmissionId ? ( -
- {isBackgroundVisibilityReady ? ( - - ) : ( -
- )} - {isReplayPreviewMode ? ( - - ) : null} - {isReplayPreviewMode && replayPreview.sidebarOpen ? ( - - ) : null} - {!isReplayPreviewMode || replayPreview.timelineVisible ? ( - - ) : null} -
- ) : null} - - {!isReplayEditMode && !isReplayPreviewMode ? ( - <> - { - // dragging handle (between map and right panel): moving right increases right panel width - 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} - /> - - - - - - - - - {selectedFeature ? ( - setMode("replay", id)} - /> - ) : null} -
- } - /> - - ) : isReplayEditMode ? ( - <> - { - setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720)); - }} - /> - String(id))} - currentTimelineYear={timelineDraftYear} - geometryChoices={geometryChoices} - wikiChoices={wikiChoices} - getCurrentMapViewState={getCurrentMapViewState} - onMutateReplay={editor.mutateActiveReplay} - /> - - ) : null} -
- ); -} - -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 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: entity?.time_start ?? null, - time_end: entity?.time_end ?? null, - }; - }).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 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; -} diff --git a/src/app/page.tsx b/src/app/page.tsx index 27f6d97..2409df0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -47,6 +47,8 @@ type LinkEntityPopupState = { left: number; }; +type CachedWiki = Wiki & { __fetched?: boolean }; + const EMPTY_RELATIONS: RelationIndex = { entitiesById: {}, entityGeometriesById: {}, @@ -84,7 +86,7 @@ export default function Page() { const [isMapLayersCollapsed, setIsMapLayersCollapsed] = useState(false); const [activeEntityId, setActiveEntityId] = useState(null); const [activeWikiSlug, setActiveWikiSlug] = useState(null); - const [wikiCache, setWikiCache] = useState>({}); + const [wikiCache, setWikiCache] = useState>({}); const [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false); const [activeWikiError, setActiveWikiError] = useState(null); const [linkEntityPopup, setLinkEntityPopup] = useState(null); @@ -123,13 +125,6 @@ export default function Page() { const hoverPopupHoveredRef = useRef(false); const linkEntityPopupRef = useRef(null); - const selectedFeature = useMemo(() => { - if (!selectedFeatureIds || selectedFeatureIds.length === 0) return null; - return ( - data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureIds[0])) || null - ); - }, [data.features, selectedFeatureIds]); - useEffect(() => { if (!selectedFeatureIds || selectedFeatureIds.length === 0) return; const stillExistIds = selectedFeatureIds.filter(id => @@ -416,7 +411,7 @@ export default function Page() { }; }, [linkEntityPopup]); - const cachedWiki = activeWikiSlug ? (wikiCache[activeWikiSlug] as Wiki & { __fetched?: boolean }) : undefined; + const cachedWiki = activeWikiSlug ? wikiCache[activeWikiSlug] : undefined; useEffect(() => { if (!activeWikiSlug) { @@ -459,7 +454,7 @@ export default function Page() { if (disposed) return; setWikiCache((prev) => ({ ...prev, - [activeWikiSlug]: { ...row, content: versionContent, __fetched: true } as any, + [activeWikiSlug]: { ...row, content: versionContent, __fetched: true }, })); } else { setWikiCache((prev) => ({ @@ -525,7 +520,7 @@ export default function Page() { {isBackgroundVisibilityReady ? ( { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function extractProjectCommitList(value: unknown): ProjectCommit[] { + let rows: unknown[] = []; + if (Array.isArray(value)) { + rows = value; + } else if (isRecord(value)) { + if (Array.isArray(value.items)) { + rows = value.items; + } else if (Array.isArray(value.data)) { + rows = value.data; + } else if (isRecord(value.data) && Array.isArray(value.data.items)) { + rows = value.data.items; + } + } + return rows.filter((row): row is ProjectCommit => isRecord(row) && typeof row.id === "string"); +} + export default function ProjectsPage() { const router = useRouter(); const [projects, setProjects] = useState([]); @@ -101,10 +120,11 @@ export default function ProjectsPage() { // Bước 2: Nếu có snapshot, tạo commit ban đầu từ JSON if (importSnapshot) { + const snapshot = toApiEditorSnapshot(importSnapshot); await apiCreateProjectCommit(projectId, { edit_summary: `Init project from ${importSnapshotName || "JSON"}`, - snapshot_json: importSnapshot as any, - } as any); + snapshot_json: snapshot, + }); toast.success("Tạo dự án từ JSON thành công!"); } else { toast.success("Tạo dự án mới thành công!"); @@ -138,11 +158,10 @@ export default function ProjectsPage() { } setIsExportingProjectId(projectId); try { - const res: any = await apiGetProjectCommits(projectId); - const rawList = res?.data?.items ?? res?.data ?? res?.items ?? []; - const commits = Array.isArray(rawList) ? rawList : []; + const res = await apiGetProjectCommits(projectId); + const commits = extractProjectCommitList(res); const head = - commits.find((c: any) => String(c?.id || "") === headCommitId) || null; + commits.find((c) => String(c.id || "") === headCommitId) || null; const snapshot = head?.snapshot_json ?? null; if (!snapshot) { toast.error("Không tìm thấy snapshot_json của head commit."); @@ -200,12 +219,9 @@ export default function ProjectsPage() { } }; - const sortedProjects = [...projects].sort((a: any, b: any) => { - let valA = a[sortBy]; - let valB = b[sortBy]; - - if (!valA) valA = ""; - if (!valB) valB = ""; + const sortedProjects = [...projects].sort((a, b) => { + const valA = String(a[sortBy] || ""); + const valB = String(b[sortBy] || ""); if (valA < valB) return sortOrder === "asc" ? -1 : 1; if (valA > valB) return sortOrder === "asc" ? 1 : -1; @@ -331,7 +347,7 @@ export default function ProjectsPage() {
- {sortedProjects.map((project: any) => ( + {sortedProjects.map((project) => (
{project.members .slice(0, 4) - .map((m: any, index: number) => + .map((m: ProjectMember, index: number) => m.avatar_url ? ( ; members?: ProjectMember[]; @@ -63,7 +65,7 @@ export interface GetProjectsParams { } export interface CreateCommitPayload { edit_summary: string; - snapshot_json: number[]; + snapshot_json: EditorSnapshot; } export interface RestoreCommitPayload { commit_id: string; diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index 707df34..b0fc372 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -33,12 +33,15 @@ export type MapHandle = { type MapProps = { mode: EditorMode; - draft: FeatureCollection; + // FeatureCollection that should actually be rendered/interacted with on the map. + // Callers should apply timeline/replay filters before passing it here. + renderDraft: FeatureCollection; backgroundVisibility: BackgroundLayerVisibility; geometryVisibility?: Record; selectedFeatureIds: (string | number)[]; onSelectFeatureIds: (ids: (string | number)[]) => void; onSetMode?: (mode: EditorMode, featureId?: string | number) => void; + // Label lookup context only. It may include non-rendered geometries for entity label resolution. labelContextDraft?: FeatureCollection; labelTimelineYear?: number | null; onCreateFeature?: (feature: FeatureCollection["features"][number]) => void; @@ -46,7 +49,7 @@ type MapProps = { onHideFeature?: (id: string | number) => void; onUpdateFeature?: (id: string | number, geometry: Geometry) => void; allowGeometryEditing?: boolean; - respectBindingFilter?: boolean; + applyGeometryBindingFilter?: boolean; height?: CSSProperties["height"]; fitToDraftBounds?: boolean; fitBoundsKey?: string | number | null; @@ -63,7 +66,7 @@ type MapProps = { const Map = forwardRef(function Map({ mode, onSetMode, - draft, + renderDraft, backgroundVisibility, geometryVisibility, selectedFeatureIds, @@ -75,7 +78,7 @@ const Map = forwardRef(function Map({ onHideFeature, onUpdateFeature, allowGeometryEditing = true, - respectBindingFilter = true, + applyGeometryBindingFilter = true, height = "100vh", fitToDraftBounds = false, fitBoundsKey = null, @@ -90,8 +93,8 @@ const Map = forwardRef(function Map({ }, ref) { // Ref giữ mode mới nhất cho MapLibre handlers được register một lần. const modeRef = useRef(mode); - // Ref giữ draft mới nhất để engine đọc không bị stale closure. - const draftRef = useRef(draft); + // Ref giữ render draft mới nhất để map engines đọc không bị stale closure. + const renderDraftRef = useRef(renderDraft); // Ref callback select feature mới nhất cho event click trên map. const onSelectFeatureIdsRef = useRef(onSelectFeatureIds); // Ref callback đổi mode mới nhất, dùng khi map interaction chuyển sang replay/select. @@ -114,7 +117,7 @@ const Map = forwardRef(function Map({ const onBindGeometriesRef = useRef(onBindGeometries); useEffect(() => { modeRef.current = mode; }, [mode]); - useEffect(() => { draftRef.current = draft; }, [draft]); + useEffect(() => { renderDraftRef.current = renderDraft; }, [renderDraft]); useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]); useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]); useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]); @@ -159,7 +162,7 @@ const Map = forwardRef(function Map({ mapRef, mode, modeRef, - draftRef, + renderDraftRef, allowGeometryEditing, selectedFeatureIds, onSelectFeatureIdsRef, @@ -174,19 +177,19 @@ const Map = forwardRef(function Map({ // Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer. const { - applyDraftToMap, + applyRenderDraftToMap, applyHighlightToMap, applyImageOverlayToMap, tryCenterToUserLocation, } = useMapSync({ mapRef, - draft, + renderDraft, labelContextDraft, labelTimelineYear, backgroundVisibility, geometryVisibility, selectedFeatureIds, - respectBindingFilter, + applyGeometryBindingFilter, fitToDraftBounds, fitBoundsKey, highlightFeatures, @@ -206,7 +209,7 @@ const Map = forwardRef(function Map({ setupMapLayers(map, backgroundVisibility, highlightFeatures, applyHighlightToMap); applyImageOverlayToMap(); setupMapInteractions(map); - applyDraftToMap(draftRef.current); + applyRenderDraftToMap(renderDraftRef.current); tryCenterToUserLocation(); return () => { diff --git a/src/uhm/components/editor/EntityWikiBindingsPanel.tsx b/src/uhm/components/editor/EntityWikiBindingsPanel.tsx index 2066960..36ecba6 100644 --- a/src/uhm/components/editor/EntityWikiBindingsPanel.tsx +++ b/src/uhm/components/editor/EntityWikiBindingsPanel.tsx @@ -31,13 +31,13 @@ function wikiTitle(w: WikiSnapshot): string { export default function EntityWikiBindingsPanel({ setLinks }: Props) { const { entityCatalog, - snapshotEntities, + snapshotEntityRows, wikis, links, } = useEditorStore( useShallow((state) => ({ entityCatalog: state.entityCatalog, - snapshotEntities: state.snapshotEntities, + snapshotEntityRows: state.snapshotEntityRows, wikis: state.snapshotWikis, links: state.snapshotEntityWikiLinks, })) @@ -59,18 +59,18 @@ export default function EntityWikiBindingsPanel({ setLinks }: Props) { ); const entityChoices = useMemo(() => { - const visibleSnapshotEntities = new globalThis.Map(); - for (const ref of snapshotEntities || []) { + const visibleSnapshotEntityRows = new globalThis.Map(); + for (const ref of snapshotEntityRows || []) { const id = String(ref?.id || "").trim(); - if (!id || ref?.operation === "delete" || visibleSnapshotEntities.has(id)) continue; - visibleSnapshotEntities.set(id, { + if (!id || ref?.operation === "delete" || visibleSnapshotEntityRows.has(id)) continue; + visibleSnapshotEntityRows.set(id, { id, name: String(ref?.name || id), isNew: ref?.source === "inline" && ref?.operation === "create", }); } - const rows = Array.from(visibleSnapshotEntities.values()).map((entity) => { + const rows = Array.from(visibleSnapshotEntityRows.values()).map((entity) => { const found = entityCatalog.find((item) => String(item.id) === entity.id) || null; return { id: entity.id, @@ -80,7 +80,7 @@ export default function EntityWikiBindingsPanel({ setLinks }: Props) { }); rows.sort((a, b) => a.name.localeCompare(b.name)); return rows; - }, [entityCatalog, snapshotEntities]); + }, [entityCatalog, snapshotEntityRows]); const activeLinks = useMemo(() => { const set = new Set(); diff --git a/src/uhm/components/editor/GeometryBindingPanel.tsx b/src/uhm/components/editor/GeometryBindingPanel.tsx index b21330e..e945acf 100644 --- a/src/uhm/components/editor/GeometryBindingPanel.tsx +++ b/src/uhm/components/editor/GeometryBindingPanel.tsx @@ -3,17 +3,29 @@ import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react"; import { useShallow } from "zustand/react/shallow"; import NewBadge from "@/uhm/components/editor/NewBadge"; +import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline"; import { useEditorStore } from "@/uhm/store/editorStore"; type GeometryChoice = { id: string; label?: string; - time_start?: number | null; - time_end?: number | null; + time_start?: unknown; + time_end?: unknown; isTimelineVisible?: boolean; + isOrphan?: boolean; + timeStatus?: GeometryTimeStatus; + timelineStatus?: GeometryTimelineStatus; isNew?: boolean; }; +type GeometryTimeStatus = "missing" | "partial" | "complete"; +type GeometryTimelineStatus = "off" | "visible" | "filteredOut"; +type GeometryRow = Required> & { + time_start: number | null; + time_end: number | null; + isTimelineVisible: boolean; +}; + type Props = { geometries: GeometryChoice[]; selectedGeometryId?: string | null; @@ -61,9 +73,12 @@ export default function GeometryBindingPanel({ .map((g) => ({ id: g.id.trim(), label: (g.label || "").trim(), - time_start: typeof g.time_start === "number" ? g.time_start : null, - time_end: typeof g.time_end === "number" ? g.time_end : null, + time_start: normalizeTimelineYearValue(g.time_start), + time_end: normalizeTimelineYearValue(g.time_end), isTimelineVisible: Boolean(g.isTimelineVisible), + isOrphan: Boolean(g.isOrphan), + timeStatus: resolveTimeStatus(g), + timelineStatus: resolveTimelineStatus(g), isNew: Boolean(g.isNew), })); cleaned.sort((a, b) => a.id.localeCompare(b.id)); @@ -85,6 +100,31 @@ export default function GeometryBindingPanel({ return a.id.localeCompare(b.id); }); }, [bindingSet, effectiveSelectedGeometryId, rows]); + const summary = useMemo(() => { + let orphan = 0; + let missingTime = 0; + let partialTime = 0; + let filteredOut = 0; + let hidden = 0; + + for (const row of rows) { + if (row.isOrphan) orphan += 1; + if (row.timeStatus === "missing") missingTime += 1; + if (row.timeStatus === "partial") partialTime += 1; + if (row.timelineStatus === "filteredOut") filteredOut += 1; + if (geometryVisibility[row.id] === false) hidden += 1; + } + + return { + total: rows.length, + orphan, + missingTime, + partialTime, + timeIssues: missingTime + partialTime, + filteredOut, + hidden, + }; + }, [geometryVisibility, rows]); const handleFocusKeyDown = (event: KeyboardEvent, geometryId: string) => { if (!canFocusGeometry) return; @@ -114,29 +154,72 @@ export default function GeometryBindingPanel({ }} >
-
+
Geometry Binding
- + + Filter binding +
-
-
{rows.length}
+
+
+ all {summary.total} + {summary.orphan > 0 ? ( + entity {summary.orphan} + ) : null} + {summary.timeIssues > 0 ? ( + + time {summary.timeIssues} + + ) : null} + {summary.filteredOut > 0 ? ( + out {summary.filteredOut} + ) : null} + {summary.hidden > 0 ? ( + hidden {summary.hidden} + ) : null} +
-
- {selectedGeometry.id} -
+
); })() @@ -248,8 +308,7 @@ export default function GeometryBindingPanel({ .map((g) => { const isBound = bindingSet.has(g.id); const isHidden = geometryVisibility[g.id] === false; - const idColor = getGeometryIdColor(g); - const labelColor = g.isTimelineVisible ? "#22c55e" : "#e5e7eb"; + const title = buildGeometryTitle(g, isHidden, isBound); return (
handleFocusGeometry(g.id)} @@ -284,33 +343,10 @@ export default function GeometryBindingPanel({ minWidth: 0, }} > - - {g.label || g.id} - - {isHidden ? hidden : null} - {isBound ? bound : null} + {g.isNew ? : null}
-
- {g.id} -
+
) : null} {formatYear(lower)} >; + onRemoveWiki?: (wikiId: string) => void; }; function clampTitle(title: string) { @@ -63,7 +64,7 @@ function clampTitle(title: string) { return t.length ? t.slice(0, 120) : "Untitled wiki"; } -export default function WikiSidebarPanel({ projectId, setWikis }: Props) { +export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) { const { wikis, requestedActiveId } = useEditorStore( useShallow((state) => ({ wikis: state.snapshotWikis, @@ -252,7 +253,11 @@ export default function WikiSidebarPanel({ projectId, setWikis }: Props) { }; const removeWiki = (id: string) => { - setWikis((prev) => prev.filter((w) => w.id !== id)); + if (onRemoveWiki) { + onRemoveWiki(id); + } else { + setWikis((prev) => prev.filter((w) => w.id !== id)); + } if (activeId === id) setActiveId(null); }; diff --git a/src/uhm/doc/commit_snapshot.ts b/src/uhm/doc/commit_snapshot.ts index 61e1148..8b472d1 100644 --- a/src/uhm/doc/commit_snapshot.ts +++ b/src/uhm/doc/commit_snapshot.ts @@ -11,7 +11,8 @@ * - Nhiều field root để optional vì frontend còn phải đọc snapshot cũ / partial. * - Replay actions trong dữ liệu thật dùng `params: unknown[]` theo positional tuple. * - Snapshot replay cũ còn `replay_features` sẽ được FE migrate sang `target_geometry_ids` khi load. - * - Trước khi gửi API, frontend còn normalize thêm một số field, ví dụ `geometries[].type`. + * - Trước khi gửi API, frontend còn normalize thêm một số field, ví dụ + * `time_start/time_end` và `geometries[].type`. */ // ---- Root request ---- @@ -53,6 +54,12 @@ export type FeatureProperties = { entity_ids?: string[]; entity_name?: string | null; entity_names?: string[]; + entity_label_candidates?: Array<{ + id: string; + name: string; + time_start?: number | null; + time_end?: number | null; + }>; entity_type_id?: string | null; point_label?: string | null; line_label?: string | null; @@ -85,6 +92,8 @@ export type EntitySnapshot = { operation?: EntitySnapshotOperation; name?: string; description?: string | null; + time_start?: number | null; + time_end?: number | null; }; export type GeometrySnapshot = { @@ -267,15 +276,11 @@ export type ReplayGeoFunctionParamTupleDocs = { padding?: number, duration?: number, ]; - fly_to_geometries: [geometry_ids: string[]]; + fly_to_geometries: [geometry_ids: string[], duration?: number]; set_geometry_visibility: [geometry_ids: string[], visible: boolean]; show_geometries: [geometry_ids: string[]]; hide_geometries: [geometry_ids: string[]]; - fit_to_geometries: [ - geometry_ids: string[], - padding?: number, - duration?: number, - ]; + fit_to_geometries: [geometry_ids: string[], duration?: number]; orbit_camera_around_geometry: [ geometry_id: string, zoom?: number, diff --git a/src/uhm/doc/developer_guide.md b/src/uhm/doc/developer_guide.md index b7c2343..cf7b81b 100644 --- a/src/uhm/doc/developer_guide.md +++ b/src/uhm/doc/developer_guide.md @@ -17,6 +17,14 @@ Tài liệu này dành cho người sửa editor hiện tại, không phải mô Nếu chưa đọc 5 file này, chưa nên sửa behavior lớn của editor. +Docs nên đọc trước khi sửa editor: + +- `src/uhm/doc/editor_operations.md` +- `src/uhm/doc/editor_data_roles.md` +- `src/uhm/doc/editor_snapshot_contract.md` +- `src/uhm/doc/editor_manual_test_checklist.md` +- `src/uhm/doc/editor_replay_actions.md` + ## 2. Cấu trúc thư mục nên ưu tiên hiểu - `src/uhm/components/editor/` @@ -40,14 +48,17 @@ Editor có 3 tầng dữ liệu: 1. `baselineSnapshot` - snapshot gốc của session -2. `initialData` +2. `baselineFeatureCollection` - `FeatureCollection` rehydrate từ snapshot đó -3. `draft` + - seed/reset cho `useEditorState()` +3. `mainDraft` - working copy để user sửa trên map +Map không render trực tiếp `mainDraft` mọi lúc. Page tạo `mapRenderDraft` từ `mainDraft`/`replayDraft`/preview draft sau khi áp timeline/replay filter, rồi truyền xuống `Map` dưới prop `renderDraft`. `labelContextDraft` chỉ dùng để lookup label, không được dùng để quyết định geometry nào hiện trên map. + Khi commit: -- geometry đi từ `draft` +- geometry đi từ `mainDraft` - entity/wiki/link đi từ snapshot collections - `buildEditorSnapshot()` quyết định operation nào là `reference`, `binding`, `update`, `delete` @@ -150,9 +161,10 @@ Nghĩa là: Một số nguyên tắc nên giữ: -- dùng `draftRef`/refs trong map engines để tránh rebind handler vô ích +- dùng `renderDraftRef`/refs trong map engines để tránh rebind handler vô ích - giữ component panel càng dumb càng tốt, logic patch state đặt ở page/hooks - khi cần undo cho entity/wiki/link, đi qua `editor.setSnapshot*()` để undo stack biết +- khi cần undo cho replay script, đi qua `editor.mutateActiveReplay()` hoặc replay collection helper hiện có - hạn chế thêm `JSON.stringify` compare ở chỗ nóng nếu chưa đo hiệu năng ## 12. Chỗ dễ gây hiểu nhầm khi debug @@ -173,7 +185,7 @@ Không phải lúc nào cũng là bug render layer. ### Selection mất -Khi timeline filter làm geometry đang chọn không còn visible, page sẽ tự cắt `selectedFeatureIds`. +Selection hiện bám theo `editor.draft`, không theo `mapRenderDraft`. Vì vậy geometry đang chọn có thể bị timeline filter ẩn khỏi map nhưng panel metadata vẫn đọc được draft gốc. ## 13. Nên test gì sau khi sửa diff --git a/src/uhm/doc/editor_data_roles.md b/src/uhm/doc/editor_data_roles.md new file mode 100644 index 0000000..b0e868c --- /dev/null +++ b/src/uhm/doc/editor_data_roles.md @@ -0,0 +1,114 @@ +# UHM Editor - vai trò dữ liệu dễ nhầm + +Tài liệu này là glossary ngắn để người sửa code và AI không nhầm các `FeatureCollection`/snapshot gần tên nhau trong editor. + +## Luật đọc nhanh + +- `mainDraft` là dữ liệu geometry chính để edit và commit. +- `mapRenderDraft` là dữ liệu đã lọc để render map. +- `labelContextDraft` chỉ để lookup label, không quyết định render. +- `baselineFeatureCollection` chỉ để seed/reset session hiện tại. +- `baselineSnapshot` là snapshot gốc để so dirty và build commit delta. +- Các collection `snapshot*` là state hiện tại của snapshot, không phải danh sách delta thô. + +## Geometry draft + +### `baselineFeatureCollection` + +FeatureCollection gốc của phiên editor hiện tại. Nó được tạo từ `baselineSnapshot.editor_feature_collection` khi mở project/restore commit, hoặc từ `EMPTY_FEATURE_COLLECTION` khi project chưa có commit. + +Khi field này đổi, `useEditorState()` reset `mainDraft`, rebuild `initialMapRef`, và clear undo stack. + +### `mainDraft` + +Working copy geometry chính. Đây là nguồn commit cho geometry và là nơi các thao tác create/update/delete/properties ghi vào. + +Không dùng `mapRenderDraft` để commit vì `mapRenderDraft` có thể thiếu geometry do timeline/replay/preview filter. + +### `editor.draft` + +Draft active theo mode: + +- mode thường: `editor.draft === mainDraft` +- mode `replay`: `editor.draft === replayDraft` + +Panel metadata và selection dùng `editor.draft` để vẫn đọc được geometry ngay cả khi map filter đang ẩn geometry đó. + +### `replayDraft` + +FeatureCollection local hydrate từ `mainDraft` theo `activeReplayDraft.target_geometry_ids`. Nó chỉ phục vụ replay edit mode, không thay thế `mainDraft`. + +### `mapRenderDraft` + +FeatureCollection do page tạo ra để truyền vào `Map` prop `renderDraft`. + +Nguồn có thể là: + +- `editor.mainDraft` ở mode thường +- `editor.replayDraft` ở replay edit mode +- `previewSession.draft` đã áp hidden ids ở replay preview mode + +Sau đó page có thể áp timeline filter. Đây là nguồn duy nhất quyết định geometry nào xuất hiện trên map. + +### `renderDraft` + +Tên prop trong `Map.tsx`/`useMapSync.ts`. Đây là `mapRenderDraft` sau khi truyền xuống component map. + +### `renderDraftRef` + +Ref của `renderDraft` trong map interaction. Ref này dùng cho hover/select/edit trên các geometry đang render/interact. Không nhầm với `draftRef` nội bộ trong `useEditorState()`. + +## Label context + +### `labelContextBaseDraft` + +FeatureCollection gốc để build label context. Nó có thể là draft rộng hơn `mapRenderDraft` để label vẫn resolve được entity/geometry liên quan. + +### `mapLabelContextDraft` + +FeatureCollection đã enrich label/entity name từ `labelContextBaseDraft`. + +Rule quan trọng: `mapLabelContextDraft` chỉ dùng cho label lookup. Nó có thể chứa geometry bị timeline filter ẩn, nên không được dùng để quyết định render source hoặc geometry visibility. + +## Snapshot state + +### `baselineSnapshot` + +Snapshot gốc của session hiện tại. Dùng để so dirty và để `buildEditorSnapshot()` biết row nào là reference/binding/update/delete. + +### `snapshotEntityRows` + +Các entity row của snapshot hiện tại. Đây là rows cho payload `entities[]`, không phải entity catalog toàn hệ thống. + +### `snapshotWikis` + +Các wiki row của snapshot hiện tại. Đây là source truth cho wiki trong commit. + +### `snapshotEntityWikiLinks` + +Các link entity-wiki hiện tại của snapshot. Snapshot builder sẽ tự sinh operation phù hợp so với `baselineSnapshot.entity_wiki`. + +## Binding và visibility + +### `geometry_entity[]` + +Join table persist quan hệ geometry-entity trong snapshot commit. `feature.properties.entity_ids` chỉ là field denormalized cho UI. + +### `binding` + +Field geometry-geometry binding trên feature. Binding này không tính là entity binding; geometry không có `entity_ids/entity_id` hợp lệ vẫn là orphan. + +### `geometryVisibility` + +Map local visibility override. Key có thể là geometry id hoặc semantic geo type key. Đây là UI-only, không đi snapshot. + +### `applyGeometryBindingFilter` + +Filter map theo selection/binding. Chỉ ảnh hưởng render trên map, không đổi draft và không đi snapshot. + +## Guard rails + +- Render path: `mapRenderDraft -> Map.renderDraft -> useMapSync(renderDraft) -> MapLibre sources`. +- Label path: `labelContextBaseDraft -> mapLabelContextDraft -> useMapSync(labelContextDraft)`. +- Commit path: `mainDraft + snapshotEntityRows + snapshotWikis + snapshotEntityWikiLinks + effectiveReplays -> buildEditorSnapshot()`. +- Orphan validation vẫn chạy trên `mainDraft`, không phụ thuộc map filter. diff --git a/src/uhm/doc/editor_features.md b/src/uhm/doc/editor_features.md index 1f341b1..a141a63 100644 --- a/src/uhm/doc/editor_features.md +++ b/src/uhm/doc/editor_features.md @@ -3,6 +3,13 @@ Tài liệu này mô tả editor đang chạy tại `src/app/editor/[id]/page.tsx` và các panel liên quan trong `src/uhm/components/`. Mục tiêu của tài liệu là phản ánh đúng implementation hiện tại, không mô tả các tính năng chưa được nối dây. +Docs liên quan: + +- `src/uhm/doc/editor_operations.md`: ma trận thao tác/undo/snapshot. +- `src/uhm/doc/editor_snapshot_contract.md`: contract commit snapshot. +- `src/uhm/doc/editor_manual_test_checklist.md`: checklist test tay. +- `src/uhm/doc/editor_replay_actions.md`: catalog action replay. + ## 1. Cách mở editor - `GET /editor/[id]`: mở editor đầy đủ với map, panel trái và panel phải. @@ -17,7 +24,7 @@ Mục tiêu của tài liệu là phản ánh đúng implementation hiện tại - `UndoListPanel` - Khu vực giữa - `Map` - - `TimelineBar` khi không ở `replay` + - `TimelineBar` khi không ở `replay`; trong `replay_preview` phụ thuộc action `timeline` - Cột phải (`BackgroundLayersPanel`) - Search hợp nhất - Geometry Binding @@ -40,6 +47,7 @@ Hai cột hai bên đều resize được bằng drag handle. - `add-path` - `add-circle` - `replay` +- `replay_preview` Ý nghĩa thực tế: @@ -49,7 +57,8 @@ Hai cột hai bên đều resize được bằng drag handle. - `add-line`: vẽ `LineString`. - `add-path`: vẽ `LineString` có render arrow layer cho route. - `add-circle`: kéo chuột để tạo polygon hình tròn, có `circle_center` và `circle_radius`. -- `replay`: hiện là chế độ tập trung vào một geometry và các geometry trong `binding`; chưa có hệ thống script replay UI/map như file schema tham chiếu. +- `replay`: chế độ tập trung vào một geometry và tập `target_geometry_ids`, có sidebar sửa stage/step/action, preview overlay và undo riêng cho session replay. +- `replay_preview`: chạy preview từ replay đang edit; action điều khiển camera/timeline/wiki/narrative overlay và hidden geometry ids. ## 4. Công cụ vẽ và phím điều khiển @@ -161,14 +170,14 @@ Panel phải có `UnifiedSearchBar` với 3 loại search: - `entity` - tìm local + backend theo tên/mô tả - - nút `Add` sẽ thêm entity vào `snapshotEntities` dưới dạng `reference` + - nút `Add` sẽ thêm entity vào `snapshotEntityRows` dưới dạng `reference` - `wiki` - tìm backend theo title - nút `Add` sẽ thêm wiki vào `snapshotWikis` dưới dạng `reference` - `geo` - tìm geometry theo tên entity - nút `Import` sẽ import geometry vào draft hiện tại - - đồng thời thêm entity tương ứng vào `snapshotEntities` nếu chưa có + - đồng thời thêm entity tương ứng vào `snapshotEntityRows` nếu chưa có - import sẽ tự tắt timeline filter để geometry mới import không bị ẩn ## 9. Entity và binding @@ -200,6 +209,14 @@ Panel `ProjectEntityRefsPanel` là nơi bind/unbind entity theo geometry đang c - Bind/unbind với geometry khác trong project. - Có nút focus để zoom vào geometry trong list binding. - Có toggle `Filter`: map chỉ hiển thị geometry liên quan tới selection nếu filter binding đang bật. +- Row geometry hiển thị chip trạng thái trong panel: + - `no entity` nếu geometry chưa bind entity. + - `no time` nếu thiếu cả `time_start` và `time_end`. + - `partial time` nếu chỉ có một trong hai mốc thời gian. + - `timeline` hoặc `out timeline` khi timeline filter đang bật. + - `hidden`, `bound`, `new` theo trạng thái UI tương ứng. +- ID geometry không render trực tiếp trong row; ID chỉ nằm trong `title` tooltip của row/nút thao tác. +- Geometry mồ côi không có style riêng trên map. Cảnh báo nằm ở panel và validation commit/submit. ## 10. Wiki và entity-wiki @@ -247,12 +264,14 @@ Số trong nút `Commit` không chỉ là geometry diff. Nó gồm: - `+1` nếu danh sách wiki dirty - `+1` nếu danh sách entity dirty - `+1` nếu danh sách entity-wiki dirty +- `+1` nếu replay script dirty ### Commit `commitSection()`: -- build snapshot từ `draft` + `snapshotEntities` + `snapshotWikis` + `snapshotEntityWikiLinks` +- build snapshot từ `mainDraft` + `snapshotEntityRows` + `snapshotWikis` + `snapshotEntityWikiLinks` + `effectiveReplays` +- chặn commit nếu không có thay đổi, còn orphan geometry, hoặc payload vượt guardrail kích thước - gửi `snapshot_json` lên API tạo commit - nếu thành công: - reset baseline sang snapshot vừa commit @@ -263,11 +282,13 @@ Số trong nút `Commit` không chỉ là geometry diff. Nó gồm: - chỉ submit được khi project có `head_commit_id` - không submit nếu còn thay đổi chưa commit +- không submit nếu còn orphan geometry ### Restore `CommitHistoryPanel` có nút `Restore`, nhưng restore hiện là: +- chỉ chạy khi không còn pending changes - load snapshot từ commit cũ vào FE - không đổi head commit trên backend @@ -293,4 +314,3 @@ Các mục sau không nên xem là tính năng hiện hành của editor: - import/export wiki JSON chuyên biệt như một workflow riêng - bộ shortcut toàn cục kiểu `Ctrl+S`, `Ctrl+Z`, `Ctrl+Y` - workflow duyệt `Approved/Rejected` được render đầy đủ trong editor page -- hệ thống replay script theo `replays[]` trong schema snapshot diff --git a/src/uhm/doc/editor_manual_test_checklist.md b/src/uhm/doc/editor_manual_test_checklist.md new file mode 100644 index 0000000..3d13200 --- /dev/null +++ b/src/uhm/doc/editor_manual_test_checklist.md @@ -0,0 +1,131 @@ +# UHM Editor - manual test checklist + +Cập nhật: 2026-05-22. + +Checklist này dùng sau mỗi lần sửa editor. Không thay thế typecheck/lint, nhưng bắt các lỗi workflow mà static check khó thấy. + +## 1. Preflight + +- Mở `/editor/[id]` với một project có ít nhất một geometry/entity/wiki. +- Mở console browser, đảm bảo không có runtime error ngay khi load. +- Kiểm tra map render đủ geometry, panel trái/phải không overlap. +- Kiểm tra `UndoListPanel` ban đầu không có action lạ từ lần load. + +## 2. Geometry create/edit/delete + +| Bước | Thao tác | Kỳ vọng | +| --- | --- | --- | +| 1 | Vẽ polygon ở `draw` mode | Geometry mới được select, panel hiện `no entity` và `no time` | +| 2 | Undo | Polygon biến mất, undo stack giảm | +| 3 | Tạo point | Point render bằng icon geotype bình thường, không đổi màu riêng vì orphan | +| 4 | Apply type/time cho point | Panel đổi `no time`/`partial time` đúng theo input | +| 5 | Sửa vertex/circle nếu có geometry phù hợp | Undo khôi phục geometry cũ | +| 6 | Xóa một geometry | Geometry biến mất, undo khôi phục đúng vị trí trong list | +| 7 | Multi-select cùng shape và xóa | Undo khôi phục toàn bộ geometry đã xóa | + +## 3. Geometry status panel + +- Row không hiển thị ID trực tiếp. +- Hover row thấy tooltip có `ID: ...`. +- Geometry không entity hiện chip `no entity`. +- Geometry thiếu cả `time_start/time_end` hiện `no time`. +- Geometry thiếu một trong hai field time hiện `partial time`. +- Bật timeline filter: + - Geometry còn visible hiện chip `timeline`. + - Geometry bị lọc khỏi draft visible hiện chip `out timeline`. +- Eye button set `hidden`, map ẩn geometry và panel hiện chip `hidden`. +- `NewBadge` vẫn hiện cho geometry mới/import chưa persisted. + +## 4. Entity và geometry-entity + +| Bước | Thao tác | Kỳ vọng | +| --- | --- | --- | +| 1 | Search entity và Add vào project | Entity xuất hiện trong panel, undo gỡ entity ref | +| 2 | Tạo entity local | Entity mới xuất hiện, form reset, undo gỡ entity | +| 3 | Sửa entity name/time | Undo khôi phục metadata entity | +| 4 | Bind entity vào selected geometry | Chip `no entity` biến mất, undo trả lại trạng thái cũ | +| 5 | Unbind entity | Chip `no entity` hiện lại, commit bị chặn nếu geometry còn orphan | +| 6 | Multi-select khác shape rồi bind entity | UI báo không thể bind nhiều geometry khác loại | + +## 5. Geometry-geometry binding + +- Chọn một geometry, bind geometry khác trong `GeometryBindingPanel`. +- Panel hiện chip `bound` cho geometry liên quan. +- Toggle Filter: map chỉ hiện selection, selected children và parent/root phù hợp. +- Undo bind/unbind geometry phải khôi phục `properties.binding`. +- Bind geometry-geometry không làm mất chip `no entity` nếu geometry vẫn chưa bind entity. + +## 6. Wiki và entity-wiki + +| Bước | Thao tác | Kỳ vọng | +| --- | --- | --- | +| 1 | Search wiki và Add | Wiki ref xuất hiện, undo gỡ wiki ref | +| 2 | Tạo/sửa wiki local | Undo khôi phục danh sách/wiki content | +| 3 | Bind entity-wiki | Link xuất hiện, undo khôi phục links | +| 4 | Xóa wiki đang có entity-wiki links | Wiki và links liên quan bị xóa cùng lúc | +| 5 | Undo xóa wiki | Wiki và entity-wiki links cùng trở lại | +| 6 | Insert wiki link trong editor | Link nằm trong doc sau khi lưu wiki | + +## 7. Replay + +- Chọn geometry có entity, bấm replay. +- Replay mở với MAIN geo và các target ids liên quan binding. +- Tạo stage, tạo step, đổi duration. +- Thêm narrative action `set_title` và `set_descriptions`. +- Thêm map action `set_time_filter`, `show_labels`, `hide_labels`. +- Thêm geo action `fly_to_geometries`, `hide_geometries`, `show_geometries`. +- Undo trong replay mode chỉ undo replay session, không undo main geometry. +- Play preview: + - Step selection chạy đúng thứ tự. + - Stop/reset khôi phục title/dialog/image/hidden geometry/timeline/map camera cơ bản. +- Thoát replay rồi vào lại, detail vẫn còn nếu chưa undo. + +## 8. Import GEO từ search + +- Search GEO theo entity. +- Import một geometry chưa có trong draft. +- Kỳ vọng: + - Timeline filter tự tắt. + - Geometry được select. + - Entity ref được thêm nếu chưa có. + - Undo gỡ cả geometry và entity ref nếu entity ref được tạo trong cùng action. +- Import lại cùng GEO: + - Không tạo duplicate geometry. + - Chỉ select geometry đã có. + +## 9. Commit và restore + +| Bước | Thao tác | Kỳ vọng | +| --- | --- | --- | +| 1 | Commit khi không có thay đổi | Báo không có thay đổi | +| 2 | Commit khi còn orphan geometry | Bị chặn, select orphan đầu tiên, panel entity báo chưa bind | +| 3 | Bind entity rồi commit | Commit thành công, undo stack cleared, pending count về 0 | +| 4 | Kiểm snapshot commit | Có `geometries`, `geometry_entity`, `entities`, `wikis`, `entity_wiki`, `replays` đúng thay đổi | +| 5 | Restore commit cũ | Draft/snapshot panels reset theo commit | + +## 10. Submit + +- Khi còn pending changes, submit phải bị chặn và yêu cầu commit trước. +- Khi còn orphan geometry, submit bị chặn giống commit. +- Khi đã commit sạch và không orphan, submit tạo submission id/status. +- Nếu project bị pending submission lock, banner unlock hoạt động và mở lại project. + +## 11. UI-only checks + +Các thao tác sau không được thêm undo action và không làm tăng pending save count: + +- Đổi timeline year/filter. +- Toggle background layers. +- Hide/show geometry local. +- Focus geometry từ panel. +- Resize panel. +- Search query. +- Pick/paste/remove image overlay trace. +- Replay preview play/stop/reset. + +## 12. Final smoke + +- `npx tsc --noEmit --pretty false`. +- Targeted eslint cho file vừa sửa. +- `git diff --check`. +- Nếu sửa frontend UI lớn: mở dev server và test ít nhất desktop viewport. diff --git a/src/uhm/doc/editor_operations.md b/src/uhm/doc/editor_operations.md new file mode 100644 index 0000000..4aaa338 --- /dev/null +++ b/src/uhm/doc/editor_operations.md @@ -0,0 +1,200 @@ +# UHM Editor - ma trận thao tác + +Cập nhật: 2026-05-22. + +Tài liệu này là checklist thao tác cho editor ở `/editor/[id]`. Mục tiêu là trả lời nhanh 4 câu hỏi khi thêm hoặc audit một tính năng: + +- Người dùng thao tác ở đâu? +- State nào bị đổi? +- Có cần undo không, undo đang dùng action nào? +- Commit snapshot có bị ảnh hưởng không? + +Nguồn chính: + +- `src/app/editor/[id]/page.tsx` +- `src/app/editor/[id]/featureCommands.ts` +- `src/uhm/lib/editor/state/useEditorState.ts` +- `src/uhm/lib/editor/project/useProjectCommands.ts` +- `src/uhm/lib/editor/snapshot/editorSnapshot.ts` + +## 1. Quy ước phân loại + +### Cần undo + +Một thao tác cần undo nếu nó đổi dữ liệu sẽ đi vào commit snapshot hoặc đổi draft geometry chính: + +- `mainDraft.features` +- `snapshotEntityRows` +- `snapshotWikis` +- `snapshotEntityWikiLinks` +- `replays` +- `activeReplayDraft.detail` + +### Không cần undo + +Một thao tác không cần undo nếu nó chỉ đổi trạng thái xem/điều hướng tạm thời: + +- `mode` +- selection/focus/hover +- timeline year/filter UI +- background layer visibility +- geometry visibility local +- image trace overlay +- resize panel +- search query/result +- status message + +### Undo action hiện có + +| Action | Phạm vi | Ý nghĩa | +| --- | --- | --- | +| `create` | main draft | Gỡ geometry vừa tạo | +| `delete` | main draft | Khôi phục geometry đã xóa, có `index` để trả về vị trí cũ | +| `update` | main draft | Khôi phục `geometry` trước khi sửa vertex/circle | +| `properties` | main draft | Khôi phục `feature.properties` trước khi patch | +| `snapshot_entities` | snapshot | Khôi phục collection entity snapshot | +| `snapshot_wikis` | snapshot | Khôi phục collection wiki snapshot | +| `snapshot_entity_wiki` | snapshot | Khôi phục collection entity-wiki snapshot | +| `replay` | replay | Khôi phục một replay theo geometry id | +| `replays` | replay collection | Khôi phục toàn bộ `replays[]` | +| `replay_session` | replay mode | Khôi phục `activeReplayDraft` trong phiên replay | +| `group` | tổng hợp | Gom nhiều undo action thành một thao tác logic | + +## 2. Geometry draft + +| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú | +| --- | --- | --- | --- | --- | --- | +| Vẽ polygon | `draw` mode, map drawing engine | Thêm feature vào `mainDraft` | `create` | `geometries[]`, `geometry_entity[]` nếu sau đó bind entity | Feature mới mặc định `type: country`, `geometry_preset: polygon`, chưa có entity | +| Tạo point | `add-point` mode | Thêm feature vào `mainDraft` | `create` | Như trên | Mặc định `type: city`, `geometry_preset: point` | +| Vẽ line | `add-line` mode | Thêm feature vào `mainDraft` | `create` | Như trên | Mặc định `type: defense_line`, `geometry_preset: line` | +| Vẽ path/route | `add-path` mode | Thêm feature vào `mainDraft` | `create` | Như trên | Mặc định `type: attack_route`, render thêm arrow layer | +| Vẽ circle | `add-circle` mode | Thêm polygon có `circle_center`, `circle_radius` | `create` | Như trên | Mặc định `type: war`, `geometry_preset: circle-area` | +| Import GEO từ search | Search `geo`, nút import | Thêm feature vào `mainDraft`, thêm entity ref nếu thiếu | `group` gồm `snapshot_entities` và `create` khi cả hai đổi | `geometries[]` và entity ref | Tắt timeline filter để GEO vừa import không bị ẩn | +| Chọn geometry | Click map/panel | `selectedFeatureIds` | Không | Không | Chỉ là UI state | +| Focus geometry từ panel | `GeometryBindingPanel` row click | Selection, `geometryFocusRequest`, có thể kéo timeline draft year về `time_start` | Không | Không | Không đổi dữ liệu commit | +| Sửa vertex/circle | Map edit engine trong `select` | `feature.geometry` | `update` | `geometries[]` | Không hoạt động trong replay mode | +| Sửa type/time metadata | `SelectedGeometryPanel` apply | `feature.properties.type/time_start/time_end/geometry_preset` | `properties` hoặc `group` khi multi-select | `geometries[]` | Validate time parse được và `time_start <= time_end` | +| Xóa một geometry | Map delete hoặc selected panel | Xóa feature khỏi `mainDraft` | `delete`, có thể group với `replays` | `geometries[]`, `geometry_entity[]` delete delta | Prune replay/target ids liên quan geometry bị xóa | +| Xóa nhiều geometry | Bulk selected panel/map callback | Xóa nhiều feature | `group` nhiều `delete`, có thể kèm `replays` | Như trên | Undo khôi phục theo index cũ | +| Ẩn/hiện geometry local | Eye button, map hide callback | `geometryVisibility` | Không | Không | Local UI only, không đi snapshot | +| Geometry status panel | `GeometryBindingPanel` | Derived từ draft/timeline/visibility | Không | Không | Hiện `no entity`, `no time`, `partial time`, `timeline`, `out timeline`, `hidden`, `bound`, `new`; ID chỉ nằm trong tooltip | + +## 3. Geometry binding + +| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú | +| --- | --- | --- | --- | --- | --- | +| Bind entity vào selected geometry | `ProjectEntityRefsPanel` checkbox | `entity_id`, `entity_ids`, `entity_name`, `entity_names` trên selected features | `properties` hoặc `group` | `geometry_entity[]` | Multi-select chỉ hợp lệ khi cùng shape type | +| Unbind entity | `ProjectEntityRefsPanel` checkbox | Các field entity trên feature | `properties` hoặc `group` | `geometry_entity[]` delete delta nếu baseline có link | Commit/submit chặn geometry không còn entity | +| Bind geometry-geometry | `GeometryBindingPanel` lock button | `feature.properties.binding` | `properties` hoặc `group` | `geometries[].binding` | Binding geometry không thay thế entity binding | +| Unbind geometry-geometry | `GeometryBindingPanel` unlock button | `feature.properties.binding` | `properties` hoặc `group` | `geometries[].binding` | Không ảnh hưởng `geometry_entity[]` | +| Bind nhiều geometry vào target | Map bind callback | `binding` của target geometry | `properties` | `geometries[].binding` | Tự bỏ target id khỏi source ids | +| Toggle binding filter | `GeometryBindingPanel` filter checkbox | `geometryBindingFilterEnabled` | Không | Không | Chỉ lọc hiển thị map theo selection/binding | + +## 4. Entity snapshot + +| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú | +| --- | --- | --- | --- | --- | --- | +| Add entity ref từ search | Search `entity`, nút add | `snapshotEntityRows`, `entityCatalog` | `snapshot_entities` nếu collection đổi | `entities[]` với `source: ref`, `operation: reference` | Không gọi API create entity | +| Tạo entity local | `ProjectEntityRefsPanel` create form | `snapshotEntityRows`, `entityCatalog`, reset form | `snapshot_entities` | `entities[]` với `source: inline`, `operation: create` | Validate name bắt buộc, không trùng tên, time hợp lệ | +| Sửa entity trong project | Entity row edit | `snapshotEntityRows` | `snapshot_entities` | `entities[]` update/reference theo source | Validate name và time | +| Copy selected geometry time vào form entity | Entity panel button | Form state | Không | Không | Chỉ tiện ích UI | + +## 5. Wiki và entity-wiki + +| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú | +| --- | --- | --- | --- | --- | --- | +| Add wiki ref từ search | Search `wiki`, nút add | `snapshotWikis`, active wiki request | `snapshot_wikis` nếu collection đổi | `wikis[]` với `source: ref`, `operation: reference` | Không fetch lại toàn bộ project | +| Tạo/sửa wiki local | `WikiSidebarPanel` | `snapshotWikis` | `snapshot_wikis` | `wikis[]` | `doc` ưu tiên HTML string, plaintext là fallback | +| Import HTML vào wiki | `WikiSidebarPanel` import | `snapshotWikis` sau khi lưu | `snapshot_wikis` | `wikis[]` | File import không tự commit | +| Export wiki | `WikiSidebarPanel` export | Không đổi editor state | Không | Không | Tạo file tải xuống phía browser | +| Xóa wiki khỏi snapshot | `WikiSidebarPanel` remove | `snapshotWikis` và các `snapshotEntityWikiLinks` trỏ tới wiki | `group` gồm `snapshot_wikis` và `snapshot_entity_wiki` | `wikis[]`, `entity_wiki[]` delta | Đây là thao tác kép, phải undo cùng nhau | +| Bind entity-wiki | `EntityWikiBindingsPanel` | `snapshotEntityWikiLinks` | `snapshot_entity_wiki` | `entity_wiki[]` với `binding` hoặc `reference` theo baseline | Link mới dùng `operation: binding` | +| Unbind entity-wiki | `EntityWikiBindingsPanel` | `snapshotEntityWikiLinks` | `snapshot_entity_wiki` | `entity_wiki[]` delete delta nếu baseline có link | Runtime chỉ remove row, snapshot builder sinh delta | +| Chèn wiki link trong editor Quill | Wiki toolbar custom link | `doc` của wiki đang sửa | `snapshot_wikis` khi lưu wiki | `wikis[].doc` | Link có thể là slug local/global hoặc marker `__missing__` | + +## 6. Replay + +| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú | +| --- | --- | --- | --- | --- | --- | +| Vào replay mode | Selected geometry panel, replay button | `mode`, `activeReplayId`, `activeReplayDraft`, `replayDraft` | Không cho việc mở mode | Không trực tiếp | Nếu đổi replay đang mở, session cũ được flush | +| Tạo seed replay | `switchReplayContext` | `activeReplayDraft` với `geometry_id`, `target_geometry_ids`, `detail` | Không ngay lúc seed | `replays[]` khi mutate/flush | MAIN geo luôn đứng đầu target list | +| Sửa replay detail | `ReplayTimelineSidebar`, `ReplayEffectsSidebar` | `activeReplayDraft.detail` | `replay_session` | `replays[].detail` qua `effectiveReplays` | Replay mode không mutate geometry | +| Undo trong replay mode | Undo button khi `mode === replay` | `activeReplayDraft` | Pop `replayUndoStack` | Có nếu session còn dirty | Undo chính và undo replay tách stack | +| Thoát/chuyển replay | Exit hoặc đổi context | Flush `activeReplayDraft` vào `replays[]` | `replay` nếu flush có đổi | `replays[]` | Commit đọc `effectiveReplays`, nên không cần thoát replay trước commit | +| Xóa geometry có replay | Delete geometry | `mainDraft`, có thể prune `replays[]` | `group` với `replays` | `geometries[]`, `replays[]` | Target ids bị xóa cũng được prune | +| Preview replay | Preview overlay | Preview session, hidden ids, preview year | Không | Không | Chỉ là mô phỏng UI/map | + +## 7. Timeline, map style và panel status + +| Thao tác | State đổi | Undo | Commit | Ghi chú | +| --- | --- | --- | --- | --- | +| Đổi timeline year | `timelineDraftYear` | Không | Không | Client-side filter | +| Bật/tắt timeline filter | `timelineFilterEnabled` | Không | Không | New geometry trong session vẫn visible | +| Geometry bị timeline lọc | Derived `mapRenderDraft` | Không | Không | Panel hiện `timeline` hoặc `out timeline`; selection/panel metadata vẫn đọc `editor.draft` | +| Geometry mồ côi | Derived từ `normalizeFeatureEntityIds(feature).length === 0` | Không riêng | Commit/submit bị chặn | Map không đổi màu riêng cho orphan; panel hiện `no entity` | +| Thiếu time | Derived từ `time_start/time_end` | Không riêng | Vẫn commit được | Panel hiện `no time` hoặc `partial time` | +| Selected style trên map | Feature-state selected | Không | Không | Vẫn giữ highlight selected màu xanh | +| Background layer visibility | `backgroundVisibility`, localStorage | Không | Không | UI preference | + +## 8. Image overlay trace + +| Thao tác | State đổi | Undo | Commit | Ghi chú | +| --- | --- | --- | --- | --- | +| Pick image overlay | `imageOverlay`, object URL | Không | Không | Overlay để trace, không vào snapshot | +| Paste image overlay | `imageOverlay`, object URL | Không | Không | Cần browser clipboard permission | +| Đổi opacity | `imageOverlay.opacity` | Không | Không | UI only | +| Dời/scale bằng keyboard | `imageOverlay.coordinates` | Không | Không | UI only | +| Remove overlay | `imageOverlay = null`, revoke URL | Không | Không | Không ảnh hưởng draft | + +## 9. Project lifecycle + +| Thao tác | Entry point | State đổi | Undo | Snapshot/API | Validation | +| --- | --- | --- | --- | --- | --- | +| Mở project | Project panel/open route | Reset session state, `baselineFeatureCollection`, baseline snapshot | Không | Fetch project/commit snapshot | Nếu có pending changes khi đổi project thì confirm bỏ thay đổi | +| Tạo project mới | Project panel | Project list, active project, baseline empty | Không | API create project | Title bắt buộc | +| Commit | `CommitPanel` | Baseline snapshot, `baselineFeatureCollection`, clear undo/changes | Không undo sau commit | `createProjectCommit` với `buildEditorSnapshot` | Chặn nếu không có thay đổi, chặn orphan geometry, guard payload lớn | +| Submit | Submit modal | Submission status | Không | `submitSection` | Chỉ submit khi không pending save và không orphan geometry | +| Restore commit | Commit history | Reset draft/snapshot/session theo commit | Không | Fetch/convert commit snapshot | Chặn nếu còn pending changes; không đổi head trên BE | +| Delete pending submission lock | Banner unlock | `blockedPendingSubmissionId`, mở lại project | Không | `deleteSubmission` | Dùng khi backend báo project đang bị pending submission khóa | + +## 10. Undo coverage checklist + +Khi thêm một thao tác mới, kiểm theo thứ tự này: + +1. Thao tác có đổi `mainDraft`, snapshot collection hoặc replay detail không? +2. Nếu có, nó phải đi qua một trong các API undoable: + - `editor.createFeature` + - `editor.createFeatureWithSnapshotEntityRows` + - `editor.updateFeature` + - `editor.deleteFeature` hoặc `editor.deleteFeatures` + - `editor.patchFeatureProperties` hoặc `editor.patchFeaturePropertiesBatch` + - `editor.setSnapshotEntityRows` + - `editor.setSnapshotWikis` + - `editor.setSnapshotEntityWikiLinks` + - `editor.setSnapshotWikisAndEntityWikiLinks` + - `editor.mutateActiveReplay` +3. Nếu thao tác đổi nhiều vùng state trong cùng một ý nghĩa người dùng, dùng `group`. +4. Nếu xóa geometry, kiểm replay target/replay collection có cần prune không. +5. Nếu xóa wiki, kiểm entity-wiki links trỏ tới wiki đó có cần xóa cùng undo không. +6. Nếu thao tác có thể tạo geometry không entity, commit/submit guard vẫn phải bắt được. +7. Nếu thao tác chỉ đổi UI view/filter/focus, ghi rõ là không undo và không snapshot. + +## 11. Snapshot checklist + +Khi một thao tác cần đi vào commit, kiểm output snapshot: + +- Geometry body nằm trong `geometries[]`. +- Geometry-entity relation nằm trong `geometry_entity[]`, không chỉ trong `feature.properties.entity_ids`. +- Entity rows nằm trong `entities[]`. +- Wiki rows nằm trong `wikis[]`. +- Entity-wiki rows nằm trong `entity_wiki[]`. +- Replay script nằm trong `replays[]`, không lưu `replayDraft`. +- Generate-only fields trên feature như `entity_id`, `entity_ids`, `entity_name`, `entity_names`, `entity_label_candidates`, `time_start`, `time_end`, `binding`, `type` được snapshot builder xử lý/loại bỏ đúng chỗ trước API payload. + +## 12. Các thao tác cần audit lại nếu editor đổi lớn + +- Multi-select khác shape hiện bị chặn ở bind entity/geometry, nhưng selected panel vẫn phải giữ rule này nếu thêm action mới. +- Timeline filter đang là client-side, nếu sau này fetch theo timeline từ backend thì `timelineStatus` trong panel cần đổi nguồn truth. +- Image overlay hiện không persist. Nếu cần lưu overlay vào project, phải thêm snapshot schema và undo. +- Background visibility hiện là localStorage. Nếu cần lưu theo project/user, phải tách khỏi nhóm UI-only. +- Replay mode hiện không mutate geometry. Nếu cho sửa geometry trong replay, phải thiết kế lại undo và commit boundary. diff --git a/src/uhm/doc/editor_replay_actions.md b/src/uhm/doc/editor_replay_actions.md new file mode 100644 index 0000000..9db3d14 --- /dev/null +++ b/src/uhm/doc/editor_replay_actions.md @@ -0,0 +1,194 @@ +# UHM Editor - replay actions catalog + +Cập nhật: 2026-05-22. + +Tài liệu này mô tả action catalog của replay editor/preview hiện tại. Shape chuẩn nằm ở `src/uhm/types/projects.ts`; dispatcher runtime nằm ở `src/uhm/lib/replay/replayDispatcher.ts`. + +## 1. Replay shape + +```ts +type BattleReplay = { + id: string; + geometry_id: string; + target_geometry_ids: string[]; + detail: ReplayStage[]; +}; + +type ReplayStage = { + id: number; + title?: string; + detail_time_start: string; + detail_time_stop: string; + steps: ReplayStep[]; +}; + +type ReplayStep = { + duration: number; + use_UI_function: ReplayAction[]; + use_map_function: ReplayAction[]; + use_geo_function: ReplayAction[]; + use_narrow_function: ReplayAction[]; +}; + +type ReplayAction = { + function_name: T; + params: unknown[]; +}; +``` + +Ghi chú: + +- `use_narrow_function` là tên field hiện tại cho nhóm narrative. +- `params` là tuple positional, không phải object schema. +- `target_geometry_ids` là source truth cho replay draft; không persist `replayDraft`. +- `detail_time_start/detail_time_stop` là string theo form replay hiện tại, không phải `time_start/time_end` số của geometry. + +## 2. Runtime execution order + +Preview flatten replay thành danh sách step theo thứ tự stage/step. + +Trong mỗi step, dispatcher chạy các group action từ step hiện tại. Duration của step quyết định thời gian chờ trước step tiếp theo. Preview state có thể đổi: + +- map camera/labels +- timeline visible/filter/year +- hidden geometry ids +- title/descriptions/subtitle/dialog/image/toast +- wiki sidebar/open wiki +- playback speed + +Stop/reset preview khôi phục presentation state và một phần map/timeline baseline. + +## 3. UI actions + +| Action | Params | Runtime hiện tại | +| --- | --- | --- | +| `timeline` | `[visible: boolean]` | Ẩn/hiện TimelineBar trong preview | +| `layer_panel` | `[visible: boolean]` | No-op hiện tại | +| `wiki_panel` | `[visible: boolean]` | Mở/đóng wiki sidebar preview | +| `close_wiki_panel` | `[]` | Đóng wiki sidebar và clear active wiki | +| `zoom_panel` | `[visible: boolean]` | No-op hiện tại | +| `wiki` | `[wikiId: string]` | Mở wiki sidebar và active wiki id | +| `toast` | `[message: string]` | Hiện toast tạm thời | +| `wiki_header` | `[headerId: string]` | No-op hiện tại | +| `playback_speed` | `[speed: number]` | Đổi tốc độ phát preview | + +Legacy shape vẫn được dispatcher đọc: + +```ts +{ function_name: "UI", params: [optionName, ...payload] } +``` + +Shape mới nên dùng trực tiếp: + +```ts +{ function_name: "timeline", params: [true] } +``` + +## 4. Map actions + +| Action | Params | Runtime hiện tại | +| --- | --- | --- | +| `set_camera_view` | `[state]` | `map.easeTo` center/zoom/pitch/bearing/duration | +| `set_time_filter` | `[year: number]` | Set replay preview timeline year | +| `enable_timeline_filter` | `[]` | Bật timeline filter | +| `disable_timeline_filter` | `[]` | Tắt timeline filter | +| `toggle_labels` | `[visible: boolean]` | Legacy labels toggle | +| `show_labels` | `[]` | Hiện symbol text labels | +| `hide_labels` | `[]` | Ẩn symbol text labels | +| `show_all_geometries` | `[]` | Clear hidden geometry ids | +| `reset_camera_north` | `[]` | Set bearing về 0 | + +`set_camera_view` chấp nhận center dạng `[lng, lat]` hoặc `{ lng, lat }`. + +## 5. Geo actions + +| Action | Params | Runtime hiện tại | +| --- | --- | --- | +| `fly_to_geometry` | `[geometryId]` | Legacy: fly tới một geometry | +| `fly_to_geometries` | `[geometryIds, duration?]` | Fit/fly tới nhiều geometry | +| `set_geometry_visibility` | `[geometryIds, visible]` | Legacy: show/hide theo boolean | +| `show_geometries` | `[geometryIds]` | Bỏ ids khỏi hidden set | +| `hide_geometries` | `[geometryIds]` | Thêm ids vào hidden set | +| `fit_to_geometries` | `[geometryIds, duration?]` | Legacy: dùng fly/fit tới geometry | +| `orbit_camera_around_geometry` | `[geometryId, zoom?, pitch?, turns?, duration?]` | Ease camera quanh bbox geometry | +| `pulse_geometry` | `[geometryId, color?, repeat?, duration?]` | No-op trong dispatcher hiện tại | +| `animate_dashed_border` | `[geometryId, color?, width?, speed?, duration?]` | No-op trong dispatcher hiện tại | +| `set_geometry_style` | `[geometryIds, fill?, opacity?, stroke?, width?]` | No-op trong dispatcher hiện tại | +| `show_geometry_label` | `[geometryId, text?, color?, size?]` | No-op trong dispatcher hiện tại | +| `follow_geometry_path` | `[geometryId, duration?]` | Legacy: fly theo một path bằng fit/fly | +| `follow_geometries_path` | `[geometryIds, duration?, zoom?, padding?]` | Hiện dùng fly/fit tới nhiều geometry | +| `dim_other_geometries` | `[geometryIds]` | Chỉ hiện target ids, ẩn các geometry khác | + +Các action visual effect no-op vẫn có trong composer để giữ schema và chuẩn bị cho runtime effect sau này. + +## 6. Narrative actions + +| Action | Params | Runtime hiện tại | +| --- | --- | --- | +| `set_title` | `[title: string]` | Set title overlay | +| `clear_title` | `[]` | Clear title | +| `set_descriptions` | `[text: string]` | Set description overlay | +| `clear_descriptions` | `[]` | Clear descriptions | +| `show_dialog_box` | `[avatar, text, side, speaker?]` | Hiện dialog, side là `left` hoặc `right` | +| `clear_dialog_box` | `[]` | Clear dialog | +| `display_historical_image` | `[url, caption?]` | Hiện image overlay lịch sử | +| `clear_historical_image` | `[]` | Clear image | +| `set_step_subtitle` | `[subtitle: string | null]` | Set subtitle | +| `clear_step_subtitle` | `[]` | Clear subtitle | + +## 7. Composer shortcuts hiện có + +Map shortcuts: + +- `show_labels` +- `hide_labels` +- `enable_timeline_filter` +- `disable_timeline_filter` +- `set_time_filter` +- `reset_camera_north` +- `show_all_geometries` + +Geo shortcuts: + +- `fly_to_geometries` +- `follow_geometries_path` +- `show_geometries` +- `hide_geometries` +- `pulse_geometry` +- `animate_dashed_border` +- `orbit_camera_around_geometry` +- `show_geometry_label` +- `dim_other_geometries` +- `set_geometry_style` + +Narrative composer hiện hỗ trợ đầy đủ các narrative actions ở mục 6. + +## 8. Normalization và migration + +Khi load snapshot: + +- Replay thiếu `geometry_id` có thể fallback từ `id`. +- `target_geometry_ids` được normalize/dedupe, MAIN geo đứng đầu. +- Snapshot cũ có `replay_features` được chuyển thành `target_geometry_ids`. +- UI legacy action `{ function_name: "UI", params: [...] }` được normalize sang option action. +- Unknown action/function bị bỏ qua trong normalize/dispatcher. +- Normalizer snapshot hiện giữ các action đang có trong type/UI, gồm `close_wiki_panel`, `show_all_geometries` và các narrative `clear_*`. + +## 9. Undo và commit boundary + +- Replay mode dùng `replayUndoStack`, tách khỏi main undo. +- Sửa stage/step/action đi qua `editor.mutateActiveReplay`. +- Mỗi mutation tạo `replay_session` undo action. +- Thoát hoặc chuyển replay flush session vào `replays[]`. +- Commit đọc `editor.effectiveReplays`, nên có thể commit khi vẫn đang ở replay mode. +- Replay mode hiện không cho create/update/delete geometry. + +## 10. Checklist khi thêm replay action + +1. Thêm function name vào `src/uhm/types/projects.ts`. +2. Thêm label/summary trong `ReplayTimelineSidebar`. +3. Thêm composer hoặc shortcut trong `ReplayEffectsSidebar`. +4. Thêm runtime trong `replayDispatcher.ts` và action module phù hợp. +5. Thêm normalize support trong `editorSnapshot.ts`. +6. Xác định action có cần reset khi stop preview không. +7. Cập nhật file này và `commit_snapshot.ts`. diff --git a/src/uhm/doc/editor_snapshot_contract.md b/src/uhm/doc/editor_snapshot_contract.md new file mode 100644 index 0000000..cc620f7 --- /dev/null +++ b/src/uhm/doc/editor_snapshot_contract.md @@ -0,0 +1,244 @@ +# UHM Editor - snapshot contract + +Cập nhật: 2026-05-22. + +Tài liệu này mô tả ranh giới dữ liệu giữa editor runtime và commit payload. Nếu `editor_operations.md` trả lời "thao tác nào đổi gì", file này trả lời "commit gửi shape nào và vì sao". + +Nguồn chính: + +- `src/uhm/lib/editor/snapshot/editorSnapshot.ts` +- `src/uhm/doc/commit_snapshot.ts` +- `src/uhm/types/projects.ts` +- `src/uhm/types/geo.ts` + +## 1. Luồng build commit + +Luồng hiện tại: + +1. `commitSection()` kiểm tra project đang mở, pending changes và orphan geometry. +2. `editor.buildPayload()` lấy geometry diff để xác định operation. +3. `buildEditorSnapshot()` nhận `mainDraft`, snapshot collections, `effectiveReplays`, `previousSnapshot`. +4. Commit API nhận snapshot đã qua `toApiEditorSnapshot()`. +5. Sau commit thành công, FE chuyển snapshot mới về session shape bằng `toEditorSessionSnapshot()` và reset baseline. + +Payload API: + +```ts +{ + snapshot_json: EditorSnapshot; + edit_summary: string; +} +``` + +`toApiEditorSnapshot()` hiện normalize thêm: + +- `time_start/time_end`: ép về `number|null` nếu field tồn tại ở feature/entity/geometry. +- `geometries[].type`: đổi type key FE sang backend type code string hoặc `null`. +- `replays[]`: normalize `id`, `geometry_id`, `target_geometry_ids`, `detail`. + +## 2. Root snapshot shape + +| Field | Nguồn runtime | Ý nghĩa | +| --- | --- | --- | +| `editor_feature_collection` | Clone từ `mainDraft` đã bỏ field generate-only | FeatureCollection runtime phục vụ load lại editor | +| `entities` | `snapshotEntityRows` + entity ids phát hiện từ geometry | Entity rows inline/ref | +| `geometries` | `mainDraft.features` + deleted ids từ diff | Geometry rows có operation | +| `geometry_entity` | `feature.properties.entity_ids/entity_id` so với baseline | Join table geometry-entity | +| `wikis` | `snapshotWikis` so với baseline | Wiki rows inline/ref/delete | +| `entity_wiki` | `snapshotEntityWikiLinks` so với baseline | Join table entity-wiki | +| `replays` | `editor.effectiveReplays` | Script replay, không chứa `replayDraft` | + +Root fields optional ở type vì FE còn phải đọc snapshot cũ/partial, nhưng commit mới nên sinh đủ các collection có liên quan. + +## 3. Geometry contract + +### `geometries[]` + +Mỗi feature trong `mainDraft.features` sinh một row: + +| Field | Rule | +| --- | --- | +| `id` | `String(feature.properties.id)` | +| `source` | Luôn `"inline"` cho geometry đang tồn tại trong draft | +| `operation` | `"create"`, `"update"` hoặc `"reference"` theo baseline/diff | +| `type` | FE type key trước `toApiEditorSnapshot()`, backend code string sau normalize API | +| `draw_geometry` | `feature.geometry` | +| `binding` | `normalizeFeatureBindingIds(feature)` | +| `time_start` / `time_end` | `feature.properties.time_start/time_end ?? null` | +| `bbox` | BBox tính từ geometry, hoặc `null` | + +Geometry đã bị xóa sinh row: + +```ts +{ + id, + source: "ref", + operation: "delete" +} +``` + +### Operation rule + +`operation` của geometry đang tồn tại được tính theo thứ tự: + +- Nếu snapshot trước đã đánh dấu row này `create`, giữ `create`. +- Nếu không có previous feature và đang có previous snapshot hoặc feature chưa persisted, là `create`. +- Nếu id nằm trong geometry changes hoặc feature khác previous snapshot, là `update`. +- Còn lại là `reference`. + +## 4. FeatureCollection runtime contract + +`editor_feature_collection` giữ geometry để load lại editor, nhưng trước khi đưa vào snapshot FE xóa các field generate-only khỏi `feature.properties`: + +- `type` +- `time_start` +- `time_end` +- `binding` +- `entity_id` +- `entity_ids` +- `entity_name` +- `entity_names` +- `entity_label_candidates` +- `entity_type_id` + +Các field này được lưu ở collection chuẩn hơn: + +- `type/time/binding` nằm ở `geometries[]`. +- entity relation nằm ở `geometry_entity[]`. +- entity label/name được hydrate lại từ `entities[]` và join table khi load. + +## 5. Geometry-entity contract + +Join table chính là `geometry_entity[]`, không phải field denormalized trên feature. + +Runtime source: + +- `normalizeFeatureEntityIds(feature)` +- Ưu tiên `entity_ids[]` hợp lệ. +- Fallback `entity_id` nếu `entity_ids` rỗng. + +Build rule: + +- Link hiện có trong baseline và vẫn còn trong draft: `operation: "reference"`. +- Link mới trong draft: `operation: "binding"`. +- Link có trong baseline nhưng không còn trong draft: `operation: "delete"`. + +Rows được dedupe/sort theo `geometry_id`, rồi `entity_id`. + +Commit/submit hiện chặn nếu có geometry không có entity ids hợp lệ. Geometry-geometry `binding` không được tính là đã bind entity. + +## 6. Entity contract + +`entities[]` được build từ: + +- `snapshotEntityRows` hiện tại. +- Entity ids xuất hiện trong `geometry_entity[]` nhưng chưa có row entity, được bổ sung row ref tối thiểu. + +Row tối thiểu: + +```ts +{ + id: string; + source: "inline" | "ref"; + operation?: "create" | "update" | "delete" | "reference"; + name?: string; + description?: string | null; + time_start?: number; + time_end?: number; +} +``` + +Quy ước: + +- Entity backend/search thêm vào snapshot dùng `source: "ref"`, `operation: "reference"`. +- Entity tạo local dùng `source: "inline"`, `operation: "create"`. +- Sửa entity inline có thể giữ `create` nếu chưa commit hoặc thành `update`. + +## 7. Wiki contract + +`wikis[]` đến từ `snapshotWikis` so với baseline. + +Row chính: + +```ts +{ + id: string; + source: "inline" | "ref"; + operation?: "create" | "update" | "delete" | "reference"; + title: string; + slug?: string | null; + doc: string | null; +} +``` + +Rule xóa: + +- Nếu wiki có trong baseline nhưng không còn trong `snapshotWikis`, snapshot builder thêm row `operation: "delete"`. +- Khi UI xóa wiki, FE cũng xóa các `snapshotEntityWikiLinks` trỏ tới wiki đó trong cùng undo group. + +`doc` hiện ưu tiên HTML string. Plaintext là fallback cho dữ liệu cũ. + +## 8. Entity-wiki contract + +Runtime source là `snapshotEntityWikiLinks`. + +Build rule tương tự geometry-entity: + +- Link có trong baseline và vẫn còn: `reference`. +- Link mới: `binding`. +- Link bị remove so với baseline: `delete`. + +Rows được dedupe/sort theo `entity_id`, rồi `wiki_id`. + +## 9. Replay contract + +Commit gửi `replays[]` từ `editor.effectiveReplays`. + +Canonical shape: + +```ts +{ + id: string; + geometry_id: string; + target_geometry_ids: string[]; + detail: ReplayStage[]; +} +``` + +Rule: + +- `id` hiện bằng `geometry_id`. +- `target_geometry_ids` được normalize, MAIN geo đứng đầu. +- `detail` là danh sách stage/step/action. +- Không gửi `replayDraft` hoặc `replay_features`. + +Snapshot cũ có `replay_features` được FE migrate sang `target_geometry_ids` khi load. + +## 10. Validation trước commit/submit + +FE chặn commit nếu: + +- Chưa mở project. +- Không có pending changes. +- Có orphan geometry. +- Payload JSON vượt guardrail kích thước hiện tại khoảng 3.5MB. + +FE chặn submit nếu: + +- Project chưa có head commit. +- Còn pending changes chưa commit. +- Có orphan geometry. + +Missing/partial time hiện chỉ là trạng thái panel, không chặn commit. + +## 11. Checklist khi đổi snapshot + +Khi thêm field/collection mới: + +1. Cập nhật type runtime trong `src/uhm/types`. +2. Cập nhật `src/uhm/doc/commit_snapshot.ts`. +3. Cập nhật `buildEditorSnapshot()` và `toEditorSessionSnapshot()` nếu field cần round-trip. +4. Cập nhật `toApiEditorSnapshot()` nếu backend cần shape khác runtime. +5. Cập nhật undo nếu thao tác chỉnh field đó là user-facing persistent action. +6. Cập nhật dirty detection/pending save count nếu collection mới độc lập với geometry. +7. Cập nhật `editor_operations.md` và manual checklist. diff --git a/src/uhm/doc/editor_state_replay.md b/src/uhm/doc/editor_state_replay.md index 5a02742..b15acca 100644 --- a/src/uhm/doc/editor_state_replay.md +++ b/src/uhm/doc/editor_state_replay.md @@ -8,6 +8,7 @@ Nguồn thật: - `src/uhm/lib/editor/state/useEditorState.ts` - `src/uhm/lib/editor/project/useProjectCommands.ts` - `src/uhm/lib/editor/snapshot/editorSnapshot.ts` +- `src/uhm/doc/editor_replay_actions.md` ## 1. Kết luận ngắn @@ -15,7 +16,7 @@ Replay mode hiện tại có 2 lớp state: - `activeReplayDraft` - là `BattleReplay` đang chỉnh - - chỉ chứa `geometry_id`, `target_geometry_ids`, `detail` + - chỉ chứa `id`, `geometry_id`, `target_geometry_ids`, `detail` - `replayDraft` - là `FeatureCollection` local, được FE hydrate lại từ `mainDraft + target_geometry_ids` - chỉ dùng để map/render/select trong replay mode @@ -125,6 +126,10 @@ Nên khi `mode === "replay"`: - `editor.draftRef` trỏ vào `replayDraftRef` - map chỉ render tập geo đang nằm trong `target_geometry_ids` +`editor.draftRef` ở đây là ref nội bộ của editor state; map interaction dùng tên `renderDraftRef` để tránh nhầm với draft commit chính. + +Khi `mode === "replay_preview"`, page dùng `previewSession.draft` và replay preview state để tạo `mapRenderDraft` rồi render/ẩn geometry. Mode này không mutate `replayDraft` hoặc `mainDraft`. + ## 7. Replay mode còn sửa geometry không Không. @@ -132,7 +137,7 @@ Không. Hiện tại state layer đã chặn toàn bộ nhánh mutate geometry trong replay mode: - `createFeature` -- `createFeatureWithSnapshotEntities` +- `createFeatureWithSnapshotEntityRows` - `patchFeatureProperties` - `patchFeaturePropertiesBatch` - `updateFeature` @@ -161,6 +166,8 @@ Undo replay vẫn riêng ở: - `replayUndoStack` +Danh sách action và tuple `params` nằm ở `editor_replay_actions.md`. + ## 9. Khi nào replay được flush về `replays[]` `activeReplayDraft` chỉ là session đang mở. diff --git a/src/uhm/doc/editor_states.md b/src/uhm/doc/editor_states.md index 190e4e2..ccad78d 100644 --- a/src/uhm/doc/editor_states.md +++ b/src/uhm/doc/editor_states.md @@ -9,7 +9,7 @@ Editor đang tách làm hai khối: - `useEditorSessionState()` - state UI, session, form, project, timeline, background, wiki -- `useEditorState(initialData, snapshotUndo)` +- `useEditorState(baselineFeatureCollection, snapshotUndo)` - state draft hình học, diff và undo Nói ngắn gọn: @@ -19,26 +19,34 @@ Nói ngắn gọn: ## 2. State geometry trung tâm -### `initialData` +### `baselineFeatureCollection` - Nằm ở `useEditorSessionState()` -- Là `FeatureCollection` đang được nạp vào editor khi mở project hoặc restore commit +- Là `FeatureCollection` baseline được nạp vào editor khi mở project hoặc restore commit - Khi thay đổi, `useEditorState()` sẽ reset toàn bộ draft và baseline tương ứng -### `draft` +### `mainDraft` - Nằm trong `useEditorState()` -- Là nguồn dữ liệu render trực tiếp cho `Map` +- Là working copy geometry chính dùng cho edit/commit - Mọi thao tác create/update/delete geometry đều đi qua đây +### `editor.draft` + +- Là draft đang active theo mode +- Ở mode thường trỏ tới `mainDraft` +- Ở mode `replay` trỏ tới `replayDraft` +- Panel metadata/selection đọc từ đây, không đọc từ `mapRenderDraft` + ### `draftRef` -- Bản ref của `draft` -- Được dùng trong event handlers của map engine để luôn đọc được state mới nhất mà không phải rebind callback liên tục +- Ref nội bộ tương ứng với draft trong `useEditorState()` +- Được dùng để luôn đọc được state mới nhất mà không phải rebind callback liên tục +- Không nhầm với `renderDraftRef` trong `Map.tsx`, vốn là dữ liệu đang render/interact trên map ### `initialMapRef` -- `Map` tạo từ `initialData` +- `Map` tạo từ `baselineFeatureCollection` - Là baseline để tính diff giữa draft hiện tại và dữ liệu gốc của session ### `changes` @@ -55,7 +63,7 @@ Lưu ý: diff hiện chỉ là cơ chế nhận biết geometry nào đã thay ### `changeCount` - Số lượng geometry thay đổi hiện tại -- Được cộng thêm dirty state của wiki/entity/entity-wiki để tạo `pendingSaveCount` +- Được cộng thêm dirty state của wiki/entity/entity-wiki/replay để tạo `pendingSaveCount` ## 3. Undo state @@ -70,12 +78,17 @@ Kiểu action hiện có: - `snapshot_entities` - `snapshot_wikis` - `snapshot_entity_wiki` +- `replay` +- `replays` +- `replay_session` - `group` Ý nghĩa: - geometry create/delete/update/properties undo được trực tiếp trên `draft` - snapshot entity/wiki/link undo được apply qua `snapshotUndo` API truyền vào `useEditorState` +- `replay`/`replays` undo các thay đổi script replay đã flush vào collection chính +- `replay_session` undo các thay đổi stage/step/action khi đang ở mode `replay` - `group` dùng để gom nhiều thay đổi thành một thao tác undo logic Editor hiện có `undo`, nhưng chưa có redo. @@ -107,7 +120,23 @@ Editor hiện có `undo`, nhưng chưa có redo. `geometryMetaForm.binding` hiện chủ yếu là giá trị hiển thị/đồng bộ UI, còn chỉnh sửa binding thật đi qua `GeometryBindingPanel`. -### 4.3. Project/session task state +### 4.3. Replay state + +Replay state nằm trong `useEditorState()`: + +- `replays` + - collection script đã flush vào state chính +- `activeReplayDraft` + - `BattleReplay` đang sửa trong mode `replay` +- `replayDraft` + - `FeatureCollection` hydrate từ `mainDraft + activeReplayDraft.target_geometry_ids` +- `effectiveReplays` + - `replays` cộng overlay của `activeReplayDraft` nếu session hiện tại đã đổi nhưng chưa flush + +Undo của replay session dùng stack riêng khi `mode === "replay"`. +`replay_preview` là session preview trong page, dùng `previewSession`/`useReplayPreview()` và không persist. + +### 4.4. Project/session task state `useProjectSessionState()` gom các cờ async vào một state machine nhỏ: @@ -127,7 +156,7 @@ Ngoài ra còn có: - `baselineSnapshot` - `commitTitle` -### 4.4. Timeline state +### 4.5. Timeline state `useTimelineState()` giữ: @@ -139,7 +168,7 @@ Ngoài ra còn có: Trong page hiện tại, timeline filter đang dùng `timelineDraftYear`. Không có fetch dữ liệu project theo `timelineYear`; timeline đang là client-side visibility filter. -### 4.5. Background/session UI +### 4.6. Background/session UI `useBackgroundSessionState()` giữ: @@ -148,7 +177,7 @@ Không có fetch dữ liệu project theo `timelineYear`; timeline đang là cli Giá trị thật được load từ `localStorage` key `uhm.backgroundLayerVisibility.v1`. -### 4.6. Wiki/session state +### 4.7. Wiki/session state `useWikiSessionState()` giữ: @@ -159,11 +188,12 @@ Giá trị thật được load từ `localStorage` key `uhm.backgroundLayerVisi ## 5. Snapshot state -Editor đang làm việc với 3 snapshot collection chính ngoài geometry: +Editor đang làm việc với các snapshot collection chính ngoài geometry: -- `snapshotEntities` +- `snapshotEntityRows` - `snapshotWikis` - `snapshotEntityWikiLinks` +- `replays` / `effectiveReplays` Chúng đại diện cho "current session snapshot", không phải danh sách delta thô. @@ -193,12 +223,27 @@ Nó được cập nhật khi: ## 7. Derived state quan trọng trong page -### `timelineVisibleDraft` +### `mapRenderDraft` -- là `draft` đã qua filter timeline nếu `timelineFilterEnabled = true` +- là `FeatureCollection` duy nhất trong page quyết định geometry nào được truyền xuống map +- nguồn có thể là `mainDraft`, `replayDraft`, hoặc preview draft tùy mode +- đã qua filter timeline nếu `timelineFilterEnabled = true` +- đã qua replay preview hidden ids nếu đang preview - geometry mới tạo trong session không bị timeline filter ẩn -### `snapshotEntitiesVisible` +### `labelContextBaseDraft` và `mapLabelContextDraft` + +- chỉ dùng để enrich/lookup label entity cho map +- có thể chứa geometry bị `mapRenderDraft` lọc ra +- không được dùng để quyết định geometry nào render trên map + +### `geometryChoices` + +- nguồn dữ liệu cho `GeometryBindingPanel` +- thêm trạng thái derived như orphan entity, time completeness, timeline visibility, hidden/bound/new +- ID geometry không phải label chính của row, nhưng vẫn nằm trong tooltip/title + +### `snapshotEntityRowsVisible` - loại bỏ các row `delete` - dedupe theo `id` @@ -220,6 +265,7 @@ Nó được cập nhật khi: - `+1` nếu wiki dirty - `+1` nếu entities dirty - `+1` nếu entity-wiki dirty +- `+1` nếu replay dirty Đây là con số dùng trong UI commit, không phải số record backend chắc chắn sẽ thay đổi. @@ -228,8 +274,9 @@ Nó được cập nhật khi: Dirty check của: - `snapshotWikis` -- `snapshotEntities` +- `snapshotEntityRows` - `snapshotEntityWikiLinks` +- `editor.effectiveReplays` đều đang làm bằng cách normalize trước rồi so `JSON.stringify`. @@ -262,7 +309,7 @@ Xảy ra khi: Hiệu ứng: -- `initialData` đổi +- `baselineFeatureCollection` đổi - `useEditorState()` reset `draft` - `undoStack` bị clear - baseline map được build lại @@ -278,3 +325,4 @@ Hiệu ứng: - timeline state có `timelineYear`, nhưng page hiện dùng `timelineDraftYear` cho filtering - dirty count của commit không tương ứng một-một với số mutation backend - map selection, binding filter và timeline filter đều là state client-side +- trạng thái orphan/time/timeline trong `GeometryBindingPanel` là derived từ draft + visibility, không phải field persist riêng diff --git a/src/uhm/doc/export_json_replay.md b/src/uhm/doc/export_json_replay.md index 44528d2..d613712 100644 --- a/src/uhm/doc/export_json_replay.md +++ b/src/uhm/doc/export_json_replay.md @@ -5,6 +5,8 @@ Tài liệu này mô tả đúng payload mà nút `Export JSON` của replay đa Nguồn thật: - `src/uhm/components/editor/ReplayTimelineSidebar.tsx` +- `src/uhm/types/projects.ts` +- `src/uhm/doc/editor_replay_actions.md` ## 1. Kết luận ngắn diff --git a/src/uhm/doc/goong_apis_in_use.md b/src/uhm/doc/goong_apis_in_use.md index 4a97d59..bfb2305 100644 --- a/src/uhm/doc/goong_apis_in_use.md +++ b/src/uhm/doc/goong_apis_in_use.md @@ -4,7 +4,7 @@ Mục tiêu của tài liệu này: - mô tả **chính xác** frontend hiện tại đang dùng gì từ Goong - mô tả **backend cần proxy gì** để giấu `api_key` -- mô tả **response nào phải rewrite** +- mô tả **response nào phải sanitize/rewrite** - tránh liệt kê thừa các API Goong mà app hiện tại không đụng tới Phạm vi kiểm tra: @@ -22,33 +22,40 @@ Frontend hiện tại **không** `map.setStyle(goongStyleJson)` trực tiếp. Thay vào đó: -1. app tự `fetch()` 2 style JSON của Goong +1. app tự `fetch()` 2 style JSON của Goong qua backend proxy 2. app parse style JSON để lấy: - `raster source` từ `goong_satellite.json` - `sources + layers` cần thiết từ `goong_map_web.json` -3. app `map.addSource(...)` và `map.addLayer(...)` thủ công -4. từ thời điểm đó, **MapLibre tự request tiếp** các `source.url` -5. rồi từ các source manifest đó, **MapLibre lại tự request tiếp** các tile URLs nằm trong `tiles[]` +3. nếu source dùng `url`, app tiếp tục fetch source manifest qua proxy trong `tiles.ts` +4. app rewrite `tiles[]` về backend proxy rồi `map.addSource(...)` và `map.addLayer(...)` thủ công +5. từ thời điểm đó, **MapLibre tự request tiếp** tile/font URLs đã là URL proxy Hệ quả: - nếu BE chỉ proxy `assets/*.json` thì **chưa đủ** -- nếu BE chỉ proxy `sources/*.json` mà **không rewrite `tiles[]`** thì **vẫn lộ key ở request tile** +- proxy phải cover style JSON, source manifest, tile URLs và glyph PBF +- frontend hiện không nhúng `api_key` trong URL; backend proxy chịu trách nhiệm gọi upstream bằng key server-side nếu upstream yêu cầu ## 2. Luồng request thật hiện tại -### 2.1. App fetch trực tiếp style JSON +### 2.1. App fetch style JSON qua proxy -Frontend gọi trực tiếp: +Frontend gọi: -1. `https://tiles.goong.io/assets/goong_satellite.json?api_key=...` -2. `https://tiles.goong.io/assets/goong_map_web.json?api_key=...` +1. `${API_BASE_URL}/proxy/tiles.goong.io/assets/goong_satellite.json` +2. `${API_BASE_URL}/proxy/tiles.goong.io/assets/goong_map_web.json` + +Upstream gốc trong code vẫn là: + +1. `https://tiles.goong.io/assets/goong_satellite.json` +2. `https://tiles.goong.io/assets/goong_map_web.json` Nguồn trong code: -- `GOONG_SATELLITE_STYLE_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:15) -- `GOONG_VECTOR_OVERLAY_STYLE_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:19) -- `loadGoongStyleDocument(...)` ở [tiles.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/tiles.ts:211) +- `GOONG_SATELLITE_STYLE_UPSTREAM_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:8) +- `GOONG_VECTOR_OVERLAY_STYLE_UPSTREAM_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:9) +- `buildGoongProxyUrl(...)` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:29) +- `loadGoongStyleDocument(...)` ở [tiles.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/tiles.ts:199) Mục đích: @@ -63,9 +70,9 @@ Mục đích: - `Country Labels` - `Rivers` -### 2.2. MapLibre fetch source manifests +### 2.2. Frontend fetch source manifests qua proxy -Sau khi app clone source spec từ style JSON và `addSource(...)`, MapLibre tự bắn tiếp các request `source.url`. +Khi style source có field `url`, `tiles.ts` tự fetch source manifest qua proxy trước khi gọi `map.addSource(...)`. Các source URL đang xuất hiện trong style JSON: @@ -89,21 +96,24 @@ Các source URL đang xuất hiện trong style JSON: - `sources/goong.json` - vector source manifest cho các lớp `riversandlakes`, `vietnam_administrator` -### 2.3. MapLibre fetch tile URLs nằm trong source manifests +### 2.3. MapLibre fetch tile URLs đã rewrite Đây là phần dễ bị bỏ sót nhất. -Khi MapLibre đã tải `sources/satellite.json`, `sources/base.json`, `sources/goong.json`, nó sẽ tiếp tục request các URL nằm trong field: +Khi `tiles.ts` đã tải `sources/satellite.json`, `sources/base.json`, `sources/goong.json`, nó rewrite mọi URL trong field: - `tiles[]` +về `${API_BASE_URL}/proxy/tiles.goong.io/...`, rồi mới đưa source spec cho MapLibre. + Tức là runtime thật của frontend hiện tại là: -1. fetch style JSON -2. fetch source manifest -3. fetch tile URL bên trong source manifest +1. FE fetch style JSON qua proxy +2. FE fetch source manifest qua proxy +3. FE rewrite `tiles[]` về proxy +4. MapLibre fetch tile URL đã rewrite -Nếu backend muốn che key hoàn toàn, thì **bước 3 bắt buộc phải được proxy hoặc rewrite về domain backend**. +Nếu backend muốn che key hoàn toàn, thì backend proxy phải xử lý cả các tile URL này bằng key server-side. ## 3. Những upstream Goong resource đang dùng thật @@ -130,6 +140,7 @@ Lưu ý: - tile URL pattern chính xác phải đọc từ source manifest upstream ở runtime - backend không nên hardcode khi chưa xác minh nội dung `tiles[]` +- frontend hiện giữ nguyên upstream target path trong proxy URL sau khi strip `api_key` ## 4. Những thứ frontend hiện tại dùng thêm hoặc KHÔNG dùng @@ -143,7 +154,7 @@ Flow hiện tại **có dùng glyphs của Goong qua proxy**. Map đang trỏ `glyphs` vào: -- `/proxy/{encoded-https://tiles.goong.io/fonts/{fontstack}/{range}.pbf}` +- `${API_BASE_URL}/proxy/tiles.goong.io/fonts/{fontstack}/{range}.pbf` Nguồn trong code: @@ -201,7 +212,8 @@ Có 2 cách: #### Cách A: Transparent proxy -BE trả về gần như đúng response của Goong, chỉ rewrite URL. +BE trả về gần như đúng response của Goong, nhưng strip/sanitize mọi `api_key` lồng trong JSON. +Frontend hiện tự wrap các upstream URL đó bằng `buildGoongProxyUrl(...)`. Ưu điểm: @@ -210,7 +222,7 @@ BE trả về gần như đúng response của Goong, chỉ rewrite URL. Nhược điểm: -- BE phải rewrite nhiều chỗ +- BE phải sanitize JSON response để không lộ key trong body response #### Cách B: Normalize thành API nội bộ @@ -227,11 +239,13 @@ Nhược điểm: Với frontend hiện tại, **Cách A** là hợp lý nhất. +Lưu ý quan trọng: frontend hiện mong nhận `sources.*.url` và `tiles[]` ở dạng upstream URL hoặc relative URL. Không rewrite các URL này thành `/proxy/...` trong JSON response hiện tại, vì FE sẽ tự gọi `buildGoongProxyUrl(...)`; rewrite sẵn sẽ dễ bị double-proxy. + ## 6. Contract backend được khuyến nghị ### 6.1. Proxy style JSON -#### `GET /proxy/goong/assets/goong_satellite.json` +#### `GET /proxy/tiles.goong.io/assets/goong_satellite.json` Upstream: @@ -241,15 +255,16 @@ Backend phải: - fetch upstream bằng key server-side - parse JSON -- rewrite `sources.*.url` về domain backend +- strip `api_key` khỏi `sources.*.url`, `glyphs`, `sprite` nếu các field đó xuất hiện trong body +- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)` - có thể giữ nguyên các field khác Response: - `Content-Type: application/json` -- body: style JSON đã rewrite +- body: style JSON đã sanitize, chưa rewrite sang `/proxy/...` -#### `GET /proxy/goong/assets/goong_map_web.json` +#### `GET /proxy/tiles.goong.io/assets/goong_map_web.json` Upstream: @@ -259,17 +274,18 @@ Backend phải: - fetch upstream bằng key server-side - parse JSON -- rewrite `sources.*.url` về domain backend +- strip `api_key` khỏi `sources.*.url`, `glyphs`, `sprite` nếu các field đó xuất hiện trong body +- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)` - có thể giữ nguyên các field khác Response: - `Content-Type: application/json` -- body: style JSON đã rewrite +- body: style JSON đã sanitize, chưa rewrite sang `/proxy/...` ### 6.2. Proxy source manifests -#### `GET /proxy/goong/sources/satellite.json` +#### `GET /proxy/tiles.goong.io/sources/satellite.json` Upstream: @@ -279,7 +295,8 @@ Backend phải: - fetch upstream - parse JSON -- rewrite mọi URL trong `tiles[]` về domain backend +- strip `api_key` khỏi mọi URL trong `tiles[]` +- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)` - giữ nguyên metadata quan trọng: - `tileSize` - `minzoom` @@ -291,9 +308,9 @@ Backend phải: Response: - `Content-Type: application/json` -- body: source manifest đã rewrite +- body: source manifest đã sanitize, chưa rewrite sang `/proxy/...` -#### `GET /proxy/goong/sources/base.json` +#### `GET /proxy/tiles.goong.io/sources/base.json` Upstream: @@ -303,10 +320,11 @@ Backend phải: - fetch upstream - parse JSON -- rewrite mọi URL trong `tiles[]` về domain backend +- strip `api_key` khỏi mọi URL trong `tiles[]` +- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)` - giữ nguyên metadata tilejson khác -#### `GET /proxy/goong/sources/goong.json` +#### `GET /proxy/tiles.goong.io/sources/goong.json` Upstream: @@ -316,22 +334,17 @@ Backend phải: - fetch upstream - parse JSON -- rewrite mọi URL trong `tiles[]` về domain backend +- strip `api_key` khỏi mọi URL trong `tiles[]` +- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)` - giữ nguyên metadata tilejson khác ### 6.3. Proxy tile endpoints Backend bắt buộc phải có route để trả tile thật. -Có thể làm generic, ví dụ: +Frontend hiện build URL proxy generic theo upstream target: -- `GET /proxy/goong/tiles/*` - -hoặc explicit hơn theo source: - -- `GET /proxy/goong/tiles/satellite/...` -- `GET /proxy/goong/tiles/base/...` -- `GET /proxy/goong/tiles/goong/...` +- `GET /proxy/tiles.goong.io/...` Yêu cầu: @@ -357,8 +370,9 @@ Luồng: 1. FE đọc `goong_satellite.json` 2. FE lấy `sources.satellite` -3. MapLibre gọi `sources/satellite.json` -4. MapLibre gọi raster tile URLs trong `tiles[]` +3. FE gọi `sources/satellite.json` qua proxy trong `tiles.ts` +4. FE rewrite `tiles[]` về proxy URL +5. MapLibre gọi raster tile URLs đã rewrite BE cần cover: @@ -372,9 +386,10 @@ Luồng: 1. FE đọc `goong_map_web.json` 2. FE lấy selected layers + selected sources -3. MapLibre gọi `sources/base.json` -4. MapLibre gọi `sources/goong.json` -5. MapLibre gọi vector tile URLs của 2 source manifest này +3. FE gọi `sources/base.json` qua proxy trong `tiles.ts` +4. FE gọi `sources/goong.json` qua proxy trong `tiles.ts` +5. FE rewrite `tiles[]` về proxy URL +6. MapLibre gọi vector tile URLs đã rewrite BE cần cover: @@ -386,20 +401,20 @@ BE cần cover: Nếu chỉ làm đúng những gì frontend hiện tại dùng, checklist tối thiểu là: -1. proxy `assets/goong_satellite.json` -2. proxy `assets/goong_map_web.json` -3. proxy `sources/satellite.json` -4. proxy `sources/base.json` -5. proxy `sources/goong.json` -6. proxy toàn bộ tile URL được khai báo trong `sources/satellite.json` -7. proxy toàn bộ tile URL được khai báo trong `sources/base.json` -8. proxy toàn bộ tile URL được khai báo trong `sources/goong.json` +1. proxy `tiles.goong.io/assets/goong_satellite.json` +2. proxy `tiles.goong.io/assets/goong_map_web.json` +3. proxy `tiles.goong.io/sources/satellite.json` +4. proxy `tiles.goong.io/sources/base.json` +5. proxy `tiles.goong.io/sources/goong.json` +6. proxy `tiles.goong.io/fonts/{fontstack}/{range}.pbf` +7. proxy toàn bộ tile URL được khai báo trong `sources/satellite.json` +8. proxy toàn bộ tile URL được khai báo trong `sources/base.json` +9. proxy toàn bộ tile URL được khai báo trong `sources/goong.json` ## 9. Những gì BE chưa cần làm ngay Cho flow hiện tại, BE **chưa cần**: -- proxy Goong `glyphs` - proxy Goong `sprite` - proxy geocoding / directions / autocomplete @@ -417,9 +432,10 @@ vì khi đó chúng có thể trở thành dependency bắt buộc. Nếu muốn làm ít rủi ro nhất: 1. làm proxy `assets/*.json` -2. rewrite `sources.*.url` +2. sanitize nested `api_key` trong style JSON 3. làm proxy `sources/*.json` -4. rewrite `tiles[]` +4. sanitize nested `api_key` trong source manifests 5. làm proxy generic cho tile +6. làm proxy Goong fonts/glyphs -Nếu làm thiếu bước 4 hoặc 5 thì key vẫn có thể lộ ở request tile. +Nếu sanitize JSON thiếu thì key có thể lộ ngay trong response style/source. Nếu proxy tile/font thiếu thì map background hoặc labels có thể không tải được. diff --git a/src/uhm/doc/goong_map_web_structure.md b/src/uhm/doc/goong_map_web_structure.md index f22ff49..2db2326 100644 --- a/src/uhm/doc/goong_map_web_structure.md +++ b/src/uhm/doc/goong_map_web_structure.md @@ -122,8 +122,12 @@ Những label dễ gây rối nếu bật nhiều: ## Gợi ý mapping cho UI -- `Country Borders` -> `boundary-land-type-0` + `boundary-land-type-0-bg` -- `Province Borders` -> `boundary-land-type-1` + `boundary-land-type-1-bg` -- `District Borders` -> `boundary-land-type-2` + `boundary-land-type-2-bg` -- `Country Labels` -> `place-country-*`, `place-city-capital*`, `place-city*`, `place-town*` -- `Rivers` -> `water`, `water-shadow`, `river-name-*`, `lake-name_*` +Mapping hiện tại trong `tiles.ts` là heuristic runtime, không hardcode đúng từng id này: + +- `Country Borders` -> ưu tiên `boundary-land-type-0`, bỏ `boundary-land-type-0-bg` +- `Province Borders` -> ưu tiên `boundary-land-type-1`, bỏ `boundary-land-type-1-bg` +- `District Borders` -> `boundary-land-type-2` và các layer cấp sâu hơn +- `Country Labels` -> symbol layer có text field và tên/source-layer giống country/admin/place/city/town/capital +- `Rivers` -> line/fill layer có tên/source-layer giống water/waterway/river/stream/canal/lake/reservoir/sea/ocean + +Water label symbol như `river-name-*`/`lake-name_*` chỉ được đưa vào nếu heuristic sau này mở rộng; code hiện tại chủ yếu lấy line/fill water. diff --git a/src/uhm/doc/goong_proxy_backend_guide.md b/src/uhm/doc/goong_proxy_backend_guide.md index c26207a..02c4fd6 100644 --- a/src/uhm/doc/goong_proxy_backend_guide.md +++ b/src/uhm/doc/goong_proxy_backend_guide.md @@ -4,8 +4,8 @@ Tài liệu này mô tả: - luồng request thật của frontend hiện tại - backend cần proxy chỗ nào -- backend cần rewrite chỗ nào -- trade-off hiệu suất nếu proxy/rewrite toàn bộ Goong +- backend cần sanitize/rewrite chỗ nào +- trade-off hiệu suất nếu proxy toàn bộ Goong - khuyến nghị triển khai thực dụng cho team BE Tài liệu liên quan: @@ -26,21 +26,23 @@ Frontend hiện tại không `setStyle(goongStyle)` trực tiếp cho MapLibre. Thay vào đó: -1. FE tự `fetch()` style JSON của Goong +1. FE gọi style JSON qua `buildGoongProxyUrl(...)` 2. FE parse style JSON 3. FE lấy ra: - raster source cho satellite - selected vector sources/layers cho borders, labels, rivers -4. FE `addSource()` và `addLayer()` thủ công -5. MapLibre tự request tiếp `source.url` -6. Từ source manifest, MapLibre tự request tiếp các tile URLs trong `tiles[]` +4. FE gọi source manifest qua `buildGoongProxyUrl(...)` nếu style source có `url` +5. FE rewrite `tiles[]` về proxy URL rồi `addSource()` và `addLayer()` thủ công +6. MapLibre request tile/font URLs đã là URL proxy Điểm quan trọng: -- browser có thể không chỉ gọi `assets/*.json` -- browser sẽ đi sâu thêm ít nhất 2 tầng: +- browser không được gọi trực tiếp `tiles.goong.io` +- browser vẫn sẽ đi qua backend proxy ở các tầng: + - `assets/*.json` - `sources/*.json` - tile URLs trong `tiles[]` + - `fonts/{fontstack}/{range}.pbf` ## 2. Luồng request hiện tại @@ -48,20 +50,29 @@ Thay vào đó: sequenceDiagram participant FE as Frontend participant GL as MapLibre + participant BE as Backend Proxy participant GO as Goong - FE->>GO: GET assets/goong_satellite.json?api_key=... - FE->>GO: GET assets/goong_map_web.json?api_key=... + FE->>BE: GET /proxy/tiles.goong.io/assets/goong_satellite.json + FE->>BE: GET /proxy/tiles.goong.io/assets/goong_map_web.json + BE->>GO: fetch upstream style JSON with server-side key + GO-->>BE: style JSON + BE-->>FE: sanitized style JSON - FE->>GL: addSource(raster/vector) + addLayer(...) + FE->>BE: GET /proxy/tiles.goong.io/sources/satellite.json + FE->>BE: GET /proxy/tiles.goong.io/sources/base.json + FE->>BE: GET /proxy/tiles.goong.io/sources/goong.json + BE->>GO: fetch upstream source manifests with server-side key + GO-->>BE: source manifests + BE-->>FE: sanitized source manifests - GL->>GO: GET sources/satellite.json?api_key=... - GL->>GO: GET sources/base.json?api_key=... - GL->>GO: GET sources/goong.json?api_key=... + FE->>GL: addSource(proxy tile URLs) + addLayer(...) - GL->>GO: GET raster tile URLs from satellite tiles[] - GL->>GO: GET vector tile URLs from base tiles[] - GL->>GO: GET vector tile URLs from goong tiles[] + GL->>BE: GET /proxy/tiles.goong.io/...tile... + GL->>BE: GET /proxy/tiles.goong.io/fonts/{fontstack}/{range}.pbf + BE->>GO: fetch upstream tile/font bytes + GO-->>BE: bytes + BE-->>GL: bytes ``` ## 3. Mục tiêu của backend proxy @@ -75,35 +86,42 @@ thì backend phải đảm bảo: 1. browser chỉ gọi domain BE 2. BE gọi Goong bằng key server-side -3. mọi URL Goong lồng bên trong JSON đều được rewrite về domain BE +3. mọi URL Goong lồng bên trong JSON đều được sanitize để không chứa `api_key` +4. frontend nhận URL upstream/relative sạch để tự wrap qua `buildGoongProxyUrl(...)` Nếu thiếu bước 3: -- `api_key` vẫn có thể lộ ở request tầng sau +- `api_key` có thể lộ ngay trong response JSON ở browser devtools -## 4. Những gì cần rewrite +## 4. Những gì cần sanitize/rewrite ### 4.1. Style JSON -Trong `goong_satellite.json` và `goong_map_web.json`, BE cần rewrite: +Trong `goong_satellite.json` và `goong_map_web.json`, BE cần sanitize: - `sources.*.url` +- `glyphs` +- `sprite` Ví dụ: - từ `https://tiles.goong.io/sources/base.json?api_key=...` -- thành `/proxy/goong/sources/base.json` +- thành `https://tiles.goong.io/sources/base.json` + +Không rewrite sẵn thành `/proxy/...` với frontend hiện tại, vì `tiles.ts` đang tự gọi `buildGoongProxyUrl(...)`. ### 4.2. Source manifests -Trong `sources/satellite.json`, `sources/base.json`, `sources/goong.json`, BE cần rewrite: +Trong `sources/satellite.json`, `sources/base.json`, `sources/goong.json`, BE cần sanitize: - mọi phần tử trong `tiles[]` Ví dụ: - từ `https://.../{z}/{x}/{y}...api_key=...` -- thành `/proxy/goong/tiles/...` +- thành `https://.../{z}/{x}/{y}...` + +Sau đó frontend rewrite URL sạch này về `${API_BASE_URL}/proxy/tiles.goong.io/...`. ### 4.3. Những field còn phải để ý cho flow hiện tại @@ -123,27 +141,28 @@ Nếu sau này FE chuyển sang `map.setStyle(goongStyleJson)` trực tiếp th ### 5.1. Style endpoints -- `GET /proxy/goong/assets/goong_satellite.json` -- `GET /proxy/goong/assets/goong_map_web.json` +- `GET /proxy/tiles.goong.io/assets/goong_satellite.json` +- `GET /proxy/tiles.goong.io/assets/goong_map_web.json` Nhiệm vụ: - gọi upstream Goong bằng key server-side - parse JSON -- rewrite `sources.*.url` -- trả JSON đã rewrite +- strip `api_key` khỏi nested URL +- trả JSON đã sanitize, chưa rewrite nested URL sang `/proxy/...` ### 5.2. Source endpoints -- `GET /proxy/goong/sources/satellite.json` -- `GET /proxy/goong/sources/base.json` -- `GET /proxy/goong/sources/goong.json` +- `GET /proxy/tiles.goong.io/sources/satellite.json` +- `GET /proxy/tiles.goong.io/sources/base.json` +- `GET /proxy/tiles.goong.io/sources/goong.json` Nhiệm vụ: - gọi upstream Goong bằng key server-side - parse JSON -- rewrite `tiles[]` +- strip `api_key` khỏi `tiles[]` +- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)` - giữ nguyên: - `bounds` - `minzoom` @@ -154,9 +173,9 @@ Nhiệm vụ: ### 5.3. Tile endpoint -Gợi ý route generic: +Route generic frontend hiện build: -- `GET /proxy/goong/tiles/*` +- `GET /proxy/tiles.goong.io/...` Nhiệm vụ: @@ -180,24 +199,25 @@ sequenceDiagram participant BE as Backend Proxy participant GO as Goong - FE->>BE: GET /proxy/goong/assets/goong_satellite.json - FE->>BE: GET /proxy/goong/assets/goong_map_web.json + FE->>BE: GET /proxy/tiles.goong.io/assets/goong_satellite.json + FE->>BE: GET /proxy/tiles.goong.io/assets/goong_map_web.json BE->>GO: fetch upstream style JSON GO-->>BE: style JSON - BE-->>FE: rewritten style JSON + BE-->>FE: sanitized style JSON - FE->>GL: addSource(raster/vector) + addLayer(...) - - GL->>BE: GET /proxy/goong/sources/satellite.json - GL->>BE: GET /proxy/goong/sources/base.json - GL->>BE: GET /proxy/goong/sources/goong.json + FE->>BE: GET /proxy/tiles.goong.io/sources/satellite.json + FE->>BE: GET /proxy/tiles.goong.io/sources/base.json + FE->>BE: GET /proxy/tiles.goong.io/sources/goong.json BE->>GO: fetch upstream source manifests GO-->>BE: source manifests - BE-->>GL: rewritten source manifests + BE-->>FE: sanitized source manifests - GL->>BE: GET /proxy/goong/tiles/... + FE->>GL: addSource(proxy tile URLs) + addLayer(...) + + GL->>BE: GET /proxy/tiles.goong.io/...tile... + GL->>BE: GET /proxy/tiles.goong.io/fonts/... BE->>GO: fetch upstream tile GO-->>BE: tile bytes BE-->>GL: tile bytes @@ -205,11 +225,11 @@ sequenceDiagram ## 7. Trade-off hiệu suất -### 7.1. Rewrite JSON có chậm không? +### 7.1. Sanitize JSON có chậm không? Có overhead, nhưng **rất nhỏ** so với tile traffic. -JSON cần rewrite hiện tại chỉ gồm: +JSON cần sanitize hiện tại chỉ gồm: - 2 style JSON - 3 source manifests @@ -218,7 +238,7 @@ Những file này nhỏ, số lượng ít, và có thể cache rất mạnh. Kết luận: -- rewrite JSON không phải bottleneck chính +- sanitize JSON không phải bottleneck chính ### 7.2. Tile proxy mới là chỗ đắt @@ -235,21 +255,20 @@ Các ảnh hưởng có thể thấy: - tăng CPU/memory nếu BE buffer response thay vì stream - tăng load connection pool tới Goong -### 7.3. Nếu không rewrite tile URL +### 7.3. Nếu không proxy tile/font URL -Nếu BE chỉ rewrite style/source JSON nhưng không rewrite `tiles[]`: +Nếu BE chỉ proxy style/source JSON nhưng thiếu tile/font route: -- browser vẫn gọi Goong trực tiếp ở bước tile -- `api_key` vẫn có thể lộ +- MapLibre request tile/font proxy URL sẽ lỗi +- hoặc nếu FE bị đổi để dùng URL upstream trực tiếp thì browser sẽ gọi Goong và có thể lộ key Tức là: -- hiệu suất tốt hơn -- nhưng mục tiêu bảo mật key không đạt +- tile/font route vẫn là phần bắt buộc nếu muốn giữ kiến trúc hiện tại ## 8. Cách giảm thiểu impact hiệu suất -### 8.1. Cache rewritten JSON ở BE +### 8.1. Cache sanitized JSON ở BE Khuyến nghị: @@ -266,7 +285,7 @@ TTL có thể dài vì: Tối ưu: -- chỉ rewrite một lần rồi reuse +- chỉ sanitize một lần rồi reuse ### 8.2. Stream tile response @@ -292,18 +311,18 @@ Nếu BE/ngược phía CDN có cache tốt, impact sẽ giảm rất nhiều. Nếu production có CDN/nginx/edge cache: - cache mạnh cho: - - rewritten style JSON - - rewritten source manifests + - sanitized style JSON + - sanitized source manifests - tile responses -Điều này quan trọng hơn tối ưu code rewrite. +Điều này quan trọng hơn tối ưu code sanitize. -### 8.5. Đừng rewrite tile mỗi request theo kiểu string building phức tạp +### 8.5. Đừng parse manifest ở mỗi tile request Nên: -- rewrite `tiles[]` một lần ở source manifest -- tile route chỉ resolve path đơn giản và forward +- sanitize source manifest một lần rồi cache +- tile route chỉ resolve target path đơn giản và forward Không nên: @@ -313,18 +332,19 @@ Không nên: Nếu team BE muốn giải pháp cân bằng giữa bảo mật và hiệu suất: -### Option A. Full proxy, full rewrite +### Option A. Full proxy, sanitize JSON BE cover: 1. style JSON 2. source manifests 3. tiles +4. fonts/glyphs Ưu điểm: - key không lộ ra browser -- FE không cần biết upstream Goong +- FE vẫn dùng upstream target path sạch rồi tự wrap proxy URL Nhược điểm: @@ -337,7 +357,7 @@ BE cover: 1. style JSON 2. source manifests -Nhưng không rewrite `tiles[]` +Nhưng để tile/font đi trực tiếp upstream. Ưu điểm: @@ -346,28 +366,27 @@ Nhưng không rewrite `tiles[]` Nhược điểm: - key vẫn lộ ở tile request +- không khớp với code hiện tại nếu `buildGoongProxyUrl(...)` vẫn được dùng cho tile/font Kết luận: - nếu ưu tiên bảo mật key thật sự: dùng **Option A** -- nếu ưu tiên hiệu suất hơn và chấp nhận domain restrictions của Goong: dùng **Option B** +- nếu ưu tiên hiệu suất hơn và chấp nhận domain restrictions của Goong: **Option B cần đổi frontend** ## 10. Recommendation cho codebase hiện tại Với frontend hiện tại, hướng hợp lý nhất là: 1. giữ nguyên FE logic parse style/source như hiện nay -2. chuyển các URL Goong ở `config.ts` sang endpoint nội bộ BE -3. để BE rewrite: - - `sources.*.url` - - `tiles[]` -4. để BE stream tile response -5. cache rewritten JSON ở BE +2. giữ `config.ts` dùng upstream URL sạch rồi để `buildGoongProxyUrl(...)` wrap thành `${API_BASE_URL}/proxy/tiles.goong.io/...` +3. để BE sanitize nested `api_key` trong style/source JSON, nhưng không rewrite nested URL thành `/proxy/...` +4. để BE stream tile/font response +5. cache sanitized JSON ở BE Nói ngắn: -- rewrite JSON: nên làm -- rewrite tile URLs: bắt buộc nếu muốn giấu key +- sanitize JSON: bắt buộc để không lộ key trong response +- FE rewrite tile URLs bằng `buildGoongProxyUrl(...)` - proxy tile: phần tốn hiệu suất nhất - muốn bù hiệu suất: phải dùng cache/stream/CDN tốt @@ -375,10 +394,11 @@ Nói ngắn: 1. Tạo route proxy cho 2 style JSON 2. Tạo route proxy cho 3 source manifests -3. Rewrite `sources.*.url` trong style JSON -4. Rewrite `tiles[]` trong source manifests +3. Strip `api_key` khỏi nested URL trong style JSON +4. Strip `api_key` khỏi `tiles[]` trong source manifests 5. Tạo route proxy tile generic -6. Stream tile response -7. Preserve cache headers -8. Cache rewritten JSON -9. Kiểm tra browser không còn request trực tiếp `tiles.goong.io` +6. Tạo route proxy fonts/glyphs +7. Stream tile/font response +8. Preserve cache headers +9. Cache sanitized JSON +10. Kiểm tra browser không còn request trực tiếp `tiles.goong.io` diff --git a/src/uhm/doc/map_engine.md b/src/uhm/doc/map_engine.md index b815981..5fdfea4 100644 --- a/src/uhm/doc/map_engine.md +++ b/src/uhm/doc/map_engine.md @@ -29,21 +29,23 @@ Nếu map init lỗi, `Map.tsx` render overlay lỗi thay vì crash im lặng. ## 2. Base style và background layers -`getBaseMapStyle()` dựng style MapLibre từ vector tile source `base`. +`getBaseMapStyle()` chỉ dựng skeleton style MapLibre: -Background layers hiện có: +- `glyphs` trỏ vào Goong glyph proxy +- `sources: {}` +- một layer `background` màu nền tối -- `graticules-line` -- `land` -- `bg-countries-fill` -- `bg-country-borders-line` -- `country-labels` -- `regions-line` -- `lakes-fill` -- `rivers-line` -- `geolines-line` +Background thật được thêm sau khi map load: -Visibility của các layer này đi qua `BackgroundLayerVisibility`. +- `raster-base-layer` được lazy-add từ `goong_satellite.json` qua proxy khi visibility bật. +- overlay vector từ `goong_map_web.json` được clone theo nhóm: + - `bg-country-borders-line` + - `bg-province-borders-line` + - `bg-district-borders-line` + - `country-labels` + - `rivers-line` + +Visibility của các nhóm này đi qua `BackgroundLayerVisibility`. ## 3. Sources mà editor đang dùng @@ -85,17 +87,20 @@ Source này dùng cho: `useMapSync()` chịu trách nhiệm: -1. filter draft theo binding nếu `respectBindingFilter = true` -2. filter theo geometry visibility -3. split feature thành nhóm polygon/line/point -4. decorate line/polygon/point cho label rendering -5. build source riêng cho path arrows -6. set selected feature state +1. nhận `renderDraft` đã được page áp timeline/replay/preview filter trước +2. filter draft theo binding nếu `applyGeometryBindingFilter = true` +3. filter theo geometry visibility +4. split feature thành nhóm polygon/line/point +5. decorate line/polygon/point cho label rendering +6. build source riêng cho path arrows +7. set selected feature state Điểm quan trọng: -- data mà map nhận không phải raw `draft` nguyên xi -- nó là `draft` sau khi đã qua visibility, binding filter và label decoration +- data mà map render không phải raw `mainDraft` nguyên xi +- `renderDraft` là nguồn quyết định geometry nào xuất hiện trên map +- `labelContextDraft` chỉ dùng để lookup label/entity name, có thể chứa geometry đã bị timeline filter ẩn, và không được dùng để quyết định render +- source MapLibre cuối cùng là `renderDraft` sau khi đã qua binding filter, geometry visibility và label decoration ## 5. Map interaction layer @@ -112,6 +117,8 @@ Binding hiện tại: `add-point` được init riêng bằng `initPoint`, nhưng hiện chưa được đưa vào `engineBindingsRef` như các mode còn lại; logic create point vẫn được bind trong `setupMapInteractions`. +`replay_preview` không có engine interaction riêng; preview controller điều khiển camera/timeline/visibility qua replay dispatcher. + ## 6. Các engine cụ thể ### `initDrawing` @@ -153,11 +160,12 @@ Binding hiện tại: - bắt đầu edit geometry - chuyển sang `replay` -`replay` hiện không phải cinematic replay đầy đủ. -Nó là mode hiển thị tập trung vào một geometry: +Trong map interaction, `replay` vẫn dùng `initSelect`; `replay_preview` không cho edit/select theo engine. +Phần script/preview replay nằm ở sidebar và preview overlay: -- có nút thoát replay -- có thể ẩn geometry ngoài danh sách `binding` +- map render `replayDraft` hydrate từ `target_geometry_ids` +- preview action có thể điều khiển camera, timeline, hidden geometry ids và presentation overlay +- replay mode không cho mutate geometry chính ## 8. Đồng bộ selection và feature state @@ -194,6 +202,7 @@ Nếu thất bại, map giữ nguyên center mặc định. ## 11. Những điều cần nhớ khi sửa map engine - preview source/layer và persisted source/layer là hai tầng khác nhau -- `draftRef` được dùng để tránh closure stale trong event handlers +- `renderDraftRef` trong map interaction là dữ liệu đang được render/interact, không phải canonical commit draft +- `draftRef` trong `useEditorState()` vẫn là ref nội bộ của draft để tránh closure stale trong editor state - `Map` chỉ là orchestration component; logic lớn nằm ở hooks - geometry render pipeline phụ thuộc khá nhiều vào `mapUtils.ts`, không chỉ mỗi `useMapSync.ts` diff --git a/src/uhm/doc/map_styling.md b/src/uhm/doc/map_styling.md index 50ac4b5..aa32829 100644 --- a/src/uhm/doc/map_styling.md +++ b/src/uhm/doc/map_styling.md @@ -11,7 +11,7 @@ Map hiện có hai nhóm style tách biệt: ### Background/base map -Định nghĩa trong `useMapLayers.ts` qua `getBaseMapStyle()`. +`getBaseMapStyle()` chỉ tạo skeleton style có `background` layer và Goong glyph proxy. Raster/vector background thật được thêm sau khi map load qua `mapUtils.ts` và `tiles.ts`. ### Geotype style @@ -22,24 +22,23 @@ Map hiện có hai nhóm style tách biệt: Danh sách layer toggle được expose ở `backgroundLayers.ts`: - `raster-base-layer` -- `graticules-line` -- `land` -- `bg-countries-fill` - `bg-country-borders-line` +- `bg-province-borders-line` +- `bg-district-borders-line` - `country-labels` -- `regions-line` -- `lakes-fill` - `rivers-line` -- `geolines-line` Lưu ý: -- không phải layer nào trong list cũng nhất thiết được add từ cùng một source path trong tương lai +- `raster-base-layer` là layer raster lazy-add từ `goong_satellite.json` +- các nhóm còn lại là overlay layer clone từ `goong_map_web.json` +- overlay layer thật có id dạng `goong-...`, nhưng metadata `uhmBackgroundGroupId` trỏ về toggle id ở trên - `BackgroundLayersPanel` chỉ biết toggle theo `id` Visibility mặc định: -- tất cả `true` +- `raster-base-layer`, `bg-country-borders-line`, `country-labels`, `rivers-line` bật +- `bg-province-borders-line`, `bg-district-borders-line` tắt - được persist bằng `uhm.backgroundLayerVisibility.v1` ## 3. Geotype registry @@ -77,7 +76,7 @@ Các type đang được register: - `port` - `bridge` -`GEOMETRY_TYPE_OPTIONS` trong `geometryTypeOptions.ts` phải khớp với tập geotype này nếu muốn user chọn được từ UI. +`GEOMETRY_TYPE_OPTIONS` trong `src/uhm/lib/map/geo/geometryTypeOptions.ts` phải khớp với tập geotype này nếu muốn user chọn được từ UI. ## 4. Type matching @@ -119,6 +118,8 @@ Point geotype dùng icon pipeline trong: - `shared/pointStyle.ts` - `ensurePointGeotypeIcons(map)` +Icon point hiện chọn theo geotype bình thường. Không còn branch icon/style riêng cho draft-orphan geometry. + Điều này có nghĩa là khi thêm geotype point mới, chỉ thêm layer là chưa đủ; cần chắc icon/style builder cũng hiểu type mới đó. ## 7. Preview và edit styling @@ -158,6 +159,8 @@ Có ba lớp filter hiển thị trong runtime: Vì vậy khi một geometry "không hiện", có thể nguyên nhân nằm ở data filtering chứ không phải style layer. +Geometry không bind entity không có màu/icon riêng trên map. Trạng thái orphan/time/timeline nằm trong `GeometryBindingPanel`, còn map chỉ giữ style geotype + selected/focus/edit states. + ## 9. Thêm geotype mới - checklist đúng với code hiện tại Nếu thêm một geotype mới, nên đi theo checklist này: diff --git a/src/uhm/doc/project_workflow.md b/src/uhm/doc/project_workflow.md index e2b5d7e..ee744a4 100644 --- a/src/uhm/doc/project_workflow.md +++ b/src/uhm/doc/project_workflow.md @@ -60,7 +60,7 @@ Phần nó thật sự quan tâm là: ### Bước 1: load baseline - `baselineSnapshot` lấy từ head commit hoặc commit được restore -- `initialData` lấy từ `baselineSnapshot.editor_feature_collection` +- `baselineFeatureCollection` lấy từ `baselineSnapshot.editor_feature_collection` - `useEditorState()` reset draft và undo ### Bước 2: chỉnh sửa cục bộ @@ -71,6 +71,7 @@ User có thể sửa: - entity snapshot - wiki snapshot - entity-wiki snapshot +- replay script Tất cả thay đổi lúc này mới chỉ ở memory của frontend. @@ -80,6 +81,7 @@ Tất cả thay đổi lúc này mới chỉ ở memory của frontend. - đã mở được project - `pendingSaveCount > 0` +- không còn orphan geometry Luồng commit: @@ -91,7 +93,7 @@ Luồng commit: - refresh `projectState` - refresh `sectionCommits` - cập nhật `baselineSnapshot` - - set `initialData = editor.draft` + - set `baselineFeatureCollection = editor.mainDraft` - `editor.clearChanges()` - clear `commitTitle` @@ -102,6 +104,7 @@ Luồng commit: - project đang mở - có `head_commit_id` - `pendingSaveCount === 0` +- không còn orphan geometry Frontend sẽ lấy latest commit từ project hiện tại rồi tạo submission mới. @@ -109,6 +112,7 @@ Frontend sẽ lấy latest commit từ project hiện tại rồi tạo submissi Nút `Restore` trong `CommitHistoryPanel` hiện là restore phía frontend: +- chỉ chạy khi `pendingSaveCount === 0` - tải commit list mới nhất - lấy snapshot của commit được chọn - normalize snapshot @@ -128,9 +132,10 @@ Nói cách khác, đây là `load snapshot into editor`, không phải `server-s - `draft` - `changes` -- `snapshotEntities` +- `snapshotEntityRows` - `snapshotWikis` - `snapshotEntityWikiLinks` +- `effectiveReplays` - `previousSnapshot` và sinh ra: @@ -141,12 +146,14 @@ và sinh ra: - `geometry_entity` - `wikis` - `entity_wiki` +- `replays` Các điểm quan trọng: - geometry many-to-many với entity được persist ở `geometry_entity[]` - denormalized fields trên feature như `entity_ids`, `entity_name`, `binding`, `time_start` sẽ bị strip khỏi `editor_feature_collection` trước khi gửi API - wiki/entity/link được chuẩn hóa lại thành `reference`, `binding`, `delete`, `create`, `update` tùy baseline +- replay script được persist ở `replays[]`; `replayDraft` không được gửi ## 7. Dirty state mà user nhìn thấy @@ -158,6 +165,7 @@ Nó gồm: - cộng thêm 1 nếu entity dirty - cộng thêm 1 nếu wiki dirty - cộng thêm 1 nếu entity-wiki dirty +- cộng thêm 1 nếu replay dirty Vì vậy: diff --git a/src/uhm/doc/wiki_system.md b/src/uhm/doc/wiki_system.md index 5b74936..98db61b 100644 --- a/src/uhm/doc/wiki_system.md +++ b/src/uhm/doc/wiki_system.md @@ -55,6 +55,7 @@ Quy ước operation: - wiki ref thêm từ search: `source: "ref"`, `operation: "reference"` - wiki đã tồn tại nhưng sửa nội dung: `operation: "update"` - wiki bị remove khỏi current state: được chuyển thành `delete` khi build snapshot so với baseline +- khi remove wiki, page editor cũng gỡ các link `entity_wiki` trỏ tới wiki đó trong cùng undo group nếu handler ngoài được truyền vào ## 4. Slug @@ -177,4 +178,5 @@ Hiện tại chưa có: - schema block editor mới cho project wiki - cross-project link graph UI -File `doc/commit_snapshot.ts` có chứa schema `replays[]`, nhưng phần replay narrative đó chưa được nối với wiki editor hiện tại. +Replay preview có thể mở `PublicWikiSidebar` bằng action `wiki_panel`, `close_wiki_panel` và `wiki`. +Wiki editor vẫn không lưu narrative replay trực tiếp; narrative/script nằm trong `replays[]`. diff --git a/src/uhm/lib/editor/draft/editorTypes.ts b/src/uhm/lib/editor/draft/editorTypes.ts index a617138..2fa952d 100644 --- a/src/uhm/lib/editor/draft/editorTypes.ts +++ b/src/uhm/lib/editor/draft/editorTypes.ts @@ -13,9 +13,10 @@ export type Change = GeometryChange; export type UndoAction = | { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry } | { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties } - | { type: "delete"; feature: Feature } + | { type: "delete"; feature: Feature; index?: number } | { type: "create"; id: FeatureProperties["id"] } | { type: "replay"; geometryId: string; label: string; prevReplay: BattleReplay | null } + | { type: "replays"; label: string; prevReplays: BattleReplay[] } | { type: "replay_session"; geometryId: string; label: string; prevReplay: BattleReplay | null } // Snapshot-scoped undo (affects commit snapshot but not GeoJSON draft directly) | { type: "snapshot_entities"; label: string; prev: EntitySnapshot[] } diff --git a/src/uhm/lib/editor/draft/useDraftState.ts b/src/uhm/lib/editor/draft/useDraftState.ts index 96a76d7..75b5c0b 100644 --- a/src/uhm/lib/editor/draft/useDraftState.ts +++ b/src/uhm/lib/editor/draft/useDraftState.ts @@ -2,11 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { FeatureCollection } from "@/uhm/types/geo"; import { deepClone } from "@/uhm/lib/editor/draft/draftDiff"; -export function useDraftState(initialData: FeatureCollection) { +export function useDraftState(seedFeatureCollection: FeatureCollection) { // Draft hiện tại (React state) để UI re-render khi dữ liệu thay đổi. - const [draft, setDraft] = useState(() => deepClone(initialData)); + const [draft, setDraft] = useState(() => deepClone(seedFeatureCollection)); // Draft ref để đọc giá trị mới nhất trong event handlers/engines mà không cần deps. - const draftRef = useRef(deepClone(initialData)); + const draftRef = useRef(deepClone(seedFeatureCollection)); const commitDraft = useCallback((nextDraft: FeatureCollection) => { const cloned = deepClone(nextDraft); diff --git a/src/uhm/lib/editor/draft/useUndoStack.ts b/src/uhm/lib/editor/draft/useUndoStack.ts index 9d4c0df..5a1c913 100644 --- a/src/uhm/lib/editor/draft/useUndoStack.ts +++ b/src/uhm/lib/editor/draft/useUndoStack.ts @@ -96,6 +96,10 @@ function isSameUndo(a: UndoAction | undefined, b: UndoAction) { && JSON.stringify(a.prevReplay) === JSON.stringify(next.prevReplay) ); } + case "replays": { + const next = b as Extract; + return a.label === next.label && JSON.stringify(a.prevReplays) === JSON.stringify(next.prevReplays); + } case "replay_session": { const next = b as Extract; return ( diff --git a/src/uhm/lib/editor/project/useProjectCommands.ts b/src/uhm/lib/editor/project/useProjectCommands.ts index 570cb49..ece490e 100644 --- a/src/uhm/lib/editor/project/useProjectCommands.ts +++ b/src/uhm/lib/editor/project/useProjectCommands.ts @@ -8,7 +8,13 @@ import { openSectionEditor, submitSection, } from "@/uhm/api/projects"; -import { buildEditorSnapshot, normalizeEditorSnapshot, toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot"; +import { + buildEditorSnapshot, + normalizeEditorSnapshot, + normalizeFeatureEntityIds, + toApiEditorSnapshot, +} from "@/uhm/lib/editor/snapshot/editorSnapshot"; +import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline"; import type { Change } from "@/uhm/lib/editor/draft/editorTypes"; import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo"; import type { BattleReplay, EditorSnapshot, ProjectCommit, EntityWikiLinkSnapshot } from "@/uhm/types/projects"; @@ -42,15 +48,15 @@ export function useProjectCommands(options: Options) { // operations should not carry over as deltas into the next commit. const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null; const commits = await fetchProjectCommits(projectId); - const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection; + const nextBaselineFeatureCollection = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection; state.setActiveSection(editorPayload.project); state.setSelectedProjectId(editorPayload.project.id); state.setProjectState(editorPayload.state); state.setBaselineSnapshot(sessionSnapshot); - state.setInitialData(nextInitialData); + state.setBaselineFeatureCollection(nextBaselineFeatureCollection); state.setProjectCommits(commits); - state.setSnapshotEntities(sessionSnapshot?.entities || []); + state.setSnapshotEntityRows(sessionSnapshot?.entities || []); state.setSnapshotWikis(sessionSnapshot?.wikis || []); state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []); state.setSelectedFeatureIds([]); @@ -68,6 +74,15 @@ export function useProjectCommands(options: Options) { return; } + const orphanGeometries = findOrphanGeometries(options.editor.mainDraft); + if (orphanGeometries.length > 0) { + const firstOrphan = orphanGeometries[0]; + state.setSelectedFeatureIds([firstOrphan.id]); + state.setEntityFormStatus("Geometry này chưa bind entity."); + state.setEntityStatus(formatOrphanGeometryMessage("Commit", orphanGeometries)); + return; + } + const geometryChanges = options.editor.buildPayload(); state.setIsSaving(true); state.setEntityStatus(null); @@ -76,7 +91,7 @@ export function useProjectCommands(options: Options) { project: state.activeSection, draft: options.editor.mainDraft, changes: geometryChanges, - snapshotEntities: state.snapshotEntities, + snapshotEntityRows: state.snapshotEntityRows, snapshotWikis: state.snapshotWikis, snapshotEntityWikiLinks: state.snapshotEntityWikiLinks, replays: options.editor.effectiveReplays, @@ -111,10 +126,10 @@ export function useProjectCommands(options: Options) { const sessionSnapshot = toEditorSessionSnapshot(snapshot); state.setProjectState(result.state); state.setBaselineSnapshot(sessionSnapshot); - state.setSnapshotEntities(sessionSnapshot.entities || []); + state.setSnapshotEntityRows(sessionSnapshot.entities || []); state.setSnapshotWikis(sessionSnapshot.wikis || []); state.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []); - state.setInitialData(options.editor.mainDraft); + state.setBaselineFeatureCollection(options.editor.mainDraft); options.editor.clearChanges(); state.setCommitTitle(""); state.setProjectCommits(await fetchProjectCommits(state.activeSection.id)); @@ -206,6 +221,15 @@ export function useProjectCommands(options: Options) { return; } + const orphanGeometries = findOrphanGeometries(options.editor.mainDraft); + if (orphanGeometries.length > 0) { + const firstOrphan = orphanGeometries[0]; + state.setSelectedFeatureIds([firstOrphan.id]); + state.setEntityFormStatus("Geometry này chưa bind entity."); + state.setEntityStatus(formatOrphanGeometryMessage("Submit", orphanGeometries)); + return; + } + state.setIsSubmitting(true); state.setEntityStatus(null); try { @@ -220,7 +244,7 @@ export function useProjectCommands(options: Options) { } finally { state.setIsSubmitting(false); } - }, [options.pendingSaveCount, options.store]); + }, [options.editor.mainDraft, options.pendingSaveCount, options.store]); const restoreCommit = useCallback(async (commitId: string) => { const state = options.store.getState(); @@ -247,11 +271,11 @@ export function useProjectCommands(options: Options) { const snapshot = normalizeEditorSnapshot(target.snapshot_json); const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null; - const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection; + const nextBaselineFeatureCollection = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection; state.setBaselineSnapshot(sessionSnapshot); - state.setInitialData(nextInitialData); - state.setSnapshotEntities(sessionSnapshot?.entities || []); + state.setBaselineFeatureCollection(nextBaselineFeatureCollection); + state.setSnapshotEntityRows(sessionSnapshot?.entities || []); state.setSnapshotWikis(sessionSnapshot?.wikis || []); state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []); state.setSelectedFeatureIds([]); @@ -281,6 +305,34 @@ export function useProjectCommands(options: Options) { }; } +type OrphanGeometry = { + id: Feature["properties"]["id"]; + label: string; +}; + +function findOrphanGeometries(draft: FeatureCollection): OrphanGeometry[] { + const rows: OrphanGeometry[] = []; + + for (const feature of draft.features || []) { + const entityIds = normalizeFeatureEntityIds(feature); + if (entityIds.length > 0) continue; + + const id = feature.properties.id; + rows.push({ + id, + label: String(id), + }); + } + + return rows; +} + +function formatOrphanGeometryMessage(action: "Commit" | "Submit", rows: OrphanGeometry[]): string { + const sample = rows.slice(0, 8).map((row) => row.label).join(", "); + const more = rows.length > 8 ? `, ... (+${rows.length - 8})` : ""; + return `Không thể ${action}: còn ${rows.length} geometry chưa bind entity. Hãy bind entity cho: ${sample}${more}.`; +} + function toEditorSessionSnapshot(snapshot: EditorSnapshot): EditorSnapshot { return { ...snapshot, @@ -311,8 +363,8 @@ function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnaps operation: "reference", name: typeof e.name === "string" ? e.name : undefined, description: typeof e.description === "string" ? e.description : e.description ?? null, - time_start: typeof e.time_start === "number" ? e.time_start : e.time_start ?? undefined, - time_end: typeof e.time_end === "number" ? e.time_end : e.time_end ?? undefined, + time_start: normalizeTimelineYearValue(e.time_start) ?? undefined, + time_end: normalizeTimelineYearValue(e.time_end) ?? undefined, }; }); } @@ -333,8 +385,8 @@ function toEditorSessionGeometries(input: EditorSnapshot["geometries"]): Geometr draw_geometry: g.draw_geometry, geometry: g.geometry, binding: Array.isArray(g.binding) ? [...g.binding] : undefined, - time_start: typeof g.time_start === "number" ? g.time_start : g.time_start ?? undefined, - time_end: typeof g.time_end === "number" ? g.time_end : g.time_end ?? undefined, + time_start: normalizeTimelineYearValue(g.time_start) ?? undefined, + time_end: normalizeTimelineYearValue(g.time_end) ?? undefined, bbox: g.bbox ? { min_lng: g.bbox.min_lng, diff --git a/src/uhm/lib/editor/session/useEntitySessionState.ts b/src/uhm/lib/editor/session/useEntitySessionState.ts index 3fa28b3..72fc036 100644 --- a/src/uhm/lib/editor/session/useEntitySessionState.ts +++ b/src/uhm/lib/editor/session/useEntitySessionState.ts @@ -11,7 +11,7 @@ export function useEntitySessionState() { // Entity catalog loaded from backend (global list, used for search/lookup). const [entityCatalog, setEntityCatalog] = useState([]); // Snapshot entity store for the current editor session (single source of truth for snapshot.entities). - const [snapshotEntities, setSnapshotEntities] = useState([]); + const [snapshotEntityRows, setSnapshotEntityRows] = useState([]); // Thông báo trạng thái/lỗi liên quan entity/session. const [entityStatus, setEntityStatus] = useState(null); // Features đang được chọn để thao tác bind entities/metadata. @@ -48,8 +48,8 @@ export function useEntitySessionState() { return { entityCatalog, setEntityCatalog, - snapshotEntities, - setSnapshotEntities, + snapshotEntityRows, + setSnapshotEntityRows, entityStatus, setEntityStatus, selectedFeatureIds, diff --git a/src/uhm/lib/editor/snapshot/editorSnapshot.ts b/src/uhm/lib/editor/snapshot/editorSnapshot.ts index 8ee7ce6..ef49ed7 100644 --- a/src/uhm/lib/editor/snapshot/editorSnapshot.ts +++ b/src/uhm/lib/editor/snapshot/editorSnapshot.ts @@ -1,5 +1,6 @@ import { DEFAULT_GEOMETRY_TYPE_ID } from "@/uhm/lib/map/geo/geometryTypeOptions"; import { normalizeGeoTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/map/geo/geoTypeMap"; +import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline"; import type { Change } from "@/uhm/lib/editor/draft/editorTypes"; import type { EntitySnapshot } from "@/uhm/types/entities"; import type { EntitySnapshotOperation } from "@/uhm/types/entities"; @@ -94,6 +95,11 @@ function getRefId(value: unknown): string { return typeof value.id === "string" ? value.id : ""; } +function normalizeApiTimeFields(row: UnknownRecord): void { + if ("time_start" in row) row.time_start = normalizeTimelineYearValue(row.time_start); + if ("time_end" in row) row.time_end = normalizeTimelineYearValue(row.time_end); +} + export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null { if (!isRecord(raw)) return null; const snapshot = raw as UnknownRecord; @@ -126,8 +132,8 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null { operation, name: typeof e.name === "string" ? e.name : undefined, description: typeof e.description === "string" ? e.description : e.description == null ? undefined : undefined, - time_start: typeof e.time_start === "number" ? e.time_start : e.time_start == null ? undefined : undefined, - time_end: typeof e.time_end === "number" ? e.time_end : e.time_end == null ? undefined : undefined, + time_start: normalizeTimelineYearValue(e.time_start) ?? undefined, + time_end: normalizeTimelineYearValue(e.time_end) ?? undefined, }; }) : undefined; @@ -156,8 +162,8 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null { draw_geometry: row.draw_geometry as GeometrySnapshot["draw_geometry"], geometry: row.geometry as GeometrySnapshot["geometry"], binding: Array.isArray(row.binding) ? row.binding as string[] : undefined, - time_start: typeof row.time_start === "number" ? row.time_start : row.time_start == null ? undefined : undefined, - time_end: typeof row.time_end === "number" ? row.time_end : row.time_end == null ? undefined : undefined, + time_start: normalizeTimelineYearValue(row.time_start) ?? undefined, + time_end: normalizeTimelineYearValue(row.time_end) ?? undefined, bbox: isRecord(row.bbox) ? { min_lng: Number(row.bbox.min_lng), @@ -278,8 +284,8 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null { const name = typeof row.name === "string" ? String(row.name).trim() : ""; if (name) entityNameById.set(id, name); entityTimeById.set(id, { - time_start: typeof row.time_start === "number" ? row.time_start : null, - time_end: typeof row.time_end === "number" ? row.time_end : null, + time_start: normalizeTimelineYearValue(row.time_start), + time_end: normalizeTimelineYearValue(row.time_end), }); } const geometryById = new Map(); @@ -293,6 +299,18 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null { const gid = String(feature.properties.id); const entity_ids = byGeom.get(gid) || []; const p = feature.properties as unknown as UnknownRecord; + const existingTimeStart = normalizeTimelineYearValue(p.time_start); + const existingTimeEnd = normalizeTimelineYearValue(p.time_end); + if (existingTimeStart !== null) { + p.time_start = existingTimeStart; + } else { + delete p.time_start; + } + if (existingTimeEnd !== null) { + p.time_end = existingTimeEnd; + } else { + delete p.time_end; + } const existingTypeKey = normalizeGeoTypeKey(p.type) || normalizeGeoTypeKey(p.entity_type_id); const fallbackTypeKey = getDefaultTypeIdForFeature(feature); @@ -334,8 +352,18 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null { || fallbackTypeKey; if (typeKey) p.type = typeKey; if (Array.isArray(geo.binding) && geo.binding.length) p.binding = geo.binding; - if (typeof geo.time_start === "number") p.time_start = geo.time_start; - if (typeof geo.time_end === "number") p.time_end = geo.time_end; + const timeStart = normalizeTimelineYearValue(geo.time_start); + const timeEnd = normalizeTimelineYearValue(geo.time_end); + if (timeStart !== null) { + p.time_start = timeStart; + } else { + delete p.time_start; + } + if (timeEnd !== null) { + p.time_end = timeEnd; + } else { + delete p.time_end; + } } else if (!existingTypeKey) { p.type = fallbackTypeKey; } @@ -359,7 +387,7 @@ export function buildEditorSnapshot(options: { project: Project; draft: FeatureCollection; changes: Change[]; - snapshotEntities: EntitySnapshot[]; + snapshotEntityRows: EntitySnapshot[]; snapshotWikis: WikiSnapshot[]; snapshotEntityWikiLinks: EntityWikiLinkSnapshot[]; replays: BattleReplay[]; @@ -410,11 +438,11 @@ export function buildEditorSnapshot(options: { operation: "reference", name: typeof cloned.name === "string" ? cloned.name : undefined, description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null, - time_start: typeof cloned.time_start === "number" ? cloned.time_start : cloned.time_start ?? undefined, - time_end: typeof cloned.time_end === "number" ? cloned.time_end : cloned.time_end ?? undefined, + time_start: normalizeTimelineYearValue(cloned.time_start) ?? undefined, + time_end: normalizeTimelineYearValue(cloned.time_end) ?? undefined, }); } - for (const row of options.snapshotEntities || []) { + for (const row of options.snapshotEntityRows || []) { if (!row) continue; const id = typeof row.id === "string" || typeof row.id === "number" ? String(row.id) : ""; if (!id) continue; @@ -435,8 +463,8 @@ export function buildEditorSnapshot(options: { name, operation, description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null, - time_start: typeof cloned.time_start === "number" ? cloned.time_start : cloned.time_start ?? undefined, - time_end: typeof cloned.time_end === "number" ? cloned.time_end : cloned.time_end ?? undefined, + time_start: normalizeTimelineYearValue(cloned.time_start) ?? undefined, + time_end: normalizeTimelineYearValue(cloned.time_end) ?? undefined, }); } @@ -483,6 +511,8 @@ export function buildEditorSnapshot(options: { : "reference"; const bbox = getFeatureBBox(feature); const typeKey = normalizeGeoTypeKey(feature.properties.type) || getDefaultTypeIdForFeature(feature); + const timeStart = normalizeTimelineYearValue(feature.properties.time_start); + const timeEnd = normalizeTimelineYearValue(feature.properties.time_end); return { id, operation, @@ -490,8 +520,8 @@ export function buildEditorSnapshot(options: { type: typeKey, draw_geometry: feature.geometry, binding: normalizeFeatureBindingIds(feature), - time_start: feature.properties.time_start ?? null, - time_end: feature.properties.time_end ?? null, + time_start: timeStart, + time_end: timeEnd, bbox: bbox ? { min_lng: bbox.minLng, @@ -689,8 +719,8 @@ export function buildEditorSnapshot(options: { operation: e.operation, name: typeof e.name === "string" ? e.name : undefined, description: typeof (e as RawEntityRow).description === "string" ? (e as RawEntityRow).description : (e as RawEntityRow).description ?? null, - time_start: typeof e.time_start === "number" ? e.time_start : e.time_start ?? undefined, - time_end: typeof e.time_end === "number" ? e.time_end : e.time_end ?? undefined, + time_start: normalizeTimelineYearValue(e.time_start) ?? undefined, + time_end: normalizeTimelineYearValue(e.time_end) ?? undefined, })) .sort((a, b) => String(a.id).localeCompare(String(b.id))), geometries: geometries.slice().sort((a, b) => String(a.id).localeCompare(String(b.id))), @@ -713,11 +743,31 @@ export function buildEditorSnapshot(options: { export function toApiEditorSnapshot(snapshot: EditorSnapshot): EditorSnapshot { const cloned = JSON.parse(JSON.stringify(snapshot)) as EditorSnapshot; + if (Array.isArray(cloned.editor_feature_collection?.features)) { + cloned.editor_feature_collection.features = cloned.editor_feature_collection.features.map((feature) => { + const properties = { ...(feature.properties as unknown as UnknownRecord) }; + normalizeApiTimeFields(properties); + return { + ...feature, + properties: properties as unknown as Feature["properties"], + }; + }); + } + + if (Array.isArray(cloned.entities)) { + cloned.entities = cloned.entities.map((entity) => { + const row = { ...(entity as unknown as UnknownRecord) }; + normalizeApiTimeFields(row); + return row as unknown as EntitySnapshot; + }); + } + if (Array.isArray(cloned.geometries)) { cloned.geometries = cloned.geometries.map((geometry) => { const row = { ...(geometry as unknown as UnknownRecord) }; const typeKey = normalizeGeoTypeKey(row.type) || normalizeGeoTypeKey(row.geo_type); delete row.geo_type; + normalizeApiTimeFields(row); if (typeKey) { const typeCode = typeKeyToGeoTypeCode(typeKey); @@ -846,6 +896,7 @@ function normalizeReplayUiOption(value: unknown): UIOptionName | null { case "timeline": case "layer_panel": case "wiki_panel": + case "close_wiki_panel": case "zoom_panel": case "wiki": case "toast": @@ -910,6 +961,7 @@ function normalizeReplayMapFunctionName(value: unknown): MapFunctionName | null case "toggle_labels": case "show_labels": case "hide_labels": + case "show_all_geometries": case "reset_camera_north": return value; default: @@ -958,10 +1010,15 @@ function normalizeReplayNarrativeActions(actions: unknown): ReplayAction("idle"); - // FeatureCollection "gốc" của session hiện tại (global timeline hoặc project snapshot). - const [initialData, setInitialData] = useState(options.emptyFeatureCollection); + // Baseline FeatureCollection used to seed/reset the editor draft for the current session. + const [baselineFeatureCollection, setBaselineFeatureCollection] = useState(options.emptyFeatureCollection); const project = useProjectSessionState({ defaultEditorUserId: options.defaultEditorUserId, @@ -41,8 +41,8 @@ export function useEditorSessionState(options: Options) { return { mode, setMode, - initialData, - setInitialData, + baselineFeatureCollection, + setBaselineFeatureCollection, ...project, ...entity, ...timeline, diff --git a/src/uhm/lib/editor/state/useEditorState.ts b/src/uhm/lib/editor/state/useEditorState.ts index ac1e454..6b237d4 100644 --- a/src/uhm/lib/editor/state/useEditorState.ts +++ b/src/uhm/lib/editor/state/useEditorState.ts @@ -19,8 +19,8 @@ export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/ export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes"; type SnapshotUndoApi = { - snapshotEntitiesRef: { current: EntitySnapshot[] }; - setSnapshotEntities: Dispatch>; + snapshotEntityRowsRef: { current: EntitySnapshot[] }; + setSnapshotEntityRows: Dispatch>; snapshotWikisRef: { current: WikiSnapshot[] }; setSnapshotWikis: Dispatch>; snapshotEntityWikiLinksRef: { current: EntityWikiLinkSnapshot[] }; @@ -41,7 +41,7 @@ type ReplayDraftSyncMode = "none" | "reset"; // - active replay draft: bản sao BattleReplay đang chỉnh (script + target ids) // - replay feature draft: FeatureCollection local được hydrate từ mainDraft + target ids export function useEditorState( - initialData: FeatureCollection, + baselineFeatureCollection: FeatureCollection, options: { snapshotUndo?: SnapshotUndoApi; initialReplays?: BattleReplay[]; @@ -50,7 +50,7 @@ export function useEditorState( ) { const { snapshotUndo, initialReplays, mode } = options; - const mainDraftState = useDraftState(initialData); + const mainDraftState = useDraftState(baselineFeatureCollection); const replayFeatureDraftState = useDraftState(EMPTY_FEATURE_COLLECTION); const { draft: mainDraft, @@ -116,7 +116,7 @@ export function useEditorState( // Map baseline (id -> feature) để diff main draft ra changes. const initialMapRef = useRef>( - buildInitialMap(initialData) + buildInitialMap(baselineFeatureCollection) ); // Version counter để ép diff recalculation sau khi reset/clear baseline. const [baselineVersion, setBaselineVersion] = useState(0); @@ -132,22 +132,27 @@ export function useEditorState( targetCommitDraft({ ...targetDraftRef.current, features: targetDraftRef.current.features.filter((feature) => - feature.properties.id !== action.id + !featureIdEquals(feature.properties.id, action.id) ), }); return true; } case "delete": { const feature = deepClone(action.feature); + const nextFeatures = [...targetDraftRef.current.features]; + const insertAt = typeof action.index === "number" && Number.isFinite(action.index) + ? Math.max(0, Math.min(action.index, nextFeatures.length)) + : nextFeatures.length; + nextFeatures.splice(insertAt, 0, feature); targetCommitDraft({ ...targetDraftRef.current, - features: [...targetDraftRef.current.features, feature], + features: nextFeatures, }); return true; } case "update": { const idx = targetDraftRef.current.features.findIndex((feature) => - feature.properties.id === action.id + featureIdEquals(feature.properties.id, action.id) ); if (idx === -1) return false; const nextFeatures = [...targetDraftRef.current.features]; @@ -160,7 +165,7 @@ export function useEditorState( } case "properties": { const idx = targetDraftRef.current.features.findIndex((feature) => - feature.properties.id === action.id + featureIdEquals(feature.properties.id, action.id) ); if (idx === -1) return false; const nextFeatures = [...targetDraftRef.current.features]; @@ -174,8 +179,8 @@ export function useEditorState( case "snapshot_entities": { if (!allowSnapshotUndo || !snapshotUndo) return false; const prev = deepClone(action.prev); - snapshotUndo.snapshotEntitiesRef.current = prev; - snapshotUndo.setSnapshotEntities(prev); + snapshotUndo.snapshotEntityRowsRef.current = prev; + snapshotUndo.setSnapshotEntityRows(prev); return true; } case "snapshot_wikis": { @@ -226,6 +231,19 @@ export function useEditorState( return true; } + if (action.type === "replays") { + const restoredReplays = deepClone(action.prevReplays || []); + updateReplaysState(restoredReplays); + + if (activeReplayId != null) { + const activeReplay = restoredReplays.find((replay) => replay.geometry_id === String(activeReplayId)) || null; + activeReplayOriginRef.current = activeReplay ? deepClone(activeReplay) : null; + activeReplaySeedRef.current = activeReplay ? deepClone(activeReplay) : null; + setActiveReplayDraftState(activeReplay, "reset"); + } + return true; + } + return applyUndoActionToDraft( action, mainDraftRef, @@ -264,7 +282,7 @@ export function useEditorState( } = useUndoStack({ applyUndoAction: applyReplayUndoAction }); useEffect(() => { - resetMainDraft(deepClone(initialData)); + resetMainDraft(deepClone(baselineFeatureCollection)); resetReplayDraft(EMPTY_FEATURE_COLLECTION); updateReplaysState(initialReplays || []); setActiveReplayId(null); @@ -273,12 +291,12 @@ export function useEditorState( activeReplaySeedRef.current = null; clearMainUndo(); clearReplayUndo(); - initialMapRef.current = buildInitialMap(initialData); + initialMapRef.current = buildInitialMap(baselineFeatureCollection); setBaselineVersion((version) => version + 1); }, [ clearMainUndo, clearReplayUndo, - initialData, + baselineFeatureCollection, initialReplays, resetMainDraft, resetReplayDraft, @@ -371,7 +389,7 @@ export function useEditorState( pushMainUndo({ type: "create", id: featureClone.properties.id }); } - function createFeatureWithSnapshotEntities( + function createFeatureWithSnapshotEntityRows( feature: Feature, nextEntities: SetStateAction, label = "Import geometry" @@ -384,7 +402,7 @@ export function useEditorState( const undoActions: UndoAction[] = []; if (snapshotUndo) { - const prevEntities = snapshotUndo.snapshotEntitiesRef.current || []; + const prevEntities = snapshotUndo.snapshotEntityRowsRef.current || []; const prevEntitiesClone = deepClone(prevEntities); const computedEntities = typeof nextEntities === "function" ? (nextEntities as (p: EntitySnapshot[]) => EntitySnapshot[])(prevEntitiesClone) @@ -403,8 +421,8 @@ export function useEditorState( label: "Cập nhật entities", prev: prevEntitiesClone, }); - snapshotUndo.snapshotEntitiesRef.current = computedEntitiesClone; - snapshotUndo.setSnapshotEntities(computedEntitiesClone); + snapshotUndo.snapshotEntityRowsRef.current = computedEntitiesClone; + snapshotUndo.setSnapshotEntityRows(computedEntitiesClone); } } @@ -428,7 +446,7 @@ export function useEditorState( return; } - const idx = mainDraftRef.current.features.findIndex((feature) => feature.properties.id === id); + const idx = mainDraftRef.current.features.findIndex((feature) => featureIdEquals(feature.properties.id, id)); if (idx === -1) return; const nextFeatures = [...mainDraftRef.current.features]; @@ -472,7 +490,7 @@ export function useEditorState( const undoActions: UndoAction[] = []; for (const [id, patch] of mergedPatches.entries()) { - const idx = nextFeatures.findIndex((feature) => feature.properties.id === id); + const idx = nextFeatures.findIndex((feature) => featureIdEquals(feature.properties.id, id)); if (idx === -1) continue; const prevProperties = deepClone(nextFeatures[idx].properties); @@ -506,7 +524,7 @@ export function useEditorState( return; } - const idx = mainDraftRef.current.features.findIndex((feature) => feature.properties.id === id); + const idx = mainDraftRef.current.features.findIndex((feature) => featureIdEquals(feature.properties.id, id)); if (idx === -1) return; const prevFeature = mainDraftRef.current.features[idx]; @@ -529,14 +547,22 @@ export function useEditorState( return; } - const idx = mainDraftRef.current.features.findIndex((feature) => feature.properties.id === id); + const idx = mainDraftRef.current.features.findIndex((feature) => featureIdEquals(feature.properties.id, id)); if (idx === -1) return; const feature = mainDraftRef.current.features[idx]; const nextFeatures = [...mainDraftRef.current.features]; nextFeatures.splice(idx, 1); - pushMainUndo({ type: "delete", feature: deepClone(feature) }); + const undoActions: UndoAction[] = []; + const replayUndoAction = pruneReplaysForDeletedGeometryIds([feature.properties.id], `Xóa replay theo GEO #${feature.properties.id}`); + if (replayUndoAction) undoActions.push(replayUndoAction); + undoActions.push({ type: "delete", feature: deepClone(feature), index: idx }); + pushMainUndo( + undoActions.length === 1 + ? undoActions[0] + : { type: "group", label: `Xóa GEO #${feature.properties.id}`, actions: undoActions } + ); commitMainDraft({ ...mainDraftRef.current, features: nextFeatures }); } @@ -549,24 +575,49 @@ export function useEditorState( const nextFeatures: Feature[] = []; const undoActions: UndoAction[] = []; - for (const feature of mainDraftRef.current.features) { + mainDraftRef.current.features.forEach((feature, index) => { if (idsSet.has(String(feature.properties.id))) { - undoActions.push({ type: "delete", feature: deepClone(feature) }); + undoActions.push({ type: "delete", feature: deepClone(feature), index }); } else { nextFeatures.push(feature); } - } + }); if (undoActions.length === 0) return; + const replayUndoAction = pruneReplaysForDeletedGeometryIds(ids, `Xóa replay theo ${undoActions.length} GEO`); + const groupedActions = replayUndoAction + ? [replayUndoAction, ...undoActions.slice().reverse()] + : undoActions.length === 1 + ? undoActions + : undoActions.slice().reverse(); pushMainUndo( - undoActions.length === 1 - ? undoActions[0] - : { type: "group", label: `Xóa ${undoActions.length} geometry`, actions: undoActions } + groupedActions.length === 1 + ? groupedActions[0] + : { type: "group", label: `Xóa ${undoActions.length} geometry`, actions: groupedActions } ); commitMainDraft({ ...mainDraftRef.current, features: nextFeatures }); } + function pruneReplaysForDeletedGeometryIds( + ids: Array, + label: string + ): UndoAction | null { + const deletedIds = new Set(ids.map((id) => String(id))); + if (!deletedIds.size) return null; + + const prevReplays = replaysRef.current || []; + const nextReplays = pruneDeletedGeometryIdsFromReplays(prevReplays, deletedIds); + if (replaysEqual(prevReplays, nextReplays)) return null; + + updateReplaysState(nextReplays); + return { + type: "replays", + label, + prevReplays: deepClone(prevReplays), + }; + } + function buildPayload(): Change[] { return Array.from(changes.values()).map((change) => deepClone(change)); } @@ -620,12 +671,12 @@ export function useEditorState( clearReplayUndo(); }, [clearReplayUndo, finalizeActiveReplaySession, setActiveReplayDraftState]); - const setSnapshotEntitiesUndoable = useCallback(( + const setSnapshotEntityRowsUndoable = useCallback(( next: SetStateAction, label = "Cập nhật entities" ) => { if (!snapshotUndo) return; - const prev = snapshotUndo.snapshotEntitiesRef.current || []; + const prev = snapshotUndo.snapshotEntityRowsRef.current || []; const prevClone = deepClone(prev); const computed = typeof next === "function" ? (next as (p: EntitySnapshot[]) => EntitySnapshot[])(prevClone) : next; let changed = true; @@ -638,8 +689,8 @@ export function useEditorState( const computedClone = deepClone(computed); pushMainUndo({ type: "snapshot_entities", label, prev: prevClone }); - snapshotUndo.snapshotEntitiesRef.current = computedClone; - snapshotUndo.setSnapshotEntities(computedClone); + snapshotUndo.snapshotEntityRowsRef.current = computedClone; + snapshotUndo.setSnapshotEntityRows(computedClone); }, [pushMainUndo, snapshotUndo]); const setSnapshotWikisUndoable = useCallback(( @@ -688,6 +739,54 @@ export function useEditorState( snapshotUndo.setSnapshotEntityWikiLinks(computedClone); }, [pushMainUndo, snapshotUndo]); + const setSnapshotWikisAndEntityWikiLinksUndoable = useCallback(( + nextWikis: SetStateAction, + nextLinks: SetStateAction, + label = "Cập nhật wiki/entity-wiki" + ) => { + if (!snapshotUndo) return; + + const prevWikis = snapshotUndo.snapshotWikisRef.current || []; + const prevWikiLinks = snapshotUndo.snapshotEntityWikiLinksRef.current || []; + const prevWikisClone = deepClone(prevWikis); + const prevWikiLinksClone = deepClone(prevWikiLinks); + const computedWikis = typeof nextWikis === "function" + ? (nextWikis as (p: WikiSnapshot[]) => WikiSnapshot[])(prevWikisClone) + : nextWikis; + const computedWikiLinks = typeof nextLinks === "function" + ? (nextLinks as (p: EntityWikiLinkSnapshot[]) => EntityWikiLinkSnapshot[])(prevWikiLinksClone) + : nextLinks; + + const wikisChanged = !jsonEquals(prevWikis, computedWikis); + const linksChanged = !jsonEquals(prevWikiLinks, computedWikiLinks); + if (!wikisChanged && !linksChanged) return; + + const undoActions: Array> = []; + if (wikisChanged) { + undoActions.push({ type: "snapshot_wikis", label: "Cập nhật wiki", prev: prevWikisClone }); + } + if (linksChanged) { + undoActions.push({ type: "snapshot_entity_wiki", label: "Cập nhật entity-wiki", prev: prevWikiLinksClone }); + } + + pushMainUndo( + undoActions.length === 1 + ? { ...undoActions[0], label } + : { type: "group", label, actions: undoActions } + ); + + if (wikisChanged) { + const computedWikisClone = deepClone(computedWikis); + snapshotUndo.snapshotWikisRef.current = computedWikisClone; + snapshotUndo.setSnapshotWikis(computedWikisClone); + } + if (linksChanged) { + const computedWikiLinksClone = deepClone(computedWikiLinks); + snapshotUndo.snapshotEntityWikiLinksRef.current = computedWikiLinksClone; + snapshotUndo.setSnapshotEntityWikiLinks(computedWikiLinksClone); + } + }, [pushMainUndo, snapshotUndo]); + const undo = useCallback(() => { if (mode === "replay") { undoReplay(); @@ -717,7 +816,7 @@ export function useEditorState( changeCount, canUndoReplay: replayUndoStack.length > 0, createFeature, - createFeatureWithSnapshotEntities, + createFeatureWithSnapshotEntityRows, patchFeatureProperties, patchFeaturePropertiesBatch, updateFeature, @@ -728,9 +827,10 @@ export function useEditorState( clearChanges, hasPersistedFeature, // Snapshot undo helpers (no-op if snapshotUndo not provided) - setSnapshotEntities: setSnapshotEntitiesUndoable, + setSnapshotEntityRows: setSnapshotEntityRowsUndoable, setSnapshotWikis: setSnapshotWikisUndoable, setSnapshotEntityWikiLinks: setSnapshotEntityWikiLinksUndoable, + setSnapshotWikisAndEntityWikiLinks: setSnapshotWikisAndEntityWikiLinksUndoable, }; } @@ -738,6 +838,18 @@ function resolveStateAction(next: SetStateAction, prev: T): T { return typeof next === "function" ? (next as (value: T) => T)(prev) : next; } +function featureIdEquals(a: FeatureProperties["id"], b: FeatureProperties["id"]) { + return String(a) === String(b); +} + +function jsonEquals(a: unknown, b: unknown) { + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } +} + function createReplaySessionSeed( sourceDraft: FeatureCollection, geometryId: string, @@ -888,6 +1000,40 @@ function replaceReplayByGeometryId( return next; } +function pruneDeletedGeometryIdsFromReplays( + replays: BattleReplay[], + deletedIds: Set +): BattleReplay[] { + const next: BattleReplay[] = []; + + for (const replay of replays || []) { + const geometryId = String(replay?.geometry_id || ""); + if (!geometryId || deletedIds.has(geometryId)) continue; + + const targetGeometryIds = normalizeReplayTargetGeometryIds( + replay.target_geometry_ids, + geometryId + ).filter((id) => !deletedIds.has(id)); + + next.push({ + ...deepClone(replay), + id: geometryId, + geometry_id: geometryId, + target_geometry_ids: targetGeometryIds, + }); + } + + return next; +} + +function replaysEqual(a: BattleReplay[] | null | undefined, b: BattleReplay[] | null | undefined) { + try { + return JSON.stringify(a ?? []) === JSON.stringify(b ?? []); + } catch { + return false; + } +} + function replayEquals(a: BattleReplay | null | undefined, b: BattleReplay | null | undefined) { try { return JSON.stringify(a ?? null) === JSON.stringify(b ?? null); diff --git a/src/uhm/lib/map/engines/selectingEngine.ts.orig b/src/uhm/lib/map/engines/selectingEngine.ts.orig deleted file mode 100644 index 4ffd708..0000000 --- a/src/uhm/lib/map/engines/selectingEngine.ts.orig +++ /dev/null @@ -1,357 +0,0 @@ -import maplibregl from "maplibre-gl"; -import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes"; - -// Khởi tạo engine chọn feature và context menu edit/delete. -export function initSelect( - map: maplibregl.Map, - getMode: ModeGetter, - onDelete?: (id: string | number) => void, - onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void, - onDuplicate?: (id: string | number) => void, - onHide?: (id: string | number) => void, - onSelectIds?: (ids: (string | number)[]) => void, - onReplayEdit?: (id: string | number) => void, - isEditSessionActive?: () => boolean, - onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void -) { - - const FEATURE_STATE_SOURCES = [ - "countries", - "places", - "path-arrow-shapes", - ] as const; - const selectedIds = new Set(); - const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit || onBindGeometries); - let contextMenu: HTMLDivElement | null = null; - let docClickHandler: ((ev: MouseEvent) => void) | null = null; - - // Bỏ highlight feature-state của toàn bộ đối tượng đang chọn. - function clearSelection(emit = true) { - if (!selectedIds.size) return; - selectedIds.forEach((id) => setSelectionStateForId(id, false)); - selectedIds.clear(); - if (emit) { - onSelectIds?.([]); - } - } - - // Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn. - function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) { - const id = feature.id ?? feature.properties?.id; - if (id === undefined || id === null) return; - - if (!additive) { - clearSelection(); - } - - const idToRemove = Array.from(selectedIds).find(sid => String(sid) === String(id)); - const isAlreadySelected = idToRemove !== undefined; - - if (additive && isAlreadySelected) { - // Alt + click on an already selected feature removes it from the selection - setSelectionStateForId(idToRemove, false); - selectedIds.delete(idToRemove); - onSelectIds?.(Array.from(selectedIds)); - return; - } - - setSelectionStateForId(id, true); - selectedIds.add(id); - onSelectIds?.(Array.from(selectedIds)); - } - - // Chọn feature theo click trái, hỗ trợ additive bằng Alt. - function onClick(e: maplibregl.MapLayerMouseEvent) { - if (getMode() !== "select" && getMode() !== "replay") return; - if (isEditSessionActive?.()) return; - const selectableLayers = getSelectableLayers(); - if (!selectableLayers.length) return; - - const features = map.queryRenderedFeatures(e.point, { - layers: selectableLayers, - }) as maplibregl.MapGeoJSONFeature[]; - - if (!features.length) { - clearSelection(); - return; - } - - const additive = !!e.originalEvent?.altKey; - selectFeature(pickPreferredFeature(features), additive); - } - - // Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải. - // Mở menu thao tác khi click phải lên feature. - function onRightClick(e: maplibregl.MapLayerMouseEvent) { - if (getMode() !== "select" && getMode() !== "replay") return; - const selectableLayers = getSelectableLayers(); - if (!selectableLayers.length) return; - - e.preventDefault(); // block browser menu - if (getMode() === "replay") return; - if (isEditSessionActive?.()) return; - - const features = map.queryRenderedFeatures(e.point, { - layers: selectableLayers, - }) as maplibregl.MapGeoJSONFeature[]; - - if (!features.length) return; - - const feature = pickPreferredFeature(features); - const id = feature.id ?? feature.properties?.id; - if (id === undefined || id === null) return; - - const isRightClickedItemAlreadySelected = Array.from(selectedIds).some(sid => String(sid) === String(id)); - const hasSelection = selectedIds.size > 0; - - // If the right-clicked item is not selected, and there is no active selection, - // make it the sole selection. If there is an active selection, do not clear it - // so we can bind the active selection to this target geometry. - if (!isRightClickedItemAlreadySelected && !hasSelection) { - clearSelection(); - selectFeature(feature, false); - } - - showContextMenu( - e.originalEvent?.clientX ?? e.point.x, - e.originalEvent?.clientY ?? e.point.y, - feature, - isRightClickedItemAlreadySelected, - hasSelection - ); - } - - // Đổi cursor pointer khi hover lên đối tượng có thể chọn. - function onMove(e: maplibregl.MapLayerMouseEvent) { - if (getMode() !== "select" && getMode() !== "replay") return; - const selectableLayers = getSelectableLayers(); - if (!selectableLayers.length) { - map.getCanvas().style.cursor = ""; - return; - } - - const features = map.queryRenderedFeatures(e.point, { - layers: selectableLayers, - }); - - map.getCanvas().style.cursor = features.length ? "pointer" : ""; - } - - function getSelectableLayers(): string[] { - const style = map.getStyle(); - if (!style || !style.layers) return []; - return style.layers - .filter((layer) => - "source" in layer && - typeof layer.source === "string" && - FEATURE_STATE_SOURCES.includes(layer.source as (typeof FEATURE_STATE_SOURCES)[number]) - ) - .map((layer) => layer.id); - } - - function setSelectionStateForId(id: string | number, selected: boolean) { - for (const source of FEATURE_STATE_SOURCES) { - if (!map.getSource(source)) continue; - map.setFeatureState({ source, id }, { selected }); - } - } - - function pickPreferredFeature(features: maplibregl.MapGeoJSONFeature[]) { - return [...features].sort((a, b) => featureSelectPriority(b) - featureSelectPriority(a))[0]; - } - - function featureSelectPriority(feature: maplibregl.MapGeoJSONFeature) { - const layerId = typeof feature.layer?.id === "string" ? feature.layer.id : ""; - const geometryType = feature.geometry?.type; - const source = typeof feature.source === "string" ? feature.source : ""; - - if (layerId.endsWith("-hit")) return 400; - if (source === "path-arrow-shapes") return 300; - if (geometryType === "LineString" || geometryType === "MultiLineString") return 200; - if (geometryType === "Point" || geometryType === "MultiPoint") return 100; - return 0; - } - - // Đồng bộ selection state từ React. - function syncSelection(ids: (string | number)[]) { - const nextSet = new Set(ids); - selectedIds.forEach((id) => { - if (!nextSet.has(id)) { - setSelectionStateForId(id, false); - } - }); - selectedIds.clear(); - ids.forEach((id) => { - setSelectionStateForId(id, true); - selectedIds.add(id); - }); - } - - map.on("click", onClick); - map.on("mousemove", onMove); - if (hasContextActions) { - map.on("contextmenu", onRightClick); - } - - const cleanup = () => { - map.off("click", onClick); - map.off("mousemove", onMove); - if (hasContextActions) { - map.off("contextmenu", onRightClick); - } - clearSelection(false); - hideContextMenu(); - }; - - return { - cleanup, - clearSelection, - syncSelection, - }; - - // Ẩn và dọn dẹp context menu hiện tại. - function hideContextMenu() { - if (contextMenu) { - contextMenu.remove(); - contextMenu = null; - } - if (docClickHandler) { - document.removeEventListener("click", docClickHandler); - docClickHandler = null; - } - } - - // Render menu ngữ cảnh tối giản gần vị trí con trỏ. - function showContextMenu( - x: number, - y: number, - clickedFeature: maplibregl.MapGeoJSONFeature, - isRightClickedItemAlreadySelected: boolean, - hasSelection: boolean - ) { - hideContextMenu(); - - const menu = document.createElement("div"); - menu.style.position = "fixed"; - menu.style.left = `${x}px`; - menu.style.top = `${y}px`; - menu.style.background = "#0f172a"; - menu.style.color = "white"; - menu.style.border = "1px solid #1f2937"; - menu.style.borderRadius = "6px"; - menu.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)"; - menu.style.zIndex = "9999"; - menu.style.minWidth = "120px"; - menu.style.fontSize = "14px"; - menu.style.padding = "4px 0"; - - // Tạo một item thao tác trong context menu. - const createItem = (label: string, onClick: () => void) => { - const item = document.createElement("div"); - item.textContent = label; - item.style.padding = "8px 12px"; - item.style.cursor = "pointer"; - item.onmouseenter = () => (item.style.background = "#1f2937"); - item.onmouseleave = () => (item.style.background = "transparent"); - item.onclick = () => { - onClick(); - hideContextMenu(); - }; - return item; - }; - - const selectedCount = selectedIds.size; - let hasMenuItems = false; - - const effectiveCount = selectedCount || 1; - const targetId = clickedFeature.id ?? clickedFeature.properties?.id; - const isClickOutsideSelection = !isRightClickedItemAlreadySelected && hasSelection; - - if (isClickOutsideSelection && onBindGeometries && targetId !== undefined && targetId !== null) { - const sourceIds = Array.from(selectedIds); - menu.appendChild( - createItem( - `Bind ${selectedCount} geo đang chọn vào geo này`, - () => { - onBindGeometries(targetId, sourceIds); - } - ) - ); - hasMenuItems = true; - - const separator = document.createElement("div"); - separator.style.height = "1px"; - separator.style.background = "#374151"; - separator.style.margin = "4px 0"; - menu.appendChild(separator); - } - - if (!isClickOutsideSelection) { - if ( - effectiveCount === 1 && - clickedFeature.source === "countries" && - clickedFeature.geometry?.type === "Polygon" && - onEdit - ) { - const single = clickedFeature; - menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single))); - hasMenuItems = true; - } - - if (effectiveCount === 1 && onDuplicate && targetId !== undefined && targetId !== null) { - menu.appendChild(createItem("Duplicate", () => onDuplicate(targetId))); - hasMenuItems = true; - } - - if (effectiveCount === 1 && onHide && targetId !== undefined && targetId !== null) { - menu.appendChild(createItem("Hide", () => onHide(targetId))); - hasMenuItems = true; - } - } - - if (onReplayEdit) { - const replayId = isClickOutsideSelection ? Array.from(selectedIds)[0] : targetId; - if (replayId !== undefined && replayId !== null) { - menu.appendChild( - createItem( - effectiveCount > 1 ? `Vào replay (${effectiveCount} geo)` : "Vào replay", - () => onReplayEdit(replayId) - ) - ); - hasMenuItems = true; - } - } - - if (onDelete) { - menu.appendChild( - createItem( - effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa", - () => { - const ids = selectedIds.size - ? Array.from(selectedIds) - : [targetId]; - ids.forEach((eachId) => { - if (eachId !== undefined && eachId !== null) onDelete(eachId); - }); - clearSelection(); - } - ) - ); - hasMenuItems = true; - } - - if (!hasMenuItems) return; - - document.body.appendChild(menu); - contextMenu = menu; - - // Đóng menu khi click ra ngoài vùng menu. - const onDocClick = (ev: MouseEvent) => { - if (!menu.contains(ev.target as Node)) { - hideContextMenu(); - } - }; - docClickHandler = onDocClick; - setTimeout(() => document.addEventListener("click", onDocClick), 0); - } -} diff --git a/src/uhm/lib/map/styles/shared/pointStyle.ts b/src/uhm/lib/map/styles/shared/pointStyle.ts index be29449..c9f6cda 100644 --- a/src/uhm/lib/map/styles/shared/pointStyle.ts +++ b/src/uhm/lib/map/styles/shared/pointStyle.ts @@ -17,8 +17,6 @@ export const POINT_GEOTYPE_IDS = [ export type PointGeotypeId = (typeof POINT_GEOTYPE_IDS)[number]; -type PointIconVariant = "default" | "draft"; - type PointLayerOptions = { iconScale?: number; haloRadius?: number; @@ -33,12 +31,9 @@ type PointStyleConfig = { }; const TYPE_MATCH_EXPR: maplibregl.ExpressionSpecification = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""]; -const DRAFT_ENTITY_EXPR: maplibregl.ExpressionSpecification = ["==", ["coalesce", ["get", "entity_id"], ""], ""]; const SELECTED_EXPR: maplibregl.ExpressionSpecification = ["boolean", ["feature-state", "selected"], false]; const ICON_CANVAS_SIZE = 64; -const DRAFT_FILL = "#ef4444"; -const DRAFT_RIM = "#7f1d1d"; const POINT_GEOMETRY_FILTER: maplibregl.ExpressionSpecification = [ "any", ["==", ["geometry-type"], "Point"], @@ -156,7 +151,7 @@ export function buildPointGeotypeLayers( source: pointSourceId, filter: pointFilter(typeId), layout: { - "icon-image": pointIconExpression(typeId), + "icon-image": getPointIconId(typeId), "icon-size": [ "interpolate", ["linear"], @@ -201,13 +196,11 @@ export function ensurePointGeotypeIcons(map: maplibregl.Map): boolean { if (typeof document === "undefined") return false; for (const typeId of POINT_GEOTYPE_IDS) { - for (const variant of ["default", "draft"] as const) { - const iconId = getPointIconId(typeId, variant); - if (map.hasImage(iconId)) continue; - const imageData = createPointIconImageData(typeId, variant); - if (!imageData) return false; - map.addImage(iconId, imageData, { pixelRatio: 2 }); - } + const iconId = getPointIconId(typeId); + if (map.hasImage(iconId)) continue; + const imageData = createPointIconImageData(typeId); + if (!imageData) return false; + map.addImage(iconId, imageData, { pixelRatio: 2 }); } return true; @@ -217,19 +210,13 @@ function pointFilter(typeId: PointGeotypeId): maplibregl.ExpressionSpecification return ["all", POINT_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]]; } -function pointIconExpression(typeId: PointGeotypeId): maplibregl.ExpressionSpecification { - return ["case", DRAFT_ENTITY_EXPR, getPointIconId(typeId, "draft"), getPointIconId(typeId, "default")]; +function getPointIconId(typeId: PointGeotypeId): string { + return `point-${typeId}`; } -function getPointIconId(typeId: PointGeotypeId, variant: PointIconVariant): string { - return `point-${typeId}-${variant}`; -} - -function createPointIconImageData(typeId: PointGeotypeId, variant: PointIconVariant): ImageData | null { +function createPointIconImageData(typeId: PointGeotypeId): ImageData | null { const config = POINT_STYLE_CONFIG[typeId]; - const palette = variant === "draft" - ? { fill: DRAFT_FILL, rim: DRAFT_RIM } - : { fill: config.fill, rim: config.rim }; + const palette = { fill: config.fill, rim: config.rim }; const canvas = document.createElement("canvas"); canvas.width = ICON_CANVAS_SIZE; diff --git a/src/uhm/lib/map/styles/shared/styleBuilders.ts b/src/uhm/lib/map/styles/shared/styleBuilders.ts index 112459c..49614a2 100644 --- a/src/uhm/lib/map/styles/shared/styleBuilders.ts +++ b/src/uhm/lib/map/styles/shared/styleBuilders.ts @@ -1,17 +1,9 @@ import maplibregl, { LayerSpecification } from "maplibre-gl"; const TYPE_MATCH_EXPR: maplibregl.ExpressionSpecification = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""]; -const DRAFT_ENTITY_EXPR: maplibregl.ExpressionSpecification = [ - "all", - ["==", ["coalesce", ["get", "entity_id"], ""], ""], - ["!", ["has", "binding"]] -]; const SELECTED_EXPR: maplibregl.ExpressionSpecification = ["boolean", ["feature-state", "selected"], false]; const SELECTED_COLOR = "#22c55e"; -const SELECTED_STROKE = "#14532d"; -const DRAFT_COLOR = "#ef4444"; -const DRAFT_STROKE = "#7f1d1d"; type ZoomStops = { z1: number; @@ -177,8 +169,6 @@ function statusColor(normalColor: string): maplibregl.ExpressionSpecification { "case", SELECTED_EXPR, SELECTED_COLOR, - DRAFT_ENTITY_EXPR, - DRAFT_COLOR, normalColor, ]; } @@ -188,19 +178,12 @@ function statusStroke(normalColor: string): maplibregl.ExpressionSpecification { "case", SELECTED_EXPR, SELECTED_COLOR, - DRAFT_ENTITY_EXPR, - DRAFT_STROKE, normalColor, ]; } -function statusFillColor(normalColor: string): maplibregl.ExpressionSpecification { - return [ - "case", - DRAFT_ENTITY_EXPR, - DRAFT_COLOR, - normalColor, - ]; +function statusFillColor(normalColor: string): string { + return normalColor; } function lineFilter(typeId: string): maplibregl.ExpressionSpecification { diff --git a/src/uhm/lib/utils/timeline.ts b/src/uhm/lib/utils/timeline.ts index 61d4f55..52c103c 100644 --- a/src/uhm/lib/utils/timeline.ts +++ b/src/uhm/lib/utils/timeline.ts @@ -23,3 +23,18 @@ export function clampYearValue(year: number, minYear: number, maxYear: number): export function clampYearToFixedRange(year: number): number { return clampYearValue(year, FIXED_TIMELINE_START_YEAR, FIXED_TIMELINE_END_YEAR); } + +export function normalizeTimelineYearValue(value: unknown): number | null { + if (typeof value === "number") { + return Number.isFinite(value) ? Math.trunc(value) : null; + } + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed.length) return null; + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? Math.trunc(parsed) : null; + } + + return null; +} diff --git a/src/uhm/store/editorStore.tsx b/src/uhm/store/editorStore.tsx index d24dc37..95dd745 100644 --- a/src/uhm/store/editorStore.tsx +++ b/src/uhm/store/editorStore.tsx @@ -35,9 +35,9 @@ export type GeometryFocusRequest = { }; type EditorStoreValues = { - // Editor mode + draft seed. + // Editor mode + baseline FeatureCollection used to seed/reset useEditorState. mode: EditorMode; - initialData: FeatureCollection; + baselineFeatureCollection: FeatureCollection; // Task flags; setTaskFlag ensures only one blocking task is active at a time. isSaving: boolean; isSubmitting: boolean; @@ -54,7 +54,7 @@ type EditorStoreValues = { baselineSnapshot: EditorSnapshot | null; // Entity state: backend catalog plus snapshot-local rows and form/search status. entityCatalog: Entity[]; - snapshotEntities: EntitySnapshot[]; + snapshotEntityRows: EntitySnapshot[]; entityStatus: string | null; selectedFeatureIds: FeatureId[]; entityForm: EntityFormState; @@ -92,12 +92,13 @@ type EditorStoreValues = { geometryFocusRequest: GeometryFocusRequest | null; replayFeatureId: string | number | null; hideOutside: boolean; + // Map visibility overrides keyed by either a geometry id or a semantic geo type key. geometryVisibility: Record; }; type EditorStoreActions = { setMode: (next: SetStateAction) => void; - setInitialData: (next: SetStateAction) => void; + setBaselineFeatureCollection: (next: SetStateAction) => void; setIsSaving: (next: SetStateAction) => void; setIsSubmitting: (next: SetStateAction) => void; setIsOpeningSection: (next: SetStateAction) => void; @@ -111,7 +112,7 @@ type EditorStoreActions = { setProjectCommits: (next: SetStateAction) => void; setBaselineSnapshot: (next: SetStateAction) => void; setEntityCatalog: (next: SetStateAction) => void; - setSnapshotEntities: (next: SetStateAction) => void; + setSnapshotEntityRows: (next: SetStateAction) => void; setEntityStatus: (next: SetStateAction) => void; setSelectedFeatureIds: (next: SetStateAction) => void; setEntityForm: (next: SetStateAction) => void; @@ -228,7 +229,7 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi { return { mode: "idle", - initialData: options.emptyFeatureCollection, + baselineFeatureCollection: options.emptyFeatureCollection, isSaving: false, isSubmitting: false, isOpeningSection: false, @@ -242,7 +243,7 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi { sectionCommits: [], baselineSnapshot: null, entityCatalog: [], - snapshotEntities: [], + snapshotEntityRows: [], entityStatus: null, selectedFeatureIds: [], entityForm: { @@ -287,7 +288,7 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi { hideOutside: false, geometryVisibility: buildInitialGeometryVisibility(), setMode: (next) => setValue("mode", next), - setInitialData: (next) => setValue("initialData", next), + setBaselineFeatureCollection: (next) => setValue("baselineFeatureCollection", next), setIsSaving: (next) => setTaskFlag("saving", next), setIsSubmitting: (next) => setTaskFlag("submitting", next), setIsOpeningSection: (next) => setTaskFlag("opening-project", next), @@ -301,7 +302,7 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi { setProjectCommits: (next) => setValue("sectionCommits", next), setBaselineSnapshot: (next) => setValue("baselineSnapshot", next), setEntityCatalog: (next) => setValue("entityCatalog", next), - setSnapshotEntities: (next) => setValue("snapshotEntities", next), + setSnapshotEntityRows: (next) => setValue("snapshotEntityRows", next), setEntityStatus: (next) => setValue("entityStatus", next), setSelectedFeatureIds: (next) => setValue("selectedFeatureIds", next), setEntityForm: (next) => setValue("entityForm", next),