preview mode

This commit is contained in:
taDuc
2026-05-18 13:45:35 +07:00
parent c09928a2b2
commit 97d505dcc7
14 changed files with 1657 additions and 208 deletions
+407
View File
@@ -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<string | null>(null);
const [dialog, setDialog] = useState<ReplayPreviewDialog | null>(null);
const [image, setImage] = useState<ReplayPreviewImage | null>(null);
const [toasts, setToasts] = useState<ReplayPreviewToast[]>([]);
const [timelineVisible, setTimelineVisible] = useState(true);
const [timelineYear, setTimelineYear] = useState(initialTimelineYear);
const [timelineFilterEnabled, setTimelineFilterEnabled] = useState(initialTimelineFilterEnabled);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [activeWikiId, setActiveWikiId] = useState<string | null>(null);
const [playbackSpeed, setPlaybackSpeed] = useState(1);
const [hiddenGeometryIds, setHiddenGeometryIds] = useState<string[]>([]);
const [activeCursor, setActiveCursor] = useState<{
stageId: number | null;
stepIndex: number | null;
}>({
stageId: null,
stepIndex: null,
});
const [activeStepNumber, setActiveStepNumber] = useState<number | null>(null);
const runIdRef = useRef(0);
const playbackSpeedRef = useRef(1);
const toastIdRef = useRef(0);
const toastTimeoutsRef = useRef<number[]>([]);
const baselineRef = useRef<PreviewBaseline | null>(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<Parameters<typeof dispatchReplayAction>[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<string>();
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<boolean>((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);
});
}