diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index ed0cf3d..19744f5 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -188,12 +188,16 @@ export default function Page() { }, [snapshotEntityWikiLinks]); const editor = useEditorState(initialData, { - snapshotEntitiesRef, - setSnapshotEntities, - snapshotWikisRef, - setSnapshotWikis, - snapshotEntityWikiLinksRef, - setSnapshotEntityWikiLinks, + snapshotUndo: { + snapshotEntitiesRef, + setSnapshotEntities, + snapshotWikisRef, + setSnapshotWikis, + snapshotEntityWikiLinksRef, + setSnapshotEntityWikiLinks, + }, + initialReplays: baselineSnapshot?.replays, + mode: mode, }); const setSnapshotWikisUndoable = useCallback( (next: SetStateAction) => { @@ -266,16 +270,19 @@ export default function Page() { // Timeline filter: only affects persisted snapshot features. // New features created in the current session remain visible regardless of time range. 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)); return { - ...editor.draft, - features: editor.draft.features.filter((feature) => { + ...activeDraft, + features: activeDraft.features.filter((feature) => { if (!editor.hasPersistedFeature(feature.properties.id)) return true; return isFeatureVisibleAtYear(feature, year); }), }; - }, [editor, timelineDraftYear, timelineFilterEnabled]); + }, [editor, mode, timelineDraftYear, timelineFilterEnabled]); const projectEntityChoices = useMemo(() => { const ids = new Set(); @@ -412,36 +419,38 @@ export default function Page() { const setMode = useCallback((m: EditorMode, featureId?: string | number) => { 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") { + if (mode === "replay") { + editor.closeReplayContext(); + setSelectedFeatureIds([]); + } setReplayFeatureId(null); setHideOutside(false); } internalSetMode(m); - }, [internalSetMode]); + }, [internalSetMode, mode, editor, selectedFeatureIds]); const effectiveGeometryVisibility = useMemo(() => { const visibility: Record = { ...geometryVisibility }; 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; if (hideOutside) { - // Tìm feature đang replay để lấy danh sách binding - const replayFeature = editor.draft.features.find( - (f) => String(f.properties.id) === String(replayFeatureId) - ); - const boundIds = new Set(); - 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 - editor.draft.features.forEach((f) => { - const fid = String(f.properties.id); - if (fid !== String(replayFeatureId) && !boundIds.has(fid)) { + // 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))); + + // Ẩn tất cả các geo KHÔNG nằm trong draft replay hiện tại + Object.keys(visibility).forEach(fid => { + if (fid === String(replayFeatureId)) { visibility[fid] = false; + } else { + visibility[fid] = currentReplayFeatureIds.has(fid); } }); } @@ -1686,6 +1695,7 @@ export default function Page() { isEntitySubmitting={isEntitySubmitting} onApplyGeometryMetadata={featureCommands.applyGeometryMetadata} changeCount={editor.changeCount} + onReplayEdit={(id) => setMode("replay", id)} /> ) : null} diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index 2f48cee..6381569 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -1,6 +1,6 @@ "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 { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState"; @@ -19,6 +19,16 @@ export type MapHoverPayload = { 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 = { mode: EditorMode; draft: FeatureCollection; @@ -45,7 +55,7 @@ type MapProps = { onToggleHideOutside?: () => void; }; -export default function Map({ +const Map = forwardRef(function Map({ mode, onSetMode, draft, @@ -69,7 +79,7 @@ export default function Map({ focusPadding, hideOutside = false, onToggleHideOutside, -}: MapProps) { +}, ref) { const modeRef = useRef(mode); const draftRef = useRef(draft); const onSelectFeatureIdsRef = useRef(onSelectFeatureIds); @@ -100,8 +110,21 @@ export default function Map({ geolocationCenteredRef, handleZoomByStep, handleZoomSliderChange, + getViewState, } = 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 { editingEngineRef, setupMapInteractions, @@ -251,6 +274,27 @@ export default function Map({ Thoát Replay Edit + +
); -} +}); + +export default Map; const zoomButtonStyle: React.CSSProperties = { width: "28px", diff --git a/src/uhm/components/editor/SelectedGeometryPanel.tsx b/src/uhm/components/editor/SelectedGeometryPanel.tsx index 3e3cc68..250144b 100644 --- a/src/uhm/components/editor/SelectedGeometryPanel.tsx +++ b/src/uhm/components/editor/SelectedGeometryPanel.tsx @@ -20,6 +20,7 @@ type Props = { isEntitySubmitting: boolean; onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>; changeCount: number; + onReplayEdit?: (id: string | number) => void; }; export default function SelectedGeometryPanel({ @@ -30,6 +31,7 @@ export default function SelectedGeometryPanel({ isEntitySubmitting, onApplyGeometryMetadata, changeCount, + onReplayEdit, }: Props) { const [collapsed, setCollapsed] = useState(false); const [geoApplyFeedback, setGeoApplyFeedback] = useState< @@ -201,6 +203,20 @@ export default function SelectedGeometryPanel({ > Apply + {onReplayEdit && selectedFeatures.length > 0 && ( + + )} {visibleGeoApplyFeedback ? (
{ + 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 { mapRef, containerRef, @@ -138,5 +152,6 @@ export function useMapInstance() { geolocationCenteredRef, handleZoomByStep, handleZoomSliderChange, + getViewState, }; } diff --git a/src/uhm/components/map/useMapInteraction.ts b/src/uhm/components/map/useMapInteraction.ts index 777f998..26a4a5a 100644 --- a/src/uhm/components/map/useMapInteraction.ts +++ b/src/uhm/components/map/useMapInteraction.ts @@ -64,6 +64,8 @@ export function useMapInteraction({ useEffect(() => { if (mode !== "select" || !selectedFeatureIds || selectedFeatureIds.length === 0) { 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]); diff --git a/src/uhm/doc/commit_snapshot.ts b/src/uhm/doc/commit_snapshot.ts index ed0f13a..d790bf2 100644 --- a/src/uhm/doc/commit_snapshot.ts +++ b/src/uhm/doc/commit_snapshot.ts @@ -32,24 +32,38 @@ export type CommitSnapshot = { // ---- Replay / Scripting System ---- export type UIFunctionName = - | "hide_timeline" - | "hide_layer_panel" - | "hide_wiki_panel" - | "hide_zoom_panel" - | "hide_all_UI" - | "open_wiki"; + | "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" - | "zoom_scale" - | "zoom_geometries" - | "change_geometry_color" - | "change_geometries_color" - | "change_geometry_texture" - | "change_geometries_texture" - | "hide_geometries"; + | "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" | "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 = { function_name: T; diff --git a/src/uhm/lib/editor/project/useProjectCommands.ts b/src/uhm/lib/editor/project/useProjectCommands.ts index d0b3dda..17d0f22 100644 --- a/src/uhm/lib/editor/project/useProjectCommands.ts +++ b/src/uhm/lib/editor/project/useProjectCommands.ts @@ -12,12 +12,14 @@ import { import { buildEditorSnapshot, normalizeEditorSnapshot, toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot"; import type { Change } from "@/uhm/lib/editor/draft/editorTypes"; 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 { WikiSnapshot } from "@/uhm/types/wiki"; type EditorDraftApi = { draft: FeatureCollection; + mainDraft: FeatureCollection; + replays: BattleReplay[]; buildPayload: () => Change[]; clearChanges: () => void; hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean; @@ -96,11 +98,12 @@ export function useProjectCommands(options: Options) { try { const snapshot = buildEditorSnapshot({ project: options.activeSection, - draft: options.editor.draft, + draft: options.editor.mainDraft, changes: geometryChanges, snapshotEntities: options.snapshotEntities, snapshotWikis: options.snapshotWikis, snapshotEntityWikiLinks: options.snapshotEntityWikiLinks, + replays: options.editor.replays, previousSnapshot: options.baselineSnapshot, hasPersistedFeature: options.editor.hasPersistedFeature, }); diff --git a/src/uhm/lib/editor/snapshot/editorSnapshot.ts b/src/uhm/lib/editor/snapshot/editorSnapshot.ts index 430e617..2905378 100644 --- a/src/uhm/lib/editor/snapshot/editorSnapshot.ts +++ b/src/uhm/lib/editor/snapshot/editorSnapshot.ts @@ -5,7 +5,7 @@ import type { EntitySnapshot } from "@/uhm/types/entities"; import type { EntitySnapshotOperation } from "@/uhm/types/entities"; 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 { EntityWikiLinkSnapshot } from "@/uhm/types/projects"; @@ -312,6 +312,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null { wikis, geometry_entity: geometryEntity || migratedGeometryEntity, entity_wiki: entityWikis, + replays: Array.isArray(snapshot.replays) ? (snapshot.replays as BattleReplay[]) : undefined, }; } @@ -322,6 +323,7 @@ export function buildEditorSnapshot(options: { snapshotEntities: EntitySnapshot[]; snapshotWikis: WikiSnapshot[]; snapshotEntityWikiLinks: EntityWikiLinkSnapshot[]; + replays: BattleReplay[]; previousSnapshot: EditorSnapshot | null; hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean; }): EditorSnapshot { @@ -663,6 +665,7 @@ export function buildEditorSnapshot(options: { })) .sort((a, b) => a.id.localeCompare(b.id)), 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; } diff --git a/src/uhm/lib/editor/state/useEditorState.ts b/src/uhm/lib/editor/state/useEditorState.ts index 2d726b4..5cc5d7d 100644 --- a/src/uhm/lib/editor/state/useEditorState.ts +++ b/src/uhm/lib/editor/state/useEditorState.ts @@ -11,7 +11,9 @@ import { useUndoStack } from "@/uhm/lib/editor/draft/useUndoStack"; import type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes"; import type { EntitySnapshot } from "@/uhm/types/entities"; 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 { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes"; @@ -31,11 +33,27 @@ type FeaturePropertiesPatch = { }; // 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 // - undoStack: lịch sử thao tác tối thiểu để hoàn tác -export function useEditorState(initialData: FeatureCollection, snapshotUndo?: SnapshotUndoApi) { - const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData); +export function useEditorState( + 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(initialReplays || []); + const [activeReplayId, setActiveReplayId] = useState(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. const initialMapRef = useRef>( @@ -125,11 +143,14 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn const { undoStack, pushUndo, undo, clearUndo } = useUndoStack({ applyUndoAction }); useEffect(() => { - resetDraft(deepClone(initialData)); + mainDraftState.resetDraft(deepClone(initialData)); + replayDraftState.resetDraft(EMPTY_FEATURE_COLLECTION); + setReplays(initialReplays || []); + setActiveReplayId(null); clearUndo(); initialMapRef.current = buildInitialMap(initialData); setBaselineVersion((version) => version + 1); - }, [clearUndo, initialData, resetDraft]); + }, [clearUndo, initialData, initialReplays, mainDraftState.resetDraft, replayDraftState.resetDraft]); const changes = useMemo(() => { const baseline = initialMapRef.current; @@ -302,7 +323,7 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn function clearChanges() { clearUndo(); - initialMapRef.current = buildInitialMap(draftRef.current); + initialMapRef.current = buildInitialMap(mainDraftState.draftRef.current); setBaselineVersion((version) => version + 1); } @@ -310,6 +331,77 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn 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(( next: SetStateAction, label = "Cập nhật entities" @@ -380,6 +472,14 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn return { draft, + draftRef, + mainDraft: mainDraftState.draft, + replayDraft: replayDraftState.draft, + replays, + setReplays, + activeReplayId, + switchReplayContext, + closeReplayContext, changes, undoStack, changeCount, diff --git a/src/uhm/lib/map/engines/selectingEngine.ts b/src/uhm/lib/map/engines/selectingEngine.ts index 811972a..cd6b04a 100644 --- a/src/uhm/lib/map/engines/selectingEngine.ts +++ b/src/uhm/lib/map/engines/selectingEngine.ts @@ -220,10 +220,7 @@ export function initSelect( hasMenuItems = true; } - if ( - selectedCount === 1 && - onReplayEdit - ) { + if (onReplayEdit) { const featureId = clickedFeature.id ?? clickedFeature.properties?.id; if (featureId) { menu.appendChild(createItem("Replay Edit", () => onReplayEdit(featureId))); diff --git a/src/uhm/lib/replay/mapActions.ts b/src/uhm/lib/replay/mapActions.ts new file mode 100644 index 0000000..a987b79 --- /dev/null +++ b/src/uhm/lib/replay/mapActions.ts @@ -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); + } +}; diff --git a/src/uhm/lib/replay/narrativeActions.ts b/src/uhm/lib/replay/narrativeActions.ts new file mode 100644 index 0000000..b8f7b55 --- /dev/null +++ b/src/uhm/lib/replay/narrativeActions.ts @@ -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); + } +}; diff --git a/src/uhm/lib/replay/replayDispatcher.ts b/src/uhm/lib/replay/replayDispatcher.ts new file mode 100644 index 0000000..407c40b --- /dev/null +++ b/src/uhm/lib/replay/replayDispatcher.ts @@ -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) => { + 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; + } +}; diff --git a/src/uhm/lib/replay/uiActions.ts b/src/uhm/lib/replay/uiActions.ts new file mode 100644 index 0000000..a5dec7b --- /dev/null +++ b/src/uhm/lib/replay/uiActions.ts @@ -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); + } +}; diff --git a/src/uhm/types/projects.ts b/src/uhm/types/projects.ts index 882b525..18c0b4e 100644 --- a/src/uhm/types/projects.ts +++ b/src/uhm/types/projects.ts @@ -84,8 +84,73 @@ export type EditorSnapshot = { geometry_entity?: GeometryEntitySnapshot[]; wikis?: WikiSnapshot[]; 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 = { + 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[]; + use_map_function: ReplayAction[]; + use_narrow_function: ReplayAction[]; +}; + +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. export type CommitSnapshot = EditorSnapshot;