"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); }); }