feat: implement replay system with action dispatchers and context switching between main and playback modes
This commit is contained in:
@@ -188,12 +188,16 @@ export default function Page() {
|
|||||||
}, [snapshotEntityWikiLinks]);
|
}, [snapshotEntityWikiLinks]);
|
||||||
|
|
||||||
const editor = useEditorState(initialData, {
|
const editor = useEditorState(initialData, {
|
||||||
|
snapshotUndo: {
|
||||||
snapshotEntitiesRef,
|
snapshotEntitiesRef,
|
||||||
setSnapshotEntities,
|
setSnapshotEntities,
|
||||||
snapshotWikisRef,
|
snapshotWikisRef,
|
||||||
setSnapshotWikis,
|
setSnapshotWikis,
|
||||||
snapshotEntityWikiLinksRef,
|
snapshotEntityWikiLinksRef,
|
||||||
setSnapshotEntityWikiLinks,
|
setSnapshotEntityWikiLinks,
|
||||||
|
},
|
||||||
|
initialReplays: baselineSnapshot?.replays,
|
||||||
|
mode: mode,
|
||||||
});
|
});
|
||||||
const setSnapshotWikisUndoable = useCallback(
|
const setSnapshotWikisUndoable = useCallback(
|
||||||
(next: SetStateAction<WikiSnapshot[]>) => {
|
(next: SetStateAction<WikiSnapshot[]>) => {
|
||||||
@@ -266,16 +270,19 @@ export default function Page() {
|
|||||||
// 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(() => {
|
||||||
if (!timelineFilterEnabled) return editor.draft;
|
// Nếu ở mode replay, sử dụng replayDraft thay vì main draft
|
||||||
|
const activeDraft = mode === "replay" ? editor.replayDraft : editor.mainDraft;
|
||||||
|
|
||||||
|
if (!timelineFilterEnabled) return activeDraft;
|
||||||
const year = clampYearToFixedRange(Math.trunc(timelineDraftYear));
|
const year = clampYearToFixedRange(Math.trunc(timelineDraftYear));
|
||||||
return {
|
return {
|
||||||
...editor.draft,
|
...activeDraft,
|
||||||
features: editor.draft.features.filter((feature) => {
|
features: activeDraft.features.filter((feature) => {
|
||||||
if (!editor.hasPersistedFeature(feature.properties.id)) return true;
|
if (!editor.hasPersistedFeature(feature.properties.id)) return true;
|
||||||
return isFeatureVisibleAtYear(feature, year);
|
return isFeatureVisibleAtYear(feature, year);
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}, [editor, timelineDraftYear, timelineFilterEnabled]);
|
}, [editor, mode, timelineDraftYear, timelineFilterEnabled]);
|
||||||
|
|
||||||
const projectEntityChoices = useMemo(() => {
|
const projectEntityChoices = useMemo(() => {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
@@ -412,36 +419,38 @@ export default function Page() {
|
|||||||
|
|
||||||
const setMode = useCallback((m: EditorMode, featureId?: string | number) => {
|
const setMode = useCallback((m: EditorMode, featureId?: string | number) => {
|
||||||
if (m === "replay" && featureId) {
|
if (m === "replay" && featureId) {
|
||||||
setReplayFeatureId(featureId);
|
// QUY TẮC: Geo chọn đầu tiên là geo main.
|
||||||
|
const triggerId = selectedFeatureIds.length > 0 ? selectedFeatureIds[0] : featureId;
|
||||||
|
setReplayFeatureId(triggerId);
|
||||||
|
editor.switchReplayContext(triggerId, selectedFeatureIds);
|
||||||
} else if (m !== "replay") {
|
} else if (m !== "replay") {
|
||||||
|
if (mode === "replay") {
|
||||||
|
editor.closeReplayContext();
|
||||||
|
setSelectedFeatureIds([]);
|
||||||
|
}
|
||||||
setReplayFeatureId(null);
|
setReplayFeatureId(null);
|
||||||
setHideOutside(false);
|
setHideOutside(false);
|
||||||
}
|
}
|
||||||
internalSetMode(m);
|
internalSetMode(m);
|
||||||
}, [internalSetMode]);
|
}, [internalSetMode, mode, editor, selectedFeatureIds]);
|
||||||
|
|
||||||
const effectiveGeometryVisibility = useMemo(() => {
|
const effectiveGeometryVisibility = useMemo(() => {
|
||||||
const visibility: Record<string, boolean> = { ...geometryVisibility };
|
const visibility: Record<string, boolean> = { ...geometryVisibility };
|
||||||
|
|
||||||
if (mode === "replay" && replayFeatureId) {
|
if (mode === "replay" && replayFeatureId) {
|
||||||
// Ẩn chính geo được chọn làm replay
|
// Ẩ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 (hideOutside) {
|
||||||
// Tìm feature đang replay để lấy danh sách binding
|
// Trong mode replay, ta chỉ hiển thị những gì có trong draft của replay đó
|
||||||
const replayFeature = editor.draft.features.find(
|
const currentReplayFeatureIds = new Set(editor.draft.features.map(f => String(f.properties.id)));
|
||||||
(f) => String(f.properties.id) === String(replayFeatureId)
|
|
||||||
);
|
|
||||||
const boundIds = new Set<string>();
|
|
||||||
if (replayFeature?.properties?.binding) {
|
|
||||||
replayFeature.properties.binding.forEach((id: string) => boundIds.add(String(id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ẩn tất cả các geo không nằm trong binding
|
// Ẩn tất cả các geo KHÔNG nằm trong draft replay hiện tại
|
||||||
editor.draft.features.forEach((f) => {
|
Object.keys(visibility).forEach(fid => {
|
||||||
const fid = String(f.properties.id);
|
if (fid === String(replayFeatureId)) {
|
||||||
if (fid !== String(replayFeatureId) && !boundIds.has(fid)) {
|
|
||||||
visibility[fid] = false;
|
visibility[fid] = false;
|
||||||
|
} else {
|
||||||
|
visibility[fid] = currentReplayFeatureIds.has(fid);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1686,6 +1695,7 @@ export default function Page() {
|
|||||||
isEntitySubmitting={isEntitySubmitting}
|
isEntitySubmitting={isEntitySubmitting}
|
||||||
onApplyGeometryMetadata={featureCommands.applyGeometryMetadata}
|
onApplyGeometryMetadata={featureCommands.applyGeometryMetadata}
|
||||||
changeCount={editor.changeCount}
|
changeCount={editor.changeCount}
|
||||||
|
onReplayEdit={(id) => setMode("replay", id)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { type CSSProperties, useEffect, useRef } from "react";
|
import { type CSSProperties, useEffect, useRef, forwardRef, useImperativeHandle, useCallback } 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";
|
||||||
@@ -19,6 +19,16 @@ export type MapHoverPayload = {
|
|||||||
lngLat: { lng: number; lat: number };
|
lngLat: { lng: number; lat: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MapHandle = {
|
||||||
|
getViewState: () => {
|
||||||
|
center: { lng: number; lat: number };
|
||||||
|
zoom: number;
|
||||||
|
pitch: number;
|
||||||
|
bearing: number;
|
||||||
|
projection: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
type MapProps = {
|
type MapProps = {
|
||||||
mode: EditorMode;
|
mode: EditorMode;
|
||||||
draft: FeatureCollection;
|
draft: FeatureCollection;
|
||||||
@@ -45,7 +55,7 @@ type MapProps = {
|
|||||||
onToggleHideOutside?: () => void;
|
onToggleHideOutside?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Map({
|
const Map = forwardRef<MapHandle, MapProps>(function Map({
|
||||||
mode,
|
mode,
|
||||||
onSetMode,
|
onSetMode,
|
||||||
draft,
|
draft,
|
||||||
@@ -69,7 +79,7 @@ export default function Map({
|
|||||||
focusPadding,
|
focusPadding,
|
||||||
hideOutside = false,
|
hideOutside = false,
|
||||||
onToggleHideOutside,
|
onToggleHideOutside,
|
||||||
}: MapProps) {
|
}, ref) {
|
||||||
const modeRef = useRef<MapProps["mode"]>(mode);
|
const modeRef = useRef<MapProps["mode"]>(mode);
|
||||||
const draftRef = useRef<FeatureCollection>(draft);
|
const draftRef = useRef<FeatureCollection>(draft);
|
||||||
const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
|
const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
|
||||||
@@ -100,8 +110,21 @@ export default function Map({
|
|||||||
geolocationCenteredRef,
|
geolocationCenteredRef,
|
||||||
handleZoomByStep,
|
handleZoomByStep,
|
||||||
handleZoomSliderChange,
|
handleZoomSliderChange,
|
||||||
|
getViewState,
|
||||||
} = useMapInstance();
|
} = useMapInstance();
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
getViewState,
|
||||||
|
}), [getViewState]);
|
||||||
|
|
||||||
|
const handleLogViewState = useCallback(() => {
|
||||||
|
const state = getViewState();
|
||||||
|
console.log("Current Map View State:", state);
|
||||||
|
if (state) {
|
||||||
|
alert(`Captured View State:\nCenter: ${state.center.lng.toFixed(4)}, ${state.center.lat.toFixed(4)}\nZoom: ${state.zoom.toFixed(2)}\nPitch: ${state.pitch.toFixed(1)}°\nBearing: ${state.bearing.toFixed(1)}°\nProjection: ${state.projection}`);
|
||||||
|
}
|
||||||
|
}, [getViewState]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
editingEngineRef,
|
editingEngineRef,
|
||||||
setupMapInteractions,
|
setupMapInteractions,
|
||||||
@@ -251,6 +274,27 @@ export default function Map({
|
|||||||
Thoát Replay Edit
|
Thoát Replay Edit
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onClick={onToggleHideOutside}
|
onClick={onToggleHideOutside}
|
||||||
style={{
|
style={{
|
||||||
@@ -406,7 +450,9 @@ export default function Map({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default Map;
|
||||||
|
|
||||||
const zoomButtonStyle: React.CSSProperties = {
|
const zoomButtonStyle: React.CSSProperties = {
|
||||||
width: "28px",
|
width: "28px",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type Props = {
|
|||||||
isEntitySubmitting: boolean;
|
isEntitySubmitting: boolean;
|
||||||
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
|
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
|
||||||
changeCount: number;
|
changeCount: number;
|
||||||
|
onReplayEdit?: (id: string | number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SelectedGeometryPanel({
|
export default function SelectedGeometryPanel({
|
||||||
@@ -30,6 +31,7 @@ export default function SelectedGeometryPanel({
|
|||||||
isEntitySubmitting,
|
isEntitySubmitting,
|
||||||
onApplyGeometryMetadata,
|
onApplyGeometryMetadata,
|
||||||
changeCount,
|
changeCount,
|
||||||
|
onReplayEdit,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [geoApplyFeedback, setGeoApplyFeedback] = useState<
|
const [geoApplyFeedback, setGeoApplyFeedback] = useState<
|
||||||
@@ -201,6 +203,20 @@ export default function SelectedGeometryPanel({
|
|||||||
>
|
>
|
||||||
Apply
|
Apply
|
||||||
</button>
|
</button>
|
||||||
|
{onReplayEdit && selectedFeatures.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onReplayEdit(selectedFeatures[0].properties.id)}
|
||||||
|
style={{
|
||||||
|
...primaryGeometryButtonStyle,
|
||||||
|
background: "#1e293b",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
color: "#38bdf8",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Replay Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{visibleGeoApplyFeedback ? (
|
{visibleGeoApplyFeedback ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -125,6 +125,20 @@ export function useMapInstance() {
|
|||||||
setZoomLevel(next);
|
setZoomLevel(next);
|
||||||
}, [zoomBounds]);
|
}, [zoomBounds]);
|
||||||
|
|
||||||
|
const getViewState = useCallback(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return null;
|
||||||
|
const center = map.getCenter();
|
||||||
|
const projection = map.getProjection();
|
||||||
|
return {
|
||||||
|
center: { lng: center.lng, lat: center.lat },
|
||||||
|
zoom: map.getZoom(),
|
||||||
|
pitch: map.getPitch(),
|
||||||
|
bearing: map.getBearing(),
|
||||||
|
projection: String(projection?.type || "mercator"),
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mapRef,
|
mapRef,
|
||||||
containerRef,
|
containerRef,
|
||||||
@@ -138,5 +152,6 @@ export function useMapInstance() {
|
|||||||
geolocationCenteredRef,
|
geolocationCenteredRef,
|
||||||
handleZoomByStep,
|
handleZoomByStep,
|
||||||
handleZoomSliderChange,
|
handleZoomSliderChange,
|
||||||
|
getViewState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export function useMapInteraction({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "select" || !selectedFeatureIds || selectedFeatureIds.length === 0) {
|
if (mode !== "select" || !selectedFeatureIds || selectedFeatureIds.length === 0) {
|
||||||
editingEngineRef.current?.clearEditing();
|
editingEngineRef.current?.clearEditing();
|
||||||
|
// Clear the internal selection state of the select engine to stay in sync with React state
|
||||||
|
engineBindingsRef.current.select?.clearSelection?.(false);
|
||||||
}
|
}
|
||||||
}, [mode, selectedFeatureIds]);
|
}, [mode, selectedFeatureIds]);
|
||||||
|
|
||||||
|
|||||||
@@ -32,24 +32,38 @@ export type CommitSnapshot = {
|
|||||||
// ---- Replay / Scripting System ----
|
// ---- Replay / Scripting System ----
|
||||||
|
|
||||||
export type UIFunctionName =
|
export type UIFunctionName =
|
||||||
| "hide_timeline"
|
| "hide_timeline" // Ẩn thanh timeline
|
||||||
| "hide_layer_panel"
|
| "hide_layer_panel" // Ẩn panel lớp bản đồ
|
||||||
| "hide_wiki_panel"
|
| "hide_wiki_panel" // Ẩn panel wiki (bên phải)
|
||||||
| "hide_zoom_panel"
|
| "hide_zoom_panel" // Ẩn các nút điều khiển zoom
|
||||||
| "hide_all_UI"
|
| "hide_all_UI" // Ẩn toàn bộ giao diện điều khiển (cinematic mode)
|
||||||
| "open_wiki";
|
| "open_wiki" // Mở panel wiki
|
||||||
|
| "show_toast_message" // Hiển thị thông báo ngắn (toast)
|
||||||
|
| "focus_wiki_header" // Cuộn đến đề mục cụ thể trong Wiki
|
||||||
|
| "set_playback_speed"; // Thay đổi tốc độ phát replay
|
||||||
|
|
||||||
export type MapFunctionName =
|
export type MapFunctionName =
|
||||||
| "zoom_to_lnglat"
|
| "zoom_to_lnglat" // Di chuyển camera đến tọa độ [lng, lat]
|
||||||
| "zoom_scale"
|
| "zoom_scale" // Thay đổi mức zoom của bản đồ
|
||||||
| "zoom_geometries"
|
| "zoom_geometries" // Zoom bao quát danh sách các geometry
|
||||||
| "change_geometry_color"
|
| "change_geometry_color" // Thay đổi màu của một geometry
|
||||||
| "change_geometries_color"
|
| "change_geometries_color" // Thay đổi màu của danh sách geometry
|
||||||
| "change_geometry_texture"
|
| "change_geometry_texture" // Thay đổi texture của một geometry
|
||||||
| "change_geometries_texture"
|
| "change_geometries_texture"// Thay đổi texture của danh sách geometry
|
||||||
| "hide_geometries";
|
| "hide_geometries" // Ẩn danh sách các geometry
|
||||||
|
| "set_camera_view" // Đặt trạng thái camera (center, zoom, pitch, bearing)
|
||||||
|
| "fly_to_geometry" // Di chuyển mượt mà đến một geometry
|
||||||
|
| "rotate_around_point" // Xoay camera quanh một điểm
|
||||||
|
| "pulse_geometry" // Hiệu ứng nhấp nháy cho geometry
|
||||||
|
| "set_time_filter" // Thay đổi bộ lọc thời gian trên bản đồ
|
||||||
|
| "toggle_labels"; // Bật/tắt hiển thị nhãn (labels) trên bản đồ
|
||||||
|
|
||||||
export type NarrativeFunctionName = "set_title" | "set_descriptions";
|
export type NarrativeFunctionName =
|
||||||
|
| "set_title" // Đặt tiêu đề cho bước replay
|
||||||
|
| "set_descriptions" // Đặt mô tả/nội dung diễn giải
|
||||||
|
| "show_dialog_box" // Hiển thị hộp thoại dẫn chuyện (có avatar)
|
||||||
|
| "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
|
||||||
|
|
||||||
export type ReplayAction<T> = {
|
export type ReplayAction<T> = {
|
||||||
function_name: T;
|
function_name: T;
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import {
|
|||||||
import { buildEditorSnapshot, normalizeEditorSnapshot, toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
import { buildEditorSnapshot, normalizeEditorSnapshot, toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
import type { Feature, FeatureCollection, FeatureId, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
import type { Feature, FeatureCollection, FeatureId, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
||||||
import type { EditorSnapshot, Project, ProjectCommit, ProjectState, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
import type { BattleReplay, EditorSnapshot, Project, ProjectCommit, ProjectState, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
|
|
||||||
type EditorDraftApi = {
|
type EditorDraftApi = {
|
||||||
draft: FeatureCollection;
|
draft: FeatureCollection;
|
||||||
|
mainDraft: FeatureCollection;
|
||||||
|
replays: BattleReplay[];
|
||||||
buildPayload: () => Change[];
|
buildPayload: () => Change[];
|
||||||
clearChanges: () => void;
|
clearChanges: () => void;
|
||||||
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
||||||
@@ -96,11 +98,12 @@ export function useProjectCommands(options: Options) {
|
|||||||
try {
|
try {
|
||||||
const snapshot = buildEditorSnapshot({
|
const snapshot = buildEditorSnapshot({
|
||||||
project: options.activeSection,
|
project: options.activeSection,
|
||||||
draft: options.editor.draft,
|
draft: options.editor.mainDraft,
|
||||||
changes: geometryChanges,
|
changes: geometryChanges,
|
||||||
snapshotEntities: options.snapshotEntities,
|
snapshotEntities: options.snapshotEntities,
|
||||||
snapshotWikis: options.snapshotWikis,
|
snapshotWikis: options.snapshotWikis,
|
||||||
snapshotEntityWikiLinks: options.snapshotEntityWikiLinks,
|
snapshotEntityWikiLinks: options.snapshotEntityWikiLinks,
|
||||||
|
replays: options.editor.replays,
|
||||||
previousSnapshot: options.baselineSnapshot,
|
previousSnapshot: options.baselineSnapshot,
|
||||||
hasPersistedFeature: options.editor.hasPersistedFeature,
|
hasPersistedFeature: options.editor.hasPersistedFeature,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { EntitySnapshot } from "@/uhm/types/entities";
|
|||||||
import type { EntitySnapshotOperation } from "@/uhm/types/entities";
|
import type { EntitySnapshotOperation } from "@/uhm/types/entities";
|
||||||
import type { Feature, FeatureCollection, Geometry, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
import type { Feature, FeatureCollection, Geometry, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
||||||
|
|
||||||
import type { EditorSnapshot, Project } from "@/uhm/types/projects";
|
import type { BattleReplay, EditorSnapshot, Project } from "@/uhm/types/projects";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||||
|
|
||||||
@@ -312,6 +312,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
wikis,
|
wikis,
|
||||||
geometry_entity: geometryEntity || migratedGeometryEntity,
|
geometry_entity: geometryEntity || migratedGeometryEntity,
|
||||||
entity_wiki: entityWikis,
|
entity_wiki: entityWikis,
|
||||||
|
replays: Array.isArray(snapshot.replays) ? (snapshot.replays as BattleReplay[]) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +323,7 @@ export function buildEditorSnapshot(options: {
|
|||||||
snapshotEntities: EntitySnapshot[];
|
snapshotEntities: EntitySnapshot[];
|
||||||
snapshotWikis: WikiSnapshot[];
|
snapshotWikis: WikiSnapshot[];
|
||||||
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
|
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
|
||||||
|
replays: BattleReplay[];
|
||||||
previousSnapshot: EditorSnapshot | null;
|
previousSnapshot: EditorSnapshot | null;
|
||||||
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
||||||
}): EditorSnapshot {
|
}): EditorSnapshot {
|
||||||
@@ -663,6 +665,7 @@ export function buildEditorSnapshot(options: {
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
entity_wiki: entityWikis,
|
entity_wiki: entityWikis,
|
||||||
|
replays: options.replays,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,6 +689,14 @@ export function toApiEditorSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(cloned.replays)) {
|
||||||
|
cloned.replays = cloned.replays.map((replay) => {
|
||||||
|
// Strip local-only replay_features before sending to BE
|
||||||
|
const { replay_features: _, ...rest } = replay;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return cloned;
|
return cloned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { useUndoStack } from "@/uhm/lib/editor/draft/useUndoStack";
|
|||||||
import type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
import type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
import type { BattleReplay, EditorSnapshot, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||||
|
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
|
||||||
|
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
|
export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
|
||||||
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
@@ -31,11 +33,27 @@ type FeaturePropertiesPatch = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// State trung tâm của editor:
|
// State trung tâm của editor:
|
||||||
// - draft: dữ liệu nguồn để render UI
|
// - draft: dữ liệu nguồn để render UI (chuyển đổi giữa main và replay)
|
||||||
// - changes: map các thay đổi chờ lưu
|
// - changes: map các thay đổi chờ lưu
|
||||||
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác
|
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác
|
||||||
export function useEditorState(initialData: FeatureCollection, snapshotUndo?: SnapshotUndoApi) {
|
export function useEditorState(
|
||||||
const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData);
|
initialData: FeatureCollection,
|
||||||
|
options: {
|
||||||
|
snapshotUndo?: SnapshotUndoApi;
|
||||||
|
initialReplays?: BattleReplay[];
|
||||||
|
mode: EditorMode;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { snapshotUndo, initialReplays, mode } = options;
|
||||||
|
|
||||||
|
const mainDraftState = useDraftState(initialData);
|
||||||
|
const replayDraftState = useDraftState(EMPTY_FEATURE_COLLECTION);
|
||||||
|
|
||||||
|
const [replays, setReplays] = useState<BattleReplay[]>(initialReplays || []);
|
||||||
|
const [activeReplayId, setActiveReplayId] = useState<string | number | null>(null);
|
||||||
|
|
||||||
|
const activeDraftState = mode === "replay" ? replayDraftState : mainDraftState;
|
||||||
|
const { draft, draftRef, commitDraft, resetDraft } = activeDraftState;
|
||||||
|
|
||||||
// Map baseline (id -> feature) để diff draft hiện tại ra changes.
|
// Map baseline (id -> feature) để diff draft hiện tại ra changes.
|
||||||
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
|
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
|
||||||
@@ -125,11 +143,14 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
|
|||||||
const { undoStack, pushUndo, undo, clearUndo } = useUndoStack({ applyUndoAction });
|
const { undoStack, pushUndo, undo, clearUndo } = useUndoStack({ applyUndoAction });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetDraft(deepClone(initialData));
|
mainDraftState.resetDraft(deepClone(initialData));
|
||||||
|
replayDraftState.resetDraft(EMPTY_FEATURE_COLLECTION);
|
||||||
|
setReplays(initialReplays || []);
|
||||||
|
setActiveReplayId(null);
|
||||||
clearUndo();
|
clearUndo();
|
||||||
initialMapRef.current = buildInitialMap(initialData);
|
initialMapRef.current = buildInitialMap(initialData);
|
||||||
setBaselineVersion((version) => version + 1);
|
setBaselineVersion((version) => version + 1);
|
||||||
}, [clearUndo, initialData, resetDraft]);
|
}, [clearUndo, initialData, initialReplays, mainDraftState.resetDraft, replayDraftState.resetDraft]);
|
||||||
|
|
||||||
const changes = useMemo(() => {
|
const changes = useMemo(() => {
|
||||||
const baseline = initialMapRef.current;
|
const baseline = initialMapRef.current;
|
||||||
@@ -302,7 +323,7 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
|
|||||||
|
|
||||||
function clearChanges() {
|
function clearChanges() {
|
||||||
clearUndo();
|
clearUndo();
|
||||||
initialMapRef.current = buildInitialMap(draftRef.current);
|
initialMapRef.current = buildInitialMap(mainDraftState.draftRef.current);
|
||||||
setBaselineVersion((version) => version + 1);
|
setBaselineVersion((version) => version + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +331,77 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
|
|||||||
return initialMapRef.current.has(id);
|
return initialMapRef.current.has(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const switchReplayContext = useCallback((featureId: string | number, selectedIds: (string | number)[] = []) => {
|
||||||
|
const id = String(featureId);
|
||||||
|
// Lưu draft replay cũ nếu có (defensive)
|
||||||
|
if (activeReplayId && mode === "replay") {
|
||||||
|
const currentDraft = replayDraftState.draftRef.current;
|
||||||
|
setReplays(prev => prev.map(r =>
|
||||||
|
r.geometry_id === String(activeReplayId)
|
||||||
|
? { ...r, replay_features: deepClone(currentDraft) }
|
||||||
|
: r
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = replays.find(r => r.geometry_id === id);
|
||||||
|
|
||||||
|
// Chuẩn bị data: bao gồm tất cả các geo đang chọn + binding của geo chính
|
||||||
|
const selectedIdsSet = new Set(selectedIds.map(String));
|
||||||
|
selectedIdsSet.add(id); // Luôn bao gồm geo chính
|
||||||
|
|
||||||
|
const triggerFeature = mainDraftState.draftRef.current.features.find(f => String(f.properties.id) === id);
|
||||||
|
const mainBoundIds = new Set(triggerFeature?.properties?.binding?.map(String) || []);
|
||||||
|
|
||||||
|
// Quy tắc: targetIds bao gồm các geo được chọn và binding CHỈ của geo chính.
|
||||||
|
const targetIds = new Set([...selectedIdsSet, ...mainBoundIds]);
|
||||||
|
|
||||||
|
const gatheredFeatures = mainDraftState.draftRef.current.features
|
||||||
|
.filter(f => targetIds.has(String(f.properties.id)))
|
||||||
|
.map(deepClone);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Đồng bộ lại danh sách geometry theo lựa chọn mới nhất (Sync với Main Draft)
|
||||||
|
// Giúp "reset" danh sách geo theo multi-select và binding mới nhất,
|
||||||
|
// nhưng vẫn giữ nguyên phần kịch bản (detail) đã dựng.
|
||||||
|
const nextFeatures: FeatureCollection = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: gatheredFeatures,
|
||||||
|
};
|
||||||
|
|
||||||
|
replayDraftState.resetDraft(deepClone(nextFeatures));
|
||||||
|
// Cập nhật lại list replays để đồng bộ
|
||||||
|
setReplays(prev => prev.map(r =>
|
||||||
|
r.geometry_id === id ? { ...r, replay_features: nextFeatures } : r
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
const initialFeatures: FeatureCollection = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: gatheredFeatures,
|
||||||
|
};
|
||||||
|
const newReplay: BattleReplay = {
|
||||||
|
geometry_id: id,
|
||||||
|
detail: [],
|
||||||
|
replay_features: initialFeatures,
|
||||||
|
};
|
||||||
|
setReplays(prev => [...prev, newReplay]);
|
||||||
|
replayDraftState.resetDraft(deepClone(initialFeatures));
|
||||||
|
}
|
||||||
|
setActiveReplayId(id);
|
||||||
|
}, [activeReplayId, mode, replayDraftState, replays, mainDraftState.draftRef]);
|
||||||
|
|
||||||
|
const closeReplayContext = useCallback(() => {
|
||||||
|
if (activeReplayId) {
|
||||||
|
const currentDraft = replayDraftState.draftRef.current;
|
||||||
|
setReplays(prev => prev.map(r =>
|
||||||
|
r.geometry_id === String(activeReplayId)
|
||||||
|
? { ...r, replay_features: deepClone(currentDraft) }
|
||||||
|
: r
|
||||||
|
));
|
||||||
|
}
|
||||||
|
setActiveReplayId(null);
|
||||||
|
replayDraftState.resetDraft(EMPTY_FEATURE_COLLECTION);
|
||||||
|
}, [activeReplayId, replayDraftState]);
|
||||||
|
|
||||||
const setSnapshotEntitiesUndoable = useCallback((
|
const setSnapshotEntitiesUndoable = useCallback((
|
||||||
next: SetStateAction<EntitySnapshot[]>,
|
next: SetStateAction<EntitySnapshot[]>,
|
||||||
label = "Cập nhật entities"
|
label = "Cập nhật entities"
|
||||||
@@ -380,6 +472,14 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
draft,
|
draft,
|
||||||
|
draftRef,
|
||||||
|
mainDraft: mainDraftState.draft,
|
||||||
|
replayDraft: replayDraftState.draft,
|
||||||
|
replays,
|
||||||
|
setReplays,
|
||||||
|
activeReplayId,
|
||||||
|
switchReplayContext,
|
||||||
|
closeReplayContext,
|
||||||
changes,
|
changes,
|
||||||
undoStack,
|
undoStack,
|
||||||
changeCount,
|
changeCount,
|
||||||
|
|||||||
@@ -220,10 +220,7 @@ export function initSelect(
|
|||||||
hasMenuItems = true;
|
hasMenuItems = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (onReplayEdit) {
|
||||||
selectedCount === 1 &&
|
|
||||||
onReplayEdit
|
|
||||||
) {
|
|
||||||
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
|
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
|
||||||
if (featureId) {
|
if (featureId) {
|
||||||
menu.appendChild(createItem("Replay Edit", () => onReplayEdit(featureId)));
|
menu.appendChild(createItem("Replay Edit", () => onReplayEdit(featureId)));
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type maplibregl from "maplibre-gl";
|
||||||
|
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Các hàm xử lý tương tác bản đồ cho hệ thống Replay.
|
||||||
|
* Hầu hết các hàm yêu cầu instance của MapLibre GL.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const mapActions = {
|
||||||
|
// Di chuyển camera đến tọa độ [lng, lat]
|
||||||
|
zoom_to_lnglat: (map: maplibregl.Map, lng: number, lat: number, zoom?: number) => {
|
||||||
|
map.easeTo({
|
||||||
|
center: [lng, lat],
|
||||||
|
zoom: zoom ?? map.getZoom(),
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Thay đổi mức zoom của bản đồ
|
||||||
|
zoom_scale: (map: maplibregl.Map, zoom: number) => {
|
||||||
|
map.easeTo({
|
||||||
|
zoom,
|
||||||
|
duration: 1500,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Đặt trạng thái camera toàn diện (center, zoom, pitch, bearing)
|
||||||
|
set_camera_view: (map: maplibregl.Map, state: { center: { lng: number; lat: number }; zoom: number; pitch: number; bearing: number }) => {
|
||||||
|
map.easeTo({
|
||||||
|
center: [state.center.lng, state.center.lat],
|
||||||
|
zoom: state.zoom,
|
||||||
|
pitch: state.pitch,
|
||||||
|
bearing: state.bearing,
|
||||||
|
duration: 2500,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Di chuyển mượt mà đến một geometry dựa trên ID
|
||||||
|
fly_to_geometry: (map: maplibregl.Map, geometryId: string | number, draft: FeatureCollection) => {
|
||||||
|
const feature = draft.features.find(f => String(f.properties.id) === String(geometryId));
|
||||||
|
if (!feature) return;
|
||||||
|
|
||||||
|
// Tính toán bounds từ geometry (giả định có helper hoặc dùng bbox của feature)
|
||||||
|
// Ở đây tạm dùng center đơn giản nếu là Point, hoặc bounds nếu là đa giác
|
||||||
|
if (feature.geometry.type === "Point") {
|
||||||
|
map.flyTo({
|
||||||
|
center: feature.geometry.coordinates as [number, number],
|
||||||
|
zoom: Math.max(map.getZoom(), 10),
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Thực tế cần tính bbox, ở đây giả định map có hàm fitBounds hoặc tương đương
|
||||||
|
// map.fitBounds(calculateBBox(feature.geometry), { padding: 50 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Xoay camera quanh một điểm
|
||||||
|
rotate_around_point: (map: maplibregl.Map, duration: number = 5000) => {
|
||||||
|
const startBearing = map.getBearing();
|
||||||
|
map.easeTo({
|
||||||
|
bearing: startBearing + 180,
|
||||||
|
duration,
|
||||||
|
easing: (t) => t,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Thay đổi màu của một geometry (thao tác trực tiếp trên layer map)
|
||||||
|
change_geometry_color: (map: maplibregl.Map, geometryId: string | number, color: string) => {
|
||||||
|
const layerId = `uhm-geo-${geometryId}`; // Giả định format ID layer
|
||||||
|
if (map.getLayer(layerId)) {
|
||||||
|
map.setPaintProperty(layerId, 'fill-color', color);
|
||||||
|
map.setPaintProperty(layerId, 'line-color', color);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Ẩn/hiện nhãn (labels) trên bản đồ
|
||||||
|
toggle_labels: (map: maplibregl.Map, visible: boolean) => {
|
||||||
|
const style = map.getStyle();
|
||||||
|
if (!style) return;
|
||||||
|
style.layers.forEach(layer => {
|
||||||
|
if (layer.type === 'symbol' && (layer as any).layout?.['text-field']) {
|
||||||
|
map.setLayoutProperty(layer.id, 'visibility', visible ? 'visible' : 'none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Thay đổi bộ lọc thời gian trên bản đồ
|
||||||
|
set_time_filter: (onYearChange: (year: number) => void, year: number) => {
|
||||||
|
onYearChange(year);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Các hàm điều khiển nội dung dẫn chuyện và thuyết minh trong Replay.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const narrativeActions = {
|
||||||
|
// Đặt tiêu đề cho cảnh hiện tại
|
||||||
|
set_title: (setTitle: (t: string) => void, title: string) => {
|
||||||
|
setTitle(title);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Đặt nội dung mô tả chi tiết
|
||||||
|
set_descriptions: (setDesc: (d: string) => void, descriptions: string) => {
|
||||||
|
setDesc(descriptions);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hiển thị hộp thoại hội thoại (Dialogue)
|
||||||
|
show_dialog_box: (setDialog: (data: { avatar: string; text: string; side: 'left' | 'right' }) => void, avatar: string, text: string) => {
|
||||||
|
setDialog({ avatar, text, side: 'left' });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hiển thị hình ảnh lịch sử đè lên bản đồ
|
||||||
|
display_historical_image: (setImage: (url: string | null) => void, imageUrl: string) => {
|
||||||
|
setImage(imageUrl);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hiển thị phụ đề (Subtitle)
|
||||||
|
set_step_subtitle: (setSubtitle: (s: string | null) => void, subtitle: string) => {
|
||||||
|
setSubtitle(subtitle);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type maplibregl from "maplibre-gl";
|
||||||
|
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||||
|
import type { ReplayAction, UIFunctionName, MapFunctionName, NarrativeFunctionName } from "@/uhm/types/projects";
|
||||||
|
import { mapActions } from "./mapActions";
|
||||||
|
import { uiActions } from "./uiActions";
|
||||||
|
import { narrativeActions } from "./narrativeActions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface định nghĩa các controller cần thiết để thực thi Replay.
|
||||||
|
* Các thành phần UI sẽ cung cấp các hàm setter này cho Dispatcher.
|
||||||
|
*/
|
||||||
|
export interface ReplayControllers {
|
||||||
|
map: maplibregl.Map | null;
|
||||||
|
draft: FeatureCollection;
|
||||||
|
|
||||||
|
// UI Setters
|
||||||
|
setTimelineVisible: (v: boolean) => void;
|
||||||
|
setUIVisible: (v: boolean) => void;
|
||||||
|
setSidebarOpen: (v: boolean) => void;
|
||||||
|
onSelectWiki: (id: string) => void;
|
||||||
|
addToast: (msg: string) => void;
|
||||||
|
setPlaybackSpeed: (s: number) => void;
|
||||||
|
onYearChange: (y: number) => void;
|
||||||
|
|
||||||
|
// Narrative Setters
|
||||||
|
setTitle: (t: string) => void;
|
||||||
|
setDescriptions: (d: string) => void;
|
||||||
|
setDialog: (data: any) => void;
|
||||||
|
setImage: (url: string | null) => void;
|
||||||
|
setSubtitle: (s: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatcher trung tâm: Nhận một Action và thực thi logic tương ứng
|
||||||
|
* bằng cách gọi đến các bộ Action con (map, ui, narrative).
|
||||||
|
*/
|
||||||
|
export const dispatchReplayAction = (controllers: ReplayControllers, action: ReplayAction<any>) => {
|
||||||
|
const { function_name, params } = action;
|
||||||
|
|
||||||
|
// 1. Nhóm Map Actions
|
||||||
|
if (controllers.map) {
|
||||||
|
const map = controllers.map;
|
||||||
|
switch (function_name as MapFunctionName) {
|
||||||
|
case "zoom_to_lnglat":
|
||||||
|
mapActions.zoom_to_lnglat(map, params[0], params[1], params[2]);
|
||||||
|
return;
|
||||||
|
case "zoom_scale":
|
||||||
|
mapActions.zoom_scale(map, params[0]);
|
||||||
|
return;
|
||||||
|
case "set_camera_view":
|
||||||
|
mapActions.set_camera_view(map, params[0]);
|
||||||
|
return;
|
||||||
|
case "fly_to_geometry":
|
||||||
|
mapActions.fly_to_geometry(map, params[0], controllers.draft);
|
||||||
|
return;
|
||||||
|
case "rotate_around_point":
|
||||||
|
mapActions.rotate_around_point(map, params[0]);
|
||||||
|
return;
|
||||||
|
case "toggle_labels":
|
||||||
|
mapActions.toggle_labels(map, params[0]);
|
||||||
|
return;
|
||||||
|
case "set_time_filter":
|
||||||
|
mapActions.set_time_filter(controllers.onYearChange, params[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Nhóm UI Actions
|
||||||
|
switch (function_name as UIFunctionName) {
|
||||||
|
case "hide_timeline":
|
||||||
|
uiActions.hide_timeline(controllers.setTimelineVisible);
|
||||||
|
return;
|
||||||
|
case "hide_all_UI":
|
||||||
|
uiActions.hide_all_UI(controllers.setUIVisible);
|
||||||
|
return;
|
||||||
|
case "open_wiki":
|
||||||
|
uiActions.open_wiki(controllers.setSidebarOpen, controllers.onSelectWiki, params[0]);
|
||||||
|
return;
|
||||||
|
case "show_toast_message":
|
||||||
|
uiActions.show_toast_message(controllers.addToast, params[0]);
|
||||||
|
return;
|
||||||
|
case "set_playback_speed":
|
||||||
|
uiActions.set_playback_speed(controllers.setPlaybackSpeed, params[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Nhóm Narrative Actions
|
||||||
|
switch (function_name as NarrativeFunctionName) {
|
||||||
|
case "set_title":
|
||||||
|
narrativeActions.set_title(controllers.setTitle, params[0]);
|
||||||
|
return;
|
||||||
|
case "set_descriptions":
|
||||||
|
narrativeActions.set_descriptions(controllers.setDescriptions, params[0]);
|
||||||
|
return;
|
||||||
|
case "show_dialog_box":
|
||||||
|
narrativeActions.show_dialog_box(controllers.setDialog, params[0], params[1]);
|
||||||
|
return;
|
||||||
|
case "display_historical_image":
|
||||||
|
narrativeActions.display_historical_image(controllers.setImage, params[0]);
|
||||||
|
return;
|
||||||
|
case "set_step_subtitle":
|
||||||
|
narrativeActions.set_step_subtitle(controllers.setSubtitle, params[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Các hàm điều khiển giao diện người dùng (UI) trong chế độ Replay.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const uiActions = {
|
||||||
|
// Ẩn thanh Timeline
|
||||||
|
hide_timeline: (setTimelineVisible: (v: boolean) => void) => {
|
||||||
|
setTimelineVisible(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Ẩn toàn bộ UI để có trải nghiệm điện ảnh (Cinematic)
|
||||||
|
hide_all_UI: (setUIVisible: (v: boolean) => void) => {
|
||||||
|
setUIVisible(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mở Wiki và tìm đến một ID cụ thể
|
||||||
|
open_wiki: (setSidebarOpen: (v: boolean) => void, onSelectWiki: (id: string) => void, wikiId: string) => {
|
||||||
|
setSidebarOpen(true);
|
||||||
|
onSelectWiki(wikiId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hiển thị thông báo (toast)
|
||||||
|
show_toast_message: (addToast: (msg: string) => void, message: string) => {
|
||||||
|
addToast(message);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Thay đổi tốc độ phát Replay
|
||||||
|
set_playback_speed: (setSpeed: (s: number) => void, speed: number) => {
|
||||||
|
setSpeed(speed);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -84,8 +84,73 @@ export type EditorSnapshot = {
|
|||||||
geometry_entity?: GeometryEntitySnapshot[];
|
geometry_entity?: GeometryEntitySnapshot[];
|
||||||
wikis?: WikiSnapshot[];
|
wikis?: WikiSnapshot[];
|
||||||
entity_wiki?: EntityWikiLinkSnapshot[];
|
entity_wiki?: EntityWikiLinkSnapshot[];
|
||||||
|
replays?: BattleReplay[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- Replay / Scripting System ----
|
||||||
|
|
||||||
|
export type UIFunctionName =
|
||||||
|
| "hide_timeline" // Ẩn thanh timeline
|
||||||
|
| "hide_layer_panel" // Ẩn panel lớp bản đồ
|
||||||
|
| "hide_wiki_panel" // Ẩn panel wiki (bên phải)
|
||||||
|
| "hide_zoom_panel" // Ẩn các nút điều khiển zoom
|
||||||
|
| "hide_all_UI" // Ẩn toàn bộ giao diện điều khiển (cinematic mode)
|
||||||
|
| "open_wiki" // Mở panel wiki
|
||||||
|
| "show_toast_message" // Hiển thị thông báo ngắn (toast)
|
||||||
|
| "focus_wiki_header" // Cuộn đến đề mục cụ thể trong Wiki
|
||||||
|
| "set_playback_speed"; // Thay đổi tốc độ phát replay
|
||||||
|
|
||||||
|
export type MapFunctionName =
|
||||||
|
| "zoom_to_lnglat" // Di chuyển camera đến tọa độ [lng, lat]
|
||||||
|
| "zoom_scale" // Thay đổi mức zoom của bản đồ
|
||||||
|
| "zoom_geometries" // Zoom bao quát danh sách các geometry
|
||||||
|
| "change_geometry_color" // Thay đổi màu của một geometry
|
||||||
|
| "change_geometries_color" // Thay đổi màu của danh sách geometry
|
||||||
|
| "change_geometry_texture" // Thay đổi texture của một geometry
|
||||||
|
| "change_geometries_texture"// Thay đổi texture của danh sách geometry
|
||||||
|
| "hide_geometries" // Ẩn danh sách các geometry
|
||||||
|
| "set_camera_view" // Đặt trạng thái camera (center, zoom, pitch, bearing)
|
||||||
|
| "fly_to_geometry" // Di chuyển mượt mà đến một geometry
|
||||||
|
| "rotate_around_point" // Xoay camera quanh một điểm
|
||||||
|
| "pulse_geometry" // Hiệu ứng nhấp nháy cho geometry
|
||||||
|
| "set_time_filter" // Thay đổi bộ lọc thời gian trên bản đồ
|
||||||
|
| "toggle_labels"; // Bật/tắt hiển thị nhãn (labels) trên bản đồ
|
||||||
|
|
||||||
|
export type NarrativeFunctionName =
|
||||||
|
| "set_title" // Đặt tiêu đề cho bước replay
|
||||||
|
| "set_descriptions" // Đặt mô tả/nội dung diễn giải
|
||||||
|
| "show_dialog_box" // Hiển thị hộp thoại dẫn chuyện (có avatar)
|
||||||
|
| "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
|
||||||
|
|
||||||
|
export type ReplayAction<T> = {
|
||||||
|
function_name: T;
|
||||||
|
params: any[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReplayStep = {
|
||||||
|
duration: number; // Trọng số thời gian của step trong 1 stage
|
||||||
|
use_UI_function: ReplayAction<UIFunctionName>[];
|
||||||
|
use_map_function: ReplayAction<MapFunctionName>[];
|
||||||
|
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReplayStage = {
|
||||||
|
id: number; // số đếm thứ tự từ 0
|
||||||
|
title?: string;
|
||||||
|
detail_time_start: string;
|
||||||
|
detail_time_stop: string;
|
||||||
|
steps: ReplayStep[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BattleReplay = {
|
||||||
|
geometry_id: string; // geometry mà khi nhấn vào là có thể replay
|
||||||
|
detail: ReplayStage[];
|
||||||
|
// Local-only: separate draft for this specific replay
|
||||||
|
replay_features?: FeatureCollection;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// Alias for clearer naming at API boundary: commits.snapshot_json is this shape.
|
// Alias for clearer naming at API boundary: commits.snapshot_json is this shape.
|
||||||
export type CommitSnapshot = EditorSnapshot;
|
export type CommitSnapshot = EditorSnapshot;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user