complete replay editor v1
This commit is contained in:
+115
-45
@@ -1,13 +1,15 @@
|
||||
"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 { 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 BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel";
|
||||
import TimelineBar from "@/uhm/components/ui/TimelineBar";
|
||||
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 ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel";
|
||||
import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel";
|
||||
@@ -82,6 +84,7 @@ function EditorPageContent() {
|
||||
const geoBindingStatusTimeoutRef = useRef<number | null>(null);
|
||||
const localCreatedEntityIdsRef = useRef<Set<string>>(new Set());
|
||||
const lastSelectedFeatureIdRef = useRef<string | null>(null);
|
||||
const mapHandleRef = useRef<MapHandle | null>(null);
|
||||
const {
|
||||
mode,
|
||||
internalSetMode,
|
||||
@@ -151,7 +154,6 @@ function EditorPageContent() {
|
||||
setTimelineFilterEnabled,
|
||||
geometryBindingFilterEnabled,
|
||||
setGeoBindingStatus,
|
||||
hoveredGeometryId,
|
||||
geometryFocusRequest,
|
||||
setGeometryFocusRequest,
|
||||
replayFeatureId,
|
||||
@@ -228,7 +230,6 @@ function EditorPageContent() {
|
||||
setTimelineFilterEnabled: state.setTimelineFilterEnabled,
|
||||
geometryBindingFilterEnabled: state.geometryBindingFilterEnabled,
|
||||
setGeoBindingStatus: state.setGeoBindingStatus,
|
||||
hoveredGeometryId: state.hoveredGeometryId,
|
||||
geometryFocusRequest: state.geometryFocusRequest,
|
||||
setGeometryFocusRequest: state.setGeometryFocusRequest,
|
||||
replayFeatureId: state.replayFeatureId,
|
||||
@@ -287,7 +288,6 @@ function EditorPageContent() {
|
||||
id: String(e.id || ""),
|
||||
name: String(e.name || "").trim() || String(e.id || ""),
|
||||
description: e.description ?? null,
|
||||
status: typeof e.status === "number" ? e.status : 1,
|
||||
geometry_count: 0,
|
||||
}))
|
||||
.filter((e) => e.id.length > 0 && e.name.length > 0);
|
||||
@@ -297,6 +297,13 @@ function EditorPageContent() {
|
||||
() => mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities),
|
||||
[entityCatalog, snapshotEntitiesAsEntities]
|
||||
);
|
||||
const [replaySelection, setReplaySelection] = useState<{
|
||||
stageId: number | null;
|
||||
stepIndex: number | null;
|
||||
}>({
|
||||
stageId: null,
|
||||
stepIndex: null,
|
||||
});
|
||||
const entitiesRef = useRef(entities);
|
||||
useEffect(() => {
|
||||
entitiesRef.current = entities;
|
||||
@@ -382,17 +389,15 @@ function EditorPageContent() {
|
||||
return normalizeFeatureBindingIds(selectedFeature);
|
||||
}, [selectedFeature]);
|
||||
|
||||
const hoveredGeometryHighlight = useMemo(() => {
|
||||
if (!hoveredGeometryId) return null;
|
||||
const feature = editor.draft.features.find(
|
||||
(item) => String(item.properties.id) === hoveredGeometryId
|
||||
);
|
||||
if (!feature) return null;
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: [feature],
|
||||
} as FeatureCollection;
|
||||
}, [editor.draft.features, hoveredGeometryId]);
|
||||
const wikiChoices = useMemo(() => {
|
||||
return (snapshotWikis || [])
|
||||
.filter((wiki) => wiki && wiki.operation !== "delete")
|
||||
.map((wiki) => ({
|
||||
id: String(wiki.id || ""),
|
||||
label: `${(wiki.title || "").trim() || "Untitled wiki"} (${String(wiki.id || "")})`,
|
||||
}))
|
||||
.filter((wiki) => wiki.id.length > 0);
|
||||
}, [snapshotWikis]);
|
||||
|
||||
const wikiDirty = useMemo(() => {
|
||||
const prev = normalizeWikisForCompare(baselineSnapshot?.wikis);
|
||||
@@ -440,6 +445,10 @@ function EditorPageContent() {
|
||||
+ (entitiesDirty ? 1 : 0)
|
||||
+ (entityWikiDirty ? 1 : 0)
|
||||
+ (replayDirty ? 1 : 0);
|
||||
const activeReplayStages = useMemo(
|
||||
() => editor.activeReplayDraft?.detail || [],
|
||||
[editor.activeReplayDraft?.detail]
|
||||
);
|
||||
|
||||
const sectionCommands = useProjectCommands({
|
||||
editor,
|
||||
@@ -459,7 +468,9 @@ function EditorPageContent() {
|
||||
// QUY TẮC: Geo chọn đầu tiên là geo main.
|
||||
const triggerId = selectedFeatureIds.length > 0 ? selectedFeatureIds[0] : featureId;
|
||||
setReplayFeatureId(triggerId);
|
||||
setReplaySelection({ stageId: null, stepIndex: null });
|
||||
editor.switchReplayContext(triggerId, selectedFeatureIds);
|
||||
setSelectedFeatureIds([]);
|
||||
} else if (m !== "replay") {
|
||||
if (mode === "replay") {
|
||||
editor.closeReplayContext();
|
||||
@@ -467,10 +478,49 @@ function EditorPageContent() {
|
||||
}
|
||||
setReplayFeatureId(null);
|
||||
setHideOutside(false);
|
||||
setReplaySelection({ stageId: null, stepIndex: null });
|
||||
}
|
||||
internalSetMode(m);
|
||||
}, [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 visibility: Record<string, boolean> = { ...geometryVisibility };
|
||||
|
||||
@@ -1097,7 +1147,6 @@ function EditorPageContent() {
|
||||
operation: "reference",
|
||||
title,
|
||||
doc: null,
|
||||
updated_at: wiki.updated_at,
|
||||
},
|
||||
...prev,
|
||||
];
|
||||
@@ -1119,7 +1168,6 @@ function EditorPageContent() {
|
||||
id: entityItem.entity_id,
|
||||
name: (entityItem.name || "").trim() || entityItem.entity_id,
|
||||
description: (entityItem.description || "").trim() || null,
|
||||
status: 1,
|
||||
geometry_count: 0,
|
||||
};
|
||||
|
||||
@@ -1226,7 +1274,6 @@ function EditorPageContent() {
|
||||
id: entityId,
|
||||
name,
|
||||
description,
|
||||
status: 1,
|
||||
geometry_count: 0,
|
||||
};
|
||||
|
||||
@@ -1241,9 +1288,7 @@ function EditorPageContent() {
|
||||
source: "inline",
|
||||
operation: "create",
|
||||
name,
|
||||
slug: null,
|
||||
description,
|
||||
status: 1,
|
||||
},
|
||||
...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
|
||||
title="Resize left panel"
|
||||
onDrag={(deltaX) => {
|
||||
@@ -1373,6 +1430,7 @@ function EditorPageContent() {
|
||||
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
||||
{isBackgroundVisibilityReady ? (
|
||||
<Map
|
||||
ref={mapHandleRef}
|
||||
mode={mode}
|
||||
onSetMode={setMode}
|
||||
draft={timelineVisibleDraft}
|
||||
@@ -1384,8 +1442,8 @@ function EditorPageContent() {
|
||||
onUpdateFeature={editor.updateFeature}
|
||||
backgroundVisibility={backgroundVisibility}
|
||||
geometryVisibility={effectiveGeometryVisibility}
|
||||
respectBindingFilter={geometryBindingFilterEnabled}
|
||||
highlightFeatures={hoveredGeometryHighlight}
|
||||
respectBindingFilter={mode === "replay" ? false : geometryBindingFilterEnabled}
|
||||
highlightFeatures={null}
|
||||
focusFeatureCollection={geometryFocusRequest?.collection || null}
|
||||
focusRequestKey={geometryFocusRequest?.key ?? null}
|
||||
focusPadding={96}
|
||||
@@ -1397,7 +1455,6 @@ function EditorPageContent() {
|
||||
) : (
|
||||
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
||||
)}
|
||||
{mode !== "replay" && (
|
||||
<TimelineBar
|
||||
year={timelineDraftYear}
|
||||
onYearChange={handleTimelineYearChange}
|
||||
@@ -1407,7 +1464,6 @@ function EditorPageContent() {
|
||||
filterEnabled={timelineFilterEnabled}
|
||||
onFilterEnabledChange={setTimelineFilterEnabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1692,7 +1748,18 @@ function EditorPageContent() {
|
||||
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>
|
||||
@@ -1794,9 +1861,7 @@ function normalizeEntitiesForCompare(input: EntitySnapshot[] | null | undefined)
|
||||
id: String(e.id),
|
||||
source: e.source,
|
||||
name: typeof e.name === "string" ? e.name.trim() : "",
|
||||
slug: typeof e.slug === "string" ? e.slug : null,
|
||||
description: e.description == null ? null : String(e.description),
|
||||
status: typeof e.status === "number" ? e.status : null,
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
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)
|
||||
.map((replay) => ({
|
||||
geometry_id: replay.geometry_id,
|
||||
target_geometry_ids: normalizeReplayTargetGeometryIdsForCompare(
|
||||
replay.target_geometry_ids,
|
||||
replay.geometry_id
|
||||
),
|
||||
detail: Array.isArray(replay.detail) ? replay.detail : [],
|
||||
replay_features: normalizeReplayFeatureCollection(replay.replay_features),
|
||||
}))
|
||||
.sort((a, b) => a.geometry_id.localeCompare(b.geometry_id));
|
||||
}
|
||||
|
||||
function normalizeReplayFeatureCollection(input: FeatureCollection | null | undefined) {
|
||||
const features = Array.isArray(input?.features) ? input.features : [];
|
||||
return {
|
||||
type: "FeatureCollection" as const,
|
||||
features: features
|
||||
.filter((feature) => feature && feature.properties && (typeof feature.properties.id === "string" || typeof feature.properties.id === "number"))
|
||||
.map((feature) => ({
|
||||
type: "Feature" as const,
|
||||
properties: {
|
||||
...feature.properties,
|
||||
id: String(feature.properties.id),
|
||||
},
|
||||
geometry: feature.geometry,
|
||||
}))
|
||||
.sort((a, b) => String(a.properties.id).localeCompare(String(b.properties.id))),
|
||||
function normalizeReplayTargetGeometryIdsForCompare(
|
||||
input: string[] | null | undefined,
|
||||
geometryId: 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 input || []) pushId(rawId);
|
||||
return orderedIds;
|
||||
}
|
||||
|
||||
function normalizeGeoSearchGeometry(value: unknown): Geometry | null {
|
||||
|
||||
@@ -332,9 +332,6 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "12px", fontWeight: 700, color: hideOutside ? "#fb7185" : "#94a3b8" }}>
|
||||
Hide Outside
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
width: "32px",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -62,7 +62,8 @@ export function useMapInteraction({
|
||||
}, [mapRef, onUpdateRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "select" || !selectedFeatureIds || selectedFeatureIds.length === 0) {
|
||||
const allowsSelectionMode = mode === "select" || mode === "replay";
|
||||
if (!allowsSelectionMode || !selectedFeatureIds || selectedFeatureIds.length === 0) {
|
||||
editingEngineRef.current?.clearEditing();
|
||||
// Clear the internal selection state of the select engine to stay in sync with React state
|
||||
engineBindingsRef.current.select?.clearSelection?.(false);
|
||||
|
||||
@@ -216,7 +216,6 @@ export default function WikiSidebarPanel({ projectId, setWikis }: Props) {
|
||||
title: seedTitle,
|
||||
slug: slug ?? null,
|
||||
doc: "",
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
setWikis((prev) => [seed, ...prev]);
|
||||
setActiveId(id);
|
||||
@@ -291,7 +290,6 @@ export default function WikiSidebarPanel({ projectId, setWikis }: Props) {
|
||||
title: nextTitle,
|
||||
slug: nextSlug,
|
||||
doc: payload,
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
+273
-103
@@ -1,13 +1,17 @@
|
||||
/**
|
||||
* Tài liệu schema tham chiếu cho snapshot commit.
|
||||
* Schema tham chiếu cho commit snapshot.
|
||||
*
|
||||
* Lưu ý:
|
||||
* - Đây không phải "single source of truth" của runtime hiện tại; logic normalize/build thật nằm ở
|
||||
* `src/uhm/lib/editor/snapshot/editorSnapshot.ts`.
|
||||
* - 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.
|
||||
* - Các field denormalized dùng cho UI như `entity_ids`, `entity_name`, `binding`, `time_start`, `time_end`
|
||||
* 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.
|
||||
* Đây là file doc tự chứa, không import runtime types.
|
||||
* Mục tiêu là mô tả đúng shape dữ liệu hiện tại của editor/commit/replay
|
||||
* mà không phụ thuộc trực tiếp vào source code runtime.
|
||||
*
|
||||
* Ghi chú:
|
||||
* - Payload tạo commit hiện là `{ snapshot_json, edit_summary }`.
|
||||
* - `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 ----
|
||||
@@ -17,81 +21,10 @@ export type CreateCommitRequest = {
|
||||
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 ----
|
||||
|
||||
export type GeometryPreset = "line" | "polygon" | "circle-area" | "point";
|
||||
|
||||
export type Geometry =
|
||||
| ({ type: "Point"; coordinates: [number, number] } & CircleGeometryMetadata)
|
||||
| ({ type: "MultiPoint"; coordinates: [number, number][] } & CircleGeometryMetadata)
|
||||
@@ -109,20 +42,21 @@ export type FeatureId = string | number;
|
||||
|
||||
export type FeatureProperties = {
|
||||
id: FeatureId;
|
||||
type?: string | null; //generate
|
||||
geometry_preset?: string | null;
|
||||
time_start?: number | null; //generate
|
||||
time_end?: number | null; //generate
|
||||
binding?: string[]; //generate
|
||||
type?: string | null;
|
||||
geometry_preset?: GeometryPreset | null;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
binding?: string[];
|
||||
|
||||
// Legacy/UI-only fields should not be relied on by the backend.
|
||||
// FE strips these when building snapshot_json, but we keep them optional here
|
||||
// because older snapshots may still contain them.
|
||||
entity_id?: string | null; //generate
|
||||
entity_ids?: string[]; //generate
|
||||
entity_name?: string | null; //generate
|
||||
entity_names?: string[]; //generate
|
||||
entity_type_id?: string | null; //generate
|
||||
// UI/editor-only denormalized fields.
|
||||
entity_id?: string | null;
|
||||
entity_ids?: string[];
|
||||
entity_name?: string | null;
|
||||
entity_names?: string[];
|
||||
entity_type_id?: string | null;
|
||||
point_label?: string | null;
|
||||
line_label?: string | null;
|
||||
polygon_label?: string | null;
|
||||
};
|
||||
|
||||
export type Feature = {
|
||||
@@ -139,14 +73,16 @@ export type FeatureCollection = {
|
||||
// ---- Snapshot rows ----
|
||||
|
||||
export type SnapshotSource = "inline" | "ref";
|
||||
|
||||
export type SnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||
|
||||
export type EntitySnapshotOperation = SnapshotOperation;
|
||||
export type GeometrySnapshotOperation = SnapshotOperation;
|
||||
export type WikiSnapshotOperation = SnapshotOperation;
|
||||
|
||||
export type EntitySnapshot = {
|
||||
id: string;
|
||||
source: SnapshotSource;
|
||||
operation?: SnapshotOperation;
|
||||
|
||||
operation?: EntitySnapshotOperation;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
};
|
||||
@@ -154,9 +90,10 @@ export type EntitySnapshot = {
|
||||
export type GeometrySnapshot = {
|
||||
id: string;
|
||||
source: SnapshotSource;
|
||||
operation?: SnapshotOperation;
|
||||
operation?: GeometrySnapshotOperation;
|
||||
type?: string | null;
|
||||
draw_geometry?: Geometry;
|
||||
geometry?: Geometry;
|
||||
binding?: string[];
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
@@ -171,17 +108,15 @@ export type GeometrySnapshot = {
|
||||
export type GeometryEntitySnapshot = {
|
||||
geometry_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 WikiSnapshot = {
|
||||
id: string;
|
||||
source: SnapshotSource;
|
||||
operation?: SnapshotOperation;
|
||||
|
||||
operation?: WikiSnapshotOperation;
|
||||
title: string;
|
||||
slug?: string | null;
|
||||
doc: WikiDoc;
|
||||
@@ -190,5 +125,240 @@ export type WikiSnapshot = {
|
||||
export type EntityWikiLinkSnapshot = {
|
||||
entity_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;
|
||||
|
||||
@@ -164,7 +164,6 @@ Có thể do:
|
||||
- timeline filter
|
||||
- geometry visibility theo type
|
||||
- binding filter
|
||||
- replay hide outside
|
||||
|
||||
Không phải lúc nào cũng là bug render layer.
|
||||
|
||||
|
||||
@@ -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`
|
||||
- là `BattleReplay` đang chỉnh
|
||||
- chỉ chứa `geometry_id`, `target_geometry_ids`, `detail`
|
||||
- `replayDraft`
|
||||
- là `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.
|
||||
@@ -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` và `snapshot_fragment.replays[0]` hiện vẫn là cùng một replay, chỉ khác góc nhìn
|
||||
@@ -157,7 +157,6 @@ Binding hiện tại:
|
||||
Nó là mode hiển thị tập trung vào một geometry:
|
||||
|
||||
- có nút thoát replay
|
||||
- có toggle `Hide Outside`
|
||||
- có thể ẩn geometry ngoài danh sách `binding`
|
||||
|
||||
## 8. Đồng bộ selection và feature state
|
||||
|
||||
@@ -306,10 +306,11 @@ function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnaps
|
||||
const id = String(e.id);
|
||||
const source: EntitySnapshot["source"] = e.source === "inline" ? "inline" : "ref";
|
||||
return {
|
||||
...e,
|
||||
id,
|
||||
source,
|
||||
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 source: GeometrySnapshot["source"] = g.source === "inline" ? "inline" : "ref";
|
||||
return {
|
||||
...g,
|
||||
id,
|
||||
source,
|
||||
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,
|
||||
entity_id,
|
||||
operation: "reference",
|
||||
base_links_hash: safeRow.base_links_hash,
|
||||
});
|
||||
}
|
||||
return Array.from(deduped.values()).sort((a, b) => {
|
||||
@@ -368,9 +381,12 @@ function toEditorSessionWikis(input: EditorSnapshot["wikis"]): WikiSnapshot[] {
|
||||
.map((w) => {
|
||||
const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref";
|
||||
return {
|
||||
...w,
|
||||
id: w.id,
|
||||
source,
|
||||
operation: "reference",
|
||||
title: typeof w.title === "string" ? w.title : "",
|
||||
slug: w.slug ?? null,
|
||||
doc: w.doc ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,16 @@ import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { EntitySnapshotOperation } from "@/uhm/types/entities";
|
||||
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 { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||
|
||||
@@ -22,7 +31,6 @@ interface RawEntityRow extends UnknownRecord {
|
||||
ref?: { id?: string };
|
||||
name?: string;
|
||||
description?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
interface RawWikiRow extends UnknownRecord {
|
||||
@@ -33,14 +41,27 @@ interface RawWikiRow extends UnknownRecord {
|
||||
title?: string;
|
||||
slug?: 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 {
|
||||
geometry_id?: string | number;
|
||||
entity_id?: string | number;
|
||||
operation?: string;
|
||||
base_links_hash?: string;
|
||||
}
|
||||
|
||||
interface RawEntityWikiRow extends UnknownRecord {
|
||||
@@ -96,14 +117,13 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
const refId = getRefId(e.ref);
|
||||
const source: "inline" | "ref" =
|
||||
existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
|
||||
const rest: UnknownRecord = { ...e };
|
||||
delete rest.ref;
|
||||
|
||||
return {
|
||||
...(rest as unknown as Omit<EntitySnapshot, "id" | "source" | "operation">),
|
||||
id,
|
||||
source,
|
||||
operation,
|
||||
name: typeof e.name === "string" ? e.name : undefined,
|
||||
description: typeof e.description === "string" ? e.description : e.description == null ? undefined : undefined,
|
||||
};
|
||||
})
|
||||
: undefined;
|
||||
@@ -113,25 +133,37 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
? geometriesRaw
|
||||
.filter(isRecord)
|
||||
.map((g) => {
|
||||
const id = getStringId(g.id);
|
||||
const opRaw = typeof g.operation === "string" ? g.operation : undefined;
|
||||
const row = g as RawGeometryRow;
|
||||
const id = getStringId(row.id);
|
||||
const opRaw = typeof row.operation === "string" ? row.operation : undefined;
|
||||
const operation: GeometrySnapshot["operation"] =
|
||||
opRaw === "delete" ? "delete" : "reference";
|
||||
const existingSource = g.source === "inline" || g.source === "ref" ? g.source : undefined;
|
||||
const refId = getRefId(g.ref);
|
||||
const hasInlineGeometry = "draw_geometry" in g || "geometry" in g;
|
||||
const existingSource = row.source === "inline" || row.source === "ref" ? row.source : undefined;
|
||||
const refId = getRefId(row.ref);
|
||||
const hasInlineGeometry = "draw_geometry" in row || "geometry" in row;
|
||||
const source: "inline" | "ref" = existingSource || (refId || !hasInlineGeometry ? "ref" : "inline");
|
||||
const rest: UnknownRecord = { ...g };
|
||||
delete rest.ref;
|
||||
const typeKey = normalizeGeoTypeKey(rest.type) || normalizeGeoTypeKey(rest.geo_type);
|
||||
delete rest.geo_type;
|
||||
const typeKey = normalizeGeoTypeKey(row.type) || normalizeGeoTypeKey(row.geo_type);
|
||||
|
||||
return {
|
||||
...(rest as unknown as Omit<GeometrySnapshot, "id" | "source" | "operation">),
|
||||
id,
|
||||
source,
|
||||
operation,
|
||||
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;
|
||||
@@ -141,6 +173,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
? wikisRaw
|
||||
.filter(isRecord)
|
||||
.map((w) => {
|
||||
const row = w as RawWikiRow;
|
||||
const id = typeof w.id === "string" ? w.id : "";
|
||||
const opRaw = typeof w.operation === "string" ? w.operation : undefined;
|
||||
const operation: WikiSnapshot["operation"] =
|
||||
@@ -149,14 +182,13 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
const refId = getRefId(w.ref);
|
||||
const source: "inline" | "ref" =
|
||||
existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
|
||||
const rest: UnknownRecord = { ...w };
|
||||
delete rest.ref;
|
||||
|
||||
return {
|
||||
...(rest as unknown as Omit<WikiSnapshot, "id" | "source" | "operation">),
|
||||
id,
|
||||
source,
|
||||
operation,
|
||||
title: typeof row.title === "string" ? row.title : "",
|
||||
slug: row.slug ?? null,
|
||||
doc: typeof row.doc === "string" ? row.doc : null,
|
||||
};
|
||||
})
|
||||
: undefined;
|
||||
@@ -173,7 +205,6 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
const geometry_id = getStringId(row.geometry_id);
|
||||
const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
|
||||
return {
|
||||
...(row as unknown as Omit<GeometryEntitySnapshot, "geometry_id" | "entity_id">),
|
||||
geometry_id,
|
||||
entity_id,
|
||||
};
|
||||
@@ -298,7 +329,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
wikis,
|
||||
geometry_entity: geometryEntity || migratedGeometryEntity,
|
||||
entity_wiki: entityWikis,
|
||||
replays: Array.isArray(snapshot.replays) ? (snapshot.replays as BattleReplay[]) : undefined,
|
||||
replays: normalizeReplaySnapshots(snapshot.replays),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -352,10 +383,11 @@ export function buildEditorSnapshot(options: {
|
||||
const cloned = JSON.parse(JSON.stringify(prev)) as EntitySnapshot;
|
||||
delete cloned.operation;
|
||||
entityRows.set(id, {
|
||||
...cloned,
|
||||
id,
|
||||
source: "inline",
|
||||
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 || []) {
|
||||
@@ -374,11 +406,11 @@ export function buildEditorSnapshot(options: {
|
||||
if (opRaw === "delete") continue;
|
||||
const operation: EntitySnapshot["operation"] = source === "ref" ? "reference" : opRaw;
|
||||
entityRows.set(id, {
|
||||
...cloned,
|
||||
id,
|
||||
source,
|
||||
name,
|
||||
operation,
|
||||
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -391,9 +423,7 @@ export function buildEditorSnapshot(options: {
|
||||
source: "ref",
|
||||
operation: "reference",
|
||||
name: id,
|
||||
slug: null,
|
||||
description: null,
|
||||
status: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -405,9 +435,7 @@ export function buildEditorSnapshot(options: {
|
||||
source: "ref",
|
||||
operation: "reference",
|
||||
name: entityId,
|
||||
slug: 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 || []) {
|
||||
const row = r as RawGeometryEntityRow;
|
||||
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 entity_id = typeof row.entity_id === "string" || typeof row.entity_id === "number" ? String(row.entity_id).trim() : "";
|
||||
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[] = [];
|
||||
@@ -481,18 +509,17 @@ export function buildEditorSnapshot(options: {
|
||||
geometry_id,
|
||||
entity_id,
|
||||
operation: baselineGeometryEntity.has(key) ? "reference" : "binding",
|
||||
base_links_hash: baselineGeometryEntity.get(key),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
for (const [key, base_links_hash] of baselineGeometryEntity.entries()) {
|
||||
for (const key of baselineGeometryEntity.values()) {
|
||||
if (currentGeometryEntityKeys.has(key)) continue;
|
||||
const [geometry_id, entity_id] = key.split("::");
|
||||
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);
|
||||
@@ -587,7 +614,6 @@ export function buildEditorSnapshot(options: {
|
||||
title: typeof prev.title === "string" ? prev.title : "Untitled wiki",
|
||||
slug: row.slug ?? null,
|
||||
doc: row.doc ?? null,
|
||||
updated_at: row.updated_at ?? undefined,
|
||||
} as WikiSnapshot);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: GeometryEntitySnapshot[] = [];
|
||||
|
||||
@@ -38,8 +38,8 @@ type ReplayDraftSyncMode = "none" | "reset";
|
||||
|
||||
// State trung tâm của editor:
|
||||
// - 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
|
||||
// - replay feature draft: FeatureCollection con để map/editor hiện tại thao tác
|
||||
// - active replay draft: bản sao BattleReplay đang chỉnh (script + target ids)
|
||||
// - replay feature draft: FeatureCollection local được hydrate từ mainDraft + target ids
|
||||
export function useEditorState(
|
||||
initialData: FeatureCollection,
|
||||
options: {
|
||||
@@ -86,9 +86,9 @@ export function useEditorState(
|
||||
return cloned;
|
||||
}, []);
|
||||
|
||||
const syncReplayFeatureDraft = useCallback((nextFeatures: FeatureCollection) => {
|
||||
resetReplayDraft(deepClone(nextFeatures));
|
||||
}, [resetReplayDraft]);
|
||||
const syncReplayFeatureDraft = useCallback((nextReplay: BattleReplay | null) => {
|
||||
resetReplayDraft(buildReplayFeatureDraft(mainDraftRef.current, nextReplay));
|
||||
}, [mainDraftRef, resetReplayDraft]);
|
||||
|
||||
const setActiveReplayDraftState = useCallback((
|
||||
next: SetStateAction<BattleReplay | null>,
|
||||
@@ -100,7 +100,7 @@ export function useEditorState(
|
||||
setActiveReplayDraft(cloned);
|
||||
|
||||
if (syncMode === "reset") {
|
||||
syncReplayFeatureDraft(cloned?.replay_features || EMPTY_FEATURE_COLLECTION);
|
||||
syncReplayFeatureDraft(cloned);
|
||||
}
|
||||
|
||||
return cloned;
|
||||
@@ -302,9 +302,6 @@ export function useEditorState(
|
||||
|
||||
const prevReplay = deepClone(currentReplay);
|
||||
const nextReplay = deepClone(currentReplay);
|
||||
if (!nextReplay.replay_features) {
|
||||
nextReplay.replay_features = deepClone(EMPTY_FEATURE_COLLECTION);
|
||||
}
|
||||
mutator(nextReplay);
|
||||
if (replayEquals(prevReplay, nextReplay)) {
|
||||
return false;
|
||||
@@ -364,10 +361,6 @@ export function useEditorState(
|
||||
const featureClone = deepClone(feature);
|
||||
|
||||
if (mode === "replay") {
|
||||
applyReplaySessionMutation(`Replay: thêm #${featureClone.properties.id}`, (draftReplay) => {
|
||||
const featureDraft = ensureReplayFeatureCollection(draftReplay);
|
||||
featureDraft.features = [...featureDraft.features, featureClone];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -384,7 +377,6 @@ export function useEditorState(
|
||||
label = "Import geometry"
|
||||
) {
|
||||
if (mode === "replay") {
|
||||
createFeature(feature);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -433,18 +425,6 @@ export function useEditorState(
|
||||
patch: Partial<FeatureProperties>
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -474,30 +454,6 @@ export function useEditorState(
|
||||
label = "Cập nhật nhiều geometry"
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -547,15 +503,6 @@ export function useEditorState(
|
||||
|
||||
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -579,10 +526,6 @@ export function useEditorState(
|
||||
|
||||
function deleteFeature(id: FeatureProperties["id"]) {
|
||||
if (mode === "replay") {
|
||||
applyReplaySessionMutation(`Replay: xóa #${id}`, (draftReplay) => {
|
||||
const featureDraft = ensureReplayFeatureCollection(draftReplay);
|
||||
featureDraft.features = featureDraft.features.filter((feature) => feature.properties.id !== id);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -737,6 +680,7 @@ export function useEditorState(
|
||||
activeReplayDraft,
|
||||
effectiveReplays,
|
||||
setReplays: updateReplaysState,
|
||||
mutateActiveReplay: applyReplaySessionMutation,
|
||||
activeReplayId,
|
||||
switchReplayContext,
|
||||
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;
|
||||
}
|
||||
|
||||
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(
|
||||
sourceDraft: FeatureCollection,
|
||||
geometryId: string,
|
||||
@@ -799,8 +717,12 @@ function createReplaySessionSeed(
|
||||
): BattleReplay {
|
||||
return {
|
||||
geometry_id: geometryId,
|
||||
target_geometry_ids: buildReplaySeedTargetIds(
|
||||
sourceDraft.features.find((feature) => String(feature.properties.id) === geometryId),
|
||||
geometryId,
|
||||
selectedIds
|
||||
),
|
||||
detail: [],
|
||||
replay_features: buildReplaySeedFeatures(sourceDraft, geometryId, selectedIds),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -811,17 +733,103 @@ function normalizeReplaySessionSeed(
|
||||
selectedIds: (string | number)[]
|
||||
): BattleReplay {
|
||||
const nextReplay = deepClone(replay);
|
||||
if (!nextReplay.replay_features) {
|
||||
nextReplay.replay_features = buildReplaySeedFeatures(sourceDraft, geometryId, selectedIds);
|
||||
}
|
||||
const triggerFeature = sourceDraft.features.find((feature) => String(feature.properties.id) === geometryId);
|
||||
const seedTargetIds = buildReplaySeedTargetIds(triggerFeature, geometryId, selectedIds);
|
||||
nextReplay.target_geometry_ids = normalizeReplayTargetGeometryIds(
|
||||
nextReplay.target_geometry_ids,
|
||||
geometryId,
|
||||
seedTargetIds
|
||||
);
|
||||
return nextReplay;
|
||||
}
|
||||
|
||||
function ensureReplayFeatureCollection(replay: BattleReplay): FeatureCollection {
|
||||
if (!replay.replay_features) {
|
||||
replay.replay_features = deepClone(EMPTY_FEATURE_COLLECTION);
|
||||
function buildReplaySeedTargetIds(
|
||||
triggerFeature: Feature | undefined,
|
||||
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(
|
||||
|
||||
@@ -69,7 +69,7 @@ export function initSelect(
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -88,7 +88,7 @@ export function initSelect(
|
||||
|
||||
if (!features.length) return;
|
||||
|
||||
const feature = features[0];
|
||||
const feature = pickPreferredFeature(features);
|
||||
const id = feature.id ?? feature.properties?.id;
|
||||
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("mousemove", onMove);
|
||||
if (hasContextActions) {
|
||||
@@ -223,7 +239,12 @@ export function initSelect(
|
||||
if (onReplayEdit) {
|
||||
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,14 +36,6 @@ import { LayerSpecification } from "maplibre-gl";
|
||||
|
||||
export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||
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),
|
||||
...getStateLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||
...getEmpireLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||
@@ -52,6 +44,14 @@ export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string
|
||||
...getBattleLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||
...getCivilizationLayers(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),
|
||||
...getPersonBirthplaceLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||
...getPersonActivityLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||
|
||||
@@ -7,32 +7,44 @@ import type { FeatureCollection } from "@/uhm/types/geo";
|
||||
*/
|
||||
|
||||
export const mapActions = {
|
||||
// Di chuyển camera đến tọa độ [lng, lat]
|
||||
zoom_to_lnglat: (map: maplibregl.Map, lng: number, lat: number, zoom?: number) => {
|
||||
map.easeTo({
|
||||
center: [lng, lat],
|
||||
zoom: zoom ?? map.getZoom(),
|
||||
duration: 2000,
|
||||
});
|
||||
},
|
||||
|
||||
// Thay đổi mức zoom của bản đồ
|
||||
zoom_scale: (map: maplibregl.Map, zoom: number) => {
|
||||
map.easeTo({
|
||||
zoom,
|
||||
duration: 1500,
|
||||
});
|
||||
},
|
||||
|
||||
// Đặt trạng thái camera toàn diện (center, zoom, pitch, bearing)
|
||||
set_camera_view: (map: maplibregl.Map, state: { center: { lng: number; lat: number }; zoom: number; pitch: number; bearing: number }) => {
|
||||
map.easeTo({
|
||||
center: [state.center.lng, state.center.lat],
|
||||
zoom: state.zoom,
|
||||
pitch: state.pitch,
|
||||
bearing: state.bearing,
|
||||
duration: 2500,
|
||||
});
|
||||
set_camera_view: (
|
||||
map: maplibregl.Map,
|
||||
state: {
|
||||
center?: [number, number] | { lng: number; lat: number };
|
||||
zoom?: number;
|
||||
pitch?: number;
|
||||
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
|
||||
@@ -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 đồ
|
||||
toggle_labels: (map: maplibregl.Map, visible: boolean) => {
|
||||
const style = map.getStyle();
|
||||
if (!style) return;
|
||||
style.layers.forEach(layer => {
|
||||
if (layer.type === 'symbol' && (layer as any).layout?.['text-field']) {
|
||||
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');
|
||||
}
|
||||
});
|
||||
@@ -89,3 +83,24 @@ export const mapActions = {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type maplibregl from "maplibre-gl";
|
||||
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 { uiActions } from "./uiActions";
|
||||
import { narrativeActions } from "./narrativeActions";
|
||||
@@ -15,7 +21,6 @@ export interface ReplayControllers {
|
||||
|
||||
// UI Setters
|
||||
setTimelineVisible: (v: boolean) => void;
|
||||
setUIVisible: (v: boolean) => void;
|
||||
setSidebarOpen: (v: boolean) => void;
|
||||
onSelectWiki: (id: string) => void;
|
||||
addToast: (msg: string) => void;
|
||||
@@ -25,7 +30,7 @@ export interface ReplayControllers {
|
||||
// Narrative Setters
|
||||
setTitle: (t: 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;
|
||||
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
|
||||
* 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;
|
||||
|
||||
// 1. Nhóm Map Actions
|
||||
if (controllers.map) {
|
||||
const map = controllers.map;
|
||||
switch (function_name as MapFunctionName) {
|
||||
case "zoom_to_lnglat":
|
||||
mapActions.zoom_to_lnglat(map, params[0], params[1], params[2]);
|
||||
return;
|
||||
case "zoom_scale":
|
||||
mapActions.zoom_scale(map, params[0]);
|
||||
return;
|
||||
switch (function_name as MapFunctionName | GeoFunctionName) {
|
||||
case "set_camera_view":
|
||||
mapActions.set_camera_view(map, params[0]);
|
||||
mapActions.set_camera_view(map, normalizeCameraViewState(params[0]));
|
||||
return;
|
||||
case "fly_to_geometry":
|
||||
mapActions.fly_to_geometry(map, params[0], controllers.draft);
|
||||
return;
|
||||
case "rotate_around_point":
|
||||
mapActions.rotate_around_point(map, params[0]);
|
||||
mapActions.fly_to_geometry(
|
||||
map,
|
||||
asStringValue(params[0]),
|
||||
controllers.draft,
|
||||
);
|
||||
return;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Nhóm UI Actions
|
||||
switch (function_name as UIFunctionName) {
|
||||
case "hide_timeline":
|
||||
uiActions.hide_timeline(controllers.setTimelineVisible);
|
||||
const uiDescriptor = getUiActionDescriptor(function_name, params);
|
||||
if (uiDescriptor) {
|
||||
const { option, payload } = uiDescriptor;
|
||||
switch (option) {
|
||||
case "timeline":
|
||||
uiActions.timeline(controllers.setTimelineVisible, Boolean(payload[0] ?? false));
|
||||
return;
|
||||
case "hide_all_UI":
|
||||
uiActions.hide_all_UI(controllers.setUIVisible);
|
||||
case "layer_panel":
|
||||
uiActions.layer_panel(Boolean(payload[0] ?? false));
|
||||
return;
|
||||
case "open_wiki":
|
||||
uiActions.open_wiki(controllers.setSidebarOpen, controllers.onSelectWiki, params[0]);
|
||||
case "wiki_panel":
|
||||
uiActions.wiki_panel(controllers.setSidebarOpen, Boolean(payload[0] ?? false));
|
||||
return;
|
||||
case "show_toast_message":
|
||||
uiActions.show_toast_message(controllers.addToast, params[0]);
|
||||
case "zoom_panel":
|
||||
uiActions.zoom_panel(Boolean(payload[0] ?? false));
|
||||
return;
|
||||
case "set_playback_speed":
|
||||
uiActions.set_playback_speed(controllers.setPlaybackSpeed, params[0]);
|
||||
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
|
||||
switch (function_name as NarrativeFunctionName) {
|
||||
case "set_title":
|
||||
narrativeActions.set_title(controllers.setTitle, params[0]);
|
||||
narrativeActions.set_title(controllers.setTitle, asStringValue(params[0]));
|
||||
return;
|
||||
case "set_descriptions":
|
||||
narrativeActions.set_descriptions(controllers.setDescriptions, params[0]);
|
||||
narrativeActions.set_descriptions(controllers.setDescriptions, asStringValue(params[0]));
|
||||
return;
|
||||
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;
|
||||
case "display_historical_image":
|
||||
narrativeActions.display_historical_image(controllers.setImage, params[0]);
|
||||
narrativeActions.display_historical_image(controllers.setImage, asStringValue(params[0]));
|
||||
return;
|
||||
case "set_step_subtitle":
|
||||
narrativeActions.set_step_subtitle(controllers.setSubtitle, params[0]);
|
||||
narrativeActions.set_step_subtitle(controllers.setSubtitle, asStringValue(params[0]));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3,29 +3,47 @@
|
||||
*/
|
||||
|
||||
export const uiActions = {
|
||||
// Ẩn thanh Timeline
|
||||
hide_timeline: (setTimelineVisible: (v: boolean) => void) => {
|
||||
setTimelineVisible(false);
|
||||
// Ẩn/hiện thanh Timeline
|
||||
timeline: (setTimelineVisible: (v: boolean) => void, visible: boolean) => {
|
||||
setTimelineVisible(visible);
|
||||
},
|
||||
|
||||
// Ẩn toàn bộ UI để có trải nghiệm điện ảnh (Cinematic)
|
||||
hide_all_UI: (setUIVisible: (v: boolean) => void) => {
|
||||
setUIVisible(false);
|
||||
// Ẩn/hiện panel layer. Runtime hiện chưa có controller riêng nên tạm no-op.
|
||||
layer_panel: (visible: boolean) => {
|
||||
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ể
|
||||
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);
|
||||
onSelectWiki(wikiId);
|
||||
},
|
||||
|
||||
// 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);
|
||||
},
|
||||
|
||||
// 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
|
||||
set_playback_speed: (setSpeed: (s: number) => void, speed: number) => {
|
||||
playback_speed: (setSpeed: (s: number) => void, speed: number) => {
|
||||
setSpeed(speed);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@ export type Entity = {
|
||||
// API cũ / snapshot editor (giữ optional để không phá flow editor snapshot)
|
||||
slug?: string | null;
|
||||
type_id?: string | null;
|
||||
status?: number | null;
|
||||
geometry_count?: number;
|
||||
};
|
||||
|
||||
@@ -29,9 +28,5 @@ export type EntitySnapshot = {
|
||||
// join tables (geometry_entity / entity_wiki), not here.
|
||||
operation?: EntitySnapshotOperation;
|
||||
name?: string;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
status?: number | null;
|
||||
base_updated_at?: string;
|
||||
base_hash?: string;
|
||||
};
|
||||
|
||||
@@ -61,8 +61,6 @@ export type GeometrySnapshot = {
|
||||
max_lng: number;
|
||||
max_lat: number;
|
||||
} | null;
|
||||
base_updated_at?: string;
|
||||
base_hash?: string;
|
||||
};
|
||||
|
||||
// Snapshot join table (geometry ↔ entity).
|
||||
@@ -73,7 +71,6 @@ export type GeometryEntitySnapshot = {
|
||||
// - reference/binding: the link exists (assigned)
|
||||
// - delete: the link is removed
|
||||
operation?: "reference" | "binding" | "delete";
|
||||
base_links_hash?: string;
|
||||
};
|
||||
|
||||
export type GeometryChange =
|
||||
|
||||
+35
-27
@@ -89,32 +89,41 @@ export type EditorSnapshot = {
|
||||
|
||||
// ---- 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 UIOptionName =
|
||||
| "timeline" // Ẩn/hiện timeline
|
||||
| "layer_panel" // Ẩn/hiện panel layer
|
||||
| "wiki_panel" // Ẩn/hiện panel wiki
|
||||
| "zoom_panel" // Ẩn/hiện nút zoom
|
||||
| "wiki" // Mở/chọn wiki
|
||||
| "toast" // Hiển thị toast
|
||||
| "wiki_header" // Focus header trong wiki
|
||||
| "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 đồ
|
||||
| "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 =
|
||||
| "set_title" // Đặt tiêu đề cho bước replay
|
||||
@@ -125,13 +134,14 @@ export type NarrativeFunctionName =
|
||||
|
||||
export type ReplayAction<T> = {
|
||||
function_name: T;
|
||||
params: any[];
|
||||
params: unknown[];
|
||||
};
|
||||
|
||||
export type ReplayStep = {
|
||||
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_geo_function: ReplayAction<GeoFunctionName>[];
|
||||
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
|
||||
};
|
||||
|
||||
@@ -145,9 +155,8 @@ export type ReplayStage = {
|
||||
|
||||
export type BattleReplay = {
|
||||
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[];
|
||||
// Local-only: separate draft for this specific replay
|
||||
replay_features?: FeatureCollection;
|
||||
};
|
||||
|
||||
|
||||
@@ -175,4 +184,3 @@ export type CreateCommitInput = {
|
||||
export type RestoreCommitInput = {
|
||||
commit_id: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,12 +2,6 @@
|
||||
// FE wiki runtime now stores HTML or plain text in this string field.
|
||||
export type WikiDoc = string | null;
|
||||
|
||||
export type WikiContentSample = {
|
||||
id: string;
|
||||
title: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||
|
||||
export type WikiSnapshot = {
|
||||
@@ -17,6 +11,4 @@ export type WikiSnapshot = {
|
||||
title: string;
|
||||
slug?: string | null;
|
||||
doc: WikiDoc;
|
||||
content_sample?: WikiContentSample[];
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user