preview mode
This commit is contained in:
+366
-36
@@ -10,6 +10,8 @@ import TimelineBar from "@/uhm/components/ui/TimelineBar";
|
|||||||
import SelectedGeometryPanel from "@/uhm/components/editor/SelectedGeometryPanel";
|
import SelectedGeometryPanel from "@/uhm/components/editor/SelectedGeometryPanel";
|
||||||
import ReplayTimelineSidebar from "@/uhm/components/editor/ReplayTimelineSidebar";
|
import ReplayTimelineSidebar from "@/uhm/components/editor/ReplayTimelineSidebar";
|
||||||
import ReplayEffectsSidebar from "@/uhm/components/editor/ReplayEffectsSidebar";
|
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 WikiSidebarPanel from "@/uhm/components/wiki/WikiSidebarPanel";
|
||||||
import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel";
|
import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel";
|
||||||
import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel";
|
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 { ApiError } from "@/uhm/api/http";
|
||||||
import { fetchCurrentUser } from "@/uhm/api/auth";
|
import { fetchCurrentUser } from "@/uhm/api/auth";
|
||||||
import { ProjectCommit } from "@/uhm/api/projects";
|
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 { searchGeometriesByEntityName, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries";
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import {
|
import {
|
||||||
@@ -42,7 +44,9 @@ import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding";
|
|||||||
import {
|
import {
|
||||||
loadBackgroundLayerVisibilityFromStorage,
|
loadBackgroundLayerVisibilityFromStorage,
|
||||||
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
||||||
|
import { deepClone } from "@/uhm/lib/editor/draft/draftDiff";
|
||||||
import { useProjectCommands } from "@/uhm/lib/editor/project/useProjectCommands";
|
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 { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
|
||||||
import { FIXED_TIMELINE_RANGE, clampYearToFixedRange } from "@/uhm/lib/utils/timeline";
|
import { FIXED_TIMELINE_RANGE, clampYearToFixedRange } from "@/uhm/lib/utils/timeline";
|
||||||
import { useFeatureCommands } from "./featureCommands";
|
import { useFeatureCommands } from "./featureCommands";
|
||||||
@@ -59,6 +63,17 @@ import {
|
|||||||
const CURRENT_YEAR = new Date().getUTCFullYear();
|
const CURRENT_YEAR = new Date().getUTCFullYear();
|
||||||
const DEFAULT_EDITOR_USER_ID = "local-editor";
|
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<MapHandle["getViewState"]>;
|
||||||
|
};
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return (
|
return (
|
||||||
<EditorStoreProvider
|
<EditorStoreProvider
|
||||||
@@ -304,6 +319,18 @@ function EditorPageContent() {
|
|||||||
stageId: null,
|
stageId: null,
|
||||||
stepIndex: null,
|
stepIndex: null,
|
||||||
});
|
});
|
||||||
|
const [previewSession, setPreviewSession] = useState<ReplayPreviewSession | null>(null);
|
||||||
|
const [previewAutoplayMode, setPreviewAutoplayMode] = useState<"start" | "selection" | null>(null);
|
||||||
|
const [previewWikiCache, setPreviewWikiCache] = useState<Record<string, Wiki>>({});
|
||||||
|
const [previewWikiError, setPreviewWikiError] = useState<string | null>(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);
|
const entitiesRef = useRef(entities);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
entitiesRef.current = entities;
|
entitiesRef.current = entities;
|
||||||
@@ -330,14 +357,54 @@ function EditorPageContent() {
|
|||||||
});
|
});
|
||||||
}, [snapshotEntities, setEntityCatalog]);
|
}, [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.
|
// Timeline filter: only affects persisted snapshot features.
|
||||||
// New features created in the current session remain visible regardless of time range.
|
// New features created in the current session remain visible regardless of time range.
|
||||||
const timelineVisibleDraft = useMemo(() => {
|
const timelineVisibleDraft = useMemo(() => {
|
||||||
// Nếu ở mode replay, sử dụng replayDraft thay vì main draft
|
const activeDraft = isReplayPreviewMode
|
||||||
const activeDraft = mode === "replay" ? editor.replayDraft : editor.mainDraft;
|
? replayPreviewDraft
|
||||||
|
: isReplayEditMode
|
||||||
|
? editor.replayDraft
|
||||||
|
: editor.mainDraft;
|
||||||
|
|
||||||
if (!timelineFilterEnabled) return activeDraft;
|
if (!activeTimelineFilterEnabled) return activeDraft;
|
||||||
const year = clampYearToFixedRange(Math.trunc(timelineDraftYear));
|
const year = clampYearToFixedRange(Math.trunc(activeTimelineYear));
|
||||||
return {
|
return {
|
||||||
...activeDraft,
|
...activeDraft,
|
||||||
features: activeDraft.features.filter((feature) => {
|
features: activeDraft.features.filter((feature) => {
|
||||||
@@ -345,7 +412,14 @@ function EditorPageContent() {
|
|||||||
return isFeatureVisibleAtYear(feature, year);
|
return isFeatureVisibleAtYear(feature, year);
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}, [editor, mode, timelineDraftYear, timelineFilterEnabled]);
|
}, [
|
||||||
|
activeTimelineFilterEnabled,
|
||||||
|
activeTimelineYear,
|
||||||
|
editor,
|
||||||
|
isReplayEditMode,
|
||||||
|
isReplayPreviewMode,
|
||||||
|
replayPreviewDraft,
|
||||||
|
]);
|
||||||
|
|
||||||
const selectedFeatures = useMemo(() => {
|
const selectedFeatures = useMemo(() => {
|
||||||
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return [];
|
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return [];
|
||||||
@@ -463,7 +537,66 @@ function EditorPageContent() {
|
|||||||
restoreCommit,
|
restoreCommit,
|
||||||
} = sectionCommands;
|
} = 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) => {
|
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) {
|
if (m === "replay" && featureId) {
|
||||||
// QUY TẮC: Geo chọn đầu tiên là geo main.
|
// QUY TẮC: Geo chọn đầu tiên là geo main.
|
||||||
const triggerId = selectedFeatureIds.length > 0 ? selectedFeatureIds[0] : featureId;
|
const triggerId = selectedFeatureIds.length > 0 ? selectedFeatureIds[0] : featureId;
|
||||||
@@ -481,7 +614,16 @@ function EditorPageContent() {
|
|||||||
setReplaySelection({ stageId: null, stepIndex: null });
|
setReplaySelection({ stageId: null, stepIndex: null });
|
||||||
}
|
}
|
||||||
internalSetMode(m);
|
internalSetMode(m);
|
||||||
}, [internalSetMode, mode, editor, selectedFeatureIds, setHideOutside, setReplayFeatureId, setSelectedFeatureIds]);
|
}, [
|
||||||
|
editor,
|
||||||
|
internalSetMode,
|
||||||
|
mode,
|
||||||
|
replayPreview.resetPreview,
|
||||||
|
selectedFeatureIds,
|
||||||
|
setHideOutside,
|
||||||
|
setReplayFeatureId,
|
||||||
|
setSelectedFeatureIds,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeReplayStages.length) {
|
if (!activeReplayStages.length) {
|
||||||
@@ -521,14 +663,141 @@ function EditorPageContent() {
|
|||||||
}
|
}
|
||||||
}, [activeReplayStages, replaySelection.stageId, replaySelection.stepIndex]);
|
}, [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<Wiki | null>(() => {
|
||||||
|
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 effectiveGeometryVisibility = useMemo(() => {
|
||||||
const visibility: Record<string, boolean> = { ...geometryVisibility };
|
const visibility: Record<string, boolean> = { ...geometryVisibility };
|
||||||
|
|
||||||
if (mode === "replay" && replayFeatureId) {
|
if ((isReplayEditMode || isReplayPreviewMode) && replayFeatureId) {
|
||||||
// Ẩn chính geo được chọn làm replay (marker kịch bản)
|
// Ẩn chính geo được chọn làm replay (marker kịch bản)
|
||||||
visibility[String(replayFeatureId)] = false;
|
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 đó
|
// 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)));
|
const currentReplayFeatureIds = new Set(editor.draft.features.map(f => String(f.properties.id)));
|
||||||
|
|
||||||
@@ -544,11 +813,14 @@ function EditorPageContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return visibility;
|
return visibility;
|
||||||
}, [geometryVisibility, mode, replayFeatureId, hideOutside, editor.draft.features]);
|
}, [
|
||||||
|
editor.draft.features,
|
||||||
const onToggleHideOutside = useCallback(() => {
|
geometryVisibility,
|
||||||
setHideOutside((prev) => !prev);
|
hideOutside,
|
||||||
}, [setHideOutside]);
|
isReplayEditMode,
|
||||||
|
isReplayPreviewMode,
|
||||||
|
replayFeatureId,
|
||||||
|
]);
|
||||||
|
|
||||||
const openProject = useCallback(async () => {
|
const openProject = useCallback(async () => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
@@ -918,10 +1190,6 @@ function EditorPageContent() {
|
|||||||
setIsBackgroundVisibilityReady(true);
|
setIsBackgroundVisibilityReady(true);
|
||||||
}, [setBackgroundVisibility, setIsBackgroundVisibilityReady]);
|
}, [setBackgroundVisibility, setIsBackgroundVisibilityReady]);
|
||||||
|
|
||||||
const handleTimelineYearChange = (nextYear: number) => {
|
|
||||||
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddEntityRefToProject = useCallback((entity: Entity) => {
|
const handleAddEntityRefToProject = useCallback((entity: Entity) => {
|
||||||
const id = String(entity.id || "").trim();
|
const id = String(entity.id || "").trim();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -1325,9 +1593,13 @@ function EditorPageContent() {
|
|||||||
setSelectedFeatureIds([feature.properties.id]);
|
setSelectedFeatureIds([feature.properties.id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mapLabelContextDraft = isReplayPreviewMode
|
||||||
|
? previewSession?.draft || EMPTY_FEATURE_COLLECTION
|
||||||
|
: editor.draft;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", minHeight: "100vh" }}>
|
<div style={{ display: "flex", minHeight: "100vh" }}>
|
||||||
{mode !== "replay" ? (
|
{!isReplayEditMode && !isReplayPreviewMode ? (
|
||||||
<>
|
<>
|
||||||
<Editor
|
<Editor
|
||||||
mode={mode}
|
mode={mode}
|
||||||
@@ -1360,7 +1632,7 @@ function EditorPageContent() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : isReplayEditMode ? (
|
||||||
<>
|
<>
|
||||||
<ReplayTimelineSidebar
|
<ReplayTimelineSidebar
|
||||||
width={leftPanelWidth}
|
width={leftPanelWidth}
|
||||||
@@ -1370,10 +1642,16 @@ function EditorPageContent() {
|
|||||||
pendingSaveCount={pendingSaveCount}
|
pendingSaveCount={pendingSaveCount}
|
||||||
replayUndoStack={editor.replayUndoStack}
|
replayUndoStack={editor.replayUndoStack}
|
||||||
canUndoReplay={editor.canUndoReplay}
|
canUndoReplay={editor.canUndoReplay}
|
||||||
onSelectStep={(stageId, stepIndex) => setReplaySelection({ stageId, stepIndex })}
|
onSelectStep={handleReplaySelectionChange}
|
||||||
onMutateReplay={editor.mutateActiveReplay}
|
onMutateReplay={editor.mutateActiveReplay}
|
||||||
onUndoReplay={editor.undo}
|
onUndoReplay={editor.undo}
|
||||||
onExitReplay={() => setMode("select")}
|
onExitReplay={() => setMode("select")}
|
||||||
|
isPreviewPlaying={false}
|
||||||
|
previewPlaybackSpeed={1}
|
||||||
|
onPlayPreviewFromStart={() => openReplayPreview("start")}
|
||||||
|
onPlayPreviewFromSelection={() => openReplayPreview("selection")}
|
||||||
|
onStopPreview={() => {}}
|
||||||
|
onResetPreview={() => {}}
|
||||||
/>
|
/>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
title="Resize left panel"
|
title="Resize left panel"
|
||||||
@@ -1382,7 +1660,7 @@ function EditorPageContent() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{blockedPendingSubmissionId ? (
|
{blockedPendingSubmissionId ? (
|
||||||
<div style={{ flex: 1, minHeight: "100vh", background: "#0b1220", color: "white", padding: "24px" }}>
|
<div style={{ flex: 1, minHeight: "100vh", background: "#0b1220", color: "white", padding: "24px" }}>
|
||||||
@@ -1434,7 +1712,7 @@ function EditorPageContent() {
|
|||||||
mode={mode}
|
mode={mode}
|
||||||
onSetMode={setMode}
|
onSetMode={setMode}
|
||||||
draft={timelineVisibleDraft}
|
draft={timelineVisibleDraft}
|
||||||
labelContextDraft={editor.draft}
|
labelContextDraft={mapLabelContextDraft}
|
||||||
selectedFeatureIds={selectedFeatureIds}
|
selectedFeatureIds={selectedFeatureIds}
|
||||||
onSelectFeatureIds={setSelectedFeatureIds}
|
onSelectFeatureIds={setSelectedFeatureIds}
|
||||||
onCreateFeature={handleCreateFeature}
|
onCreateFeature={handleCreateFeature}
|
||||||
@@ -1442,32 +1720,84 @@ function EditorPageContent() {
|
|||||||
onUpdateFeature={editor.updateFeature}
|
onUpdateFeature={editor.updateFeature}
|
||||||
backgroundVisibility={backgroundVisibility}
|
backgroundVisibility={backgroundVisibility}
|
||||||
geometryVisibility={effectiveGeometryVisibility}
|
geometryVisibility={effectiveGeometryVisibility}
|
||||||
respectBindingFilter={mode === "replay" ? false : geometryBindingFilterEnabled}
|
respectBindingFilter={isReplayEditMode || isReplayPreviewMode ? false : geometryBindingFilterEnabled}
|
||||||
highlightFeatures={null}
|
highlightFeatures={null}
|
||||||
focusFeatureCollection={geometryFocusRequest?.collection || null}
|
focusFeatureCollection={geometryFocusRequest?.collection || null}
|
||||||
focusRequestKey={geometryFocusRequest?.key ?? null}
|
focusRequestKey={geometryFocusRequest?.key ?? null}
|
||||||
focusPadding={96}
|
focusPadding={96}
|
||||||
hideOutside={hideOutside}
|
|
||||||
onToggleHideOutside={onToggleHideOutside}
|
|
||||||
onUndoReplay={editor.undo}
|
|
||||||
canUndoReplay={editor.canUndoReplay}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
||||||
)}
|
)}
|
||||||
|
{isReplayPreviewMode ? (
|
||||||
|
<ReplayPreviewOverlay
|
||||||
|
isPreviewMode={true}
|
||||||
|
isPlaying={replayPreview.isPlaying}
|
||||||
|
title={replayPreview.title}
|
||||||
|
descriptions={replayPreview.descriptions}
|
||||||
|
subtitle={replayPreview.subtitle}
|
||||||
|
dialog={replayPreview.dialog}
|
||||||
|
image={replayPreview.image}
|
||||||
|
toasts={replayPreview.toasts}
|
||||||
|
sidebarOpen={replayPreview.sidebarOpen}
|
||||||
|
playbackSpeed={replayPreview.playbackSpeed}
|
||||||
|
activeStepLabel={replayPreviewActiveStepLabel}
|
||||||
|
activeStepNumber={replayPreview.activeStepNumber}
|
||||||
|
totalSteps={replayPreview.totalSteps}
|
||||||
|
onPlayPreview={replayPreview.playFromStart}
|
||||||
|
onStopPreview={replayPreview.stopPreview}
|
||||||
|
onResetPreview={replayPreview.resetPreview}
|
||||||
|
onExitPreview={exitReplayPreview}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{isReplayPreviewMode && replayPreview.sidebarOpen ? (
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
width: 420,
|
||||||
|
maxWidth: "calc(100vw - 2rem)",
|
||||||
|
zIndex: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PublicWikiSidebar
|
||||||
|
entity={null}
|
||||||
|
wiki={replayPreviewActiveWiki}
|
||||||
|
isLoading={isPreviewWikiLoading}
|
||||||
|
error={replayPreview.activeWikiId ? previewWikiError : "Chưa có wiki được chọn trong step này."}
|
||||||
|
onClose={() => {
|
||||||
|
setPreviewWikiError(null);
|
||||||
|
replayPreview.closeWikiPanel();
|
||||||
|
}}
|
||||||
|
onWikiLinkRequest={handleReplayPreviewWikiLinkRequest}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
) : null}
|
||||||
|
{!isReplayPreviewMode || replayPreview.timelineVisible ? (
|
||||||
<TimelineBar
|
<TimelineBar
|
||||||
year={timelineDraftYear}
|
year={activeTimelineYear}
|
||||||
onYearChange={handleTimelineYearChange}
|
onYearChange={
|
||||||
|
isReplayPreviewMode
|
||||||
|
? replayPreview.setTimelineYear
|
||||||
|
: handleTimelineYearChange
|
||||||
|
}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
disabled={false}
|
disabled={false}
|
||||||
statusText={null}
|
statusText={null}
|
||||||
filterEnabled={timelineFilterEnabled}
|
filterEnabled={activeTimelineFilterEnabled}
|
||||||
onFilterEnabledChange={setTimelineFilterEnabled}
|
onFilterEnabledChange={
|
||||||
|
isReplayPreviewMode
|
||||||
|
? replayPreview.setTimelineFilterEnabled
|
||||||
|
: setTimelineFilterEnabled
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{mode !== "replay" ? (
|
{!isReplayEditMode && !isReplayPreviewMode ? (
|
||||||
<>
|
<>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
title="Resize right panel"
|
title="Resize right panel"
|
||||||
@@ -1740,7 +2070,7 @@ function EditorPageContent() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : isReplayEditMode ? (
|
||||||
<>
|
<>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
title="Resize right panel"
|
title="Resize right panel"
|
||||||
@@ -1757,11 +2087,11 @@ function EditorPageContent() {
|
|||||||
currentTimelineYear={timelineDraftYear}
|
currentTimelineYear={timelineDraftYear}
|
||||||
geometryChoices={geometryChoices}
|
geometryChoices={geometryChoices}
|
||||||
wikiChoices={wikiChoices}
|
wikiChoices={wikiChoices}
|
||||||
getCurrentMapViewState={() => mapHandleRef.current?.getViewState() ?? null}
|
getCurrentMapViewState={getCurrentMapViewState}
|
||||||
onMutateReplay={editor.mutateActiveReplay}
|
onMutateReplay={editor.mutateActiveReplay}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-124
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 "maplibre-gl/dist/maplibre-gl.css";
|
||||||
|
|
||||||
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||||
@@ -27,6 +27,7 @@ export type MapHandle = {
|
|||||||
bearing: number;
|
bearing: number;
|
||||||
projection: string;
|
projection: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
getMap: () => import("maplibre-gl").Map | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MapProps = {
|
type MapProps = {
|
||||||
@@ -51,10 +52,6 @@ type MapProps = {
|
|||||||
focusFeatureCollection?: FeatureCollection | null;
|
focusFeatureCollection?: FeatureCollection | null;
|
||||||
focusRequestKey?: string | number | null;
|
focusRequestKey?: string | number | null;
|
||||||
focusPadding?: number | import("maplibre-gl").PaddingOptions;
|
focusPadding?: number | import("maplibre-gl").PaddingOptions;
|
||||||
hideOutside?: boolean;
|
|
||||||
onToggleHideOutside?: () => void;
|
|
||||||
onUndoReplay?: () => void;
|
|
||||||
canUndoReplay?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const Map = forwardRef<MapHandle, MapProps>(function Map({
|
const Map = forwardRef<MapHandle, MapProps>(function Map({
|
||||||
@@ -79,10 +76,6 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
focusFeatureCollection = null,
|
focusFeatureCollection = null,
|
||||||
focusRequestKey = null,
|
focusRequestKey = null,
|
||||||
focusPadding,
|
focusPadding,
|
||||||
hideOutside = false,
|
|
||||||
onToggleHideOutside,
|
|
||||||
onUndoReplay,
|
|
||||||
canUndoReplay = false,
|
|
||||||
}, ref) {
|
}, ref) {
|
||||||
const modeRef = useRef<MapProps["mode"]>(mode);
|
const modeRef = useRef<MapProps["mode"]>(mode);
|
||||||
const draftRef = useRef<FeatureCollection>(draft);
|
const draftRef = useRef<FeatureCollection>(draft);
|
||||||
@@ -119,15 +112,8 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
getViewState,
|
getViewState,
|
||||||
}), [getViewState]);
|
getMap: () => mapRef.current,
|
||||||
|
}), [getViewState, mapRef]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
editingEngineRef,
|
editingEngineRef,
|
||||||
@@ -256,112 +242,6 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mode === "replay" && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSetMode?.("select")}
|
|
||||||
style={{
|
|
||||||
...zoomButtonStyle,
|
|
||||||
width: "auto",
|
|
||||||
padding: "0 12px",
|
|
||||||
fontSize: "12px",
|
|
||||||
fontWeight: 700,
|
|
||||||
background: "#7f1d1d",
|
|
||||||
color: "white",
|
|
||||||
border: "1px solid #991b1b",
|
|
||||||
borderRadius: "999px",
|
|
||||||
cursor: "pointer",
|
|
||||||
marginRight: "4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Thoát Replay Edit
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleLogViewState}
|
|
||||||
title="Capture current map view state"
|
|
||||||
style={{
|
|
||||||
...zoomButtonStyle,
|
|
||||||
width: "auto",
|
|
||||||
padding: "0 12px",
|
|
||||||
fontSize: "12px",
|
|
||||||
fontWeight: 700,
|
|
||||||
background: "#1e293b",
|
|
||||||
color: "#38bdf8",
|
|
||||||
border: "1px solid #334155",
|
|
||||||
borderRadius: "999px",
|
|
||||||
cursor: "pointer",
|
|
||||||
marginRight: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Capture View
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onUndoReplay}
|
|
||||||
disabled={!onUndoReplay || !canUndoReplay}
|
|
||||||
title="Undo thao tác replay gần nhất"
|
|
||||||
style={{
|
|
||||||
...zoomButtonStyle,
|
|
||||||
width: "auto",
|
|
||||||
padding: "0 12px",
|
|
||||||
fontSize: "12px",
|
|
||||||
fontWeight: 700,
|
|
||||||
background: !onUndoReplay || !canUndoReplay ? "#0f172a" : "#1e293b",
|
|
||||||
color: !onUndoReplay || !canUndoReplay ? "#64748b" : "#f8fafc",
|
|
||||||
border: "1px solid #334155",
|
|
||||||
borderRadius: "999px",
|
|
||||||
cursor: !onUndoReplay || !canUndoReplay ? "not-allowed" : "pointer",
|
|
||||||
marginRight: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Undo Replay
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
onClick={onToggleHideOutside}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "8px",
|
|
||||||
cursor: "pointer",
|
|
||||||
marginRight: "8px",
|
|
||||||
userSelect: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "32px",
|
|
||||||
height: "18px",
|
|
||||||
borderRadius: "10px",
|
|
||||||
background: hideOutside ? "#e11d48" : "#334155",
|
|
||||||
position: "relative",
|
|
||||||
transition: "background 0.2s",
|
|
||||||
border: "1px solid rgba(255,255,255,0.1)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "2px",
|
|
||||||
left: hideOutside ? "16px" : "2px",
|
|
||||||
width: "12px",
|
|
||||||
height: "12px",
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: "white",
|
|
||||||
transition: "left 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
|
|
||||||
boxShadow: "0 1px 2px rgba(0,0,0,0.3)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ width: "1px", height: "20px", background: "rgba(148, 163, 184, 0.3)", marginRight: "4px" }} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label
|
<label
|
||||||
title={
|
title={
|
||||||
isGlobeProjection
|
isGlobeProjection
|
||||||
|
|||||||
@@ -43,5 +43,12 @@ export function ModeHint({ mode }: { mode: EditorMode }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (mode === "replay_preview") {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||||
|
Đang xem preview replay trên session tách biệt.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ const uiOptionChoices: Array<{ label: string; value: UIOptionName }> = [
|
|||||||
{ label: "Timeline", value: "timeline" },
|
{ label: "Timeline", value: "timeline" },
|
||||||
{ label: "Layer Panel", value: "layer_panel" },
|
{ label: "Layer Panel", value: "layer_panel" },
|
||||||
{ label: "Wiki Panel", value: "wiki_panel" },
|
{ label: "Wiki Panel", value: "wiki_panel" },
|
||||||
|
{ label: "Close Wiki Panel", value: "close_wiki_panel" },
|
||||||
{ label: "Zoom Panel", value: "zoom_panel" },
|
{ label: "Zoom Panel", value: "zoom_panel" },
|
||||||
{ label: "Wiki", value: "wiki" },
|
{ label: "Wiki", value: "wiki" },
|
||||||
{ label: "Toast", value: "toast" },
|
{ label: "Toast", value: "toast" },
|
||||||
@@ -96,6 +97,7 @@ const uiSimpleOptionValues: UIOptionName[] = [
|
|||||||
"timeline",
|
"timeline",
|
||||||
"layer_panel",
|
"layer_panel",
|
||||||
"wiki_panel",
|
"wiki_panel",
|
||||||
|
"close_wiki_panel",
|
||||||
"zoom_panel",
|
"zoom_panel",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -153,6 +155,13 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
|
|||||||
deserialize: (params) => ({ title: asString(params[0]) }),
|
deserialize: (params) => ({ title: asString(params[0]) }),
|
||||||
serialize: (values) => [asString(values.title)],
|
serialize: (values) => [asString(values.title)],
|
||||||
},
|
},
|
||||||
|
clear_title: {
|
||||||
|
label: "Xóa tiêu đề",
|
||||||
|
fields: [],
|
||||||
|
create: () => ({ function_name: "clear_title", params: [] }),
|
||||||
|
deserialize: () => ({}),
|
||||||
|
serialize: () => [],
|
||||||
|
},
|
||||||
set_descriptions: {
|
set_descriptions: {
|
||||||
label: "Mô tả",
|
label: "Mô tả",
|
||||||
fields: [{ name: "text", label: "Text", kind: "textarea", placeholder: "Nội dung diễn giải" }],
|
fields: [{ name: "text", label: "Text", kind: "textarea", placeholder: "Nội dung diễn giải" }],
|
||||||
@@ -160,6 +169,13 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
|
|||||||
deserialize: (params) => ({ text: asString(params[0]) }),
|
deserialize: (params) => ({ text: asString(params[0]) }),
|
||||||
serialize: (values) => [asString(values.text)],
|
serialize: (values) => [asString(values.text)],
|
||||||
},
|
},
|
||||||
|
clear_descriptions: {
|
||||||
|
label: "Xóa mô tả",
|
||||||
|
fields: [],
|
||||||
|
create: () => ({ function_name: "clear_descriptions", params: [] }),
|
||||||
|
deserialize: () => ({}),
|
||||||
|
serialize: () => [],
|
||||||
|
},
|
||||||
show_dialog_box: {
|
show_dialog_box: {
|
||||||
label: "Dialog box",
|
label: "Dialog box",
|
||||||
fields: [
|
fields: [
|
||||||
@@ -190,6 +206,13 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
|
|||||||
asString(values.speaker),
|
asString(values.speaker),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
clear_dialog_box: {
|
||||||
|
label: "Đóng dialog box",
|
||||||
|
fields: [],
|
||||||
|
create: () => ({ function_name: "clear_dialog_box", params: [] }),
|
||||||
|
deserialize: () => ({}),
|
||||||
|
serialize: () => [],
|
||||||
|
},
|
||||||
display_historical_image: {
|
display_historical_image: {
|
||||||
label: "Ảnh lịch sử",
|
label: "Ảnh lịch sử",
|
||||||
fields: [
|
fields: [
|
||||||
@@ -206,6 +229,13 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
|
|||||||
emptyToUndefined(asString(values.caption)),
|
emptyToUndefined(asString(values.caption)),
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
|
clear_historical_image: {
|
||||||
|
label: "Xóa ảnh lịch sử",
|
||||||
|
fields: [],
|
||||||
|
create: () => ({ function_name: "clear_historical_image", params: [] }),
|
||||||
|
deserialize: () => ({}),
|
||||||
|
serialize: () => [],
|
||||||
|
},
|
||||||
set_step_subtitle: {
|
set_step_subtitle: {
|
||||||
label: "Phụ đề",
|
label: "Phụ đề",
|
||||||
fields: [{ name: "subtitle", label: "Subtitle", kind: "textarea", placeholder: "Để trống để ẩn subtitle" }],
|
fields: [{ name: "subtitle", label: "Subtitle", kind: "textarea", placeholder: "Để trống để ẩn subtitle" }],
|
||||||
@@ -213,6 +243,13 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
|
|||||||
deserialize: (params) => ({ subtitle: params[0] == null ? "" : asString(params[0]) }),
|
deserialize: (params) => ({ subtitle: params[0] == null ? "" : asString(params[0]) }),
|
||||||
serialize: (values) => [emptyToNull(asString(values.subtitle))],
|
serialize: (values) => [emptyToNull(asString(values.subtitle))],
|
||||||
},
|
},
|
||||||
|
clear_step_subtitle: {
|
||||||
|
label: "Xóa phụ đề",
|
||||||
|
fields: [],
|
||||||
|
create: () => ({ function_name: "clear_step_subtitle", params: [] }),
|
||||||
|
deserialize: () => ({}),
|
||||||
|
serialize: () => [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ReplayEffectsSidebar({
|
export default function ReplayEffectsSidebar({
|
||||||
@@ -451,6 +488,16 @@ function MapFunctionShortcutPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<ShortcutButton
|
||||||
|
label="Show All Geo"
|
||||||
|
tone="green"
|
||||||
|
onClick={() =>
|
||||||
|
onAppendActions(
|
||||||
|
[{ function_name: "show_all_geometries", params: [] }],
|
||||||
|
"Map: show all geometries"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -576,13 +623,13 @@ function GeoFunctionShortcutPanel({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ShortcutButton
|
<ShortcutButton
|
||||||
label="Dim Others"
|
label="Hide Others"
|
||||||
tone="slate"
|
tone="slate"
|
||||||
disabled={!hasSelection}
|
disabled={!hasSelection}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onAppendActions(
|
onAppendActions(
|
||||||
[{ function_name: "dim_other_geometries", params: [selectedIds, 0.18] }],
|
[{ function_name: "dim_other_geometries", params: [selectedIds] }],
|
||||||
`Geo: dim others ngoài ${selectedCount} geo`
|
`Geo: hide others ngoài ${selectedCount} geo`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1402,6 +1449,7 @@ function buildEmptyUiOptionSelection(): Record<UIOptionName, boolean> {
|
|||||||
timeline: false,
|
timeline: false,
|
||||||
layer_panel: false,
|
layer_panel: false,
|
||||||
wiki_panel: false,
|
wiki_panel: false,
|
||||||
|
close_wiki_panel: false,
|
||||||
zoom_panel: false,
|
zoom_panel: false,
|
||||||
wiki: false,
|
wiki: false,
|
||||||
toast: false,
|
toast: false,
|
||||||
@@ -1523,6 +1571,11 @@ function buildUiOptionAction(
|
|||||||
function_name: option,
|
function_name: option,
|
||||||
params: [false],
|
params: [false],
|
||||||
};
|
};
|
||||||
|
case "close_wiki_panel":
|
||||||
|
return {
|
||||||
|
function_name: option,
|
||||||
|
params: [],
|
||||||
|
};
|
||||||
case "wiki":
|
case "wiki":
|
||||||
return {
|
return {
|
||||||
function_name: option,
|
function_name: option,
|
||||||
@@ -1574,6 +1627,7 @@ function normalizeUiOptionValue(value: unknown): UIOptionName | null {
|
|||||||
case "timeline":
|
case "timeline":
|
||||||
case "layer_panel":
|
case "layer_panel":
|
||||||
case "wiki_panel":
|
case "wiki_panel":
|
||||||
|
case "close_wiki_panel":
|
||||||
case "zoom_panel":
|
case "zoom_panel":
|
||||||
case "wiki":
|
case "wiki":
|
||||||
case "toast":
|
case "toast":
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 15,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasNarrativeCard ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 72,
|
||||||
|
left: 18,
|
||||||
|
maxWidth: 460,
|
||||||
|
borderRadius: 18,
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.26)",
|
||||||
|
background: "linear-gradient(145deg, rgba(15, 23, 42, 0.94), rgba(30, 41, 59, 0.88))",
|
||||||
|
boxShadow: "0 14px 42px rgba(2, 6, 23, 0.42)",
|
||||||
|
padding: "18px 20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title.trim().length ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 26,
|
||||||
|
lineHeight: 1.1,
|
||||||
|
fontWeight: 900,
|
||||||
|
color: "#f8fafc",
|
||||||
|
overflowWrap: "anywhere",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{descriptions.trim().length ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: title.trim().length ? 12 : 0,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
color: "#dbeafe",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{descriptions}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{toasts.length ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 72,
|
||||||
|
right: hasWikiPreview ? 454 : 18,
|
||||||
|
display: "grid",
|
||||||
|
gap: 8,
|
||||||
|
width: 280,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
style={{
|
||||||
|
borderRadius: 14,
|
||||||
|
border: "1px solid rgba(56, 189, 248, 0.28)",
|
||||||
|
background: "rgba(8, 47, 73, 0.9)",
|
||||||
|
color: "#e0f2fe",
|
||||||
|
padding: "12px 14px",
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
boxShadow: "0 10px 26px rgba(2, 6, 23, 0.32)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toast.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{image ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 18,
|
||||||
|
bottom: 96,
|
||||||
|
width: 320,
|
||||||
|
borderRadius: 18,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.22)",
|
||||||
|
background: "rgba(15, 23, 42, 0.9)",
|
||||||
|
boxShadow: "0 16px 44px rgba(2, 6, 23, 0.42)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={image.url}
|
||||||
|
alt={image.caption || "Historical image"}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "block",
|
||||||
|
maxHeight: 240,
|
||||||
|
objectFit: "cover",
|
||||||
|
background: "#020617",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{image.caption?.trim() ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px 12px",
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
color: "#cbd5e1",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{image.caption}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{dialog ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: dialog.side === "right" ? "auto" : 18,
|
||||||
|
right: dialog.side === "right" ? 18 : "auto",
|
||||||
|
bottom: subtitle ? 138 : 96,
|
||||||
|
maxWidth: 420,
|
||||||
|
display: "grid",
|
||||||
|
gap: 10,
|
||||||
|
gridTemplateColumns: dialog.avatar.trim().length ? "56px 1fr" : "1fr",
|
||||||
|
alignItems: "start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dialog.avatar.trim().length ? (
|
||||||
|
<img
|
||||||
|
src={dialog.avatar}
|
||||||
|
alt={dialog.speaker || "speaker"}
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: "50%",
|
||||||
|
objectFit: "cover",
|
||||||
|
border: "2px solid rgba(125, 211, 252, 0.55)",
|
||||||
|
background: "#0f172a",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 18,
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.24)",
|
||||||
|
background: "rgba(15, 23, 42, 0.92)",
|
||||||
|
padding: "14px 16px",
|
||||||
|
color: "#f8fafc",
|
||||||
|
boxShadow: "0 14px 36px rgba(2, 6, 23, 0.38)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dialog.speaker?.trim() ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 6,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#7dd3fc",
|
||||||
|
fontWeight: 900,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dialog.speaker}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dialog.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{subtitle?.trim() ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
bottom: 90,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
maxWidth: 720,
|
||||||
|
borderRadius: 999,
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.24)",
|
||||||
|
background: "rgba(2, 6, 23, 0.84)",
|
||||||
|
color: "#f8fafc",
|
||||||
|
padding: "10px 18px",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
textAlign: "center",
|
||||||
|
boxShadow: "0 12px 32px rgba(2, 6, 23, 0.28)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isPreviewMode ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 64,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
width: "min(520px, calc(100% - 72px))",
|
||||||
|
borderRadius: 18,
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.24)",
|
||||||
|
background: "rgba(15, 23, 42, 0.9)",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
padding: "12px 14px",
|
||||||
|
boxShadow: "0 12px 32px rgba(2, 6, 23, 0.3)",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "grid", gap: 6, minWidth: 0 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "3px 8px",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "rgba(34, 197, 94, 0.2)",
|
||||||
|
color: "#86efac",
|
||||||
|
fontWeight: 900,
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</span>
|
||||||
|
{activeStepLabel ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "#f8fafc",
|
||||||
|
overflowWrap: "anywhere",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeStepLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span style={{ fontSize: 12, color: "#94a3b8" }}>
|
||||||
|
x{playbackSpeed.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{totalSteps > 0 ? (
|
||||||
|
<div style={{ display: "grid", gap: 6 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "rgba(51, 65, 85, 0.8)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${Math.max(0, Math.min(100, ((activeStepNumber || 0) / totalSteps) * 100))}%`,
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "linear-gradient(90deg, #22c55e, #38bdf8)",
|
||||||
|
transition: "width 180ms ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#94a3b8" }}>
|
||||||
|
Step {activeStepNumber || 0}/{totalSteps}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, flex: "0 0 auto" }}>
|
||||||
|
{isPlaying ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onStopPreview}
|
||||||
|
style={previewButtonStyle("#7f1d1d")}
|
||||||
|
>
|
||||||
|
Dừng
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onResetPreview}
|
||||||
|
style={previewButtonStyle("#1e3a8a")}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPlayPreview}
|
||||||
|
style={previewButtonStyle("#166534")}
|
||||||
|
>
|
||||||
|
Phát lại
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onExitPreview}
|
||||||
|
style={previewButtonStyle("#334155")}
|
||||||
|
>
|
||||||
|
Thoát preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -27,6 +27,12 @@ type Props = {
|
|||||||
onMutateReplay: (label: string, mutator: (draftReplay: BattleReplay) => void) => boolean;
|
onMutateReplay: (label: string, mutator: (draftReplay: BattleReplay) => void) => boolean;
|
||||||
onUndoReplay: () => void;
|
onUndoReplay: () => void;
|
||||||
onExitReplay: () => 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";
|
type ActionGroupKey = "use_UI_function" | "use_map_function" | "use_geo_function" | "use_narrow_function";
|
||||||
@@ -72,6 +78,12 @@ export default function ReplayTimelineSidebar({
|
|||||||
onMutateReplay,
|
onMutateReplay,
|
||||||
onUndoReplay,
|
onUndoReplay,
|
||||||
onExitReplay,
|
onExitReplay,
|
||||||
|
isPreviewPlaying,
|
||||||
|
previewPlaybackSpeed,
|
||||||
|
onPlayPreviewFromStart,
|
||||||
|
onPlayPreviewFromSelection,
|
||||||
|
onStopPreview,
|
||||||
|
onResetPreview,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const stages = useMemo(() => replay?.detail || [], [replay?.detail]);
|
const stages = useMemo(() => replay?.detail || [], [replay?.detail]);
|
||||||
const selectedStage =
|
const selectedStage =
|
||||||
@@ -368,6 +380,80 @@ export default function ReplayTimelineSidebar({
|
|||||||
Thoát replay
|
Thoát replay
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: isPreviewPlaying ? "1fr 1fr" : "1fr 1fr",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPlayPreviewFromStart}
|
||||||
|
disabled={!replay || totalSteps === 0}
|
||||||
|
style={{
|
||||||
|
...buttonStyle,
|
||||||
|
background: !replay || totalSteps === 0 ? "#1e293b" : "#166534",
|
||||||
|
border: "none",
|
||||||
|
cursor: !replay || totalSteps === 0 ? "not-allowed" : "pointer",
|
||||||
|
opacity: !replay || totalSteps === 0 ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Play từ đầu
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPlayPreviewFromSelection}
|
||||||
|
disabled={!replay || selectedStage == null || selectedStepIndex == null}
|
||||||
|
style={{
|
||||||
|
...buttonStyle,
|
||||||
|
background:
|
||||||
|
!replay || selectedStage == null || selectedStepIndex == null
|
||||||
|
? "#1e293b"
|
||||||
|
: "#0f766e",
|
||||||
|
border: "none",
|
||||||
|
cursor:
|
||||||
|
!replay || selectedStage == null || selectedStepIndex == null
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
opacity:
|
||||||
|
!replay || selectedStage == null || selectedStepIndex == null
|
||||||
|
? 0.7
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Play từ step
|
||||||
|
</button>
|
||||||
|
{isPreviewPlaying ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onStopPreview}
|
||||||
|
style={{
|
||||||
|
...buttonStyle,
|
||||||
|
background: "#7f1d1d",
|
||||||
|
border: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dừng
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{isPreviewPlaying ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onResetPreview}
|
||||||
|
style={{
|
||||||
|
...buttonStyle,
|
||||||
|
background: "#1e3a8a",
|
||||||
|
border: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset preview
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#94a3b8" }}>
|
||||||
|
Preview sẽ mở trong mode riêng với snapshot replay tại thời điểm bấm play.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
@@ -999,6 +1085,7 @@ const uiOptionLabels: Record<UIOptionName, string> = {
|
|||||||
timeline: "Timeline",
|
timeline: "Timeline",
|
||||||
layer_panel: "Layer Panel",
|
layer_panel: "Layer Panel",
|
||||||
wiki_panel: "Wiki Panel",
|
wiki_panel: "Wiki Panel",
|
||||||
|
close_wiki_panel: "Đóng Wiki Panel",
|
||||||
zoom_panel: "Zoom Panel",
|
zoom_panel: "Zoom Panel",
|
||||||
wiki: "Wiki",
|
wiki: "Wiki",
|
||||||
toast: "Toast",
|
toast: "Toast",
|
||||||
@@ -1008,10 +1095,15 @@ const uiOptionLabels: Record<UIOptionName, string> = {
|
|||||||
|
|
||||||
const narrativeFunctionLabels: Record<NarrativeFunctionName, string> = {
|
const narrativeFunctionLabels: Record<NarrativeFunctionName, string> = {
|
||||||
set_title: "Tiêu đề step",
|
set_title: "Tiêu đề step",
|
||||||
|
clear_title: "Xóa tiêu đề",
|
||||||
set_descriptions: "Mô tả",
|
set_descriptions: "Mô tả",
|
||||||
|
clear_descriptions: "Xóa mô tả",
|
||||||
show_dialog_box: "Dialog box",
|
show_dialog_box: "Dialog box",
|
||||||
|
clear_dialog_box: "Đóng dialog box",
|
||||||
display_historical_image: "Ảnh lịch sử",
|
display_historical_image: "Ảnh lịch sử",
|
||||||
|
clear_historical_image: "Xóa ảnh lịch sử",
|
||||||
set_step_subtitle: "Phụ đề",
|
set_step_subtitle: "Phụ đề",
|
||||||
|
clear_step_subtitle: "Xóa phụ đề",
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapFunctionLabels: Record<MapFunctionName, string> = {
|
const mapFunctionLabels: Record<MapFunctionName, string> = {
|
||||||
@@ -1022,6 +1114,7 @@ const mapFunctionLabels: Record<MapFunctionName, string> = {
|
|||||||
toggle_labels: "Bật/tắt labels",
|
toggle_labels: "Bật/tắt labels",
|
||||||
show_labels: "Hiện labels",
|
show_labels: "Hiện labels",
|
||||||
hide_labels: "Ẩn labels",
|
hide_labels: "Ẩn labels",
|
||||||
|
show_all_geometries: "Hiện tất cả geo",
|
||||||
reset_camera_north: "North up",
|
reset_camera_north: "North up",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1039,7 +1132,7 @@ const geoFunctionLabels: Record<GeoFunctionName, string> = {
|
|||||||
show_geometry_label: "Label geometry",
|
show_geometry_label: "Label geometry",
|
||||||
follow_geometry_path: "Follow path",
|
follow_geometry_path: "Follow path",
|
||||||
follow_geometries_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[] {
|
function buildStepActionEntries(step: ReplayStep): StepActionEntry[] {
|
||||||
@@ -1070,9 +1163,15 @@ function buildNarrativeActionEntry(
|
|||||||
case "set_title":
|
case "set_title":
|
||||||
summary = summarizeValue(params[0], "Tiêu đề trống");
|
summary = summarizeValue(params[0], "Tiêu đề trống");
|
||||||
break;
|
break;
|
||||||
|
case "clear_title":
|
||||||
|
summary = "title=null";
|
||||||
|
break;
|
||||||
case "set_descriptions":
|
case "set_descriptions":
|
||||||
summary = summarizeValue(params[0], "Mô tả trống");
|
summary = summarizeValue(params[0], "Mô tả trống");
|
||||||
break;
|
break;
|
||||||
|
case "clear_descriptions":
|
||||||
|
summary = "descriptions=null";
|
||||||
|
break;
|
||||||
case "show_dialog_box":
|
case "show_dialog_box":
|
||||||
summary = [
|
summary = [
|
||||||
`speaker=${summarizeValue(params[3], "ẩn danh")}`,
|
`speaker=${summarizeValue(params[3], "ẩn danh")}`,
|
||||||
@@ -1080,15 +1179,24 @@ function buildNarrativeActionEntry(
|
|||||||
`text=${summarizeValue(params[1], "trống")}`,
|
`text=${summarizeValue(params[1], "trống")}`,
|
||||||
].join(" | ");
|
].join(" | ");
|
||||||
break;
|
break;
|
||||||
|
case "clear_dialog_box":
|
||||||
|
summary = "dialog=null";
|
||||||
|
break;
|
||||||
case "display_historical_image":
|
case "display_historical_image":
|
||||||
summary = [
|
summary = [
|
||||||
`url=${summarizeValue(params[0], "trống")}`,
|
`url=${summarizeValue(params[0], "trống")}`,
|
||||||
`caption=${summarizeValue(params[1], "trống")}`,
|
`caption=${summarizeValue(params[1], "trống")}`,
|
||||||
].join(" | ");
|
].join(" | ");
|
||||||
break;
|
break;
|
||||||
|
case "clear_historical_image":
|
||||||
|
summary = "image=null";
|
||||||
|
break;
|
||||||
case "set_step_subtitle":
|
case "set_step_subtitle":
|
||||||
summary = summarizeValue(params[0], "Ẩn subtitle");
|
summary = summarizeValue(params[0], "Ẩn subtitle");
|
||||||
break;
|
break;
|
||||||
|
case "clear_step_subtitle":
|
||||||
|
summary = "subtitle=null";
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1129,6 +1237,9 @@ function buildMapActionEntry(
|
|||||||
case "hide_labels":
|
case "hide_labels":
|
||||||
summary = "visible=false";
|
summary = "visible=false";
|
||||||
break;
|
break;
|
||||||
|
case "show_all_geometries":
|
||||||
|
summary = "hidden_ids=[]";
|
||||||
|
break;
|
||||||
case "set_camera_view":
|
case "set_camera_view":
|
||||||
summary = summarizeCameraViewValue(params[0]);
|
summary = summarizeCameraViewValue(params[0]);
|
||||||
break;
|
break;
|
||||||
@@ -1248,8 +1359,7 @@ function buildGeoActionEntry(
|
|||||||
break;
|
break;
|
||||||
case "dim_other_geometries":
|
case "dim_other_geometries":
|
||||||
summary = [
|
summary = [
|
||||||
`focus=${summarizeGeometryIdsValue(params[0])}`,
|
`keep=${summarizeGeometryIdsValue(params[0])}`,
|
||||||
`other_opacity=${summarizeValue(params[1], "mặc định")}`,
|
|
||||||
].join(" | ");
|
].join(" | ");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1278,6 +1388,8 @@ function buildUiActionEntry(
|
|||||||
|
|
||||||
if (option === "timeline" || option === "layer_panel" || option === "wiki_panel" || option === "zoom_panel") {
|
if (option === "timeline" || option === "layer_panel" || option === "wiki_panel" || option === "zoom_panel") {
|
||||||
summary = `visible=${Boolean(params[0]) ? "true" : "false"}`;
|
summary = `visible=${Boolean(params[0]) ? "true" : "false"}`;
|
||||||
|
} else if (option === "close_wiki_panel") {
|
||||||
|
summary = "visible=false | active_wiki=null";
|
||||||
} else if (option === "wiki") {
|
} else if (option === "wiki") {
|
||||||
summary = `wiki_id=${summarizeValue(params[0], "trống")}`;
|
summary = `wiki_id=${summarizeValue(params[0], "trống")}`;
|
||||||
} else if (option === "toast") {
|
} else if (option === "toast") {
|
||||||
@@ -1342,6 +1454,7 @@ function normalizeUiOptionValue(value: unknown): UIOptionName | null {
|
|||||||
case "timeline":
|
case "timeline":
|
||||||
case "layer_panel":
|
case "layer_panel":
|
||||||
case "wiki_panel":
|
case "wiki_panel":
|
||||||
|
case "close_wiki_panel":
|
||||||
case "zoom_panel":
|
case "zoom_panel":
|
||||||
case "wiki":
|
case "wiki":
|
||||||
case "toast":
|
case "toast":
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export type UIOptionName =
|
|||||||
| "timeline"
|
| "timeline"
|
||||||
| "layer_panel"
|
| "layer_panel"
|
||||||
| "wiki_panel"
|
| "wiki_panel"
|
||||||
|
| "close_wiki_panel"
|
||||||
| "zoom_panel"
|
| "zoom_panel"
|
||||||
| "wiki"
|
| "wiki"
|
||||||
| "toast"
|
| "toast"
|
||||||
@@ -152,6 +153,7 @@ export type MapFunctionName =
|
|||||||
| "toggle_labels"
|
| "toggle_labels"
|
||||||
| "show_labels"
|
| "show_labels"
|
||||||
| "hide_labels"
|
| "hide_labels"
|
||||||
|
| "show_all_geometries"
|
||||||
| "reset_camera_north";
|
| "reset_camera_north";
|
||||||
|
|
||||||
export type GeoFunctionName =
|
export type GeoFunctionName =
|
||||||
@@ -172,10 +174,15 @@ export type GeoFunctionName =
|
|||||||
|
|
||||||
export type NarrativeFunctionName =
|
export type NarrativeFunctionName =
|
||||||
| "set_title"
|
| "set_title"
|
||||||
|
| "clear_title"
|
||||||
| "set_descriptions"
|
| "set_descriptions"
|
||||||
|
| "clear_descriptions"
|
||||||
| "show_dialog_box"
|
| "show_dialog_box"
|
||||||
|
| "clear_dialog_box"
|
||||||
| "display_historical_image"
|
| "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.
|
* Runtime thật hiện dùng positional array cho params.
|
||||||
@@ -228,6 +235,7 @@ export type ReplayUiParamTupleDocs = {
|
|||||||
timeline: [visible: boolean];
|
timeline: [visible: boolean];
|
||||||
layer_panel: [visible: boolean];
|
layer_panel: [visible: boolean];
|
||||||
wiki_panel: [visible: boolean];
|
wiki_panel: [visible: boolean];
|
||||||
|
close_wiki_panel: [];
|
||||||
zoom_panel: [visible: boolean];
|
zoom_panel: [visible: boolean];
|
||||||
wiki: [wiki_id: string];
|
wiki: [wiki_id: string];
|
||||||
toast: [message: string];
|
toast: [message: string];
|
||||||
@@ -248,6 +256,7 @@ export type ReplayMapFunctionParamTupleDocs = {
|
|||||||
toggle_labels: [visible: boolean];
|
toggle_labels: [visible: boolean];
|
||||||
show_labels: [];
|
show_labels: [];
|
||||||
hide_labels: [];
|
hide_labels: [];
|
||||||
|
show_all_geometries: [];
|
||||||
reset_camera_north: [];
|
reset_camera_north: [];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -314,24 +323,28 @@ export type ReplayGeoFunctionParamTupleDocs = {
|
|||||||
];
|
];
|
||||||
dim_other_geometries: [
|
dim_other_geometries: [
|
||||||
geometry_ids: string[],
|
geometry_ids: string[],
|
||||||
opacity?: number,
|
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReplayNarrativeParamTupleDocs = {
|
export type ReplayNarrativeParamTupleDocs = {
|
||||||
set_title: [title: string];
|
set_title: [title: string];
|
||||||
|
clear_title: [];
|
||||||
set_descriptions: [text: string];
|
set_descriptions: [text: string];
|
||||||
|
clear_descriptions: [];
|
||||||
show_dialog_box: [
|
show_dialog_box: [
|
||||||
avatar: string,
|
avatar: string,
|
||||||
text: string,
|
text: string,
|
||||||
side?: "left" | "right",
|
side?: "left" | "right",
|
||||||
speaker?: string,
|
speaker?: string,
|
||||||
];
|
];
|
||||||
|
clear_dialog_box: [];
|
||||||
display_historical_image: [
|
display_historical_image: [
|
||||||
url: string,
|
url: string,
|
||||||
caption?: string,
|
caption?: string,
|
||||||
];
|
];
|
||||||
|
clear_historical_image: [];
|
||||||
set_step_subtitle: [subtitle: string | null];
|
set_step_subtitle: [subtitle: string | null];
|
||||||
|
clear_step_subtitle: [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReplayParamTupleDocs =
|
export type ReplayParamTupleDocs =
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ export type EditorMode =
|
|||||||
| "add-line"
|
| "add-line"
|
||||||
| "add-path"
|
| "add-path"
|
||||||
| "add-circle"
|
| "add-circle"
|
||||||
| "replay";
|
| "replay"
|
||||||
|
| "replay_preview";
|
||||||
|
|
||||||
export type TimelineRange = {
|
export type TimelineRange = {
|
||||||
min: number;
|
min: number;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type maplibregl from "maplibre-gl";
|
import type maplibregl from "maplibre-gl";
|
||||||
import type { FeatureCollection } from "@/uhm/types/geo";
|
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.
|
* 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
|
// 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) => {
|
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;
|
if (!feature) return;
|
||||||
|
|
||||||
// Tính toán bounds từ geometry (giả định có helper hoặc dùng bbox của feature)
|
const bbox = getFeatureCollectionBBox({
|
||||||
// Ở đây tạm dùng center đơn giản nếu là Point, hoặc bounds nếu là đa giác
|
type: "FeatureCollection",
|
||||||
if (feature.geometry.type === "Point") {
|
features: [feature],
|
||||||
map.flyTo({
|
});
|
||||||
center: feature.geometry.coordinates as [number, number],
|
if (!bbox) return;
|
||||||
zoom: Math.max(map.getZoom(), 10),
|
|
||||||
duration: 3000,
|
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,
|
||||||
});
|
});
|
||||||
} 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 });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Ẩn/hiện nhãn (labels) trên bản đồ
|
// Ẩn/hiện nhãn (labels) trên bản đồ
|
||||||
|
|||||||
@@ -8,23 +8,71 @@ export const narrativeActions = {
|
|||||||
setTitle(title);
|
setTitle(title);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clear_title: (setTitle: (t: string) => void) => {
|
||||||
|
setTitle("");
|
||||||
|
},
|
||||||
|
|
||||||
// Đặt nội dung mô tả chi tiết
|
// Đặt nội dung mô tả chi tiết
|
||||||
set_descriptions: (setDesc: (d: string) => void, descriptions: string) => {
|
set_descriptions: (setDesc: (d: string) => void, descriptions: string) => {
|
||||||
setDesc(descriptions);
|
setDesc(descriptions);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clear_descriptions: (setDesc: (d: string) => void) => {
|
||||||
|
setDesc("");
|
||||||
|
},
|
||||||
|
|
||||||
// Hiển thị hộp thoại hội thoại (Dialogue)
|
// 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) => {
|
show_dialog_box: (
|
||||||
setDialog({ avatar, text, side: 'left' });
|
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 đồ
|
// Hiển thị hình ảnh lịch sử đè lên bản đồ
|
||||||
display_historical_image: (setImage: (url: string | null) => void, imageUrl: string) => {
|
display_historical_image: (
|
||||||
setImage(imageUrl);
|
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)
|
// Hiển thị phụ đề (Subtitle)
|
||||||
set_step_subtitle: (setSubtitle: (s: string | null) => void, subtitle: string) => {
|
set_step_subtitle: (setSubtitle: (s: string | null) => void, subtitle: string) => {
|
||||||
setSubtitle(subtitle);
|
setSubtitle(subtitle);
|
||||||
}
|
},
|
||||||
|
|
||||||
|
clear_step_subtitle: (setSubtitle: (s: string | null) => void) => {
|
||||||
|
setSubtitle(null);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,17 +21,27 @@ export interface ReplayControllers {
|
|||||||
|
|
||||||
// UI Setters
|
// UI Setters
|
||||||
setTimelineVisible: (v: boolean) => void;
|
setTimelineVisible: (v: boolean) => void;
|
||||||
|
setTimelineFilterEnabled: (v: boolean) => void;
|
||||||
setSidebarOpen: (v: boolean) => void;
|
setSidebarOpen: (v: boolean) => void;
|
||||||
onSelectWiki: (id: string) => void;
|
onSelectWiki: (id: string) => void;
|
||||||
addToast: (msg: string) => void;
|
addToast: (msg: string) => void;
|
||||||
setPlaybackSpeed: (s: number) => void;
|
setPlaybackSpeed: (s: number) => void;
|
||||||
onYearChange: (y: number) => void;
|
onYearChange: (y: number) => void;
|
||||||
|
showGeometries: (ids: string[]) => void;
|
||||||
|
hideGeometries: (ids: string[]) => void;
|
||||||
|
showOnlyGeometries: (ids: string[]) => void;
|
||||||
|
showAllGeometries: () => void;
|
||||||
|
|
||||||
// Narrative Setters
|
// Narrative Setters
|
||||||
setTitle: (t: string) => void;
|
setTitle: (t: string) => void;
|
||||||
setDescriptions: (d: string) => void;
|
setDescriptions: (d: string) => void;
|
||||||
setDialog: (data: { avatar: string; text: string; side: "left" | "right" }) => void;
|
setDialog: (data: {
|
||||||
setImage: (url: string | null) => void;
|
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;
|
setSubtitle: (s: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +72,14 @@ export const dispatchReplayAction = (
|
|||||||
controllers.draft,
|
controllers.draft,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
case "fly_to_geometries":
|
||||||
|
mapActions.fly_to_geometries(
|
||||||
|
map,
|
||||||
|
toStringValues(params[0]),
|
||||||
|
controllers.draft,
|
||||||
|
asNumberValue(params[1], 2200)
|
||||||
|
);
|
||||||
|
return;
|
||||||
case "toggle_labels":
|
case "toggle_labels":
|
||||||
mapActions.toggle_labels(map, asBooleanValue(params[0], true));
|
mapActions.toggle_labels(map, asBooleanValue(params[0], true));
|
||||||
return;
|
return;
|
||||||
@@ -71,27 +89,79 @@ export const dispatchReplayAction = (
|
|||||||
case "hide_labels":
|
case "hide_labels":
|
||||||
mapActions.toggle_labels(map, false);
|
mapActions.toggle_labels(map, false);
|
||||||
return;
|
return;
|
||||||
|
case "show_all_geometries":
|
||||||
|
controllers.showAllGeometries();
|
||||||
|
return;
|
||||||
case "set_time_filter":
|
case "set_time_filter":
|
||||||
mapActions.set_time_filter(controllers.onYearChange, asNumberValue(params[0], 0));
|
mapActions.set_time_filter(controllers.onYearChange, asNumberValue(params[0], 0));
|
||||||
return;
|
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":
|
case "reset_camera_north":
|
||||||
mapActions.set_camera_view(map, { bearing: 0 });
|
mapActions.set_camera_view(map, { bearing: 0 });
|
||||||
return;
|
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 "pulse_geometry":
|
||||||
case "animate_dashed_border":
|
case "animate_dashed_border":
|
||||||
case "set_geometry_style":
|
case "set_geometry_style":
|
||||||
case "show_geometry_label":
|
case "show_geometry_label":
|
||||||
case "follow_geometry_path":
|
return;
|
||||||
case "follow_geometries_path":
|
|
||||||
case "dim_other_geometries":
|
case "dim_other_geometries":
|
||||||
|
controllers.showOnlyGeometries(toStringValues(params[0]));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,6 +180,9 @@ export const dispatchReplayAction = (
|
|||||||
case "wiki_panel":
|
case "wiki_panel":
|
||||||
uiActions.wiki_panel(controllers.setSidebarOpen, Boolean(payload[0] ?? false));
|
uiActions.wiki_panel(controllers.setSidebarOpen, Boolean(payload[0] ?? false));
|
||||||
return;
|
return;
|
||||||
|
case "close_wiki_panel":
|
||||||
|
uiActions.close_wiki_panel(controllers.setSidebarOpen, controllers.onSelectWiki);
|
||||||
|
return;
|
||||||
case "zoom_panel":
|
case "zoom_panel":
|
||||||
uiActions.zoom_panel(Boolean(payload[0] ?? false));
|
uiActions.zoom_panel(Boolean(payload[0] ?? false));
|
||||||
return;
|
return;
|
||||||
@@ -143,22 +216,43 @@ export const dispatchReplayAction = (
|
|||||||
case "set_title":
|
case "set_title":
|
||||||
narrativeActions.set_title(controllers.setTitle, asStringValue(params[0]));
|
narrativeActions.set_title(controllers.setTitle, asStringValue(params[0]));
|
||||||
return;
|
return;
|
||||||
|
case "clear_title":
|
||||||
|
narrativeActions.clear_title(controllers.setTitle);
|
||||||
|
return;
|
||||||
case "set_descriptions":
|
case "set_descriptions":
|
||||||
narrativeActions.set_descriptions(controllers.setDescriptions, asStringValue(params[0]));
|
narrativeActions.set_descriptions(controllers.setDescriptions, asStringValue(params[0]));
|
||||||
return;
|
return;
|
||||||
|
case "clear_descriptions":
|
||||||
|
narrativeActions.clear_descriptions(controllers.setDescriptions);
|
||||||
|
return;
|
||||||
case "show_dialog_box":
|
case "show_dialog_box":
|
||||||
narrativeActions.show_dialog_box(
|
narrativeActions.show_dialog_box(
|
||||||
controllers.setDialog,
|
controllers.setDialog,
|
||||||
asStringValue(params[0]),
|
asStringValue(params[0]),
|
||||||
asStringValue(params[1])
|
asStringValue(params[1]),
|
||||||
|
normalizeDialogSide(params[2]),
|
||||||
|
nullableStringValue(params[3])
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
case "clear_dialog_box":
|
||||||
|
narrativeActions.clear_dialog_box(controllers.setDialog);
|
||||||
|
return;
|
||||||
case "display_historical_image":
|
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;
|
return;
|
||||||
case "set_step_subtitle":
|
case "set_step_subtitle":
|
||||||
narrativeActions.set_step_subtitle(controllers.setSubtitle, asStringValue(params[0]));
|
narrativeActions.set_step_subtitle(controllers.setSubtitle, asStringValue(params[0]));
|
||||||
return;
|
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 "timeline":
|
||||||
case "layer_panel":
|
case "layer_panel":
|
||||||
case "wiki_panel":
|
case "wiki_panel":
|
||||||
|
case "close_wiki_panel":
|
||||||
case "zoom_panel":
|
case "zoom_panel":
|
||||||
case "wiki":
|
case "wiki":
|
||||||
case "toast":
|
case "toast":
|
||||||
@@ -235,10 +330,19 @@ function asStringValue(value: unknown) {
|
|||||||
return typeof value === "string" ? value : value == null ? "" : String(value);
|
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) {
|
function asBooleanValue(value: unknown, fallback: boolean) {
|
||||||
return typeof value === "boolean" ? value : fallback;
|
return typeof value === "boolean" ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDialogSide(value: unknown): "left" | "right" {
|
||||||
|
return value === "right" ? "right" : "left";
|
||||||
|
}
|
||||||
|
|
||||||
function asOptionalNumberValue(value: unknown) {
|
function asOptionalNumberValue(value: unknown) {
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||||
}
|
}
|
||||||
@@ -246,3 +350,12 @@ function asOptionalNumberValue(value: unknown) {
|
|||||||
function asNumberValue(value: unknown, fallback: number) {
|
function asNumberValue(value: unknown, fallback: number) {
|
||||||
return asOptionalNumberValue(value) ?? fallback;
|
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);
|
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.
|
// Ẩn/hiện panel zoom. Runtime hiện chưa có controller riêng nên tạm no-op.
|
||||||
zoom_panel: (visible: boolean) => {
|
zoom_panel: (visible: boolean) => {
|
||||||
void visible;
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -93,6 +93,7 @@ export type UIOptionName =
|
|||||||
| "timeline" // Ẩn/hiện timeline
|
| "timeline" // Ẩn/hiện timeline
|
||||||
| "layer_panel" // Ẩn/hiện panel layer
|
| "layer_panel" // Ẩn/hiện panel layer
|
||||||
| "wiki_panel" // Ẩn/hiện panel wiki
|
| "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
|
| "zoom_panel" // Ẩn/hiện nút zoom
|
||||||
| "wiki" // Mở/chọn wiki
|
| "wiki" // Mở/chọn wiki
|
||||||
| "toast" // Hiển thị toast
|
| "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 đồ
|
| "toggle_labels" // Legacy: bật/tắt hiển thị nhãn (labels) trên bản đồ
|
||||||
| "show_labels" // Hiện labels
|
| "show_labels" // Hiện labels
|
||||||
| "hide_labels" // Ẩ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
|
| "reset_camera_north"; // Đưa camera về hướng bắc
|
||||||
|
|
||||||
export type GeoFunctionName =
|
export type GeoFunctionName =
|
||||||
@@ -123,14 +125,19 @@ export type GeoFunctionName =
|
|||||||
| "show_geometry_label" // Hiện label riêng cho geometry
|
| "show_geometry_label" // Hiện label riêng cho geometry
|
||||||
| "follow_geometry_path" // Legacy: cho camera bám theo một path 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
|
| "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 =
|
export type NarrativeFunctionName =
|
||||||
| "set_title" // Đặt tiêu đề cho bước replay
|
| "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
|
| "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)
|
| "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 đồ
|
| "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<T> = {
|
export type ReplayAction<T> = {
|
||||||
function_name: T;
|
function_name: T;
|
||||||
|
|||||||
Reference in New Issue
Block a user