refactor: undo feature cover every single part of editor
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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<UIOptionName>[];
|
||||
use_map_function: ReplayAction<MapFunctionName>[];
|
||||
use_geo_function: ReplayAction<GeoFunctionName>[];
|
||||
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
|
||||
};
|
||||
|
||||
type ReplayAction<T> = {
|
||||
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`.
|
||||
@@ -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.
|
||||
@@ -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ở.
|
||||
|
||||
@@ -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<featureId, Feature>` tạo từ `initialData`
|
||||
- `Map<featureId, Feature>` 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
+34
-25
@@ -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`
|
||||
|
||||
+13
-10
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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[]`.
|
||||
|
||||
Reference in New Issue
Block a user