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 1933a93..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); @@ -742,10 +786,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") { @@ -761,7 +807,7 @@ function EditorPageContent() { editor, internalSetMode, mode, - replayPreview.resetPreview, + resetReplayPreview, selectedFeatureIds, setHideOutside, setReplayFeatureId, @@ -809,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(() => { @@ -831,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); @@ -903,8 +952,8 @@ function EditorPageContent() { }, [ isReplayPreviewMode, previewWikiCache, - replayPreview.activeWikiId, - replayPreview.sidebarOpen, + replayPreviewActiveWikiId, + replayPreviewSidebarOpen, replayPreviewWikiRows, ]); @@ -934,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(() => { @@ -1257,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) { @@ -1283,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 @@ -1346,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 [ { @@ -1355,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, ]; @@ -1397,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 = @@ -1540,6 +1587,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(); @@ -1551,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))); } @@ -1724,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], @@ -1735,7 +1812,7 @@ function EditorPageContent() { geometry, }; - editor.createFeatureWithSnapshotEntities( + editor.createFeatureWithSnapshotEntityRows( feature, (prev) => { if (prev.some((e) => String(e.id) === importedEntity.id)) return prev; @@ -1827,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 [ { @@ -1878,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 ( @@ -2001,24 +2080,31 @@ function EditorPageContent() { ref={mapHandleRef} mode={mode} onSetMode={setMode} - draft={timelineVisibleDraft} + renderDraft={mapRenderDraft} labelContextDraft={mapLabelContextDraft} labelTimelineYear={activeTimelineFilterEnabled ? activeTimelineYear : null} 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} geometryVisibility={effectiveGeometryVisibility} - respectBindingFilter={isReplayEditMode || isReplayPreviewMode ? false : geometryBindingFilterEnabled} + applyGeometryBindingFilter={isReplayEditMode || isReplayPreviewMode ? false : geometryBindingFilterEnabled} highlightFeatures={null} focusFeatureCollection={geometryFocusRequest?.collection || null} focusRequestKey={geometryFocusRequest?.key ?? null} focusPadding={96} imageOverlay={imageOverlay} onImageOverlayChange={setImageOverlay} + onBindGeometries={handleBindGeometries} /> ) : (
@@ -2154,15 +2240,21 @@ function EditorPageContent() { - {selectedFeature ? ( + {selectedFeatures.length > 0 ? ( { + editor.deleteFeatures(ids); + setSelectedFeatureIds([]); + }} + onDeselectAll={() => setSelectedFeatureIds([])} changeCount={editor.changeCount} onReplayEdit={(id) => setMode("replay", id)} /> @@ -2243,8 +2335,8 @@ function buildEntityLabelContextDraft(draft: FeatureCollection, entities: Entity return { id, name, - time_start: entity?.time_start ?? null, - time_end: entity?.time_end ?? null, + time_start: normalizeTimelineYearValue(entity?.time_start), + time_end: normalizeTimelineYearValue(entity?.time_end), }; }).filter((candidate) => candidate !== null); diff --git a/src/app/page.tsx b/src/app/page.tsx index 7a01287..76b3808 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/styles/TimelineBar.module.css b/src/styles/TimelineBar.module.css index e28952a..14167f4 100644 --- a/src/styles/TimelineBar.module.css +++ b/src/styles/TimelineBar.module.css @@ -214,11 +214,21 @@ display: inline-flex; align-items: center; gap: 10px; + padding: 0; + border: 0; + background: transparent; + color: inherit; cursor: pointer; user-select: none; transition: opacity 0.2s; } +.toggleContainer:focus-visible { + outline: 2px solid rgba(52, 211, 153, 0.8); + outline-offset: 3px; + border-radius: 999px; +} + .toggleTrack { width: 38px; height: 20px; diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index 2e5961c..b0fc372 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -33,20 +33,23 @@ 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; - 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; - respectBindingFilter?: boolean; + applyGeometryBindingFilter?: boolean; height?: CSSProperties["height"]; fitToDraftBounds?: boolean; fitBoundsKey?: string | number | null; @@ -57,12 +60,13 @@ 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({ mode, onSetMode, - draft, + renderDraft, backgroundVisibility, geometryVisibility, selectedFeatureIds, @@ -74,7 +78,7 @@ const Map = forwardRef(function Map({ onHideFeature, onUpdateFeature, allowGeometryEditing = true, - respectBindingFilter = true, + applyGeometryBindingFilter = true, height = "100vh", fitToDraftBounds = false, fitBoundsKey = null, @@ -85,11 +89,12 @@ 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); - // 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. @@ -108,9 +113,11 @@ 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(() => { renderDraftRef.current = renderDraft; }, [renderDraft]); useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]); useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]); useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]); @@ -120,6 +127,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 { @@ -154,7 +162,7 @@ const Map = forwardRef(function Map({ mapRef, mode, modeRef, - draftRef, + renderDraftRef, allowGeometryEditing, selectedFeatureIds, onSelectFeatureIdsRef, @@ -164,23 +172,24 @@ const Map = forwardRef(function Map({ onHideRef, onUpdateRef, onHoverFeatureChangeRef, + onBindGeometriesRef, }); // 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, @@ -200,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} -
+
+ + +
+
+ )} +
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/editor/UndoListPanel.tsx b/src/uhm/components/editor/UndoListPanel.tsx index f502c9d..794921a 100644 --- a/src/uhm/components/editor/UndoListPanel.tsx +++ b/src/uhm/components/editor/UndoListPanel.tsx @@ -49,6 +49,7 @@ export function formatUndoLabel(action: UndoAction) { case "snapshot_wikis": case "snapshot_entity_wiki": case "replay": + case "replays": case "replay_session": case "group": return action.label; diff --git a/src/uhm/components/map/useMapInteraction.ts b/src/uhm/components/map/useMapInteraction.ts index f51aa9f..b56729a 100644 --- a/src/uhm/components/map/useMapInteraction.ts +++ b/src/uhm/components/map/useMapInteraction.ts @@ -16,29 +16,33 @@ type EngineBinding = { cleanup: () => void; cancel?: () => void; clearSelection?: (skipNotify?: boolean) => void; + syncSelection?: (ids: (string | number)[]) => void; }; type UseMapInteractionProps = { mapRef: React.MutableRefObject; mode: EditorMode; modeRef: React.MutableRefObject; - draftRef: React.MutableRefObject; + // Rendered/interacted FeatureCollection from Map.tsx. This may already be filtered by + // replay/timeline state, so do not treat it as the canonical commit/edit draft. + renderDraftRef: React.MutableRefObject; allowGeometryEditing: boolean; selectedFeatureIds: (string | number)[]; 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>; + onBindGeometriesRef?: React.MutableRefObject<((targetId: string | number, sourceIds: (string | number)[]) => void) | undefined>; }; export function useMapInteraction({ mapRef, mode, modeRef, - draftRef, + renderDraftRef, allowGeometryEditing, selectedFeatureIds, onSelectFeatureIdsRef, @@ -48,6 +52,7 @@ export function useMapInteraction({ onHideRef, onUpdateRef, onHoverFeatureChangeRef, + onBindGeometriesRef, }: UseMapInteractionProps) { const editingEngineRef = useRef | null>(null); const engineBindingsRef = useRef>>({}); @@ -72,6 +77,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) { @@ -134,7 +146,7 @@ export function useMapInteraction({ map, () => modeRef.current, allowGeometryEditing - ? (id: string | number) => { + ? (id: string | number | (string | number)[]) => { editingEngineRef.current?.clearEditing(); onSelectFeatureIdsRef.current?.([]); onDeleteRef.current?.(id); @@ -143,7 +155,7 @@ export function useMapInteraction({ allowGeometryEditing ? (feature) => { const rawId = feature.id ?? feature.properties?.id; - const originalFeature = draftRef.current.features.find( + const originalFeature = renderDraftRef.current.features.find( (item) => String(item.properties.id) === String(rawId) ); editingEngineRef.current?.beginEditing( @@ -153,7 +165,7 @@ export function useMapInteraction({ : undefined, allowGeometryEditing ? (id: string | number) => { - const originalFeature = draftRef.current.features.find( + const originalFeature = renderDraftRef.current.features.find( (item) => String(item.properties.id) === String(id) ); if (!originalFeature) return; @@ -170,7 +182,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( @@ -301,7 +314,7 @@ export function useMapInteraction({ } const currentFeature = - draftRef.current.features.find( + renderDraftRef.current.features.find( (item) => String(item.properties.id) === String(rawFeatureId) ) || null; diff --git a/src/uhm/components/map/useMapSync.ts b/src/uhm/components/map/useMapSync.ts index ee2f808..7afe209 100644 --- a/src/uhm/components/map/useMapSync.ts +++ b/src/uhm/components/map/useMapSync.ts @@ -20,13 +20,17 @@ import { applyImageOverlay, type MapImageOverlay } from "./imageOverlay"; type UseMapSyncProps = { mapRef: React.MutableRefObject; - draft: FeatureCollection; + // Already-filtered FeatureCollection that should be written to MapLibre sources. + // Timeline/replay filters must be applied before this hook receives it. + renderDraft: FeatureCollection; + // Lookup-only context for labels. It may contain geometries that are not rendered. + // Never use it to decide which geometries appear on the map. labelContextDraft?: FeatureCollection; labelTimelineYear?: number | null; backgroundVisibility: BackgroundLayerVisibility; geometryVisibility?: Record; selectedFeatureIds: (string | number)[]; - respectBindingFilter: boolean; + applyGeometryBindingFilter: boolean; fitToDraftBounds: boolean; fitBoundsKey?: string | number | null; highlightFeatures?: FeatureCollection | null; @@ -44,13 +48,13 @@ type UseMapSyncProps = { export function useMapSync({ mapRef, - draft, + renderDraft, labelContextDraft, labelTimelineYear, backgroundVisibility, geometryVisibility, selectedFeatureIds, - respectBindingFilter, + applyGeometryBindingFilter, fitToDraftBounds, fitBoundsKey, highlightFeatures, @@ -62,13 +66,13 @@ export function useMapSync({ editingEngineRef, geolocationCenteredRef, }: UseMapSyncProps) { - const draftRef = useRef(draft); + const renderDraftRef = useRef(renderDraft); const labelContextDraftRef = useRef(labelContextDraft); const labelTimelineYearRef = useRef(labelTimelineYear); const backgroundVisibilityRef = useRef(backgroundVisibility); const geometryVisibilityRef = useRef | undefined>(geometryVisibility); const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds); - const respectBindingFilterRef = useRef(respectBindingFilter); + const applyGeometryBindingFilterRef = useRef(applyGeometryBindingFilter); const fitToDraftBoundsRef = useRef(fitToDraftBounds); const highlightFeaturesRef = useRef(highlightFeatures || null); const imageOverlayRef = useRef(imageOverlay || null); @@ -77,13 +81,13 @@ export function useMapSync({ const fitBoundsAppliedRef = useRef(false); - useEffect(() => { draftRef.current = draft; }, [draft]); + useEffect(() => { renderDraftRef.current = renderDraft; }, [renderDraft]); useEffect(() => { labelContextDraftRef.current = labelContextDraft; }, [labelContextDraft]); useEffect(() => { labelTimelineYearRef.current = labelTimelineYear; }, [labelTimelineYear]); useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; }, [backgroundVisibility]); useEffect(() => { geometryVisibilityRef.current = geometryVisibility; }, [geometryVisibility]); useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]); - useEffect(() => { respectBindingFilterRef.current = respectBindingFilter; }, [respectBindingFilter]); + useEffect(() => { applyGeometryBindingFilterRef.current = applyGeometryBindingFilter; }, [applyGeometryBindingFilter]); useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]); useEffect(() => { highlightFeaturesRef.current = highlightFeatures || null; }, [highlightFeatures]); useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]); @@ -94,8 +98,8 @@ export function useMapSync({ fitBoundsAppliedRef.current = false; }, [fitBoundsKey]); - const applyDraftToMap = useCallback(( - fc: FeatureCollection, + const applyRenderDraftToMap = useCallback(( + renderFc: FeatureCollection, labelContextOverride?: FeatureCollection, selectedIdsOverride?: (string | number)[], highlightFeaturesOverride?: FeatureCollection | null @@ -115,22 +119,22 @@ export function useMapSync({ } } - const labelContext = labelContextOverride || labelContextDraftRef.current || fc; + const labelContext = labelContextOverride || labelContextDraftRef.current || renderFc; const currentSelectedIds = selectedIdsOverride || selectedFeatureIdsRef.current; const highlightFeaturesVal = highlightFeaturesOverride !== undefined ? highlightFeaturesOverride : highlightFeaturesRef.current; - const visibleDraftRaw = respectBindingFilterRef.current - ? filterDraftByBinding(labelContext, currentSelectedIds, highlightFeaturesVal) - : labelContext; - const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current); + const bindingFilteredRenderDraft = applyGeometryBindingFilterRef.current + ? filterDraftByBinding(renderFc, currentSelectedIds, highlightFeaturesVal) + : renderFc; + const mapSourceDraft = filterDraftByGeometryVisibility(bindingFilteredRenderDraft, geometryVisibilityRef.current); const labelTimelineYear = labelTimelineYearRef.current; - const { polygons, points } = splitDraftFeatures(visibleDraft); + const { polygons, points } = splitDraftFeatures(mapSourceDraft); const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext, labelTimelineYear); const labeledPoints = decoratePointFeaturesWithLabels(points, labelContext, labelTimelineYear); const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext, labelTimelineYear); - const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft); + const pathArrowShapes = buildPathArrowFeatureCollection(mapSourceDraft); countriesSource.setData(labeledGeometries); placesSource.setData(labeledPoints); @@ -147,7 +151,7 @@ export function useMapSync({ }); }); if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) { - fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft); + fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, mapSourceDraft); } }, [mapRef]); @@ -206,24 +210,24 @@ export function useMapSync({ }, [imageOverlay, mapRef]); useEffect(() => { - applyDraftToMap(draft, labelContextDraft, selectedFeatureIds, highlightFeatures); + applyRenderDraftToMap(renderDraft, labelContextDraft, selectedFeatureIds, highlightFeatures); const editingId = editingEngineRef.current?.editingRef?.current?.id; if (allowGeometryEditing && editingId !== undefined && editingId !== null) { - const stillExists = draft.features.some((f) => f.properties.id === editingId); + const stillExists = renderDraft.features.some((f) => f.properties.id === editingId); if (!stillExists) { editingEngineRef.current?.clearEditing(); } } }, [ allowGeometryEditing, - draft, + renderDraft, labelContextDraft, labelTimelineYear, selectedFeatureIds, - respectBindingFilter, + applyGeometryBindingFilter, geometryVisibility, highlightFeatures, - applyDraftToMap, + applyRenderDraftToMap, editingEngineRef, ]); @@ -259,7 +263,7 @@ export function useMapSync({ }, [focusRequestKey, mapRef]); return { - applyDraftToMap, + applyRenderDraftToMap, applyHighlightToMap, tryCenterToUserLocation, applyImageOverlayToMap: () => { diff --git a/src/uhm/components/ui/TimelineBar.tsx b/src/uhm/components/ui/TimelineBar.tsx index 8618a4d..8fda89c 100644 --- a/src/uhm/components/ui/TimelineBar.tsx +++ b/src/uhm/components/ui/TimelineBar.tsx @@ -55,9 +55,15 @@ export default function TimelineBar({ >
{typeof filterEnabled === "boolean" && onFilterEnabledChange ? ( - + ) : 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 2e2a784..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,17 +547,77 @@ 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 }); } + function deleteFeatures(ids: Array) { + if (mode === "replay") { + return; + } + + const idsSet = new Set(ids.map(String)); + const nextFeatures: Feature[] = []; + const undoActions: UndoAction[] = []; + + mainDraftRef.current.features.forEach((feature, index) => { + if (idsSet.has(String(feature.properties.id))) { + 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( + 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)); } @@ -593,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; @@ -611,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(( @@ -661,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(); @@ -690,19 +816,21 @@ export function useEditorState( changeCount, canUndoReplay: replayUndoStack.length > 0, createFeature, - createFeatureWithSnapshotEntities, + createFeatureWithSnapshotEntityRows, patchFeatureProperties, patchFeaturePropertiesBatch, updateFeature, deleteFeature, + deleteFeatures, undo, buildPayload, clearChanges, hasPersistedFeature, // Snapshot undo helpers (no-op if snapshotUndo not provided) - setSnapshotEntities: setSnapshotEntitiesUndoable, + setSnapshotEntityRows: setSnapshotEntityRowsUndoable, setSnapshotWikis: setSnapshotWikisUndoable, setSnapshotEntityWikiLinks: setSnapshotEntityWikiLinksUndoable, + setSnapshotWikisAndEntityWikiLinks: setSnapshotWikisAndEntityWikiLinksUndoable, }; } @@ -710,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, @@ -860,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 b/src/uhm/lib/map/engines/selectingEngine.ts index fb643c9..4fdbfb2 100644 --- a/src/uhm/lib/map/engines/selectingEngine.ts +++ b/src/uhm/lib/map/engines/selectingEngine.ts @@ -5,13 +5,14 @@ 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, 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; @@ -43,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; } @@ -97,8 +101,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 = 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); } @@ -106,7 +115,9 @@ export function initSelect( showContextMenu( e.originalEvent?.clientX ?? e.point.x, e.originalEvent?.clientY ?? e.point.y, - feature + feature, + isRightClickedItemAlreadySelected, + hasSelection ); } @@ -161,6 +172,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 +206,7 @@ export function initSelect( return { cleanup, clearSelection, + syncSelection, }; // Ẩn và dọn dẹp context menu hiện tại. @@ -198,7 +225,9 @@ export function initSelect( function showContextMenu( x: number, y: number, - clickedFeature: maplibregl.MapGeoJSONFeature + clickedFeature: maplibregl.MapGeoJSONFeature, + isRightClickedItemAlreadySelected: boolean, + hasSelection: boolean ) { hideContextMenu(); @@ -231,68 +260,106 @@ export function initSelect( return item; }; - const selectedCount = selectedIds.size || 1; - let hasMenuItems = false; + const selectedCount = selectedIds.size; + const effectiveCount = selectedCount || 1; + const targetId = clickedFeature.id ?? clickedFeature.properties?.id; + const isClickOutsideSelection = !isRightClickedItemAlreadySelected && hasSelection; - 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; + 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 (selectedCount === 1 && onDuplicate) { - const featureId = clickedFeature.id ?? clickedFeature.properties?.id; - if (featureId !== undefined && featureId !== null) { - menu.appendChild(createItem("Duplicate", () => onDuplicate(featureId))); - hasMenuItems = true; + if (!isClickOutsideSelection) { + if ( + effectiveCount === 1 && + clickedFeature.source === "countries" && + clickedFeature.geometry?.type === "Polygon" && + onEdit + ) { + const single = clickedFeature; + items.push({ + group: "edit", + label: "Chỉnh sửa", + onClick: () => onEdit(single), + }); } - } - 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 && targetId !== undefined && targetId !== null) { + items.push({ + group: "edit", + label: "Duplicate", + onClick: () => onDuplicate(targetId), + }); + } + + if (effectiveCount === 1 && onHide && targetId !== undefined && targetId !== null) { + items.push({ + group: "edit", + label: "Hide", + onClick: () => onHide(targetId), + }); } } if (onReplayEdit) { - const featureId = clickedFeature.id ?? clickedFeature.properties?.id; - if (featureId) { - menu.appendChild( - createItem( - selectedCount > 1 ? `Vào replay (${selectedCount} geo)` : "Vào replay", - () => onReplayEdit(featureId) - ) - ); - hasMenuItems = true; + 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) { - 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(); + 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); } - ) - ); - hasMenuItems = true; + clearSelection(); + }, + }); } - if (!hasMenuItems) return; + 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/styles/shared/pointStyle.ts b/src/uhm/lib/map/styles/shared/pointStyle.ts index a1b9e06..a34174f 100644 --- a/src/uhm/lib/map/styles/shared/pointStyle.ts +++ b/src/uhm/lib/map/styles/shared/pointStyle.ts @@ -45,7 +45,6 @@ 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 = 48; @@ -168,7 +167,7 @@ export function buildPointGeotypeLayers( source: pointSourceId, filter: pointFilter(typeId), layout: { - "icon-image": pointIconExpression(typeId), + "icon-image": getPointIconId(typeId), "icon-size": [ "interpolate", ["linear"], @@ -262,13 +261,11 @@ export function ensurePointGeotypeIcons(map: maplibregl.Map): boolean { } 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; @@ -278,19 +275,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),