From 97d505dcc74aab39b39e930dc86db1894232b8d0 Mon Sep 17 00:00:00 2001 From: taDuc Date: Mon, 18 May 2026 13:45:35 +0700 Subject: [PATCH] preview mode --- src/app/editor/[id]/page.tsx | 412 +++++++++++++++-- src/uhm/components/Map.tsx | 128 +----- src/uhm/components/editor/ModeHint.tsx | 7 + .../editor/ReplayEffectsSidebar.tsx | 60 ++- .../editor/ReplayPreviewOverlay.tsx | 415 ++++++++++++++++++ .../editor/ReplayTimelineSidebar.tsx | 119 ++++- src/uhm/doc/commit_snapshot.ts | 17 +- src/uhm/lib/editor/session/sessionTypes.ts | 3 +- src/uhm/lib/replay/mapActions.ts | 79 +++- src/uhm/lib/replay/narrativeActions.ts | 58 ++- src/uhm/lib/replay/replayDispatcher.ts | 141 +++++- src/uhm/lib/replay/uiActions.ts | 8 + src/uhm/lib/replay/useReplayPreview.ts | 407 +++++++++++++++++ src/uhm/types/projects.ts | 11 +- 14 files changed, 1657 insertions(+), 208 deletions(-) create mode 100644 src/uhm/components/editor/ReplayPreviewOverlay.tsx create mode 100644 src/uhm/lib/replay/useReplayPreview.ts diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 3b564bd..81bbc1d 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -10,6 +10,8 @@ import TimelineBar from "@/uhm/components/ui/TimelineBar"; import SelectedGeometryPanel from "@/uhm/components/editor/SelectedGeometryPanel"; import ReplayTimelineSidebar from "@/uhm/components/editor/ReplayTimelineSidebar"; import ReplayEffectsSidebar from "@/uhm/components/editor/ReplayEffectsSidebar"; +import ReplayPreviewOverlay from "@/uhm/components/editor/ReplayPreviewOverlay"; +import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar"; import WikiSidebarPanel from "@/uhm/components/wiki/WikiSidebarPanel"; import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel"; import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel"; @@ -18,7 +20,7 @@ import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities" import { ApiError } from "@/uhm/api/http"; import { fetchCurrentUser } from "@/uhm/api/auth"; import { ProjectCommit } from "@/uhm/api/projects"; -import { searchWikisByTitle, type Wiki } from "@/uhm/api/wikis"; +import { fetchWikiById, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis"; import { searchGeometriesByEntityName, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries"; import type { EntitySnapshot } from "@/uhm/types/entities"; import { @@ -42,7 +44,9 @@ import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding"; import { loadBackgroundLayerVisibilityFromStorage, } from "@/uhm/lib/editor/background/backgroundVisibilityStorage"; +import { deepClone } from "@/uhm/lib/editor/draft/draftDiff"; import { useProjectCommands } from "@/uhm/lib/editor/project/useProjectCommands"; +import { useReplayPreview } from "@/uhm/lib/replay/useReplayPreview"; import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants"; import { FIXED_TIMELINE_RANGE, clampYearToFixedRange } from "@/uhm/lib/utils/timeline"; import { useFeatureCommands } from "./featureCommands"; @@ -59,6 +63,17 @@ import { const CURRENT_YEAR = new Date().getUTCFullYear(); const DEFAULT_EDITOR_USER_ID = "local-editor"; +type ReplayPreviewSession = { + replay: BattleReplay; + draft: FeatureCollection; + wikis: WikiSnapshot[]; + selectedStageId: number | null; + selectedStepIndex: number | null; + timelineYear: number; + timelineFilterEnabled: boolean; + mapViewState: ReturnType; +}; + export default function Page() { return ( (null); + const [previewAutoplayMode, setPreviewAutoplayMode] = useState<"start" | "selection" | null>(null); + const [previewWikiCache, setPreviewWikiCache] = useState>({}); + const [previewWikiError, setPreviewWikiError] = useState(null); + const [isPreviewWikiLoading, setIsPreviewWikiLoading] = useState(false); + const handleReplaySelectionChange = useCallback((stageId: number | null, stepIndex: number | null) => { + setReplaySelection({ stageId, stepIndex }); + }, []); + const getCurrentMapInstance = useCallback(() => mapHandleRef.current?.getMap() ?? null, []); + const getCurrentMapViewState = useCallback(() => mapHandleRef.current?.getViewState() ?? null, []); + const isReplayEditMode = mode === "replay"; + const isReplayPreviewMode = mode === "replay_preview"; const entitiesRef = useRef(entities); useEffect(() => { entitiesRef.current = entities; @@ -330,14 +357,54 @@ function EditorPageContent() { }); }, [snapshotEntities, setEntityCatalog]); + const handleTimelineYearChange = useCallback((nextYear: number) => { + setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear))); + }, [setTimelineDraftYear]); + + const replayPreview = useReplayPreview({ + replay: previewSession?.replay || null, + draft: previewSession?.draft || EMPTY_FEATURE_COLLECTION, + getMapInstance: getCurrentMapInstance, + initialTimelineYear: previewSession?.timelineYear ?? timelineDraftYear, + initialTimelineFilterEnabled: previewSession?.timelineFilterEnabled ?? timelineFilterEnabled, + initialMapViewState: previewSession?.mapViewState ?? null, + selectedStageId: previewSession?.selectedStageId ?? replaySelection.stageId, + selectedStepIndex: previewSession?.selectedStepIndex ?? replaySelection.stepIndex, + onSelectStep: () => {}, + }); + + const replayPreviewDraft = useMemo(() => { + const sourceDraft = previewSession?.draft || EMPTY_FEATURE_COLLECTION; + if (!isReplayPreviewMode || replayPreview.hiddenGeometryIds.length === 0) { + return sourceDraft; + } + const hiddenIds = new Set(replayPreview.hiddenGeometryIds); + return { + ...sourceDraft, + features: sourceDraft.features.filter( + (feature) => !hiddenIds.has(String(feature.properties.id)) + ), + }; + }, [isReplayPreviewMode, previewSession?.draft, replayPreview.hiddenGeometryIds]); + + const activeTimelineYear = isReplayPreviewMode + ? replayPreview.timelineYear + : timelineDraftYear; + const activeTimelineFilterEnabled = isReplayPreviewMode + ? replayPreview.timelineFilterEnabled + : timelineFilterEnabled; + // Timeline filter: only affects persisted snapshot features. // New features created in the current session remain visible regardless of time range. const timelineVisibleDraft = useMemo(() => { - // Nếu ở mode replay, sử dụng replayDraft thay vì main draft - const activeDraft = mode === "replay" ? editor.replayDraft : editor.mainDraft; + const activeDraft = isReplayPreviewMode + ? replayPreviewDraft + : isReplayEditMode + ? editor.replayDraft + : editor.mainDraft; - if (!timelineFilterEnabled) return activeDraft; - const year = clampYearToFixedRange(Math.trunc(timelineDraftYear)); + if (!activeTimelineFilterEnabled) return activeDraft; + const year = clampYearToFixedRange(Math.trunc(activeTimelineYear)); return { ...activeDraft, features: activeDraft.features.filter((feature) => { @@ -345,7 +412,14 @@ function EditorPageContent() { return isFeatureVisibleAtYear(feature, year); }), }; - }, [editor, mode, timelineDraftYear, timelineFilterEnabled]); + }, [ + activeTimelineFilterEnabled, + activeTimelineYear, + editor, + isReplayEditMode, + isReplayPreviewMode, + replayPreviewDraft, + ]); const selectedFeatures = useMemo(() => { if (!selectedFeatureIds || selectedFeatureIds.length === 0) return []; @@ -463,7 +537,66 @@ function EditorPageContent() { restoreCommit, } = sectionCommands; + const exitReplayPreview = useCallback(() => { + replayPreview.resetPreview(); + setPreviewAutoplayMode(null); + setPreviewSession(null); + internalSetMode("replay"); + }, [internalSetMode, replayPreview.resetPreview]); + + const openReplayPreview = useCallback((autoplayMode: "start" | "selection") => { + if (!editor.activeReplayDraft) return; + + setPreviewSession({ + replay: deepClone(editor.activeReplayDraft), + draft: deepClone(editor.replayDraft), + wikis: deepClone(snapshotWikis), + selectedStageId: replaySelection.stageId, + selectedStepIndex: replaySelection.stepIndex, + timelineYear: timelineDraftYear, + timelineFilterEnabled, + mapViewState: getCurrentMapViewState(), + }); + setPreviewAutoplayMode(autoplayMode); + setSelectedFeatureIds([]); + internalSetMode("replay_preview"); + }, [ + editor.activeReplayDraft, + editor.replayDraft, + getCurrentMapViewState, + internalSetMode, + replaySelection.stageId, + replaySelection.stepIndex, + setSelectedFeatureIds, + snapshotWikis, + timelineDraftYear, + timelineFilterEnabled, + ]); + const setMode = useCallback((m: EditorMode, featureId?: string | number) => { + if (m === "replay_preview") { + return; + } + + if (mode === "replay_preview") { + replayPreview.resetPreview(); + setPreviewAutoplayMode(null); + setPreviewSession(null); + + if (m === "replay") { + internalSetMode("replay"); + return; + } + + editor.closeReplayContext(); + setSelectedFeatureIds([]); + setReplayFeatureId(null); + setHideOutside(false); + setReplaySelection({ stageId: null, stepIndex: null }); + internalSetMode(m); + return; + } + if (m === "replay" && featureId) { // QUY TẮC: Geo chọn đầu tiên là geo main. const triggerId = selectedFeatureIds.length > 0 ? selectedFeatureIds[0] : featureId; @@ -481,7 +614,16 @@ function EditorPageContent() { setReplaySelection({ stageId: null, stepIndex: null }); } internalSetMode(m); - }, [internalSetMode, mode, editor, selectedFeatureIds, setHideOutside, setReplayFeatureId, setSelectedFeatureIds]); + }, [ + editor, + internalSetMode, + mode, + replayPreview.resetPreview, + selectedFeatureIds, + setHideOutside, + setReplayFeatureId, + setSelectedFeatureIds, + ]); useEffect(() => { if (!activeReplayStages.length) { @@ -521,14 +663,141 @@ function EditorPageContent() { } }, [activeReplayStages, replaySelection.stageId, replaySelection.stepIndex]); + useEffect(() => { + if (!isReplayPreviewMode || !previewSession || !previewAutoplayMode) return; + if (previewAutoplayMode === "selection") { + replayPreview.playFromSelection(); + } else { + replayPreview.playFromStart(); + } + setPreviewAutoplayMode(null); + }, [ + isReplayPreviewMode, + previewAutoplayMode, + previewSession, + replayPreview.playFromSelection, + replayPreview.playFromStart, + ]); + + useEffect(() => { + setPreviewWikiCache({}); + setPreviewWikiError(null); + setIsPreviewWikiLoading(false); + }, [previewSession]); + + const replayPreviewActiveStepLabel = useMemo(() => { + if ( + replayPreview.activeCursor.stageId == null || + replayPreview.activeCursor.stepIndex == null + ) { + return null; + } + return `Stage #${replayPreview.activeCursor.stageId} · Step ${replayPreview.activeCursor.stepIndex + 1}`; + }, [replayPreview.activeCursor.stageId, replayPreview.activeCursor.stepIndex]); + + const replayPreviewWikiRows = previewSession?.wikis || []; + const replayPreviewActiveWikiSnapshot = useMemo(() => { + if (!replayPreview.activeWikiId) return null; + return replayPreviewWikiRows.find((item) => item.id === replayPreview.activeWikiId) || null; + }, [replayPreview.activeWikiId, replayPreviewWikiRows]); + + useEffect(() => { + if (!isReplayPreviewMode || !replayPreview.sidebarOpen) { + setPreviewWikiError(null); + setIsPreviewWikiLoading(false); + return; + } + + const activeWikiId = String(replayPreview.activeWikiId || "").trim(); + if (!activeWikiId.length) { + setPreviewWikiError(null); + setIsPreviewWikiLoading(false); + return; + } + + const localWiki = replayPreviewWikiRows.find((item) => item.id === activeWikiId) || null; + if (!localWiki) { + setPreviewWikiError("Không tìm thấy wiki trong snapshot preview."); + setIsPreviewWikiLoading(false); + return; + } + + if (typeof localWiki.doc === "string") { + setPreviewWikiError(null); + setIsPreviewWikiLoading(false); + return; + } + + if (previewWikiCache[activeWikiId]) { + setPreviewWikiError(null); + setIsPreviewWikiLoading(false); + return; + } + + let disposed = false; + setPreviewWikiError(null); + setIsPreviewWikiLoading(true); + void fetchWikiById(activeWikiId) + .then((row) => { + if (disposed) return; + setPreviewWikiCache((prev) => ({ ...prev, [activeWikiId]: row })); + }) + .catch((err) => { + if (disposed) return; + setPreviewWikiError(err instanceof Error ? err.message : "Không tải được wiki preview."); + }) + .finally(() => { + if (!disposed) { + setIsPreviewWikiLoading(false); + } + }); + + return () => { + disposed = true; + }; + }, [ + isReplayPreviewMode, + previewWikiCache, + replayPreview.activeWikiId, + replayPreview.sidebarOpen, + replayPreviewWikiRows, + ]); + + const replayPreviewActiveWiki = useMemo(() => { + const snapshotWiki = replayPreviewActiveWikiSnapshot; + if (!snapshotWiki) return null; + if (typeof snapshotWiki.doc === "string") { + return { + id: snapshotWiki.id, + project_id: projectId, + title: snapshotWiki.title, + slug: snapshotWiki.slug ?? null, + content: snapshotWiki.doc || "", + }; + } + return previewWikiCache[snapshotWiki.id] || null; + }, [previewWikiCache, projectId, replayPreviewActiveWikiSnapshot]); + + const handleReplayPreviewWikiLinkRequest = useCallback(({ slug }: { slug: string; rect: DOMRect }) => { + const nextSlug = String(slug || "").trim(); + if (!nextSlug.length) return; + const match = replayPreviewWikiRows.find((item) => String(item.slug || "").trim() === nextSlug) || null; + if (!match) { + setPreviewWikiError(`Wiki /wiki/${nextSlug} không có trong snapshot preview.`); + return; + } + setPreviewWikiError(null); + replayPreview.openWikiPanelById(match.id); + }, [replayPreview.openWikiPanelById, replayPreviewWikiRows]); + const effectiveGeometryVisibility = useMemo(() => { const visibility: Record = { ...geometryVisibility }; - if (mode === "replay" && replayFeatureId) { + if ((isReplayEditMode || isReplayPreviewMode) && replayFeatureId) { // Ẩn chính geo được chọn làm replay (marker kịch bản) visibility[String(replayFeatureId)] = false; - if (hideOutside) { + if (isReplayEditMode && hideOutside) { // Trong mode replay, ta chỉ hiển thị những gì có trong draft của replay đó const currentReplayFeatureIds = new Set(editor.draft.features.map(f => String(f.properties.id))); @@ -544,11 +813,14 @@ function EditorPageContent() { } return visibility; - }, [geometryVisibility, mode, replayFeatureId, hideOutside, editor.draft.features]); - - const onToggleHideOutside = useCallback(() => { - setHideOutside((prev) => !prev); - }, [setHideOutside]); + }, [ + editor.draft.features, + geometryVisibility, + hideOutside, + isReplayEditMode, + isReplayPreviewMode, + replayFeatureId, + ]); const openProject = useCallback(async () => { if (!projectId) return; @@ -918,10 +1190,6 @@ function EditorPageContent() { setIsBackgroundVisibilityReady(true); }, [setBackgroundVisibility, setIsBackgroundVisibilityReady]); - const handleTimelineYearChange = (nextYear: number) => { - setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear))); - }; - const handleAddEntityRefToProject = useCallback((entity: Entity) => { const id = String(entity.id || "").trim(); if (!id) return; @@ -1325,9 +1593,13 @@ function EditorPageContent() { setSelectedFeatureIds([feature.properties.id]); }; + const mapLabelContextDraft = isReplayPreviewMode + ? previewSession?.draft || EMPTY_FEATURE_COLLECTION + : editor.draft; + return (
- {mode !== "replay" ? ( + {!isReplayEditMode && !isReplayPreviewMode ? ( <> - ) : ( + ) : isReplayEditMode ? ( <> setReplaySelection({ stageId, stepIndex })} + onSelectStep={handleReplaySelectionChange} onMutateReplay={editor.mutateActiveReplay} onUndoReplay={editor.undo} onExitReplay={() => setMode("select")} + isPreviewPlaying={false} + previewPlaybackSpeed={1} + onPlayPreviewFromStart={() => openReplayPreview("start")} + onPlayPreviewFromSelection={() => openReplayPreview("selection")} + onStopPreview={() => {}} + onResetPreview={() => {}} /> - )} + ) : null} {blockedPendingSubmissionId ? (
@@ -1434,7 +1712,7 @@ function EditorPageContent() { mode={mode} onSetMode={setMode} draft={timelineVisibleDraft} - labelContextDraft={editor.draft} + labelContextDraft={mapLabelContextDraft} selectedFeatureIds={selectedFeatureIds} onSelectFeatureIds={setSelectedFeatureIds} onCreateFeature={handleCreateFeature} @@ -1442,32 +1720,84 @@ function EditorPageContent() { onUpdateFeature={editor.updateFeature} backgroundVisibility={backgroundVisibility} geometryVisibility={effectiveGeometryVisibility} - respectBindingFilter={mode === "replay" ? false : geometryBindingFilterEnabled} + respectBindingFilter={isReplayEditMode || isReplayPreviewMode ? false : geometryBindingFilterEnabled} highlightFeatures={null} focusFeatureCollection={geometryFocusRequest?.collection || null} focusRequestKey={geometryFocusRequest?.key ?? null} focusPadding={96} - hideOutside={hideOutside} - onToggleHideOutside={onToggleHideOutside} - onUndoReplay={editor.undo} - canUndoReplay={editor.canUndoReplay} /> ) : (
)} - + {isReplayPreviewMode ? ( + + ) : null} + {isReplayPreviewMode && replayPreview.sidebarOpen ? ( + + ) : null} + {!isReplayPreviewMode || replayPreview.timelineVisible ? ( + + ) : null}
) : null} - {mode !== "replay" ? ( + {!isReplayEditMode && !isReplayPreviewMode ? ( <> - ) : ( + ) : isReplayEditMode ? ( <> mapHandleRef.current?.getViewState() ?? null} + getCurrentMapViewState={getCurrentMapViewState} onMutateReplay={editor.mutateActiveReplay} /> - )} + ) : null}
); } diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index 49492f0..b4f99c8 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -1,6 +1,6 @@ "use client"; -import { type CSSProperties, useEffect, useRef, forwardRef, useImperativeHandle, useCallback } from "react"; +import { type CSSProperties, useEffect, useRef, forwardRef, useImperativeHandle } from "react"; import "maplibre-gl/dist/maplibre-gl.css"; import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState"; @@ -27,6 +27,7 @@ export type MapHandle = { bearing: number; projection: string; } | null; + getMap: () => import("maplibre-gl").Map | null; }; type MapProps = { @@ -51,10 +52,6 @@ type MapProps = { focusFeatureCollection?: FeatureCollection | null; focusRequestKey?: string | number | null; focusPadding?: number | import("maplibre-gl").PaddingOptions; - hideOutside?: boolean; - onToggleHideOutside?: () => void; - onUndoReplay?: () => void; - canUndoReplay?: boolean; }; const Map = forwardRef(function Map({ @@ -79,10 +76,6 @@ const Map = forwardRef(function Map({ focusFeatureCollection = null, focusRequestKey = null, focusPadding, - hideOutside = false, - onToggleHideOutside, - onUndoReplay, - canUndoReplay = false, }, ref) { const modeRef = useRef(mode); const draftRef = useRef(draft); @@ -119,15 +112,8 @@ const Map = forwardRef(function Map({ useImperativeHandle(ref, () => ({ getViewState, - }), [getViewState]); - - const handleLogViewState = useCallback(() => { - const state = getViewState(); - console.log("Current Map View State:", state); - if (state) { - alert(`Captured View State:\nCenter: ${state.center.lng.toFixed(4)}, ${state.center.lat.toFixed(4)}\nZoom: ${state.zoom.toFixed(2)}\nPitch: ${state.pitch.toFixed(1)}°\nBearing: ${state.bearing.toFixed(1)}°\nProjection: ${state.projection}`); - } - }, [getViewState]); + getMap: () => mapRef.current, + }), [getViewState, mapRef]); const { editingEngineRef, @@ -256,112 +242,6 @@ const Map = forwardRef(function Map({ pointerEvents: "auto", }} > - {mode === "replay" && ( - <> - - - - - - -
-
-
-
-
-
- - )} -
@@ -576,13 +623,13 @@ function GeoFunctionShortcutPanel({ } /> onAppendActions( - [{ function_name: "dim_other_geometries", params: [selectedIds, 0.18] }], - `Geo: dim others ngoài ${selectedCount} geo` + [{ function_name: "dim_other_geometries", params: [selectedIds] }], + `Geo: hide others ngoài ${selectedCount} geo` ) } /> @@ -1402,6 +1449,7 @@ function buildEmptyUiOptionSelection(): Record { timeline: false, layer_panel: false, wiki_panel: false, + close_wiki_panel: false, zoom_panel: false, wiki: false, toast: false, @@ -1523,6 +1571,11 @@ function buildUiOptionAction( function_name: option, params: [false], }; + case "close_wiki_panel": + return { + function_name: option, + params: [], + }; case "wiki": return { function_name: option, @@ -1574,6 +1627,7 @@ function normalizeUiOptionValue(value: unknown): UIOptionName | null { case "timeline": case "layer_panel": case "wiki_panel": + case "close_wiki_panel": case "zoom_panel": case "wiki": case "toast": diff --git a/src/uhm/components/editor/ReplayPreviewOverlay.tsx b/src/uhm/components/editor/ReplayPreviewOverlay.tsx new file mode 100644 index 0000000..a895554 --- /dev/null +++ b/src/uhm/components/editor/ReplayPreviewOverlay.tsx @@ -0,0 +1,415 @@ +"use client"; + +import type { CSSProperties } from "react"; +import type { + ReplayPreviewDialog, + ReplayPreviewImage, + ReplayPreviewToast, +} from "@/uhm/lib/replay/useReplayPreview"; + +type Props = { + isPreviewMode: boolean; + isPlaying: boolean; + title: string; + descriptions: string; + subtitle: string | null; + dialog: ReplayPreviewDialog | null; + image: ReplayPreviewImage | null; + toasts: ReplayPreviewToast[]; + sidebarOpen: boolean; + playbackSpeed: number; + activeStepLabel: string | null; + activeStepNumber: number | null; + totalSteps: number; + onPlayPreview: () => void; + onStopPreview: () => void; + onResetPreview: () => void; + onExitPreview: () => void; +}; + +export default function ReplayPreviewOverlay({ + isPreviewMode, + isPlaying, + title, + descriptions, + subtitle, + dialog, + image, + toasts, + sidebarOpen, + playbackSpeed, + activeStepLabel, + activeStepNumber, + totalSteps, + onPlayPreview, + onStopPreview, + onResetPreview, + onExitPreview, +}: Props) { + const hasNarrativeCard = title.trim().length > 0 || descriptions.trim().length > 0; + const hasWikiPreview = sidebarOpen; + const shouldRender = + isPreviewMode || + isPlaying || + hasNarrativeCard || + Boolean(subtitle) || + Boolean(dialog) || + Boolean(image) || + Boolean(toasts.length); + + if (!shouldRender) { + return null; + } + + return ( +
+ {hasNarrativeCard ? ( +
+ {title.trim().length ? ( +
+ {title} +
+ ) : null} + {descriptions.trim().length ? ( +
+ {descriptions} +
+ ) : null} +
+ ) : null} + + {toasts.length ? ( +
+ {toasts.map((toast) => ( +
+ {toast.message} +
+ ))} +
+ ) : null} + + {image ? ( +
+ {image.caption + {image.caption?.trim() ? ( +
+ {image.caption} +
+ ) : null} +
+ ) : null} + + {dialog ? ( +
+ {dialog.avatar.trim().length ? ( + {dialog.speaker + ) : null} +
+ {dialog.speaker?.trim() ? ( +
+ {dialog.speaker} +
+ ) : null} +
+ {dialog.text} +
+
+
+ ) : null} + + {subtitle?.trim() ? ( +
+ {subtitle} +
+ ) : null} + + {isPreviewMode ? ( +
+
+
+
+ + Preview + + {activeStepLabel ? ( + + {activeStepLabel} + + ) : null} + + x{playbackSpeed.toFixed(2)} + +
+ {totalSteps > 0 ? ( +
+
+
+
+
+ Step {activeStepNumber || 0}/{totalSteps} +
+
+ ) : null} +
+
+ {isPlaying ? ( + <> + + + + ) : ( + + )} + +
+
+
+ ) : null} +
+ ); +} + +function previewButtonStyle(background: string): CSSProperties { + return { + border: "none", + background, + color: "white", + borderRadius: 10, + padding: "8px 12px", + cursor: "pointer", + fontSize: 12, + fontWeight: 800, + boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.08)", + }; +} diff --git a/src/uhm/components/editor/ReplayTimelineSidebar.tsx b/src/uhm/components/editor/ReplayTimelineSidebar.tsx index 4c03a8d..713c2db 100644 --- a/src/uhm/components/editor/ReplayTimelineSidebar.tsx +++ b/src/uhm/components/editor/ReplayTimelineSidebar.tsx @@ -27,6 +27,12 @@ type Props = { onMutateReplay: (label: string, mutator: (draftReplay: BattleReplay) => void) => boolean; onUndoReplay: () => void; onExitReplay: () => void; + isPreviewPlaying: boolean; + previewPlaybackSpeed: number; + onPlayPreviewFromStart: () => void; + onPlayPreviewFromSelection: () => void; + onStopPreview: () => void; + onResetPreview: () => void; }; type ActionGroupKey = "use_UI_function" | "use_map_function" | "use_geo_function" | "use_narrow_function"; @@ -72,6 +78,12 @@ export default function ReplayTimelineSidebar({ onMutateReplay, onUndoReplay, onExitReplay, + isPreviewPlaying, + previewPlaybackSpeed, + onPlayPreviewFromStart, + onPlayPreviewFromSelection, + onStopPreview, + onResetPreview, }: Props) { const stages = useMemo(() => replay?.detail || [], [replay?.detail]); const selectedStage = @@ -368,6 +380,80 @@ export default function ReplayTimelineSidebar({ Thoát replay
+
+ + + {isPreviewPlaying ? ( + + ) : null} + {isPreviewPlaying ? ( + + ) : null} +
+
+ Preview sẽ mở trong mode riêng với snapshot replay tại thời điểm bấm play. +
@@ -999,6 +1085,7 @@ const uiOptionLabels: Record = { timeline: "Timeline", layer_panel: "Layer Panel", wiki_panel: "Wiki Panel", + close_wiki_panel: "Đóng Wiki Panel", zoom_panel: "Zoom Panel", wiki: "Wiki", toast: "Toast", @@ -1008,10 +1095,15 @@ const uiOptionLabels: Record = { const narrativeFunctionLabels: Record = { set_title: "Tiêu đề step", + clear_title: "Xóa tiêu đề", set_descriptions: "Mô tả", + clear_descriptions: "Xóa mô tả", show_dialog_box: "Dialog box", + clear_dialog_box: "Đóng dialog box", display_historical_image: "Ảnh lịch sử", + clear_historical_image: "Xóa ảnh lịch sử", set_step_subtitle: "Phụ đề", + clear_step_subtitle: "Xóa phụ đề", }; const mapFunctionLabels: Record = { @@ -1022,6 +1114,7 @@ const mapFunctionLabels: Record = { toggle_labels: "Bật/tắt labels", show_labels: "Hiện labels", hide_labels: "Ẩn labels", + show_all_geometries: "Hiện tất cả geo", reset_camera_north: "North up", }; @@ -1039,7 +1132,7 @@ const geoFunctionLabels: Record = { show_geometry_label: "Label geometry", follow_geometry_path: "Follow path", follow_geometries_path: "Follow path", - dim_other_geometries: "Làm mờ geo khác", + dim_other_geometries: "Ẩn geo khác", }; function buildStepActionEntries(step: ReplayStep): StepActionEntry[] { @@ -1070,9 +1163,15 @@ function buildNarrativeActionEntry( case "set_title": summary = summarizeValue(params[0], "Tiêu đề trống"); break; + case "clear_title": + summary = "title=null"; + break; case "set_descriptions": summary = summarizeValue(params[0], "Mô tả trống"); break; + case "clear_descriptions": + summary = "descriptions=null"; + break; case "show_dialog_box": summary = [ `speaker=${summarizeValue(params[3], "ẩn danh")}`, @@ -1080,15 +1179,24 @@ function buildNarrativeActionEntry( `text=${summarizeValue(params[1], "trống")}`, ].join(" | "); break; + case "clear_dialog_box": + summary = "dialog=null"; + break; case "display_historical_image": summary = [ `url=${summarizeValue(params[0], "trống")}`, `caption=${summarizeValue(params[1], "trống")}`, ].join(" | "); break; + case "clear_historical_image": + summary = "image=null"; + break; case "set_step_subtitle": summary = summarizeValue(params[0], "Ẩn subtitle"); break; + case "clear_step_subtitle": + summary = "subtitle=null"; + break; } return { @@ -1129,6 +1237,9 @@ function buildMapActionEntry( case "hide_labels": summary = "visible=false"; break; + case "show_all_geometries": + summary = "hidden_ids=[]"; + break; case "set_camera_view": summary = summarizeCameraViewValue(params[0]); break; @@ -1248,8 +1359,7 @@ function buildGeoActionEntry( break; case "dim_other_geometries": summary = [ - `focus=${summarizeGeometryIdsValue(params[0])}`, - `other_opacity=${summarizeValue(params[1], "mặc định")}`, + `keep=${summarizeGeometryIdsValue(params[0])}`, ].join(" | "); break; } @@ -1278,6 +1388,8 @@ function buildUiActionEntry( if (option === "timeline" || option === "layer_panel" || option === "wiki_panel" || option === "zoom_panel") { summary = `visible=${Boolean(params[0]) ? "true" : "false"}`; + } else if (option === "close_wiki_panel") { + summary = "visible=false | active_wiki=null"; } else if (option === "wiki") { summary = `wiki_id=${summarizeValue(params[0], "trống")}`; } else if (option === "toast") { @@ -1342,6 +1454,7 @@ function normalizeUiOptionValue(value: unknown): UIOptionName | null { case "timeline": case "layer_panel": case "wiki_panel": + case "close_wiki_panel": case "zoom_panel": case "wiki": case "toast": diff --git a/src/uhm/doc/commit_snapshot.ts b/src/uhm/doc/commit_snapshot.ts index bcf5d92..61e1148 100644 --- a/src/uhm/doc/commit_snapshot.ts +++ b/src/uhm/doc/commit_snapshot.ts @@ -138,6 +138,7 @@ export type UIOptionName = | "timeline" | "layer_panel" | "wiki_panel" + | "close_wiki_panel" | "zoom_panel" | "wiki" | "toast" @@ -152,6 +153,7 @@ export type MapFunctionName = | "toggle_labels" | "show_labels" | "hide_labels" + | "show_all_geometries" | "reset_camera_north"; export type GeoFunctionName = @@ -172,10 +174,15 @@ export type GeoFunctionName = export type NarrativeFunctionName = | "set_title" + | "clear_title" | "set_descriptions" + | "clear_descriptions" | "show_dialog_box" + | "clear_dialog_box" | "display_historical_image" - | "set_step_subtitle"; + | "clear_historical_image" + | "set_step_subtitle" + | "clear_step_subtitle"; /** * Runtime thật hiện dùng positional array cho params. @@ -228,6 +235,7 @@ export type ReplayUiParamTupleDocs = { timeline: [visible: boolean]; layer_panel: [visible: boolean]; wiki_panel: [visible: boolean]; + close_wiki_panel: []; zoom_panel: [visible: boolean]; wiki: [wiki_id: string]; toast: [message: string]; @@ -248,6 +256,7 @@ export type ReplayMapFunctionParamTupleDocs = { toggle_labels: [visible: boolean]; show_labels: []; hide_labels: []; + show_all_geometries: []; reset_camera_north: []; }; @@ -314,24 +323,28 @@ export type ReplayGeoFunctionParamTupleDocs = { ]; dim_other_geometries: [ geometry_ids: string[], - opacity?: number, ]; }; export type ReplayNarrativeParamTupleDocs = { set_title: [title: string]; + clear_title: []; set_descriptions: [text: string]; + clear_descriptions: []; show_dialog_box: [ avatar: string, text: string, side?: "left" | "right", speaker?: string, ]; + clear_dialog_box: []; display_historical_image: [ url: string, caption?: string, ]; + clear_historical_image: []; set_step_subtitle: [subtitle: string | null]; + clear_step_subtitle: []; }; export type ReplayParamTupleDocs = diff --git a/src/uhm/lib/editor/session/sessionTypes.ts b/src/uhm/lib/editor/session/sessionTypes.ts index 24748af..c46342d 100644 --- a/src/uhm/lib/editor/session/sessionTypes.ts +++ b/src/uhm/lib/editor/session/sessionTypes.ts @@ -8,7 +8,8 @@ export type EditorMode = | "add-line" | "add-path" | "add-circle" - | "replay"; + | "replay" + | "replay_preview"; export type TimelineRange = { min: number; diff --git a/src/uhm/lib/replay/mapActions.ts b/src/uhm/lib/replay/mapActions.ts index 4d4a2b7..8f3575a 100644 --- a/src/uhm/lib/replay/mapActions.ts +++ b/src/uhm/lib/replay/mapActions.ts @@ -1,5 +1,6 @@ import type maplibregl from "maplibre-gl"; import type { FeatureCollection } from "@/uhm/types/geo"; +import { fitMapToFeatureCollection, getFeatureCollectionBBox } from "@/uhm/components/map/mapUtils"; /** * Các hàm xử lý tương tác bản đồ cho hệ thống Replay. @@ -49,21 +50,73 @@ export const mapActions = { // Di chuyển mượt mà đến một geometry dựa trên ID fly_to_geometry: (map: maplibregl.Map, geometryId: string | number, draft: FeatureCollection) => { - const feature = draft.features.find(f => String(f.properties.id) === String(geometryId)); + mapActions.fly_to_geometries(map, [geometryId], draft); + }, + + // Di chuyển mượt mà đến một hoặc nhiều geometry dựa trên ID. + fly_to_geometries: ( + map: maplibregl.Map, + geometryIds: Array, + draft: FeatureCollection, + duration = 2200 + ) => { + const ids = new Set( + geometryIds + .map((id) => String(id).trim()) + .filter((id) => id.length > 0) + ); + if (!ids.size) return; + + const targetFeatures = draft.features.filter((feature) => + ids.has(String(feature.properties.id)) + ); + if (!targetFeatures.length) return; + + fitMapToFeatureCollection( + map, + { + type: "FeatureCollection", + features: targetFeatures, + }, + 64, + { + duration, + maxZoom: 8.5, + pointZoom: 8, + } + ); + }, + + orbit_camera_around_geometry: ( + map: maplibregl.Map, + geometryId: string | number, + draft: FeatureCollection, + zoom = 8, + pitch = 45, + turns = 1, + duration = 5000 + ) => { + const feature = draft.features.find( + (item) => String(item.properties.id) === String(geometryId) + ); if (!feature) return; - // Tính toán bounds từ geometry (giả định có helper hoặc dùng bbox của feature) - // Ở đây tạm dùng center đơn giản nếu là Point, hoặc bounds nếu là đa giác - if (feature.geometry.type === "Point") { - map.flyTo({ - center: feature.geometry.coordinates as [number, number], - zoom: Math.max(map.getZoom(), 10), - duration: 3000, - }); - } else { - // Thực tế cần tính bbox, ở đây giả định map có hàm fitBounds hoặc tương đương - // map.fitBounds(calculateBBox(feature.geometry), { padding: 50 }); - } + const bbox = getFeatureCollectionBBox({ + type: "FeatureCollection", + features: [feature], + }); + if (!bbox) return; + + map.easeTo({ + center: [ + (bbox.minLng + bbox.maxLng) / 2, + (bbox.minLat + bbox.maxLat) / 2, + ], + zoom, + pitch, + bearing: map.getBearing() + (Number.isFinite(turns) ? turns * 360 : 360), + duration, + }); }, // Ẩn/hiện nhãn (labels) trên bản đồ diff --git a/src/uhm/lib/replay/narrativeActions.ts b/src/uhm/lib/replay/narrativeActions.ts index b8f7b55..a753334 100644 --- a/src/uhm/lib/replay/narrativeActions.ts +++ b/src/uhm/lib/replay/narrativeActions.ts @@ -8,23 +8,71 @@ export const narrativeActions = { setTitle(title); }, + clear_title: (setTitle: (t: string) => void) => { + setTitle(""); + }, + // Đặt nội dung mô tả chi tiết set_descriptions: (setDesc: (d: string) => void, descriptions: string) => { setDesc(descriptions); }, + clear_descriptions: (setDesc: (d: string) => void) => { + setDesc(""); + }, + // Hiển thị hộp thoại hội thoại (Dialogue) - show_dialog_box: (setDialog: (data: { avatar: string; text: string; side: 'left' | 'right' }) => void, avatar: string, text: string) => { - setDialog({ avatar, text, side: 'left' }); + show_dialog_box: ( + setDialog: (data: { + avatar: string; + text: string; + side: "left" | "right"; + speaker?: string | null; + }) => void, + avatar: string, + text: string, + side: "left" | "right", + speaker: string | null + ) => { + setDialog({ avatar, text, side, speaker }); + }, + + clear_dialog_box: ( + setDialog: (data: { + avatar: string; + text: string; + side: "left" | "right"; + speaker?: string | null; + } | null) => void + ) => { + setDialog(null); }, // Hiển thị hình ảnh lịch sử đè lên bản đồ - display_historical_image: (setImage: (url: string | null) => void, imageUrl: string) => { - setImage(imageUrl); + display_historical_image: ( + setImage: (image: { url: string; caption?: string | null } | null) => void, + imageUrl: string, + caption: string | null + ) => { + if (!imageUrl.trim().length) { + setImage(null); + return; + } + setImage({ url: imageUrl, caption }); + }, + + clear_historical_image: ( + setImage: (image: { url: string; caption?: string | null } | null) => void, + ) => { + setImage(null); }, // Hiển thị phụ đề (Subtitle) set_step_subtitle: (setSubtitle: (s: string | null) => void, subtitle: string) => { setSubtitle(subtitle); - } + }, + + clear_step_subtitle: (setSubtitle: (s: string | null) => void) => { + setSubtitle(null); + }, }; diff --git a/src/uhm/lib/replay/replayDispatcher.ts b/src/uhm/lib/replay/replayDispatcher.ts index c4430ee..68abfdc 100644 --- a/src/uhm/lib/replay/replayDispatcher.ts +++ b/src/uhm/lib/replay/replayDispatcher.ts @@ -21,17 +21,27 @@ export interface ReplayControllers { // UI Setters setTimelineVisible: (v: boolean) => void; + setTimelineFilterEnabled: (v: boolean) => void; setSidebarOpen: (v: boolean) => void; onSelectWiki: (id: string) => void; addToast: (msg: string) => void; setPlaybackSpeed: (s: number) => void; onYearChange: (y: number) => void; + showGeometries: (ids: string[]) => void; + hideGeometries: (ids: string[]) => void; + showOnlyGeometries: (ids: string[]) => void; + showAllGeometries: () => void; // Narrative Setters setTitle: (t: string) => void; setDescriptions: (d: string) => void; - setDialog: (data: { avatar: string; text: string; side: "left" | "right" }) => void; - setImage: (url: string | null) => void; + setDialog: (data: { + avatar: string; + text: string; + side: "left" | "right"; + speaker?: string | null; + } | null) => void; + setImage: (image: { url: string; caption?: string | null } | null) => void; setSubtitle: (s: string | null) => void; } @@ -62,6 +72,14 @@ export const dispatchReplayAction = ( controllers.draft, ); return; + case "fly_to_geometries": + mapActions.fly_to_geometries( + map, + toStringValues(params[0]), + controllers.draft, + asNumberValue(params[1], 2200) + ); + return; case "toggle_labels": mapActions.toggle_labels(map, asBooleanValue(params[0], true)); return; @@ -71,27 +89,79 @@ export const dispatchReplayAction = ( case "hide_labels": mapActions.toggle_labels(map, false); return; + case "show_all_geometries": + controllers.showAllGeometries(); + return; case "set_time_filter": mapActions.set_time_filter(controllers.onYearChange, asNumberValue(params[0], 0)); return; + case "enable_timeline_filter": + controllers.setTimelineFilterEnabled(true); + return; + case "disable_timeline_filter": + controllers.setTimelineFilterEnabled(false); + return; + case "show_geometries": + controllers.showGeometries(toStringValues(params[0])); + return; + case "hide_geometries": + controllers.hideGeometries(toStringValues(params[0])); + return; + case "set_geometry_visibility": { + const geometryIds = toStringValues(params[0]); + const visible = asBooleanValue(params[1], true); + if (visible) { + controllers.showGeometries(geometryIds); + } else { + controllers.hideGeometries(geometryIds); + } + return; + } + case "fit_to_geometries": + mapActions.fly_to_geometries( + map, + toStringValues(params[0]), + controllers.draft, + asNumberValue(params[1], 1800) + ); + return; + case "orbit_camera_around_geometry": + mapActions.orbit_camera_around_geometry( + map, + asStringValue(params[0]), + controllers.draft, + asNumberValue(params[1], 8), + asNumberValue(params[2], 45), + asNumberValue(params[3], 1), + asNumberValue(params[4], 5000) + ); + return; + case "follow_geometry_path": + mapActions.fly_to_geometries( + map, + [asStringValue(params[0])], + controllers.draft, + asNumberValue(params[1], 5000) + ); + return; + case "follow_geometries_path": + mapActions.fly_to_geometries( + map, + toStringValues(params[0]), + controllers.draft, + asNumberValue(params[1], 5000) + ); + return; case "reset_camera_north": mapActions.set_camera_view(map, { bearing: 0 }); return; - case "fly_to_geometries": - case "enable_timeline_filter": - case "disable_timeline_filter": - case "show_geometries": - case "hide_geometries": - case "set_geometry_visibility": - case "fit_to_geometries": - case "orbit_camera_around_geometry": case "pulse_geometry": case "animate_dashed_border": case "set_geometry_style": case "show_geometry_label": - case "follow_geometry_path": - case "follow_geometries_path": + return; case "dim_other_geometries": + controllers.showOnlyGeometries(toStringValues(params[0])); return; } } @@ -110,6 +180,9 @@ export const dispatchReplayAction = ( case "wiki_panel": uiActions.wiki_panel(controllers.setSidebarOpen, Boolean(payload[0] ?? false)); return; + case "close_wiki_panel": + uiActions.close_wiki_panel(controllers.setSidebarOpen, controllers.onSelectWiki); + return; case "zoom_panel": uiActions.zoom_panel(Boolean(payload[0] ?? false)); return; @@ -143,22 +216,43 @@ export const dispatchReplayAction = ( case "set_title": narrativeActions.set_title(controllers.setTitle, asStringValue(params[0])); return; + case "clear_title": + narrativeActions.clear_title(controllers.setTitle); + return; case "set_descriptions": narrativeActions.set_descriptions(controllers.setDescriptions, asStringValue(params[0])); return; + case "clear_descriptions": + narrativeActions.clear_descriptions(controllers.setDescriptions); + return; case "show_dialog_box": narrativeActions.show_dialog_box( controllers.setDialog, asStringValue(params[0]), - asStringValue(params[1]) + asStringValue(params[1]), + normalizeDialogSide(params[2]), + nullableStringValue(params[3]) ); return; + case "clear_dialog_box": + narrativeActions.clear_dialog_box(controllers.setDialog); + return; case "display_historical_image": - narrativeActions.display_historical_image(controllers.setImage, asStringValue(params[0])); + narrativeActions.display_historical_image( + controllers.setImage, + asStringValue(params[0]), + nullableStringValue(params[1]) + ); + return; + case "clear_historical_image": + narrativeActions.clear_historical_image(controllers.setImage); return; case "set_step_subtitle": narrativeActions.set_step_subtitle(controllers.setSubtitle, asStringValue(params[0])); return; + case "clear_step_subtitle": + narrativeActions.clear_step_subtitle(controllers.setSubtitle); + return; } }; @@ -167,6 +261,7 @@ function normalizeUiOption(value: unknown): UIOptionName | null { case "timeline": case "layer_panel": case "wiki_panel": + case "close_wiki_panel": case "zoom_panel": case "wiki": case "toast": @@ -235,10 +330,19 @@ function asStringValue(value: unknown) { return typeof value === "string" ? value : value == null ? "" : String(value); } +function nullableStringValue(value: unknown) { + const next = asStringValue(value).trim(); + return next.length > 0 ? next : null; +} + function asBooleanValue(value: unknown, fallback: boolean) { return typeof value === "boolean" ? value : fallback; } +function normalizeDialogSide(value: unknown): "left" | "right" { + return value === "right" ? "right" : "left"; +} + function asOptionalNumberValue(value: unknown) { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } @@ -246,3 +350,12 @@ function asOptionalNumberValue(value: unknown) { function asNumberValue(value: unknown, fallback: number) { return asOptionalNumberValue(value) ?? fallback; } + +function toStringValues(value: unknown) { + if (!Array.isArray(value)) { + return []; + } + return value + .map((item) => asStringValue(item).trim()) + .filter((item) => item.length > 0); +} diff --git a/src/uhm/lib/replay/uiActions.ts b/src/uhm/lib/replay/uiActions.ts index 519fcac..aac2830 100644 --- a/src/uhm/lib/replay/uiActions.ts +++ b/src/uhm/lib/replay/uiActions.ts @@ -19,6 +19,14 @@ export const uiActions = { setSidebarOpen(visible); }, + close_wiki_panel: ( + setSidebarOpen: (v: boolean) => void, + onSelectWiki: (id: string) => void, + ) => { + setSidebarOpen(false); + onSelectWiki(""); + }, + // Ẩn/hiện panel zoom. Runtime hiện chưa có controller riêng nên tạm no-op. zoom_panel: (visible: boolean) => { void visible; diff --git a/src/uhm/lib/replay/useReplayPreview.ts b/src/uhm/lib/replay/useReplayPreview.ts new file mode 100644 index 0000000..427b681 --- /dev/null +++ b/src/uhm/lib/replay/useReplayPreview.ts @@ -0,0 +1,407 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { FeatureCollection } from "@/uhm/types/geo"; +import type { BattleReplay, ReplayStage, ReplayStep } from "@/uhm/types/projects"; +import { dispatchReplayAction } from "./replayDispatcher"; +import { mapActions } from "./mapActions"; + +export type ReplayPreviewDialog = { + avatar: string; + text: string; + side: "left" | "right"; + speaker?: string | null; +}; + +export type ReplayPreviewImage = { + url: string; + caption?: string | null; +}; + +export type ReplayPreviewToast = { + id: number; + message: string; +}; + +type PreviewBaseline = { + timelineYear: number; + timelineFilterEnabled: boolean; + timelineVisible: boolean; + mapViewState: { + center: { lng: number; lat: number }; + zoom: number; + pitch: number; + bearing: number; + projection: string; + } | null; +}; + +type FlattenedReplayStep = { + stage: ReplayStage; + step: ReplayStep; + stageId: number; + stepIndex: number; +}; + +type UseReplayPreviewOptions = { + replay: BattleReplay | null; + draft: FeatureCollection; + getMapInstance: () => import("maplibre-gl").Map | null; + initialTimelineYear: number; + initialTimelineFilterEnabled: boolean; + initialMapViewState: PreviewBaseline["mapViewState"]; + selectedStageId: number | null; + selectedStepIndex: number | null; + onSelectStep: (stageId: number | null, stepIndex: number | null) => void; +}; + +export function useReplayPreview({ + replay, + draft, + getMapInstance, + initialTimelineYear, + initialTimelineFilterEnabled, + initialMapViewState, + selectedStageId, + selectedStepIndex, + onSelectStep, +}: UseReplayPreviewOptions) { + const [isPlaying, setIsPlaying] = useState(false); + const [title, setTitle] = useState(""); + const [descriptions, setDescriptions] = useState(""); + const [subtitle, setSubtitle] = useState(null); + const [dialog, setDialog] = useState(null); + const [image, setImage] = useState(null); + const [toasts, setToasts] = useState([]); + const [timelineVisible, setTimelineVisible] = useState(true); + const [timelineYear, setTimelineYear] = useState(initialTimelineYear); + const [timelineFilterEnabled, setTimelineFilterEnabled] = useState(initialTimelineFilterEnabled); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [activeWikiId, setActiveWikiId] = useState(null); + const [playbackSpeed, setPlaybackSpeed] = useState(1); + const [hiddenGeometryIds, setHiddenGeometryIds] = useState([]); + const [activeCursor, setActiveCursor] = useState<{ + stageId: number | null; + stepIndex: number | null; + }>({ + stageId: null, + stepIndex: null, + }); + const [activeStepNumber, setActiveStepNumber] = useState(null); + + const runIdRef = useRef(0); + const playbackSpeedRef = useRef(1); + const toastIdRef = useRef(0); + const toastTimeoutsRef = useRef([]); + const baselineRef = useRef(null); + + const flatSteps = useMemo(() => flattenReplaySteps(replay), [replay]); + + useEffect(() => { + playbackSpeedRef.current = playbackSpeed; + }, [playbackSpeed]); + + useEffect(() => { + setTimelineYear(initialTimelineYear); + setTimelineFilterEnabled(initialTimelineFilterEnabled); + setTimelineVisible(true); + baselineRef.current = { + timelineYear: initialTimelineYear, + timelineFilterEnabled: initialTimelineFilterEnabled, + timelineVisible: true, + mapViewState: initialMapViewState, + }; + }, [initialMapViewState, initialTimelineFilterEnabled, initialTimelineYear, replay?.id]); + + useEffect(() => { + return () => { + runIdRef.current += 1; + toastTimeoutsRef.current.forEach((timeoutId) => window.clearTimeout(timeoutId)); + toastTimeoutsRef.current = []; + }; + }, []); + + const clearToasts = useCallback(() => { + toastTimeoutsRef.current.forEach((timeoutId) => window.clearTimeout(timeoutId)); + toastTimeoutsRef.current = []; + setToasts([]); + }, []); + + const resetPresentation = useCallback(() => { + setTitle(""); + setDescriptions(""); + setSubtitle(null); + setDialog(null); + setImage(null); + setSidebarOpen(false); + setActiveWikiId(null); + playbackSpeedRef.current = 1; + setPlaybackSpeed(1); + setHiddenGeometryIds([]); + clearToasts(); + }, [clearToasts]); + + const addToast = useCallback((message: string) => { + const text = String(message || "").trim(); + if (!text.length) return; + + const id = ++toastIdRef.current; + setToasts((prev) => [...prev, { id, message: text }]); + const timeoutId = window.setTimeout(() => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + toastTimeoutsRef.current = toastTimeoutsRef.current.filter((item) => item !== timeoutId); + }, 3200); + toastTimeoutsRef.current.push(timeoutId); + }, []); + + const restorePreviewState = useCallback(() => { + setIsPlaying(false); + setActiveCursor({ stageId: null, stepIndex: null }); + setActiveStepNumber(null); + resetPresentation(); + + const baseline = baselineRef.current; + if (!baseline) { + setTimelineVisible(true); + return; + } + + setTimelineVisible(baseline.timelineVisible); + setTimelineYear(baseline.timelineYear); + setTimelineFilterEnabled(baseline.timelineFilterEnabled); + const map = getMapInstance(); + if (map) { + mapActions.toggle_labels(map, true); + if (baseline.mapViewState) { + mapActions.set_camera_view(map, { + center: baseline.mapViewState.center, + zoom: baseline.mapViewState.zoom, + pitch: baseline.mapViewState.pitch, + bearing: baseline.mapViewState.bearing, + duration: 650, + }); + } + } + }, [getMapInstance, resetPresentation]); + + const resetPreview = useCallback(() => { + runIdRef.current += 1; + restorePreviewState(); + }, [restorePreviewState]); + + const stopPreview = useCallback(() => { + runIdRef.current += 1; + restorePreviewState(); + }, [restorePreviewState]); + + const controllersRef = useRef[0] | null>(null); + controllersRef.current = { + map: getMapInstance(), + draft, + setTimelineVisible, + setTimelineFilterEnabled, + setSidebarOpen, + onSelectWiki: (id) => { + const nextId = String(id || "").trim(); + setActiveWikiId(nextId || null); + }, + addToast, + setPlaybackSpeed: (nextSpeed) => { + const safe = Number.isFinite(nextSpeed) && nextSpeed > 0 ? nextSpeed : 1; + playbackSpeedRef.current = safe; + setPlaybackSpeed(safe); + }, + onYearChange: setTimelineYear, + showGeometries: (ids) => { + const nextIds = normalizeIdList(ids); + if (!nextIds.length) return; + setHiddenGeometryIds((prev) => prev.filter((id) => !nextIds.includes(id))); + }, + hideGeometries: (ids) => { + const nextIds = normalizeIdList(ids); + if (!nextIds.length) return; + setHiddenGeometryIds((prev) => { + const seen = new Set(prev); + for (const id of nextIds) { + seen.add(id); + } + return Array.from(seen); + }); + }, + showOnlyGeometries: (ids) => { + const keepIds = new Set(normalizeIdList(ids)); + if (!keepIds.size) return; + setHiddenGeometryIds( + draft.features + .map((feature) => String(feature.properties.id)) + .filter((id) => !keepIds.has(id)) + ); + }, + showAllGeometries: () => { + setHiddenGeometryIds([]); + }, + setTitle, + setDescriptions, + setDialog, + setImage, + setSubtitle, + }; + + const playFromIndex = useCallback(async (startIndex: number) => { + if (!flatSteps.length) return; + const safeStartIndex = Math.max(0, Math.min(flatSteps.length - 1, startIndex)); + resetPresentation(); + setTimelineVisible(true); + setTimelineYear(initialTimelineYear); + setTimelineFilterEnabled(initialTimelineFilterEnabled); + + const runId = runIdRef.current + 1; + runIdRef.current = runId; + setIsPlaying(true); + + for (let index = safeStartIndex; index < flatSteps.length; index += 1) { + if (runIdRef.current !== runId) return; + + const current = flatSteps[index]; + setActiveCursor({ + stageId: current.stageId, + stepIndex: current.stepIndex, + }); + setActiveStepNumber(index + 1); + onSelectStep(current.stageId, current.stepIndex); + + const controllers = controllersRef.current; + if (!controllers) return; + + const actions = [ + ...current.step.use_narrow_function, + ...current.step.use_map_function, + ...current.step.use_geo_function, + ...current.step.use_UI_function, + ]; + for (const action of actions) { + if (runIdRef.current !== runId) return; + dispatchReplayAction(controllers, action); + } + + const duration = Math.max(1, Math.trunc(Number(current.step.duration) || 1000)); + const waitMs = Math.max(60, Math.round(duration / playbackSpeedRef.current)); + const completed = await waitForPreviewDelay(waitMs, () => runIdRef.current !== runId); + if (!completed) return; + } + + if (runIdRef.current !== runId) return; + restorePreviewState(); + }, [ + flatSteps, + initialTimelineFilterEnabled, + initialTimelineYear, + onSelectStep, + resetPresentation, + restorePreviewState, + ]); + + const playFromStart = useCallback(() => { + void playFromIndex(0); + }, [playFromIndex]); + + const playFromSelection = useCallback(() => { + const selectedIndex = findReplayStepIndex(flatSteps, selectedStageId, selectedStepIndex); + void playFromIndex(selectedIndex >= 0 ? selectedIndex : 0); + }, [flatSteps, playFromIndex, selectedStageId, selectedStepIndex]); + + return { + isPlaying, + title, + descriptions, + subtitle, + dialog, + image, + toasts, + timelineVisible, + timelineYear, + timelineFilterEnabled, + sidebarOpen, + activeWikiId, + playbackSpeed, + activeStepNumber, + totalSteps: flatSteps.length, + hiddenGeometryIds, + activeCursor, + hasPlayableSteps: flatSteps.length > 0, + playFromStart, + playFromSelection, + stopPreview, + resetPreview, + setTimelineYear, + setTimelineFilterEnabled, + closeWikiPanel: () => { + setSidebarOpen(false); + setActiveWikiId(null); + }, + openWikiPanelById: (wikiId: string) => { + const nextId = String(wikiId || "").trim(); + if (!nextId.length) return; + setActiveWikiId(nextId); + setSidebarOpen(true); + }, + }; +} + +function flattenReplaySteps(replay: BattleReplay | null): FlattenedReplayStep[] { + if (!replay) return []; + return replay.detail.flatMap((stage) => + stage.steps.map((step, stepIndex) => ({ + stage, + step, + stageId: stage.id, + stepIndex, + })) + ); +} + +function findReplayStepIndex( + steps: FlattenedReplayStep[], + selectedStageId: number | null, + selectedStepIndex: number | null +) { + if (selectedStageId == null || selectedStepIndex == null) { + return -1; + } + return steps.findIndex( + (item) => + item.stageId === selectedStageId && + item.stepIndex === selectedStepIndex + ); +} + +function normalizeIdList(ids: string[]) { + const seen = new Set(); + const next: string[] = []; + for (const item of ids) { + const id = String(item || "").trim(); + if (!id.length || seen.has(id)) continue; + seen.add(id); + next.push(id); + } + return next; +} + +function waitForPreviewDelay(duration: number, isCancelled: () => boolean) { + return new Promise((resolve) => { + const timeoutId = window.setTimeout(() => { + resolve(!isCancelled()); + }, duration); + + const cancelLoop = () => { + if (!isCancelled()) { + window.setTimeout(cancelLoop, 32); + return; + } + window.clearTimeout(timeoutId); + resolve(false); + }; + + window.setTimeout(cancelLoop, 32); + }); +} diff --git a/src/uhm/types/projects.ts b/src/uhm/types/projects.ts index 72b8a8e..6ad54af 100644 --- a/src/uhm/types/projects.ts +++ b/src/uhm/types/projects.ts @@ -93,6 +93,7 @@ export type UIOptionName = | "timeline" // Ẩn/hiện timeline | "layer_panel" // Ẩn/hiện panel layer | "wiki_panel" // Ẩn/hiện panel wiki + | "close_wiki_panel" // Đóng panel wiki và xóa wiki đang active | "zoom_panel" // Ẩn/hiện nút zoom | "wiki" // Mở/chọn wiki | "toast" // Hiển thị toast @@ -107,6 +108,7 @@ export type MapFunctionName = | "toggle_labels" // Legacy: bật/tắt hiển thị nhãn (labels) trên bản đồ | "show_labels" // Hiện labels | "hide_labels" // Ẩn labels + | "show_all_geometries" // Hiện lại toàn bộ geometry đang có trong replay draft | "reset_camera_north"; // Đưa camera về hướng bắc export type GeoFunctionName = @@ -123,14 +125,19 @@ export type GeoFunctionName = | "show_geometry_label" // Hiện label riêng cho geometry | "follow_geometry_path" // Legacy: cho camera bám theo một path geometry | "follow_geometries_path" // Cho camera bám theo chuỗi path geometry - | "dim_other_geometries"; // Làm mờ các geometry ngoài target set + | "dim_other_geometries"; // Ẩn các geometry ngoài target set, chỉ giữ geo focus export type NarrativeFunctionName = | "set_title" // Đặt tiêu đề cho bước replay + | "clear_title" // Xóa tiêu đề hiện tại | "set_descriptions" // Đặt mô tả/nội dung diễn giải + | "clear_descriptions" // Xóa mô tả hiện tại | "show_dialog_box" // Hiển thị hộp thoại dẫn chuyện (có avatar) + | "clear_dialog_box" // Đóng/xóa dialog hiện tại | "display_historical_image" // Hiển thị hình ảnh tư liệu đè lên bản đồ - | "set_step_subtitle"; // Hiển thị phụ đề phía dưới màn hình + | "clear_historical_image" // Xóa ảnh lịch sử hiện tại + | "set_step_subtitle" // Hiển thị phụ đề phía dưới màn hình + | "clear_step_subtitle"; // Xóa phụ đề hiện tại export type ReplayAction = { function_name: T;