preview mode
This commit is contained in:
@@ -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<string | number>,
|
||||
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 đồ
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user