complete replay editor v1

This commit is contained in:
taDuc
2026-05-17 21:45:33 +07:00
parent 3808086529
commit 047f662736
23 changed files with 4658 additions and 490 deletions
-3
View File
@@ -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
+2 -1
View File
@@ -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(),
}
)
);
+315 -145
View File
@@ -1,194 +1,364 @@
/**
* Tài liệu schema tham chiếu cho snapshot commit.
* Schema tham chiếu cho commit snapshot.
*
* Lưu ý:
* - Đây 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 ----
export type CreateCommitRequest = {
snapshot_json: CommitSnapshot;
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[];
snapshot_json: CommitSnapshot;
edit_summary: string;
};
// ---- 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)
| ({ type: "LineString"; coordinates: [number, number][] } & CircleGeometryMetadata)
| ({ type: "MultiLineString"; coordinates: [number, number][][] } & CircleGeometryMetadata)
| ({ type: "Polygon"; coordinates: [number, number][][] } & CircleGeometryMetadata)
| ({ type: "MultiPolygon"; coordinates: [number, number][][][] } & CircleGeometryMetadata);
| ({ type: "Point"; coordinates: [number, number] } & CircleGeometryMetadata)
| ({ type: "MultiPoint"; coordinates: [number, number][] } & CircleGeometryMetadata)
| ({ type: "LineString"; coordinates: [number, number][] } & CircleGeometryMetadata)
| ({ type: "MultiLineString"; coordinates: [number, number][][] } & CircleGeometryMetadata)
| ({ type: "Polygon"; coordinates: [number, number][][] } & CircleGeometryMetadata)
| ({ type: "MultiPolygon"; coordinates: [number, number][][][] } & CircleGeometryMetadata);
export type CircleGeometryMetadata = {
circle_center?: [number, number];
circle_radius?: number;
circle_center?: [number, number];
circle_radius?: number;
};
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
id: FeatureId;
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 = {
type: "Feature";
properties: FeatureProperties;
geometry: Geometry;
type: "Feature";
properties: FeatureProperties;
geometry: Geometry;
};
export type FeatureCollection = {
type: "FeatureCollection";
features: Feature[];
type: "FeatureCollection";
features: Feature[];
};
// ---- Snapshot rows ----
export type SnapshotSource = "inline" | "ref";
export type SnapshotOperation = "create" | "update" | "delete" | "reference";
export type EntitySnapshot = {
id: string;
source: SnapshotSource;
operation?: SnapshotOperation;
export type EntitySnapshotOperation = SnapshotOperation;
export type GeometrySnapshotOperation = SnapshotOperation;
export type WikiSnapshotOperation = SnapshotOperation;
name?: string;
description?: string | null;
export type EntitySnapshot = {
id: string;
source: SnapshotSource;
operation?: EntitySnapshotOperation;
name?: string;
description?: string | null;
};
export type GeometrySnapshot = {
id: string;
source: SnapshotSource;
operation?: SnapshotOperation;
type?: string | null;
draw_geometry?: Geometry;
binding?: string[];
time_start?: number | null;
time_end?: number | null;
bbox?: {
min_lng: number;
min_lat: number;
max_lng: number;
max_lat: number;
} | null;
id: string;
source: SnapshotSource;
operation?: GeometrySnapshotOperation;
type?: string | null;
draw_geometry?: Geometry;
geometry?: Geometry;
binding?: string[];
time_start?: number | null;
time_end?: number | null;
bbox?: {
min_lng: number;
min_lat: number;
max_lng: number;
max_lat: number;
} | null;
};
export type GeometryEntitySnapshot = {
geometry_id: string;
entity_id: string;
operation?: "reference" | "delete" | "binding";
geometry_id: string;
entity_id: string;
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;
title: string;
slug?: string | null;
doc: WikiDoc;
id: string;
source: SnapshotSource;
operation?: WikiSnapshotOperation;
title: string;
slug?: string | null;
doc: WikiDoc;
};
export type EntityWikiLinkSnapshot = {
entity_id: string;
wiki_id: string;
operation?: "reference" | "delete" | "binding";
entity_id: string;
wiki_id: string;
operation?: "reference" | "binding" | "delete";
};
// ---- Replay / Scripting System (runtime shape) ----
/**
* Canonical UI action names trong snapshot hiện tại.
* Không còn wrapper `function_name: "UI"` trong shape mới.
*/
export type UIOptionName =
| "timeline"
| "layer_panel"
| "wiki_panel"
| "zoom_panel"
| "wiki"
| "toast"
| "wiki_header"
| "playback_speed";
export type MapFunctionName =
| "set_camera_view"
| "set_time_filter"
| "enable_timeline_filter"
| "disable_timeline_filter"
| "toggle_labels"
| "show_labels"
| "hide_labels"
| "reset_camera_north";
export type GeoFunctionName =
| "fly_to_geometry"
| "fly_to_geometries"
| "set_geometry_visibility"
| "show_geometries"
| "hide_geometries"
| "fit_to_geometries"
| "orbit_camera_around_geometry"
| "pulse_geometry"
| "animate_dashed_border"
| "set_geometry_style"
| "show_geometry_label"
| "follow_geometry_path"
| "follow_geometries_path"
| "dim_other_geometries";
export type NarrativeFunctionName =
| "set_title"
| "set_descriptions"
| "show_dialog_box"
| "display_historical_image"
| "set_step_subtitle";
/**
* Runtime thật hiện dùng positional array cho params.
* File doc này giữ đúng shape đó.
*/
export type ReplayAction<T> = {
function_name: T;
params: unknown[];
};
export type ReplayStep = {
duration: number;
use_UI_function: ReplayAction<UIOptionName>[];
use_map_function: ReplayAction<MapFunctionName>[];
use_geo_function: ReplayAction<GeoFunctionName>[];
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
};
export type ReplayStage = {
id: number;
title?: string;
detail_time_start: string;
detail_time_stop: string;
steps: ReplayStep[];
};
export type BattleReplay = {
geometry_id: string;
target_geometry_ids: string[];
detail: ReplayStage[];
};
// ---- Replay tuple docs ----
/**
* Doc-only helper để giải thích meaning của từng vị trí trong `params`.
* Runtime không ép các tuple này; chúng chỉ là tài liệu tham chiếu.
*/
export type ReplayCameraViewStateDoc = {
center?: [number, number] | { lng: number; lat: number };
zoom?: number;
pitch?: number;
bearing?: number;
duration?: number;
};
export type ReplayUiParamTupleDocs = {
timeline: [visible: boolean];
layer_panel: [visible: boolean];
wiki_panel: [visible: boolean];
zoom_panel: [visible: boolean];
wiki: [wiki_id: string];
toast: [message: string];
wiki_header: [header_id: string];
playback_speed: [speed: number];
};
/**
* Snapshot cũ kiểu `function_name: "UI"` chỉ còn là legacy input.
* Frontend hiện normalize chúng sang `function_name: UIOptionName` khi load.
*/
export type ReplayMapFunctionParamTupleDocs = {
set_camera_view: [state: ReplayCameraViewStateDoc];
set_time_filter: [year: number];
enable_timeline_filter: [];
disable_timeline_filter: [];
toggle_labels: [visible: boolean];
show_labels: [];
hide_labels: [];
reset_camera_north: [];
};
export type ReplayGeoFunctionParamTupleDocs = {
fly_to_geometry: [
geometry_id: string,
zoom?: number,
padding?: number,
duration?: number,
];
fly_to_geometries: [geometry_ids: string[]];
set_geometry_visibility: [geometry_ids: string[], visible: boolean];
show_geometries: [geometry_ids: string[]];
hide_geometries: [geometry_ids: string[]];
fit_to_geometries: [
geometry_ids: string[],
padding?: number,
duration?: number,
];
orbit_camera_around_geometry: [
geometry_id: string,
zoom?: number,
pitch?: number,
revolutions?: number,
duration?: number,
];
pulse_geometry: [
geometry_id: string,
color?: string,
repeat?: number,
duration?: number,
];
animate_dashed_border: [
geometry_id: string,
color?: string,
width?: number,
speed?: number,
duration?: number,
];
set_geometry_style: [
geometry_ids: string[],
fill_color?: string,
fill_opacity?: number,
line_color?: string,
line_width?: number,
];
show_geometry_label: [
geometry_id: string,
text?: string,
color?: string,
size?: number,
];
follow_geometry_path: [
geometry_id: string,
duration?: number,
zoom?: number,
pitch?: number,
];
follow_geometries_path: [
geometry_ids: string[],
duration?: number,
zoom?: number,
pitch?: number,
];
dim_other_geometries: [
geometry_ids: string[],
opacity?: number,
];
};
export type ReplayNarrativeParamTupleDocs = {
set_title: [title: string];
set_descriptions: [text: string];
show_dialog_box: [
avatar: string,
text: string,
side?: "left" | "right",
speaker?: string,
];
display_historical_image: [
url: string,
caption?: string,
];
set_step_subtitle: [subtitle: string | null];
};
export type ReplayParamTupleDocs =
& ReplayUiParamTupleDocs
& ReplayMapFunctionParamTupleDocs
& ReplayGeoFunctionParamTupleDocs
& ReplayNarrativeParamTupleDocs;
export type ReplayActionTupleDoc<T extends keyof ReplayParamTupleDocs> = {
function_name: T;
params: ReplayParamTupleDocs[T];
};
// ---- Snapshot root ----
export type EditorSnapshot = {
// Legacy snapshots có thể còn field project embedded.
project?: {
id: string;
title: string;
};
editor_feature_collection?: FeatureCollection;
entities?: EntitySnapshot[];
geometries?: GeometrySnapshot[];
geometry_entity?: GeometryEntitySnapshot[];
wikis?: WikiSnapshot[];
entity_wiki?: EntityWikiLinkSnapshot[];
replays?: BattleReplay[];
};
export type CommitSnapshot = EditorSnapshot;
-1
View File
@@ -164,7 +164,6 @@ Có thể do:
- timeline filter
- geometry visibility theo type
- binding filter
- replay hide outside
Không phải lúc nào cũng là bug render layer.
+218
View File
@@ -0,0 +1,218 @@
# UHM Editor - state replay hiện tại
Tài liệu này mô tả đúng flow replay mode hiện tại của `/editor/[id]`.
Nguồn thật:
- `src/app/editor/[id]/page.tsx`
- `src/uhm/lib/editor/state/useEditorState.ts`
- `src/uhm/lib/editor/project/useProjectCommands.ts`
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
## 1. Kết luận ngắn
Replay mode hiện tại có 2 lớp state:
- `activeReplayDraft`
-`BattleReplay` đang chỉnh
- chỉ chứa `geometry_id`, `target_geometry_ids`, `detail`
- `replayDraft`
-`FeatureCollection` local, được FE hydrate lại từ `mainDraft + target_geometry_ids`
- chỉ dùng để map/render/select trong replay mode
Điểm quan trọng:
- `replayDraft` không còn được persist vào commit/API
- commit chỉ lưu `replays[]` với `target_geometry_ids`
- snapshot cũ còn `replay_features` sẽ được FE migrate sang `target_geometry_ids` khi load
## 2. Shape replay hiện tại
```ts
type BattleReplay = {
geometry_id: string;
target_geometry_ids: string[];
detail: ReplayStage[];
};
```
Ý nghĩa:
- `geometry_id`
- MAIN geo của replay
- cũng là key để tìm replay tương ứng
- `target_geometry_ids`
- toàn bộ geo được đưa vào replay
- phần tử đầu nên luôn là MAIN geo
- `detail`
- stage/step/actions của kịch bản
## 3. Replay được mở như thế nào
Khi vào replay từ UI:
1. editor lấy `triggerId`
- ưu tiên `selectedFeatureIds[0]`
- nếu chưa có selection thì dùng `featureId` vừa click
2. gọi `editor.switchReplayContext(triggerId, selectedFeatureIds)`
3. `switchReplayContext()` sẽ:
- flush replay cũ nếu đang mở replay khác
- tìm replay đã tồn tại theo `geometry_id`
- nếu chưa có thì tạo seed mới
## 4. Seed replay được tạo ra sao
Replay seed mới có dạng:
```ts
{
geometry_id: triggerId,
target_geometry_ids: [...],
detail: []
}
```
`target_geometry_ids` được build từ:
- MAIN geo
- toàn bộ bulk selection hiện tại
- toàn bộ `binding` của MAIN geo trong `mainDraft`
Rule hiện tại:
- MAIN geo luôn đứng đầu
- geo trùng sẽ được dedupe
- nếu replay đã tồn tại sẵn, FE giữ `detail` cũ và chỉ append thêm geo mới còn thiếu vào `target_geometry_ids`
## 5. `replayDraft` được hydrate thế nào
`replayDraft` không còn nằm trong snapshot.
Mỗi lần:
- mở replay
- undo replay session
- restore `activeReplayDraft`
FE sẽ hydrate lại:
```ts
replayDraft = hydrate(mainDraft, activeReplayDraft.target_geometry_ids)
```
Hydrate hiện tại:
- lấy feature từ `mainDraft` theo đúng thứ tự `target_geometry_ids`
- clone ra `FeatureCollection` mới
- flatten `binding` thành `[]` để các geo trong replay bình đẳng với nhau
## 6. Trong replay mode map đang đọc gì
`useEditorState()` vẫn switch active draft như cũ:
```ts
const activeDraft = mode === "replay" ? replayDraft : mainDraft;
```
Nên khi `mode === "replay"`:
- `editor.draft` trỏ vào `replayDraft`
- `editor.draftRef` trỏ vào `replayDraftRef`
- map chỉ render tập geo đang nằm trong `target_geometry_ids`
## 7. Replay mode còn sửa geometry không
Không.
Hiện tại state layer đã chặn toàn bộ nhánh mutate geometry trong replay mode:
- `createFeature`
- `createFeatureWithSnapshotEntities`
- `patchFeatureProperties`
- `patchFeaturePropertiesBatch`
- `updateFeature`
- `deleteFeature`
Nghĩa là:
- replay mode chỉ còn là nơi viết script replay
- không còn persist hay commit geometry edit riêng của replay
## 8. Cái gì vẫn được sửa trong replay mode
Replay sidebar vẫn sửa:
- `detail[]`
- `stage`
- `step`
- các action `UI / map / geo / narrative`
Các thay đổi đó đi qua:
- `editor.mutateActiveReplay`
- `applyReplaySessionMutation()`
Undo replay vẫn riêng ở:
- `replayUndoStack`
## 9. Khi nào replay được flush về `replays[]`
`activeReplayDraft` chỉ là session đang mở.
Nó được flush về `replays[]` khi:
- thoát replay mode
- chuyển sang replay khác
Hàm chịu trách nhiệm là:
- `finalizeActiveReplaySession()`
## 10. Commit lấy replay từ đâu
Commit không lấy `activeReplayDraft` trực tiếp.
Nó lấy:
- `editor.effectiveReplays`
`effectiveReplays` là:
- `replays`
- cộng thêm overlay của `activeReplayDraft` nếu session hiện tại đã thay đổi nhưng chưa flush
Vì vậy:
- đang còn ở replay mode vẫn commit được replay mới nhất
- không cần thoát replay mode mới lưu được script
## 11. Replay đi qua API ra sao
Payload commit hiện tại chỉ gửi:
- `geometry_id`
- `target_geometry_ids`
- `detail`
Không gửi:
- `replayDraft`
- `replay_features`
- `FeatureCollection` local của replay mode
## 12. Migrate dữ liệu cũ
Snapshot cũ nếu còn:
```ts
replay_features?: FeatureCollection
```
thì FE sẽ:
- đọc `replay_features.features[].properties.id`
- chuyển chúng thành `target_geometry_ids`
- bỏ `replay_features` khỏi runtime replay mới
Nên dữ liệu cũ vẫn mở được, nhưng commit mới sẽ ra schema mới.
+246
View File
@@ -0,0 +1,246 @@
# Replay Export JSON
Tài liệu này mô tả đúng payload mà nút `Export JSON` của replay đang xuất ra hiện tại.
Nguồn thật:
- `src/uhm/components/editor/ReplayTimelineSidebar.tsx`
## 1. Kết luận ngắn
Export hiện tại có dạng:
```json
{
"exported_at": "2026-05-17T12:34:56.000Z",
"geometry_id": "geo-main-id",
"current_replay": { "...": "BattleReplay hiện tại" },
"snapshot_fragment": {
"replays": [
{ "...": "chính current_replay" }
]
}
}
```
Trong đó:
- `current_replay` là replay đang edit
- `snapshot_fragment.replays[0]` là cùng replay đó, nhưng đặt vào đúng chỗ trong commit snapshot
## 2. Root payload
```ts
type ReplayExportPayload = {
exported_at: string;
geometry_id: string;
current_replay: BattleReplay;
snapshot_fragment: {
replays: BattleReplay[];
};
};
```
Ý nghĩa:
- `exported_at`
- timestamp ISO lúc bấm export
- chỉ để debug
- `geometry_id`
- copy nhanh từ `current_replay.geometry_id`
- `current_replay`
- replay draft hiện tại
- `snapshot_fragment`
- fragment để test replay này nếu đặt vào commit snapshot thật
## 3. Shape của `current_replay`
```ts
type BattleReplay = {
geometry_id: string;
target_geometry_ids: string[];
detail: ReplayStage[];
};
```
Ý nghĩa:
- `geometry_id`
- MAIN geo của replay
- `target_geometry_ids`
- toàn bộ geo thuộc replay
- phần tử đầu nên luôn là MAIN geo
- `detail`
- stage/step/actions của replay script
## 4. `target_geometry_ids` là gì
Đây là phần thay thế cho `replay_features` cũ.
FE không còn export/persist cả `FeatureCollection` riêng của replay nữa. Thay vào đó chỉ lưu:
- geo MAIN
- các geo được đưa vào replay từ bulk select
- binding của MAIN geo
Khi mở replay, FE sẽ hydrate lại `replayDraft` từ:
- `mainDraft`
- `target_geometry_ids`
## 5. Shape của `detail`
```ts
type ReplayStage = {
id: number;
title?: string;
detail_time_start: string;
detail_time_stop: string;
steps: ReplayStep[];
};
```
```ts
type ReplayStep = {
duration: number;
use_UI_function: ReplayAction<UIOptionName>[];
use_map_function: ReplayAction<MapFunctionName>[];
use_geo_function: ReplayAction<GeoFunctionName>[];
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
};
```
Ý nghĩa:
- `stage` là cụm lớn theo mốc thời gian hoặc nhịp kể chuyện
- `step` là đơn vị phát nhỏ hơn trong một stage
- `duration` là trọng số thời gian của step
- action hiện tách thành 4 nhóm
## 6. Ví dụ JSON gần thực tế
```json
{
"exported_at": "2026-05-17T12:34:56.000Z",
"geometry_id": "019e13ab-4823-76c5-afde-2391c0cf311d",
"current_replay": {
"geometry_id": "019e13ab-4823-76c5-afde-2391c0cf311d",
"target_geometry_ids": [
"019e13ab-4823-76c5-afde-2391c0cf311d",
"019e13ab-6063-713d-a28f-98a1556817a7",
"019e13ab-5896-713a-111111111111"
],
"detail": [
{
"id": 0,
"title": "Mở đầu chiến dịch",
"detail_time_start": "1939",
"detail_time_stop": "1940",
"steps": [
{
"duration": 1000,
"use_UI_function": [
{
"function_name": "timeline",
"params": [false]
}
],
"use_map_function": [
{
"function_name": "set_time_filter",
"params": [1939]
}
],
"use_geo_function": [
{
"function_name": "fly_to_geometries",
"params": [
[
"019e13ab-4823-76c5-afde-2391c0cf311d",
"019e13ab-6063-713d-a28f-98a1556817a7"
]
]
}
],
"use_narrow_function": [
{
"function_name": "set_title",
"params": ["Chiến dịch bắt đầu"]
}
]
}
]
}
]
},
"snapshot_fragment": {
"replays": [
{
"geometry_id": "019e13ab-4823-76c5-afde-2391c0cf311d",
"target_geometry_ids": [
"019e13ab-4823-76c5-afde-2391c0cf311d",
"019e13ab-6063-713d-a28f-98a1556817a7",
"019e13ab-5896-713a-111111111111"
],
"detail": [
{
"id": 0,
"title": "Mở đầu chiến dịch",
"detail_time_start": "1939",
"detail_time_stop": "1940",
"steps": [
{
"duration": 1000,
"use_UI_function": [
{
"function_name": "timeline",
"params": [false]
}
],
"use_map_function": [
{
"function_name": "set_time_filter",
"params": [1939]
}
],
"use_geo_function": [
{
"function_name": "fly_to_geometries",
"params": [
[
"019e13ab-4823-76c5-afde-2391c0cf311d",
"019e13ab-6063-713d-a28f-98a1556817a7"
]
]
}
],
"use_narrow_function": [
{
"function_name": "set_title",
"params": ["Chiến dịch bắt đầu"]
}
]
}
]
}
]
}
]
}
}
```
## 7. Cách đọc file export
Khi nhìn file export:
- nếu cần biết replay bám vào geo nào, xem `geometry_id`
- nếu cần biết replay gồm những geo nào, xem `target_geometry_ids`
- nếu cần biết script sẽ làm gì, xem `detail[].steps[]`
- nếu cần so với commit snapshot, xem `snapshot_fragment.replays`
## 8. Ghi chú quan trọng
- Export hiện tại không còn chứa `replay_features`
- Nếu mở replay cũ từng dùng `replay_features`, FE sẽ migrate sang `target_geometry_ids` trước khi export
- `current_replay``snapshot_fragment.replays[0]` hiện vẫn là cùng một replay, chỉ khác góc nhìn
-1
View File
@@ -157,7 +157,6 @@ Binding hiện tại:
Nó là mode hiển thị tập trung vào một geometry:
- 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,
};
});
}
+289 -35
View File
@@ -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[] = [];
+105 -97
View File
@@ -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(
+24 -3
View File
@@ -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;
}
}
+8 -8
View File
@@ -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),
+60 -45
View File
@@ -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;
}
+182 -39
View File
@@ -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);
return;
case "hide_all_UI":
uiActions.hide_all_UI(controllers.setUIVisible);
return;
case "open_wiki":
uiActions.open_wiki(controllers.setSidebarOpen, controllers.onSelectWiki, params[0]);
return;
case "show_toast_message":
uiActions.show_toast_message(controllers.addToast, params[0]);
return;
case "set_playback_speed":
uiActions.set_playback_speed(controllers.setPlaybackSpeed, params[0]);
return;
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 "layer_panel":
uiActions.layer_panel(Boolean(payload[0] ?? false));
return;
case "wiki_panel":
uiActions.wiki_panel(controllers.setSidebarOpen, Boolean(payload[0] ?? false));
return;
case "zoom_panel":
uiActions.zoom_panel(Boolean(payload[0] ?? false));
return;
case "wiki":
uiActions.wiki(
controllers.setSidebarOpen,
controllers.onSelectWiki,
typeof payload[0] === "string" ? payload[0] : ""
);
return;
case "toast":
uiActions.toast(
controllers.addToast,
typeof payload[0] === "string" ? payload[0] : ""
);
return;
case "wiki_header":
uiActions.wiki_header(typeof payload[0] === "string" ? payload[0] : "");
return;
case "playback_speed":
uiActions.playback_speed(
controllers.setPlaybackSpeed,
typeof payload[0] === "number" ? payload[0] : 1
);
return;
}
}
// 3. Nhóm Narrative Actions
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;
}
+27 -9
View File
@@ -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);
}
};
-5
View File
@@ -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;
};
-3
View File
@@ -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
View File
@@ -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;
};
-8
View File
@@ -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;
};