{typeof filterEnabled === "boolean" && onFilterEnabledChange ? (
-
onFilterEnabledChange(!filterEnabled)}
+ disabled={effectiveDisabled}
>
- onFilterEnabledChange(e.target.checked)}
- disabled={effectiveDisabled}
- aria-label="Toggle timeline filter"
- style={{ display: "none" }}
- />
-
+
) : null}
{formatYear(lower)}
>;
+ onRemoveWiki?: (wikiId: string) => void;
};
function clampTitle(title: string) {
@@ -63,7 +64,7 @@ function clampTitle(title: string) {
return t.length ? t.slice(0, 120) : "Untitled wiki";
}
-export default function WikiSidebarPanel({ projectId, setWikis }: Props) {
+export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
const { wikis, requestedActiveId } = useEditorStore(
useShallow((state) => ({
wikis: state.snapshotWikis,
@@ -252,7 +253,11 @@ export default function WikiSidebarPanel({ projectId, setWikis }: Props) {
};
const removeWiki = (id: string) => {
- setWikis((prev) => prev.filter((w) => w.id !== id));
+ if (onRemoveWiki) {
+ onRemoveWiki(id);
+ } else {
+ setWikis((prev) => prev.filter((w) => w.id !== id));
+ }
if (activeId === id) setActiveId(null);
};
diff --git a/src/uhm/doc/commit_snapshot.ts b/src/uhm/doc/commit_snapshot.ts
index 61e1148..8b472d1 100644
--- a/src/uhm/doc/commit_snapshot.ts
+++ b/src/uhm/doc/commit_snapshot.ts
@@ -11,7 +11,8 @@
* - 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`.
+ * - Trước khi gửi API, frontend còn normalize thêm một số field, ví dụ
+ * `time_start/time_end` và `geometries[].type`.
*/
// ---- Root request ----
@@ -53,6 +54,12 @@ export type FeatureProperties = {
entity_ids?: string[];
entity_name?: string | null;
entity_names?: string[];
+ entity_label_candidates?: Array<{
+ id: string;
+ name: string;
+ time_start?: number | null;
+ time_end?: number | null;
+ }>;
entity_type_id?: string | null;
point_label?: string | null;
line_label?: string | null;
@@ -85,6 +92,8 @@ export type EntitySnapshot = {
operation?: EntitySnapshotOperation;
name?: string;
description?: string | null;
+ time_start?: number | null;
+ time_end?: number | null;
};
export type GeometrySnapshot = {
@@ -267,15 +276,11 @@ export type ReplayGeoFunctionParamTupleDocs = {
padding?: number,
duration?: number,
];
- fly_to_geometries: [geometry_ids: string[]];
+ fly_to_geometries: [geometry_ids: string[], duration?: number];
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,
- ];
+ fit_to_geometries: [geometry_ids: string[], duration?: number];
orbit_camera_around_geometry: [
geometry_id: string,
zoom?: number,
diff --git a/src/uhm/doc/developer_guide.md b/src/uhm/doc/developer_guide.md
index b7c2343..cf7b81b 100644
--- a/src/uhm/doc/developer_guide.md
+++ b/src/uhm/doc/developer_guide.md
@@ -17,6 +17,14 @@ Tài liệu này dành cho người sửa editor hiện tại, không phải mô
Nếu chưa đọc 5 file này, chưa nên sửa behavior lớn của editor.
+Docs nên đọc trước khi sửa editor:
+
+- `src/uhm/doc/editor_operations.md`
+- `src/uhm/doc/editor_data_roles.md`
+- `src/uhm/doc/editor_snapshot_contract.md`
+- `src/uhm/doc/editor_manual_test_checklist.md`
+- `src/uhm/doc/editor_replay_actions.md`
+
## 2. Cấu trúc thư mục nên ưu tiên hiểu
- `src/uhm/components/editor/`
@@ -40,14 +48,17 @@ Editor có 3 tầng dữ liệu:
1. `baselineSnapshot`
- snapshot gốc của session
-2. `initialData`
+2. `baselineFeatureCollection`
- `FeatureCollection` rehydrate từ snapshot đó
-3. `draft`
+ - seed/reset cho `useEditorState()`
+3. `mainDraft`
- working copy để user sửa trên map
+Map không render trực tiếp `mainDraft` mọi lúc. Page tạo `mapRenderDraft` từ `mainDraft`/`replayDraft`/preview draft sau khi áp timeline/replay filter, rồi truyền xuống `Map` dưới prop `renderDraft`. `labelContextDraft` chỉ dùng để lookup label, không được dùng để quyết định geometry nào hiện trên map.
+
Khi commit:
-- geometry đi từ `draft`
+- geometry đi từ `mainDraft`
- entity/wiki/link đi từ snapshot collections
- `buildEditorSnapshot()` quyết định operation nào là `reference`, `binding`, `update`, `delete`
@@ -150,9 +161,10 @@ Nghĩa là:
Một số nguyên tắc nên giữ:
-- dùng `draftRef`/refs trong map engines để tránh rebind handler vô ích
+- dùng `renderDraftRef`/refs trong map engines để tránh rebind handler vô ích
- giữ component panel càng dumb càng tốt, logic patch state đặt ở page/hooks
- khi cần undo cho entity/wiki/link, đi qua `editor.setSnapshot*()` để undo stack biết
+- khi cần undo cho replay script, đi qua `editor.mutateActiveReplay()` hoặc replay collection helper hiện có
- hạn chế thêm `JSON.stringify` compare ở chỗ nóng nếu chưa đo hiệu năng
## 12. Chỗ dễ gây hiểu nhầm khi debug
@@ -173,7 +185,7 @@ Không phải lúc nào cũng là bug render layer.
### Selection mất
-Khi timeline filter làm geometry đang chọn không còn visible, page sẽ tự cắt `selectedFeatureIds`.
+Selection hiện bám theo `editor.draft`, không theo `mapRenderDraft`. Vì vậy geometry đang chọn có thể bị timeline filter ẩn khỏi map nhưng panel metadata vẫn đọc được draft gốc.
## 13. Nên test gì sau khi sửa
diff --git a/src/uhm/doc/editor_data_roles.md b/src/uhm/doc/editor_data_roles.md
new file mode 100644
index 0000000..b0e868c
--- /dev/null
+++ b/src/uhm/doc/editor_data_roles.md
@@ -0,0 +1,114 @@
+# UHM Editor - vai trò dữ liệu dễ nhầm
+
+Tài liệu này là glossary ngắn để người sửa code và AI không nhầm các `FeatureCollection`/snapshot gần tên nhau trong editor.
+
+## Luật đọc nhanh
+
+- `mainDraft` là dữ liệu geometry chính để edit và commit.
+- `mapRenderDraft` là dữ liệu đã lọc để render map.
+- `labelContextDraft` chỉ để lookup label, không quyết định render.
+- `baselineFeatureCollection` chỉ để seed/reset session hiện tại.
+- `baselineSnapshot` là snapshot gốc để so dirty và build commit delta.
+- Các collection `snapshot*` là state hiện tại của snapshot, không phải danh sách delta thô.
+
+## Geometry draft
+
+### `baselineFeatureCollection`
+
+FeatureCollection gốc của phiên editor hiện tại. Nó được tạo từ `baselineSnapshot.editor_feature_collection` khi mở project/restore commit, hoặc từ `EMPTY_FEATURE_COLLECTION` khi project chưa có commit.
+
+Khi field này đổi, `useEditorState()` reset `mainDraft`, rebuild `initialMapRef`, và clear undo stack.
+
+### `mainDraft`
+
+Working copy geometry chính. Đây là nguồn commit cho geometry và là nơi các thao tác create/update/delete/properties ghi vào.
+
+Không dùng `mapRenderDraft` để commit vì `mapRenderDraft` có thể thiếu geometry do timeline/replay/preview filter.
+
+### `editor.draft`
+
+Draft active theo mode:
+
+- mode thường: `editor.draft === mainDraft`
+- mode `replay`: `editor.draft === replayDraft`
+
+Panel metadata và selection dùng `editor.draft` để vẫn đọc được geometry ngay cả khi map filter đang ẩn geometry đó.
+
+### `replayDraft`
+
+FeatureCollection local hydrate từ `mainDraft` theo `activeReplayDraft.target_geometry_ids`. Nó chỉ phục vụ replay edit mode, không thay thế `mainDraft`.
+
+### `mapRenderDraft`
+
+FeatureCollection do page tạo ra để truyền vào `Map` prop `renderDraft`.
+
+Nguồn có thể là:
+
+- `editor.mainDraft` ở mode thường
+- `editor.replayDraft` ở replay edit mode
+- `previewSession.draft` đã áp hidden ids ở replay preview mode
+
+Sau đó page có thể áp timeline filter. Đây là nguồn duy nhất quyết định geometry nào xuất hiện trên map.
+
+### `renderDraft`
+
+Tên prop trong `Map.tsx`/`useMapSync.ts`. Đây là `mapRenderDraft` sau khi truyền xuống component map.
+
+### `renderDraftRef`
+
+Ref của `renderDraft` trong map interaction. Ref này dùng cho hover/select/edit trên các geometry đang render/interact. Không nhầm với `draftRef` nội bộ trong `useEditorState()`.
+
+## Label context
+
+### `labelContextBaseDraft`
+
+FeatureCollection gốc để build label context. Nó có thể là draft rộng hơn `mapRenderDraft` để label vẫn resolve được entity/geometry liên quan.
+
+### `mapLabelContextDraft`
+
+FeatureCollection đã enrich label/entity name từ `labelContextBaseDraft`.
+
+Rule quan trọng: `mapLabelContextDraft` chỉ dùng cho label lookup. Nó có thể chứa geometry bị timeline filter ẩn, nên không được dùng để quyết định render source hoặc geometry visibility.
+
+## Snapshot state
+
+### `baselineSnapshot`
+
+Snapshot gốc của session hiện tại. Dùng để so dirty và để `buildEditorSnapshot()` biết row nào là reference/binding/update/delete.
+
+### `snapshotEntityRows`
+
+Các entity row của snapshot hiện tại. Đây là rows cho payload `entities[]`, không phải entity catalog toàn hệ thống.
+
+### `snapshotWikis`
+
+Các wiki row của snapshot hiện tại. Đây là source truth cho wiki trong commit.
+
+### `snapshotEntityWikiLinks`
+
+Các link entity-wiki hiện tại của snapshot. Snapshot builder sẽ tự sinh operation phù hợp so với `baselineSnapshot.entity_wiki`.
+
+## Binding và visibility
+
+### `geometry_entity[]`
+
+Join table persist quan hệ geometry-entity trong snapshot commit. `feature.properties.entity_ids` chỉ là field denormalized cho UI.
+
+### `binding`
+
+Field geometry-geometry binding trên feature. Binding này không tính là entity binding; geometry không có `entity_ids/entity_id` hợp lệ vẫn là orphan.
+
+### `geometryVisibility`
+
+Map local visibility override. Key có thể là geometry id hoặc semantic geo type key. Đây là UI-only, không đi snapshot.
+
+### `applyGeometryBindingFilter`
+
+Filter map theo selection/binding. Chỉ ảnh hưởng render trên map, không đổi draft và không đi snapshot.
+
+## Guard rails
+
+- Render path: `mapRenderDraft -> Map.renderDraft -> useMapSync(renderDraft) -> MapLibre sources`.
+- Label path: `labelContextBaseDraft -> mapLabelContextDraft -> useMapSync(labelContextDraft)`.
+- Commit path: `mainDraft + snapshotEntityRows + snapshotWikis + snapshotEntityWikiLinks + effectiveReplays -> buildEditorSnapshot()`.
+- Orphan validation vẫn chạy trên `mainDraft`, không phụ thuộc map filter.
diff --git a/src/uhm/doc/editor_features.md b/src/uhm/doc/editor_features.md
index 1f341b1..a141a63 100644
--- a/src/uhm/doc/editor_features.md
+++ b/src/uhm/doc/editor_features.md
@@ -3,6 +3,13 @@
Tài liệu này mô tả editor đang chạy tại `src/app/editor/[id]/page.tsx` và các panel liên quan trong `src/uhm/components/`.
Mục tiêu của tài liệu là phản ánh đúng implementation hiện tại, không mô tả các tính năng chưa được nối dây.
+Docs liên quan:
+
+- `src/uhm/doc/editor_operations.md`: ma trận thao tác/undo/snapshot.
+- `src/uhm/doc/editor_snapshot_contract.md`: contract commit snapshot.
+- `src/uhm/doc/editor_manual_test_checklist.md`: checklist test tay.
+- `src/uhm/doc/editor_replay_actions.md`: catalog action replay.
+
## 1. Cách mở editor
- `GET /editor/[id]`: mở editor đầy đủ với map, panel trái và panel phải.
@@ -17,7 +24,7 @@ Mục tiêu của tài liệu là phản ánh đúng implementation hiện tại
- `UndoListPanel`
- Khu vực giữa
- `Map`
- - `TimelineBar` khi không ở `replay`
+ - `TimelineBar` khi không ở `replay`; trong `replay_preview` phụ thuộc action `timeline`
- Cột phải (`BackgroundLayersPanel`)
- Search hợp nhất
- Geometry Binding
@@ -40,6 +47,7 @@ Hai cột hai bên đều resize được bằng drag handle.
- `add-path`
- `add-circle`
- `replay`
+- `replay_preview`
Ý nghĩa thực tế:
@@ -49,7 +57,8 @@ Hai cột hai bên đều resize được bằng drag handle.
- `add-line`: vẽ `LineString`.
- `add-path`: vẽ `LineString` có render arrow layer cho route.
- `add-circle`: kéo chuột để tạo polygon hình tròn, có `circle_center` và `circle_radius`.
-- `replay`: hiện là chế độ tập trung vào một geometry và các geometry trong `binding`; chưa có hệ thống script replay UI/map như file schema tham chiếu.
+- `replay`: chế độ tập trung vào một geometry và tập `target_geometry_ids`, có sidebar sửa stage/step/action, preview overlay và undo riêng cho session replay.
+- `replay_preview`: chạy preview từ replay đang edit; action điều khiển camera/timeline/wiki/narrative overlay và hidden geometry ids.
## 4. Công cụ vẽ và phím điều khiển
@@ -161,14 +170,14 @@ Panel phải có `UnifiedSearchBar` với 3 loại search:
- `entity`
- tìm local + backend theo tên/mô tả
- - nút `Add` sẽ thêm entity vào `snapshotEntities` dưới dạng `reference`
+ - nút `Add` sẽ thêm entity vào `snapshotEntityRows` dưới dạng `reference`
- `wiki`
- tìm backend theo title
- nút `Add` sẽ thêm wiki vào `snapshotWikis` dưới dạng `reference`
- `geo`
- tìm geometry theo tên entity
- nút `Import` sẽ import geometry vào draft hiện tại
- - đồng thời thêm entity tương ứng vào `snapshotEntities` nếu chưa có
+ - đồng thời thêm entity tương ứng vào `snapshotEntityRows` nếu chưa có
- import sẽ tự tắt timeline filter để geometry mới import không bị ẩn
## 9. Entity và binding
@@ -200,6 +209,14 @@ Panel `ProjectEntityRefsPanel` là nơi bind/unbind entity theo geometry đang c
- Bind/unbind với geometry khác trong project.
- Có nút focus để zoom vào geometry trong list binding.
- Có toggle `Filter`: map chỉ hiển thị geometry liên quan tới selection nếu filter binding đang bật.
+- Row geometry hiển thị chip trạng thái trong panel:
+ - `no entity` nếu geometry chưa bind entity.
+ - `no time` nếu thiếu cả `time_start` và `time_end`.
+ - `partial time` nếu chỉ có một trong hai mốc thời gian.
+ - `timeline` hoặc `out timeline` khi timeline filter đang bật.
+ - `hidden`, `bound`, `new` theo trạng thái UI tương ứng.
+- ID geometry không render trực tiếp trong row; ID chỉ nằm trong `title` tooltip của row/nút thao tác.
+- Geometry mồ côi không có style riêng trên map. Cảnh báo nằm ở panel và validation commit/submit.
## 10. Wiki và entity-wiki
@@ -247,12 +264,14 @@ Số trong nút `Commit` không chỉ là geometry diff. Nó gồm:
- `+1` nếu danh sách wiki dirty
- `+1` nếu danh sách entity dirty
- `+1` nếu danh sách entity-wiki dirty
+- `+1` nếu replay script dirty
### Commit
`commitSection()`:
-- build snapshot từ `draft` + `snapshotEntities` + `snapshotWikis` + `snapshotEntityWikiLinks`
+- build snapshot từ `mainDraft` + `snapshotEntityRows` + `snapshotWikis` + `snapshotEntityWikiLinks` + `effectiveReplays`
+- chặn commit nếu không có thay đổi, còn orphan geometry, hoặc payload vượt guardrail kích thước
- gửi `snapshot_json` lên API tạo commit
- nếu thành công:
- reset baseline sang snapshot vừa commit
@@ -263,11 +282,13 @@ Số trong nút `Commit` không chỉ là geometry diff. Nó gồm:
- chỉ submit được khi project có `head_commit_id`
- không submit nếu còn thay đổi chưa commit
+- không submit nếu còn orphan geometry
### Restore
`CommitHistoryPanel` có nút `Restore`, nhưng restore hiện là:
+- chỉ chạy khi không còn pending changes
- load snapshot từ commit cũ vào FE
- không đổi head commit trên backend
@@ -293,4 +314,3 @@ Các mục sau không nên xem là tính năng hiện hành của editor:
- import/export wiki JSON chuyên biệt như một workflow riêng
- bộ shortcut toàn cục kiểu `Ctrl+S`, `Ctrl+Z`, `Ctrl+Y`
- workflow duyệt `Approved/Rejected` được render đầy đủ trong editor page
-- hệ thống replay script theo `replays[]` trong schema snapshot
diff --git a/src/uhm/doc/editor_manual_test_checklist.md b/src/uhm/doc/editor_manual_test_checklist.md
new file mode 100644
index 0000000..3d13200
--- /dev/null
+++ b/src/uhm/doc/editor_manual_test_checklist.md
@@ -0,0 +1,131 @@
+# UHM Editor - manual test checklist
+
+Cập nhật: 2026-05-22.
+
+Checklist này dùng sau mỗi lần sửa editor. Không thay thế typecheck/lint, nhưng bắt các lỗi workflow mà static check khó thấy.
+
+## 1. Preflight
+
+- Mở `/editor/[id]` với một project có ít nhất một geometry/entity/wiki.
+- Mở console browser, đảm bảo không có runtime error ngay khi load.
+- Kiểm tra map render đủ geometry, panel trái/phải không overlap.
+- Kiểm tra `UndoListPanel` ban đầu không có action lạ từ lần load.
+
+## 2. Geometry create/edit/delete
+
+| Bước | Thao tác | Kỳ vọng |
+| --- | --- | --- |
+| 1 | Vẽ polygon ở `draw` mode | Geometry mới được select, panel hiện `no entity` và `no time` |
+| 2 | Undo | Polygon biến mất, undo stack giảm |
+| 3 | Tạo point | Point render bằng icon geotype bình thường, không đổi màu riêng vì orphan |
+| 4 | Apply type/time cho point | Panel đổi `no time`/`partial time` đúng theo input |
+| 5 | Sửa vertex/circle nếu có geometry phù hợp | Undo khôi phục geometry cũ |
+| 6 | Xóa một geometry | Geometry biến mất, undo khôi phục đúng vị trí trong list |
+| 7 | Multi-select cùng shape và xóa | Undo khôi phục toàn bộ geometry đã xóa |
+
+## 3. Geometry status panel
+
+- Row không hiển thị ID trực tiếp.
+- Hover row thấy tooltip có `ID: ...`.
+- Geometry không entity hiện chip `no entity`.
+- Geometry thiếu cả `time_start/time_end` hiện `no time`.
+- Geometry thiếu một trong hai field time hiện `partial time`.
+- Bật timeline filter:
+ - Geometry còn visible hiện chip `timeline`.
+ - Geometry bị lọc khỏi draft visible hiện chip `out timeline`.
+- Eye button set `hidden`, map ẩn geometry và panel hiện chip `hidden`.
+- `NewBadge` vẫn hiện cho geometry mới/import chưa persisted.
+
+## 4. Entity và geometry-entity
+
+| Bước | Thao tác | Kỳ vọng |
+| --- | --- | --- |
+| 1 | Search entity và Add vào project | Entity xuất hiện trong panel, undo gỡ entity ref |
+| 2 | Tạo entity local | Entity mới xuất hiện, form reset, undo gỡ entity |
+| 3 | Sửa entity name/time | Undo khôi phục metadata entity |
+| 4 | Bind entity vào selected geometry | Chip `no entity` biến mất, undo trả lại trạng thái cũ |
+| 5 | Unbind entity | Chip `no entity` hiện lại, commit bị chặn nếu geometry còn orphan |
+| 6 | Multi-select khác shape rồi bind entity | UI báo không thể bind nhiều geometry khác loại |
+
+## 5. Geometry-geometry binding
+
+- Chọn một geometry, bind geometry khác trong `GeometryBindingPanel`.
+- Panel hiện chip `bound` cho geometry liên quan.
+- Toggle Filter: map chỉ hiện selection, selected children và parent/root phù hợp.
+- Undo bind/unbind geometry phải khôi phục `properties.binding`.
+- Bind geometry-geometry không làm mất chip `no entity` nếu geometry vẫn chưa bind entity.
+
+## 6. Wiki và entity-wiki
+
+| Bước | Thao tác | Kỳ vọng |
+| --- | --- | --- |
+| 1 | Search wiki và Add | Wiki ref xuất hiện, undo gỡ wiki ref |
+| 2 | Tạo/sửa wiki local | Undo khôi phục danh sách/wiki content |
+| 3 | Bind entity-wiki | Link xuất hiện, undo khôi phục links |
+| 4 | Xóa wiki đang có entity-wiki links | Wiki và links liên quan bị xóa cùng lúc |
+| 5 | Undo xóa wiki | Wiki và entity-wiki links cùng trở lại |
+| 6 | Insert wiki link trong editor | Link nằm trong doc sau khi lưu wiki |
+
+## 7. Replay
+
+- Chọn geometry có entity, bấm replay.
+- Replay mở với MAIN geo và các target ids liên quan binding.
+- Tạo stage, tạo step, đổi duration.
+- Thêm narrative action `set_title` và `set_descriptions`.
+- Thêm map action `set_time_filter`, `show_labels`, `hide_labels`.
+- Thêm geo action `fly_to_geometries`, `hide_geometries`, `show_geometries`.
+- Undo trong replay mode chỉ undo replay session, không undo main geometry.
+- Play preview:
+ - Step selection chạy đúng thứ tự.
+ - Stop/reset khôi phục title/dialog/image/hidden geometry/timeline/map camera cơ bản.
+- Thoát replay rồi vào lại, detail vẫn còn nếu chưa undo.
+
+## 8. Import GEO từ search
+
+- Search GEO theo entity.
+- Import một geometry chưa có trong draft.
+- Kỳ vọng:
+ - Timeline filter tự tắt.
+ - Geometry được select.
+ - Entity ref được thêm nếu chưa có.
+ - Undo gỡ cả geometry và entity ref nếu entity ref được tạo trong cùng action.
+- Import lại cùng GEO:
+ - Không tạo duplicate geometry.
+ - Chỉ select geometry đã có.
+
+## 9. Commit và restore
+
+| Bước | Thao tác | Kỳ vọng |
+| --- | --- | --- |
+| 1 | Commit khi không có thay đổi | Báo không có thay đổi |
+| 2 | Commit khi còn orphan geometry | Bị chặn, select orphan đầu tiên, panel entity báo chưa bind |
+| 3 | Bind entity rồi commit | Commit thành công, undo stack cleared, pending count về 0 |
+| 4 | Kiểm snapshot commit | Có `geometries`, `geometry_entity`, `entities`, `wikis`, `entity_wiki`, `replays` đúng thay đổi |
+| 5 | Restore commit cũ | Draft/snapshot panels reset theo commit |
+
+## 10. Submit
+
+- Khi còn pending changes, submit phải bị chặn và yêu cầu commit trước.
+- Khi còn orphan geometry, submit bị chặn giống commit.
+- Khi đã commit sạch và không orphan, submit tạo submission id/status.
+- Nếu project bị pending submission lock, banner unlock hoạt động và mở lại project.
+
+## 11. UI-only checks
+
+Các thao tác sau không được thêm undo action và không làm tăng pending save count:
+
+- Đổi timeline year/filter.
+- Toggle background layers.
+- Hide/show geometry local.
+- Focus geometry từ panel.
+- Resize panel.
+- Search query.
+- Pick/paste/remove image overlay trace.
+- Replay preview play/stop/reset.
+
+## 12. Final smoke
+
+- `npx tsc --noEmit --pretty false`.
+- Targeted eslint cho file vừa sửa.
+- `git diff --check`.
+- Nếu sửa frontend UI lớn: mở dev server và test ít nhất desktop viewport.
diff --git a/src/uhm/doc/editor_operations.md b/src/uhm/doc/editor_operations.md
new file mode 100644
index 0000000..4aaa338
--- /dev/null
+++ b/src/uhm/doc/editor_operations.md
@@ -0,0 +1,200 @@
+# UHM Editor - ma trận thao tác
+
+Cập nhật: 2026-05-22.
+
+Tài liệu này là checklist thao tác cho editor ở `/editor/[id]`. Mục tiêu là trả lời nhanh 4 câu hỏi khi thêm hoặc audit một tính năng:
+
+- Người dùng thao tác ở đâu?
+- State nào bị đổi?
+- Có cần undo không, undo đang dùng action nào?
+- Commit snapshot có bị ảnh hưởng không?
+
+Nguồn chính:
+
+- `src/app/editor/[id]/page.tsx`
+- `src/app/editor/[id]/featureCommands.ts`
+- `src/uhm/lib/editor/state/useEditorState.ts`
+- `src/uhm/lib/editor/project/useProjectCommands.ts`
+- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
+
+## 1. Quy ước phân loại
+
+### Cần undo
+
+Một thao tác cần undo nếu nó đổi dữ liệu sẽ đi vào commit snapshot hoặc đổi draft geometry chính:
+
+- `mainDraft.features`
+- `snapshotEntityRows`
+- `snapshotWikis`
+- `snapshotEntityWikiLinks`
+- `replays`
+- `activeReplayDraft.detail`
+
+### Không cần undo
+
+Một thao tác không cần undo nếu nó chỉ đổi trạng thái xem/điều hướng tạm thời:
+
+- `mode`
+- selection/focus/hover
+- timeline year/filter UI
+- background layer visibility
+- geometry visibility local
+- image trace overlay
+- resize panel
+- search query/result
+- status message
+
+### Undo action hiện có
+
+| Action | Phạm vi | Ý nghĩa |
+| --- | --- | --- |
+| `create` | main draft | Gỡ geometry vừa tạo |
+| `delete` | main draft | Khôi phục geometry đã xóa, có `index` để trả về vị trí cũ |
+| `update` | main draft | Khôi phục `geometry` trước khi sửa vertex/circle |
+| `properties` | main draft | Khôi phục `feature.properties` trước khi patch |
+| `snapshot_entities` | snapshot | Khôi phục collection entity snapshot |
+| `snapshot_wikis` | snapshot | Khôi phục collection wiki snapshot |
+| `snapshot_entity_wiki` | snapshot | Khôi phục collection entity-wiki snapshot |
+| `replay` | replay | Khôi phục một replay theo geometry id |
+| `replays` | replay collection | Khôi phục toàn bộ `replays[]` |
+| `replay_session` | replay mode | Khôi phục `activeReplayDraft` trong phiên replay |
+| `group` | tổng hợp | Gom nhiều undo action thành một thao tác logic |
+
+## 2. Geometry draft
+
+| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú |
+| --- | --- | --- | --- | --- | --- |
+| Vẽ polygon | `draw` mode, map drawing engine | Thêm feature vào `mainDraft` | `create` | `geometries[]`, `geometry_entity[]` nếu sau đó bind entity | Feature mới mặc định `type: country`, `geometry_preset: polygon`, chưa có entity |
+| Tạo point | `add-point` mode | Thêm feature vào `mainDraft` | `create` | Như trên | Mặc định `type: city`, `geometry_preset: point` |
+| Vẽ line | `add-line` mode | Thêm feature vào `mainDraft` | `create` | Như trên | Mặc định `type: defense_line`, `geometry_preset: line` |
+| Vẽ path/route | `add-path` mode | Thêm feature vào `mainDraft` | `create` | Như trên | Mặc định `type: attack_route`, render thêm arrow layer |
+| Vẽ circle | `add-circle` mode | Thêm polygon có `circle_center`, `circle_radius` | `create` | Như trên | Mặc định `type: war`, `geometry_preset: circle-area` |
+| Import GEO từ search | Search `geo`, nút import | Thêm feature vào `mainDraft`, thêm entity ref nếu thiếu | `group` gồm `snapshot_entities` và `create` khi cả hai đổi | `geometries[]` và entity ref | Tắt timeline filter để GEO vừa import không bị ẩn |
+| Chọn geometry | Click map/panel | `selectedFeatureIds` | Không | Không | Chỉ là UI state |
+| Focus geometry từ panel | `GeometryBindingPanel` row click | Selection, `geometryFocusRequest`, có thể kéo timeline draft year về `time_start` | Không | Không | Không đổi dữ liệu commit |
+| Sửa vertex/circle | Map edit engine trong `select` | `feature.geometry` | `update` | `geometries[]` | Không hoạt động trong replay mode |
+| Sửa type/time metadata | `SelectedGeometryPanel` apply | `feature.properties.type/time_start/time_end/geometry_preset` | `properties` hoặc `group` khi multi-select | `geometries[]` | Validate time parse được và `time_start <= time_end` |
+| Xóa một geometry | Map delete hoặc selected panel | Xóa feature khỏi `mainDraft` | `delete`, có thể group với `replays` | `geometries[]`, `geometry_entity[]` delete delta | Prune replay/target ids liên quan geometry bị xóa |
+| Xóa nhiều geometry | Bulk selected panel/map callback | Xóa nhiều feature | `group` nhiều `delete`, có thể kèm `replays` | Như trên | Undo khôi phục theo index cũ |
+| Ẩn/hiện geometry local | Eye button, map hide callback | `geometryVisibility` | Không | Không | Local UI only, không đi snapshot |
+| Geometry status panel | `GeometryBindingPanel` | Derived từ draft/timeline/visibility | Không | Không | Hiện `no entity`, `no time`, `partial time`, `timeline`, `out timeline`, `hidden`, `bound`, `new`; ID chỉ nằm trong tooltip |
+
+## 3. Geometry binding
+
+| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú |
+| --- | --- | --- | --- | --- | --- |
+| Bind entity vào selected geometry | `ProjectEntityRefsPanel` checkbox | `entity_id`, `entity_ids`, `entity_name`, `entity_names` trên selected features | `properties` hoặc `group` | `geometry_entity[]` | Multi-select chỉ hợp lệ khi cùng shape type |
+| Unbind entity | `ProjectEntityRefsPanel` checkbox | Các field entity trên feature | `properties` hoặc `group` | `geometry_entity[]` delete delta nếu baseline có link | Commit/submit chặn geometry không còn entity |
+| Bind geometry-geometry | `GeometryBindingPanel` lock button | `feature.properties.binding` | `properties` hoặc `group` | `geometries[].binding` | Binding geometry không thay thế entity binding |
+| Unbind geometry-geometry | `GeometryBindingPanel` unlock button | `feature.properties.binding` | `properties` hoặc `group` | `geometries[].binding` | Không ảnh hưởng `geometry_entity[]` |
+| Bind nhiều geometry vào target | Map bind callback | `binding` của target geometry | `properties` | `geometries[].binding` | Tự bỏ target id khỏi source ids |
+| Toggle binding filter | `GeometryBindingPanel` filter checkbox | `geometryBindingFilterEnabled` | Không | Không | Chỉ lọc hiển thị map theo selection/binding |
+
+## 4. Entity snapshot
+
+| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú |
+| --- | --- | --- | --- | --- | --- |
+| Add entity ref từ search | Search `entity`, nút add | `snapshotEntityRows`, `entityCatalog` | `snapshot_entities` nếu collection đổi | `entities[]` với `source: ref`, `operation: reference` | Không gọi API create entity |
+| Tạo entity local | `ProjectEntityRefsPanel` create form | `snapshotEntityRows`, `entityCatalog`, reset form | `snapshot_entities` | `entities[]` với `source: inline`, `operation: create` | Validate name bắt buộc, không trùng tên, time hợp lệ |
+| Sửa entity trong project | Entity row edit | `snapshotEntityRows` | `snapshot_entities` | `entities[]` update/reference theo source | Validate name và time |
+| Copy selected geometry time vào form entity | Entity panel button | Form state | Không | Không | Chỉ tiện ích UI |
+
+## 5. Wiki và entity-wiki
+
+| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú |
+| --- | --- | --- | --- | --- | --- |
+| Add wiki ref từ search | Search `wiki`, nút add | `snapshotWikis`, active wiki request | `snapshot_wikis` nếu collection đổi | `wikis[]` với `source: ref`, `operation: reference` | Không fetch lại toàn bộ project |
+| Tạo/sửa wiki local | `WikiSidebarPanel` | `snapshotWikis` | `snapshot_wikis` | `wikis[]` | `doc` ưu tiên HTML string, plaintext là fallback |
+| Import HTML vào wiki | `WikiSidebarPanel` import | `snapshotWikis` sau khi lưu | `snapshot_wikis` | `wikis[]` | File import không tự commit |
+| Export wiki | `WikiSidebarPanel` export | Không đổi editor state | Không | Không | Tạo file tải xuống phía browser |
+| Xóa wiki khỏi snapshot | `WikiSidebarPanel` remove | `snapshotWikis` và các `snapshotEntityWikiLinks` trỏ tới wiki | `group` gồm `snapshot_wikis` và `snapshot_entity_wiki` | `wikis[]`, `entity_wiki[]` delta | Đây là thao tác kép, phải undo cùng nhau |
+| Bind entity-wiki | `EntityWikiBindingsPanel` | `snapshotEntityWikiLinks` | `snapshot_entity_wiki` | `entity_wiki[]` với `binding` hoặc `reference` theo baseline | Link mới dùng `operation: binding` |
+| Unbind entity-wiki | `EntityWikiBindingsPanel` | `snapshotEntityWikiLinks` | `snapshot_entity_wiki` | `entity_wiki[]` delete delta nếu baseline có link | Runtime chỉ remove row, snapshot builder sinh delta |
+| Chèn wiki link trong editor Quill | Wiki toolbar custom link | `doc` của wiki đang sửa | `snapshot_wikis` khi lưu wiki | `wikis[].doc` | Link có thể là slug local/global hoặc marker `__missing__` |
+
+## 6. Replay
+
+| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú |
+| --- | --- | --- | --- | --- | --- |
+| Vào replay mode | Selected geometry panel, replay button | `mode`, `activeReplayId`, `activeReplayDraft`, `replayDraft` | Không cho việc mở mode | Không trực tiếp | Nếu đổi replay đang mở, session cũ được flush |
+| Tạo seed replay | `switchReplayContext` | `activeReplayDraft` với `geometry_id`, `target_geometry_ids`, `detail` | Không ngay lúc seed | `replays[]` khi mutate/flush | MAIN geo luôn đứng đầu target list |
+| Sửa replay detail | `ReplayTimelineSidebar`, `ReplayEffectsSidebar` | `activeReplayDraft.detail` | `replay_session` | `replays[].detail` qua `effectiveReplays` | Replay mode không mutate geometry |
+| Undo trong replay mode | Undo button khi `mode === replay` | `activeReplayDraft` | Pop `replayUndoStack` | Có nếu session còn dirty | Undo chính và undo replay tách stack |
+| Thoát/chuyển replay | Exit hoặc đổi context | Flush `activeReplayDraft` vào `replays[]` | `replay` nếu flush có đổi | `replays[]` | Commit đọc `effectiveReplays`, nên không cần thoát replay trước commit |
+| Xóa geometry có replay | Delete geometry | `mainDraft`, có thể prune `replays[]` | `group` với `replays` | `geometries[]`, `replays[]` | Target ids bị xóa cũng được prune |
+| Preview replay | Preview overlay | Preview session, hidden ids, preview year | Không | Không | Chỉ là mô phỏng UI/map |
+
+## 7. Timeline, map style và panel status
+
+| Thao tác | State đổi | Undo | Commit | Ghi chú |
+| --- | --- | --- | --- | --- |
+| Đổi timeline year | `timelineDraftYear` | Không | Không | Client-side filter |
+| Bật/tắt timeline filter | `timelineFilterEnabled` | Không | Không | New geometry trong session vẫn visible |
+| Geometry bị timeline lọc | Derived `mapRenderDraft` | Không | Không | Panel hiện `timeline` hoặc `out timeline`; selection/panel metadata vẫn đọc `editor.draft` |
+| Geometry mồ côi | Derived từ `normalizeFeatureEntityIds(feature).length === 0` | Không riêng | Commit/submit bị chặn | Map không đổi màu riêng cho orphan; panel hiện `no entity` |
+| Thiếu time | Derived từ `time_start/time_end` | Không riêng | Vẫn commit được | Panel hiện `no time` hoặc `partial time` |
+| Selected style trên map | Feature-state selected | Không | Không | Vẫn giữ highlight selected màu xanh |
+| Background layer visibility | `backgroundVisibility`, localStorage | Không | Không | UI preference |
+
+## 8. Image overlay trace
+
+| Thao tác | State đổi | Undo | Commit | Ghi chú |
+| --- | --- | --- | --- | --- |
+| Pick image overlay | `imageOverlay`, object URL | Không | Không | Overlay để trace, không vào snapshot |
+| Paste image overlay | `imageOverlay`, object URL | Không | Không | Cần browser clipboard permission |
+| Đổi opacity | `imageOverlay.opacity` | Không | Không | UI only |
+| Dời/scale bằng keyboard | `imageOverlay.coordinates` | Không | Không | UI only |
+| Remove overlay | `imageOverlay = null`, revoke URL | Không | Không | Không ảnh hưởng draft |
+
+## 9. Project lifecycle
+
+| Thao tác | Entry point | State đổi | Undo | Snapshot/API | Validation |
+| --- | --- | --- | --- | --- | --- |
+| Mở project | Project panel/open route | Reset session state, `baselineFeatureCollection`, baseline snapshot | Không | Fetch project/commit snapshot | Nếu có pending changes khi đổi project thì confirm bỏ thay đổi |
+| Tạo project mới | Project panel | Project list, active project, baseline empty | Không | API create project | Title bắt buộc |
+| Commit | `CommitPanel` | Baseline snapshot, `baselineFeatureCollection`, clear undo/changes | Không undo sau commit | `createProjectCommit` với `buildEditorSnapshot` | Chặn nếu không có thay đổi, chặn orphan geometry, guard payload lớn |
+| Submit | Submit modal | Submission status | Không | `submitSection` | Chỉ submit khi không pending save và không orphan geometry |
+| Restore commit | Commit history | Reset draft/snapshot/session theo commit | Không | Fetch/convert commit snapshot | Chặn nếu còn pending changes; không đổi head trên BE |
+| Delete pending submission lock | Banner unlock | `blockedPendingSubmissionId`, mở lại project | Không | `deleteSubmission` | Dùng khi backend báo project đang bị pending submission khóa |
+
+## 10. Undo coverage checklist
+
+Khi thêm một thao tác mới, kiểm theo thứ tự này:
+
+1. Thao tác có đổi `mainDraft`, snapshot collection hoặc replay detail không?
+2. Nếu có, nó phải đi qua một trong các API undoable:
+ - `editor.createFeature`
+ - `editor.createFeatureWithSnapshotEntityRows`
+ - `editor.updateFeature`
+ - `editor.deleteFeature` hoặc `editor.deleteFeatures`
+ - `editor.patchFeatureProperties` hoặc `editor.patchFeaturePropertiesBatch`
+ - `editor.setSnapshotEntityRows`
+ - `editor.setSnapshotWikis`
+ - `editor.setSnapshotEntityWikiLinks`
+ - `editor.setSnapshotWikisAndEntityWikiLinks`
+ - `editor.mutateActiveReplay`
+3. Nếu thao tác đổi nhiều vùng state trong cùng một ý nghĩa người dùng, dùng `group`.
+4. Nếu xóa geometry, kiểm replay target/replay collection có cần prune không.
+5. Nếu xóa wiki, kiểm entity-wiki links trỏ tới wiki đó có cần xóa cùng undo không.
+6. Nếu thao tác có thể tạo geometry không entity, commit/submit guard vẫn phải bắt được.
+7. Nếu thao tác chỉ đổi UI view/filter/focus, ghi rõ là không undo và không snapshot.
+
+## 11. Snapshot checklist
+
+Khi một thao tác cần đi vào commit, kiểm output snapshot:
+
+- Geometry body nằm trong `geometries[]`.
+- Geometry-entity relation nằm trong `geometry_entity[]`, không chỉ trong `feature.properties.entity_ids`.
+- Entity rows nằm trong `entities[]`.
+- Wiki rows nằm trong `wikis[]`.
+- Entity-wiki rows nằm trong `entity_wiki[]`.
+- Replay script nằm trong `replays[]`, không lưu `replayDraft`.
+- Generate-only fields trên feature như `entity_id`, `entity_ids`, `entity_name`, `entity_names`, `entity_label_candidates`, `time_start`, `time_end`, `binding`, `type` được snapshot builder xử lý/loại bỏ đúng chỗ trước API payload.
+
+## 12. Các thao tác cần audit lại nếu editor đổi lớn
+
+- Multi-select khác shape hiện bị chặn ở bind entity/geometry, nhưng selected panel vẫn phải giữ rule này nếu thêm action mới.
+- Timeline filter đang là client-side, nếu sau này fetch theo timeline từ backend thì `timelineStatus` trong panel cần đổi nguồn truth.
+- Image overlay hiện không persist. Nếu cần lưu overlay vào project, phải thêm snapshot schema và undo.
+- Background visibility hiện là localStorage. Nếu cần lưu theo project/user, phải tách khỏi nhóm UI-only.
+- Replay mode hiện không mutate geometry. Nếu cho sửa geometry trong replay, phải thiết kế lại undo và commit boundary.
diff --git a/src/uhm/doc/editor_replay_actions.md b/src/uhm/doc/editor_replay_actions.md
new file mode 100644
index 0000000..9db3d14
--- /dev/null
+++ b/src/uhm/doc/editor_replay_actions.md
@@ -0,0 +1,194 @@
+# UHM Editor - replay actions catalog
+
+Cập nhật: 2026-05-22.
+
+Tài liệu này mô tả action catalog của replay editor/preview hiện tại. Shape chuẩn nằm ở `src/uhm/types/projects.ts`; dispatcher runtime nằm ở `src/uhm/lib/replay/replayDispatcher.ts`.
+
+## 1. Replay shape
+
+```ts
+type BattleReplay = {
+ id: string;
+ geometry_id: string;
+ target_geometry_ids: string[];
+ detail: ReplayStage[];
+};
+
+type ReplayStage = {
+ id: number;
+ title?: string;
+ detail_time_start: string;
+ detail_time_stop: string;
+ steps: ReplayStep[];
+};
+
+type ReplayStep = {
+ duration: number;
+ use_UI_function: ReplayAction
[];
+ use_map_function: ReplayAction[];
+ use_geo_function: ReplayAction[];
+ use_narrow_function: ReplayAction[];
+};
+
+type ReplayAction = {
+ function_name: T;
+ params: unknown[];
+};
+```
+
+Ghi chú:
+
+- `use_narrow_function` là tên field hiện tại cho nhóm narrative.
+- `params` là tuple positional, không phải object schema.
+- `target_geometry_ids` là source truth cho replay draft; không persist `replayDraft`.
+- `detail_time_start/detail_time_stop` là string theo form replay hiện tại, không phải `time_start/time_end` số của geometry.
+
+## 2. Runtime execution order
+
+Preview flatten replay thành danh sách step theo thứ tự stage/step.
+
+Trong mỗi step, dispatcher chạy các group action từ step hiện tại. Duration của step quyết định thời gian chờ trước step tiếp theo. Preview state có thể đổi:
+
+- map camera/labels
+- timeline visible/filter/year
+- hidden geometry ids
+- title/descriptions/subtitle/dialog/image/toast
+- wiki sidebar/open wiki
+- playback speed
+
+Stop/reset preview khôi phục presentation state và một phần map/timeline baseline.
+
+## 3. UI actions
+
+| Action | Params | Runtime hiện tại |
+| --- | --- | --- |
+| `timeline` | `[visible: boolean]` | Ẩn/hiện TimelineBar trong preview |
+| `layer_panel` | `[visible: boolean]` | No-op hiện tại |
+| `wiki_panel` | `[visible: boolean]` | Mở/đóng wiki sidebar preview |
+| `close_wiki_panel` | `[]` | Đóng wiki sidebar và clear active wiki |
+| `zoom_panel` | `[visible: boolean]` | No-op hiện tại |
+| `wiki` | `[wikiId: string]` | Mở wiki sidebar và active wiki id |
+| `toast` | `[message: string]` | Hiện toast tạm thời |
+| `wiki_header` | `[headerId: string]` | No-op hiện tại |
+| `playback_speed` | `[speed: number]` | Đổi tốc độ phát preview |
+
+Legacy shape vẫn được dispatcher đọc:
+
+```ts
+{ function_name: "UI", params: [optionName, ...payload] }
+```
+
+Shape mới nên dùng trực tiếp:
+
+```ts
+{ function_name: "timeline", params: [true] }
+```
+
+## 4. Map actions
+
+| Action | Params | Runtime hiện tại |
+| --- | --- | --- |
+| `set_camera_view` | `[state]` | `map.easeTo` center/zoom/pitch/bearing/duration |
+| `set_time_filter` | `[year: number]` | Set replay preview timeline year |
+| `enable_timeline_filter` | `[]` | Bật timeline filter |
+| `disable_timeline_filter` | `[]` | Tắt timeline filter |
+| `toggle_labels` | `[visible: boolean]` | Legacy labels toggle |
+| `show_labels` | `[]` | Hiện symbol text labels |
+| `hide_labels` | `[]` | Ẩn symbol text labels |
+| `show_all_geometries` | `[]` | Clear hidden geometry ids |
+| `reset_camera_north` | `[]` | Set bearing về 0 |
+
+`set_camera_view` chấp nhận center dạng `[lng, lat]` hoặc `{ lng, lat }`.
+
+## 5. Geo actions
+
+| Action | Params | Runtime hiện tại |
+| --- | --- | --- |
+| `fly_to_geometry` | `[geometryId]` | Legacy: fly tới một geometry |
+| `fly_to_geometries` | `[geometryIds, duration?]` | Fit/fly tới nhiều geometry |
+| `set_geometry_visibility` | `[geometryIds, visible]` | Legacy: show/hide theo boolean |
+| `show_geometries` | `[geometryIds]` | Bỏ ids khỏi hidden set |
+| `hide_geometries` | `[geometryIds]` | Thêm ids vào hidden set |
+| `fit_to_geometries` | `[geometryIds, duration?]` | Legacy: dùng fly/fit tới geometry |
+| `orbit_camera_around_geometry` | `[geometryId, zoom?, pitch?, turns?, duration?]` | Ease camera quanh bbox geometry |
+| `pulse_geometry` | `[geometryId, color?, repeat?, duration?]` | No-op trong dispatcher hiện tại |
+| `animate_dashed_border` | `[geometryId, color?, width?, speed?, duration?]` | No-op trong dispatcher hiện tại |
+| `set_geometry_style` | `[geometryIds, fill?, opacity?, stroke?, width?]` | No-op trong dispatcher hiện tại |
+| `show_geometry_label` | `[geometryId, text?, color?, size?]` | No-op trong dispatcher hiện tại |
+| `follow_geometry_path` | `[geometryId, duration?]` | Legacy: fly theo một path bằng fit/fly |
+| `follow_geometries_path` | `[geometryIds, duration?, zoom?, padding?]` | Hiện dùng fly/fit tới nhiều geometry |
+| `dim_other_geometries` | `[geometryIds]` | Chỉ hiện target ids, ẩn các geometry khác |
+
+Các action visual effect no-op vẫn có trong composer để giữ schema và chuẩn bị cho runtime effect sau này.
+
+## 6. Narrative actions
+
+| Action | Params | Runtime hiện tại |
+| --- | --- | --- |
+| `set_title` | `[title: string]` | Set title overlay |
+| `clear_title` | `[]` | Clear title |
+| `set_descriptions` | `[text: string]` | Set description overlay |
+| `clear_descriptions` | `[]` | Clear descriptions |
+| `show_dialog_box` | `[avatar, text, side, speaker?]` | Hiện dialog, side là `left` hoặc `right` |
+| `clear_dialog_box` | `[]` | Clear dialog |
+| `display_historical_image` | `[url, caption?]` | Hiện image overlay lịch sử |
+| `clear_historical_image` | `[]` | Clear image |
+| `set_step_subtitle` | `[subtitle: string | null]` | Set subtitle |
+| `clear_step_subtitle` | `[]` | Clear subtitle |
+
+## 7. Composer shortcuts hiện có
+
+Map shortcuts:
+
+- `show_labels`
+- `hide_labels`
+- `enable_timeline_filter`
+- `disable_timeline_filter`
+- `set_time_filter`
+- `reset_camera_north`
+- `show_all_geometries`
+
+Geo shortcuts:
+
+- `fly_to_geometries`
+- `follow_geometries_path`
+- `show_geometries`
+- `hide_geometries`
+- `pulse_geometry`
+- `animate_dashed_border`
+- `orbit_camera_around_geometry`
+- `show_geometry_label`
+- `dim_other_geometries`
+- `set_geometry_style`
+
+Narrative composer hiện hỗ trợ đầy đủ các narrative actions ở mục 6.
+
+## 8. Normalization và migration
+
+Khi load snapshot:
+
+- Replay thiếu `geometry_id` có thể fallback từ `id`.
+- `target_geometry_ids` được normalize/dedupe, MAIN geo đứng đầu.
+- Snapshot cũ có `replay_features` được chuyển thành `target_geometry_ids`.
+- UI legacy action `{ function_name: "UI", params: [...] }` được normalize sang option action.
+- Unknown action/function bị bỏ qua trong normalize/dispatcher.
+- Normalizer snapshot hiện giữ các action đang có trong type/UI, gồm `close_wiki_panel`, `show_all_geometries` và các narrative `clear_*`.
+
+## 9. Undo và commit boundary
+
+- Replay mode dùng `replayUndoStack`, tách khỏi main undo.
+- Sửa stage/step/action đi qua `editor.mutateActiveReplay`.
+- Mỗi mutation tạo `replay_session` undo action.
+- Thoát hoặc chuyển replay flush session vào `replays[]`.
+- Commit đọc `editor.effectiveReplays`, nên có thể commit khi vẫn đang ở replay mode.
+- Replay mode hiện không cho create/update/delete geometry.
+
+## 10. Checklist khi thêm replay action
+
+1. Thêm function name vào `src/uhm/types/projects.ts`.
+2. Thêm label/summary trong `ReplayTimelineSidebar`.
+3. Thêm composer hoặc shortcut trong `ReplayEffectsSidebar`.
+4. Thêm runtime trong `replayDispatcher.ts` và action module phù hợp.
+5. Thêm normalize support trong `editorSnapshot.ts`.
+6. Xác định action có cần reset khi stop preview không.
+7. Cập nhật file này và `commit_snapshot.ts`.
diff --git a/src/uhm/doc/editor_snapshot_contract.md b/src/uhm/doc/editor_snapshot_contract.md
new file mode 100644
index 0000000..cc620f7
--- /dev/null
+++ b/src/uhm/doc/editor_snapshot_contract.md
@@ -0,0 +1,244 @@
+# UHM Editor - snapshot contract
+
+Cập nhật: 2026-05-22.
+
+Tài liệu này mô tả ranh giới dữ liệu giữa editor runtime và commit payload. Nếu `editor_operations.md` trả lời "thao tác nào đổi gì", file này trả lời "commit gửi shape nào và vì sao".
+
+Nguồn chính:
+
+- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
+- `src/uhm/doc/commit_snapshot.ts`
+- `src/uhm/types/projects.ts`
+- `src/uhm/types/geo.ts`
+
+## 1. Luồng build commit
+
+Luồng hiện tại:
+
+1. `commitSection()` kiểm tra project đang mở, pending changes và orphan geometry.
+2. `editor.buildPayload()` lấy geometry diff để xác định operation.
+3. `buildEditorSnapshot()` nhận `mainDraft`, snapshot collections, `effectiveReplays`, `previousSnapshot`.
+4. Commit API nhận snapshot đã qua `toApiEditorSnapshot()`.
+5. Sau commit thành công, FE chuyển snapshot mới về session shape bằng `toEditorSessionSnapshot()` và reset baseline.
+
+Payload API:
+
+```ts
+{
+ snapshot_json: EditorSnapshot;
+ edit_summary: string;
+}
+```
+
+`toApiEditorSnapshot()` hiện normalize thêm:
+
+- `time_start/time_end`: ép về `number|null` nếu field tồn tại ở feature/entity/geometry.
+- `geometries[].type`: đổi type key FE sang backend type code string hoặc `null`.
+- `replays[]`: normalize `id`, `geometry_id`, `target_geometry_ids`, `detail`.
+
+## 2. Root snapshot shape
+
+| Field | Nguồn runtime | Ý nghĩa |
+| --- | --- | --- |
+| `editor_feature_collection` | Clone từ `mainDraft` đã bỏ field generate-only | FeatureCollection runtime phục vụ load lại editor |
+| `entities` | `snapshotEntityRows` + entity ids phát hiện từ geometry | Entity rows inline/ref |
+| `geometries` | `mainDraft.features` + deleted ids từ diff | Geometry rows có operation |
+| `geometry_entity` | `feature.properties.entity_ids/entity_id` so với baseline | Join table geometry-entity |
+| `wikis` | `snapshotWikis` so với baseline | Wiki rows inline/ref/delete |
+| `entity_wiki` | `snapshotEntityWikiLinks` so với baseline | Join table entity-wiki |
+| `replays` | `editor.effectiveReplays` | Script replay, không chứa `replayDraft` |
+
+Root fields optional ở type vì FE còn phải đọc snapshot cũ/partial, nhưng commit mới nên sinh đủ các collection có liên quan.
+
+## 3. Geometry contract
+
+### `geometries[]`
+
+Mỗi feature trong `mainDraft.features` sinh một row:
+
+| Field | Rule |
+| --- | --- |
+| `id` | `String(feature.properties.id)` |
+| `source` | Luôn `"inline"` cho geometry đang tồn tại trong draft |
+| `operation` | `"create"`, `"update"` hoặc `"reference"` theo baseline/diff |
+| `type` | FE type key trước `toApiEditorSnapshot()`, backend code string sau normalize API |
+| `draw_geometry` | `feature.geometry` |
+| `binding` | `normalizeFeatureBindingIds(feature)` |
+| `time_start` / `time_end` | `feature.properties.time_start/time_end ?? null` |
+| `bbox` | BBox tính từ geometry, hoặc `null` |
+
+Geometry đã bị xóa sinh row:
+
+```ts
+{
+ id,
+ source: "ref",
+ operation: "delete"
+}
+```
+
+### Operation rule
+
+`operation` của geometry đang tồn tại được tính theo thứ tự:
+
+- Nếu snapshot trước đã đánh dấu row này `create`, giữ `create`.
+- Nếu không có previous feature và đang có previous snapshot hoặc feature chưa persisted, là `create`.
+- Nếu id nằm trong geometry changes hoặc feature khác previous snapshot, là `update`.
+- Còn lại là `reference`.
+
+## 4. FeatureCollection runtime contract
+
+`editor_feature_collection` giữ geometry để load lại editor, nhưng trước khi đưa vào snapshot FE xóa các field generate-only khỏi `feature.properties`:
+
+- `type`
+- `time_start`
+- `time_end`
+- `binding`
+- `entity_id`
+- `entity_ids`
+- `entity_name`
+- `entity_names`
+- `entity_label_candidates`
+- `entity_type_id`
+
+Các field này được lưu ở collection chuẩn hơn:
+
+- `type/time/binding` nằm ở `geometries[]`.
+- entity relation nằm ở `geometry_entity[]`.
+- entity label/name được hydrate lại từ `entities[]` và join table khi load.
+
+## 5. Geometry-entity contract
+
+Join table chính là `geometry_entity[]`, không phải field denormalized trên feature.
+
+Runtime source:
+
+- `normalizeFeatureEntityIds(feature)`
+- Ưu tiên `entity_ids[]` hợp lệ.
+- Fallback `entity_id` nếu `entity_ids` rỗng.
+
+Build rule:
+
+- Link hiện có trong baseline và vẫn còn trong draft: `operation: "reference"`.
+- Link mới trong draft: `operation: "binding"`.
+- Link có trong baseline nhưng không còn trong draft: `operation: "delete"`.
+
+Rows được dedupe/sort theo `geometry_id`, rồi `entity_id`.
+
+Commit/submit hiện chặn nếu có geometry không có entity ids hợp lệ. Geometry-geometry `binding` không được tính là đã bind entity.
+
+## 6. Entity contract
+
+`entities[]` được build từ:
+
+- `snapshotEntityRows` hiện tại.
+- Entity ids xuất hiện trong `geometry_entity[]` nhưng chưa có row entity, được bổ sung row ref tối thiểu.
+
+Row tối thiểu:
+
+```ts
+{
+ id: string;
+ source: "inline" | "ref";
+ operation?: "create" | "update" | "delete" | "reference";
+ name?: string;
+ description?: string | null;
+ time_start?: number;
+ time_end?: number;
+}
+```
+
+Quy ước:
+
+- Entity backend/search thêm vào snapshot dùng `source: "ref"`, `operation: "reference"`.
+- Entity tạo local dùng `source: "inline"`, `operation: "create"`.
+- Sửa entity inline có thể giữ `create` nếu chưa commit hoặc thành `update`.
+
+## 7. Wiki contract
+
+`wikis[]` đến từ `snapshotWikis` so với baseline.
+
+Row chính:
+
+```ts
+{
+ id: string;
+ source: "inline" | "ref";
+ operation?: "create" | "update" | "delete" | "reference";
+ title: string;
+ slug?: string | null;
+ doc: string | null;
+}
+```
+
+Rule xóa:
+
+- Nếu wiki có trong baseline nhưng không còn trong `snapshotWikis`, snapshot builder thêm row `operation: "delete"`.
+- Khi UI xóa wiki, FE cũng xóa các `snapshotEntityWikiLinks` trỏ tới wiki đó trong cùng undo group.
+
+`doc` hiện ưu tiên HTML string. Plaintext là fallback cho dữ liệu cũ.
+
+## 8. Entity-wiki contract
+
+Runtime source là `snapshotEntityWikiLinks`.
+
+Build rule tương tự geometry-entity:
+
+- Link có trong baseline và vẫn còn: `reference`.
+- Link mới: `binding`.
+- Link bị remove so với baseline: `delete`.
+
+Rows được dedupe/sort theo `entity_id`, rồi `wiki_id`.
+
+## 9. Replay contract
+
+Commit gửi `replays[]` từ `editor.effectiveReplays`.
+
+Canonical shape:
+
+```ts
+{
+ id: string;
+ geometry_id: string;
+ target_geometry_ids: string[];
+ detail: ReplayStage[];
+}
+```
+
+Rule:
+
+- `id` hiện bằng `geometry_id`.
+- `target_geometry_ids` được normalize, MAIN geo đứng đầu.
+- `detail` là danh sách stage/step/action.
+- Không gửi `replayDraft` hoặc `replay_features`.
+
+Snapshot cũ có `replay_features` được FE migrate sang `target_geometry_ids` khi load.
+
+## 10. Validation trước commit/submit
+
+FE chặn commit nếu:
+
+- Chưa mở project.
+- Không có pending changes.
+- Có orphan geometry.
+- Payload JSON vượt guardrail kích thước hiện tại khoảng 3.5MB.
+
+FE chặn submit nếu:
+
+- Project chưa có head commit.
+- Còn pending changes chưa commit.
+- Có orphan geometry.
+
+Missing/partial time hiện chỉ là trạng thái panel, không chặn commit.
+
+## 11. Checklist khi đổi snapshot
+
+Khi thêm field/collection mới:
+
+1. Cập nhật type runtime trong `src/uhm/types`.
+2. Cập nhật `src/uhm/doc/commit_snapshot.ts`.
+3. Cập nhật `buildEditorSnapshot()` và `toEditorSessionSnapshot()` nếu field cần round-trip.
+4. Cập nhật `toApiEditorSnapshot()` nếu backend cần shape khác runtime.
+5. Cập nhật undo nếu thao tác chỉnh field đó là user-facing persistent action.
+6. Cập nhật dirty detection/pending save count nếu collection mới độc lập với geometry.
+7. Cập nhật `editor_operations.md` và manual checklist.
diff --git a/src/uhm/doc/editor_state_replay.md b/src/uhm/doc/editor_state_replay.md
index 5a02742..b15acca 100644
--- a/src/uhm/doc/editor_state_replay.md
+++ b/src/uhm/doc/editor_state_replay.md
@@ -8,6 +8,7 @@ Nguồn thật:
- `src/uhm/lib/editor/state/useEditorState.ts`
- `src/uhm/lib/editor/project/useProjectCommands.ts`
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
+- `src/uhm/doc/editor_replay_actions.md`
## 1. Kết luận ngắn
@@ -15,7 +16,7 @@ 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`
+ - chỉ chứa `id`, `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
@@ -125,6 +126,10 @@ Nên khi `mode === "replay"`:
- `editor.draftRef` trỏ vào `replayDraftRef`
- map chỉ render tập geo đang nằm trong `target_geometry_ids`
+`editor.draftRef` ở đây là ref nội bộ của editor state; map interaction dùng tên `renderDraftRef` để tránh nhầm với draft commit chính.
+
+Khi `mode === "replay_preview"`, page dùng `previewSession.draft` và replay preview state để tạo `mapRenderDraft` rồi render/ẩn geometry. Mode này không mutate `replayDraft` hoặc `mainDraft`.
+
## 7. Replay mode còn sửa geometry không
Không.
@@ -132,7 +137,7 @@ Không.
Hiện tại state layer đã chặn toàn bộ nhánh mutate geometry trong replay mode:
- `createFeature`
-- `createFeatureWithSnapshotEntities`
+- `createFeatureWithSnapshotEntityRows`
- `patchFeatureProperties`
- `patchFeaturePropertiesBatch`
- `updateFeature`
@@ -161,6 +166,8 @@ Undo replay vẫn riêng ở:
- `replayUndoStack`
+Danh sách action và tuple `params` nằm ở `editor_replay_actions.md`.
+
## 9. Khi nào replay được flush về `replays[]`
`activeReplayDraft` chỉ là session đang mở.
diff --git a/src/uhm/doc/editor_states.md b/src/uhm/doc/editor_states.md
index 190e4e2..ccad78d 100644
--- a/src/uhm/doc/editor_states.md
+++ b/src/uhm/doc/editor_states.md
@@ -9,7 +9,7 @@ Editor đang tách làm hai khối:
- `useEditorSessionState()`
- state UI, session, form, project, timeline, background, wiki
-- `useEditorState(initialData, snapshotUndo)`
+- `useEditorState(baselineFeatureCollection, snapshotUndo)`
- state draft hình học, diff và undo
Nói ngắn gọn:
@@ -19,26 +19,34 @@ Nói ngắn gọn:
## 2. State geometry trung tâm
-### `initialData`
+### `baselineFeatureCollection`
- Nằm ở `useEditorSessionState()`
-- Là `FeatureCollection` đang được nạp vào editor khi mở project hoặc restore commit
+- Là `FeatureCollection` baseline được nạp vào editor khi mở project hoặc restore commit
- Khi thay đổi, `useEditorState()` sẽ reset toàn bộ draft và baseline tương ứng
-### `draft`
+### `mainDraft`
- Nằm trong `useEditorState()`
-- Là nguồn dữ liệu render trực tiếp cho `Map`
+- Là working copy geometry chính dùng cho edit/commit
- Mọi thao tác create/update/delete geometry đều đi qua đây
+### `editor.draft`
+
+- Là draft đang active theo mode
+- Ở mode thường trỏ tới `mainDraft`
+- Ở mode `replay` trỏ tới `replayDraft`
+- Panel metadata/selection đọc từ đây, không đọc từ `mapRenderDraft`
+
### `draftRef`
-- Bản ref của `draft`
-- Được dùng trong event handlers của map engine để luôn đọc được state mới nhất mà không phải rebind callback liên tục
+- Ref nội bộ tương ứng với draft trong `useEditorState()`
+- Được dùng để luôn đọc được state mới nhất mà không phải rebind callback liên tục
+- Không nhầm với `renderDraftRef` trong `Map.tsx`, vốn là dữ liệu đang render/interact trên map
### `initialMapRef`
-- `Map` tạo từ `initialData`
+- `Map` tạo từ `baselineFeatureCollection`
- Là baseline để tính diff giữa draft hiện tại và dữ liệu gốc của session
### `changes`
@@ -55,7 +63,7 @@ Lưu ý: diff hiện chỉ là cơ chế nhận biết geometry nào đã thay
### `changeCount`
- Số lượng geometry thay đổi hiện tại
-- Được cộng thêm dirty state của wiki/entity/entity-wiki để tạo `pendingSaveCount`
+- Được cộng thêm dirty state của wiki/entity/entity-wiki/replay để tạo `pendingSaveCount`
## 3. Undo state
@@ -70,12 +78,17 @@ Kiểu action hiện có:
- `snapshot_entities`
- `snapshot_wikis`
- `snapshot_entity_wiki`
+- `replay`
+- `replays`
+- `replay_session`
- `group`
Ý nghĩa:
- geometry create/delete/update/properties undo được trực tiếp trên `draft`
- snapshot entity/wiki/link undo được apply qua `snapshotUndo` API truyền vào `useEditorState`
+- `replay`/`replays` undo các thay đổi script replay đã flush vào collection chính
+- `replay_session` undo các thay đổi stage/step/action khi đang ở mode `replay`
- `group` dùng để gom nhiều thay đổi thành một thao tác undo logic
Editor hiện có `undo`, nhưng chưa có redo.
@@ -107,7 +120,23 @@ Editor hiện có `undo`, nhưng chưa có redo.
`geometryMetaForm.binding` hiện chủ yếu là giá trị hiển thị/đồng bộ UI, còn chỉnh sửa binding thật đi qua `GeometryBindingPanel`.
-### 4.3. Project/session task state
+### 4.3. Replay state
+
+Replay state nằm trong `useEditorState()`:
+
+- `replays`
+ - collection script đã flush vào state chính
+- `activeReplayDraft`
+ - `BattleReplay` đang sửa trong mode `replay`
+- `replayDraft`
+ - `FeatureCollection` hydrate từ `mainDraft + activeReplayDraft.target_geometry_ids`
+- `effectiveReplays`
+ - `replays` cộng overlay của `activeReplayDraft` nếu session hiện tại đã đổi nhưng chưa flush
+
+Undo của replay session dùng stack riêng khi `mode === "replay"`.
+`replay_preview` là session preview trong page, dùng `previewSession`/`useReplayPreview()` và không persist.
+
+### 4.4. Project/session task state
`useProjectSessionState()` gom các cờ async vào một state machine nhỏ:
@@ -127,7 +156,7 @@ Ngoài ra còn có:
- `baselineSnapshot`
- `commitTitle`
-### 4.4. Timeline state
+### 4.5. Timeline state
`useTimelineState()` giữ:
@@ -139,7 +168,7 @@ Ngoài ra còn có:
Trong page hiện tại, timeline filter đang dùng `timelineDraftYear`.
Không có fetch dữ liệu project theo `timelineYear`; timeline đang là client-side visibility filter.
-### 4.5. Background/session UI
+### 4.6. Background/session UI
`useBackgroundSessionState()` giữ:
@@ -148,7 +177,7 @@ Không có fetch dữ liệu project theo `timelineYear`; timeline đang là cli
Giá trị thật được load từ `localStorage` key `uhm.backgroundLayerVisibility.v1`.
-### 4.6. Wiki/session state
+### 4.7. Wiki/session state
`useWikiSessionState()` giữ:
@@ -159,11 +188,12 @@ Giá trị thật được load từ `localStorage` key `uhm.backgroundLayerVisi
## 5. Snapshot state
-Editor đang làm việc với 3 snapshot collection chính ngoài geometry:
+Editor đang làm việc với các snapshot collection chính ngoài geometry:
-- `snapshotEntities`
+- `snapshotEntityRows`
- `snapshotWikis`
- `snapshotEntityWikiLinks`
+- `replays` / `effectiveReplays`
Chúng đại diện cho "current session snapshot", không phải danh sách delta thô.
@@ -193,12 +223,27 @@ Nó được cập nhật khi:
## 7. Derived state quan trọng trong page
-### `timelineVisibleDraft`
+### `mapRenderDraft`
-- là `draft` đã qua filter timeline nếu `timelineFilterEnabled = true`
+- là `FeatureCollection` duy nhất trong page quyết định geometry nào được truyền xuống map
+- nguồn có thể là `mainDraft`, `replayDraft`, hoặc preview draft tùy mode
+- đã qua filter timeline nếu `timelineFilterEnabled = true`
+- đã qua replay preview hidden ids nếu đang preview
- geometry mới tạo trong session không bị timeline filter ẩn
-### `snapshotEntitiesVisible`
+### `labelContextBaseDraft` và `mapLabelContextDraft`
+
+- chỉ dùng để enrich/lookup label entity cho map
+- có thể chứa geometry bị `mapRenderDraft` lọc ra
+- không được dùng để quyết định geometry nào render trên map
+
+### `geometryChoices`
+
+- nguồn dữ liệu cho `GeometryBindingPanel`
+- thêm trạng thái derived như orphan entity, time completeness, timeline visibility, hidden/bound/new
+- ID geometry không phải label chính của row, nhưng vẫn nằm trong tooltip/title
+
+### `snapshotEntityRowsVisible`
- loại bỏ các row `delete`
- dedupe theo `id`
@@ -220,6 +265,7 @@ Nó được cập nhật khi:
- `+1` nếu wiki dirty
- `+1` nếu entities dirty
- `+1` nếu entity-wiki dirty
+- `+1` nếu replay dirty
Đây là con số dùng trong UI commit, không phải số record backend chắc chắn sẽ thay đổi.
@@ -228,8 +274,9 @@ Nó được cập nhật khi:
Dirty check của:
- `snapshotWikis`
-- `snapshotEntities`
+- `snapshotEntityRows`
- `snapshotEntityWikiLinks`
+- `editor.effectiveReplays`
đều đang làm bằng cách normalize trước rồi so `JSON.stringify`.
@@ -262,7 +309,7 @@ Xảy ra khi:
Hiệu ứng:
-- `initialData` đổi
+- `baselineFeatureCollection` đổi
- `useEditorState()` reset `draft`
- `undoStack` bị clear
- baseline map được build lại
@@ -278,3 +325,4 @@ Hiệu ứng:
- timeline state có `timelineYear`, nhưng page hiện dùng `timelineDraftYear` cho filtering
- dirty count của commit không tương ứng một-một với số mutation backend
- map selection, binding filter và timeline filter đều là state client-side
+- trạng thái orphan/time/timeline trong `GeometryBindingPanel` là derived từ draft + visibility, không phải field persist riêng
diff --git a/src/uhm/doc/export_json_replay.md b/src/uhm/doc/export_json_replay.md
index 44528d2..d613712 100644
--- a/src/uhm/doc/export_json_replay.md
+++ b/src/uhm/doc/export_json_replay.md
@@ -5,6 +5,8 @@ Tài liệu này mô tả đúng payload mà nút `Export JSON` của replay đa
Nguồn thật:
- `src/uhm/components/editor/ReplayTimelineSidebar.tsx`
+- `src/uhm/types/projects.ts`
+- `src/uhm/doc/editor_replay_actions.md`
## 1. Kết luận ngắn
diff --git a/src/uhm/doc/goong_apis_in_use.md b/src/uhm/doc/goong_apis_in_use.md
index 4a97d59..bfb2305 100644
--- a/src/uhm/doc/goong_apis_in_use.md
+++ b/src/uhm/doc/goong_apis_in_use.md
@@ -4,7 +4,7 @@ Mục tiêu của tài liệu này:
- mô tả **chính xác** frontend hiện tại đang dùng gì từ Goong
- mô tả **backend cần proxy gì** để giấu `api_key`
-- mô tả **response nào phải rewrite**
+- mô tả **response nào phải sanitize/rewrite**
- tránh liệt kê thừa các API Goong mà app hiện tại không đụng tới
Phạm vi kiểm tra:
@@ -22,33 +22,40 @@ Frontend hiện tại **không** `map.setStyle(goongStyleJson)` trực tiếp.
Thay vào đó:
-1. app tự `fetch()` 2 style JSON của Goong
+1. app tự `fetch()` 2 style JSON của Goong qua backend proxy
2. app parse style JSON để lấy:
- `raster source` từ `goong_satellite.json`
- `sources + layers` cần thiết từ `goong_map_web.json`
-3. app `map.addSource(...)` và `map.addLayer(...)` thủ công
-4. từ thời điểm đó, **MapLibre tự request tiếp** các `source.url`
-5. rồi từ các source manifest đó, **MapLibre lại tự request tiếp** các tile URLs nằm trong `tiles[]`
+3. nếu source dùng `url`, app tiếp tục fetch source manifest qua proxy trong `tiles.ts`
+4. app rewrite `tiles[]` về backend proxy rồi `map.addSource(...)` và `map.addLayer(...)` thủ công
+5. từ thời điểm đó, **MapLibre tự request tiếp** tile/font URLs đã là URL proxy
Hệ quả:
- nếu BE chỉ proxy `assets/*.json` thì **chưa đủ**
-- nếu BE chỉ proxy `sources/*.json` mà **không rewrite `tiles[]`** thì **vẫn lộ key ở request tile**
+- proxy phải cover style JSON, source manifest, tile URLs và glyph PBF
+- frontend hiện không nhúng `api_key` trong URL; backend proxy chịu trách nhiệm gọi upstream bằng key server-side nếu upstream yêu cầu
## 2. Luồng request thật hiện tại
-### 2.1. App fetch trực tiếp style JSON
+### 2.1. App fetch style JSON qua proxy
-Frontend gọi trực tiếp:
+Frontend gọi:
-1. `https://tiles.goong.io/assets/goong_satellite.json?api_key=...`
-2. `https://tiles.goong.io/assets/goong_map_web.json?api_key=...`
+1. `${API_BASE_URL}/proxy/tiles.goong.io/assets/goong_satellite.json`
+2. `${API_BASE_URL}/proxy/tiles.goong.io/assets/goong_map_web.json`
+
+Upstream gốc trong code vẫn là:
+
+1. `https://tiles.goong.io/assets/goong_satellite.json`
+2. `https://tiles.goong.io/assets/goong_map_web.json`
Nguồn trong code:
-- `GOONG_SATELLITE_STYLE_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:15)
-- `GOONG_VECTOR_OVERLAY_STYLE_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:19)
-- `loadGoongStyleDocument(...)` ở [tiles.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/tiles.ts:211)
+- `GOONG_SATELLITE_STYLE_UPSTREAM_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:8)
+- `GOONG_VECTOR_OVERLAY_STYLE_UPSTREAM_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:9)
+- `buildGoongProxyUrl(...)` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:29)
+- `loadGoongStyleDocument(...)` ở [tiles.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/tiles.ts:199)
Mục đích:
@@ -63,9 +70,9 @@ Mục đích:
- `Country Labels`
- `Rivers`
-### 2.2. MapLibre fetch source manifests
+### 2.2. Frontend fetch source manifests qua proxy
-Sau khi app clone source spec từ style JSON và `addSource(...)`, MapLibre tự bắn tiếp các request `source.url`.
+Khi style source có field `url`, `tiles.ts` tự fetch source manifest qua proxy trước khi gọi `map.addSource(...)`.
Các source URL đang xuất hiện trong style JSON:
@@ -89,21 +96,24 @@ Các source URL đang xuất hiện trong style JSON:
- `sources/goong.json`
- vector source manifest cho các lớp `riversandlakes`, `vietnam_administrator`
-### 2.3. MapLibre fetch tile URLs nằm trong source manifests
+### 2.3. MapLibre fetch tile URLs đã rewrite
Đây là phần dễ bị bỏ sót nhất.
-Khi MapLibre đã tải `sources/satellite.json`, `sources/base.json`, `sources/goong.json`, nó sẽ tiếp tục request các URL nằm trong field:
+Khi `tiles.ts` đã tải `sources/satellite.json`, `sources/base.json`, `sources/goong.json`, nó rewrite mọi URL trong field:
- `tiles[]`
+về `${API_BASE_URL}/proxy/tiles.goong.io/...`, rồi mới đưa source spec cho MapLibre.
+
Tức là runtime thật của frontend hiện tại là:
-1. fetch style JSON
-2. fetch source manifest
-3. fetch tile URL bên trong source manifest
+1. FE fetch style JSON qua proxy
+2. FE fetch source manifest qua proxy
+3. FE rewrite `tiles[]` về proxy
+4. MapLibre fetch tile URL đã rewrite
-Nếu backend muốn che key hoàn toàn, thì **bước 3 bắt buộc phải được proxy hoặc rewrite về domain backend**.
+Nếu backend muốn che key hoàn toàn, thì backend proxy phải xử lý cả các tile URL này bằng key server-side.
## 3. Những upstream Goong resource đang dùng thật
@@ -130,6 +140,7 @@ Lưu ý:
- tile URL pattern chính xác phải đọc từ source manifest upstream ở runtime
- backend không nên hardcode khi chưa xác minh nội dung `tiles[]`
+- frontend hiện giữ nguyên upstream target path trong proxy URL sau khi strip `api_key`
## 4. Những thứ frontend hiện tại dùng thêm hoặc KHÔNG dùng
@@ -143,7 +154,7 @@ Flow hiện tại **có dùng glyphs của Goong qua proxy**.
Map đang trỏ `glyphs` vào:
-- `/proxy/{encoded-https://tiles.goong.io/fonts/{fontstack}/{range}.pbf}`
+- `${API_BASE_URL}/proxy/tiles.goong.io/fonts/{fontstack}/{range}.pbf`
Nguồn trong code:
@@ -201,7 +212,8 @@ Có 2 cách:
#### Cách A: Transparent proxy
-BE trả về gần như đúng response của Goong, chỉ rewrite URL.
+BE trả về gần như đúng response của Goong, nhưng strip/sanitize mọi `api_key` lồng trong JSON.
+Frontend hiện tự wrap các upstream URL đó bằng `buildGoongProxyUrl(...)`.
Ưu điểm:
@@ -210,7 +222,7 @@ BE trả về gần như đúng response của Goong, chỉ rewrite URL.
Nhược điểm:
-- BE phải rewrite nhiều chỗ
+- BE phải sanitize JSON response để không lộ key trong body response
#### Cách B: Normalize thành API nội bộ
@@ -227,11 +239,13 @@ Nhược điểm:
Với frontend hiện tại, **Cách A** là hợp lý nhất.
+Lưu ý quan trọng: frontend hiện mong nhận `sources.*.url` và `tiles[]` ở dạng upstream URL hoặc relative URL. Không rewrite các URL này thành `/proxy/...` trong JSON response hiện tại, vì FE sẽ tự gọi `buildGoongProxyUrl(...)`; rewrite sẵn sẽ dễ bị double-proxy.
+
## 6. Contract backend được khuyến nghị
### 6.1. Proxy style JSON
-#### `GET /proxy/goong/assets/goong_satellite.json`
+#### `GET /proxy/tiles.goong.io/assets/goong_satellite.json`
Upstream:
@@ -241,15 +255,16 @@ Backend phải:
- fetch upstream bằng key server-side
- parse JSON
-- rewrite `sources.*.url` về domain backend
+- strip `api_key` khỏi `sources.*.url`, `glyphs`, `sprite` nếu các field đó xuất hiện trong body
+- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
- có thể giữ nguyên các field khác
Response:
- `Content-Type: application/json`
-- body: style JSON đã rewrite
+- body: style JSON đã sanitize, chưa rewrite sang `/proxy/...`
-#### `GET /proxy/goong/assets/goong_map_web.json`
+#### `GET /proxy/tiles.goong.io/assets/goong_map_web.json`
Upstream:
@@ -259,17 +274,18 @@ Backend phải:
- fetch upstream bằng key server-side
- parse JSON
-- rewrite `sources.*.url` về domain backend
+- strip `api_key` khỏi `sources.*.url`, `glyphs`, `sprite` nếu các field đó xuất hiện trong body
+- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
- có thể giữ nguyên các field khác
Response:
- `Content-Type: application/json`
-- body: style JSON đã rewrite
+- body: style JSON đã sanitize, chưa rewrite sang `/proxy/...`
### 6.2. Proxy source manifests
-#### `GET /proxy/goong/sources/satellite.json`
+#### `GET /proxy/tiles.goong.io/sources/satellite.json`
Upstream:
@@ -279,7 +295,8 @@ Backend phải:
- fetch upstream
- parse JSON
-- rewrite mọi URL trong `tiles[]` về domain backend
+- strip `api_key` khỏi mọi URL trong `tiles[]`
+- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
- giữ nguyên metadata quan trọng:
- `tileSize`
- `minzoom`
@@ -291,9 +308,9 @@ Backend phải:
Response:
- `Content-Type: application/json`
-- body: source manifest đã rewrite
+- body: source manifest đã sanitize, chưa rewrite sang `/proxy/...`
-#### `GET /proxy/goong/sources/base.json`
+#### `GET /proxy/tiles.goong.io/sources/base.json`
Upstream:
@@ -303,10 +320,11 @@ Backend phải:
- fetch upstream
- parse JSON
-- rewrite mọi URL trong `tiles[]` về domain backend
+- strip `api_key` khỏi mọi URL trong `tiles[]`
+- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
- giữ nguyên metadata tilejson khác
-#### `GET /proxy/goong/sources/goong.json`
+#### `GET /proxy/tiles.goong.io/sources/goong.json`
Upstream:
@@ -316,22 +334,17 @@ Backend phải:
- fetch upstream
- parse JSON
-- rewrite mọi URL trong `tiles[]` về domain backend
+- strip `api_key` khỏi mọi URL trong `tiles[]`
+- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
- giữ nguyên metadata tilejson khác
### 6.3. Proxy tile endpoints
Backend bắt buộc phải có route để trả tile thật.
-Có thể làm generic, ví dụ:
+Frontend hiện build URL proxy generic theo upstream target:
-- `GET /proxy/goong/tiles/*`
-
-hoặc explicit hơn theo source:
-
-- `GET /proxy/goong/tiles/satellite/...`
-- `GET /proxy/goong/tiles/base/...`
-- `GET /proxy/goong/tiles/goong/...`
+- `GET /proxy/tiles.goong.io/...`
Yêu cầu:
@@ -357,8 +370,9 @@ Luồng:
1. FE đọc `goong_satellite.json`
2. FE lấy `sources.satellite`
-3. MapLibre gọi `sources/satellite.json`
-4. MapLibre gọi raster tile URLs trong `tiles[]`
+3. FE gọi `sources/satellite.json` qua proxy trong `tiles.ts`
+4. FE rewrite `tiles[]` về proxy URL
+5. MapLibre gọi raster tile URLs đã rewrite
BE cần cover:
@@ -372,9 +386,10 @@ Luồng:
1. FE đọc `goong_map_web.json`
2. FE lấy selected layers + selected sources
-3. MapLibre gọi `sources/base.json`
-4. MapLibre gọi `sources/goong.json`
-5. MapLibre gọi vector tile URLs của 2 source manifest này
+3. FE gọi `sources/base.json` qua proxy trong `tiles.ts`
+4. FE gọi `sources/goong.json` qua proxy trong `tiles.ts`
+5. FE rewrite `tiles[]` về proxy URL
+6. MapLibre gọi vector tile URLs đã rewrite
BE cần cover:
@@ -386,20 +401,20 @@ BE cần cover:
Nếu chỉ làm đúng những gì frontend hiện tại dùng, checklist tối thiểu là:
-1. proxy `assets/goong_satellite.json`
-2. proxy `assets/goong_map_web.json`
-3. proxy `sources/satellite.json`
-4. proxy `sources/base.json`
-5. proxy `sources/goong.json`
-6. proxy toàn bộ tile URL được khai báo trong `sources/satellite.json`
-7. proxy toàn bộ tile URL được khai báo trong `sources/base.json`
-8. proxy toàn bộ tile URL được khai báo trong `sources/goong.json`
+1. proxy `tiles.goong.io/assets/goong_satellite.json`
+2. proxy `tiles.goong.io/assets/goong_map_web.json`
+3. proxy `tiles.goong.io/sources/satellite.json`
+4. proxy `tiles.goong.io/sources/base.json`
+5. proxy `tiles.goong.io/sources/goong.json`
+6. proxy `tiles.goong.io/fonts/{fontstack}/{range}.pbf`
+7. proxy toàn bộ tile URL được khai báo trong `sources/satellite.json`
+8. proxy toàn bộ tile URL được khai báo trong `sources/base.json`
+9. proxy toàn bộ tile URL được khai báo trong `sources/goong.json`
## 9. Những gì BE chưa cần làm ngay
Cho flow hiện tại, BE **chưa cần**:
-- proxy Goong `glyphs`
- proxy Goong `sprite`
- proxy geocoding / directions / autocomplete
@@ -417,9 +432,10 @@ vì khi đó chúng có thể trở thành dependency bắt buộc.
Nếu muốn làm ít rủi ro nhất:
1. làm proxy `assets/*.json`
-2. rewrite `sources.*.url`
+2. sanitize nested `api_key` trong style JSON
3. làm proxy `sources/*.json`
-4. rewrite `tiles[]`
+4. sanitize nested `api_key` trong source manifests
5. làm proxy generic cho tile
+6. làm proxy Goong fonts/glyphs
-Nếu làm thiếu bước 4 hoặc 5 thì key vẫn có thể lộ ở request tile.
+Nếu sanitize JSON thiếu thì key có thể lộ ngay trong response style/source. Nếu proxy tile/font thiếu thì map background hoặc labels có thể không tải được.
diff --git a/src/uhm/doc/goong_map_web_structure.md b/src/uhm/doc/goong_map_web_structure.md
index f22ff49..2db2326 100644
--- a/src/uhm/doc/goong_map_web_structure.md
+++ b/src/uhm/doc/goong_map_web_structure.md
@@ -122,8 +122,12 @@ Những label dễ gây rối nếu bật nhiều:
## Gợi ý mapping cho UI
-- `Country Borders` -> `boundary-land-type-0` + `boundary-land-type-0-bg`
-- `Province Borders` -> `boundary-land-type-1` + `boundary-land-type-1-bg`
-- `District Borders` -> `boundary-land-type-2` + `boundary-land-type-2-bg`
-- `Country Labels` -> `place-country-*`, `place-city-capital*`, `place-city*`, `place-town*`
-- `Rivers` -> `water`, `water-shadow`, `river-name-*`, `lake-name_*`
+Mapping hiện tại trong `tiles.ts` là heuristic runtime, không hardcode đúng từng id này:
+
+- `Country Borders` -> ưu tiên `boundary-land-type-0`, bỏ `boundary-land-type-0-bg`
+- `Province Borders` -> ưu tiên `boundary-land-type-1`, bỏ `boundary-land-type-1-bg`
+- `District Borders` -> `boundary-land-type-2` và các layer cấp sâu hơn
+- `Country Labels` -> symbol layer có text field và tên/source-layer giống country/admin/place/city/town/capital
+- `Rivers` -> line/fill layer có tên/source-layer giống water/waterway/river/stream/canal/lake/reservoir/sea/ocean
+
+Water label symbol như `river-name-*`/`lake-name_*` chỉ được đưa vào nếu heuristic sau này mở rộng; code hiện tại chủ yếu lấy line/fill water.
diff --git a/src/uhm/doc/goong_proxy_backend_guide.md b/src/uhm/doc/goong_proxy_backend_guide.md
index c26207a..02c4fd6 100644
--- a/src/uhm/doc/goong_proxy_backend_guide.md
+++ b/src/uhm/doc/goong_proxy_backend_guide.md
@@ -4,8 +4,8 @@ Tài liệu này mô tả:
- luồng request thật của frontend hiện tại
- backend cần proxy chỗ nào
-- backend cần rewrite chỗ nào
-- trade-off hiệu suất nếu proxy/rewrite toàn bộ Goong
+- backend cần sanitize/rewrite chỗ nào
+- trade-off hiệu suất nếu proxy toàn bộ Goong
- khuyến nghị triển khai thực dụng cho team BE
Tài liệu liên quan:
@@ -26,21 +26,23 @@ Frontend hiện tại không `setStyle(goongStyle)` trực tiếp cho MapLibre.
Thay vào đó:
-1. FE tự `fetch()` style JSON của Goong
+1. FE gọi style JSON qua `buildGoongProxyUrl(...)`
2. FE parse style JSON
3. FE lấy ra:
- raster source cho satellite
- selected vector sources/layers cho borders, labels, rivers
-4. FE `addSource()` và `addLayer()` thủ công
-5. MapLibre tự request tiếp `source.url`
-6. Từ source manifest, MapLibre tự request tiếp các tile URLs trong `tiles[]`
+4. FE gọi source manifest qua `buildGoongProxyUrl(...)` nếu style source có `url`
+5. FE rewrite `tiles[]` về proxy URL rồi `addSource()` và `addLayer()` thủ công
+6. MapLibre request tile/font URLs đã là URL proxy
Điểm quan trọng:
-- browser có thể không chỉ gọi `assets/*.json`
-- browser sẽ đi sâu thêm ít nhất 2 tầng:
+- browser không được gọi trực tiếp `tiles.goong.io`
+- browser vẫn sẽ đi qua backend proxy ở các tầng:
+ - `assets/*.json`
- `sources/*.json`
- tile URLs trong `tiles[]`
+ - `fonts/{fontstack}/{range}.pbf`
## 2. Luồng request hiện tại
@@ -48,20 +50,29 @@ Thay vào đó:
sequenceDiagram
participant FE as Frontend
participant GL as MapLibre
+ participant BE as Backend Proxy
participant GO as Goong
- FE->>GO: GET assets/goong_satellite.json?api_key=...
- FE->>GO: GET assets/goong_map_web.json?api_key=...
+ FE->>BE: GET /proxy/tiles.goong.io/assets/goong_satellite.json
+ FE->>BE: GET /proxy/tiles.goong.io/assets/goong_map_web.json
+ BE->>GO: fetch upstream style JSON with server-side key
+ GO-->>BE: style JSON
+ BE-->>FE: sanitized style JSON
- FE->>GL: addSource(raster/vector) + addLayer(...)
+ FE->>BE: GET /proxy/tiles.goong.io/sources/satellite.json
+ FE->>BE: GET /proxy/tiles.goong.io/sources/base.json
+ FE->>BE: GET /proxy/tiles.goong.io/sources/goong.json
+ BE->>GO: fetch upstream source manifests with server-side key
+ GO-->>BE: source manifests
+ BE-->>FE: sanitized source manifests
- GL->>GO: GET sources/satellite.json?api_key=...
- GL->>GO: GET sources/base.json?api_key=...
- GL->>GO: GET sources/goong.json?api_key=...
+ FE->>GL: addSource(proxy tile URLs) + addLayer(...)
- GL->>GO: GET raster tile URLs from satellite tiles[]
- GL->>GO: GET vector tile URLs from base tiles[]
- GL->>GO: GET vector tile URLs from goong tiles[]
+ GL->>BE: GET /proxy/tiles.goong.io/...tile...
+ GL->>BE: GET /proxy/tiles.goong.io/fonts/{fontstack}/{range}.pbf
+ BE->>GO: fetch upstream tile/font bytes
+ GO-->>BE: bytes
+ BE-->>GL: bytes
```
## 3. Mục tiêu của backend proxy
@@ -75,35 +86,42 @@ thì backend phải đảm bảo:
1. browser chỉ gọi domain BE
2. BE gọi Goong bằng key server-side
-3. mọi URL Goong lồng bên trong JSON đều được rewrite về domain BE
+3. mọi URL Goong lồng bên trong JSON đều được sanitize để không chứa `api_key`
+4. frontend nhận URL upstream/relative sạch để tự wrap qua `buildGoongProxyUrl(...)`
Nếu thiếu bước 3:
-- `api_key` vẫn có thể lộ ở request tầng sau
+- `api_key` có thể lộ ngay trong response JSON ở browser devtools
-## 4. Những gì cần rewrite
+## 4. Những gì cần sanitize/rewrite
### 4.1. Style JSON
-Trong `goong_satellite.json` và `goong_map_web.json`, BE cần rewrite:
+Trong `goong_satellite.json` và `goong_map_web.json`, BE cần sanitize:
- `sources.*.url`
+- `glyphs`
+- `sprite`
Ví dụ:
- từ `https://tiles.goong.io/sources/base.json?api_key=...`
-- thành `/proxy/goong/sources/base.json`
+- thành `https://tiles.goong.io/sources/base.json`
+
+Không rewrite sẵn thành `/proxy/...` với frontend hiện tại, vì `tiles.ts` đang tự gọi `buildGoongProxyUrl(...)`.
### 4.2. Source manifests
-Trong `sources/satellite.json`, `sources/base.json`, `sources/goong.json`, BE cần rewrite:
+Trong `sources/satellite.json`, `sources/base.json`, `sources/goong.json`, BE cần sanitize:
- mọi phần tử trong `tiles[]`
Ví dụ:
- từ `https://.../{z}/{x}/{y}...api_key=...`
-- thành `/proxy/goong/tiles/...`
+- thành `https://.../{z}/{x}/{y}...`
+
+Sau đó frontend rewrite URL sạch này về `${API_BASE_URL}/proxy/tiles.goong.io/...`.
### 4.3. Những field còn phải để ý cho flow hiện tại
@@ -123,27 +141,28 @@ Nếu sau này FE chuyển sang `map.setStyle(goongStyleJson)` trực tiếp th
### 5.1. Style endpoints
-- `GET /proxy/goong/assets/goong_satellite.json`
-- `GET /proxy/goong/assets/goong_map_web.json`
+- `GET /proxy/tiles.goong.io/assets/goong_satellite.json`
+- `GET /proxy/tiles.goong.io/assets/goong_map_web.json`
Nhiệm vụ:
- gọi upstream Goong bằng key server-side
- parse JSON
-- rewrite `sources.*.url`
-- trả JSON đã rewrite
+- strip `api_key` khỏi nested URL
+- trả JSON đã sanitize, chưa rewrite nested URL sang `/proxy/...`
### 5.2. Source endpoints
-- `GET /proxy/goong/sources/satellite.json`
-- `GET /proxy/goong/sources/base.json`
-- `GET /proxy/goong/sources/goong.json`
+- `GET /proxy/tiles.goong.io/sources/satellite.json`
+- `GET /proxy/tiles.goong.io/sources/base.json`
+- `GET /proxy/tiles.goong.io/sources/goong.json`
Nhiệm vụ:
- gọi upstream Goong bằng key server-side
- parse JSON
-- rewrite `tiles[]`
+- strip `api_key` khỏi `tiles[]`
+- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
- giữ nguyên:
- `bounds`
- `minzoom`
@@ -154,9 +173,9 @@ Nhiệm vụ:
### 5.3. Tile endpoint
-Gợi ý route generic:
+Route generic frontend hiện build:
-- `GET /proxy/goong/tiles/*`
+- `GET /proxy/tiles.goong.io/...`
Nhiệm vụ:
@@ -180,24 +199,25 @@ sequenceDiagram
participant BE as Backend Proxy
participant GO as Goong
- FE->>BE: GET /proxy/goong/assets/goong_satellite.json
- FE->>BE: GET /proxy/goong/assets/goong_map_web.json
+ FE->>BE: GET /proxy/tiles.goong.io/assets/goong_satellite.json
+ FE->>BE: GET /proxy/tiles.goong.io/assets/goong_map_web.json
BE->>GO: fetch upstream style JSON
GO-->>BE: style JSON
- BE-->>FE: rewritten style JSON
+ BE-->>FE: sanitized style JSON
- FE->>GL: addSource(raster/vector) + addLayer(...)
-
- GL->>BE: GET /proxy/goong/sources/satellite.json
- GL->>BE: GET /proxy/goong/sources/base.json
- GL->>BE: GET /proxy/goong/sources/goong.json
+ FE->>BE: GET /proxy/tiles.goong.io/sources/satellite.json
+ FE->>BE: GET /proxy/tiles.goong.io/sources/base.json
+ FE->>BE: GET /proxy/tiles.goong.io/sources/goong.json
BE->>GO: fetch upstream source manifests
GO-->>BE: source manifests
- BE-->>GL: rewritten source manifests
+ BE-->>FE: sanitized source manifests
- GL->>BE: GET /proxy/goong/tiles/...
+ FE->>GL: addSource(proxy tile URLs) + addLayer(...)
+
+ GL->>BE: GET /proxy/tiles.goong.io/...tile...
+ GL->>BE: GET /proxy/tiles.goong.io/fonts/...
BE->>GO: fetch upstream tile
GO-->>BE: tile bytes
BE-->>GL: tile bytes
@@ -205,11 +225,11 @@ sequenceDiagram
## 7. Trade-off hiệu suất
-### 7.1. Rewrite JSON có chậm không?
+### 7.1. Sanitize JSON có chậm không?
Có overhead, nhưng **rất nhỏ** so với tile traffic.
-JSON cần rewrite hiện tại chỉ gồm:
+JSON cần sanitize hiện tại chỉ gồm:
- 2 style JSON
- 3 source manifests
@@ -218,7 +238,7 @@ Những file này nhỏ, số lượng ít, và có thể cache rất mạnh.
Kết luận:
-- rewrite JSON không phải bottleneck chính
+- sanitize JSON không phải bottleneck chính
### 7.2. Tile proxy mới là chỗ đắt
@@ -235,21 +255,20 @@ Các ảnh hưởng có thể thấy:
- tăng CPU/memory nếu BE buffer response thay vì stream
- tăng load connection pool tới Goong
-### 7.3. Nếu không rewrite tile URL
+### 7.3. Nếu không proxy tile/font URL
-Nếu BE chỉ rewrite style/source JSON nhưng không rewrite `tiles[]`:
+Nếu BE chỉ proxy style/source JSON nhưng thiếu tile/font route:
-- browser vẫn gọi Goong trực tiếp ở bước tile
-- `api_key` vẫn có thể lộ
+- MapLibre request tile/font proxy URL sẽ lỗi
+- hoặc nếu FE bị đổi để dùng URL upstream trực tiếp thì browser sẽ gọi Goong và có thể lộ key
Tức là:
-- hiệu suất tốt hơn
-- nhưng mục tiêu bảo mật key không đạt
+- tile/font route vẫn là phần bắt buộc nếu muốn giữ kiến trúc hiện tại
## 8. Cách giảm thiểu impact hiệu suất
-### 8.1. Cache rewritten JSON ở BE
+### 8.1. Cache sanitized JSON ở BE
Khuyến nghị:
@@ -266,7 +285,7 @@ TTL có thể dài vì:
Tối ưu:
-- chỉ rewrite một lần rồi reuse
+- chỉ sanitize một lần rồi reuse
### 8.2. Stream tile response
@@ -292,18 +311,18 @@ Nếu BE/ngược phía CDN có cache tốt, impact sẽ giảm rất nhiều.
Nếu production có CDN/nginx/edge cache:
- cache mạnh cho:
- - rewritten style JSON
- - rewritten source manifests
+ - sanitized style JSON
+ - sanitized source manifests
- tile responses
-Điều này quan trọng hơn tối ưu code rewrite.
+Điều này quan trọng hơn tối ưu code sanitize.
-### 8.5. Đừng rewrite tile mỗi request theo kiểu string building phức tạp
+### 8.5. Đừng parse manifest ở mỗi tile request
Nên:
-- rewrite `tiles[]` một lần ở source manifest
-- tile route chỉ resolve path đơn giản và forward
+- sanitize source manifest một lần rồi cache
+- tile route chỉ resolve target path đơn giản và forward
Không nên:
@@ -313,18 +332,19 @@ Không nên:
Nếu team BE muốn giải pháp cân bằng giữa bảo mật và hiệu suất:
-### Option A. Full proxy, full rewrite
+### Option A. Full proxy, sanitize JSON
BE cover:
1. style JSON
2. source manifests
3. tiles
+4. fonts/glyphs
Ưu điểm:
- key không lộ ra browser
-- FE không cần biết upstream Goong
+- FE vẫn dùng upstream target path sạch rồi tự wrap proxy URL
Nhược điểm:
@@ -337,7 +357,7 @@ BE cover:
1. style JSON
2. source manifests
-Nhưng không rewrite `tiles[]`
+Nhưng để tile/font đi trực tiếp upstream.
Ưu điểm:
@@ -346,28 +366,27 @@ Nhưng không rewrite `tiles[]`
Nhược điểm:
- key vẫn lộ ở tile request
+- không khớp với code hiện tại nếu `buildGoongProxyUrl(...)` vẫn được dùng cho tile/font
Kết luận:
- nếu ưu tiên bảo mật key thật sự: dùng **Option A**
-- nếu ưu tiên hiệu suất hơn và chấp nhận domain restrictions của Goong: dùng **Option B**
+- nếu ưu tiên hiệu suất hơn và chấp nhận domain restrictions của Goong: **Option B cần đổi frontend**
## 10. Recommendation cho codebase hiện tại
Với frontend hiện tại, hướng hợp lý nhất là:
1. giữ nguyên FE logic parse style/source như hiện nay
-2. chuyển các URL Goong ở `config.ts` sang endpoint nội bộ BE
-3. để BE rewrite:
- - `sources.*.url`
- - `tiles[]`
-4. để BE stream tile response
-5. cache rewritten JSON ở BE
+2. giữ `config.ts` dùng upstream URL sạch rồi để `buildGoongProxyUrl(...)` wrap thành `${API_BASE_URL}/proxy/tiles.goong.io/...`
+3. để BE sanitize nested `api_key` trong style/source JSON, nhưng không rewrite nested URL thành `/proxy/...`
+4. để BE stream tile/font response
+5. cache sanitized JSON ở BE
Nói ngắn:
-- rewrite JSON: nên làm
-- rewrite tile URLs: bắt buộc nếu muốn giấu key
+- sanitize JSON: bắt buộc để không lộ key trong response
+- FE rewrite tile URLs bằng `buildGoongProxyUrl(...)`
- proxy tile: phần tốn hiệu suất nhất
- muốn bù hiệu suất: phải dùng cache/stream/CDN tốt
@@ -375,10 +394,11 @@ Nói ngắn:
1. Tạo route proxy cho 2 style JSON
2. Tạo route proxy cho 3 source manifests
-3. Rewrite `sources.*.url` trong style JSON
-4. Rewrite `tiles[]` trong source manifests
+3. Strip `api_key` khỏi nested URL trong style JSON
+4. Strip `api_key` khỏi `tiles[]` trong source manifests
5. Tạo route proxy tile generic
-6. Stream tile response
-7. Preserve cache headers
-8. Cache rewritten JSON
-9. Kiểm tra browser không còn request trực tiếp `tiles.goong.io`
+6. Tạo route proxy fonts/glyphs
+7. Stream tile/font response
+8. Preserve cache headers
+9. Cache sanitized JSON
+10. Kiểm tra browser không còn request trực tiếp `tiles.goong.io`
diff --git a/src/uhm/doc/map_engine.md b/src/uhm/doc/map_engine.md
index b815981..5fdfea4 100644
--- a/src/uhm/doc/map_engine.md
+++ b/src/uhm/doc/map_engine.md
@@ -29,21 +29,23 @@ Nếu map init lỗi, `Map.tsx` render overlay lỗi thay vì crash im lặng.
## 2. Base style và background layers
-`getBaseMapStyle()` dựng style MapLibre từ vector tile source `base`.
+`getBaseMapStyle()` chỉ dựng skeleton style MapLibre:
-Background layers hiện có:
+- `glyphs` trỏ vào Goong glyph proxy
+- `sources: {}`
+- một layer `background` màu nền tối
-- `graticules-line`
-- `land`
-- `bg-countries-fill`
-- `bg-country-borders-line`
-- `country-labels`
-- `regions-line`
-- `lakes-fill`
-- `rivers-line`
-- `geolines-line`
+Background thật được thêm sau khi map load:
-Visibility của các layer này đi qua `BackgroundLayerVisibility`.
+- `raster-base-layer` được lazy-add từ `goong_satellite.json` qua proxy khi visibility bật.
+- overlay vector từ `goong_map_web.json` được clone theo nhóm:
+ - `bg-country-borders-line`
+ - `bg-province-borders-line`
+ - `bg-district-borders-line`
+ - `country-labels`
+ - `rivers-line`
+
+Visibility của các nhóm này đi qua `BackgroundLayerVisibility`.
## 3. Sources mà editor đang dùng
@@ -85,17 +87,20 @@ Source này dùng cho:
`useMapSync()` chịu trách nhiệm:
-1. filter draft theo binding nếu `respectBindingFilter = true`
-2. filter theo geometry visibility
-3. split feature thành nhóm polygon/line/point
-4. decorate line/polygon/point cho label rendering
-5. build source riêng cho path arrows
-6. set selected feature state
+1. nhận `renderDraft` đã được page áp timeline/replay/preview filter trước
+2. filter draft theo binding nếu `applyGeometryBindingFilter = true`
+3. filter theo geometry visibility
+4. split feature thành nhóm polygon/line/point
+5. decorate line/polygon/point cho label rendering
+6. build source riêng cho path arrows
+7. set selected feature state
Điểm quan trọng:
-- data mà map nhận không phải raw `draft` nguyên xi
-- nó là `draft` sau khi đã qua visibility, binding filter và label decoration
+- data mà map render không phải raw `mainDraft` nguyên xi
+- `renderDraft` là nguồn quyết định geometry nào xuất hiện trên map
+- `labelContextDraft` chỉ dùng để lookup label/entity name, có thể chứa geometry đã bị timeline filter ẩn, và không được dùng để quyết định render
+- source MapLibre cuối cùng là `renderDraft` sau khi đã qua binding filter, geometry visibility và label decoration
## 5. Map interaction layer
@@ -112,6 +117,8 @@ Binding hiện tại:
`add-point` được init riêng bằng `initPoint`, nhưng hiện chưa được đưa vào `engineBindingsRef` như các mode còn lại; logic create point vẫn được bind trong `setupMapInteractions`.
+`replay_preview` không có engine interaction riêng; preview controller điều khiển camera/timeline/visibility qua replay dispatcher.
+
## 6. Các engine cụ thể
### `initDrawing`
@@ -153,11 +160,12 @@ Binding hiện tại:
- bắt đầu edit geometry
- chuyển sang `replay`
-`replay` hiện không phải cinematic replay đầy đủ.
-Nó là mode hiển thị tập trung vào một geometry:
+Trong map interaction, `replay` vẫn dùng `initSelect`; `replay_preview` không cho edit/select theo engine.
+Phần script/preview replay nằm ở sidebar và preview overlay:
-- có nút thoát replay
-- có thể ẩn geometry ngoài danh sách `binding`
+- map render `replayDraft` hydrate từ `target_geometry_ids`
+- preview action có thể điều khiển camera, timeline, hidden geometry ids và presentation overlay
+- replay mode không cho mutate geometry chính
## 8. Đồng bộ selection và feature state
@@ -194,6 +202,7 @@ Nếu thất bại, map giữ nguyên center mặc định.
## 11. Những điều cần nhớ khi sửa map engine
- preview source/layer và persisted source/layer là hai tầng khác nhau
-- `draftRef` được dùng để tránh closure stale trong event handlers
+- `renderDraftRef` trong map interaction là dữ liệu đang được render/interact, không phải canonical commit draft
+- `draftRef` trong `useEditorState()` vẫn là ref nội bộ của draft để tránh closure stale trong editor state
- `Map` chỉ là orchestration component; logic lớn nằm ở hooks
- geometry render pipeline phụ thuộc khá nhiều vào `mapUtils.ts`, không chỉ mỗi `useMapSync.ts`
diff --git a/src/uhm/doc/map_styling.md b/src/uhm/doc/map_styling.md
index 50ac4b5..aa32829 100644
--- a/src/uhm/doc/map_styling.md
+++ b/src/uhm/doc/map_styling.md
@@ -11,7 +11,7 @@ Map hiện có hai nhóm style tách biệt:
### Background/base map
-Định nghĩa trong `useMapLayers.ts` qua `getBaseMapStyle()`.
+`getBaseMapStyle()` chỉ tạo skeleton style có `background` layer và Goong glyph proxy. Raster/vector background thật được thêm sau khi map load qua `mapUtils.ts` và `tiles.ts`.
### Geotype style
@@ -22,24 +22,23 @@ Map hiện có hai nhóm style tách biệt:
Danh sách layer toggle được expose ở `backgroundLayers.ts`:
- `raster-base-layer`
-- `graticules-line`
-- `land`
-- `bg-countries-fill`
- `bg-country-borders-line`
+- `bg-province-borders-line`
+- `bg-district-borders-line`
- `country-labels`
-- `regions-line`
-- `lakes-fill`
- `rivers-line`
-- `geolines-line`
Lưu ý:
-- không phải layer nào trong list cũng nhất thiết được add từ cùng một source path trong tương lai
+- `raster-base-layer` là layer raster lazy-add từ `goong_satellite.json`
+- các nhóm còn lại là overlay layer clone từ `goong_map_web.json`
+- overlay layer thật có id dạng `goong-...`, nhưng metadata `uhmBackgroundGroupId` trỏ về toggle id ở trên
- `BackgroundLayersPanel` chỉ biết toggle theo `id`
Visibility mặc định:
-- tất cả `true`
+- `raster-base-layer`, `bg-country-borders-line`, `country-labels`, `rivers-line` bật
+- `bg-province-borders-line`, `bg-district-borders-line` tắt
- được persist bằng `uhm.backgroundLayerVisibility.v1`
## 3. Geotype registry
@@ -77,7 +76,7 @@ Các type đang được register:
- `port`
- `bridge`
-`GEOMETRY_TYPE_OPTIONS` trong `geometryTypeOptions.ts` phải khớp với tập geotype này nếu muốn user chọn được từ UI.
+`GEOMETRY_TYPE_OPTIONS` trong `src/uhm/lib/map/geo/geometryTypeOptions.ts` phải khớp với tập geotype này nếu muốn user chọn được từ UI.
## 4. Type matching
@@ -119,6 +118,8 @@ Point geotype dùng icon pipeline trong:
- `shared/pointStyle.ts`
- `ensurePointGeotypeIcons(map)`
+Icon point hiện chọn theo geotype bình thường. Không còn branch icon/style riêng cho draft-orphan geometry.
+
Điều này có nghĩa là khi thêm geotype point mới, chỉ thêm layer là chưa đủ; cần chắc icon/style builder cũng hiểu type mới đó.
## 7. Preview và edit styling
@@ -158,6 +159,8 @@ Có ba lớp filter hiển thị trong runtime:
Vì vậy khi một geometry "không hiện", có thể nguyên nhân nằm ở data filtering chứ không phải style layer.
+Geometry không bind entity không có màu/icon riêng trên map. Trạng thái orphan/time/timeline nằm trong `GeometryBindingPanel`, còn map chỉ giữ style geotype + selected/focus/edit states.
+
## 9. Thêm geotype mới - checklist đúng với code hiện tại
Nếu thêm một geotype mới, nên đi theo checklist này:
diff --git a/src/uhm/doc/project_workflow.md b/src/uhm/doc/project_workflow.md
index e2b5d7e..ee744a4 100644
--- a/src/uhm/doc/project_workflow.md
+++ b/src/uhm/doc/project_workflow.md
@@ -60,7 +60,7 @@ Phần nó thật sự quan tâm là:
### Bước 1: load baseline
- `baselineSnapshot` lấy từ head commit hoặc commit được restore
-- `initialData` lấy từ `baselineSnapshot.editor_feature_collection`
+- `baselineFeatureCollection` lấy từ `baselineSnapshot.editor_feature_collection`
- `useEditorState()` reset draft và undo
### Bước 2: chỉnh sửa cục bộ
@@ -71,6 +71,7 @@ User có thể sửa:
- entity snapshot
- wiki snapshot
- entity-wiki snapshot
+- replay script
Tất cả thay đổi lúc này mới chỉ ở memory của frontend.
@@ -80,6 +81,7 @@ Tất cả thay đổi lúc này mới chỉ ở memory của frontend.
- đã mở được project
- `pendingSaveCount > 0`
+- không còn orphan geometry
Luồng commit:
@@ -91,7 +93,7 @@ Luồng commit:
- refresh `projectState`
- refresh `sectionCommits`
- cập nhật `baselineSnapshot`
- - set `initialData = editor.draft`
+ - set `baselineFeatureCollection = editor.mainDraft`
- `editor.clearChanges()`
- clear `commitTitle`
@@ -102,6 +104,7 @@ Luồng commit:
- project đang mở
- có `head_commit_id`
- `pendingSaveCount === 0`
+- không còn orphan geometry
Frontend sẽ lấy latest commit từ project hiện tại rồi tạo submission mới.
@@ -109,6 +112,7 @@ Frontend sẽ lấy latest commit từ project hiện tại rồi tạo submissi
Nút `Restore` trong `CommitHistoryPanel` hiện là restore phía frontend:
+- chỉ chạy khi `pendingSaveCount === 0`
- tải commit list mới nhất
- lấy snapshot của commit được chọn
- normalize snapshot
@@ -128,9 +132,10 @@ Nói cách khác, đây là `load snapshot into editor`, không phải `server-s
- `draft`
- `changes`
-- `snapshotEntities`
+- `snapshotEntityRows`
- `snapshotWikis`
- `snapshotEntityWikiLinks`
+- `effectiveReplays`
- `previousSnapshot`
và sinh ra:
@@ -141,12 +146,14 @@ và sinh ra:
- `geometry_entity`
- `wikis`
- `entity_wiki`
+- `replays`
Các điểm quan trọng:
- geometry many-to-many với entity được persist ở `geometry_entity[]`
- denormalized fields trên feature như `entity_ids`, `entity_name`, `binding`, `time_start` sẽ bị strip khỏi `editor_feature_collection` trước khi gửi API
- wiki/entity/link được chuẩn hóa lại thành `reference`, `binding`, `delete`, `create`, `update` tùy baseline
+- replay script được persist ở `replays[]`; `replayDraft` không được gửi
## 7. Dirty state mà user nhìn thấy
@@ -158,6 +165,7 @@ Nó gồm:
- cộng thêm 1 nếu entity dirty
- cộng thêm 1 nếu wiki dirty
- cộng thêm 1 nếu entity-wiki dirty
+- cộng thêm 1 nếu replay dirty
Vì vậy:
diff --git a/src/uhm/doc/wiki_system.md b/src/uhm/doc/wiki_system.md
index 5b74936..98db61b 100644
--- a/src/uhm/doc/wiki_system.md
+++ b/src/uhm/doc/wiki_system.md
@@ -55,6 +55,7 @@ Quy ước operation:
- wiki ref thêm từ search: `source: "ref"`, `operation: "reference"`
- wiki đã tồn tại nhưng sửa nội dung: `operation: "update"`
- wiki bị remove khỏi current state: được chuyển thành `delete` khi build snapshot so với baseline
+- khi remove wiki, page editor cũng gỡ các link `entity_wiki` trỏ tới wiki đó trong cùng undo group nếu handler ngoài được truyền vào
## 4. Slug
@@ -177,4 +178,5 @@ Hiện tại chưa có:
- schema block editor mới cho project wiki
- cross-project link graph UI
-File `doc/commit_snapshot.ts` có chứa schema `replays[]`, nhưng phần replay narrative đó chưa được nối với wiki editor hiện tại.
+Replay preview có thể mở `PublicWikiSidebar` bằng action `wiki_panel`, `close_wiki_panel` và `wiki`.
+Wiki editor vẫn không lưu narrative replay trực tiếp; narrative/script nằm trong `replays[]`.
diff --git a/src/uhm/lib/editor/draft/editorTypes.ts b/src/uhm/lib/editor/draft/editorTypes.ts
index a617138..2fa952d 100644
--- a/src/uhm/lib/editor/draft/editorTypes.ts
+++ b/src/uhm/lib/editor/draft/editorTypes.ts
@@ -13,9 +13,10 @@ export type Change = GeometryChange;
export type UndoAction =
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
| { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties }
- | { type: "delete"; feature: Feature }
+ | { type: "delete"; feature: Feature; index?: number }
| { type: "create"; id: FeatureProperties["id"] }
| { type: "replay"; geometryId: string; label: string; prevReplay: BattleReplay | null }
+ | { type: "replays"; label: string; prevReplays: BattleReplay[] }
| { type: "replay_session"; geometryId: string; label: string; prevReplay: BattleReplay | null }
// Snapshot-scoped undo (affects commit snapshot but not GeoJSON draft directly)
| { type: "snapshot_entities"; label: string; prev: EntitySnapshot[] }
diff --git a/src/uhm/lib/editor/draft/useDraftState.ts b/src/uhm/lib/editor/draft/useDraftState.ts
index 96a76d7..75b5c0b 100644
--- a/src/uhm/lib/editor/draft/useDraftState.ts
+++ b/src/uhm/lib/editor/draft/useDraftState.ts
@@ -2,11 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { FeatureCollection } from "@/uhm/types/geo";
import { deepClone } from "@/uhm/lib/editor/draft/draftDiff";
-export function useDraftState(initialData: FeatureCollection) {
+export function useDraftState(seedFeatureCollection: FeatureCollection) {
// Draft hiện tại (React state) để UI re-render khi dữ liệu thay đổi.
- const [draft, setDraft] = useState(() => deepClone(initialData));
+ const [draft, setDraft] = useState(() => deepClone(seedFeatureCollection));
// Draft ref để đọc giá trị mới nhất trong event handlers/engines mà không cần deps.
- const draftRef = useRef(deepClone(initialData));
+ const draftRef = useRef(deepClone(seedFeatureCollection));
const commitDraft = useCallback((nextDraft: FeatureCollection) => {
const cloned = deepClone(nextDraft);
diff --git a/src/uhm/lib/editor/draft/useUndoStack.ts b/src/uhm/lib/editor/draft/useUndoStack.ts
index 9d4c0df..5a1c913 100644
--- a/src/uhm/lib/editor/draft/useUndoStack.ts
+++ b/src/uhm/lib/editor/draft/useUndoStack.ts
@@ -96,6 +96,10 @@ function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
&& JSON.stringify(a.prevReplay) === JSON.stringify(next.prevReplay)
);
}
+ case "replays": {
+ const next = b as Extract;
+ return a.label === next.label && JSON.stringify(a.prevReplays) === JSON.stringify(next.prevReplays);
+ }
case "replay_session": {
const next = b as Extract;
return (
diff --git a/src/uhm/lib/editor/project/useProjectCommands.ts b/src/uhm/lib/editor/project/useProjectCommands.ts
index 570cb49..ece490e 100644
--- a/src/uhm/lib/editor/project/useProjectCommands.ts
+++ b/src/uhm/lib/editor/project/useProjectCommands.ts
@@ -8,7 +8,13 @@ import {
openSectionEditor,
submitSection,
} from "@/uhm/api/projects";
-import { buildEditorSnapshot, normalizeEditorSnapshot, toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
+import {
+ buildEditorSnapshot,
+ normalizeEditorSnapshot,
+ normalizeFeatureEntityIds,
+ toApiEditorSnapshot,
+} from "@/uhm/lib/editor/snapshot/editorSnapshot";
+import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
import type { BattleReplay, EditorSnapshot, ProjectCommit, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
@@ -42,15 +48,15 @@ export function useProjectCommands(options: Options) {
// operations should not carry over as deltas into the next commit.
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
const commits = await fetchProjectCommits(projectId);
- const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
+ const nextBaselineFeatureCollection = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
state.setActiveSection(editorPayload.project);
state.setSelectedProjectId(editorPayload.project.id);
state.setProjectState(editorPayload.state);
state.setBaselineSnapshot(sessionSnapshot);
- state.setInitialData(nextInitialData);
+ state.setBaselineFeatureCollection(nextBaselineFeatureCollection);
state.setProjectCommits(commits);
- state.setSnapshotEntities(sessionSnapshot?.entities || []);
+ state.setSnapshotEntityRows(sessionSnapshot?.entities || []);
state.setSnapshotWikis(sessionSnapshot?.wikis || []);
state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
state.setSelectedFeatureIds([]);
@@ -68,6 +74,15 @@ export function useProjectCommands(options: Options) {
return;
}
+ const orphanGeometries = findOrphanGeometries(options.editor.mainDraft);
+ if (orphanGeometries.length > 0) {
+ const firstOrphan = orphanGeometries[0];
+ state.setSelectedFeatureIds([firstOrphan.id]);
+ state.setEntityFormStatus("Geometry này chưa bind entity.");
+ state.setEntityStatus(formatOrphanGeometryMessage("Commit", orphanGeometries));
+ return;
+ }
+
const geometryChanges = options.editor.buildPayload();
state.setIsSaving(true);
state.setEntityStatus(null);
@@ -76,7 +91,7 @@ export function useProjectCommands(options: Options) {
project: state.activeSection,
draft: options.editor.mainDraft,
changes: geometryChanges,
- snapshotEntities: state.snapshotEntities,
+ snapshotEntityRows: state.snapshotEntityRows,
snapshotWikis: state.snapshotWikis,
snapshotEntityWikiLinks: state.snapshotEntityWikiLinks,
replays: options.editor.effectiveReplays,
@@ -111,10 +126,10 @@ export function useProjectCommands(options: Options) {
const sessionSnapshot = toEditorSessionSnapshot(snapshot);
state.setProjectState(result.state);
state.setBaselineSnapshot(sessionSnapshot);
- state.setSnapshotEntities(sessionSnapshot.entities || []);
+ state.setSnapshotEntityRows(sessionSnapshot.entities || []);
state.setSnapshotWikis(sessionSnapshot.wikis || []);
state.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []);
- state.setInitialData(options.editor.mainDraft);
+ state.setBaselineFeatureCollection(options.editor.mainDraft);
options.editor.clearChanges();
state.setCommitTitle("");
state.setProjectCommits(await fetchProjectCommits(state.activeSection.id));
@@ -206,6 +221,15 @@ export function useProjectCommands(options: Options) {
return;
}
+ const orphanGeometries = findOrphanGeometries(options.editor.mainDraft);
+ if (orphanGeometries.length > 0) {
+ const firstOrphan = orphanGeometries[0];
+ state.setSelectedFeatureIds([firstOrphan.id]);
+ state.setEntityFormStatus("Geometry này chưa bind entity.");
+ state.setEntityStatus(formatOrphanGeometryMessage("Submit", orphanGeometries));
+ return;
+ }
+
state.setIsSubmitting(true);
state.setEntityStatus(null);
try {
@@ -220,7 +244,7 @@ export function useProjectCommands(options: Options) {
} finally {
state.setIsSubmitting(false);
}
- }, [options.pendingSaveCount, options.store]);
+ }, [options.editor.mainDraft, options.pendingSaveCount, options.store]);
const restoreCommit = useCallback(async (commitId: string) => {
const state = options.store.getState();
@@ -247,11 +271,11 @@ export function useProjectCommands(options: Options) {
const snapshot = normalizeEditorSnapshot(target.snapshot_json);
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
- const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
+ const nextBaselineFeatureCollection = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
state.setBaselineSnapshot(sessionSnapshot);
- state.setInitialData(nextInitialData);
- state.setSnapshotEntities(sessionSnapshot?.entities || []);
+ state.setBaselineFeatureCollection(nextBaselineFeatureCollection);
+ state.setSnapshotEntityRows(sessionSnapshot?.entities || []);
state.setSnapshotWikis(sessionSnapshot?.wikis || []);
state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
state.setSelectedFeatureIds([]);
@@ -281,6 +305,34 @@ export function useProjectCommands(options: Options) {
};
}
+type OrphanGeometry = {
+ id: Feature["properties"]["id"];
+ label: string;
+};
+
+function findOrphanGeometries(draft: FeatureCollection): OrphanGeometry[] {
+ const rows: OrphanGeometry[] = [];
+
+ for (const feature of draft.features || []) {
+ const entityIds = normalizeFeatureEntityIds(feature);
+ if (entityIds.length > 0) continue;
+
+ const id = feature.properties.id;
+ rows.push({
+ id,
+ label: String(id),
+ });
+ }
+
+ return rows;
+}
+
+function formatOrphanGeometryMessage(action: "Commit" | "Submit", rows: OrphanGeometry[]): string {
+ const sample = rows.slice(0, 8).map((row) => row.label).join(", ");
+ const more = rows.length > 8 ? `, ... (+${rows.length - 8})` : "";
+ return `Không thể ${action}: còn ${rows.length} geometry chưa bind entity. Hãy bind entity cho: ${sample}${more}.`;
+}
+
function toEditorSessionSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
return {
...snapshot,
@@ -311,8 +363,8 @@ function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnaps
operation: "reference",
name: typeof e.name === "string" ? e.name : undefined,
description: typeof e.description === "string" ? e.description : e.description ?? null,
- time_start: typeof e.time_start === "number" ? e.time_start : e.time_start ?? undefined,
- time_end: typeof e.time_end === "number" ? e.time_end : e.time_end ?? undefined,
+ time_start: normalizeTimelineYearValue(e.time_start) ?? undefined,
+ time_end: normalizeTimelineYearValue(e.time_end) ?? undefined,
};
});
}
@@ -333,8 +385,8 @@ function toEditorSessionGeometries(input: EditorSnapshot["geometries"]): Geometr
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,
+ time_start: normalizeTimelineYearValue(g.time_start) ?? undefined,
+ time_end: normalizeTimelineYearValue(g.time_end) ?? undefined,
bbox: g.bbox
? {
min_lng: g.bbox.min_lng,
diff --git a/src/uhm/lib/editor/session/useEntitySessionState.ts b/src/uhm/lib/editor/session/useEntitySessionState.ts
index 3fa28b3..72fc036 100644
--- a/src/uhm/lib/editor/session/useEntitySessionState.ts
+++ b/src/uhm/lib/editor/session/useEntitySessionState.ts
@@ -11,7 +11,7 @@ export function useEntitySessionState() {
// Entity catalog loaded from backend (global list, used for search/lookup).
const [entityCatalog, setEntityCatalog] = useState([]);
// Snapshot entity store for the current editor session (single source of truth for snapshot.entities).
- const [snapshotEntities, setSnapshotEntities] = useState([]);
+ const [snapshotEntityRows, setSnapshotEntityRows] = useState([]);
// Thông báo trạng thái/lỗi liên quan entity/session.
const [entityStatus, setEntityStatus] = useState(null);
// Features đang được chọn để thao tác bind entities/metadata.
@@ -48,8 +48,8 @@ export function useEntitySessionState() {
return {
entityCatalog,
setEntityCatalog,
- snapshotEntities,
- setSnapshotEntities,
+ snapshotEntityRows,
+ setSnapshotEntityRows,
entityStatus,
setEntityStatus,
selectedFeatureIds,
diff --git a/src/uhm/lib/editor/snapshot/editorSnapshot.ts b/src/uhm/lib/editor/snapshot/editorSnapshot.ts
index 8ee7ce6..ef49ed7 100644
--- a/src/uhm/lib/editor/snapshot/editorSnapshot.ts
+++ b/src/uhm/lib/editor/snapshot/editorSnapshot.ts
@@ -1,5 +1,6 @@
import { DEFAULT_GEOMETRY_TYPE_ID } from "@/uhm/lib/map/geo/geometryTypeOptions";
import { normalizeGeoTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/map/geo/geoTypeMap";
+import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { EntitySnapshotOperation } from "@/uhm/types/entities";
@@ -94,6 +95,11 @@ function getRefId(value: unknown): string {
return typeof value.id === "string" ? value.id : "";
}
+function normalizeApiTimeFields(row: UnknownRecord): void {
+ if ("time_start" in row) row.time_start = normalizeTimelineYearValue(row.time_start);
+ if ("time_end" in row) row.time_end = normalizeTimelineYearValue(row.time_end);
+}
+
export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
if (!isRecord(raw)) return null;
const snapshot = raw as UnknownRecord;
@@ -126,8 +132,8 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
operation,
name: typeof e.name === "string" ? e.name : undefined,
description: typeof e.description === "string" ? e.description : e.description == null ? undefined : undefined,
- time_start: typeof e.time_start === "number" ? e.time_start : e.time_start == null ? undefined : undefined,
- time_end: typeof e.time_end === "number" ? e.time_end : e.time_end == null ? undefined : undefined,
+ time_start: normalizeTimelineYearValue(e.time_start) ?? undefined,
+ time_end: normalizeTimelineYearValue(e.time_end) ?? undefined,
};
})
: undefined;
@@ -156,8 +162,8 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
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,
+ time_start: normalizeTimelineYearValue(row.time_start) ?? undefined,
+ time_end: normalizeTimelineYearValue(row.time_end) ?? undefined,
bbox: isRecord(row.bbox)
? {
min_lng: Number(row.bbox.min_lng),
@@ -278,8 +284,8 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const name = typeof row.name === "string" ? String(row.name).trim() : "";
if (name) entityNameById.set(id, name);
entityTimeById.set(id, {
- time_start: typeof row.time_start === "number" ? row.time_start : null,
- time_end: typeof row.time_end === "number" ? row.time_end : null,
+ time_start: normalizeTimelineYearValue(row.time_start),
+ time_end: normalizeTimelineYearValue(row.time_end),
});
}
const geometryById = new Map();
@@ -293,6 +299,18 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const gid = String(feature.properties.id);
const entity_ids = byGeom.get(gid) || [];
const p = feature.properties as unknown as UnknownRecord;
+ const existingTimeStart = normalizeTimelineYearValue(p.time_start);
+ const existingTimeEnd = normalizeTimelineYearValue(p.time_end);
+ if (existingTimeStart !== null) {
+ p.time_start = existingTimeStart;
+ } else {
+ delete p.time_start;
+ }
+ if (existingTimeEnd !== null) {
+ p.time_end = existingTimeEnd;
+ } else {
+ delete p.time_end;
+ }
const existingTypeKey = normalizeGeoTypeKey(p.type) || normalizeGeoTypeKey(p.entity_type_id);
const fallbackTypeKey = getDefaultTypeIdForFeature(feature);
@@ -334,8 +352,18 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|| fallbackTypeKey;
if (typeKey) p.type = typeKey;
if (Array.isArray(geo.binding) && geo.binding.length) p.binding = geo.binding;
- if (typeof geo.time_start === "number") p.time_start = geo.time_start;
- if (typeof geo.time_end === "number") p.time_end = geo.time_end;
+ const timeStart = normalizeTimelineYearValue(geo.time_start);
+ const timeEnd = normalizeTimelineYearValue(geo.time_end);
+ if (timeStart !== null) {
+ p.time_start = timeStart;
+ } else {
+ delete p.time_start;
+ }
+ if (timeEnd !== null) {
+ p.time_end = timeEnd;
+ } else {
+ delete p.time_end;
+ }
} else if (!existingTypeKey) {
p.type = fallbackTypeKey;
}
@@ -359,7 +387,7 @@ export function buildEditorSnapshot(options: {
project: Project;
draft: FeatureCollection;
changes: Change[];
- snapshotEntities: EntitySnapshot[];
+ snapshotEntityRows: EntitySnapshot[];
snapshotWikis: WikiSnapshot[];
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
replays: BattleReplay[];
@@ -410,11 +438,11 @@ export function buildEditorSnapshot(options: {
operation: "reference",
name: typeof cloned.name === "string" ? cloned.name : undefined,
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
- time_start: typeof cloned.time_start === "number" ? cloned.time_start : cloned.time_start ?? undefined,
- time_end: typeof cloned.time_end === "number" ? cloned.time_end : cloned.time_end ?? undefined,
+ time_start: normalizeTimelineYearValue(cloned.time_start) ?? undefined,
+ time_end: normalizeTimelineYearValue(cloned.time_end) ?? undefined,
});
}
- for (const row of options.snapshotEntities || []) {
+ for (const row of options.snapshotEntityRows || []) {
if (!row) continue;
const id = typeof row.id === "string" || typeof row.id === "number" ? String(row.id) : "";
if (!id) continue;
@@ -435,8 +463,8 @@ export function buildEditorSnapshot(options: {
name,
operation,
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
- time_start: typeof cloned.time_start === "number" ? cloned.time_start : cloned.time_start ?? undefined,
- time_end: typeof cloned.time_end === "number" ? cloned.time_end : cloned.time_end ?? undefined,
+ time_start: normalizeTimelineYearValue(cloned.time_start) ?? undefined,
+ time_end: normalizeTimelineYearValue(cloned.time_end) ?? undefined,
});
}
@@ -483,6 +511,8 @@ export function buildEditorSnapshot(options: {
: "reference";
const bbox = getFeatureBBox(feature);
const typeKey = normalizeGeoTypeKey(feature.properties.type) || getDefaultTypeIdForFeature(feature);
+ const timeStart = normalizeTimelineYearValue(feature.properties.time_start);
+ const timeEnd = normalizeTimelineYearValue(feature.properties.time_end);
return {
id,
operation,
@@ -490,8 +520,8 @@ export function buildEditorSnapshot(options: {
type: typeKey,
draw_geometry: feature.geometry,
binding: normalizeFeatureBindingIds(feature),
- time_start: feature.properties.time_start ?? null,
- time_end: feature.properties.time_end ?? null,
+ time_start: timeStart,
+ time_end: timeEnd,
bbox: bbox
? {
min_lng: bbox.minLng,
@@ -689,8 +719,8 @@ export function buildEditorSnapshot(options: {
operation: e.operation,
name: typeof e.name === "string" ? e.name : undefined,
description: typeof (e as RawEntityRow).description === "string" ? (e as RawEntityRow).description : (e as RawEntityRow).description ?? null,
- time_start: typeof e.time_start === "number" ? e.time_start : e.time_start ?? undefined,
- time_end: typeof e.time_end === "number" ? e.time_end : e.time_end ?? undefined,
+ time_start: normalizeTimelineYearValue(e.time_start) ?? undefined,
+ time_end: normalizeTimelineYearValue(e.time_end) ?? undefined,
}))
.sort((a, b) => String(a.id).localeCompare(String(b.id))),
geometries: geometries.slice().sort((a, b) => String(a.id).localeCompare(String(b.id))),
@@ -713,11 +743,31 @@ export function buildEditorSnapshot(options: {
export function toApiEditorSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
const cloned = JSON.parse(JSON.stringify(snapshot)) as EditorSnapshot;
+ if (Array.isArray(cloned.editor_feature_collection?.features)) {
+ cloned.editor_feature_collection.features = cloned.editor_feature_collection.features.map((feature) => {
+ const properties = { ...(feature.properties as unknown as UnknownRecord) };
+ normalizeApiTimeFields(properties);
+ return {
+ ...feature,
+ properties: properties as unknown as Feature["properties"],
+ };
+ });
+ }
+
+ if (Array.isArray(cloned.entities)) {
+ cloned.entities = cloned.entities.map((entity) => {
+ const row = { ...(entity as unknown as UnknownRecord) };
+ normalizeApiTimeFields(row);
+ return row as unknown as EntitySnapshot;
+ });
+ }
+
if (Array.isArray(cloned.geometries)) {
cloned.geometries = cloned.geometries.map((geometry) => {
const row = { ...(geometry as unknown as UnknownRecord) };
const typeKey = normalizeGeoTypeKey(row.type) || normalizeGeoTypeKey(row.geo_type);
delete row.geo_type;
+ normalizeApiTimeFields(row);
if (typeKey) {
const typeCode = typeKeyToGeoTypeCode(typeKey);
@@ -846,6 +896,7 @@ function normalizeReplayUiOption(value: unknown): UIOptionName | null {
case "timeline":
case "layer_panel":
case "wiki_panel":
+ case "close_wiki_panel":
case "zoom_panel":
case "wiki":
case "toast":
@@ -910,6 +961,7 @@ function normalizeReplayMapFunctionName(value: unknown): MapFunctionName | null
case "toggle_labels":
case "show_labels":
case "hide_labels":
+ case "show_all_geometries":
case "reset_camera_north":
return value;
default:
@@ -958,10 +1010,15 @@ function normalizeReplayNarrativeActions(actions: unknown): ReplayAction("idle");
- // FeatureCollection "gốc" của session hiện tại (global timeline hoặc project snapshot).
- const [initialData, setInitialData] = useState(options.emptyFeatureCollection);
+ // Baseline FeatureCollection used to seed/reset the editor draft for the current session.
+ const [baselineFeatureCollection, setBaselineFeatureCollection] = useState(options.emptyFeatureCollection);
const project = useProjectSessionState({
defaultEditorUserId: options.defaultEditorUserId,
@@ -41,8 +41,8 @@ export function useEditorSessionState(options: Options) {
return {
mode,
setMode,
- initialData,
- setInitialData,
+ baselineFeatureCollection,
+ setBaselineFeatureCollection,
...project,
...entity,
...timeline,
diff --git a/src/uhm/lib/editor/state/useEditorState.ts b/src/uhm/lib/editor/state/useEditorState.ts
index ac1e454..6b237d4 100644
--- a/src/uhm/lib/editor/state/useEditorState.ts
+++ b/src/uhm/lib/editor/state/useEditorState.ts
@@ -19,8 +19,8 @@ export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
type SnapshotUndoApi = {
- snapshotEntitiesRef: { current: EntitySnapshot[] };
- setSnapshotEntities: Dispatch>;
+ snapshotEntityRowsRef: { current: EntitySnapshot[] };
+ setSnapshotEntityRows: Dispatch>;
snapshotWikisRef: { current: WikiSnapshot[] };
setSnapshotWikis: Dispatch>;
snapshotEntityWikiLinksRef: { current: EntityWikiLinkSnapshot[] };
@@ -41,7 +41,7 @@ type ReplayDraftSyncMode = "none" | "reset";
// - 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,
+ baselineFeatureCollection: FeatureCollection,
options: {
snapshotUndo?: SnapshotUndoApi;
initialReplays?: BattleReplay[];
@@ -50,7 +50,7 @@ export function useEditorState(
) {
const { snapshotUndo, initialReplays, mode } = options;
- const mainDraftState = useDraftState(initialData);
+ const mainDraftState = useDraftState(baselineFeatureCollection);
const replayFeatureDraftState = useDraftState(EMPTY_FEATURE_COLLECTION);
const {
draft: mainDraft,
@@ -116,7 +116,7 @@ export function useEditorState(
// Map baseline (id -> feature) để diff main draft ra changes.
const initialMapRef = useRef>(
- buildInitialMap(initialData)
+ buildInitialMap(baselineFeatureCollection)
);
// Version counter để ép diff recalculation sau khi reset/clear baseline.
const [baselineVersion, setBaselineVersion] = useState(0);
@@ -132,22 +132,27 @@ export function useEditorState(
targetCommitDraft({
...targetDraftRef.current,
features: targetDraftRef.current.features.filter((feature) =>
- feature.properties.id !== action.id
+ !featureIdEquals(feature.properties.id, action.id)
),
});
return true;
}
case "delete": {
const feature = deepClone(action.feature);
+ const nextFeatures = [...targetDraftRef.current.features];
+ const insertAt = typeof action.index === "number" && Number.isFinite(action.index)
+ ? Math.max(0, Math.min(action.index, nextFeatures.length))
+ : nextFeatures.length;
+ nextFeatures.splice(insertAt, 0, feature);
targetCommitDraft({
...targetDraftRef.current,
- features: [...targetDraftRef.current.features, feature],
+ features: nextFeatures,
});
return true;
}
case "update": {
const idx = targetDraftRef.current.features.findIndex((feature) =>
- feature.properties.id === action.id
+ featureIdEquals(feature.properties.id, action.id)
);
if (idx === -1) return false;
const nextFeatures = [...targetDraftRef.current.features];
@@ -160,7 +165,7 @@ export function useEditorState(
}
case "properties": {
const idx = targetDraftRef.current.features.findIndex((feature) =>
- feature.properties.id === action.id
+ featureIdEquals(feature.properties.id, action.id)
);
if (idx === -1) return false;
const nextFeatures = [...targetDraftRef.current.features];
@@ -174,8 +179,8 @@ export function useEditorState(
case "snapshot_entities": {
if (!allowSnapshotUndo || !snapshotUndo) return false;
const prev = deepClone(action.prev);
- snapshotUndo.snapshotEntitiesRef.current = prev;
- snapshotUndo.setSnapshotEntities(prev);
+ snapshotUndo.snapshotEntityRowsRef.current = prev;
+ snapshotUndo.setSnapshotEntityRows(prev);
return true;
}
case "snapshot_wikis": {
@@ -226,6 +231,19 @@ export function useEditorState(
return true;
}
+ if (action.type === "replays") {
+ const restoredReplays = deepClone(action.prevReplays || []);
+ updateReplaysState(restoredReplays);
+
+ if (activeReplayId != null) {
+ const activeReplay = restoredReplays.find((replay) => replay.geometry_id === String(activeReplayId)) || null;
+ activeReplayOriginRef.current = activeReplay ? deepClone(activeReplay) : null;
+ activeReplaySeedRef.current = activeReplay ? deepClone(activeReplay) : null;
+ setActiveReplayDraftState(activeReplay, "reset");
+ }
+ return true;
+ }
+
return applyUndoActionToDraft(
action,
mainDraftRef,
@@ -264,7 +282,7 @@ export function useEditorState(
} = useUndoStack({ applyUndoAction: applyReplayUndoAction });
useEffect(() => {
- resetMainDraft(deepClone(initialData));
+ resetMainDraft(deepClone(baselineFeatureCollection));
resetReplayDraft(EMPTY_FEATURE_COLLECTION);
updateReplaysState(initialReplays || []);
setActiveReplayId(null);
@@ -273,12 +291,12 @@ export function useEditorState(
activeReplaySeedRef.current = null;
clearMainUndo();
clearReplayUndo();
- initialMapRef.current = buildInitialMap(initialData);
+ initialMapRef.current = buildInitialMap(baselineFeatureCollection);
setBaselineVersion((version) => version + 1);
}, [
clearMainUndo,
clearReplayUndo,
- initialData,
+ baselineFeatureCollection,
initialReplays,
resetMainDraft,
resetReplayDraft,
@@ -371,7 +389,7 @@ export function useEditorState(
pushMainUndo({ type: "create", id: featureClone.properties.id });
}
- function createFeatureWithSnapshotEntities(
+ function createFeatureWithSnapshotEntityRows(
feature: Feature,
nextEntities: SetStateAction,
label = "Import geometry"
@@ -384,7 +402,7 @@ export function useEditorState(
const undoActions: UndoAction[] = [];
if (snapshotUndo) {
- const prevEntities = snapshotUndo.snapshotEntitiesRef.current || [];
+ const prevEntities = snapshotUndo.snapshotEntityRowsRef.current || [];
const prevEntitiesClone = deepClone(prevEntities);
const computedEntities = typeof nextEntities === "function"
? (nextEntities as (p: EntitySnapshot[]) => EntitySnapshot[])(prevEntitiesClone)
@@ -403,8 +421,8 @@ export function useEditorState(
label: "Cập nhật entities",
prev: prevEntitiesClone,
});
- snapshotUndo.snapshotEntitiesRef.current = computedEntitiesClone;
- snapshotUndo.setSnapshotEntities(computedEntitiesClone);
+ snapshotUndo.snapshotEntityRowsRef.current = computedEntitiesClone;
+ snapshotUndo.setSnapshotEntityRows(computedEntitiesClone);
}
}
@@ -428,7 +446,7 @@ export function useEditorState(
return;
}
- const idx = mainDraftRef.current.features.findIndex((feature) => feature.properties.id === id);
+ const idx = mainDraftRef.current.features.findIndex((feature) => featureIdEquals(feature.properties.id, id));
if (idx === -1) return;
const nextFeatures = [...mainDraftRef.current.features];
@@ -472,7 +490,7 @@ export function useEditorState(
const undoActions: UndoAction[] = [];
for (const [id, patch] of mergedPatches.entries()) {
- const idx = nextFeatures.findIndex((feature) => feature.properties.id === id);
+ const idx = nextFeatures.findIndex((feature) => featureIdEquals(feature.properties.id, id));
if (idx === -1) continue;
const prevProperties = deepClone(nextFeatures[idx].properties);
@@ -506,7 +524,7 @@ export function useEditorState(
return;
}
- const idx = mainDraftRef.current.features.findIndex((feature) => feature.properties.id === id);
+ const idx = mainDraftRef.current.features.findIndex((feature) => featureIdEquals(feature.properties.id, id));
if (idx === -1) return;
const prevFeature = mainDraftRef.current.features[idx];
@@ -529,14 +547,22 @@ export function useEditorState(
return;
}
- const idx = mainDraftRef.current.features.findIndex((feature) => feature.properties.id === id);
+ const idx = mainDraftRef.current.features.findIndex((feature) => featureIdEquals(feature.properties.id, id));
if (idx === -1) return;
const feature = mainDraftRef.current.features[idx];
const nextFeatures = [...mainDraftRef.current.features];
nextFeatures.splice(idx, 1);
- pushMainUndo({ type: "delete", feature: deepClone(feature) });
+ const undoActions: UndoAction[] = [];
+ const replayUndoAction = pruneReplaysForDeletedGeometryIds([feature.properties.id], `Xóa replay theo GEO #${feature.properties.id}`);
+ if (replayUndoAction) undoActions.push(replayUndoAction);
+ undoActions.push({ type: "delete", feature: deepClone(feature), index: idx });
+ pushMainUndo(
+ undoActions.length === 1
+ ? undoActions[0]
+ : { type: "group", label: `Xóa GEO #${feature.properties.id}`, actions: undoActions }
+ );
commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
}
@@ -549,24 +575,49 @@ export function useEditorState(
const nextFeatures: Feature[] = [];
const undoActions: UndoAction[] = [];
- for (const feature of mainDraftRef.current.features) {
+ mainDraftRef.current.features.forEach((feature, index) => {
if (idsSet.has(String(feature.properties.id))) {
- undoActions.push({ type: "delete", feature: deepClone(feature) });
+ undoActions.push({ type: "delete", feature: deepClone(feature), index });
} else {
nextFeatures.push(feature);
}
- }
+ });
if (undoActions.length === 0) return;
+ const replayUndoAction = pruneReplaysForDeletedGeometryIds(ids, `Xóa replay theo ${undoActions.length} GEO`);
+ const groupedActions = replayUndoAction
+ ? [replayUndoAction, ...undoActions.slice().reverse()]
+ : undoActions.length === 1
+ ? undoActions
+ : undoActions.slice().reverse();
pushMainUndo(
- undoActions.length === 1
- ? undoActions[0]
- : { type: "group", label: `Xóa ${undoActions.length} geometry`, actions: undoActions }
+ groupedActions.length === 1
+ ? groupedActions[0]
+ : { type: "group", label: `Xóa ${undoActions.length} geometry`, actions: groupedActions }
);
commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
}
+ function pruneReplaysForDeletedGeometryIds(
+ ids: Array,
+ label: string
+ ): UndoAction | null {
+ const deletedIds = new Set(ids.map((id) => String(id)));
+ if (!deletedIds.size) return null;
+
+ const prevReplays = replaysRef.current || [];
+ const nextReplays = pruneDeletedGeometryIdsFromReplays(prevReplays, deletedIds);
+ if (replaysEqual(prevReplays, nextReplays)) return null;
+
+ updateReplaysState(nextReplays);
+ return {
+ type: "replays",
+ label,
+ prevReplays: deepClone(prevReplays),
+ };
+ }
+
function buildPayload(): Change[] {
return Array.from(changes.values()).map((change) => deepClone(change));
}
@@ -620,12 +671,12 @@ export function useEditorState(
clearReplayUndo();
}, [clearReplayUndo, finalizeActiveReplaySession, setActiveReplayDraftState]);
- const setSnapshotEntitiesUndoable = useCallback((
+ const setSnapshotEntityRowsUndoable = useCallback((
next: SetStateAction,
label = "Cập nhật entities"
) => {
if (!snapshotUndo) return;
- const prev = snapshotUndo.snapshotEntitiesRef.current || [];
+ const prev = snapshotUndo.snapshotEntityRowsRef.current || [];
const prevClone = deepClone(prev);
const computed = typeof next === "function" ? (next as (p: EntitySnapshot[]) => EntitySnapshot[])(prevClone) : next;
let changed = true;
@@ -638,8 +689,8 @@ export function useEditorState(
const computedClone = deepClone(computed);
pushMainUndo({ type: "snapshot_entities", label, prev: prevClone });
- snapshotUndo.snapshotEntitiesRef.current = computedClone;
- snapshotUndo.setSnapshotEntities(computedClone);
+ snapshotUndo.snapshotEntityRowsRef.current = computedClone;
+ snapshotUndo.setSnapshotEntityRows(computedClone);
}, [pushMainUndo, snapshotUndo]);
const setSnapshotWikisUndoable = useCallback((
@@ -688,6 +739,54 @@ export function useEditorState(
snapshotUndo.setSnapshotEntityWikiLinks(computedClone);
}, [pushMainUndo, snapshotUndo]);
+ const setSnapshotWikisAndEntityWikiLinksUndoable = useCallback((
+ nextWikis: SetStateAction,
+ nextLinks: SetStateAction,
+ label = "Cập nhật wiki/entity-wiki"
+ ) => {
+ if (!snapshotUndo) return;
+
+ const prevWikis = snapshotUndo.snapshotWikisRef.current || [];
+ const prevWikiLinks = snapshotUndo.snapshotEntityWikiLinksRef.current || [];
+ const prevWikisClone = deepClone(prevWikis);
+ const prevWikiLinksClone = deepClone(prevWikiLinks);
+ const computedWikis = typeof nextWikis === "function"
+ ? (nextWikis as (p: WikiSnapshot[]) => WikiSnapshot[])(prevWikisClone)
+ : nextWikis;
+ const computedWikiLinks = typeof nextLinks === "function"
+ ? (nextLinks as (p: EntityWikiLinkSnapshot[]) => EntityWikiLinkSnapshot[])(prevWikiLinksClone)
+ : nextLinks;
+
+ const wikisChanged = !jsonEquals(prevWikis, computedWikis);
+ const linksChanged = !jsonEquals(prevWikiLinks, computedWikiLinks);
+ if (!wikisChanged && !linksChanged) return;
+
+ const undoActions: Array> = [];
+ if (wikisChanged) {
+ undoActions.push({ type: "snapshot_wikis", label: "Cập nhật wiki", prev: prevWikisClone });
+ }
+ if (linksChanged) {
+ undoActions.push({ type: "snapshot_entity_wiki", label: "Cập nhật entity-wiki", prev: prevWikiLinksClone });
+ }
+
+ pushMainUndo(
+ undoActions.length === 1
+ ? { ...undoActions[0], label }
+ : { type: "group", label, actions: undoActions }
+ );
+
+ if (wikisChanged) {
+ const computedWikisClone = deepClone(computedWikis);
+ snapshotUndo.snapshotWikisRef.current = computedWikisClone;
+ snapshotUndo.setSnapshotWikis(computedWikisClone);
+ }
+ if (linksChanged) {
+ const computedWikiLinksClone = deepClone(computedWikiLinks);
+ snapshotUndo.snapshotEntityWikiLinksRef.current = computedWikiLinksClone;
+ snapshotUndo.setSnapshotEntityWikiLinks(computedWikiLinksClone);
+ }
+ }, [pushMainUndo, snapshotUndo]);
+
const undo = useCallback(() => {
if (mode === "replay") {
undoReplay();
@@ -717,7 +816,7 @@ export function useEditorState(
changeCount,
canUndoReplay: replayUndoStack.length > 0,
createFeature,
- createFeatureWithSnapshotEntities,
+ createFeatureWithSnapshotEntityRows,
patchFeatureProperties,
patchFeaturePropertiesBatch,
updateFeature,
@@ -728,9 +827,10 @@ export function useEditorState(
clearChanges,
hasPersistedFeature,
// Snapshot undo helpers (no-op if snapshotUndo not provided)
- setSnapshotEntities: setSnapshotEntitiesUndoable,
+ setSnapshotEntityRows: setSnapshotEntityRowsUndoable,
setSnapshotWikis: setSnapshotWikisUndoable,
setSnapshotEntityWikiLinks: setSnapshotEntityWikiLinksUndoable,
+ setSnapshotWikisAndEntityWikiLinks: setSnapshotWikisAndEntityWikiLinksUndoable,
};
}
@@ -738,6 +838,18 @@ function resolveStateAction(next: SetStateAction, prev: T): T {
return typeof next === "function" ? (next as (value: T) => T)(prev) : next;
}
+function featureIdEquals(a: FeatureProperties["id"], b: FeatureProperties["id"]) {
+ return String(a) === String(b);
+}
+
+function jsonEquals(a: unknown, b: unknown) {
+ try {
+ return JSON.stringify(a) === JSON.stringify(b);
+ } catch {
+ return false;
+ }
+}
+
function createReplaySessionSeed(
sourceDraft: FeatureCollection,
geometryId: string,
@@ -888,6 +1000,40 @@ function replaceReplayByGeometryId(
return next;
}
+function pruneDeletedGeometryIdsFromReplays(
+ replays: BattleReplay[],
+ deletedIds: Set
+): BattleReplay[] {
+ const next: BattleReplay[] = [];
+
+ for (const replay of replays || []) {
+ const geometryId = String(replay?.geometry_id || "");
+ if (!geometryId || deletedIds.has(geometryId)) continue;
+
+ const targetGeometryIds = normalizeReplayTargetGeometryIds(
+ replay.target_geometry_ids,
+ geometryId
+ ).filter((id) => !deletedIds.has(id));
+
+ next.push({
+ ...deepClone(replay),
+ id: geometryId,
+ geometry_id: geometryId,
+ target_geometry_ids: targetGeometryIds,
+ });
+ }
+
+ return next;
+}
+
+function replaysEqual(a: BattleReplay[] | null | undefined, b: BattleReplay[] | null | undefined) {
+ try {
+ return JSON.stringify(a ?? []) === JSON.stringify(b ?? []);
+ } catch {
+ return false;
+ }
+}
+
function replayEquals(a: BattleReplay | null | undefined, b: BattleReplay | null | undefined) {
try {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
diff --git a/src/uhm/lib/map/engines/selectingEngine.ts.orig b/src/uhm/lib/map/engines/selectingEngine.ts.orig
deleted file mode 100644
index 4ffd708..0000000
--- a/src/uhm/lib/map/engines/selectingEngine.ts.orig
+++ /dev/null
@@ -1,357 +0,0 @@
-import maplibregl from "maplibre-gl";
-import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
-
-// Khởi tạo engine chọn feature và context menu edit/delete.
-export function initSelect(
- map: maplibregl.Map,
- getMode: ModeGetter,
- onDelete?: (id: string | number) => void,
- onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
- onDuplicate?: (id: string | number) => void,
- onHide?: (id: string | number) => void,
- onSelectIds?: (ids: (string | number)[]) => void,
- onReplayEdit?: (id: string | number) => void,
- isEditSessionActive?: () => boolean,
- onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void
-) {
-
- const FEATURE_STATE_SOURCES = [
- "countries",
- "places",
- "path-arrow-shapes",
- ] as const;
- const selectedIds = new Set();
- const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit || onBindGeometries);
- let contextMenu: HTMLDivElement | null = null;
- let docClickHandler: ((ev: MouseEvent) => void) | null = null;
-
- // Bỏ highlight feature-state của toàn bộ đối tượng đang chọn.
- function clearSelection(emit = true) {
- if (!selectedIds.size) return;
- selectedIds.forEach((id) => setSelectionStateForId(id, false));
- selectedIds.clear();
- if (emit) {
- onSelectIds?.([]);
- }
- }
-
- // Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn.
- function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
- const id = feature.id ?? feature.properties?.id;
- if (id === undefined || id === null) return;
-
- if (!additive) {
- clearSelection();
- }
-
- const idToRemove = Array.from(selectedIds).find(sid => String(sid) === String(id));
- const isAlreadySelected = idToRemove !== undefined;
-
- if (additive && isAlreadySelected) {
- // Alt + click on an already selected feature removes it from the selection
- setSelectionStateForId(idToRemove, false);
- selectedIds.delete(idToRemove);
- onSelectIds?.(Array.from(selectedIds));
- return;
- }
-
- setSelectionStateForId(id, true);
- selectedIds.add(id);
- onSelectIds?.(Array.from(selectedIds));
- }
-
- // Chọn feature theo click trái, hỗ trợ additive bằng Alt.
- function onClick(e: maplibregl.MapLayerMouseEvent) {
- if (getMode() !== "select" && getMode() !== "replay") return;
- if (isEditSessionActive?.()) return;
- const selectableLayers = getSelectableLayers();
- if (!selectableLayers.length) return;
-
- const features = map.queryRenderedFeatures(e.point, {
- layers: selectableLayers,
- }) as maplibregl.MapGeoJSONFeature[];
-
- if (!features.length) {
- clearSelection();
- return;
- }
-
- const additive = !!e.originalEvent?.altKey;
- selectFeature(pickPreferredFeature(features), additive);
- }
-
- // Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải.
- // Mở menu thao tác khi click phải lên feature.
- function onRightClick(e: maplibregl.MapLayerMouseEvent) {
- if (getMode() !== "select" && getMode() !== "replay") return;
- const selectableLayers = getSelectableLayers();
- if (!selectableLayers.length) return;
-
- e.preventDefault(); // block browser menu
- if (getMode() === "replay") return;
- if (isEditSessionActive?.()) return;
-
- const features = map.queryRenderedFeatures(e.point, {
- layers: selectableLayers,
- }) as maplibregl.MapGeoJSONFeature[];
-
- if (!features.length) return;
-
- const feature = pickPreferredFeature(features);
- const id = feature.id ?? feature.properties?.id;
- if (id === undefined || id === null) return;
-
- const isRightClickedItemAlreadySelected = Array.from(selectedIds).some(sid => String(sid) === String(id));
- const hasSelection = selectedIds.size > 0;
-
- // If the right-clicked item is not selected, and there is no active selection,
- // make it the sole selection. If there is an active selection, do not clear it
- // so we can bind the active selection to this target geometry.
- if (!isRightClickedItemAlreadySelected && !hasSelection) {
- clearSelection();
- selectFeature(feature, false);
- }
-
- showContextMenu(
- e.originalEvent?.clientX ?? e.point.x,
- e.originalEvent?.clientY ?? e.point.y,
- feature,
- isRightClickedItemAlreadySelected,
- hasSelection
- );
- }
-
- // Đổi cursor pointer khi hover lên đối tượng có thể chọn.
- function onMove(e: maplibregl.MapLayerMouseEvent) {
- if (getMode() !== "select" && getMode() !== "replay") return;
- const selectableLayers = getSelectableLayers();
- if (!selectableLayers.length) {
- map.getCanvas().style.cursor = "";
- return;
- }
-
- const features = map.queryRenderedFeatures(e.point, {
- layers: selectableLayers,
- });
-
- map.getCanvas().style.cursor = features.length ? "pointer" : "";
- }
-
- function getSelectableLayers(): string[] {
- const style = map.getStyle();
- if (!style || !style.layers) return [];
- return style.layers
- .filter((layer) =>
- "source" in layer &&
- typeof layer.source === "string" &&
- FEATURE_STATE_SOURCES.includes(layer.source as (typeof FEATURE_STATE_SOURCES)[number])
- )
- .map((layer) => layer.id);
- }
-
- function setSelectionStateForId(id: string | number, selected: boolean) {
- for (const source of FEATURE_STATE_SOURCES) {
- if (!map.getSource(source)) continue;
- map.setFeatureState({ source, id }, { selected });
- }
- }
-
- 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;
- }
-
- // Đồng bộ selection state từ React.
- function syncSelection(ids: (string | number)[]) {
- const nextSet = new Set(ids);
- selectedIds.forEach((id) => {
- if (!nextSet.has(id)) {
- setSelectionStateForId(id, false);
- }
- });
- selectedIds.clear();
- ids.forEach((id) => {
- setSelectionStateForId(id, true);
- selectedIds.add(id);
- });
- }
-
- map.on("click", onClick);
- map.on("mousemove", onMove);
- if (hasContextActions) {
- map.on("contextmenu", onRightClick);
- }
-
- const cleanup = () => {
- map.off("click", onClick);
- map.off("mousemove", onMove);
- if (hasContextActions) {
- map.off("contextmenu", onRightClick);
- }
- clearSelection(false);
- hideContextMenu();
- };
-
- return {
- cleanup,
- clearSelection,
- syncSelection,
- };
-
- // Ẩn và dọn dẹp context menu hiện tại.
- function hideContextMenu() {
- if (contextMenu) {
- contextMenu.remove();
- contextMenu = null;
- }
- if (docClickHandler) {
- document.removeEventListener("click", docClickHandler);
- docClickHandler = null;
- }
- }
-
- // Render menu ngữ cảnh tối giản gần vị trí con trỏ.
- function showContextMenu(
- x: number,
- y: number,
- clickedFeature: maplibregl.MapGeoJSONFeature,
- isRightClickedItemAlreadySelected: boolean,
- hasSelection: boolean
- ) {
- hideContextMenu();
-
- const menu = document.createElement("div");
- menu.style.position = "fixed";
- menu.style.left = `${x}px`;
- menu.style.top = `${y}px`;
- menu.style.background = "#0f172a";
- menu.style.color = "white";
- menu.style.border = "1px solid #1f2937";
- menu.style.borderRadius = "6px";
- menu.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)";
- menu.style.zIndex = "9999";
- menu.style.minWidth = "120px";
- menu.style.fontSize = "14px";
- menu.style.padding = "4px 0";
-
- // Tạo một item thao tác trong context menu.
- const createItem = (label: string, onClick: () => void) => {
- const item = document.createElement("div");
- item.textContent = label;
- item.style.padding = "8px 12px";
- item.style.cursor = "pointer";
- item.onmouseenter = () => (item.style.background = "#1f2937");
- item.onmouseleave = () => (item.style.background = "transparent");
- item.onclick = () => {
- onClick();
- hideContextMenu();
- };
- return item;
- };
-
- const selectedCount = selectedIds.size;
- let hasMenuItems = false;
-
- const effectiveCount = selectedCount || 1;
- const targetId = clickedFeature.id ?? clickedFeature.properties?.id;
- const isClickOutsideSelection = !isRightClickedItemAlreadySelected && hasSelection;
-
- if (isClickOutsideSelection && onBindGeometries && targetId !== undefined && targetId !== null) {
- const sourceIds = Array.from(selectedIds);
- menu.appendChild(
- createItem(
- `Bind ${selectedCount} geo đang chọn vào geo này`,
- () => {
- onBindGeometries(targetId, sourceIds);
- }
- )
- );
- hasMenuItems = true;
-
- const separator = document.createElement("div");
- separator.style.height = "1px";
- separator.style.background = "#374151";
- separator.style.margin = "4px 0";
- menu.appendChild(separator);
- }
-
- if (!isClickOutsideSelection) {
- if (
- effectiveCount === 1 &&
- clickedFeature.source === "countries" &&
- clickedFeature.geometry?.type === "Polygon" &&
- onEdit
- ) {
- const single = clickedFeature;
- menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
- hasMenuItems = true;
- }
-
- if (effectiveCount === 1 && onDuplicate && targetId !== undefined && targetId !== null) {
- menu.appendChild(createItem("Duplicate", () => onDuplicate(targetId)));
- hasMenuItems = true;
- }
-
- if (effectiveCount === 1 && onHide && targetId !== undefined && targetId !== null) {
- menu.appendChild(createItem("Hide", () => onHide(targetId)));
- hasMenuItems = true;
- }
- }
-
- if (onReplayEdit) {
- const replayId = isClickOutsideSelection ? Array.from(selectedIds)[0] : targetId;
- if (replayId !== undefined && replayId !== null) {
- menu.appendChild(
- createItem(
- effectiveCount > 1 ? `Vào replay (${effectiveCount} geo)` : "Vào replay",
- () => onReplayEdit(replayId)
- )
- );
- hasMenuItems = true;
- }
- }
-
- if (onDelete) {
- menu.appendChild(
- createItem(
- effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa",
- () => {
- const ids = selectedIds.size
- ? Array.from(selectedIds)
- : [targetId];
- ids.forEach((eachId) => {
- if (eachId !== undefined && eachId !== null) onDelete(eachId);
- });
- clearSelection();
- }
- )
- );
- hasMenuItems = true;
- }
-
- if (!hasMenuItems) return;
-
- document.body.appendChild(menu);
- contextMenu = menu;
-
- // Đóng menu khi click ra ngoài vùng menu.
- const onDocClick = (ev: MouseEvent) => {
- if (!menu.contains(ev.target as Node)) {
- hideContextMenu();
- }
- };
- docClickHandler = onDocClick;
- setTimeout(() => document.addEventListener("click", onDocClick), 0);
- }
-}
diff --git a/src/uhm/lib/map/styles/shared/pointStyle.ts b/src/uhm/lib/map/styles/shared/pointStyle.ts
index be29449..c9f6cda 100644
--- a/src/uhm/lib/map/styles/shared/pointStyle.ts
+++ b/src/uhm/lib/map/styles/shared/pointStyle.ts
@@ -17,8 +17,6 @@ export const POINT_GEOTYPE_IDS = [
export type PointGeotypeId = (typeof POINT_GEOTYPE_IDS)[number];
-type PointIconVariant = "default" | "draft";
-
type PointLayerOptions = {
iconScale?: number;
haloRadius?: number;
@@ -33,12 +31,9 @@ type PointStyleConfig = {
};
const TYPE_MATCH_EXPR: maplibregl.ExpressionSpecification = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""];
-const DRAFT_ENTITY_EXPR: maplibregl.ExpressionSpecification = ["==", ["coalesce", ["get", "entity_id"], ""], ""];
const SELECTED_EXPR: maplibregl.ExpressionSpecification = ["boolean", ["feature-state", "selected"], false];
const ICON_CANVAS_SIZE = 64;
-const DRAFT_FILL = "#ef4444";
-const DRAFT_RIM = "#7f1d1d";
const POINT_GEOMETRY_FILTER: maplibregl.ExpressionSpecification = [
"any",
["==", ["geometry-type"], "Point"],
@@ -156,7 +151,7 @@ export function buildPointGeotypeLayers(
source: pointSourceId,
filter: pointFilter(typeId),
layout: {
- "icon-image": pointIconExpression(typeId),
+ "icon-image": getPointIconId(typeId),
"icon-size": [
"interpolate",
["linear"],
@@ -201,13 +196,11 @@ export function ensurePointGeotypeIcons(map: maplibregl.Map): boolean {
if (typeof document === "undefined") return false;
for (const typeId of POINT_GEOTYPE_IDS) {
- for (const variant of ["default", "draft"] as const) {
- const iconId = getPointIconId(typeId, variant);
- if (map.hasImage(iconId)) continue;
- const imageData = createPointIconImageData(typeId, variant);
- if (!imageData) return false;
- map.addImage(iconId, imageData, { pixelRatio: 2 });
- }
+ const iconId = getPointIconId(typeId);
+ if (map.hasImage(iconId)) continue;
+ const imageData = createPointIconImageData(typeId);
+ if (!imageData) return false;
+ map.addImage(iconId, imageData, { pixelRatio: 2 });
}
return true;
@@ -217,19 +210,13 @@ function pointFilter(typeId: PointGeotypeId): maplibregl.ExpressionSpecification
return ["all", POINT_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]];
}
-function pointIconExpression(typeId: PointGeotypeId): maplibregl.ExpressionSpecification {
- return ["case", DRAFT_ENTITY_EXPR, getPointIconId(typeId, "draft"), getPointIconId(typeId, "default")];
+function getPointIconId(typeId: PointGeotypeId): string {
+ return `point-${typeId}`;
}
-function getPointIconId(typeId: PointGeotypeId, variant: PointIconVariant): string {
- return `point-${typeId}-${variant}`;
-}
-
-function createPointIconImageData(typeId: PointGeotypeId, variant: PointIconVariant): ImageData | null {
+function createPointIconImageData(typeId: PointGeotypeId): ImageData | null {
const config = POINT_STYLE_CONFIG[typeId];
- const palette = variant === "draft"
- ? { fill: DRAFT_FILL, rim: DRAFT_RIM }
- : { fill: config.fill, rim: config.rim };
+ const palette = { fill: config.fill, rim: config.rim };
const canvas = document.createElement("canvas");
canvas.width = ICON_CANVAS_SIZE;
diff --git a/src/uhm/lib/map/styles/shared/styleBuilders.ts b/src/uhm/lib/map/styles/shared/styleBuilders.ts
index 112459c..49614a2 100644
--- a/src/uhm/lib/map/styles/shared/styleBuilders.ts
+++ b/src/uhm/lib/map/styles/shared/styleBuilders.ts
@@ -1,17 +1,9 @@
import maplibregl, { LayerSpecification } from "maplibre-gl";
const TYPE_MATCH_EXPR: maplibregl.ExpressionSpecification = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""];
-const DRAFT_ENTITY_EXPR: maplibregl.ExpressionSpecification = [
- "all",
- ["==", ["coalesce", ["get", "entity_id"], ""], ""],
- ["!", ["has", "binding"]]
-];
const SELECTED_EXPR: maplibregl.ExpressionSpecification = ["boolean", ["feature-state", "selected"], false];
const SELECTED_COLOR = "#22c55e";
-const SELECTED_STROKE = "#14532d";
-const DRAFT_COLOR = "#ef4444";
-const DRAFT_STROKE = "#7f1d1d";
type ZoomStops = {
z1: number;
@@ -177,8 +169,6 @@ function statusColor(normalColor: string): maplibregl.ExpressionSpecification {
"case",
SELECTED_EXPR,
SELECTED_COLOR,
- DRAFT_ENTITY_EXPR,
- DRAFT_COLOR,
normalColor,
];
}
@@ -188,19 +178,12 @@ function statusStroke(normalColor: string): maplibregl.ExpressionSpecification {
"case",
SELECTED_EXPR,
SELECTED_COLOR,
- DRAFT_ENTITY_EXPR,
- DRAFT_STROKE,
normalColor,
];
}
-function statusFillColor(normalColor: string): maplibregl.ExpressionSpecification {
- return [
- "case",
- DRAFT_ENTITY_EXPR,
- DRAFT_COLOR,
- normalColor,
- ];
+function statusFillColor(normalColor: string): string {
+ return normalColor;
}
function lineFilter(typeId: string): maplibregl.ExpressionSpecification {
diff --git a/src/uhm/lib/utils/timeline.ts b/src/uhm/lib/utils/timeline.ts
index 61d4f55..52c103c 100644
--- a/src/uhm/lib/utils/timeline.ts
+++ b/src/uhm/lib/utils/timeline.ts
@@ -23,3 +23,18 @@ export function clampYearValue(year: number, minYear: number, maxYear: number):
export function clampYearToFixedRange(year: number): number {
return clampYearValue(year, FIXED_TIMELINE_START_YEAR, FIXED_TIMELINE_END_YEAR);
}
+
+export function normalizeTimelineYearValue(value: unknown): number | null {
+ if (typeof value === "number") {
+ return Number.isFinite(value) ? Math.trunc(value) : null;
+ }
+
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ if (!trimmed.length) return null;
+ const parsed = Number(trimmed);
+ return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
+ }
+
+ return null;
+}
diff --git a/src/uhm/store/editorStore.tsx b/src/uhm/store/editorStore.tsx
index d24dc37..95dd745 100644
--- a/src/uhm/store/editorStore.tsx
+++ b/src/uhm/store/editorStore.tsx
@@ -35,9 +35,9 @@ export type GeometryFocusRequest = {
};
type EditorStoreValues = {
- // Editor mode + draft seed.
+ // Editor mode + baseline FeatureCollection used to seed/reset useEditorState.
mode: EditorMode;
- initialData: FeatureCollection;
+ baselineFeatureCollection: FeatureCollection;
// Task flags; setTaskFlag ensures only one blocking task is active at a time.
isSaving: boolean;
isSubmitting: boolean;
@@ -54,7 +54,7 @@ type EditorStoreValues = {
baselineSnapshot: EditorSnapshot | null;
// Entity state: backend catalog plus snapshot-local rows and form/search status.
entityCatalog: Entity[];
- snapshotEntities: EntitySnapshot[];
+ snapshotEntityRows: EntitySnapshot[];
entityStatus: string | null;
selectedFeatureIds: FeatureId[];
entityForm: EntityFormState;
@@ -92,12 +92,13 @@ type EditorStoreValues = {
geometryFocusRequest: GeometryFocusRequest | null;
replayFeatureId: string | number | null;
hideOutside: boolean;
+ // Map visibility overrides keyed by either a geometry id or a semantic geo type key.
geometryVisibility: Record;
};
type EditorStoreActions = {
setMode: (next: SetStateAction) => void;
- setInitialData: (next: SetStateAction) => void;
+ setBaselineFeatureCollection: (next: SetStateAction) => void;
setIsSaving: (next: SetStateAction) => void;
setIsSubmitting: (next: SetStateAction) => void;
setIsOpeningSection: (next: SetStateAction) => void;
@@ -111,7 +112,7 @@ type EditorStoreActions = {
setProjectCommits: (next: SetStateAction) => void;
setBaselineSnapshot: (next: SetStateAction) => void;
setEntityCatalog: (next: SetStateAction) => void;
- setSnapshotEntities: (next: SetStateAction) => void;
+ setSnapshotEntityRows: (next: SetStateAction) => void;
setEntityStatus: (next: SetStateAction) => void;
setSelectedFeatureIds: (next: SetStateAction) => void;
setEntityForm: (next: SetStateAction) => void;
@@ -228,7 +229,7 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
return {
mode: "idle",
- initialData: options.emptyFeatureCollection,
+ baselineFeatureCollection: options.emptyFeatureCollection,
isSaving: false,
isSubmitting: false,
isOpeningSection: false,
@@ -242,7 +243,7 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
sectionCommits: [],
baselineSnapshot: null,
entityCatalog: [],
- snapshotEntities: [],
+ snapshotEntityRows: [],
entityStatus: null,
selectedFeatureIds: [],
entityForm: {
@@ -287,7 +288,7 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
hideOutside: false,
geometryVisibility: buildInitialGeometryVisibility(),
setMode: (next) => setValue("mode", next),
- setInitialData: (next) => setValue("initialData", next),
+ setBaselineFeatureCollection: (next) => setValue("baselineFeatureCollection", next),
setIsSaving: (next) => setTaskFlag("saving", next),
setIsSubmitting: (next) => setTaskFlag("submitting", next),
setIsOpeningSection: (next) => setTaskFlag("opening-project", next),
@@ -301,7 +302,7 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
setProjectCommits: (next) => setValue("sectionCommits", next),
setBaselineSnapshot: (next) => setValue("baselineSnapshot", next),
setEntityCatalog: (next) => setValue("entityCatalog", next),
- setSnapshotEntities: (next) => setValue("snapshotEntities", next),
+ setSnapshotEntityRows: (next) => setValue("snapshotEntityRows", next),
setEntityStatus: (next) => setValue("entityStatus", next),
setSelectedFeatureIds: (next) => setValue("selectedFeatureIds", next),
setEntityForm: (next) => setValue("entityForm", next),