complete replay editor v1

This commit is contained in:
taDuc
2026-05-17 21:45:33 +07:00
parent 3808086529
commit 047f662736
23 changed files with 4658 additions and 490 deletions
+124 -54
View File
@@ -1,13 +1,15 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, type SetStateAction, type PointerEvent as ReactPointerEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction, type PointerEvent as ReactPointerEvent } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import Map from "@/uhm/components/Map"; import Map, { type MapHandle } from "@/uhm/components/Map";
import Editor from "@/uhm/components/Editor"; import Editor from "@/uhm/components/Editor";
import BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel"; import BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel";
import TimelineBar from "@/uhm/components/ui/TimelineBar"; 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 ReplayEffectsSidebar from "@/uhm/components/editor/ReplayEffectsSidebar";
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";
@@ -82,6 +84,7 @@ function EditorPageContent() {
const geoBindingStatusTimeoutRef = useRef<number | null>(null); const geoBindingStatusTimeoutRef = useRef<number | null>(null);
const localCreatedEntityIdsRef = useRef<Set<string>>(new Set()); const localCreatedEntityIdsRef = useRef<Set<string>>(new Set());
const lastSelectedFeatureIdRef = useRef<string | null>(null); const lastSelectedFeatureIdRef = useRef<string | null>(null);
const mapHandleRef = useRef<MapHandle | null>(null);
const { const {
mode, mode,
internalSetMode, internalSetMode,
@@ -151,7 +154,6 @@ function EditorPageContent() {
setTimelineFilterEnabled, setTimelineFilterEnabled,
geometryBindingFilterEnabled, geometryBindingFilterEnabled,
setGeoBindingStatus, setGeoBindingStatus,
hoveredGeometryId,
geometryFocusRequest, geometryFocusRequest,
setGeometryFocusRequest, setGeometryFocusRequest,
replayFeatureId, replayFeatureId,
@@ -228,7 +230,6 @@ function EditorPageContent() {
setTimelineFilterEnabled: state.setTimelineFilterEnabled, setTimelineFilterEnabled: state.setTimelineFilterEnabled,
geometryBindingFilterEnabled: state.geometryBindingFilterEnabled, geometryBindingFilterEnabled: state.geometryBindingFilterEnabled,
setGeoBindingStatus: state.setGeoBindingStatus, setGeoBindingStatus: state.setGeoBindingStatus,
hoveredGeometryId: state.hoveredGeometryId,
geometryFocusRequest: state.geometryFocusRequest, geometryFocusRequest: state.geometryFocusRequest,
setGeometryFocusRequest: state.setGeometryFocusRequest, setGeometryFocusRequest: state.setGeometryFocusRequest,
replayFeatureId: state.replayFeatureId, replayFeatureId: state.replayFeatureId,
@@ -287,7 +288,6 @@ function EditorPageContent() {
id: String(e.id || ""), id: String(e.id || ""),
name: String(e.name || "").trim() || String(e.id || ""), name: String(e.name || "").trim() || String(e.id || ""),
description: e.description ?? null, description: e.description ?? null,
status: typeof e.status === "number" ? e.status : 1,
geometry_count: 0, geometry_count: 0,
})) }))
.filter((e) => e.id.length > 0 && e.name.length > 0); .filter((e) => e.id.length > 0 && e.name.length > 0);
@@ -297,6 +297,13 @@ function EditorPageContent() {
() => mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities), () => mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities),
[entityCatalog, snapshotEntitiesAsEntities] [entityCatalog, snapshotEntitiesAsEntities]
); );
const [replaySelection, setReplaySelection] = useState<{
stageId: number | null;
stepIndex: number | null;
}>({
stageId: null,
stepIndex: null,
});
const entitiesRef = useRef(entities); const entitiesRef = useRef(entities);
useEffect(() => { useEffect(() => {
entitiesRef.current = entities; entitiesRef.current = entities;
@@ -382,17 +389,15 @@ function EditorPageContent() {
return normalizeFeatureBindingIds(selectedFeature); return normalizeFeatureBindingIds(selectedFeature);
}, [selectedFeature]); }, [selectedFeature]);
const hoveredGeometryHighlight = useMemo(() => { const wikiChoices = useMemo(() => {
if (!hoveredGeometryId) return null; return (snapshotWikis || [])
const feature = editor.draft.features.find( .filter((wiki) => wiki && wiki.operation !== "delete")
(item) => String(item.properties.id) === hoveredGeometryId .map((wiki) => ({
); id: String(wiki.id || ""),
if (!feature) return null; label: `${(wiki.title || "").trim() || "Untitled wiki"} (${String(wiki.id || "")})`,
return { }))
type: "FeatureCollection", .filter((wiki) => wiki.id.length > 0);
features: [feature], }, [snapshotWikis]);
} as FeatureCollection;
}, [editor.draft.features, hoveredGeometryId]);
const wikiDirty = useMemo(() => { const wikiDirty = useMemo(() => {
const prev = normalizeWikisForCompare(baselineSnapshot?.wikis); const prev = normalizeWikisForCompare(baselineSnapshot?.wikis);
@@ -440,6 +445,10 @@ function EditorPageContent() {
+ (entitiesDirty ? 1 : 0) + (entitiesDirty ? 1 : 0)
+ (entityWikiDirty ? 1 : 0) + (entityWikiDirty ? 1 : 0)
+ (replayDirty ? 1 : 0); + (replayDirty ? 1 : 0);
const activeReplayStages = useMemo(
() => editor.activeReplayDraft?.detail || [],
[editor.activeReplayDraft?.detail]
);
const sectionCommands = useProjectCommands({ const sectionCommands = useProjectCommands({
editor, editor,
@@ -459,7 +468,9 @@ function EditorPageContent() {
// 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;
setReplayFeatureId(triggerId); setReplayFeatureId(triggerId);
setReplaySelection({ stageId: null, stepIndex: null });
editor.switchReplayContext(triggerId, selectedFeatureIds); editor.switchReplayContext(triggerId, selectedFeatureIds);
setSelectedFeatureIds([]);
} else if (m !== "replay") { } else if (m !== "replay") {
if (mode === "replay") { if (mode === "replay") {
editor.closeReplayContext(); editor.closeReplayContext();
@@ -467,10 +478,49 @@ function EditorPageContent() {
} }
setReplayFeatureId(null); setReplayFeatureId(null);
setHideOutside(false); setHideOutside(false);
setReplaySelection({ stageId: null, stepIndex: null });
} }
internalSetMode(m); internalSetMode(m);
}, [internalSetMode, mode, editor, selectedFeatureIds, setHideOutside, setReplayFeatureId, setSelectedFeatureIds]); }, [internalSetMode, mode, editor, selectedFeatureIds, setHideOutside, setReplayFeatureId, setSelectedFeatureIds]);
useEffect(() => {
if (!activeReplayStages.length) {
if (replaySelection.stageId != null || replaySelection.stepIndex != null) {
setReplaySelection({ stageId: null, stepIndex: null });
}
return;
}
const targetStage =
activeReplayStages.find((stage) => stage.id === replaySelection.stageId) ||
activeReplayStages[0];
const nextStageId = targetStage.id;
let nextStepIndex: number | null = null;
if (targetStage.steps.length > 0) {
if (
replaySelection.stageId === targetStage.id &&
replaySelection.stepIndex != null &&
replaySelection.stepIndex >= 0 &&
replaySelection.stepIndex < targetStage.steps.length
) {
nextStepIndex = replaySelection.stepIndex;
} else {
nextStepIndex = 0;
}
}
if (
nextStageId !== replaySelection.stageId ||
nextStepIndex !== replaySelection.stepIndex
) {
setReplaySelection({
stageId: nextStageId,
stepIndex: nextStepIndex,
});
}
}, [activeReplayStages, replaySelection.stageId, replaySelection.stepIndex]);
const effectiveGeometryVisibility = useMemo(() => { const effectiveGeometryVisibility = useMemo(() => {
const visibility: Record<string, boolean> = { ...geometryVisibility }; const visibility: Record<string, boolean> = { ...geometryVisibility };
@@ -1097,7 +1147,6 @@ function EditorPageContent() {
operation: "reference", operation: "reference",
title, title,
doc: null, doc: null,
updated_at: wiki.updated_at,
}, },
...prev, ...prev,
]; ];
@@ -1119,7 +1168,6 @@ function EditorPageContent() {
id: entityItem.entity_id, id: entityItem.entity_id,
name: (entityItem.name || "").trim() || entityItem.entity_id, name: (entityItem.name || "").trim() || entityItem.entity_id,
description: (entityItem.description || "").trim() || null, description: (entityItem.description || "").trim() || null,
status: 1,
geometry_count: 0, geometry_count: 0,
}; };
@@ -1226,7 +1274,6 @@ function EditorPageContent() {
id: entityId, id: entityId,
name, name,
description, description,
status: 1,
geometry_count: 0, geometry_count: 0,
}; };
@@ -1241,9 +1288,7 @@ function EditorPageContent() {
source: "inline", source: "inline",
operation: "create", operation: "create",
name, name,
slug: null,
description, description,
status: 1,
}, },
...prev, ...prev,
]; ];
@@ -1317,7 +1362,19 @@ function EditorPageContent() {
</> </>
) : ( ) : (
<> <>
<div style={{ width: leftPanelWidth, height: "100vh", background: "#0b1220", borderRight: "1px solid #1f2937", flex: "0 0 auto" }} /> <ReplayTimelineSidebar
width={leftPanelWidth}
replay={editor.activeReplayDraft}
selectedStageId={replaySelection.stageId}
selectedStepIndex={replaySelection.stepIndex}
pendingSaveCount={pendingSaveCount}
replayUndoStack={editor.replayUndoStack}
canUndoReplay={editor.canUndoReplay}
onSelectStep={(stageId, stepIndex) => setReplaySelection({ stageId, stepIndex })}
onMutateReplay={editor.mutateActiveReplay}
onUndoReplay={editor.undo}
onExitReplay={() => setMode("select")}
/>
<ResizeHandle <ResizeHandle
title="Resize left panel" title="Resize left panel"
onDrag={(deltaX) => { onDrag={(deltaX) => {
@@ -1373,6 +1430,7 @@ function EditorPageContent() {
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}> <div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
{isBackgroundVisibilityReady ? ( {isBackgroundVisibilityReady ? (
<Map <Map
ref={mapHandleRef}
mode={mode} mode={mode}
onSetMode={setMode} onSetMode={setMode}
draft={timelineVisibleDraft} draft={timelineVisibleDraft}
@@ -1384,8 +1442,8 @@ function EditorPageContent() {
onUpdateFeature={editor.updateFeature} onUpdateFeature={editor.updateFeature}
backgroundVisibility={backgroundVisibility} backgroundVisibility={backgroundVisibility}
geometryVisibility={effectiveGeometryVisibility} geometryVisibility={effectiveGeometryVisibility}
respectBindingFilter={geometryBindingFilterEnabled} respectBindingFilter={mode === "replay" ? false : geometryBindingFilterEnabled}
highlightFeatures={hoveredGeometryHighlight} highlightFeatures={null}
focusFeatureCollection={geometryFocusRequest?.collection || null} focusFeatureCollection={geometryFocusRequest?.collection || null}
focusRequestKey={geometryFocusRequest?.key ?? null} focusRequestKey={geometryFocusRequest?.key ?? null}
focusPadding={96} focusPadding={96}
@@ -1397,17 +1455,15 @@ function EditorPageContent() {
) : ( ) : (
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} /> <div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
)} )}
{mode !== "replay" && ( <TimelineBar
<TimelineBar year={timelineDraftYear}
year={timelineDraftYear} onYearChange={handleTimelineYearChange}
onYearChange={handleTimelineYearChange} isLoading={false}
isLoading={false} disabled={false}
disabled={false} statusText={null}
statusText={null} filterEnabled={timelineFilterEnabled}
filterEnabled={timelineFilterEnabled} onFilterEnabledChange={setTimelineFilterEnabled}
onFilterEnabledChange={setTimelineFilterEnabled} />
/>
)}
</div> </div>
) : null} ) : null}
@@ -1692,7 +1748,18 @@ function EditorPageContent() {
setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720)); setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720));
}} }}
/> />
<div style={{ width: rightPanelWidth, height: "100vh", background: "#111827", borderLeft: "1px solid #1f2937", flex: "0 0 auto" }} /> <ReplayEffectsSidebar
width={rightPanelWidth}
replay={editor.activeReplayDraft}
selectedStageId={replaySelection.stageId}
selectedStepIndex={replaySelection.stepIndex}
selectedFeatureIds={selectedFeatureIds.map((id) => String(id))}
currentTimelineYear={timelineDraftYear}
geometryChoices={geometryChoices}
wikiChoices={wikiChoices}
getCurrentMapViewState={() => mapHandleRef.current?.getViewState() ?? null}
onMutateReplay={editor.mutateActiveReplay}
/>
</> </>
)} )}
</div> </div>
@@ -1794,9 +1861,7 @@ function normalizeEntitiesForCompare(input: EntitySnapshot[] | null | undefined)
id: String(e.id), id: String(e.id),
source: e.source, source: e.source,
name: typeof e.name === "string" ? e.name.trim() : "", name: typeof e.name === "string" ? e.name.trim() : "",
slug: typeof e.slug === "string" ? e.slug : null,
description: e.description == null ? null : String(e.description), description: e.description == null ? null : String(e.description),
status: typeof e.status === "number" ? e.status : null,
})) }))
.sort((a, b) => a.id.localeCompare(b.id)); .sort((a, b) => a.id.localeCompare(b.id));
return normalized; return normalized;
@@ -1821,28 +1886,33 @@ function normalizeReplaysForCompare(input: BattleReplay[] | null | undefined) {
.filter((replay) => replay && typeof replay.geometry_id === "string" && replay.geometry_id.trim().length > 0) .filter((replay) => replay && typeof replay.geometry_id === "string" && replay.geometry_id.trim().length > 0)
.map((replay) => ({ .map((replay) => ({
geometry_id: replay.geometry_id, geometry_id: replay.geometry_id,
target_geometry_ids: normalizeReplayTargetGeometryIdsForCompare(
replay.target_geometry_ids,
replay.geometry_id
),
detail: Array.isArray(replay.detail) ? replay.detail : [], detail: Array.isArray(replay.detail) ? replay.detail : [],
replay_features: normalizeReplayFeatureCollection(replay.replay_features),
})) }))
.sort((a, b) => a.geometry_id.localeCompare(b.geometry_id)); .sort((a, b) => a.geometry_id.localeCompare(b.geometry_id));
} }
function normalizeReplayFeatureCollection(input: FeatureCollection | null | undefined) { function normalizeReplayTargetGeometryIdsForCompare(
const features = Array.isArray(input?.features) ? input.features : []; input: string[] | null | undefined,
return { geometryId: string
type: "FeatureCollection" as const, ) {
features: features const orderedIds: string[] = [];
.filter((feature) => feature && feature.properties && (typeof feature.properties.id === "string" || typeof feature.properties.id === "number")) const seen = new Set<string>();
.map((feature) => ({
type: "Feature" as const, const pushId = (rawId: string | number | null | undefined) => {
properties: { if (rawId == null) return;
...feature.properties, const id = String(rawId).trim();
id: String(feature.properties.id), if (!id || seen.has(id)) return;
}, seen.add(id);
geometry: feature.geometry, orderedIds.push(id);
}))
.sort((a, b) => String(a.properties.id).localeCompare(String(b.properties.id))),
}; };
pushId(geometryId);
for (const rawId of input || []) pushId(rawId);
return orderedIds;
} }
function normalizeGeoSearchGeometry(value: unknown): Geometry | null { function normalizeGeoSearchGeometry(value: unknown): Geometry | null {
-3
View File
@@ -332,9 +332,6 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
userSelect: "none", userSelect: "none",
}} }}
> >
<span style={{ fontSize: "12px", fontWeight: 700, color: hideOutside ? "#fb7185" : "#94a3b8" }}>
Hide Outside
</span>
<div <div
style={{ style={{
width: "32px", width: "32px",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -62,7 +62,8 @@ export function useMapInteraction({
}, [mapRef, onUpdateRef]); }, [mapRef, onUpdateRef]);
useEffect(() => { useEffect(() => {
if (mode !== "select" || !selectedFeatureIds || selectedFeatureIds.length === 0) { const allowsSelectionMode = mode === "select" || mode === "replay";
if (!allowsSelectionMode || !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 // Clear the internal selection state of the select engine to stay in sync with React state
engineBindingsRef.current.select?.clearSelection?.(false); engineBindingsRef.current.select?.clearSelection?.(false);
@@ -216,7 +216,6 @@ export default function WikiSidebarPanel({ projectId, setWikis }: Props) {
title: seedTitle, title: seedTitle,
slug: slug ?? null, slug: slug ?? null,
doc: "", doc: "",
updated_at: new Date().toISOString(),
}; };
setWikis((prev) => [seed, ...prev]); setWikis((prev) => [seed, ...prev]);
setActiveId(id); setActiveId(id);
@@ -291,7 +290,6 @@ export default function WikiSidebarPanel({ projectId, setWikis }: Props) {
title: nextTitle, title: nextTitle,
slug: nextSlug, slug: nextSlug,
doc: payload, doc: payload,
updated_at: new Date().toISOString(),
} }
) )
); );
+315 -145
View File
@@ -1,194 +1,364 @@
/** /**
* Tài liệu schema tham chiếu cho snapshot commit. * Schema tham chiếu cho commit snapshot.
* *
* Lưu ý: * Đây là file doc tự chứa, không import runtime types.
* - Đây không phải "single source of truth" của runtime hiện tại; logic normalize/build thật nằm ở * Mục tiêu là mô tả đúng shape dữ liệu hiện tại của editor/commit/replay
* `src/uhm/lib/editor/snapshot/editorSnapshot.ts`. * mà không phụ thuộc trực tiếp vào source code runtime.
* - Các phần như `replays` và nhóm `UIFunctionName` / `MapFunctionName` mô tả schema dự phòng hoặc tương lai. *
* Editor route `/editor/[id]` hiện có mode `replay`, nhưng chưa thực thi hệ thống scripted replay đầy đủ theo file này. * Ghi chú:
* - Các field denormalized dùng cho UI như `entity_ids`, `entity_name`, `binding`, `time_start`, `time_end` * - Payload tạo commit hiện là `{ snapshot_json, edit_summary }`.
* có thể xuất hiện trong editor runtime, nhưng frontend sẽ strip hoặc tái sinh chúng khi build snapshot API. * - `CommitSnapshot` hiện tương đương `EditorSnapshot`.
* - Nhiều field root để optional vì frontend còn phải đọc snapshot cũ / partial.
* - Replay actions trong dữ liệu thật dùng `params: unknown[]` theo positional tuple.
* - Snapshot replay cũ còn `replay_features` sẽ được FE migrate sang `target_geometry_ids` khi load.
* - Trước khi gửi API, frontend còn normalize thêm một số field, ví dụ `geometries[].type`.
*/ */
// ---- Root request ---- // ---- Root request ----
export type CreateCommitRequest = { export type CreateCommitRequest = {
snapshot_json: CommitSnapshot; snapshot_json: CommitSnapshot;
edit_summary: string; edit_summary: string;
};
// ---- Snapshot root ----
export type CommitSnapshot = {
editor_feature_collection: FeatureCollection;
entities: EntitySnapshot[];
geometries: GeometrySnapshot[];
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<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[];
}; };
// ---- GeoJSON / FeatureCollection ---- // ---- GeoJSON / FeatureCollection ----
export type GeometryPreset = "line" | "polygon" | "circle-area" | "point";
export type Geometry = export type Geometry =
| ({ type: "Point"; coordinates: [number, number] } & CircleGeometryMetadata) | ({ type: "Point"; coordinates: [number, number] } & CircleGeometryMetadata)
| ({ type: "MultiPoint"; coordinates: [number, number][] } & CircleGeometryMetadata) | ({ type: "MultiPoint"; coordinates: [number, number][] } & CircleGeometryMetadata)
| ({ type: "LineString"; coordinates: [number, number][] } & CircleGeometryMetadata) | ({ type: "LineString"; coordinates: [number, number][] } & CircleGeometryMetadata)
| ({ type: "MultiLineString"; coordinates: [number, number][][] } & CircleGeometryMetadata) | ({ type: "MultiLineString"; coordinates: [number, number][][] } & CircleGeometryMetadata)
| ({ type: "Polygon"; coordinates: [number, number][][] } & CircleGeometryMetadata) | ({ type: "Polygon"; coordinates: [number, number][][] } & CircleGeometryMetadata)
| ({ type: "MultiPolygon"; coordinates: [number, number][][][] } & CircleGeometryMetadata); | ({ type: "MultiPolygon"; coordinates: [number, number][][][] } & CircleGeometryMetadata);
export type CircleGeometryMetadata = { export type CircleGeometryMetadata = {
circle_center?: [number, number]; circle_center?: [number, number];
circle_radius?: number; circle_radius?: number;
}; };
export type FeatureId = string | number; export type FeatureId = string | number;
export type FeatureProperties = { export type FeatureProperties = {
id: FeatureId; id: FeatureId;
type?: string | null; //generate type?: string | null;
geometry_preset?: string | null; geometry_preset?: GeometryPreset | null;
time_start?: number | null; //generate time_start?: number | null;
time_end?: number | null; //generate time_end?: number | null;
binding?: string[]; //generate binding?: string[];
// Legacy/UI-only fields should not be relied on by the backend. // UI/editor-only denormalized fields.
// FE strips these when building snapshot_json, but we keep them optional here entity_id?: string | null;
// because older snapshots may still contain them. entity_ids?: string[];
entity_id?: string | null; //generate entity_name?: string | null;
entity_ids?: string[]; //generate entity_names?: string[];
entity_name?: string | null; //generate entity_type_id?: string | null;
entity_names?: string[]; //generate point_label?: string | null;
entity_type_id?: string | null; //generate line_label?: string | null;
polygon_label?: string | null;
}; };
export type Feature = { export type Feature = {
type: "Feature"; type: "Feature";
properties: FeatureProperties; properties: FeatureProperties;
geometry: Geometry; geometry: Geometry;
}; };
export type FeatureCollection = { export type FeatureCollection = {
type: "FeatureCollection"; type: "FeatureCollection";
features: Feature[]; features: Feature[];
}; };
// ---- Snapshot rows ---- // ---- Snapshot rows ----
export type SnapshotSource = "inline" | "ref"; export type SnapshotSource = "inline" | "ref";
export type SnapshotOperation = "create" | "update" | "delete" | "reference"; export type SnapshotOperation = "create" | "update" | "delete" | "reference";
export type EntitySnapshot = { export type EntitySnapshotOperation = SnapshotOperation;
id: string; export type GeometrySnapshotOperation = SnapshotOperation;
source: SnapshotSource; export type WikiSnapshotOperation = SnapshotOperation;
operation?: SnapshotOperation;
name?: string; export type EntitySnapshot = {
description?: string | null; id: string;
source: SnapshotSource;
operation?: EntitySnapshotOperation;
name?: string;
description?: string | null;
}; };
export type GeometrySnapshot = { export type GeometrySnapshot = {
id: string; id: string;
source: SnapshotSource; source: SnapshotSource;
operation?: SnapshotOperation; operation?: GeometrySnapshotOperation;
type?: string | null; type?: string | null;
draw_geometry?: Geometry; draw_geometry?: Geometry;
binding?: string[]; geometry?: Geometry;
time_start?: number | null; binding?: string[];
time_end?: number | null; time_start?: number | null;
bbox?: { time_end?: number | null;
min_lng: number; bbox?: {
min_lat: number; min_lng: number;
max_lng: number; min_lat: number;
max_lat: number; max_lng: number;
} | null; max_lat: number;
} | null;
}; };
export type GeometryEntitySnapshot = { export type GeometryEntitySnapshot = {
geometry_id: string; geometry_id: string;
entity_id: string; entity_id: string;
operation?: "reference" | "delete" | "binding"; operation?: "reference" | "binding" | "delete";
}; };
// FE stores wiki doc as a string (often HTML) or null for ref-only rows.
export type WikiDoc = string | null; export type WikiDoc = string | null;
export type WikiSnapshot = { export type WikiSnapshot = {
id: string; id: string;
source: SnapshotSource; source: SnapshotSource;
operation?: SnapshotOperation; operation?: WikiSnapshotOperation;
title: string;
title: string; slug?: string | null;
slug?: string | null; doc: WikiDoc;
doc: WikiDoc;
}; };
export type EntityWikiLinkSnapshot = { export type EntityWikiLinkSnapshot = {
entity_id: string; entity_id: string;
wiki_id: string; wiki_id: string;
operation?: "reference" | "delete" | "binding"; operation?: "reference" | "binding" | "delete";
}; };
// ---- Replay / Scripting System (runtime shape) ----
/**
* Canonical UI action names trong snapshot hiện tại.
* Không còn wrapper `function_name: "UI"` trong shape mới.
*/
export type UIOptionName =
| "timeline"
| "layer_panel"
| "wiki_panel"
| "zoom_panel"
| "wiki"
| "toast"
| "wiki_header"
| "playback_speed";
export type MapFunctionName =
| "set_camera_view"
| "set_time_filter"
| "enable_timeline_filter"
| "disable_timeline_filter"
| "toggle_labels"
| "show_labels"
| "hide_labels"
| "reset_camera_north";
export type GeoFunctionName =
| "fly_to_geometry"
| "fly_to_geometries"
| "set_geometry_visibility"
| "show_geometries"
| "hide_geometries"
| "fit_to_geometries"
| "orbit_camera_around_geometry"
| "pulse_geometry"
| "animate_dashed_border"
| "set_geometry_style"
| "show_geometry_label"
| "follow_geometry_path"
| "follow_geometries_path"
| "dim_other_geometries";
export type NarrativeFunctionName =
| "set_title"
| "set_descriptions"
| "show_dialog_box"
| "display_historical_image"
| "set_step_subtitle";
/**
* Runtime thật hiện dùng positional array cho params.
* File doc này giữ đúng shape đó.
*/
export type ReplayAction<T> = {
function_name: T;
params: unknown[];
};
export type ReplayStep = {
duration: number;
use_UI_function: ReplayAction<UIOptionName>[];
use_map_function: ReplayAction<MapFunctionName>[];
use_geo_function: ReplayAction<GeoFunctionName>[];
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
};
export type ReplayStage = {
id: number;
title?: string;
detail_time_start: string;
detail_time_stop: string;
steps: ReplayStep[];
};
export type BattleReplay = {
geometry_id: string;
target_geometry_ids: string[];
detail: ReplayStage[];
};
// ---- Replay tuple docs ----
/**
* Doc-only helper để giải thích meaning của từng vị trí trong `params`.
* Runtime không ép các tuple này; chúng chỉ là tài liệu tham chiếu.
*/
export type ReplayCameraViewStateDoc = {
center?: [number, number] | { lng: number; lat: number };
zoom?: number;
pitch?: number;
bearing?: number;
duration?: number;
};
export type ReplayUiParamTupleDocs = {
timeline: [visible: boolean];
layer_panel: [visible: boolean];
wiki_panel: [visible: boolean];
zoom_panel: [visible: boolean];
wiki: [wiki_id: string];
toast: [message: string];
wiki_header: [header_id: string];
playback_speed: [speed: number];
};
/**
* Snapshot cũ kiểu `function_name: "UI"` chỉ còn là legacy input.
* Frontend hiện normalize chúng sang `function_name: UIOptionName` khi load.
*/
export type ReplayMapFunctionParamTupleDocs = {
set_camera_view: [state: ReplayCameraViewStateDoc];
set_time_filter: [year: number];
enable_timeline_filter: [];
disable_timeline_filter: [];
toggle_labels: [visible: boolean];
show_labels: [];
hide_labels: [];
reset_camera_north: [];
};
export type ReplayGeoFunctionParamTupleDocs = {
fly_to_geometry: [
geometry_id: string,
zoom?: number,
padding?: number,
duration?: number,
];
fly_to_geometries: [geometry_ids: string[]];
set_geometry_visibility: [geometry_ids: string[], visible: boolean];
show_geometries: [geometry_ids: string[]];
hide_geometries: [geometry_ids: string[]];
fit_to_geometries: [
geometry_ids: string[],
padding?: number,
duration?: number,
];
orbit_camera_around_geometry: [
geometry_id: string,
zoom?: number,
pitch?: number,
revolutions?: number,
duration?: number,
];
pulse_geometry: [
geometry_id: string,
color?: string,
repeat?: number,
duration?: number,
];
animate_dashed_border: [
geometry_id: string,
color?: string,
width?: number,
speed?: number,
duration?: number,
];
set_geometry_style: [
geometry_ids: string[],
fill_color?: string,
fill_opacity?: number,
line_color?: string,
line_width?: number,
];
show_geometry_label: [
geometry_id: string,
text?: string,
color?: string,
size?: number,
];
follow_geometry_path: [
geometry_id: string,
duration?: number,
zoom?: number,
pitch?: number,
];
follow_geometries_path: [
geometry_ids: string[],
duration?: number,
zoom?: number,
pitch?: number,
];
dim_other_geometries: [
geometry_ids: string[],
opacity?: number,
];
};
export type ReplayNarrativeParamTupleDocs = {
set_title: [title: string];
set_descriptions: [text: string];
show_dialog_box: [
avatar: string,
text: string,
side?: "left" | "right",
speaker?: string,
];
display_historical_image: [
url: string,
caption?: string,
];
set_step_subtitle: [subtitle: string | null];
};
export type ReplayParamTupleDocs =
& ReplayUiParamTupleDocs
& ReplayMapFunctionParamTupleDocs
& ReplayGeoFunctionParamTupleDocs
& ReplayNarrativeParamTupleDocs;
export type ReplayActionTupleDoc<T extends keyof ReplayParamTupleDocs> = {
function_name: T;
params: ReplayParamTupleDocs[T];
};
// ---- Snapshot root ----
export type EditorSnapshot = {
// Legacy snapshots có thể còn field project embedded.
project?: {
id: string;
title: string;
};
editor_feature_collection?: FeatureCollection;
entities?: EntitySnapshot[];
geometries?: GeometrySnapshot[];
geometry_entity?: GeometryEntitySnapshot[];
wikis?: WikiSnapshot[];
entity_wiki?: EntityWikiLinkSnapshot[];
replays?: BattleReplay[];
};
export type CommitSnapshot = EditorSnapshot;
-1
View File
@@ -164,7 +164,6 @@ Có thể do:
- timeline filter - timeline filter
- geometry visibility theo type - geometry visibility theo type
- binding filter - binding filter
- replay hide outside
Không phải lúc nào cũng là bug render layer. Không phải lúc nào cũng là bug render layer.
+218
View File
@@ -0,0 +1,218 @@
# UHM Editor - state replay hiện tại
Tài liệu này mô tả đúng flow replay mode hiện tại của `/editor/[id]`.
Nguồn thật:
- `src/app/editor/[id]/page.tsx`
- `src/uhm/lib/editor/state/useEditorState.ts`
- `src/uhm/lib/editor/project/useProjectCommands.ts`
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
## 1. Kết luận ngắn
Replay mode hiện tại có 2 lớp state:
- `activeReplayDraft`
-`BattleReplay` đang chỉnh
- chỉ chứa `geometry_id`, `target_geometry_ids`, `detail`
- `replayDraft`
-`FeatureCollection` local, được FE hydrate lại từ `mainDraft + target_geometry_ids`
- chỉ dùng để map/render/select trong replay mode
Điểm quan trọng:
- `replayDraft` không còn được persist vào commit/API
- commit chỉ lưu `replays[]` với `target_geometry_ids`
- snapshot cũ còn `replay_features` sẽ được FE migrate sang `target_geometry_ids` khi load
## 2. Shape replay hiện tại
```ts
type BattleReplay = {
geometry_id: string;
target_geometry_ids: string[];
detail: ReplayStage[];
};
```
Ý nghĩa:
- `geometry_id`
- MAIN geo của replay
- cũng là key để tìm replay tương ứng
- `target_geometry_ids`
- toàn bộ geo được đưa vào replay
- phần tử đầu nên luôn là MAIN geo
- `detail`
- stage/step/actions của kịch bản
## 3. Replay được mở như thế nào
Khi vào replay từ UI:
1. editor lấy `triggerId`
- ưu tiên `selectedFeatureIds[0]`
- nếu chưa có selection thì dùng `featureId` vừa click
2. gọi `editor.switchReplayContext(triggerId, selectedFeatureIds)`
3. `switchReplayContext()` sẽ:
- flush replay cũ nếu đang mở replay khác
- tìm replay đã tồn tại theo `geometry_id`
- nếu chưa có thì tạo seed mới
## 4. Seed replay được tạo ra sao
Replay seed mới có dạng:
```ts
{
geometry_id: triggerId,
target_geometry_ids: [...],
detail: []
}
```
`target_geometry_ids` được build từ:
- MAIN geo
- toàn bộ bulk selection hiện tại
- toàn bộ `binding` của MAIN geo trong `mainDraft`
Rule hiện tại:
- MAIN geo luôn đứng đầu
- geo trùng sẽ được dedupe
- nếu replay đã tồn tại sẵn, FE giữ `detail` cũ và chỉ append thêm geo mới còn thiếu vào `target_geometry_ids`
## 5. `replayDraft` được hydrate thế nào
`replayDraft` không còn nằm trong snapshot.
Mỗi lần:
- mở replay
- undo replay session
- restore `activeReplayDraft`
FE sẽ hydrate lại:
```ts
replayDraft = hydrate(mainDraft, activeReplayDraft.target_geometry_ids)
```
Hydrate hiện tại:
- lấy feature từ `mainDraft` theo đúng thứ tự `target_geometry_ids`
- clone ra `FeatureCollection` mới
- flatten `binding` thành `[]` để các geo trong replay bình đẳng với nhau
## 6. Trong replay mode map đang đọc gì
`useEditorState()` vẫn switch active draft như cũ:
```ts
const activeDraft = mode === "replay" ? replayDraft : mainDraft;
```
Nên khi `mode === "replay"`:
- `editor.draft` trỏ vào `replayDraft`
- `editor.draftRef` trỏ vào `replayDraftRef`
- map chỉ render tập geo đang nằm trong `target_geometry_ids`
## 7. Replay mode còn sửa geometry không
Không.
Hiện tại state layer đã chặn toàn bộ nhánh mutate geometry trong replay mode:
- `createFeature`
- `createFeatureWithSnapshotEntities`
- `patchFeatureProperties`
- `patchFeaturePropertiesBatch`
- `updateFeature`
- `deleteFeature`
Nghĩa là:
- replay mode chỉ còn là nơi viết script replay
- không còn persist hay commit geometry edit riêng của replay
## 8. Cái gì vẫn được sửa trong replay mode
Replay sidebar vẫn sửa:
- `detail[]`
- `stage`
- `step`
- các action `UI / map / geo / narrative`
Các thay đổi đó đi qua:
- `editor.mutateActiveReplay`
- `applyReplaySessionMutation()`
Undo replay vẫn riêng ở:
- `replayUndoStack`
## 9. Khi nào replay được flush về `replays[]`
`activeReplayDraft` chỉ là session đang mở.
Nó được flush về `replays[]` khi:
- thoát replay mode
- chuyển sang replay khác
Hàm chịu trách nhiệm là:
- `finalizeActiveReplaySession()`
## 10. Commit lấy replay từ đâu
Commit không lấy `activeReplayDraft` trực tiếp.
Nó lấy:
- `editor.effectiveReplays`
`effectiveReplays` là:
- `replays`
- cộng thêm overlay của `activeReplayDraft` nếu session hiện tại đã thay đổi nhưng chưa flush
Vì vậy:
- đang còn ở replay mode vẫn commit được replay mới nhất
- không cần thoát replay mode mới lưu được script
## 11. Replay đi qua API ra sao
Payload commit hiện tại chỉ gửi:
- `geometry_id`
- `target_geometry_ids`
- `detail`
Không gửi:
- `replayDraft`
- `replay_features`
- `FeatureCollection` local của replay mode
## 12. Migrate dữ liệu cũ
Snapshot cũ nếu còn:
```ts
replay_features?: FeatureCollection
```
thì FE sẽ:
- đọc `replay_features.features[].properties.id`
- chuyển chúng thành `target_geometry_ids`
- bỏ `replay_features` khỏi runtime replay mới
Nên dữ liệu cũ vẫn mở được, nhưng commit mới sẽ ra schema mới.
+246
View File
@@ -0,0 +1,246 @@
# Replay Export JSON
Tài liệu này mô tả đúng payload mà nút `Export JSON` của replay đang xuất ra hiện tại.
Nguồn thật:
- `src/uhm/components/editor/ReplayTimelineSidebar.tsx`
## 1. Kết luận ngắn
Export hiện tại có dạng:
```json
{
"exported_at": "2026-05-17T12:34:56.000Z",
"geometry_id": "geo-main-id",
"current_replay": { "...": "BattleReplay hiện tại" },
"snapshot_fragment": {
"replays": [
{ "...": "chính current_replay" }
]
}
}
```
Trong đó:
- `current_replay` là replay đang edit
- `snapshot_fragment.replays[0]` là cùng replay đó, nhưng đặt vào đúng chỗ trong commit snapshot
## 2. Root payload
```ts
type ReplayExportPayload = {
exported_at: string;
geometry_id: string;
current_replay: BattleReplay;
snapshot_fragment: {
replays: BattleReplay[];
};
};
```
Ý nghĩa:
- `exported_at`
- timestamp ISO lúc bấm export
- chỉ để debug
- `geometry_id`
- copy nhanh từ `current_replay.geometry_id`
- `current_replay`
- replay draft hiện tại
- `snapshot_fragment`
- fragment để test replay này nếu đặt vào commit snapshot thật
## 3. Shape của `current_replay`
```ts
type BattleReplay = {
geometry_id: string;
target_geometry_ids: string[];
detail: ReplayStage[];
};
```
Ý nghĩa:
- `geometry_id`
- MAIN geo của replay
- `target_geometry_ids`
- toàn bộ geo thuộc replay
- phần tử đầu nên luôn là MAIN geo
- `detail`
- stage/step/actions của replay script
## 4. `target_geometry_ids` là gì
Đây là phần thay thế cho `replay_features` cũ.
FE không còn export/persist cả `FeatureCollection` riêng của replay nữa. Thay vào đó chỉ lưu:
- geo MAIN
- các geo được đưa vào replay từ bulk select
- binding của MAIN geo
Khi mở replay, FE sẽ hydrate lại `replayDraft` từ:
- `mainDraft`
- `target_geometry_ids`
## 5. Shape của `detail`
```ts
type ReplayStage = {
id: number;
title?: string;
detail_time_start: string;
detail_time_stop: string;
steps: ReplayStep[];
};
```
```ts
type ReplayStep = {
duration: number;
use_UI_function: ReplayAction<UIOptionName>[];
use_map_function: ReplayAction<MapFunctionName>[];
use_geo_function: ReplayAction<GeoFunctionName>[];
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
};
```
Ý nghĩa:
- `stage` là cụm lớn theo mốc thời gian hoặc nhịp kể chuyện
- `step` là đơn vị phát nhỏ hơn trong một stage
- `duration` là trọng số thời gian của step
- action hiện tách thành 4 nhóm
## 6. Ví dụ JSON gần thực tế
```json
{
"exported_at": "2026-05-17T12:34:56.000Z",
"geometry_id": "019e13ab-4823-76c5-afde-2391c0cf311d",
"current_replay": {
"geometry_id": "019e13ab-4823-76c5-afde-2391c0cf311d",
"target_geometry_ids": [
"019e13ab-4823-76c5-afde-2391c0cf311d",
"019e13ab-6063-713d-a28f-98a1556817a7",
"019e13ab-5896-713a-111111111111"
],
"detail": [
{
"id": 0,
"title": "Mở đầu chiến dịch",
"detail_time_start": "1939",
"detail_time_stop": "1940",
"steps": [
{
"duration": 1000,
"use_UI_function": [
{
"function_name": "timeline",
"params": [false]
}
],
"use_map_function": [
{
"function_name": "set_time_filter",
"params": [1939]
}
],
"use_geo_function": [
{
"function_name": "fly_to_geometries",
"params": [
[
"019e13ab-4823-76c5-afde-2391c0cf311d",
"019e13ab-6063-713d-a28f-98a1556817a7"
]
]
}
],
"use_narrow_function": [
{
"function_name": "set_title",
"params": ["Chiến dịch bắt đầu"]
}
]
}
]
}
]
},
"snapshot_fragment": {
"replays": [
{
"geometry_id": "019e13ab-4823-76c5-afde-2391c0cf311d",
"target_geometry_ids": [
"019e13ab-4823-76c5-afde-2391c0cf311d",
"019e13ab-6063-713d-a28f-98a1556817a7",
"019e13ab-5896-713a-111111111111"
],
"detail": [
{
"id": 0,
"title": "Mở đầu chiến dịch",
"detail_time_start": "1939",
"detail_time_stop": "1940",
"steps": [
{
"duration": 1000,
"use_UI_function": [
{
"function_name": "timeline",
"params": [false]
}
],
"use_map_function": [
{
"function_name": "set_time_filter",
"params": [1939]
}
],
"use_geo_function": [
{
"function_name": "fly_to_geometries",
"params": [
[
"019e13ab-4823-76c5-afde-2391c0cf311d",
"019e13ab-6063-713d-a28f-98a1556817a7"
]
]
}
],
"use_narrow_function": [
{
"function_name": "set_title",
"params": ["Chiến dịch bắt đầu"]
}
]
}
]
}
]
}
]
}
}
```
## 7. Cách đọc file export
Khi nhìn file export:
- nếu cần biết replay bám vào geo nào, xem `geometry_id`
- nếu cần biết replay gồm những geo nào, xem `target_geometry_ids`
- nếu cần biết script sẽ làm gì, xem `detail[].steps[]`
- nếu cần so với commit snapshot, xem `snapshot_fragment.replays`
## 8. Ghi chú quan trọng
- Export hiện tại không còn chứa `replay_features`
- Nếu mở replay cũ từng dùng `replay_features`, FE sẽ migrate sang `target_geometry_ids` trước khi export
- `current_replay``snapshot_fragment.replays[0]` hiện vẫn là cùng một replay, chỉ khác góc nhìn
-1
View File
@@ -157,7 +157,6 @@ Binding hiện tại:
Nó là mode hiển thị tập trung vào một geometry: Nó là mode hiển thị tập trung vào một geometry:
- có nút thoát replay - có nút thoát replay
- có toggle `Hide Outside`
- có thể ẩn geometry ngoài danh sách `binding` - có thể ẩn geometry ngoài danh sách `binding`
## 8. Đồng bộ selection và feature state ## 8. Đồng bộ selection và feature state
@@ -306,10 +306,11 @@ function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnaps
const id = String(e.id); const id = String(e.id);
const source: EntitySnapshot["source"] = e.source === "inline" ? "inline" : "ref"; const source: EntitySnapshot["source"] = e.source === "inline" ? "inline" : "ref";
return { return {
...e,
id, id,
source, source,
operation: "reference", operation: "reference",
name: typeof e.name === "string" ? e.name : undefined,
description: typeof e.description === "string" ? e.description : e.description ?? null,
}; };
}); });
} }
@@ -323,10 +324,23 @@ function toEditorSessionGeometries(input: EditorSnapshot["geometries"]): Geometr
const id = String(g.id); const id = String(g.id);
const source: GeometrySnapshot["source"] = g.source === "inline" ? "inline" : "ref"; const source: GeometrySnapshot["source"] = g.source === "inline" ? "inline" : "ref";
return { return {
...g,
id, id,
source, source,
operation: "reference", operation: "reference",
type: g.type ?? undefined,
draw_geometry: g.draw_geometry,
geometry: g.geometry,
binding: Array.isArray(g.binding) ? [...g.binding] : undefined,
time_start: typeof g.time_start === "number" ? g.time_start : g.time_start ?? undefined,
time_end: typeof g.time_end === "number" ? g.time_end : g.time_end ?? undefined,
bbox: g.bbox
? {
min_lng: g.bbox.min_lng,
min_lat: g.bbox.min_lat,
max_lng: g.bbox.max_lng,
max_lat: g.bbox.max_lat,
}
: g.bbox ?? undefined,
}; };
}); });
} }
@@ -350,7 +364,6 @@ function toEditorSessionGeometryEntity(input: EditorSnapshot["geometry_entity"])
geometry_id, geometry_id,
entity_id, entity_id,
operation: "reference", operation: "reference",
base_links_hash: safeRow.base_links_hash,
}); });
} }
return Array.from(deduped.values()).sort((a, b) => { return Array.from(deduped.values()).sort((a, b) => {
@@ -368,9 +381,12 @@ function toEditorSessionWikis(input: EditorSnapshot["wikis"]): WikiSnapshot[] {
.map((w) => { .map((w) => {
const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref"; const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref";
return { return {
...w, id: w.id,
source, source,
operation: "reference", operation: "reference",
title: typeof w.title === "string" ? w.title : "",
slug: w.slug ?? null,
doc: w.doc ?? null,
}; };
}); });
} }
+289 -35
View File
@@ -5,7 +5,16 @@ 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, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo"; import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
import type { BattleReplay, EditorSnapshot, Project } from "@/uhm/types/projects"; import type {
BattleReplay,
EditorSnapshot,
GeoFunctionName,
MapFunctionName,
NarrativeFunctionName,
Project,
ReplayAction,
UIOptionName,
} 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";
@@ -22,7 +31,6 @@ interface RawEntityRow extends UnknownRecord {
ref?: { id?: string }; ref?: { id?: string };
name?: string; name?: string;
description?: string; description?: string;
status?: number;
} }
interface RawWikiRow extends UnknownRecord { interface RawWikiRow extends UnknownRecord {
@@ -33,14 +41,27 @@ interface RawWikiRow extends UnknownRecord {
title?: string; title?: string;
slug?: string; slug?: string;
doc?: string; doc?: string;
updated_at?: string | number; }
interface RawGeometryRow extends UnknownRecord {
id?: string | number;
operation?: string;
source?: string;
ref?: { id?: string };
type?: unknown;
geo_type?: unknown;
draw_geometry?: unknown;
geometry?: unknown;
binding?: unknown;
time_start?: unknown;
time_end?: unknown;
bbox?: unknown;
} }
interface RawGeometryEntityRow extends UnknownRecord { interface RawGeometryEntityRow extends UnknownRecord {
geometry_id?: string | number; geometry_id?: string | number;
entity_id?: string | number; entity_id?: string | number;
operation?: string; operation?: string;
base_links_hash?: string;
} }
interface RawEntityWikiRow extends UnknownRecord { interface RawEntityWikiRow extends UnknownRecord {
@@ -96,14 +117,13 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const refId = getRefId(e.ref); const refId = getRefId(e.ref);
const source: "inline" | "ref" = const source: "inline" | "ref" =
existingSource || (refId || opRaw === "reference" ? "ref" : "inline"); existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
const rest: UnknownRecord = { ...e };
delete rest.ref;
return { return {
...(rest as unknown as Omit<EntitySnapshot, "id" | "source" | "operation">),
id, id,
source, source,
operation, operation,
name: typeof e.name === "string" ? e.name : undefined,
description: typeof e.description === "string" ? e.description : e.description == null ? undefined : undefined,
}; };
}) })
: undefined; : undefined;
@@ -113,25 +133,37 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
? geometriesRaw ? geometriesRaw
.filter(isRecord) .filter(isRecord)
.map((g) => { .map((g) => {
const id = getStringId(g.id); const row = g as RawGeometryRow;
const opRaw = typeof g.operation === "string" ? g.operation : undefined; const id = getStringId(row.id);
const opRaw = typeof row.operation === "string" ? row.operation : undefined;
const operation: GeometrySnapshot["operation"] = const operation: GeometrySnapshot["operation"] =
opRaw === "delete" ? "delete" : "reference"; opRaw === "delete" ? "delete" : "reference";
const existingSource = g.source === "inline" || g.source === "ref" ? g.source : undefined; const existingSource = row.source === "inline" || row.source === "ref" ? row.source : undefined;
const refId = getRefId(g.ref); const refId = getRefId(row.ref);
const hasInlineGeometry = "draw_geometry" in g || "geometry" in g; const hasInlineGeometry = "draw_geometry" in row || "geometry" in row;
const source: "inline" | "ref" = existingSource || (refId || !hasInlineGeometry ? "ref" : "inline"); const source: "inline" | "ref" = existingSource || (refId || !hasInlineGeometry ? "ref" : "inline");
const rest: UnknownRecord = { ...g }; const typeKey = normalizeGeoTypeKey(row.type) || normalizeGeoTypeKey(row.geo_type);
delete rest.ref;
const typeKey = normalizeGeoTypeKey(rest.type) || normalizeGeoTypeKey(rest.geo_type);
delete rest.geo_type;
return { return {
...(rest as unknown as Omit<GeometrySnapshot, "id" | "source" | "operation">),
id, id,
source, source,
operation, operation,
type: typeKey, type: typeKey,
draw_geometry: row.draw_geometry as GeometrySnapshot["draw_geometry"],
geometry: row.geometry as GeometrySnapshot["geometry"],
binding: Array.isArray(row.binding) ? row.binding as string[] : undefined,
time_start: typeof row.time_start === "number" ? row.time_start : row.time_start == null ? undefined : undefined,
time_end: typeof row.time_end === "number" ? row.time_end : row.time_end == null ? undefined : undefined,
bbox: isRecord(row.bbox)
? {
min_lng: Number(row.bbox.min_lng),
min_lat: Number(row.bbox.min_lat),
max_lng: Number(row.bbox.max_lng),
max_lat: Number(row.bbox.max_lat),
}
: row.bbox == null
? undefined
: undefined,
}; };
}) })
: undefined; : undefined;
@@ -141,6 +173,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
? wikisRaw ? wikisRaw
.filter(isRecord) .filter(isRecord)
.map((w) => { .map((w) => {
const row = w as RawWikiRow;
const id = typeof w.id === "string" ? w.id : ""; const id = typeof w.id === "string" ? w.id : "";
const opRaw = typeof w.operation === "string" ? w.operation : undefined; const opRaw = typeof w.operation === "string" ? w.operation : undefined;
const operation: WikiSnapshot["operation"] = const operation: WikiSnapshot["operation"] =
@@ -149,14 +182,13 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const refId = getRefId(w.ref); const refId = getRefId(w.ref);
const source: "inline" | "ref" = const source: "inline" | "ref" =
existingSource || (refId || opRaw === "reference" ? "ref" : "inline"); existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
const rest: UnknownRecord = { ...w };
delete rest.ref;
return { return {
...(rest as unknown as Omit<WikiSnapshot, "id" | "source" | "operation">),
id, id,
source, source,
operation, operation,
title: typeof row.title === "string" ? row.title : "",
slug: row.slug ?? null,
doc: typeof row.doc === "string" ? row.doc : null,
}; };
}) })
: undefined; : undefined;
@@ -173,7 +205,6 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const geometry_id = getStringId(row.geometry_id); const geometry_id = getStringId(row.geometry_id);
const entity_id = typeof row.entity_id === "string" ? row.entity_id : ""; const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
return { return {
...(row as unknown as Omit<GeometryEntitySnapshot, "geometry_id" | "entity_id">),
geometry_id, geometry_id,
entity_id, entity_id,
}; };
@@ -298,7 +329,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, replays: normalizeReplaySnapshots(snapshot.replays),
}; };
} }
@@ -352,10 +383,11 @@ export function buildEditorSnapshot(options: {
const cloned = JSON.parse(JSON.stringify(prev)) as EntitySnapshot; const cloned = JSON.parse(JSON.stringify(prev)) as EntitySnapshot;
delete cloned.operation; delete cloned.operation;
entityRows.set(id, { entityRows.set(id, {
...cloned,
id, id,
source: "inline", source: "inline",
operation: "reference", operation: "reference",
name: typeof cloned.name === "string" ? cloned.name : undefined,
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
}); });
} }
for (const row of options.snapshotEntities || []) { for (const row of options.snapshotEntities || []) {
@@ -374,11 +406,11 @@ export function buildEditorSnapshot(options: {
if (opRaw === "delete") continue; if (opRaw === "delete") continue;
const operation: EntitySnapshot["operation"] = source === "ref" ? "reference" : opRaw; const operation: EntitySnapshot["operation"] = source === "ref" ? "reference" : opRaw;
entityRows.set(id, { entityRows.set(id, {
...cloned,
id, id,
source, source,
name, name,
operation, operation,
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
}); });
} }
@@ -391,9 +423,7 @@ export function buildEditorSnapshot(options: {
source: "ref", source: "ref",
operation: "reference", operation: "reference",
name: id, name: id,
slug: null,
description: null, description: null,
status: 1,
}); });
} }
@@ -405,9 +435,7 @@ export function buildEditorSnapshot(options: {
source: "ref", source: "ref",
operation: "reference", operation: "reference",
name: entityId, name: entityId,
slug: null,
description: null, description: null,
status: 1,
}); });
} }
} }
@@ -457,7 +485,7 @@ export function buildEditorSnapshot(options: {
}); });
} }
const baselineGeometryEntity = new globalThis.Map<string, string | undefined>(); const baselineGeometryEntity = new Set<string>();
for (const r of options.previousSnapshot?.geometry_entity || []) { for (const r of options.previousSnapshot?.geometry_entity || []) {
const row = r as RawGeometryEntityRow; const row = r as RawGeometryEntityRow;
if (!row) continue; if (!row) continue;
@@ -465,7 +493,7 @@ export function buildEditorSnapshot(options: {
const geometry_id = typeof row.geometry_id === "string" || typeof row.geometry_id === "number" ? String(row.geometry_id).trim() : ""; const geometry_id = typeof row.geometry_id === "string" || typeof row.geometry_id === "number" ? String(row.geometry_id).trim() : "";
const entity_id = typeof row.entity_id === "string" || typeof row.entity_id === "number" ? String(row.entity_id).trim() : ""; const entity_id = typeof row.entity_id === "string" || typeof row.entity_id === "number" ? String(row.entity_id).trim() : "";
if (!geometry_id || !entity_id) continue; if (!geometry_id || !entity_id) continue;
baselineGeometryEntity.set(`${geometry_id}::${entity_id}`, row.base_links_hash); baselineGeometryEntity.add(`${geometry_id}::${entity_id}`);
} }
const currentGeometryEntityRows: GeometryEntitySnapshot[] = []; const currentGeometryEntityRows: GeometryEntitySnapshot[] = [];
@@ -481,18 +509,17 @@ export function buildEditorSnapshot(options: {
geometry_id, geometry_id,
entity_id, entity_id,
operation: baselineGeometryEntity.has(key) ? "reference" : "binding", operation: baselineGeometryEntity.has(key) ? "reference" : "binding",
base_links_hash: baselineGeometryEntity.get(key),
}); });
} }
} }
// Relations removed during this session are emitted as "delete" operations. // Relations removed during this session are emitted as "delete" operations.
// NOTE: The editor state itself should remove the relation row; the commit payload is the delta. // NOTE: The editor state itself should remove the relation row; the commit payload is the delta.
for (const [key, base_links_hash] of baselineGeometryEntity.entries()) { for (const key of baselineGeometryEntity.values()) {
if (currentGeometryEntityKeys.has(key)) continue; if (currentGeometryEntityKeys.has(key)) continue;
const [geometry_id, entity_id] = key.split("::"); const [geometry_id, entity_id] = key.split("::");
if (!geometry_id || !entity_id) continue; if (!geometry_id || !entity_id) continue;
currentGeometryEntityRows.push({ geometry_id, entity_id, operation: "delete", base_links_hash }); currentGeometryEntityRows.push({ geometry_id, entity_id, operation: "delete" });
} }
const geometryEntity = dedupeAndSortGeometryEntity(currentGeometryEntityRows); const geometryEntity = dedupeAndSortGeometryEntity(currentGeometryEntityRows);
@@ -587,7 +614,6 @@ export function buildEditorSnapshot(options: {
title: typeof prev.title === "string" ? prev.title : "Untitled wiki", title: typeof prev.title === "string" ? prev.title : "Untitled wiki",
slug: row.slug ?? null, slug: row.slug ?? null,
doc: row.doc ?? null, doc: row.doc ?? null,
updated_at: row.updated_at ?? undefined,
} as WikiSnapshot); } as WikiSnapshot);
} }
const wikis = [...wikisCurrent, ...deletedWikis]; const wikis = [...wikisCurrent, ...deletedWikis];
@@ -675,9 +701,237 @@ export function toApiEditorSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
}); });
} }
if (Array.isArray(cloned.replays)) {
cloned.replays = cloned.replays.map((replay) => {
const geometryId = typeof replay?.geometry_id === "string" ? replay.geometry_id : "";
return {
geometry_id: geometryId,
target_geometry_ids: normalizeReplayTargetGeometryIds(replay as unknown, geometryId),
detail: Array.isArray(replay?.detail) ? replay.detail : [],
};
});
}
return cloned; return cloned;
} }
function normalizeReplaySnapshots(value: unknown): BattleReplay[] | undefined {
if (!Array.isArray(value)) return undefined;
return value.map((replay) => normalizeReplaySnapshot(replay as BattleReplay));
}
function normalizeReplaySnapshot(replay: BattleReplay): BattleReplay {
const geometryId = typeof replay?.geometry_id === "string" ? replay.geometry_id : "";
return {
geometry_id: geometryId,
target_geometry_ids: normalizeReplayTargetGeometryIds(replay, geometryId),
detail: Array.isArray(replay.detail)
? replay.detail.map((stage) => ({
...stage,
steps: Array.isArray(stage.steps)
? stage.steps.map((step) => {
const { mapActions, geoActions } = normalizeReplayMapAndGeoActions(
isRecord(step) ? step.use_map_function : undefined,
isRecord(step) ? step.use_geo_function : undefined
);
return {
...step,
use_UI_function: normalizeReplayUiActions(isRecord(step) ? step.use_UI_function : undefined),
use_map_function: mapActions,
use_geo_function: geoActions,
use_narrow_function: normalizeReplayNarrativeActions(
isRecord(step) ? step.use_narrow_function : undefined
),
};
})
: [],
}))
: [],
};
}
function normalizeReplayTargetGeometryIds(replay: unknown, geometryId: string): string[] {
const orderedIds: string[] = [];
const seen = new Set<string>();
const pushId = (rawId: unknown) => {
if (typeof rawId !== "string" && typeof rawId !== "number") return;
const id = String(rawId).trim();
if (!id || seen.has(id)) return;
seen.add(id);
orderedIds.push(id);
};
pushId(geometryId);
if (isRecord(replay) && Array.isArray(replay.target_geometry_ids)) {
for (const rawId of replay.target_geometry_ids) pushId(rawId);
}
if (isRecord(replay) && isRecord(replay.replay_features) && Array.isArray(replay.replay_features.features)) {
for (const feature of replay.replay_features.features) {
if (!isRecord(feature) || !isRecord(feature.properties)) continue;
pushId(feature.properties.id);
}
}
return orderedIds;
}
function normalizeReplayUiActions(actions: unknown): ReplayAction<UIOptionName>[] {
if (!Array.isArray(actions)) return [];
return actions.flatMap((action) => {
if (!isRecord(action)) return [];
const functionName = action.function_name;
const params = Array.isArray(action.params) ? action.params : [];
if (functionName === "UI") {
const option = normalizeReplayUiOption(params[0]);
if (!option) return [];
return [{
function_name: option,
params: params.slice(1),
}];
}
const option = normalizeReplayUiOption(functionName);
if (!option) return [];
return [{
function_name: option,
params,
}];
});
}
function normalizeReplayUiOption(value: unknown): UIOptionName | null {
switch (value) {
case "timeline":
case "layer_panel":
case "wiki_panel":
case "zoom_panel":
case "wiki":
case "toast":
case "wiki_header":
case "playback_speed":
return value;
default:
return null;
}
}
function normalizeReplayMapAndGeoActions(
mapActions: unknown,
geoActions: unknown
): {
mapActions: ReplayAction<MapFunctionName>[];
geoActions: ReplayAction<GeoFunctionName>[];
} {
const combinedActions = [
...(Array.isArray(mapActions) ? mapActions : []),
...(Array.isArray(geoActions) ? geoActions : []),
];
const normalizedMapActions: ReplayAction<MapFunctionName>[] = [];
const normalizedGeoActions: ReplayAction<GeoFunctionName>[] = [];
for (const action of combinedActions) {
if (!isRecord(action)) continue;
const functionName = action.function_name;
const params = Array.isArray(action.params) ? action.params : [];
const mapFunctionName = normalizeReplayMapFunctionName(functionName);
if (mapFunctionName) {
normalizedMapActions.push({
function_name: mapFunctionName,
params,
});
continue;
}
const geoFunctionName = normalizeReplayGeoFunctionName(functionName);
if (geoFunctionName) {
normalizedGeoActions.push({
function_name: geoFunctionName,
params,
});
}
}
return {
mapActions: normalizedMapActions,
geoActions: normalizedGeoActions,
};
}
function normalizeReplayMapFunctionName(value: unknown): MapFunctionName | null {
switch (value) {
case "set_camera_view":
case "set_time_filter":
case "enable_timeline_filter":
case "disable_timeline_filter":
case "toggle_labels":
case "show_labels":
case "hide_labels":
case "reset_camera_north":
return value;
default:
return null;
}
}
function normalizeReplayGeoFunctionName(value: unknown): GeoFunctionName | null {
switch (value) {
case "fly_to_geometry":
case "fly_to_geometries":
case "set_geometry_visibility":
case "show_geometries":
case "hide_geometries":
case "fit_to_geometries":
case "orbit_camera_around_geometry":
case "pulse_geometry":
case "animate_dashed_border":
case "set_geometry_style":
case "show_geometry_label":
case "follow_geometry_path":
case "follow_geometries_path":
case "dim_other_geometries":
return value;
default:
return null;
}
}
function normalizeReplayNarrativeActions(actions: unknown): ReplayAction<NarrativeFunctionName>[] {
if (!Array.isArray(actions)) return [];
return actions.flatMap((action) => {
if (!isRecord(action)) return [];
const functionName = normalizeReplayNarrativeFunctionName(action.function_name);
if (!functionName) return [];
return [{
function_name: functionName,
params: Array.isArray(action.params) ? action.params : [],
}];
});
}
function normalizeReplayNarrativeFunctionName(value: unknown): NarrativeFunctionName | null {
switch (value) {
case "set_title":
case "set_descriptions":
case "show_dialog_box":
case "display_historical_image":
case "set_step_subtitle":
return value;
default:
return null;
}
}
function dedupeAndSortGeometryEntity(rows: GeometryEntitySnapshot[]): GeometryEntitySnapshot[] { function dedupeAndSortGeometryEntity(rows: GeometryEntitySnapshot[]): GeometryEntitySnapshot[] {
const seen = new Set<string>(); const seen = new Set<string>();
const deduped: GeometryEntitySnapshot[] = []; const deduped: GeometryEntitySnapshot[] = [];
+105 -97
View File
@@ -38,8 +38,8 @@ type ReplayDraftSyncMode = "none" | "reset";
// State trung tâm của editor: // State trung tâm của editor:
// - main draft: dữ liệu section thông thường // - main draft: dữ liệu section thông thường
// - active replay draft: bản sao đầy đủ của toàn bộ BattleReplay đang chỉnh // - active replay draft: bản sao BattleReplay đang chỉnh (script + target ids)
// - replay feature draft: FeatureCollection con để map/editor hiện tại thao tác // - replay feature draft: FeatureCollection local được hydrate từ mainDraft + target ids
export function useEditorState( export function useEditorState(
initialData: FeatureCollection, initialData: FeatureCollection,
options: { options: {
@@ -86,9 +86,9 @@ export function useEditorState(
return cloned; return cloned;
}, []); }, []);
const syncReplayFeatureDraft = useCallback((nextFeatures: FeatureCollection) => { const syncReplayFeatureDraft = useCallback((nextReplay: BattleReplay | null) => {
resetReplayDraft(deepClone(nextFeatures)); resetReplayDraft(buildReplayFeatureDraft(mainDraftRef.current, nextReplay));
}, [resetReplayDraft]); }, [mainDraftRef, resetReplayDraft]);
const setActiveReplayDraftState = useCallback(( const setActiveReplayDraftState = useCallback((
next: SetStateAction<BattleReplay | null>, next: SetStateAction<BattleReplay | null>,
@@ -100,7 +100,7 @@ export function useEditorState(
setActiveReplayDraft(cloned); setActiveReplayDraft(cloned);
if (syncMode === "reset") { if (syncMode === "reset") {
syncReplayFeatureDraft(cloned?.replay_features || EMPTY_FEATURE_COLLECTION); syncReplayFeatureDraft(cloned);
} }
return cloned; return cloned;
@@ -302,9 +302,6 @@ export function useEditorState(
const prevReplay = deepClone(currentReplay); const prevReplay = deepClone(currentReplay);
const nextReplay = deepClone(currentReplay); const nextReplay = deepClone(currentReplay);
if (!nextReplay.replay_features) {
nextReplay.replay_features = deepClone(EMPTY_FEATURE_COLLECTION);
}
mutator(nextReplay); mutator(nextReplay);
if (replayEquals(prevReplay, nextReplay)) { if (replayEquals(prevReplay, nextReplay)) {
return false; return false;
@@ -364,10 +361,6 @@ export function useEditorState(
const featureClone = deepClone(feature); const featureClone = deepClone(feature);
if (mode === "replay") { if (mode === "replay") {
applyReplaySessionMutation(`Replay: thêm #${featureClone.properties.id}`, (draftReplay) => {
const featureDraft = ensureReplayFeatureCollection(draftReplay);
featureDraft.features = [...featureDraft.features, featureClone];
});
return; return;
} }
@@ -384,7 +377,6 @@ export function useEditorState(
label = "Import geometry" label = "Import geometry"
) { ) {
if (mode === "replay") { if (mode === "replay") {
createFeature(feature);
return; return;
} }
@@ -433,18 +425,6 @@ export function useEditorState(
patch: Partial<FeatureProperties> patch: Partial<FeatureProperties>
) { ) {
if (mode === "replay") { if (mode === "replay") {
applyReplaySessionMutation(`Replay: cập nhật thuộc tính #${id}`, (draftReplay) => {
const featureDraft = ensureReplayFeatureCollection(draftReplay);
const idx = featureDraft.features.findIndex((feature) => feature.properties.id === id);
if (idx === -1) return;
featureDraft.features[idx] = {
...featureDraft.features[idx],
properties: {
...featureDraft.features[idx].properties,
...deepClone(patch),
},
};
});
return; return;
} }
@@ -474,30 +454,6 @@ export function useEditorState(
label = "Cập nhật nhiều geometry" label = "Cập nhật nhiều geometry"
) { ) {
if (mode === "replay") { if (mode === "replay") {
applyReplaySessionMutation(label, (draftReplay) => {
const featureDraft = ensureReplayFeatureCollection(draftReplay);
const mergedPatches = new Map<FeatureProperties["id"], Partial<FeatureProperties>>();
for (const item of patches || []) {
if (!item) continue;
const prev = mergedPatches.get(item.id) || {};
mergedPatches.set(item.id, {
...prev,
...deepClone(item.patch),
});
}
featureDraft.features = featureDraft.features.map((feature) => {
const featurePatch = mergedPatches.get(feature.properties.id);
if (!featurePatch) return feature;
return {
...feature,
properties: {
...feature.properties,
...deepClone(featurePatch),
},
};
});
});
return; return;
} }
@@ -547,15 +503,6 @@ export function useEditorState(
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) { function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
if (mode === "replay") { if (mode === "replay") {
applyReplaySessionMutation(`Replay: chỉnh sửa #${id}`, (draftReplay) => {
const featureDraft = ensureReplayFeatureCollection(draftReplay);
const idx = featureDraft.features.findIndex((feature) => feature.properties.id === id);
if (idx === -1) return;
featureDraft.features[idx] = {
...featureDraft.features[idx],
geometry: deepClone(newGeometry),
};
});
return; return;
} }
@@ -579,10 +526,6 @@ export function useEditorState(
function deleteFeature(id: FeatureProperties["id"]) { function deleteFeature(id: FeatureProperties["id"]) {
if (mode === "replay") { if (mode === "replay") {
applyReplaySessionMutation(`Replay: xóa #${id}`, (draftReplay) => {
const featureDraft = ensureReplayFeatureCollection(draftReplay);
featureDraft.features = featureDraft.features.filter((feature) => feature.properties.id !== id);
});
return; return;
} }
@@ -737,6 +680,7 @@ export function useEditorState(
activeReplayDraft, activeReplayDraft,
effectiveReplays, effectiveReplays,
setReplays: updateReplaysState, setReplays: updateReplaysState,
mutateActiveReplay: applyReplaySessionMutation,
activeReplayId, activeReplayId,
switchReplayContext, switchReplayContext,
closeReplayContext, closeReplayContext,
@@ -766,32 +710,6 @@ function resolveStateAction<T>(next: SetStateAction<T>, prev: T): T {
return typeof next === "function" ? (next as (value: T) => T)(prev) : next; return typeof next === "function" ? (next as (value: T) => T)(prev) : next;
} }
function buildReplaySeedFeatures(
sourceDraft: FeatureCollection,
featureId: string,
selectedIds: (string | number)[]
): FeatureCollection {
const selectedIdsSet = new Set(selectedIds.map(String));
selectedIdsSet.add(featureId);
const triggerFeature = sourceDraft.features.find(
(feature) => String(feature.properties.id) === featureId
);
const mainBoundIds = new Set(
Array.isArray(triggerFeature?.properties?.binding)
? triggerFeature.properties.binding.map(String)
: []
);
const targetIds = new Set([...selectedIdsSet, ...mainBoundIds]);
return {
type: "FeatureCollection",
features: sourceDraft.features
.filter((feature) => targetIds.has(String(feature.properties.id)))
.map(deepClone),
};
}
function createReplaySessionSeed( function createReplaySessionSeed(
sourceDraft: FeatureCollection, sourceDraft: FeatureCollection,
geometryId: string, geometryId: string,
@@ -799,8 +717,12 @@ function createReplaySessionSeed(
): BattleReplay { ): BattleReplay {
return { return {
geometry_id: geometryId, geometry_id: geometryId,
target_geometry_ids: buildReplaySeedTargetIds(
sourceDraft.features.find((feature) => String(feature.properties.id) === geometryId),
geometryId,
selectedIds
),
detail: [], detail: [],
replay_features: buildReplaySeedFeatures(sourceDraft, geometryId, selectedIds),
}; };
} }
@@ -811,17 +733,103 @@ function normalizeReplaySessionSeed(
selectedIds: (string | number)[] selectedIds: (string | number)[]
): BattleReplay { ): BattleReplay {
const nextReplay = deepClone(replay); const nextReplay = deepClone(replay);
if (!nextReplay.replay_features) { const triggerFeature = sourceDraft.features.find((feature) => String(feature.properties.id) === geometryId);
nextReplay.replay_features = buildReplaySeedFeatures(sourceDraft, geometryId, selectedIds); const seedTargetIds = buildReplaySeedTargetIds(triggerFeature, geometryId, selectedIds);
} nextReplay.target_geometry_ids = normalizeReplayTargetGeometryIds(
nextReplay.target_geometry_ids,
geometryId,
seedTargetIds
);
return nextReplay; return nextReplay;
} }
function ensureReplayFeatureCollection(replay: BattleReplay): FeatureCollection { function buildReplaySeedTargetIds(
if (!replay.replay_features) { triggerFeature: Feature | undefined,
replay.replay_features = deepClone(EMPTY_FEATURE_COLLECTION); featureId: string,
selectedIds: (string | number)[]
) {
const orderedIds: string[] = [];
const seen = new Set<string>();
const pushId = (rawId: string | number | null | undefined) => {
if (rawId == null) return;
const id = String(rawId).trim();
if (!id || seen.has(id)) return;
seen.add(id);
orderedIds.push(id);
};
pushId(featureId);
for (const rawId of selectedIds || []) {
pushId(rawId);
} }
return replay.replay_features;
if (Array.isArray(triggerFeature?.properties?.binding)) {
for (const rawId of triggerFeature.properties.binding) {
pushId(rawId);
}
}
return orderedIds;
}
function buildReplayFeatureDraft(
sourceDraft: FeatureCollection,
replay: BattleReplay | null
): FeatureCollection {
if (!replay) return deepClone(EMPTY_FEATURE_COLLECTION);
return buildReplayFeatureDraftFromTargetIds(
sourceDraft,
normalizeReplayTargetGeometryIds(replay.target_geometry_ids, replay.geometry_id)
);
}
function buildReplayFeatureDraftFromTargetIds(
sourceDraft: FeatureCollection,
targetGeometryIds: string[]
): FeatureCollection {
return {
type: "FeatureCollection",
features: targetGeometryIds
.map((id) =>
sourceDraft.features.find((feature) => String(feature.properties.id) === id) || null
)
.filter(Boolean)
.map((feature) => sanitizeReplayFeature(deepClone(feature!))),
};
}
function normalizeReplayTargetGeometryIds(
targetGeometryIds: string[] | undefined,
geometryId: string,
extraIds: (string | number)[] = []
): string[] {
const orderedIds: string[] = [];
const seen = new Set<string>();
const pushId = (rawId: string | number | null | undefined) => {
if (rawId == null) return;
const id = String(rawId).trim();
if (!id || seen.has(id)) return;
seen.add(id);
orderedIds.push(id);
};
pushId(geometryId);
for (const rawId of targetGeometryIds || []) pushId(rawId);
for (const rawId of extraIds || []) pushId(rawId);
return orderedIds;
}
function sanitizeReplayFeature(feature: Feature): Feature {
return {
...feature,
properties: {
...feature.properties,
binding: [],
},
};
} }
function replaceReplayByGeometryId( function replaceReplayByGeometryId(
+24 -3
View File
@@ -69,7 +69,7 @@ export function initSelect(
} }
const additive = !!e.originalEvent?.altKey; const additive = !!e.originalEvent?.altKey;
selectFeature(features[0], additive); selectFeature(pickPreferredFeature(features), additive);
} }
// Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải. // Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải.
@@ -88,7 +88,7 @@ export function initSelect(
if (!features.length) return; if (!features.length) return;
const feature = features[0]; const feature = pickPreferredFeature(features);
const id = feature.id ?? feature.properties?.id; const id = feature.id ?? feature.properties?.id;
if (id === undefined || id === null) return; if (id === undefined || id === null) return;
@@ -136,6 +136,22 @@ export function initSelect(
} }
} }
function pickPreferredFeature(features: maplibregl.MapGeoJSONFeature[]) {
return [...features].sort((a, b) => featureSelectPriority(b) - featureSelectPriority(a))[0];
}
function featureSelectPriority(feature: maplibregl.MapGeoJSONFeature) {
const layerId = typeof feature.layer?.id === "string" ? feature.layer.id : "";
const geometryType = feature.geometry?.type;
const source = typeof feature.source === "string" ? feature.source : "";
if (layerId.endsWith("-hit")) return 400;
if (source === "path-arrow-shapes") return 300;
if (geometryType === "LineString" || geometryType === "MultiLineString") return 200;
if (geometryType === "Point" || geometryType === "MultiPoint") return 100;
return 0;
}
map.on("click", onClick); map.on("click", onClick);
map.on("mousemove", onMove); map.on("mousemove", onMove);
if (hasContextActions) { if (hasContextActions) {
@@ -223,7 +239,12 @@ export function initSelect(
if (onReplayEdit) { if (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(
selectedCount > 1 ? `Vào replay (${selectedCount} geo)` : "Vào replay",
() => onReplayEdit(featureId)
)
);
hasMenuItems = true; hasMenuItems = true;
} }
} }
+8 -8
View File
@@ -36,14 +36,6 @@ import { LayerSpecification } from "maplibre-gl";
export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
return [ return [
...getDefenseLineLayers(sourceId, pathArrowSourceId, pointSourceId),
...getAttackRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getRetreatRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getInvasionRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getMigrationRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getRefugeeRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getTradeRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getShippingRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getCountryLayers(sourceId, pathArrowSourceId, pointSourceId), ...getCountryLayers(sourceId, pathArrowSourceId, pointSourceId),
...getStateLayers(sourceId, pathArrowSourceId, pointSourceId), ...getStateLayers(sourceId, pathArrowSourceId, pointSourceId),
...getEmpireLayers(sourceId, pathArrowSourceId, pointSourceId), ...getEmpireLayers(sourceId, pathArrowSourceId, pointSourceId),
@@ -52,6 +44,14 @@ export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string
...getBattleLayers(sourceId, pathArrowSourceId, pointSourceId), ...getBattleLayers(sourceId, pathArrowSourceId, pointSourceId),
...getCivilizationLayers(sourceId, pathArrowSourceId, pointSourceId), ...getCivilizationLayers(sourceId, pathArrowSourceId, pointSourceId),
...getRebellionZoneLayers(sourceId, pathArrowSourceId, pointSourceId), ...getRebellionZoneLayers(sourceId, pathArrowSourceId, pointSourceId),
...getDefenseLineLayers(sourceId, pathArrowSourceId, pointSourceId),
...getAttackRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getRetreatRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getInvasionRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getMigrationRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getRefugeeRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getTradeRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getShippingRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
...getPersonDeathplaceLayers(sourceId, pathArrowSourceId, pointSourceId), ...getPersonDeathplaceLayers(sourceId, pathArrowSourceId, pointSourceId),
...getPersonBirthplaceLayers(sourceId, pathArrowSourceId, pointSourceId), ...getPersonBirthplaceLayers(sourceId, pathArrowSourceId, pointSourceId),
...getPersonActivityLayers(sourceId, pathArrowSourceId, pointSourceId), ...getPersonActivityLayers(sourceId, pathArrowSourceId, pointSourceId),
+60 -45
View File
@@ -7,32 +7,44 @@ import type { FeatureCollection } from "@/uhm/types/geo";
*/ */
export const mapActions = { 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) // Đặ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 }) => { set_camera_view: (
map.easeTo({ map: maplibregl.Map,
center: [state.center.lng, state.center.lat], state: {
zoom: state.zoom, center?: [number, number] | { lng: number; lat: number };
pitch: state.pitch, zoom?: number;
bearing: state.bearing, pitch?: number;
duration: 2500, bearing?: number;
}); duration?: number;
}
) => {
const center = normalizeReplayCenter(state.center);
const nextView: maplibregl.EaseToOptions = {
duration: Number.isFinite(state.duration) ? state.duration : 2500,
};
if (center) {
nextView.center = center;
}
if (Number.isFinite(state.zoom)) {
nextView.zoom = state.zoom;
}
if (Number.isFinite(state.pitch)) {
nextView.pitch = state.pitch;
}
if (Number.isFinite(state.bearing)) {
nextView.bearing = state.bearing;
}
if (
nextView.center == null &&
nextView.zoom == null &&
nextView.pitch == null &&
nextView.bearing == null
) {
return;
}
map.easeTo(nextView);
}, },
// 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
@@ -54,31 +66,13 @@ export const mapActions = {
} }
}, },
// 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 đồ // Ẩn/hiện nhãn (labels) trên bản đồ
toggle_labels: (map: maplibregl.Map, visible: boolean) => { toggle_labels: (map: maplibregl.Map, visible: boolean) => {
const style = map.getStyle(); const style = map.getStyle();
if (!style) return; if (!style) return;
style.layers.forEach(layer => { style.layers.forEach(layer => {
if (layer.type === 'symbol' && (layer as any).layout?.['text-field']) { const layout = "layout" in layer ? layer.layout : undefined;
if (layer.type === 'symbol' && layout && typeof layout === "object" && "text-field" in layout) {
map.setLayoutProperty(layer.id, 'visibility', visible ? 'visible' : 'none'); map.setLayoutProperty(layer.id, 'visibility', visible ? 'visible' : 'none');
} }
}); });
@@ -89,3 +83,24 @@ export const mapActions = {
onYearChange(year); onYearChange(year);
} }
}; };
function normalizeReplayCenter(
center: [number, number] | { lng: number; lat: number } | undefined
): [number, number] | null {
if (Array.isArray(center) && center.length >= 2) {
const lng = Number(center[0]);
const lat = Number(center[1]);
return Number.isFinite(lng) && Number.isFinite(lat) ? [lng, lat] : null;
}
if (
center &&
typeof center === "object" &&
"lng" in center &&
"lat" in center
) {
const lng = Number(center.lng);
const lat = Number(center.lat);
return Number.isFinite(lng) && Number.isFinite(lat) ? [lng, lat] : null;
}
return null;
}
+182 -39
View File
@@ -1,6 +1,12 @@
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 type { ReplayAction, UIFunctionName, MapFunctionName, NarrativeFunctionName } from "@/uhm/types/projects"; import type {
GeoFunctionName,
MapFunctionName,
NarrativeFunctionName,
ReplayAction,
UIOptionName,
} from "@/uhm/types/projects";
import { mapActions } from "./mapActions"; import { mapActions } from "./mapActions";
import { uiActions } from "./uiActions"; import { uiActions } from "./uiActions";
import { narrativeActions } from "./narrativeActions"; import { narrativeActions } from "./narrativeActions";
@@ -15,7 +21,6 @@ export interface ReplayControllers {
// UI Setters // UI Setters
setTimelineVisible: (v: boolean) => void; setTimelineVisible: (v: boolean) => void;
setUIVisible: (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;
@@ -25,7 +30,7 @@ export interface ReplayControllers {
// Narrative Setters // Narrative Setters
setTitle: (t: string) => void; setTitle: (t: string) => void;
setDescriptions: (d: string) => void; setDescriptions: (d: string) => void;
setDialog: (data: any) => void; setDialog: (data: { avatar: string; text: string; side: "left" | "right" }) => void;
setImage: (url: string | null) => void; setImage: (url: string | null) => void;
setSubtitle: (s: string | null) => void; setSubtitle: (s: string | null) => void;
} }
@@ -34,72 +39,210 @@ export interface ReplayControllers {
* Dispatcher trung tâm: Nhận một Action và thực thi logic tương ứng * 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). * bằng cách gọi đến các bộ Action con (map, ui, narrative).
*/ */
export const dispatchReplayAction = (controllers: ReplayControllers, action: ReplayAction<any>) => { export const dispatchReplayAction = (
controllers: ReplayControllers,
action: ReplayAction<UIOptionName | MapFunctionName | GeoFunctionName | NarrativeFunctionName> | {
function_name: "UI";
params: unknown[];
}
) => {
const { function_name, params } = action; const { function_name, params } = action;
// 1. Nhóm Map Actions // 1. Nhóm Map Actions
if (controllers.map) { if (controllers.map) {
const map = controllers.map; const map = controllers.map;
switch (function_name as MapFunctionName) { switch (function_name as MapFunctionName | GeoFunctionName) {
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": case "set_camera_view":
mapActions.set_camera_view(map, params[0]); mapActions.set_camera_view(map, normalizeCameraViewState(params[0]));
return; return;
case "fly_to_geometry": case "fly_to_geometry":
mapActions.fly_to_geometry(map, params[0], controllers.draft); mapActions.fly_to_geometry(
return; map,
case "rotate_around_point": asStringValue(params[0]),
mapActions.rotate_around_point(map, params[0]); controllers.draft,
);
return; return;
case "toggle_labels": case "toggle_labels":
mapActions.toggle_labels(map, params[0]); mapActions.toggle_labels(map, asBooleanValue(params[0], true));
return;
case "show_labels":
mapActions.toggle_labels(map, true);
return;
case "hide_labels":
mapActions.toggle_labels(map, false);
return; return;
case "set_time_filter": case "set_time_filter":
mapActions.set_time_filter(controllers.onYearChange, params[0]); mapActions.set_time_filter(controllers.onYearChange, asNumberValue(params[0], 0));
return;
case "reset_camera_north":
mapActions.set_camera_view(map, { bearing: 0 });
return;
case "fly_to_geometries":
case "enable_timeline_filter":
case "disable_timeline_filter":
case "show_geometries":
case "hide_geometries":
case "set_geometry_visibility":
case "fit_to_geometries":
case "orbit_camera_around_geometry":
case "pulse_geometry":
case "animate_dashed_border":
case "set_geometry_style":
case "show_geometry_label":
case "follow_geometry_path":
case "follow_geometries_path":
case "dim_other_geometries":
return; return;
} }
} }
// 2. Nhóm UI Actions // 2. Nhóm UI Actions
switch (function_name as UIFunctionName) { const uiDescriptor = getUiActionDescriptor(function_name, params);
case "hide_timeline": if (uiDescriptor) {
uiActions.hide_timeline(controllers.setTimelineVisible); const { option, payload } = uiDescriptor;
return; switch (option) {
case "hide_all_UI": case "timeline":
uiActions.hide_all_UI(controllers.setUIVisible); uiActions.timeline(controllers.setTimelineVisible, Boolean(payload[0] ?? false));
return; return;
case "open_wiki": case "layer_panel":
uiActions.open_wiki(controllers.setSidebarOpen, controllers.onSelectWiki, params[0]); uiActions.layer_panel(Boolean(payload[0] ?? false));
return; return;
case "show_toast_message": case "wiki_panel":
uiActions.show_toast_message(controllers.addToast, params[0]); uiActions.wiki_panel(controllers.setSidebarOpen, Boolean(payload[0] ?? false));
return; return;
case "set_playback_speed": case "zoom_panel":
uiActions.set_playback_speed(controllers.setPlaybackSpeed, params[0]); uiActions.zoom_panel(Boolean(payload[0] ?? false));
return; return;
case "wiki":
uiActions.wiki(
controllers.setSidebarOpen,
controllers.onSelectWiki,
typeof payload[0] === "string" ? payload[0] : ""
);
return;
case "toast":
uiActions.toast(
controllers.addToast,
typeof payload[0] === "string" ? payload[0] : ""
);
return;
case "wiki_header":
uiActions.wiki_header(typeof payload[0] === "string" ? payload[0] : "");
return;
case "playback_speed":
uiActions.playback_speed(
controllers.setPlaybackSpeed,
typeof payload[0] === "number" ? payload[0] : 1
);
return;
}
} }
// 3. Nhóm Narrative Actions // 3. Nhóm Narrative Actions
switch (function_name as NarrativeFunctionName) { switch (function_name as NarrativeFunctionName) {
case "set_title": case "set_title":
narrativeActions.set_title(controllers.setTitle, params[0]); narrativeActions.set_title(controllers.setTitle, asStringValue(params[0]));
return; return;
case "set_descriptions": case "set_descriptions":
narrativeActions.set_descriptions(controllers.setDescriptions, params[0]); narrativeActions.set_descriptions(controllers.setDescriptions, asStringValue(params[0]));
return; return;
case "show_dialog_box": case "show_dialog_box":
narrativeActions.show_dialog_box(controllers.setDialog, params[0], params[1]); narrativeActions.show_dialog_box(
controllers.setDialog,
asStringValue(params[0]),
asStringValue(params[1])
);
return; return;
case "display_historical_image": case "display_historical_image":
narrativeActions.display_historical_image(controllers.setImage, params[0]); narrativeActions.display_historical_image(controllers.setImage, asStringValue(params[0]));
return; return;
case "set_step_subtitle": case "set_step_subtitle":
narrativeActions.set_step_subtitle(controllers.setSubtitle, params[0]); narrativeActions.set_step_subtitle(controllers.setSubtitle, asStringValue(params[0]));
return; return;
} }
}; };
function normalizeUiOption(value: unknown): UIOptionName | null {
switch (value) {
case "timeline":
case "layer_panel":
case "wiki_panel":
case "zoom_panel":
case "wiki":
case "toast":
case "wiki_header":
case "playback_speed":
return value;
default:
return null;
}
}
function getUiActionDescriptor(function_name: unknown, params: unknown[]) {
if (function_name === "UI") {
const option = normalizeUiOption(params[0]);
if (!option) return null;
return {
option,
payload: params.slice(1),
};
}
const option = normalizeUiOption(function_name);
if (!option) return null;
return {
option,
payload: params,
};
}
function normalizeCameraViewState(value: unknown) {
if (!value || typeof value !== "object") {
return {};
}
const record = value as Record<string, unknown>;
const nextState: {
center?: [number, number] | { lng: number; lat: number };
zoom?: number;
pitch?: number;
bearing?: number;
duration?: number;
} = {};
const center = record.center;
if (Array.isArray(center) && center.length >= 2) {
const lng = Number(center[0]);
const lat = Number(center[1]);
if (Number.isFinite(lng) && Number.isFinite(lat)) {
nextState.center = [lng, lat];
}
}
const zoom = asOptionalNumberValue(record.zoom);
const pitch = asOptionalNumberValue(record.pitch);
const bearing = asOptionalNumberValue(record.bearing);
const duration = asOptionalNumberValue(record.duration);
if (zoom != null) nextState.zoom = zoom;
if (pitch != null) nextState.pitch = pitch;
if (bearing != null) nextState.bearing = bearing;
if (duration != null) nextState.duration = duration;
return nextState;
}
function asStringValue(value: unknown) {
return typeof value === "string" ? value : value == null ? "" : String(value);
}
function asBooleanValue(value: unknown, fallback: boolean) {
return typeof value === "boolean" ? value : fallback;
}
function asOptionalNumberValue(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function asNumberValue(value: unknown, fallback: number) {
return asOptionalNumberValue(value) ?? fallback;
}
+27 -9
View File
@@ -3,29 +3,47 @@
*/ */
export const uiActions = { export const uiActions = {
// Ẩn thanh Timeline // Ẩn/hiện thanh Timeline
hide_timeline: (setTimelineVisible: (v: boolean) => void) => { timeline: (setTimelineVisible: (v: boolean) => void, visible: boolean) => {
setTimelineVisible(false); setTimelineVisible(visible);
}, },
// Ẩn toàn bộ UI để có trải nghiệm điện ảnh (Cinematic) // Ẩn/hiện panel layer. Runtime hiện chưa có controller riêng nên tạm no-op.
hide_all_UI: (setUIVisible: (v: boolean) => void) => { layer_panel: (visible: boolean) => {
setUIVisible(false); void visible;
return;
},
// Ẩn/hiện panel wiki.
wiki_panel: (setSidebarOpen: (v: boolean) => void, visible: boolean) => {
setSidebarOpen(visible);
},
// Ẩn/hiện panel zoom. Runtime hiện chưa có controller riêng nên tạm no-op.
zoom_panel: (visible: boolean) => {
void visible;
return;
}, },
// Mở Wiki và tìm đến một ID cụ thể // Mở Wiki và tìm đến một ID cụ thể
open_wiki: (setSidebarOpen: (v: boolean) => void, onSelectWiki: (id: string) => void, wikiId: string) => { wiki: (setSidebarOpen: (v: boolean) => void, onSelectWiki: (id: string) => void, wikiId: string) => {
setSidebarOpen(true); setSidebarOpen(true);
onSelectWiki(wikiId); onSelectWiki(wikiId);
}, },
// Hiển thị thông báo (toast) // Hiển thị thông báo (toast)
show_toast_message: (addToast: (msg: string) => void, message: string) => { toast: (addToast: (msg: string) => void, message: string) => {
addToast(message); addToast(message);
}, },
// Focus header trong wiki. Runtime hiện chưa có controller riêng nên tạm no-op.
wiki_header: (headerId: string) => {
void headerId;
return;
},
// Thay đổi tốc độ phát Replay // Thay đổi tốc độ phát Replay
set_playback_speed: (setSpeed: (s: number) => void, speed: number) => { playback_speed: (setSpeed: (s: number) => void, speed: number) => {
setSpeed(speed); setSpeed(speed);
} }
}; };
-5
View File
@@ -11,7 +11,6 @@ export type Entity = {
// API cũ / snapshot editor (giữ optional để không phá flow editor snapshot) // API cũ / snapshot editor (giữ optional để không phá flow editor snapshot)
slug?: string | null; slug?: string | null;
type_id?: string | null; type_id?: string | null;
status?: number | null;
geometry_count?: number; geometry_count?: number;
}; };
@@ -29,9 +28,5 @@ export type EntitySnapshot = {
// join tables (geometry_entity / entity_wiki), not here. // join tables (geometry_entity / entity_wiki), not here.
operation?: EntitySnapshotOperation; operation?: EntitySnapshotOperation;
name?: string; name?: string;
slug?: string | null;
description?: string | null; description?: string | null;
status?: number | null;
base_updated_at?: string;
base_hash?: string;
}; };
-3
View File
@@ -61,8 +61,6 @@ export type GeometrySnapshot = {
max_lng: number; max_lng: number;
max_lat: number; max_lat: number;
} | null; } | null;
base_updated_at?: string;
base_hash?: string;
}; };
// Snapshot join table (geometry ↔ entity). // Snapshot join table (geometry ↔ entity).
@@ -73,7 +71,6 @@ export type GeometryEntitySnapshot = {
// - reference/binding: the link exists (assigned) // - reference/binding: the link exists (assigned)
// - delete: the link is removed // - delete: the link is removed
operation?: "reference" | "binding" | "delete"; operation?: "reference" | "binding" | "delete";
base_links_hash?: string;
}; };
export type GeometryChange = export type GeometryChange =
+35 -27
View File
@@ -89,32 +89,41 @@ export type EditorSnapshot = {
// ---- Replay / Scripting System ---- // ---- Replay / Scripting System ----
export type UIFunctionName = export type UIOptionName =
| "hide_timeline" // Ẩn thanh timeline | "timeline" // Ẩn/hiện timeline
| "hide_layer_panel" // Ẩn panel lớp bản đồ | "layer_panel" // Ẩn/hiện panel layer
| "hide_wiki_panel" // Ẩn panel wiki (bên phải) | "wiki_panel" // Ẩn/hiện panel wiki
| "hide_zoom_panel" // Ẩn các nút điều khiển zoom | "zoom_panel" // Ẩn/hiện nút zoom
| "hide_all_UI" // Ẩn toàn bộ giao diện điều khiển (cinematic mode) | "wiki" // Mở/chọn wiki
| "open_wiki" // Mở panel wiki | "toast" // Hiển thị toast
| "show_toast_message" // Hiển thị thông báo ngắn (toast) | "wiki_header" // Focus header trong wiki
| "focus_wiki_header" // Cuộn đến đề mục cụ thể trong Wiki | "playback_speed"; // Thay đổi tốc độ phát replay
| "set_playback_speed"; // Thay đổi tốc độ phát replay
export type MapFunctionName = 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) | "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 đồ | "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 đồ | "enable_timeline_filter" // Bật timeline filter
| "disable_timeline_filter" // Tắt timeline filter
| "toggle_labels" // Legacy: bật/tắt hiển thị nhãn (labels) trên bản đồ
| "show_labels" // Hiện labels
| "hide_labels" // Ẩn labels
| "reset_camera_north"; // Đưa camera về hướng bắc
export type GeoFunctionName =
| "fly_to_geometry" // Legacy: di chuyển mượt mà đến một geometry
| "fly_to_geometries" // Di chuyển mượt mà đến một hoặc nhiều geometry
| "set_geometry_visibility" // Legacy: ẩn/hiện một hoặc nhiều geometry
| "show_geometries" // Hiện một hoặc nhiều geometry
| "hide_geometries" // Ẩn một hoặc nhiều geometry
| "fit_to_geometries" // Legacy: fit camera theo nhiều geometry
| "orbit_camera_around_geometry" // Quay camera quanh một geometry
| "pulse_geometry" // Hiệu ứng pulse/emphasis cho geometry
| "animate_dashed_border" // Hiệu ứng border nét đứt chuyển động
| "set_geometry_style" // Đổi style trực tiếp của 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_geometries_path" // Cho camera bám theo chuỗi path geometry
| "dim_other_geometries"; // Làm mờ các geometry ngoài target set
export type NarrativeFunctionName = export type NarrativeFunctionName =
| "set_title" // Đặt tiêu đề cho bước replay | "set_title" // Đặt tiêu đề cho bước replay
@@ -125,13 +134,14 @@ export type NarrativeFunctionName =
export type ReplayAction<T> = { export type ReplayAction<T> = {
function_name: T; function_name: T;
params: any[]; params: unknown[];
}; };
export type ReplayStep = { export type ReplayStep = {
duration: number; // Trọng số thời gian của step trong 1 stage duration: number; // Trọng số thời gian của step trong 1 stage
use_UI_function: ReplayAction<UIFunctionName>[]; use_UI_function: ReplayAction<UIOptionName>[];
use_map_function: ReplayAction<MapFunctionName>[]; use_map_function: ReplayAction<MapFunctionName>[];
use_geo_function: ReplayAction<GeoFunctionName>[];
use_narrow_function: ReplayAction<NarrativeFunctionName>[]; use_narrow_function: ReplayAction<NarrativeFunctionName>[];
}; };
@@ -145,9 +155,8 @@ export type ReplayStage = {
export type BattleReplay = { export type BattleReplay = {
geometry_id: string; // geometry mà khi nhấn vào là có thể replay geometry_id: string; // geometry mà khi nhấn vào là có thể replay
target_geometry_ids: string[]; // tập geometry được đưa vào replay, phần tử đầu nên là MAIN geo
detail: ReplayStage[]; detail: ReplayStage[];
// Local-only: separate draft for this specific replay
replay_features?: FeatureCollection;
}; };
@@ -175,4 +184,3 @@ export type CreateCommitInput = {
export type RestoreCommitInput = { export type RestoreCommitInput = {
commit_id: string; commit_id: string;
}; };
-8
View File
@@ -2,12 +2,6 @@
// FE wiki runtime now stores HTML or plain text in this string field. // FE wiki runtime now stores HTML or plain text in this string field.
export type WikiDoc = string | null; export type WikiDoc = string | null;
export type WikiContentSample = {
id: string;
title: string;
created_at: string;
};
export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference"; export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference";
export type WikiSnapshot = { export type WikiSnapshot = {
@@ -17,6 +11,4 @@ export type WikiSnapshot = {
title: string; title: string;
slug?: string | null; slug?: string | null;
doc: WikiDoc; doc: WikiDoc;
content_sample?: WikiContentSample[];
updated_at?: string;
}; };