From 8306543828f09cba7b7ea3cf9241f31269961208 Mon Sep 17 00:00:00 2001 From: taDuc Date: Tue, 26 May 2026 01:26:47 +0700 Subject: [PATCH] feat: enable feature selection for preview modes and add click interaction support to the selecting engine --- src/app/editor/[id]/page.tsx | 936 +++++++++++++++++- src/app/page.tsx | 10 +- src/uhm/components/Map.tsx | 91 +- .../editor/ReplayPreviewLayerPanel.tsx | 53 +- src/uhm/components/map/mapUtils.ts | 44 +- src/uhm/components/map/useMapInteraction.ts | 76 +- src/uhm/components/map/useMapLayers.ts | 60 +- src/uhm/components/map/useMapSync.ts | 40 +- src/uhm/components/wiki/PublicWikiSidebar.tsx | 269 ++++- src/uhm/doc/developer_guide.md | 1 + src/uhm/doc/editor_manual_test_checklist.md | 4 +- src/uhm/doc/editor_states.md | 23 +- src/uhm/lib/editor/session/sessionTypes.ts | 1 + src/uhm/lib/map/engines/selectingEngine.ts | 59 +- .../map/styles/geotypes/migration_route.ts | 1 + .../lib/map/styles/geotypes/military_route.ts | 1 + .../lib/map/styles/geotypes/retreat_route.ts | 1 + .../lib/map/styles/geotypes/trade_route.ts | 1 + .../lib/map/styles/shared/styleBuilders.ts | 3 +- src/uhm/lib/replay/useReplayPreview.ts | 14 +- 20 files changed, 1356 insertions(+), 332 deletions(-) diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index e3d6ae1..2c31148 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from "react"; import { useParams, useRouter } from "next/navigation"; import { useShallow } from "zustand/react/shallow"; -import Map, { type MapHandle } from "@/uhm/components/Map"; +import Map, { type MapFeaturePayload, type MapHandle } from "@/uhm/components/Map"; import Editor from "@/uhm/components/Editor"; import BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel"; import TimelineBar from "@/uhm/components/ui/TimelineBar"; @@ -49,8 +49,6 @@ import { loadBackgroundLayerVisibilityFromStorage, persistBackgroundLayerVisibility, } from "@/uhm/lib/editor/background/backgroundVisibilityStorage"; -import { BACKGROUND_LAYER_OPTIONS } from "@/uhm/lib/map/styles/backgroundLayers"; -import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap"; import { deepClone } from "@/uhm/lib/editor/draft/draftDiff"; import { useProjectCommands } from "@/uhm/lib/editor/project/useProjectCommands"; import { useReplayPreview } from "@/uhm/lib/replay/useReplayPreview"; @@ -89,9 +87,12 @@ const CURRENT_YEAR = new Date().getUTCFullYear(); const DEFAULT_EDITOR_USER_ID = "local-editor"; type ReplayPreviewSession = { - replay: BattleReplay; + replay: BattleReplay | null; + replays: BattleReplay[]; draft: FeatureCollection; + entities: Entity[]; wikis: WikiSnapshot[]; + entityWikiLinks: EntityWikiLinkSnapshot[]; selectedStageId: number | null; selectedStepIndex: number | null; timelineYear: number; @@ -99,6 +100,24 @@ type ReplayPreviewSession = { mapViewState: ReturnType; }; +type PreviewRelationIndex = { + entitiesById: Record; + entityGeometriesById: Record; + entityWikisById: Record; + geometryEntityIds: Record; + wikiEntityIdsById: Record; + wikiEntityIdsBySlug: Record; + wikiById: Record; + wikiBySlug: Record; +}; + +type PreviewLinkEntityPopupState = { + slug: string; + entities: Entity[]; + top: number; + left: number; +}; + export default function Page() { return ( (null); // Ref bridge sang Map imperative API (getMap/getViewState) cho replay preview. const mapHandleRef = useRef(null); + const editorOriginalMapViewStateRef = useRef | null>(null); // State chính của editor nằm trong zustand store để các panel con đọc cùng source-of-truth. const { mode, @@ -385,12 +405,31 @@ function EditorPageContent() { const [previewWikiError, setPreviewWikiError] = useState(null); // State loading riêng cho wiki preview sidebar. const [isPreviewWikiLoading, setIsPreviewWikiLoading] = useState(false); + const [previewFeaturePopupAnchor, setPreviewFeaturePopupAnchor] = useState(null); + const [previewExpandedEntityId, setPreviewExpandedEntityId] = useState(null); + const [previewActiveEntityId, setPreviewActiveEntityId] = useState(null); + const [isPreviewEntitySidebarOpen, setIsPreviewEntitySidebarOpen] = useState(false); + const [previewLinkEntityPopup, setPreviewLinkEntityPopup] = useState(null); + const [previewEntityFocusToken, setPreviewEntityFocusToken] = useState(0); + const [previewSidebarWidth, setPreviewSidebarWidth] = useState(() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem("public-wiki-sidebar-width"); + if (saved) { + const parsed = parseInt(saved, 10); + if (!Number.isNaN(parsed) && parsed >= 320 && parsed <= 800) { + return parsed; + } + } + } + return 420; + }); // State ảnh overlay local-only để vẽ trace theo ảnh mẫu. const [imageOverlay, setImageOverlay] = useState(null); // Bật/tắt điều khiển ảnh overlay bằng phím mũi tên và W/S. const [imageOverlayKeyboardEnabled, setImageOverlayKeyboardEnabled] = useState(false); // Ref giữ object URL hiện tại để revoke khi đổi/xóa ảnh, tránh leak bộ nhớ. const imageOverlayObjectUrlRef = useRef(null); + const previewLinkEntityPopupRef = useRef(null); // Cập nhật stage/step được chọn trong sidebar replay. const handleReplaySelectionChange = useCallback((stageId: number | null, stepIndex: number | null) => { setReplaySelection({ stageId, stepIndex }); @@ -399,8 +438,30 @@ function EditorPageContent() { const getCurrentMapInstance = useCallback(() => mapHandleRef.current?.getMap() ?? null, []); // Helper đọc camera/view hiện tại để lưu vào replay preview. const getCurrentMapViewState = useCallback(() => mapHandleRef.current?.getViewState() ?? null, []); + const restoreEditorOriginalMapState = useCallback(() => { + const map = getCurrentMapInstance(); + const savedViewState = editorOriginalMapViewStateRef.current; + if (map && savedViewState) { + mapHandleRef.current?.setGlobeProjection(savedViewState.projection === "globe"); + map.easeTo({ + center: savedViewState.center, + zoom: savedViewState.zoom, + pitch: savedViewState.pitch, + bearing: savedViewState.bearing, + duration: 650, + }); + } + editorOriginalMapViewStateRef.current = null; + }, [getCurrentMapInstance]); const isReplayEditMode = mode === "replay"; + const isViewerPreviewMode = mode === "preview"; const isReplayPreviewMode = mode === "replay_preview"; + const isAnyPreviewMode = isViewerPreviewMode || isReplayPreviewMode; + const previewReturnModeRef = useRef("select"); + const replayPreviewReturnRef = useRef<{ + mode: "replay" | "preview"; + session: ReplayPreviewSession | null; + }>({ mode: "replay", session: null }); // Ref mirror entity list cho debounce search không phụ thuộc closure cũ. const entitiesRef = useRef(entities); useEffect(() => { @@ -494,6 +555,28 @@ function EditorPageContent() { setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear))); }, [setTimelineDraftYear]); + const handleViewerPreviewTimelineYearChange = useCallback((nextYear: number) => { + setPreviewSession((prev) => + prev + ? { + ...prev, + timelineYear: clampYearToFixedRange(Math.trunc(nextYear)), + } + : prev + ); + }, []); + + const handleViewerPreviewTimelineFilterChange = useCallback((enabled: boolean) => { + setPreviewSession((prev) => + prev + ? { + ...prev, + timelineFilterEnabled: enabled, + } + : prev + ); + }, []); + // Hook điều phối phát replay preview và các side effect lên map/UI. const replayPreview = useReplayPreview({ replay: previewSession?.replay || null, @@ -505,6 +588,9 @@ function EditorPageContent() { selectedStageId: previewSession?.selectedStageId ?? replaySelection.stageId, selectedStepIndex: previewSession?.selectedStepIndex ?? replaySelection.stepIndex, onSelectStep: () => { }, + setMapProjection: useCallback((type: "globe" | "mercator") => { + mapHandleRef.current?.setGlobeProjection(type === "globe"); + }, []), }); const { hiddenGeometryIds: replayPreviewHiddenGeometryIds, @@ -517,6 +603,7 @@ function EditorPageContent() { activeWikiId: replayPreviewActiveWikiId, sidebarOpen: replayPreviewSidebarOpen, openWikiPanelById: openReplayPreviewWikiPanelById, + closeWikiPanel: closeReplayPreviewWikiPanel, } = replayPreview; // Draft hiển thị trong preview có thể ẩn bớt geometry theo action replay. @@ -536,19 +623,25 @@ function EditorPageContent() { const activeTimelineYear = isReplayPreviewMode ? replayPreviewTimelineYear - : timelineDraftYear; + : isViewerPreviewMode + ? previewSession?.timelineYear ?? timelineDraftYear + : timelineDraftYear; const activeTimelineFilterEnabled = isReplayPreviewMode ? replayPreviewTimelineFilterEnabled - : timelineFilterEnabled; + : isViewerPreviewMode + ? previewSession?.timelineFilterEnabled ?? timelineFilterEnabled + : timelineFilterEnabled; // Render draft is the only FeatureCollection that decides what appears on the map. // It may be timeline-filtered, replay-filtered, or preview-filtered, but it is not the edit source. const mapRenderDraft = useMemo(() => { const activeDraft = isReplayPreviewMode ? replayPreviewDraft - : isReplayEditMode - ? editor.replayDraft - : editor.mainDraft; + : isViewerPreviewMode + ? previewSession?.draft || editor.mainDraft + : isReplayEditMode + ? editor.replayDraft + : editor.mainDraft; if (!activeTimelineFilterEnabled) return activeDraft; const year = clampYearToFixedRange(Math.trunc(activeTimelineYear)); @@ -562,6 +655,8 @@ function EditorPageContent() { editor, isReplayEditMode, isReplayPreviewMode, + isViewerPreviewMode, + previewSession?.draft, replayPreviewDraft, ]); @@ -726,22 +821,91 @@ function EditorPageContent() { restoreCommit, } = sectionCommands; - // Thoát preview và quay về replay edit mode. + const clearPreviewViewerState = useCallback(() => { + setPreviewActiveEntityId(null); + setIsPreviewEntitySidebarOpen(false); + setPreviewFeaturePopupAnchor(null); + setPreviewLinkEntityPopup(null); + setPreviewWikiError(null); + closeReplayPreviewWikiPanel(); + setSelectedFeatureIds([]); + }, [closeReplayPreviewWikiPanel, setSelectedFeatureIds]); + + const openViewerPreview = useCallback(() => { + if (mode === "preview" || mode === "replay_preview" || mode === "replay") return; + previewReturnModeRef.current = mode === "idle" ? "select" : mode; + editorOriginalMapViewStateRef.current = getCurrentMapViewState(); + setPreviewSession({ + replay: null, + replays: deepClone(editor.effectiveReplays), + draft: deepClone(editor.mainDraft), + entities: deepClone(entities), + wikis: deepClone(snapshotWikis), + entityWikiLinks: deepClone(snapshotEntityWikiLinks), + selectedStageId: null, + selectedStepIndex: null, + timelineYear: timelineDraftYear, + timelineFilterEnabled, + mapViewState: getCurrentMapViewState(), + }); + setPreviewAutoplayMode(null); + clearPreviewViewerState(); + internalSetMode("preview"); + }, [ + clearPreviewViewerState, + editor.effectiveReplays, + editor.mainDraft, + entities, + getCurrentMapViewState, + internalSetMode, + mode, + snapshotEntityWikiLinks, + snapshotWikis, + timelineDraftYear, + timelineFilterEnabled, + ]); + + const exitViewerPreview = useCallback(() => { + restoreEditorOriginalMapState(); + setPreviewAutoplayMode(null); + setPreviewSession(null); + clearPreviewViewerState(); + internalSetMode(previewReturnModeRef.current || "select"); + }, [clearPreviewViewerState, internalSetMode, restoreEditorOriginalMapState]); + + // Thoát replay preview. Nếu replay được mở từ preview thường thì quay lại preview thường. const exitReplayPreview = useCallback(() => { resetReplayPreview(); setPreviewAutoplayMode(null); + const returnState = replayPreviewReturnRef.current; + replayPreviewReturnRef.current = { mode: "replay", session: null }; + + if (returnState.mode === "preview" && returnState.session) { + setPreviewSession(deepClone(returnState.session)); + clearPreviewViewerState(); + internalSetMode("preview"); + return; + } + + restoreEditorOriginalMapState(); setPreviewSession(null); + clearPreviewViewerState(); internalSetMode("replay"); - }, [internalSetMode, resetReplayPreview]); + }, [clearPreviewViewerState, internalSetMode, resetReplayPreview, restoreEditorOriginalMapState]); // Đóng băng draft/replay hiện tại thành session preview để phát thử. const openReplayPreview = useCallback((autoplayMode: "start" | "selection") => { if (!editor.activeReplayDraft) return; + replayPreviewReturnRef.current = { mode: "replay", session: null }; + editorOriginalMapViewStateRef.current = getCurrentMapViewState(); setPreviewSession({ replay: deepClone(editor.activeReplayDraft), + replays: deepClone(editor.effectiveReplays), draft: deepClone(editor.replayDraft), + entities: deepClone(entities), wikis: deepClone(snapshotWikis), + entityWikiLinks: deepClone(snapshotEntityWikiLinks), selectedStageId: replaySelection.stageId, selectedStepIndex: replaySelection.stepIndex, timelineYear: timelineDraftYear, @@ -753,20 +917,75 @@ function EditorPageContent() { internalSetMode("replay_preview"); }, [ editor.activeReplayDraft, + editor.effectiveReplays, editor.replayDraft, + entities, getCurrentMapViewState, internalSetMode, replaySelection.stageId, replaySelection.stepIndex, setSelectedFeatureIds, + snapshotEntityWikiLinks, snapshotWikis, timelineDraftYear, timelineFilterEnabled, ]); - // State machine chuyển mode editor, xử lý riêng replay/replay_preview để không mất draft. + const viewerPreviewSelectedReplay = useMemo(() => { + if (!isViewerPreviewMode || !selectedFeatureIds.length) return null; + const selectedGeometryId = String(selectedFeatureIds[0] ?? "").trim(); + if (!selectedGeometryId.length) return null; + return (previewSession?.replays || []).find( + (replay) => + String(replay?.geometry_id || "").trim() === selectedGeometryId && + hasPlayableReplaySteps(replay) + ) || null; + }, [isViewerPreviewMode, previewSession?.replays, selectedFeatureIds]); + + const openSelectedViewerReplayPreview = useCallback(() => { + if (!isViewerPreviewMode || !previewSession || !viewerPreviewSelectedReplay) return; + + const returnSession = deepClone(previewSession); + const selectedReplay = deepClone(viewerPreviewSelectedReplay); + replayPreviewReturnRef.current = { + mode: "preview", + session: returnSession, + }; + setPreviewSession({ + ...returnSession, + replay: selectedReplay, + draft: buildReplayPreviewDraftFromSource(returnSession.draft, selectedReplay), + selectedStageId: null, + selectedStepIndex: null, + timelineYear: activeTimelineYear, + timelineFilterEnabled: activeTimelineFilterEnabled, + mapViewState: getCurrentMapViewState(), + }); + setPreviewAutoplayMode("start"); + clearPreviewViewerState(); + internalSetMode("replay_preview"); + }, [ + activeTimelineFilterEnabled, + activeTimelineYear, + clearPreviewViewerState, + getCurrentMapViewState, + internalSetMode, + isViewerPreviewMode, + previewSession, + viewerPreviewSelectedReplay, + ]); + + // State machine chuyển mode editor, xử lý riêng preview/replay để không mất draft. const setMode = useCallback((m: EditorMode, featureId?: string | number) => { - if (m === "replay_preview") { + if (m === "preview" || m === "replay_preview") { + return; + } + + if (mode === "preview") { + setPreviewAutoplayMode(null); + setPreviewSession(null); + clearPreviewViewerState(); + internalSetMode(m); return; } @@ -774,6 +993,7 @@ function EditorPageContent() { resetReplayPreview(); setPreviewAutoplayMode(null); setPreviewSession(null); + clearPreviewViewerState(); if (m === "replay") { internalSetMode("replay"); @@ -809,10 +1029,12 @@ function EditorPageContent() { } internalSetMode(m); }, [ + clearPreviewViewerState, editor, internalSetMode, mode, resetReplayPreview, + restoreEditorOriginalMapState, selectedFeatureIds, setHideOutside, setReplayFeatureId, @@ -894,6 +1116,38 @@ function EditorPageContent() { () => previewSession?.wikis || [], [previewSession?.wikis] ); + const previewRelations = useMemo( + () => buildPreviewRelationIndex({ + draft: previewSession?.draft || EMPTY_FEATURE_COLLECTION, + entities: previewSession?.entities || [], + wikis: replayPreviewWikiRows, + entityWikiLinks: previewSession?.entityWikiLinks || [], + wikiCache: previewWikiCache, + projectId, + }), + [ + previewSession?.draft, + previewSession?.entities, + previewSession?.entityWikiLinks, + previewWikiCache, + projectId, + replayPreviewWikiRows, + ] + ); + const previewFeaturePopupEntityIds = useMemo(() => { + if (!previewFeaturePopupAnchor) return []; + return previewRelations.geometryEntityIds[String(previewFeaturePopupAnchor.featureId)] || []; + }, [previewFeaturePopupAnchor, previewRelations.geometryEntityIds]); + const previewFeaturePopupEntities = useMemo( + () => previewFeaturePopupEntityIds + .map((entityId) => previewRelations.entitiesById[entityId] || null) + .filter((entity): entity is Entity => Boolean(entity)), + [previewFeaturePopupEntityIds, previewRelations.entitiesById] + ); + + useEffect(() => { + setPreviewExpandedEntityId(null); + }, [previewFeaturePopupAnchor]); // Wiki snapshot đang được step preview yêu cầu mở. const replayPreviewActiveWikiSnapshot = useMemo(() => { if (!replayPreviewActiveWikiId) return null; @@ -901,7 +1155,7 @@ function EditorPageContent() { }, [replayPreviewActiveWikiId, replayPreviewWikiRows]); useEffect(() => { - if (!isReplayPreviewMode || !replayPreviewSidebarOpen) { + if (!isAnyPreviewMode || !replayPreviewSidebarOpen) { setPreviewWikiError(null); setIsPreviewWikiLoading(false); return; @@ -955,7 +1209,7 @@ function EditorPageContent() { disposed = true; }; }, [ - isReplayPreviewMode, + isAnyPreviewMode, previewWikiCache, replayPreviewActiveWikiId, replayPreviewSidebarOpen, @@ -978,8 +1232,110 @@ function EditorPageContent() { return previewWikiCache[snapshotWiki.id] || null; }, [previewWikiCache, projectId, replayPreviewActiveWikiSnapshot]); + const replayPreviewActiveEntityId = useMemo(() => { + const activeWikiEntityIds = replayPreviewActiveWikiId + ? previewRelations.wikiEntityIdsById[String(replayPreviewActiveWikiId)] || [] + : []; + + if ( + previewActiveEntityId && + (!activeWikiEntityIds.length || activeWikiEntityIds.includes(previewActiveEntityId)) + ) { + return previewActiveEntityId; + } + + return activeWikiEntityIds[0] || previewActiveEntityId; + }, [previewActiveEntityId, previewRelations.wikiEntityIdsById, replayPreviewActiveWikiId]); + + const replayPreviewActiveEntity = replayPreviewActiveEntityId + ? previewRelations.entitiesById[replayPreviewActiveEntityId] || null + : null; + const replayPreviewActiveEntityGeometries = replayPreviewActiveEntityId + ? previewRelations.entityGeometriesById[replayPreviewActiveEntityId] || EMPTY_FEATURE_COLLECTION + : EMPTY_FEATURE_COLLECTION; + const isReplayPreviewWikiSidebarOpen = isAnyPreviewMode && (replayPreviewSidebarOpen || isPreviewEntitySidebarOpen); + + const closeReplayPreviewSidebar = useCallback(() => { + closeReplayPreviewWikiPanel(); + setPreviewActiveEntityId(null); + setIsPreviewEntitySidebarOpen(false); + setPreviewWikiError(null); + setPreviewLinkEntityPopup(null); + setSelectedFeatureIds([]); + }, [closeReplayPreviewWikiPanel, setSelectedFeatureIds]); + + const selectReplayPreviewEntity = useCallback(( + entityId: string, + options?: { + sourceFeatureId?: string | number | null; + preferredWikiId?: string | null; + preferredWikiSlug?: string | null; + focusMap?: boolean; + selectGeometry?: boolean; + } + ) => { + const id = String(entityId || "").trim(); + const entity = previewRelations.entitiesById[id] || null; + if (!entity) return; + + const linkedWikis = previewRelations.entityWikisById[id] || []; + const preferredWikiId = String(options?.preferredWikiId || "").trim(); + const preferredWikiSlug = String(options?.preferredWikiSlug || "").trim(); + const nextWiki = + linkedWikis.find((wiki) => preferredWikiId && wiki.id === preferredWikiId) || + linkedWikis.find((wiki) => preferredWikiSlug && String(wiki.slug || "").trim() === preferredWikiSlug) || + linkedWikis[0] || + null; + + setPreviewActiveEntityId(id); + setIsPreviewEntitySidebarOpen(true); + setPreviewWikiError(null); + setPreviewLinkEntityPopup(null); + + if (options?.focusMap !== false) { + setPreviewEntityFocusToken((prev) => prev + 1); + } + if (options?.selectGeometry && options.sourceFeatureId != null) { + setSelectedFeatureIds([options.sourceFeatureId]); + } + if (nextWiki) { + openReplayPreviewWikiPanelById(nextWiki.id); + } + }, [ + openReplayPreviewWikiPanelById, + previewRelations.entitiesById, + previewRelations.entityWikisById, + setSelectedFeatureIds, + ]); + + const handlePreviewMapFeatureClick = useCallback((payload: MapFeaturePayload | null) => { + if (!isAnyPreviewMode) return; + setPreviewFeaturePopupAnchor(payload); + setPreviewLinkEntityPopup(null); + }, [isAnyPreviewMode]); + + useEffect(() => { + if (!previewLinkEntityPopup) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setPreviewLinkEntityPopup(null); + }; + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as Node | null; + if (target && previewLinkEntityPopupRef.current?.contains(target)) return; + setPreviewLinkEntityPopup(null); + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("pointerdown", handlePointerDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("pointerdown", handlePointerDown); + }; + }, [previewLinkEntityPopup]); + // Điều hướng link wiki nội bộ trong preview nhưng chỉ trong phạm vi snapshot preview. - const handleReplayPreviewWikiLinkRequest = useCallback(({ slug }: { slug: string; rect: DOMRect }) => { + const handleReplayPreviewWikiLinkRequest = useCallback(({ slug, rect }: { slug: string; rect: DOMRect }) => { const nextSlug = String(slug || "").trim(); if (!nextSlug.length) return; const match = replayPreviewWikiRows.find((item) => String(item.slug || "").trim() === nextSlug) || null; @@ -987,9 +1343,42 @@ function EditorPageContent() { setPreviewWikiError(`Wiki /wiki/${nextSlug} không có trong snapshot preview.`); return; } + + const linkedEntityIds = previewRelations.wikiEntityIdsBySlug[nextSlug] || []; + const linkedEntities = linkedEntityIds + .map((entityId) => previewRelations.entitiesById[entityId] || null) + .filter((entity): entity is Entity => Boolean(entity)); + + if (linkedEntities.length === 1) { + selectReplayPreviewEntity(linkedEntities[0].id, { + preferredWikiId: match.id, + preferredWikiSlug: nextSlug, + }); + return; + } + + if (linkedEntities.length > 1) { + const popupWidth = 240; + const popupHeight = Math.min(240, linkedEntities.length * 44 + 20); + const { top, left } = computeFixedPopupPosition(rect, popupWidth, popupHeight); + setPreviewLinkEntityPopup({ + slug: nextSlug, + entities: linkedEntities, + top, + left, + }); + return; + } + setPreviewWikiError(null); openReplayPreviewWikiPanelById(match.id); - }, [openReplayPreviewWikiPanelById, replayPreviewWikiRows]); + }, [ + openReplayPreviewWikiPanelById, + previewRelations.entitiesById, + previewRelations.wikiEntityIdsBySlug, + replayPreviewWikiRows, + selectReplayPreviewEntity, + ]); // Visibility cuối cùng theo type/layer, có override riêng cho replay edit/preview. const effectiveGeometryVisibility = useMemo(() => { @@ -2009,15 +2398,17 @@ function EditorPageContent() { }; // Base draft for label lookup only. It must not decide which geometry is rendered. - const labelContextBaseDraft = isReplayPreviewMode + const labelContextBaseDraft = isAnyPreviewMode ? 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(labelContextBaseDraft, entities), - [entities, labelContextBaseDraft] - ); + const mapLabelContextDraft = useMemo(() => { + const entitiesForLabel = isAnyPreviewMode + ? previewSession?.entities || [] + : entities; + return buildEntityLabelContextDraft(labelContextBaseDraft, entitiesForLabel); + }, [entities, isAnyPreviewMode, labelContextBaseDraft, previewSession?.entities]); if (blockedPendingSubmissionId) { return ( @@ -2142,7 +2533,7 @@ function EditorPageContent() { return (
- {!isReplayEditMode && !isReplayPreviewMode ? ( + {!isReplayEditMode && !isAnyPreviewMode ? ( <> ) : (
@@ -2247,7 +2667,7 @@ function EditorPageContent() { isPlaying={replayPreview.isPlaying} dialog={replayPreview.dialog} toasts={replayPreview.toasts} - sidebarOpen={replayPreview.sidebarOpen} + sidebarOpen={isReplayPreviewWikiSidebarOpen} playbackSpeed={replayPreview.playbackSpeed} activeStepLabel={replayPreviewActiveStepLabel} activeStepNumber={replayPreview.activeStepNumber} @@ -2258,32 +2678,31 @@ function EditorPageContent() { onExitPreview={exitReplayPreview} /> ) : null} - {isReplayPreviewMode && replayPreview.sidebarOpen ? ( + {isAnyPreviewMode && isReplayPreviewWikiSidebarOpen ? ( ) : null} - {isReplayPreviewMode ? ( + {isAnyPreviewMode ? ( ) : null} + {isAnyPreviewMode && previewFeaturePopupAnchor && previewFeaturePopupEntities.length > 0 ? ( +
+
+ {(() => { + // 1. Expanded entity (nested wiki selection) + if (previewExpandedEntityId) { + const entity = previewRelations.entitiesById[previewExpandedEntityId]; + if (!entity) return null; + const wikis = previewRelations.entityWikisById[previewExpandedEntityId] || []; + return ( +
+
+ + + {entity.name} + +
+
Chọn Wiki bài viết:
+
+ {wikis.map((wiki) => ( + + ))} +
+
+ ); + } + + // 2. Case 1: Exactly 1 entity bound to geometry + if (previewFeaturePopupEntities.length === 1) { + const singleEntity = previewFeaturePopupEntities[0]; + const entityWikis = previewRelations.entityWikisById[singleEntity.id] || []; + if (entityWikis.length === 1) { + const singleWiki = entityWikis[0]; + const blockquoteMatch = singleWiki.content + ? singleWiki.content.match(/]*>([\s\S]*?)<\/blockquote>/) + : null; + let previewSummary = blockquoteMatch ? blockquoteMatch[1].trim() : ""; + + if (!previewSummary) { + const pMatch = singleWiki.content + ? singleWiki.content.match(/]*>([\s\S]*?)<\/p>/) + : null; + previewSummary = pMatch ? pMatch[1].trim() : ""; + } + + if (!previewSummary) { + previewSummary = singleEntity.description || "Không có mô tả hay tóm tắt."; + } + const cleanSummaryText = previewSummary + .replace(/<[^>]*>/g, "") + .replace(/ /gi, " ") + .replace(/\u00a0/g, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/'/g, "'") + .trim(); + + return ( +
+
+
+ {singleEntity.name} +
+
+
+ {cleanSummaryText} +
+ +
+ ); + } else if (entityWikis.length > 1) { + return ( +
+
+
+ Entity +
+
+ {singleEntity.name} +
+
+ Thực thể này có nhiều Wiki liên kết. Chọn để đọc: +
+
+
+ {entityWikis.map((wiki) => ( + + ))} +
+
+ ); + } + } + + // 3. Case 2: Multiple entities bound to geometry + return ( +
+
+
Related Entities
+
+ Geometry #{String(previewFeaturePopupAnchor.featureId)} +
+
+
+
+ {previewFeaturePopupEntities.map((entity) => { + const entityWikis = previewRelations.entityWikisById[entity.id] || []; + return ( + + ); + })} +
+
+
+ ); + })()} +
+
+ ) : null} {!isReplayPreviewMode || replayPreview.timelineVisible ? ( ) : null} + {isAnyPreviewMode && previewLinkEntityPopup ? ( +
+
+
+ Related Entities +
+
+ /wiki/{previewLinkEntityPopup.slug} +
+
+
+
+ {previewLinkEntityPopup.entities.map((entity) => ( + + ))} +
+
+
+ ) : null}
- {!isReplayEditMode && !isReplayPreviewMode ? ( + {!isReplayEditMode && !isAnyPreviewMode ? ( <> Array.isArray(stage?.steps) && stage.steps.length > 0) + ); +} + +function buildReplayPreviewDraftFromSource(sourceDraft: FeatureCollection, replay: BattleReplay): FeatureCollection { + const targetIds = normalizeReplayPreviewTargetGeometryIds(replay); + return { + type: "FeatureCollection", + features: targetIds + .map((id) => + sourceDraft.features.find((feature) => String(feature.properties.id) === id) || null + ) + .filter((feature): feature is Feature => Boolean(feature)) + .map((feature) => ({ + ...deepClone(feature), + properties: { + ...deepClone(feature.properties), + bound_with: null, + }, + })), + }; +} + +function normalizeReplayPreviewTargetGeometryIds(replay: BattleReplay) { + const orderedIds: string[] = []; + const seen = new Set(); + const pushId = (rawId: string | number | null | undefined) => { + if (rawId == null) return; + const id = String(rawId).trim(); + if (!id.length || seen.has(id)) return; + seen.add(id); + orderedIds.push(id); + }; + + pushId(replay.geometry_id); + for (const rawId of replay.target_geometry_ids || []) pushId(rawId); + return orderedIds; +} + function readImageAspectRatio(url: string): Promise { return new Promise((resolve, reject) => { const image = new Image(); @@ -2464,7 +3191,130 @@ function readImageAspectRatio(url: string): Promise { }); } -// ReplayPreviewLayerPanel is imported from "@/uhm/components/editor/ReplayPreviewLayerPanel" +function buildPreviewRelationIndex(options: { + draft: FeatureCollection; + entities: Entity[]; + wikis: WikiSnapshot[]; + entityWikiLinks: EntityWikiLinkSnapshot[]; + wikiCache: Record; + projectId: string; +}): PreviewRelationIndex { + const next: PreviewRelationIndex = { + entitiesById: {}, + entityGeometriesById: {}, + entityWikisById: {}, + geometryEntityIds: {}, + wikiEntityIdsById: {}, + wikiEntityIdsBySlug: {}, + wikiById: {}, + wikiBySlug: {}, + }; + + for (const entity of options.entities || []) { + const id = String(entity?.id || "").trim(); + if (!id) continue; + next.entitiesById[id] = entity; + } + + for (const wikiSnapshot of options.wikis || []) { + if (!wikiSnapshot || wikiSnapshot.operation === "delete") continue; + const wiki = snapshotWikiToWiki(wikiSnapshot, options.wikiCache, options.projectId); + if (!wiki?.id) continue; + next.wikiById[wiki.id] = wiki; + const slug = String(wiki.slug || "").trim(); + if (slug) next.wikiBySlug[slug] = wiki; + } + + for (const feature of options.draft.features || []) { + const geometryId = String(feature.properties.id); + for (const entityId of normalizeFeatureEntityIds(feature)) { + if (!next.entitiesById[entityId]) { + next.entitiesById[entityId] = { id: entityId, name: entityId }; + } + pushUniqueString(next.geometryEntityIds, geometryId, entityId); + if (!next.entityGeometriesById[entityId]) { + next.entityGeometriesById[entityId] = { type: "FeatureCollection", features: [] }; + } + if (!next.entityGeometriesById[entityId].features.some((item) => String(item.properties.id) === geometryId)) { + next.entityGeometriesById[entityId].features.push(feature); + } + } + } + + for (const link of options.entityWikiLinks || []) { + if (!link || link.operation === "delete") continue; + const entityId = String(link.entity_id || "").trim(); + const wikiId = String(link.wiki_id || "").trim(); + const entity = next.entitiesById[entityId] || null; + const wiki = next.wikiById[wikiId] || null; + if (!entity || !wiki) continue; + + if (!next.entityWikisById[entityId]) next.entityWikisById[entityId] = []; + if (!next.entityWikisById[entityId].some((item) => item.id === wiki.id)) { + next.entityWikisById[entityId].push(wiki); + } + + pushUniqueString(next.wikiEntityIdsById, wiki.id, entityId); + const slug = String(wiki.slug || "").trim(); + if (slug) pushUniqueString(next.wikiEntityIdsBySlug, slug, entityId); + } + + normalizeRelationArrays(next.geometryEntityIds); + normalizeRelationArrays(next.wikiEntityIdsById); + normalizeRelationArrays(next.wikiEntityIdsBySlug); + return next; +} + +function snapshotWikiToWiki(snapshot: WikiSnapshot, wikiCache: Record, projectId: string): Wiki { + if (typeof snapshot.doc === "string") { + return { + id: snapshot.id, + project_id: projectId, + title: snapshot.title, + slug: snapshot.slug ?? null, + content: snapshot.doc || "", + }; + } + + return wikiCache[snapshot.id] || { + id: snapshot.id, + project_id: projectId, + title: snapshot.title, + slug: snapshot.slug ?? null, + content: "", + }; +} + +function pushUniqueString(target: Record, key: string, value: string) { + if (!target[key]) { + target[key] = [value]; + return; + } + if (!target[key].includes(value)) { + target[key].push(value); + } +} + +function normalizeRelationArrays(target: Record) { + for (const key of Object.keys(target)) { + target[key] = Array.from(new Set(target[key])); + } +} + +function computeFixedPopupPosition(rect: DOMRect, width: number, height: number) { + const margin = 12; + const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1440; + const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 900; + const preferredLeft = rect.right + margin; + const maxLeft = Math.max(margin, viewportWidth - width - margin); + const left = Math.min(preferredLeft, maxLeft); + + const preferredTop = rect.top; + const maxTop = Math.max(margin, viewportHeight - height - margin); + const top = Math.max(margin, Math.min(preferredTop, maxTop)); + + return { top, left }; +} function isTypingTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; diff --git a/src/app/page.tsx b/src/app/page.tsx index 76b3808..0acf540 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import Map, { type MapHoverPayload } from "@/uhm/components/Map"; +import Map, { type MapFeaturePayload } from "@/uhm/components/Map"; import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar"; import TimelineBar from "@/uhm/components/ui/TimelineBar"; import mapLayersStyles from "@/styles/MapLayers.module.css"; @@ -82,7 +82,7 @@ export default function Page() { completed: 0, total: 0, }); - const [hoverAnchor, setHoverAnchor] = useState(null); + const [hoverAnchor, setHoverAnchor] = useState(null); const [isMapLayersCollapsed, setIsMapLayersCollapsed] = useState(false); const [activeEntityId, setActiveEntityId] = useState(null); const [activeWikiSlug, setActiveWikiSlug] = useState(null); @@ -369,7 +369,7 @@ export default function Page() { }); }, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureIds]); - const handleMapHoverChange = useCallback((payload: MapHoverPayload | null) => { + const handleMapHoverChange = useCallback((payload: MapFeaturePayload | null) => { clearHoverHideTimer(); if (payload) { @@ -529,8 +529,8 @@ export default function Page() { geometryVisibility={geometryVisibility} allowGeometryEditing={false} applyGeometryBindingFilter={true} - onHoverFeatureChange={handleMapHoverChange} - highlightFeatures={activeEntityGeometries} + onFeatureClick={handleMapHoverChange} + focusFeatureCollection={activeEntityGeometries} focusRequestKey={entityFocusToken} focusPadding={activeEntityId && isLargeScreen ? { top: 84, right: sidebarWidth + 80, bottom: 116, left: 84 } : { top: 84, right: 84, bottom: 116, left: 84 }} diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index 6ecb404..ece4182 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -13,7 +13,7 @@ import { useMapInteraction } from "./map/useMapInteraction"; import { useMapSync } from "./map/useMapSync"; import { bindImageOverlayInteractions, type MapImageOverlay } from "./map/imageOverlay"; -export type MapHoverPayload = { +export type MapFeaturePayload = { featureId: string | number; feature: Feature | null; point: { x: number; y: number }; @@ -29,6 +29,7 @@ export type MapHandle = { projection: string; } | null; getMap: () => import("maplibre-gl").Map | null; + setGlobeProjection: (isGlobe: boolean) => void; }; type MapProps = { @@ -53,8 +54,7 @@ type MapProps = { height?: CSSProperties["height"]; fitToDraftBounds?: boolean; fitBoundsKey?: string | number | null; - onHoverFeatureChange?: ((payload: MapHoverPayload | null) => void) | undefined; - highlightFeatures?: FeatureCollection | null; + onFeatureClick?: ((payload: MapFeaturePayload | null) => void) | undefined; focusFeatureCollection?: FeatureCollection | null; focusRequestKey?: string | number | null; focusPadding?: number | import("maplibre-gl").PaddingOptions; @@ -62,6 +62,10 @@ type MapProps = { onImageOverlayChange?: (overlay: MapImageOverlay) => void; onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void; showViewportControls?: boolean; + isPreviewMode?: boolean; + onEnterPreview?: () => void; + onExitPreview?: () => void; + onPlayPreviewReplay?: () => void; }; const Map = forwardRef(function Map({ @@ -83,8 +87,7 @@ const Map = forwardRef(function Map({ height = "100vh", fitToDraftBounds = false, fitBoundsKey = null, - onHoverFeatureChange, - highlightFeatures = null, + onFeatureClick, focusFeatureCollection = null, focusRequestKey = null, focusPadding, @@ -92,6 +95,10 @@ const Map = forwardRef(function Map({ onImageOverlayChange, onBindGeometries, showViewportControls = true, + isPreviewMode = false, + onEnterPreview, + onExitPreview, + onPlayPreviewReplay, }, ref) { // Ref giữ mode mới nhất cho MapLibre handlers được register một lần. const modeRef = useRef(mode); @@ -101,8 +108,8 @@ const Map = forwardRef(function Map({ const onSelectFeatureIdsRef = useRef(onSelectFeatureIds); // Ref callback đổi mode mới nhất, dùng khi map interaction chuyển sang replay/select. const onSetModeRef = useRef(onSetMode); - // Ref callback hover mới nhất cho tooltip/panel ngoài map. - const onHoverFeatureChangeRef = useRef(onHoverFeatureChange); + // Ref callback click feature mới nhất cho tooltip/panel ngoài map. + const onFeatureClickRef = useRef(onFeatureClick); // Ref callback create mới nhất khi drawing engine tạo feature. const onCreateRef = useRef(onCreateFeature); // Ref callback delete mới nhất khi editing engine xóa feature. @@ -122,7 +129,7 @@ const Map = forwardRef(function Map({ useEffect(() => { renderDraftRef.current = renderDraft; }, [renderDraft]); useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]); useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]); - useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]); + useEffect(() => { onFeatureClickRef.current = onFeatureClick; }, [onFeatureClick]); useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]); useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]); useEffect(() => { onHideRef.current = onHideFeature; }, [onHideFeature]); @@ -153,7 +160,10 @@ const Map = forwardRef(function Map({ useImperativeHandle(ref, () => ({ getViewState, getMap: () => mapRef.current, - }), [getViewState, mapRef]); + setGlobeProjection: (isGlobe: boolean) => { + setIsGlobeProjection(isGlobe); + }, + }), [getViewState, mapRef, setIsGlobeProjection]); // Hook gắn/dọn các interaction vẽ, chọn, sửa geometry. const { @@ -173,14 +183,13 @@ const Map = forwardRef(function Map({ onDeleteRef, onHideRef, onUpdateRef, - onHoverFeatureChangeRef, + onFeatureClickRef, onBindGeometriesRef, }); // Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer. const { applyRenderDraftToMap, - applyHighlightToMap, applyImageOverlayToMap, tryCenterToUserLocation, } = useMapSync({ @@ -194,7 +203,6 @@ const Map = forwardRef(function Map({ applyGeometryBindingFilter, fitToDraftBounds, fitBoundsKey, - highlightFeatures, focusFeatureCollection, focusRequestKey, focusPadding, @@ -208,7 +216,7 @@ const Map = forwardRef(function Map({ const map = mapRef.current; if (!map || !isMapLoaded) return; - setupMapLayers(map, backgroundVisibility, highlightFeatures, applyHighlightToMap); + setupMapLayers(map, backgroundVisibility); applyImageOverlayToMap(); setupMapInteractions(map); applyRenderDraftToMap(renderDraftRef.current); @@ -363,9 +371,62 @@ const Map = forwardRef(function Map({ > {isGlobeProjection ? "Globe" : "Flat"} - + - + ) : null} + + {onPlayPreviewReplay ? ( + + ) : null} + +