feat: implement comprehensive map editing system with advanced geometry tools, replay management, and project session state modules.
Build and Release / release (push) Successful in 55s
Build and Release / release (push) Successful in 55s
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
# Editor (/editor) - Local Store & Snapshot Conversion
|
||||
|
||||
Tài liệu này mô tả chi tiết **các nơi lưu trữ state (store) ở phía FrontEndUser** trong `/editor/[id]`, ý nghĩa từng biến state, state nào là “single source of truth”, state nào chỉ là cache/UI, và cách chuyển đổi qua lại giữa:
|
||||
|
||||
1. **Local session state** (React state trong phiên làm việc)
|
||||
2. **Commit snapshot** (`commits.snapshot_json`)
|
||||
3. **Reload trang** (mất state local, load lại từ commit snapshot)
|
||||
|
||||
Mục tiêu: dễ debug, nhất quán dữ liệu, tránh sai semantics `"reference"`/`"binding"`.
|
||||
|
||||
---
|
||||
|
||||
## 0) 5 Dataset Quan Trọng Nhất (GEO/ENT/WIKI/ENT_WIKI/GEO_ENT)
|
||||
|
||||
Trong `/editor`, 5 nhóm dữ liệu quan trọng nhất tương ứng trực tiếp với snapshot:
|
||||
|
||||
1. **GEO**: `snapshot_json.geometries[]` + `snapshot_json.editor_feature_collection`
|
||||
2. **ENT**: `snapshot_json.entities[]`
|
||||
3. **WIKI**: `snapshot_json.wikis[]`
|
||||
4. **ENT_WIKI** (entity ↔ wiki): `snapshot_json.entity_wiki[]`
|
||||
5. **GEO_ENT** (geometry ↔ entity): `snapshot_json.geometry_entity[]`
|
||||
|
||||
Điểm quan trọng về “store”:
|
||||
|
||||
- **ENT/WIKI/ENT_WIKI** có store snapshot riêng trong React session:
|
||||
- `snapshotEntities` -> `entities[]`
|
||||
- `snapshotWikis` -> `wikis[]`
|
||||
- `snapshotEntityWikiLinks` -> `entity_wiki[]`
|
||||
|
||||
- **GEO/GEO_ENT không có store snapshot riêng theo kiểu `snapshotGeometries` / `snapshotGeometryEntity`**.
|
||||
- Trong session, GEO sống ở **`editor.draft`** (GeoJSON FeatureCollection).
|
||||
- Khi commit, FE **build ra**:
|
||||
- `geometries[]` từ `editor.draft + editor.changes + baselineSnapshot.geometries`
|
||||
- `geometry_entity[]` từ `editor.draft.features[].properties.entity_ids`
|
||||
|
||||
Vì vậy, nếu bạn “tìm store của geo trong React state” thì bạn sẽ thấy nó nằm ở `useEditorState()` chứ không nằm trong `useEditorSessionState()`.
|
||||
|
||||
---
|
||||
|
||||
## 1) Nguyên tắc chung
|
||||
|
||||
### 1.1 Single source of truth theo lớp
|
||||
|
||||
- **Geometry (map/editor):** `useEditorState(initialData)` là state trung tâm cho `draft/changes/undo`.
|
||||
- **Snapshot stores (phần sẽ đi vào commit snapshot):**
|
||||
- `snapshotEntities` -> `snapshot_json.entities`
|
||||
- `snapshotWikis` -> `snapshot_json.wikis`
|
||||
- `snapshotEntityWikiLinks` -> `snapshot_json.entity_wiki`
|
||||
- **Catalog/cache để tìm kiếm & hiển thị:**
|
||||
- `entityCatalog` là danh sách entity “global” trong RAM (fetch + search merge). Không phải snapshot.
|
||||
|
||||
### 1.2 “reference” vs “binding”
|
||||
|
||||
- `"reference"` (entities/wikis/geometries.operation) nghĩa là **không sửa record** trong commit đó.
|
||||
- `"binding"` (chỉ áp dụng cho `entity_wiki.operation`) nghĩa là **link entity ↔ wiki đang tồn tại** trong snapshot.
|
||||
- `"delete"` nghĩa là xóa record (entities/wikis/geometries) hoặc unlink (entity_wiki).
|
||||
|
||||
Khi **mở 1 phiên editor mới từ commit**, mọi operation local đều bị “reset về baseline”:
|
||||
|
||||
- `entities[].operation` và `wikis[].operation` trong session -> `"reference"`
|
||||
- `entity_wiki[].operation` trong session -> `"binding"` (nếu link còn active)
|
||||
|
||||
---
|
||||
|
||||
## 2) Local state: danh sách đầy đủ và ý nghĩa
|
||||
|
||||
Các state này được tạo từ `useEditorSessionState()` và `useEditorState()` trong:
|
||||
|
||||
- `FrontEndUser/src/app/editor/[id]/page.tsx`
|
||||
- `FrontEndUser/src/uhm/lib/useEditorSessionState.ts`
|
||||
- `FrontEndUser/src/uhm/lib/useEditorState.ts`
|
||||
|
||||
### 2.1 Geometry editor state (core)
|
||||
|
||||
Nguồn: `const editor = useEditorState(initialData)`
|
||||
|
||||
- `initialData: FeatureCollection`
|
||||
- Là **baseline** của session hiện tại để render Map ban đầu.
|
||||
- Được set khi:
|
||||
- mở project (load snapshot head),
|
||||
- restore FE-only từ 1 commit,
|
||||
- hoặc import/replace dữ liệu session.
|
||||
|
||||
- `editor.draft: FeatureCollection`
|
||||
- **Single source of truth** cho geometry đang hiển thị + chỉnh sửa.
|
||||
- Map render trực tiếp từ `draft` (hoặc bản “visibleDraft” đã filter theo timeline/bound_with).
|
||||
- Đây chính là **store runtime của GEO** trong session.
|
||||
|
||||
- `editor.changes: Map<id, Change>`
|
||||
- Diff giữa `draft` và baseline map nội bộ (initialMapRef).
|
||||
- Dùng để tính `pendingSaveCount` và để build snapshot geometries/update/delete.
|
||||
|
||||
- `editor.undoStack`
|
||||
- Danh sách thao tác gần nhất (create/update/properties/delete).
|
||||
|
||||
- `editor.changeCount`
|
||||
- Số lượng changes (để chặn commit khi không đổi gì).
|
||||
|
||||
- `editor.hasPersistedFeature(id)`
|
||||
- `true` nếu feature đã tồn tại trong baseline map nội bộ.
|
||||
- Dùng để phân biệt geometry mới khi build snapshot và hiển thị trạng thái `new`.
|
||||
|
||||
### 2.2 Snapshot stores (persisted on commit)
|
||||
|
||||
Các state này là “source of truth” cho những phần non-geometry trong commit snapshot.
|
||||
|
||||
#### a) `snapshotEntities: EntitySnapshot[]`
|
||||
|
||||
- Dùng để build `snapshot_json.entities`.
|
||||
- Bao gồm:
|
||||
- entity “pin” vào project (`source:"ref"`, `operation:"reference"`),
|
||||
- entity tạo mới local (`source:"inline"`, `operation:"create"`),
|
||||
- entity bị xóa (nếu có) (`operation:"delete"`).
|
||||
|
||||
Lưu ý quan trọng:
|
||||
|
||||
- `snapshotEntities` là nơi “giữ entity” **qua các commit**, kể cả entity tạo mới chưa bind geometry.
|
||||
- `buildEditorSnapshot()` có logic carry-forward inline entity từ `previousSnapshot` để tránh mất entity sau commit/reload.
|
||||
|
||||
#### b) `snapshotWikis: WikiSnapshot[]`
|
||||
|
||||
- Dùng để build `snapshot_json.wikis`.
|
||||
- Wiki hiện lưu `doc` là **string (HTML)** (Quill) hoặc `null` với ref wiki.
|
||||
- Tiptap JSON cũ: được normalize sang HTML để hiển thị.
|
||||
|
||||
#### c) `snapshotEntityWikiLinks: EntityWikiLinkSnapshot[]`
|
||||
|
||||
- Dùng để build `snapshot_json.entity_wiki`.
|
||||
- `operation`:
|
||||
- `"binding"`: link đang tồn tại
|
||||
- `"delete"`: unlink trong snapshot
|
||||
- (compat) `"reference"` từ snapshot cũ được normalize thành `"binding"` khi load.
|
||||
|
||||
### 2.3 Catalog/cache state (không persist)
|
||||
|
||||
#### `entityCatalog: Entity[]`
|
||||
|
||||
Đây là **RAM cache** để:
|
||||
|
||||
- hiển thị tên/description/status của entity,
|
||||
- merge kết quả fetch + search,
|
||||
- giảm tình trạng UI “cùng 1 entity nhưng 2 object khác nhau”.
|
||||
|
||||
Không ghi thẳng vào snapshot. Snapshot vẫn lấy từ `snapshotEntities`.
|
||||
|
||||
Trong page, danh sách `entities` dùng cho UI được merge:
|
||||
|
||||
`entities = mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities)`
|
||||
|
||||
Nghĩa là: snapshot entities (local) luôn được ưu tiên hiển thị trong UI.
|
||||
|
||||
### 2.4 UI-only state (không persist)
|
||||
|
||||
Các state sau chỉ phục vụ UX, mất khi reload:
|
||||
|
||||
- `mode` (idle/select/add-*)
|
||||
- `selectedFeatureId`
|
||||
- `selectedGeometryEntityIds` (list bind tạm thời cho UI, map patch sẽ sync vào feature properties)
|
||||
- `geometryMetaForm`
|
||||
- `entityForm` (tạo entity mới)
|
||||
- `entityFormStatus` (toast/status 3s)
|
||||
- `searchKind`, `searchQuery`
|
||||
- `entitySearchResults`, `wikiSearchResults`, `geoSearchResults`
|
||||
- `timelineDraftYear`, `timelineFilterEnabled`
|
||||
- panel widths (`leftPanelWidth`, `rightPanelWidth`)
|
||||
|
||||
### 2.5 LocalStorage (trên browser)
|
||||
|
||||
Hiện tại chỉ có **1 thứ** persist sang LocalStorage:
|
||||
|
||||
- `backgroundVisibility` (ẩn/hiện layer nền)
|
||||
|
||||
Các snapshot stores (`snapshotEntities`, `snapshotWikis`, `snapshotEntityWikiLinks`, `draft`) **không** lưu LocalStorage; chúng được persist qua commit snapshot (backend).
|
||||
|
||||
---
|
||||
|
||||
## 3) Chuyển đổi giữa local session ↔ snapshot
|
||||
|
||||
### 3.1 Load snapshot -> mở session
|
||||
|
||||
Luồng: `openSectionEditor()` -> `normalizeEditorSnapshot()` -> `toEditorSessionSnapshot()`
|
||||
|
||||
Khi mở session mới:
|
||||
|
||||
1. `baselineSnapshot = toEditorSessionSnapshot(snapshot)`
|
||||
2. `initialData = baselineSnapshot.editor_feature_collection || EMPTY_FEATURE_COLLECTION`
|
||||
3. `snapshotEntities = baselineSnapshot.entities || []`
|
||||
4. `snapshotWikis = baselineSnapshot.wikis || []`
|
||||
5. `snapshotEntityWikiLinks = baselineSnapshot.entity_wiki || []`
|
||||
|
||||
Riêng về GEO/GEO_ENT khi load:
|
||||
|
||||
- `baselineSnapshot.editor_feature_collection` là dữ liệu map gốc đưa vào `initialData`.
|
||||
- `normalizeEditorSnapshot()` sẽ **rehydrate** `feature.properties.entity_ids/entity_id` từ `snapshot.geometry_entity[]` (hoặc legacy `link_scopes`) để UI bind entity hoạt động.
|
||||
- Lưu ý: đây là rehydrate phục vụ editor UX, **không phải** dữ liệu persist chính thức trên `feature.properties` trong snapshot.
|
||||
|
||||
Điểm mấu chốt: **toEditorSessionSnapshot() reset operation** để snapshot trở thành “baseline state”:
|
||||
|
||||
- entities/wikis -> `"reference"`
|
||||
- entity_wiki active -> `"binding"`
|
||||
|
||||
### 3.2 Commit session -> snapshot_json
|
||||
|
||||
Luồng: `commitSection()` -> `buildEditorSnapshot({ draft, changes, snapshotEntities, snapshotWikis, snapshotEntityWikiLinks, previousSnapshot: baselineSnapshot })`
|
||||
|
||||
`buildEditorSnapshot()` sẽ tạo:
|
||||
|
||||
- `editor_feature_collection` (draft đã strip các field denormalized)
|
||||
- `geometries[]` (create/update/delete dựa trên changes + previousSnapshot)
|
||||
- `geometry_entity[]` (join table từ feature.properties.entity_ids)
|
||||
- `entities[]` (từ snapshotEntities + carry-forward inline + ensure entities referenced by joins)
|
||||
- `wikis[]` (từ snapshotWikis, tương tự)
|
||||
- `entity_wiki[]` (từ snapshotEntityWikiLinks, đã dedupe/sort)
|
||||
|
||||
Sau khi commit thành công:
|
||||
|
||||
- `baselineSnapshot` cập nhật = `toEditorSessionSnapshot(snapshot)` của commit mới
|
||||
- snapshot stores cập nhật theo baseline mới (operation reset về `"reference"/"binding"`)
|
||||
|
||||
### 3.3 Reload trang -> mất local state
|
||||
|
||||
Khi reload:
|
||||
|
||||
- Toàn bộ React state reset
|
||||
- App sẽ load lại snapshot từ backend (head commit)
|
||||
- Các thứ bạn “tạo/sửa” chỉ còn lại nếu đã nằm trong commit snapshot
|
||||
|
||||
Vì vậy:
|
||||
|
||||
- Entity/Wiki/Link/Geometry muốn “không mất” phải đi qua **Commit**.
|
||||
- Các state UI (selected geo, search results, form đang nhập) sẽ mất.
|
||||
|
||||
---
|
||||
|
||||
## 4) GEO Search (`/geometries/entity`) và tác động lên local store
|
||||
|
||||
Search GEO gọi:
|
||||
|
||||
`GET /geometries/entity?name=<keyword>&limit=<n>`
|
||||
|
||||
Khi bấm **Import** một geometry từ kết quả search:
|
||||
|
||||
1. Giữ nguyên `timelineFilterEnabled`; geometry import vẫn tuân theo filter năm hiện tại.
|
||||
2. Add entity tương ứng vào:
|
||||
- `snapshotEntities` (source:"ref", operation:"reference")
|
||||
- `entityCatalog` (để UI có name/description)
|
||||
3. Nếu geometry chưa có trong `editor.draft`:
|
||||
- tạo `Feature` mới với `id = geometry.id`
|
||||
- set `properties.type` từ `geo_type` (map qua `geoTypeCodeToTypeKey`)
|
||||
- set `time_start/time_end/bound_with`
|
||||
- set denormalized `entity_id/entity_ids/entity_name/entity_names` để UI/joins hoạt động
|
||||
4. `editor.createFeature(feature)` và auto select feature đó.
|
||||
|
||||
Lưu ý: Import geo tạo ra “create change” trong editor session, nên sẽ đi vào commit snapshot.
|
||||
|
||||
---
|
||||
|
||||
## 4.1 Nhìn nhanh “5 dataset nằm ở đâu” trong session
|
||||
|
||||
- GEO:
|
||||
- Runtime store: `editor.draft.features[]`
|
||||
- Persisted on commit: `snapshot_json.geometries[]` (build khi commit)
|
||||
|
||||
- ENT:
|
||||
- Runtime store (snapshot): `snapshotEntities`
|
||||
- Persisted on commit: `snapshot_json.entities[]`
|
||||
|
||||
- WIKI:
|
||||
- Runtime store (snapshot): `snapshotWikis`
|
||||
- Persisted on commit: `snapshot_json.wikis[]`
|
||||
|
||||
- ENT_WIKI:
|
||||
- Runtime store (snapshot): `snapshotEntityWikiLinks`
|
||||
- Persisted on commit: `snapshot_json.entity_wiki[]`
|
||||
|
||||
- GEO_ENT:
|
||||
- Runtime store: denormalized tạm thời trên `editor.draft.features[].properties.entity_ids` (để UI chạy)
|
||||
- Persisted on commit: `snapshot_json.geometry_entity[]` (build khi commit)
|
||||
|
||||
---
|
||||
|
||||
## 5) Checklist khi debug “mất dữ liệu”
|
||||
|
||||
1. Dữ liệu có nằm trong `snapshotEntities/snapshotWikis/snapshotEntityWikiLinks/editor.draft` không?
|
||||
2. Có bấm **Commit** chưa?
|
||||
3. `pendingSaveCount` có > 0 không (Commit button có enable không)?
|
||||
4. Khi reload, snapshot head commit load lên có chứa các rows đó không?
|
||||
5. Nếu entity tạo mới bị mất:
|
||||
- kiểm tra commit snapshot có `entities[].source:"inline"` không
|
||||
- nếu có mà reload vẫn mất, kiểm tra `normalizeEditorSnapshot()` có parse đúng không
|
||||
|
||||
---
|
||||
|
||||
## 6) File/entrypoints liên quan
|
||||
|
||||
- Session stores:
|
||||
- `FrontEndUser/src/uhm/lib/useEditorSessionState.ts`
|
||||
- `FrontEndUser/src/uhm/lib/editor/session/useEntitySessionState.ts`
|
||||
- `FrontEndUser/src/uhm/lib/editor/session/useWikiSessionState.ts`
|
||||
- `FrontEndUser/src/uhm/lib/editor/session/useSectionSessionState.ts`
|
||||
|
||||
- Geometry editor core:
|
||||
- `FrontEndUser/src/uhm/lib/useEditorState.ts`
|
||||
|
||||
- Snapshot normalization + build snapshot:
|
||||
- `FrontEndUser/src/uhm/lib/editor/snapshot/editorSnapshot.ts`
|
||||
|
||||
- Open/commit/restore commands:
|
||||
- `FrontEndUser/src/uhm/lib/editor/section/useSectionCommands.ts`
|
||||
|
||||
- Page wiring / UI state:
|
||||
- `FrontEndUser/src/app/editor/[id]/page.tsx`
|
||||
@@ -0,0 +1,72 @@
|
||||
# Thuật toán Băm Màu sắc từ ID (Color Hashing Algorithm)
|
||||
|
||||
Tài liệu này mô tả chi tiết giải thuật băm chuỗi định danh (ID) thành mã màu sắc HSL trong ứng dụng bản đồ lịch sử, nhằm giải quyết vấn đề trùng lặp màu sắc hiển thị giữa các thực thể/hình học.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vấn đề thực tế (Problem Statement)
|
||||
Trong các phiên bản trước, hàm băm chuỗi thành màu sử dụng giải thuật cộng dồn mã ký tự đơn giản:
|
||||
$$\text{hash} = \sum \text{char} + ((\text{hash} \ll 5) - \text{hash})$$
|
||||
Với độ bão hòa (Saturation) và độ sáng (Lightness) cố định ở mức `70%` và `50%`.
|
||||
|
||||
Cách tiếp cận này gặp phải điểm yếu nghiêm trọng khi xử lý **định danh tuần tự** (sequential IDs) hoặc các chuỗi ID gần giống nhau (ví dụ: các ID tự tăng như `1`, `2`, `3` hoặc các UUID chỉ khác nhau ký tự cuối):
|
||||
* Giải thuật băm cũ sinh ra các giá trị băm liên tiếp nhau (ví dụ: `1001`, `1002`, `1003`).
|
||||
* Khi chia lấy dư cho $360$ để tìm góc màu Hue, kết quả cho ra các góc màu liền kề (ví dụ: $201^\circ$, $202^\circ$, $203^\circ$).
|
||||
* Đối với mắt người, các góc màu quá sát nhau này hoàn toàn không thể phân biệt được, dẫn đến việc các quốc gia/vùng lãnh thổ/tuyến đường cạnh nhau bị hiển thị trùng một màu, gây hiểu nhầm dữ liệu lịch sử.
|
||||
|
||||
---
|
||||
|
||||
## 2. Giải pháp & Thuật toán Nâng cấp (Proposed Solution)
|
||||
|
||||
Để giải quyết triệt để vấn đề này, thuật toán mới đã được cải tiến thông qua hai kỹ thuật chính:
|
||||
|
||||
### A. Phân tán giá trị băm của Knuth (Knuth's Multiplicative Hashing)
|
||||
Sau bước băm ký tự ban đầu bằng DJB2 nâng cao (sử dụng XOR), giá trị băm sẽ được nhân với hằng số vàng của Knuth:
|
||||
$$A = 2654435761 \quad (\approx 2^{32} \times \frac{\sqrt{5} - 1}{2})$$
|
||||
Hằng số này hoạt động như một bộ xáo trộn bit (bit mixer). Hai giá trị băm ban đầu đứng cạnh nhau sau khi nhân với $2654435761$ và lấy trị tuyệt đối sẽ được phân tán đều khắp không gian số nguyên 32-bit. Điều này đảm bảo góc màu Hue giữa hai ID kề nhau sẽ có độ tương phản cực kỳ cao (ví dụ: góc màu lệch nhau từ $30^\circ$ tới $180^\circ$).
|
||||
|
||||
### B. Biến thiên Độ bão hòa (Saturation) và Độ sáng (Lightness)
|
||||
Thay vì cố định cứng $S = 70\%$ và $L = 50\%$, hai tham số này cũng được tính toán động từ giá trị băm phân tán:
|
||||
* **Saturation ($S$):** Dao động ngẫu nhiên trong khoảng $[70\%, 90\%]$.
|
||||
* **Lightness ($L$):** Dao động ngẫu nhiên trong khoảng $[45\%, 60\%]$.
|
||||
|
||||
Điều này giúp mở rộng không gian màu từ 1 chiều (chỉ thay đổi Hue) lên 3 chiều (thay đổi cả Hue, Saturation và Lightness), tạo ra hàng ngàn biến thể màu sắc độc nhất.
|
||||
|
||||
---
|
||||
|
||||
## 3. Mã Nguồn Triển khai (Implementation Code)
|
||||
|
||||
Hàm băm được đặt tại [mapUtils.ts](../components/map/mapUtils.ts):
|
||||
|
||||
```typescript
|
||||
export function hashStringToColor(str: string): string {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash * 33) ^ str.charCodeAt(i);
|
||||
}
|
||||
// Sử dụng hằng số nhân của Knuth để phân tán các mã băm kề nhau
|
||||
const scattered = Math.abs(hash * 2654435761);
|
||||
const hue = scattered % 360;
|
||||
|
||||
// Tự động biến thiên nhẹ độ bão hòa và độ sáng để tăng độ đa dạng màu
|
||||
const saturation = 70 + (scattered % 20); // 70% đến 90%
|
||||
const lightness = 45 + ((scattered >> 5) % 15); // 45% đến 60%
|
||||
|
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Ứng dụng trong Bản đồ (Map Application Context)
|
||||
|
||||
Hàm này được gọi tự động trong bộ lọc dữ liệu địa lý nhằm gán màu sắc trực quan cho các hình học không có màu chỉ định sẵn:
|
||||
* **Tuyến đường (Lines):** Gộp các `entity_ids` thành một chuỗi duy nhất, sắp xếp theo thứ tự bảng chữ cái để đảm bảo tính nhất quán, sau đó băm thành màu sắc của tuyến đường.
|
||||
* **Lãnh thổ/Vùng (Polygons):** Băm trực tiếp từ `geometry_id` của bản vẽ nháp hoặc thực thể để mỗi quốc gia/lãnh thổ có một màu sắc ranh giới trực quan riêng biệt.
|
||||
|
||||
---
|
||||
|
||||
## 5. Ưu điểm nổi bật (Key Benefits)
|
||||
1. **Độ tương phản cao (High Contrast):** Các thực thể có ID tuần tự nằm cạnh nhau trên bản đồ luôn hiển thị màu sắc tương phản rõ rệt.
|
||||
2. **Nhất quán (Deterministic):** Cùng một ID chuỗi đầu vào sẽ luôn trả về chính xác một mã màu duy nhất ở mọi thời điểm tải trang.
|
||||
3. **Thẩm mỹ hiện đại (Modern Aesthetics):** Giới hạn độ sáng trong khoảng $45\% - 60\%$ giúp giữ cho màu sắc luôn rực rỡ (neon-like), không bị quá tối ẩn vào nền bản đồ, cũng không bị quá sáng làm mất đi tính thẩm mỹ của giao diện tối (dark theme).
|
||||
@@ -0,0 +1,69 @@
|
||||
# UHM Editor - Luồng Dữ Liệu Commit và Load Snapshot (FrontEnd)
|
||||
|
||||
Tài liệu này giải thích chi tiết vòng đời của dữ liệu liên kết (binding) giữa **Geometry** và **Entity** trên FrontEnd (FE), từ lúc người dùng thao tác trên giao diện, đóng gói gửi lên API (Commit), cho đến khi tải ngược lại từ API về Editor (Load).
|
||||
|
||||
---
|
||||
|
||||
## 1. Sơ đồ tổng quan luồng dữ liệu
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[UI: Click Bind/Unbind] -->|Cập nhật| B(Editor Memory State: properties.entity_ids)
|
||||
B -->|Bấm Commit| C(buildEditorSnapshot)
|
||||
C -->|1. Xóa entity_ids khỏi GeoJSON<br>2. So sánh Baseline để tính operation: binding/delete/reference| D(Snapshot JSON phẳng)
|
||||
D -->|POST /projects/:id/commits| E[Backend: Lưu thô JSON]
|
||||
E -->|Mở Editor / Pull Commit| F(toEditorSessionSnapshot & normalizeEditorSnapshot)
|
||||
F -->|1. Lọc bỏ các dòng operation = delete<br>2. Ghép ngược entity_id vào GeoJSON properties| G(Editor Memory State mới)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Các giai đoạn chi tiết
|
||||
|
||||
### Giai đoạn 1: Editor Runtime State (Đang chỉnh sửa)
|
||||
Khi người dùng đang mở Editor, dữ liệu liên kết được lưu trực tiếp trong thuộc tính của đối tượng hình học (GeoJSON Feature Properties) để hiển thị nhanh trên bản đồ:
|
||||
- `feature.properties.entity_ids`: Mảng chứa các ID của thực thể đang liên kết (Ví dụ: `["entity-uuid-1", "entity-uuid-2"]`).
|
||||
- `feature.properties.entity_id`: ID của thực thể chính (thường là phần tử đầu tiên).
|
||||
|
||||
### Giai đoạn 2: Trước khi gửi Commit (Build Snapshot)
|
||||
Để đảm bảo cơ sở dữ liệu snapshot gọn nhẹ và không bị dư thừa dữ liệu (normalized database), hàm `buildEditorSnapshot()` sẽ thực hiện hai việc quan trọng:
|
||||
|
||||
1. **Xóa thuộc tính liên kết trong GeoJSON:**
|
||||
Trước khi lưu `editor_feature_collection`, FE sẽ **xóa sạch** các trường động như `entity_id`, `entity_ids`, `entity_name`, `entity_names` khỏi thuộc tính của Feature. Do đó, phần GeoJSON lưu trong snapshot **không chứa** thông tin liên kết thực thể.
|
||||
|
||||
2. **Chuyển đổi thành bảng liên kết phẳng (`geometry_entity[]`):**
|
||||
FE so sánh danh sách liên kết hiện tại trong draft với **Baseline** (dữ liệu của commit trước đó) để tính toán cờ hành động (`operation`) cho từng cặp liên kết:
|
||||
- **`binding` (Tạo mới):** Có trong draft hiện tại nhưng **không có** trong baseline.
|
||||
- **`reference` (Không đổi):** Có trong cả draft hiện tại và baseline.
|
||||
- **`delete` (Xóa bỏ):** **Có** trong baseline cũ nhưng **không còn** trong draft hiện tại.
|
||||
|
||||
### Giai đoạn 3: Gửi và lưu trữ trên Backend
|
||||
1. FE đóng gói snapshot này vào trường `snapshot_json` và gửi tới API: `POST /projects/{id}/commits`.
|
||||
2. Backend nhận được JSON này và lưu trữ nguyên vẹn vào cột `snapshot_json` của bảng `commits`.
|
||||
|
||||
*(Lưu ý: Backend hiện tại chưa xử lý cờ `delete` để cập nhật bảng liên kết vật lý `entity_geometries` dưới Database gốc khi duyệt commit, dẫn đến lỗi bất đồng bộ mà bạn đang gặp).*
|
||||
|
||||
### Giai đoạn 4: Tải Commit về (Load / Hydrate)
|
||||
Khi Editor được mở lại hoặc pull commit mới nhất về, FE nhận được `snapshot_json` từ API. Hàm `toEditorSessionSnapshot()` và `normalizeEditorSnapshot()` sẽ thực hiện ngược lại:
|
||||
|
||||
1. **Lọc bỏ dòng đã xóa:**
|
||||
Duyệt qua mảng `geometry_entity`, nếu gặp dòng có `operation === "delete"`, FE sẽ **bỏ qua ngay lập tức** (không nạp dòng này vào bộ nhớ baseline mới).
|
||||
|
||||
2. **Tái hợp nhất (Hydrate) vào GeoJSON:**
|
||||
Duyệt qua các liên kết hợp lệ còn lại, nhóm chúng theo `geometry_id`, sau đó gán ngược danh sách `entity_ids` vào các Feature GeoJSON tương ứng để Editor có thể vẽ liên kết lên bản đồ.
|
||||
|
||||
---
|
||||
|
||||
## 3. Bảng tóm tắt trạng thái các cờ `operation`
|
||||
|
||||
| Trạng thái liên kết | Draft hiện tại | Baseline trước đó | Cờ `operation` trong Snapshot | FE xử lý khi Load |
|
||||
| :--- | :---: | :---: | :---: | :--- |
|
||||
| **Liên kết cũ giữ nguyên** | Có | Có | `reference` | Nạp vào bộ nhớ |
|
||||
| **Tạo liên kết mới** | Có | Không | `binding` | Nạp vào bộ nhớ |
|
||||
| **Gỡ liên kết cũ (Unbind)** | Không | Có | `delete` | **Bỏ qua (Không nạp)** |
|
||||
|
||||
---
|
||||
|
||||
## 4. Tại sao cơ chế này đôi khi gây bối rối?
|
||||
- **Khác biệt giữa Snapshot JSON và DB gốc:** Snapshot JSON lưu trữ cả lịch sử thay đổi (cờ `delete`), trong khi Database gốc chỉ lưu trạng thái thực tế cuối cùng. Do đó, đọc trực tiếp API snapshot sẽ thấy dòng có cờ `delete`, nhưng chạy code FE hoặc DB đã duyệt thì liên kết đó phải mất đi.
|
||||
- **Tính một lần (One-time instruction):** Dòng `"delete"` chỉ xuất hiện **đúng 1 lần** trong snapshot của commit thực hiện hành động xóa. Ở commit tiếp theo, do baseline đã lọc bỏ dòng này từ trước, nên diff sẽ không sinh ra dòng `"delete"` đó nữa.
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Schema tham chiếu cho commit snapshot.
|
||||
*
|
||||
* Đây là file doc tự chứa, không import runtime types.
|
||||
* Mục tiêu là mô tả đúng shape dữ liệu hiện tại của editor/commit/replay
|
||||
* mà không phụ thuộc trực tiếp vào source code runtime.
|
||||
*
|
||||
* Ghi chú:
|
||||
* - Payload tạo commit hiện là `{ snapshot_json, edit_summary }`.
|
||||
* - `CommitSnapshot` hiện tương đương `EditorSnapshot`.
|
||||
* - Nhiều field root để optional vì frontend còn phải đọc snapshot cũ / partial.
|
||||
* - Replay actions trong dữ liệu thật dùng `params: unknown[]` theo positional tuple.
|
||||
* - Snapshot replay cũ còn `replay_features` sẽ được FE migrate sang `target_geometry_ids` khi load.
|
||||
* - Trước khi gửi API, frontend còn normalize thêm một số field, ví dụ
|
||||
* `time_start/time_end` và `geometries[].type`.
|
||||
*/
|
||||
|
||||
// ---- Root request ----
|
||||
|
||||
export type CreateCommitRequest = {
|
||||
snapshot_json: CommitSnapshot;
|
||||
edit_summary: string;
|
||||
};
|
||||
|
||||
// ---- GeoJSON / FeatureCollection ----
|
||||
|
||||
export type GeometryPreset = "line" | "polygon" | "circle-area" | "point";
|
||||
|
||||
export type Geometry =
|
||||
| ({ type: "Point"; coordinates: [number, number] } & CircleGeometryMetadata)
|
||||
| ({ type: "MultiPoint"; coordinates: [number, number][] } & CircleGeometryMetadata)
|
||||
| ({ type: "LineString"; coordinates: [number, number][] } & CircleGeometryMetadata)
|
||||
| ({ type: "MultiLineString"; coordinates: [number, number][][] } & CircleGeometryMetadata)
|
||||
| ({ type: "Polygon"; coordinates: [number, number][][] } & CircleGeometryMetadata)
|
||||
| ({ type: "MultiPolygon"; coordinates: [number, number][][][] } & CircleGeometryMetadata);
|
||||
|
||||
export type CircleGeometryMetadata = {
|
||||
circle_center?: [number, number];
|
||||
circle_radius?: number;
|
||||
};
|
||||
|
||||
export type FeatureId = string | number;
|
||||
|
||||
export type FeatureProperties = {
|
||||
id: FeatureId;
|
||||
source?: SnapshotSource;
|
||||
type?: string | null;
|
||||
geometry_preset?: GeometryPreset | null;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
bound_with?: string | null;
|
||||
|
||||
// UI/editor-only denormalized fields.
|
||||
entity_id?: string | null;
|
||||
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;
|
||||
polygon_label?: string | null;
|
||||
};
|
||||
|
||||
export type Feature = {
|
||||
type: "Feature";
|
||||
properties: FeatureProperties;
|
||||
geometry: Geometry;
|
||||
};
|
||||
|
||||
export type FeatureCollection = {
|
||||
type: "FeatureCollection";
|
||||
features: Feature[];
|
||||
};
|
||||
|
||||
// ---- Snapshot rows ----
|
||||
|
||||
export type SnapshotSource = "inline" | "ref";
|
||||
export type SnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||
|
||||
export type EntitySnapshotOperation = SnapshotOperation;
|
||||
export type GeometrySnapshotOperation = SnapshotOperation;
|
||||
export type WikiSnapshotOperation = SnapshotOperation;
|
||||
|
||||
export type EntitySnapshot = {
|
||||
id: string;
|
||||
source: SnapshotSource;
|
||||
operation?: EntitySnapshotOperation;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
};
|
||||
|
||||
export type GeometrySnapshot = {
|
||||
id: string;
|
||||
source: SnapshotSource;
|
||||
operation?: GeometrySnapshotOperation;
|
||||
type?: string | null;
|
||||
draw_geometry?: Geometry;
|
||||
geometry?: Geometry;
|
||||
bound_with?: string | null;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
bbox?: {
|
||||
min_lng: number;
|
||||
min_lat: number;
|
||||
max_lng: number;
|
||||
max_lat: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type GeometryEntitySnapshot = {
|
||||
geometry_id: string;
|
||||
entity_id: string;
|
||||
operation?: "reference" | "binding" | "delete";
|
||||
};
|
||||
|
||||
export type WikiDoc = string | null;
|
||||
|
||||
export type WikiSnapshot = {
|
||||
id: string;
|
||||
source: SnapshotSource;
|
||||
operation?: WikiSnapshotOperation;
|
||||
title: string;
|
||||
slug?: string | null;
|
||||
doc: WikiDoc;
|
||||
};
|
||||
|
||||
export type EntityWikiLinkSnapshot = {
|
||||
entity_id: string;
|
||||
wiki_id: string;
|
||||
operation?: "reference" | "binding" | "delete";
|
||||
};
|
||||
|
||||
// ---- Replay / Scripting System (runtime shape) ----
|
||||
|
||||
/**
|
||||
* Canonical UI action names trong snapshot hiện tại.
|
||||
* Không còn wrapper `function_name: "UI"` trong shape mới.
|
||||
*/
|
||||
export type DialogState = {
|
||||
text: string; // Subtitle / spoken narrative text
|
||||
image_url?: string; // Optional image URL
|
||||
};
|
||||
|
||||
export type UIOptionName =
|
||||
| "timeline"
|
||||
| "layer_panel"
|
||||
| "zoom_panel"
|
||||
| "wiki"
|
||||
|
||||
export type MapFunctionName =
|
||||
| "set_camera_view"
|
||||
| "set_labels_visible";
|
||||
|
||||
export type GeoFunctionName =
|
||||
| "fly_to_geometries"
|
||||
| "set_geometry_visibility"
|
||||
| "follow_geometries_path"
|
||||
| "hide_others_geometries"
|
||||
| "pulse_geometry"
|
||||
| "animate_dashed_border"
|
||||
| "set_geometry_style"
|
||||
| "orbit_camera_around_geometry"
|
||||
| "set_as_background_geometries"
|
||||
| "remove_from_background_geometries";
|
||||
|
||||
export type NarrativeFunctionName =
|
||||
| "set_dialog";
|
||||
|
||||
/**
|
||||
* Runtime thật hiện dùng positional array cho params.
|
||||
* File doc này giữ đúng shape đó.
|
||||
*/
|
||||
export type ReplayAction<T> = {
|
||||
function_name: T;
|
||||
params: unknown[];
|
||||
};
|
||||
|
||||
export type ReplayStep = {
|
||||
duration: number;
|
||||
use_UI_function: ReplayAction<UIOptionName>[];
|
||||
use_map_function: ReplayAction<MapFunctionName>[];
|
||||
use_geo_function: ReplayAction<GeoFunctionName>[];
|
||||
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
|
||||
};
|
||||
|
||||
export type ReplayStage = {
|
||||
id: number;
|
||||
title?: string;
|
||||
detail_time_start: string;
|
||||
detail_time_stop: string;
|
||||
steps: ReplayStep[];
|
||||
};
|
||||
|
||||
export type BattleReplay = {
|
||||
id: string;
|
||||
geometry_id: string;
|
||||
target_geometry_ids: string[];
|
||||
detail: ReplayStage[];
|
||||
};
|
||||
|
||||
// ---- Replay tuple docs ----
|
||||
|
||||
/**
|
||||
* Doc-only helper để giải thích meaning của từng vị trí trong `params`.
|
||||
* Runtime không ép các tuple này; chúng chỉ là tài liệu tham chiếu.
|
||||
*/
|
||||
|
||||
export type ReplayCameraViewStateDoc = {
|
||||
center?: [number, number] | { lng: number; lat: number };
|
||||
zoom?: number;
|
||||
pitch?: number;
|
||||
bearing?: number;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
export type ReplayUiParamTupleDocs = {
|
||||
timeline: [visible: boolean];
|
||||
layer_panel: [visible: boolean];
|
||||
zoom_panel: [visible: boolean];
|
||||
wiki: [wiki_id: string | null];
|
||||
};
|
||||
|
||||
/**
|
||||
* Snapshot cũ kiểu `function_name: "UI"` chỉ còn là legacy input.
|
||||
* Frontend hiện normalize chúng sang `function_name: UIOptionName` khi load.
|
||||
*/
|
||||
|
||||
export type ReplayMapFunctionParamTupleDocs = {
|
||||
set_camera_view: [state: ReplayCameraViewStateDoc];
|
||||
set_labels_visible: [visible: boolean];
|
||||
};
|
||||
|
||||
export type ReplayGeoFunctionParamTupleDocs = {
|
||||
fly_to_geometries: [geometry_ids: string[], duration?: number];
|
||||
set_geometry_visibility: [geometry_ids: string[], visible: boolean];
|
||||
follow_geometries_path: [
|
||||
geometry_ids: string[],
|
||||
duration?: number,
|
||||
zoom?: number,
|
||||
pitch?: number,
|
||||
];
|
||||
hide_others_geometries: [
|
||||
geometry_ids: string[],
|
||||
];
|
||||
pulse_geometry: [ //beta feature
|
||||
geometry_id: string,
|
||||
color?: string,
|
||||
repeat?: number,
|
||||
duration?: number,
|
||||
];
|
||||
animate_dashed_border: [//beta feature
|
||||
geometry_id: string,
|
||||
color?: string,
|
||||
width?: number,
|
||||
speed?: number,
|
||||
duration?: number,
|
||||
];
|
||||
set_geometry_style: [//beta feature
|
||||
geometry_ids: string[],
|
||||
fill_color?: string,
|
||||
fill_opacity?: number,
|
||||
line_color?: string,
|
||||
line_width?: number,
|
||||
];
|
||||
orbit_camera_around_geometry: [//beta feature
|
||||
geometry_id: string,
|
||||
zoom?: number,
|
||||
pitch?: number,
|
||||
revolutions?: number,
|
||||
duration?: number,
|
||||
];
|
||||
set_as_background_geometries: [
|
||||
geometry_ids: string[],
|
||||
];
|
||||
remove_from_background_geometries: [
|
||||
geometry_ids: string[],
|
||||
];
|
||||
};
|
||||
|
||||
export type ReplayNarrativeParamTupleDocs = {
|
||||
set_dialog: [dialog: DialogState | null];
|
||||
};
|
||||
|
||||
export type ReplayParamTupleDocs =
|
||||
& ReplayUiParamTupleDocs
|
||||
& ReplayMapFunctionParamTupleDocs
|
||||
& ReplayGeoFunctionParamTupleDocs
|
||||
& ReplayNarrativeParamTupleDocs;
|
||||
|
||||
export type ReplayActionTupleDoc<T extends keyof ReplayParamTupleDocs> = {
|
||||
function_name: T;
|
||||
params: ReplayParamTupleDocs[T];
|
||||
};
|
||||
|
||||
// ---- Snapshot root ----
|
||||
|
||||
export type EditorSnapshot = {
|
||||
// Legacy snapshots có thể còn field project embedded.
|
||||
project?: {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
editor_feature_collection?: FeatureCollection;
|
||||
entities?: EntitySnapshot[];
|
||||
geometries?: GeometrySnapshot[];
|
||||
geometry_entity?: GeometryEntitySnapshot[];
|
||||
wikis?: WikiSnapshot[];
|
||||
entity_wiki?: EntityWikiLinkSnapshot[];
|
||||
replays?: BattleReplay[];
|
||||
};
|
||||
|
||||
export type CommitSnapshot = EditorSnapshot;
|
||||
@@ -0,0 +1,203 @@
|
||||
# UHM Editor - developer guide thực dụng
|
||||
|
||||
Tài liệu này dành cho người sửa editor hiện tại, không phải mô tả kiến trúc lý tưởng.
|
||||
|
||||
## 1. Entry points quan trọng
|
||||
|
||||
- `src/app/editor/[id]/page.tsx`
|
||||
- orchestration chính của project editor
|
||||
- `src/uhm/components/Map.tsx`
|
||||
- container cho map và các hook map
|
||||
- `src/uhm/lib/editor/state/useEditorState.ts`
|
||||
- draft geometry + diff + undo
|
||||
- `src/uhm/lib/editor/state/useEditorSessionState.ts`
|
||||
- session/UI/project/wiki/entity state
|
||||
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
|
||||
- normalize snapshot từ backend và build snapshot gửi ngược lại backend
|
||||
|
||||
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/`
|
||||
- panel UI bên trái/phải
|
||||
- `src/uhm/components/wiki/`
|
||||
- wiki editor và wiki viewer/sidebar
|
||||
- `src/uhm/components/map/`
|
||||
- hooks tích hợp MapLibre
|
||||
- `src/uhm/lib/map/engines/`
|
||||
- logic interaction theo mode
|
||||
- `src/uhm/lib/editor/session/`
|
||||
- các nhóm session state
|
||||
- `src/uhm/lib/editor/draft/`
|
||||
- draft diff và undo
|
||||
- `src/uhm/lib/editor/snapshot/`
|
||||
- schema conversion / snapshot semantics
|
||||
|
||||
## 3. Cách editor thật sự vận hành
|
||||
|
||||
Editor có 3 tầng dữ liệu:
|
||||
|
||||
1. `baselineSnapshot`
|
||||
- snapshot gốc của session
|
||||
2. `baselineFeatureCollection`
|
||||
- `FeatureCollection` rehydrate từ snapshot đó
|
||||
- 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ừ `mainDraft`
|
||||
- entity/wiki/link đi từ snapshot collections
|
||||
- `buildEditorSnapshot()` quyết định operation nào là `reference`, `binding`, `update`, `delete`
|
||||
|
||||
Đừng tự build payload ở component nếu chưa hiểu file `editorSnapshot.ts`.
|
||||
|
||||
## 4. Khi thêm mode/tool mới
|
||||
|
||||
Checklist an toàn:
|
||||
|
||||
1. Thêm mode vào `sessionTypes.ts`.
|
||||
2. Thêm button vào `ToolsPanel.tsx`.
|
||||
3. Nếu mode cần preview source/layer mới, thêm vào `setupMapLayers()`.
|
||||
4. Nối mode với engine trong `useMapInteraction.ts`.
|
||||
5. Nếu tool tạo geometry mới, chọn default:
|
||||
- `type`
|
||||
- `geometry_preset`
|
||||
- `entity_ids`
|
||||
- `bound_with`
|
||||
6. Kiểm tra interaction cleanup khi chuyển mode.
|
||||
|
||||
Nếu mode chưa được cleanup đúng, map rất dễ giữ preview cũ hoặc event listener cũ.
|
||||
|
||||
## 5. Khi thêm geotype mới
|
||||
|
||||
Checklist ngắn:
|
||||
|
||||
1. Cập nhật `geoTypeMap` nếu cần mapping backend code <-> key.
|
||||
2. Cập nhật `geometryTypeOptions.ts`.
|
||||
3. Tạo style file trong `styles/geotypes/`.
|
||||
4. Register ở `geotypeLayers.ts`.
|
||||
5. Kiểm tra point icon hoặc label pipeline nếu type mới là point/route/polygon label.
|
||||
|
||||
Nếu chỉ sửa `geometryTypeOptions.ts` mà quên style registry, UI sẽ cho chọn type nhưng map không render đúng.
|
||||
|
||||
## 6. Khi sửa snapshot semantics
|
||||
|
||||
File quan trọng nhất là `editorSnapshot.ts`.
|
||||
|
||||
Ở đó đang có hai hướng xử lý khác nhau:
|
||||
|
||||
- `normalizeEditorSnapshot(raw)`
|
||||
- đọc payload từ backend
|
||||
- rehydrate fields UI như `entity_ids`, `entity_name`, `bound_with`, `time_start`, `time_end`
|
||||
- `buildEditorSnapshot(options)`
|
||||
- strip các field generate-only khỏi `editor_feature_collection`
|
||||
- build `geometry_entity[]` và `entity_wiki[]`
|
||||
- tính operation phù hợp
|
||||
|
||||
Nguyên tắc:
|
||||
|
||||
- feature trong editor có thể mang field denormalized để UI dễ dùng
|
||||
- payload gửi backend thì không nên mang những field denormalized đó
|
||||
|
||||
## 7. Khi sửa wiki editor
|
||||
|
||||
Wiki project editor hiện là Quill, không phải Tiptap.
|
||||
|
||||
Các file nên đọc trước:
|
||||
|
||||
- `WikiSidebarPanel.tsx`
|
||||
- `PublicWikiSidebar.tsx`
|
||||
|
||||
Các điểm dễ làm hỏng:
|
||||
|
||||
- sanitize link của Quill
|
||||
- compatibility với doc dạng HTML/plain text
|
||||
- slug links nội bộ
|
||||
- sentinel `__missing__`
|
||||
|
||||
Nếu thay storage format, phải sửa cả editor lẫn viewer compatibility path.
|
||||
|
||||
## 8. Những key localStorage thật sự đang dùng
|
||||
|
||||
- `uhm.backgroundLayerVisibility.v1`
|
||||
- `uhm:mapProjection`
|
||||
|
||||
Hiện không có local draft autosave toàn editor.
|
||||
Đừng dựa vào doc cũ hoặc giả định rằng F5 sẽ hồi lại draft geometry/wiki/entity.
|
||||
|
||||
## 9. Restore commit hiện là FE-only
|
||||
|
||||
`CommitHistoryPanel -> Restore`:
|
||||
|
||||
- load snapshot từ commit cũ
|
||||
- reset editor state ở frontend
|
||||
- không đổi head commit trên backend
|
||||
|
||||
Nếu muốn restore server-side thật, cần dùng endpoint backend riêng và sửa cả UI wording.
|
||||
|
||||
## 10. Pending submission lock là rule thật
|
||||
|
||||
`openSectionEditor()` chủ động chặn project có `PENDING` submission.
|
||||
|
||||
Nghĩa là:
|
||||
|
||||
- không nên "lách" UI để cho sửa tiếp
|
||||
- nếu đổi behavior này, phải thống nhất với backend contract
|
||||
|
||||
## 11. Performance và state hygiene
|
||||
|
||||
Một số nguyên tắc nên giữ:
|
||||
|
||||
- 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
|
||||
- khi thiết kế các chế độ preview, đảm bảo khôi phục camera view state & projection (Globe/Flat) về trạng thái gốc của editor bằng cách dùng `editorOriginalMapViewStateRef` và calling `restoreEditorOriginalMapState()`.
|
||||
|
||||
## 12. Chỗ dễ gây hiểu nhầm khi debug
|
||||
|
||||
### Geometry biến mất
|
||||
|
||||
Có thể do:
|
||||
|
||||
- timeline filter
|
||||
- geometry visibility theo type
|
||||
- bound_with filter
|
||||
|
||||
Không phải lúc nào cũng là bug render layer.
|
||||
|
||||
### Commit count lạ
|
||||
|
||||
`Commit (N)` là `pendingSaveCount`, không phải số mutation backend.
|
||||
|
||||
### Selection mất
|
||||
|
||||
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
|
||||
|
||||
Ít nhất nên test thủ công:
|
||||
|
||||
1. mở project có commit cũ
|
||||
2. tạo geometry mới bằng mode liên quan
|
||||
3. sửa metadata geometry
|
||||
4. bind entity và geometry
|
||||
5. tạo/sửa wiki
|
||||
6. link entity-wiki
|
||||
7. commit
|
||||
8. restore từ commit cũ
|
||||
9. mở project có pending submission nếu đang debug flow đó
|
||||
@@ -0,0 +1,109 @@
|
||||
# Cơ Chế Hút Điểm Khi Vẽ (Snapping Precision)
|
||||
|
||||
Tài liệu này giải thích chi tiết về cơ chế hoạt động của tính năng hút điểm (**Snapping**) khi người dùng vẽ hoặc chỉnh sửa các đối tượng địa lý (Point, LineString, Polygon) trên bản đồ lịch sử. Đồng thời so sánh giải thuật cũ (Pixel Space) và giải thuật mới (LngLat Space) để đảm bảo độ chính xác tuyệt đối ở mọi mức Zoom.
|
||||
|
||||
---
|
||||
|
||||
## 1. Giới thiệu Tính năng Snapping
|
||||
Khi vẽ biên giới, tuyến đường hành quân hoặc định vị địa điểm, người dùng có thể nhấn giữ phím **`Shift`** hoặc **`Alt`** để tự động hút điểm vẽ hiện tại vào các đối tượng địa lý sẵn có trên bản đồ (như bờ biển, biên giới quốc gia lân cận, hoặc các điểm di tích).
|
||||
|
||||
Tính năng này giúp:
|
||||
- Tránh các khe hở (gaps) hoặc chồng chéo (overlaps) giữa các vùng lãnh thổ giáp ranh.
|
||||
- Giảm thiểu thời gian và công sức vẽ tay thủ công khi đi theo các đường biên phức tạp.
|
||||
|
||||
---
|
||||
|
||||
## 2. Vấn đề của giải thuật cũ (Pixel Space)
|
||||
|
||||
### Cách thức hoạt động cũ:
|
||||
1. Chiếu các đỉnh của bản đồ gốc từ Kinh/Vĩ độ (`LngLat`) lên hệ tọa độ màn hình (`Pixel` - $x, y$).
|
||||
2. Tìm điểm pixel gần nhất trên đoạn thẳng pixel tương ứng với con trỏ chuột.
|
||||
3. Giải chiếu ngược (`map.unproject`) điểm pixel đó thành tọa độ địa lý `LngLat` để lưu trữ.
|
||||
|
||||
### Nhược điểm:
|
||||
* **Sai số tỉ lệ theo mức Zoom:** Ở mức zoom nhỏ (nhìn từ xa), một đoạn biên giới ngoài đời thực dài hàng trăm kilômét chỉ hiển thị ngắn ngủi vài pixel trên màn hình.
|
||||
* **Lệch tọa độ khi phóng to:** Sai số làm tròn pixel lúc vẽ ở zoom nhỏ sẽ phóng đại lên thành sai số hàng nghìn mét ngoài thực địa khi người dùng phóng to bản đồ (zoom lớn). Hai quốc gia giáp ranh vẽ ở zoom nhỏ sẽ bị hở hoặc đè lên nhau khi zoom cận cảnh.
|
||||
|
||||
---
|
||||
|
||||
## 3. Giải thuật mới: Tính toán trên không gian Kinh/Vĩ độ gốc (LngLat Space)
|
||||
|
||||
Để khắc phục hoàn toàn hiện tượng lệch tọa độ, giải thuật mới kết hợp cả **Pixel Space** (để lọc tương tác) và **LngLat Space** (để tính toán tọa độ chốt).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Người dùng di chuột + giữ Shift] --> B(Tìm các đối tượng gần nhất trong bán kính Pixel màn hình)
|
||||
B --> C(Xác định đoạn thẳng AB mục tiêu trên màn hình)
|
||||
C --> D{Tính toán tọa độ điểm hút}
|
||||
D -->|Giải thuật cũ| E(Tính điểm gần nhất trên Pixel màn hình -> Giải chiếu ngược LngLat)
|
||||
D -->|Giải thuật mới| F(Nội suy tuyến tính LngLat trực tiếp từ tọa độ gốc của A & B)
|
||||
E --> G(Bị lệch tọa độ khi phóng to bản đồ)
|
||||
F --> H(Chính xác tuyệt đối 100% ở mọi mức Zoom)
|
||||
```
|
||||
|
||||
### Các bước thực hiện chi tiết:
|
||||
|
||||
1. **Bước 1: Lọc đối tượng gần màn hình**
|
||||
Hệ thống sử dụng khoảng cách pixel để xác định đối tượng mà người dùng đang trỏ tới (ví dụ: nằm trong phạm vi `24px` đến `34px` trên màn hình). Điều này đảm bảo tính năng hoạt động đúng theo cảm quan của mắt người dùng.
|
||||
|
||||
2. **Bước 2: Xác định đoạn thẳng mục tiêu**
|
||||
Hệ thống xác định đoạn thẳng nối hai đỉnh gốc $A(lng_A, lat_A)$ và $B(lng_B, lat_B)$ của đối tượng đích.
|
||||
|
||||
3. **Bước 3: Chiếu điểm trực tiếp trên không gian LngLat**
|
||||
Để tính toán chính xác điểm gần nhất $C(lng_C, lat_C)$ nằm trên đoạn thẳng $AB$, hệ thống thực hiện phép chiếu vector trong không gian tọa độ địa lý địa phương, có bù trừ độ cong kinh tuyến dựa vào vĩ độ trung bình ($\cos(lat)$):
|
||||
|
||||
$$\text{lat}_{\text{rad}} = \frac{lat_A + lat_B + lat_{\text{cursor}}}{3} \times \frac{\pi}{180}$$
|
||||
$$\text{cos}_{\text{lat}} = \cos(\text{lat}_{\text{rad}})$$
|
||||
|
||||
Chuyển đổi tạm thời sang hệ tọa độ phẳng cục bộ:
|
||||
$$x_A = lng_A \times \text{cos}_{\text{lat}}, \quad y_A = lat_A$$
|
||||
$$x_B = lng_B \times \text{cos}_{\text{lat}}, \quad y_B = lat_B$$
|
||||
$$x_P = lng_{\text{cursor}} \times \text{cos}_{\text{lat}}, \quad y_P = lat_{\text{cursor}}$$
|
||||
|
||||
Tính tham số nội suy $t$ ($0 \le t \le 1$) của điểm hình chiếu trên đoạn thẳng $AB$:
|
||||
$$dx = x_B - x_A, \quad dy = y_B - y_A$$
|
||||
$$t = \max\left(0, \min\left(1, \frac{(x_P - x_A)dx + (y_P - y_A)dy}{dx^2 + dy^2}\right)\right)$$
|
||||
|
||||
Nội suy tọa độ LngLat chính xác của điểm chốt:
|
||||
$$lng_C = a[0] + (b[0] - a[0]) \times t$$
|
||||
$$lat_C = a[1] + (b[1] - a[1]) \times t$$
|
||||
|
||||
---
|
||||
|
||||
## 4. Ưu điểm vượt trội của Giải thuật mới
|
||||
* **Độ chính xác tuyệt đối:** Điểm chốt luôn nằm **collinear (thẳng hàng/nội suy tuyến tính)** hoàn hảo giữa hai đỉnh $A$ and $B$ gốc của bản đồ với độ chính xác số thực dấu phẩy động 64-bit.
|
||||
* **Độc lập với Zoom:** Dù bạn vẽ ở Zoom nhỏ nhất (mức 2 - toàn cầu) hay Zoom lớn nhất (mức 18 - cận cảnh), đường vẽ mới vẫn sẽ khít khịt với đường biên cũ mà không xuất hiện bất kỳ sai số hay khe hở nào.
|
||||
* **Tối ưu trải nghiệm:** Người dùng có thể bao quát toàn bộ bản đồ quốc gia lớn để vẽ nhanh mà vẫn đạt được độ chuẩn xác tuyệt đối như khi phóng to cận cảnh để chỉnh sửa.
|
||||
|
||||
---
|
||||
|
||||
## 5. Chỉ báo Màu sắc Hút điểm (Snapping Color Indicators)
|
||||
Để tăng tính tương tác và giúp người dùng kiểm soát chính xác điểm vẽ đang hút vào đâu, hệ thống tự động đổi màu sắc của **đỉnh đang được kéo** (dragged handle) trong chế độ chỉnh sửa:
|
||||
|
||||
| Trạng thái Snap | Màu sắc hiển thị | Mã màu HEX | Ý nghĩa |
|
||||
| :--- | :---: | :---: | :--- |
|
||||
| **Hút vào Đỉnh (Vertex)** | Xanh lá | `#22c55e` | Điểm đang kéo trùng khít với một đỉnh mốc cũ của đối tượng địa lý khác. |
|
||||
| **Hút vào Cạnh (Edge)** | Vàng | `#eab308` | Điểm đang kéo nằm hoàn hảo trên đường nối giữa hai đỉnh của đối tượng địa lý khác. |
|
||||
| **Không hút (None)** | Xanh dương | `#3b82f6` | Điểm đang kéo tự do, không dính vào bất kỳ đối tượng nào. |
|
||||
| **Chế độ xóa hàng loạt** | Đỏ | `#ef4444` | Toàn bộ các đỉnh chuyển sang màu đỏ khi bạn bật chế độ xóa đỉnh bằng phím `Delete`. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Tính Năng Tự Động Bám Biên (Auto-Tracing)
|
||||
|
||||
Khi vẽ bản đồ lịch sử, việc copy hoặc chạy dọc theo biên giới có sẵn của quốc gia láng giềng là cực kỳ thường gặp. Thay vì phải click thủ công từng đỉnh, hệ thống hỗ trợ **Auto-Tracing** (Bắt và chạy theo biên giới) với quy trình tối giản và chính xác.
|
||||
|
||||
### Cách thức hoạt động:
|
||||
1. **Bật Snapping:** Nhấn giữ **`Shift`** khi click để đặt điểm bắt đầu trên biên giới (Point 1).
|
||||
2. **Kích hoạt Trace:** Nhấn tổ hợp **`Shift + T`** và click chọn điểm bắt đầu để hệ thống hiểu bạn muốn bắt đầu một chuỗi trace.
|
||||
3. **Xem trước (Preview):** Di chuột đến điểm kết thúc mong muốn (Point 2) trên cùng quốc gia đó. Một đường vẽ nháp **màu vàng hổ phách (`#eab308`)** sẽ tự động chạy dọc theo biên giới để bạn xem trước.
|
||||
4. **Chốt Trace:** Click chuột để chốt. Toàn bộ các đỉnh trung gian sẽ lập tức được chèn vào hình vẽ của bạn. Đường biên giới đã trace xong cũng sẽ giữ nguyên **màu vàng** để phân biệt với phần vẽ tự do (màu xanh lá).
|
||||
|
||||
### Thuật toán Dò hướng tối ưu (Area-based Splitting):
|
||||
Khi chọn 2 điểm trên một đa giác khép kín (Polygon), biên giới sẽ chia đa giác làm 2 con đường (xuôi và ngược chiều kim đồng hồ). Để xác định chính xác người dùng muốn đi đường nào:
|
||||
1. Hệ thống tạo ra 2 đa giác phụ khép kín tương ứng với 2 con đường bằng cách nối thẳng điểm bắt đầu và điểm kết thúc.
|
||||
2. Tính diện tích của cả 2 đa giác phụ này.
|
||||
3. Chọn con đường thuộc đa giác phụ **có diện tích nhỏ hơn** (vì đường biên cần copy luôn là một lát cắt nhỏ của quốc gia, đa giác phụ chứa nó sẽ nhỏ hơn rất nhiều so với phần còn lại của quốc gia láng giềng).
|
||||
|
||||
### Quay lại bước trước (Undo/Backspace):
|
||||
* Khi nhấn **`Backspace`** sau khi thực hiện trace, hệ thống sẽ **xóa hàng loạt** tất cả các đỉnh trung gian được copy của lượt trace đó, đưa hình vẽ quay trở lại ngay điểm bắt đầu trace (Point 1). Điều này giúp người dùng không phải bấm Backspace hàng chục lần để hoàn tác một đường biên phức tạp.
|
||||
@@ -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.
|
||||
|
||||
### `bound_with`
|
||||
|
||||
Field geometry-geometry trên feature con, lưu id geometry cha mà nó nằm trong. `bound_with` 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/bound_with. 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.
|
||||
@@ -0,0 +1,333 @@
|
||||
# UHM Editor - tính năng hiện có
|
||||
|
||||
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.
|
||||
|
||||
## 2. Bố cục giao diện
|
||||
|
||||
- Cột trái (`Editor.tsx`)
|
||||
- `ProjectPanel`
|
||||
- `ToolsPanel`
|
||||
- `CommitPanel`
|
||||
- `CommitHistoryPanel`
|
||||
- `UndoListPanel`
|
||||
- Khu vực giữa
|
||||
- `Map`
|
||||
- `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
|
||||
- Entities
|
||||
- Wiki
|
||||
- Entity ↔ Wiki
|
||||
- Selected Geometry
|
||||
|
||||
Hai cột hai bên đều resize được bằng drag handle.
|
||||
|
||||
## 3. Editor modes
|
||||
|
||||
`EditorMode` hiện có:
|
||||
|
||||
- `idle`
|
||||
- `select`
|
||||
- `draw`
|
||||
- `add-point`
|
||||
- `add-line`
|
||||
- `add-path`
|
||||
- `add-circle`
|
||||
- `replay`
|
||||
- `replay_preview`
|
||||
|
||||
Ý nghĩa thực tế:
|
||||
|
||||
- `select`: chọn geometry, xóa geometry, mở vertex editing cho polygon/circle, vào replay.
|
||||
- `draw`: vẽ polygon.
|
||||
- `add-point`: tạo point.
|
||||
- `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`: 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
|
||||
|
||||
### Polygon (`draw`)
|
||||
|
||||
- Click để thêm đỉnh.
|
||||
- `Shift` hoặc `Alt` khi click/move để snap vào geometry gần nhất.
|
||||
- `Enter` để hoàn tất polygon.
|
||||
- `Escape` để hủy.
|
||||
- `Backspace` để bỏ đỉnh cuối.
|
||||
|
||||
Geometry mới mặc định có:
|
||||
|
||||
- `type: "country"`
|
||||
- `geometry_preset: "polygon"`
|
||||
- `entity_ids: []`
|
||||
- `bound_with: null`
|
||||
|
||||
### Point (`add-point`)
|
||||
|
||||
- Click một lần để tạo point.
|
||||
- Geometry mới mặc định có `type: "city"` và `geometry_preset: "point"`.
|
||||
|
||||
### Line (`add-line`)
|
||||
|
||||
- Click để thêm đỉnh.
|
||||
- `Enter` để hoàn tất.
|
||||
- `Escape` để hủy.
|
||||
- `Backspace` để bỏ đỉnh cuối.
|
||||
|
||||
Geometry mới mặc định có `type: "defense_line"` và `geometry_preset: "line"`.
|
||||
|
||||
### Path (`add-path`)
|
||||
|
||||
- Tương tự `add-line`, nhưng render preview và layer theo route/path.
|
||||
- Geometry mới mặc định có `type: "attack_route"` và `geometry_preset: "line"`.
|
||||
|
||||
### Circle (`add-circle`)
|
||||
|
||||
- `mousedown` để đặt tâm.
|
||||
- Kéo chuột để thay đổi bán kính.
|
||||
- `mouseup` để hoàn tất.
|
||||
- `Escape` để hủy.
|
||||
|
||||
Geometry trả về vẫn là `Polygon`, nhưng có thêm:
|
||||
|
||||
- `circle_center`
|
||||
- `circle_radius`
|
||||
|
||||
Mặc định `type: "war"` và `geometry_preset: "circle-area"`.
|
||||
|
||||
## 5. Chọn và sửa geometry
|
||||
|
||||
### Selection
|
||||
|
||||
- `Map` trả về danh sách `selectedFeatureIds`.
|
||||
- `SelectedGeometryPanel`, `ProjectEntityRefsPanel` và `GeometryBindingPanel` đều đọc từ selection này.
|
||||
- Multi-select có tồn tại ở level state, nhưng một số thao tác chỉ hợp lệ khi các geometry cùng shape.
|
||||
|
||||
### Vertex editing (Chỉnh sửa đỉnh)
|
||||
|
||||
Khi đang ở chế độ `select`, nhấp đúp vào geometry để mở chế độ chỉnh sửa chi tiết qua `editingEngine`:
|
||||
|
||||
* **Kéo thả đỉnh (Move Vertex):** Kéo các handle (điểm tròn) để dịch chuyển vị trí đỉnh.
|
||||
* **Chỉnh sửa hình tròn (Circle Editing):**
|
||||
* Handle `0`: di chuyển tâm hình tròn.
|
||||
* Handle `1`: thay đổi bán kính.
|
||||
* **Thêm đỉnh mới:** `Ctrl` (hoặc `Cmd`) + click vào một cạnh bất kỳ của Polygon/LineString để chèn thêm đỉnh mới.
|
||||
* **Vẽ tiếp / Bám dọc biên (Continue Draw / Tracing):**
|
||||
* Nhấp chuột phải vào một đỉnh và chọn `"Vẽ tiếp về bên trái"` hoặc `"Vẽ tiếp về bên phải"`.
|
||||
* Trong quá trình vẽ tiếp, nhấn giữ phím `T` để tự động bám dọc (trace) theo biên của hình học khác gần nhất.
|
||||
* Hệ thống tự khóa snap vào đối tượng đang bám để tránh đứt gãy hình học, tự động khâu nối (`stitchRing`) và làm sạch đỉnh trùng bằng sai số sai biệt $10^{-9}$ để giữ nguyên nút kết nối.
|
||||
* Nhấn `Backspace` để hoàn tác (undo) các đỉnh hoặc toàn bộ đoạn vừa bám (trace).
|
||||
* Nhấn `Enter` để lưu đoạn vẽ tiếp, hoặc `Escape` để hủy.
|
||||
* **Xóa hàng loạt đỉnh (Range Delete):**
|
||||
* Nhấn phím `Delete` (hoặc click nút Xóa đỉnh trên panel) để vào chế độ Xóa đỉnh (các handle đổi sang màu **Đỏ**).
|
||||
* *Xóa đơn:* Nhấp chuột trái vào bất kỳ đỉnh nào để xóa đỉnh đó.
|
||||
* *Xóa khoảng (Range Delete):*
|
||||
* Giữ phím `Shift` và click vào đỉnh đầu tiên (đổi sang màu **Xanh lá** làm điểm neo, các đỉnh khác đổi sang màu **Xanh dương** an toàn).
|
||||
* Di chuyển chuột tới đỉnh thứ hai: Toàn bộ cung đường đi giữa hai điểm neo dự kiến xóa sẽ hiển thị màu **Đỏ**, các đỉnh không bị ảnh hưởng sẽ giữ màu **Xanh dương**.
|
||||
* Đối với Polygon, mặc định cung đường ngắn nhất (trung điểm gần chuột nhất) sẽ được chọn. Người dùng có thể **nhấn giữ phím Alt** để cưỡng bức chọn cung ngược lại.
|
||||
* Click vào đỉnh thứ hai (hoặc nhấn giữ Shift + click) để xác nhận xóa toàn bộ các đỉnh màu đỏ ở giữa.
|
||||
* Nhấn `Escape` hoặc click chuột phải, hoặc click ra vùng trống ngoài bản đồ để hủy chọn khoảng xóa.
|
||||
* Nhấn `Delete` lần nữa hoặc nhấn `Escape` (khi không chọn khoảng) để thoát chế độ Xóa đỉnh.
|
||||
* **Áp dụng & Hủy chỉnh sửa:**
|
||||
* Nhấn `Enter` để lưu toàn bộ thay đổi hình học.
|
||||
* Nhấn `Escape` (khi không trong chế độ xóa/vẽ tiếp) để hủy bỏ mọi thay đổi và quay lại trạng thái cũ.
|
||||
|
||||
### Xóa geometry
|
||||
|
||||
- Hành động xóa toàn bộ một hình học được đi qua `onDeleteFeature`.
|
||||
- Undo có thể khôi phục lại geometry vừa xóa cùng các liên kết tương ứng.
|
||||
|
||||
## 6. Metadata geometry
|
||||
|
||||
`SelectedGeometryPanel` hiện cho phép sửa:
|
||||
|
||||
- `type_key`
|
||||
- `time_start`
|
||||
- `time_end`
|
||||
|
||||
`bound_with` không nằm trong form metadata; việc bind/unbind geometry hiện đi qua `GeometryBindingPanel`.
|
||||
|
||||
Các ràng buộc đang có:
|
||||
|
||||
- `time_start` và `time_end` phải parse được thành số hoặc để trống.
|
||||
- Nếu cả hai đều có giá trị thì `time_start <= time_end`.
|
||||
|
||||
Khi apply, editor patch trực tiếp `feature.properties` của geometry đang chọn.
|
||||
|
||||
## 7. Timeline
|
||||
|
||||
`TimelineBar` hiện dùng dải năm cố định từ util timeline.
|
||||
|
||||
- Slider + numeric input cùng điều khiển `timelineDraftYear`.
|
||||
- Có toggle `filterEnabled`.
|
||||
- Khi bật filter:
|
||||
- mọi geometry chỉ hiện nếu năm hiện tại nằm trong `[time_start, time_end]`
|
||||
- geometry mới tạo trong session cũng tuân theo filter này
|
||||
|
||||
Timeline hiện là filter phía client, không fetch lại dữ liệu project theo năm.
|
||||
|
||||
## 8. Search hợp nhất và import
|
||||
|
||||
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 `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 `snapshotEntityRows` nếu chưa có
|
||||
- import giữ nguyên timeline filter; geometry mới import có thể bị ẩn nếu ngoài năm hiện tại
|
||||
|
||||
## 9. Entity và binding
|
||||
|
||||
### Project entities
|
||||
|
||||
`ProjectEntityRefsPanel` hỗ trợ:
|
||||
|
||||
- tạo entity local (`source: "inline"`, `operation: "create"`)
|
||||
- sửa entity đã có trong snapshot
|
||||
- bind/unbind entity vào geometry đang chọn
|
||||
|
||||
Editor không gọi API create entity riêng ở bước này. Entity mới chỉ sống trong snapshot cho tới khi commit project.
|
||||
|
||||
### Geometry ↔ Entity
|
||||
|
||||
Liên kết nhiều-nhiều được thể hiện bằng:
|
||||
|
||||
- field UI trên feature: `entity_id`, `entity_ids`, `entity_name`, `entity_names`
|
||||
- payload snapshot: `geometry_entity[]`
|
||||
|
||||
Panel `ProjectEntityRefsPanel` là nơi bind/unbind entity theo geometry đang chọn.
|
||||
|
||||
### Geometry ↔ Geometry
|
||||
|
||||
`GeometryBindingPanel` thao tác trên `feature.properties.bound_with` của geometry con.
|
||||
|
||||
- Chọn một geometry làm gốc.
|
||||
- Bind/unbind với geometry khác trong project bằng cách set/clear `bound_with` của geometry con.
|
||||
- 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 bound_with đ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
|
||||
|
||||
### Wiki panel
|
||||
|
||||
`WikiSidebarPanel` dùng `react-quill-new`.
|
||||
|
||||
Các khả năng đang có:
|
||||
|
||||
- tạo wiki local
|
||||
- sửa title/slug/doc
|
||||
- import HTML file
|
||||
- export nội dung hiện tại theo định dạng suy ra từ `doc`
|
||||
- lưu wiki vào `snapshotWikis`
|
||||
|
||||
Storage thực tế của `doc`:
|
||||
|
||||
- format mới: HTML string
|
||||
- plaintext fallback
|
||||
|
||||
### Internal wiki link
|
||||
|
||||
Toolbar `link` mở modal custom:
|
||||
|
||||
- tìm wiki local theo title/slug
|
||||
- tìm wiki global từ server
|
||||
- chèn link bằng `slug`, không bắt buộc scheme URL
|
||||
- có thể tạo `__missing__` link để đánh dấu liên kết chưa map được
|
||||
|
||||
### Entity ↔ Wiki
|
||||
|
||||
`EntityWikiBindingsPanel` quản lý `snapshotEntityWikiLinks`.
|
||||
|
||||
- link mới dùng `operation: "binding"`
|
||||
- unlink bằng cách remove row khỏi editor state
|
||||
- khi build snapshot, editor tự sinh delta `binding` hoặc `delete` so với baseline
|
||||
|
||||
## 11. Commit, submit và restore
|
||||
|
||||
### Pending change count
|
||||
|
||||
Số trong nút `Commit` không chỉ là geometry diff. Nó gồm:
|
||||
|
||||
- `editor.changeCount`
|
||||
- `+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ừ `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
|
||||
- clear undo stack
|
||||
- clear geometry changes
|
||||
|
||||
### Submit
|
||||
|
||||
- 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
|
||||
|
||||
Đây là FE-only restore để tiếp tục chỉnh sửa từ snapshot cũ.
|
||||
|
||||
## 12. Pending submission lock
|
||||
|
||||
Khi `openSectionEditor()` thấy project có submission `PENDING`, editor bị chặn mở.
|
||||
|
||||
UI hiện tại:
|
||||
|
||||
- hiển thị màn hình lock
|
||||
- cho phép xóa pending submission để unlock
|
||||
|
||||
Luồng này bám sát rule backend mới, không phải readonly mode giả lập ở FE.
|
||||
|
||||
## 13. Những thứ doc cũ từng nhắc nhưng code hiện chưa có
|
||||
|
||||
Các mục sau không nên xem là tính năng hiện hành của editor:
|
||||
|
||||
- autosave toàn bộ draft editor vào `localStorage`
|
||||
- restore head commit trên backend từ UI 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
|
||||
@@ -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.bound_with`.
|
||||
- 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 có `bound_with` trỏ tới MAIN.
|
||||
- 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 và projection (Globe/Flat) ban đầu.
|
||||
- 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 (khôi phục hoàn toàn camera view state và projection của editor ban đầu).
|
||||
|
||||
## 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,136 @@
|
||||
# UHM Editor - Tài liệu tham chiếu thuật toán Toán học & Hình học
|
||||
|
||||
Tài liệu này hệ thống hóa toàn bộ các công thức toán học, thuật toán hình học không gian (Geospatial) và thuật toán đồ thị được áp dụng trong công cụ chỉnh sửa bản đồ của **Ultimate History Map (UHM)**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Công thức khoảng cách Haversine (`distanceMeters`)
|
||||
|
||||
Để tính toán khoảng cách thực tế giữa hai tọa độ Địa lý $(lng_1, lat_1)$ và $(lng_2, lat_2)$ trên bề mặt cong của Trái Đất (mô hình cầu), hệ thống sử dụng công thức Haversine.
|
||||
|
||||
### Công thức toán học
|
||||
Cho bán kính trung bình của Trái Đất $R = 6,378,137\text{ m}$. Chuyển đổi tọa độ từ độ (degrees) sang radian (radians):
|
||||
$$\Delta lat = (lat_2 - lat_1) \times \frac{\pi}{180}$$
|
||||
$$\Delta lng = (lng_2 - lng_1) \times \frac{\pi}{180}$$
|
||||
|
||||
Đại lượng trung gian $a$:
|
||||
$$a = \sin^2\left(\frac{\Delta lat}{2}\right) + \cos(lat_1 \times \frac{\pi}{180}) \times \cos(lat_2 \times \frac{\pi}{180}) \times \sin^2\left(\frac{\Delta lng}{2}\right)$$
|
||||
|
||||
Khoảng cách góc $c$:
|
||||
$$c = 2 \times \operatorname{atan2}\left(\sqrt{a}, \sqrt{1 - a}\right)$$
|
||||
|
||||
Khoảng cách thực tế $d$ (mét):
|
||||
$$d = R \times c$$
|
||||
|
||||
---
|
||||
|
||||
## 2. Chiếu điểm lên đoạn thẳng & Snap hình học (`snapToNearestGeometry`)
|
||||
|
||||
Khi di chuyển hoặc kéo đỉnh, hệ thống chiếu tọa độ chuột hiện tại lên các cạnh của đa giác hoặc đường thẳng để tìm điểm bám (snap) gần nhất.
|
||||
|
||||
### Chiếu Vector tuyến tính
|
||||
Xét một đoạn thẳng nối từ điểm $A(x_A, y_A)$ đến điểm $B(x_B, y_B)$ và điểm chuột hiện tại là $P(x_P, y_P)$.
|
||||
Ta định nghĩa các vector:
|
||||
$$\vec{AB} = B - A = (x_B - x_A, y_B - y_A)$$
|
||||
$$\vec{AP} = P - A = (x_P - x_A, y_P - y_A)$$
|
||||
|
||||
Hình chiếu vuông góc của $P$ lên đường thẳng chứa $AB$ được xác định bởi tham số tỉ lệ $t$:
|
||||
$$t = \frac{\vec{AP} \cdot \vec{AB}}{\|\vec{AB}\|^2} = \frac{(x_P - x_A)(x_B - x_A) + (y_P - y_A)(y_B - y_A)}{(x_B - x_A)^2 + (y_B - y_A)^2}$$
|
||||
|
||||
Để giới hạn điểm chiếu nằm trực tiếp **trong lòng đoạn thẳng** $AB$, ta ràng buộc tham số $t$ về đoạn $[0, 1]$:
|
||||
$$t_{\text{clamped}} = \max(0, \min(1, t))$$
|
||||
|
||||
Tọa độ điểm chiếu gần nhất $P_{\text{projected}}$:
|
||||
$$P_{\text{projected}} = A + t_{\text{clamped}} \times \vec{AB}$$
|
||||
|
||||
### Ngưỡng Snap (Tolerance)
|
||||
Hệ thống chuyển đổi khoảng cách từ điểm chiếu đến con trỏ chuột sang đơn vị pixel màn hình. Nếu khoảng cách hình chiếu nhỏ hơn ngưỡng sai số cho phép (ví dụ: $8\text{px}$ hoặc $1\text{m}$ thực tế), con trỏ sẽ tự động bị hút vào điểm $P_{\text{projected}}$ đó.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tạo hình tròn đa giác (`buildCircleRing`)
|
||||
|
||||
Vì các chuẩn dữ liệu GeoJSON không hỗ trợ kiểu dữ liệu `Circle` nguyên bản, hệ thống chuyển đổi hình tròn có tâm $C(lng_C, lat_C)$ và bán kính $r$ (mét) thành một đa giác khép kín (`Polygon`) gồm 64 đỉnh.
|
||||
|
||||
### Công thức lượng giác trên mặt cầu
|
||||
Với mỗi góc $\theta$ chạy từ $0^{\circ}$ đến $360^{\circ}$ (chia thành 64 phân đoạn, mỗi bước $\Delta\theta = \frac{2\pi}{64}$ radians):
|
||||
|
||||
1. Tính bán kính góc $d = \frac{r}{R}$ (với $R$ là bán kính Trái Đất).
|
||||
2. Tọa độ vĩ độ mới ($lat_{\theta}$):
|
||||
$$lat_{\theta} = \arcsin\left(\sin(lat_C) \cos(d) + \cos(lat_C) \sin(d) \cos(\theta)\right)$$
|
||||
3. Tọa độ kinh độ mới ($lng_{\theta}$):
|
||||
$$lng_{\theta} = lng_C + \operatorname{atan2}\left(\sin(\theta) \sin(d) \cos(lat_C), \cos(d) - \sin(lat_C) \sin(lat_{\theta})\right)$$
|
||||
|
||||
Tập hợp 64 tọa độ $(lng_{\theta}, lat_{\theta})$ tạo thành vòng khép kín mô tả chính xác biên hình tròn trên bản đồ.
|
||||
|
||||
---
|
||||
|
||||
## 4. Kiểm tra vòng khép kín sai số cao (`isClosed`)
|
||||
|
||||
Trong tính toán đồ thị địa lý, do sai số dấu phẩy động (floating-point precision) tích lũy trong quá trình tính toán của trình duyệt, tọa độ điểm đầu và điểm cuối của Polygon có thể lệch nhau một lượng cực nhỏ.
|
||||
|
||||
Hệ thống áp dụng sai số tuyệt đối $\epsilon = 10^{-9}$ để kiểm tra tính khép kín:
|
||||
$$\Delta lng = |lng_{\text{start}} - lng_{\text{end}}|$$
|
||||
$$\Delta lat = |lat_{\text{start}} - lat_{\text{end}}|$$
|
||||
$$\text{isClosed} = (\Delta lng < 10^{-9}) \land (\Delta lat < 10^{-9})$$
|
||||
|
||||
Điều này ngăn chặn việc hệ thống phân loại nhầm Polygon khép kín thành LineString hở.
|
||||
|
||||
---
|
||||
|
||||
## 5. Khâu nối và làm sạch đường biên (`stitchRing` & `cleanRing`)
|
||||
|
||||
Khi bám dọc biên (Trace) từ một đỉnh vẽ tiếp, hệ thống tiến hành cắt và ghép 3 mảng tọa độ:
|
||||
1. `prefix`: Các điểm trước điểm bắt đầu trace.
|
||||
2. `activeDrawn`: Các điểm thu được từ đường đi trace.
|
||||
3. `suffix`: Các điểm sau điểm kết thúc trace.
|
||||
|
||||
Do quá trình ghép nối trực tiếp tại các ranh giới khâu (join points) dễ sinh ra các điểm trùng lặp gần nhau (sai số nhỏ), hàm `cleanRing` sẽ duyệt qua mảng kết quả và loại bỏ các điểm trùng kế tiếp nếu khoảng cách giữa chúng bé hơn $\epsilon = 10^{-9}$:
|
||||
|
||||
$$\text{duplicate} = (|lng_i - lng_{i-1}| < 10^{-9}) \land (|lat_i - lat_{i-1}| < 10^{-9})$$
|
||||
|
||||
---
|
||||
|
||||
## 6. Định hướng Đông - Tây / Trái - Phải (`isToTheRight`)
|
||||
|
||||
Để xác định một đỉnh nằm bên trái hay bên phải đỉnh khác khi vẽ tiếp mà không phụ thuộc vào thứ tự chỉ mục ban đầu (vốn không trực quan cho người dùng):
|
||||
|
||||
$$\text{isToTheRight}(A, B) = \begin{cases}
|
||||
lng_A > lng_B, & \text{nếu } lng_A \neq lng_B \\
|
||||
lat_A < lat_B, & \text{nếu } lng_A = lng_B
|
||||
\end{cases}$$
|
||||
|
||||
Quy ước này giúp người dùng dễ dàng định hình hướng đi (bên phải tương đương với đi về phía Đông hoặc đi xuống phía Nam nếu trùng kinh độ).
|
||||
|
||||
---
|
||||
|
||||
## 7. Giải thuật chọn cung xóa của Polygon trong Range Delete
|
||||
|
||||
Khi xóa một khoảng đỉnh trên đa giác khép kín giữa 2 đỉnh chỉ mục $i_{\text{start}}$ và $i_{\text{hover}}$, đa giác luôn bị chia làm hai cung đường đi thay thế:
|
||||
|
||||
* **Đường đi A (Thuận chiều kim đồng hồ):**
|
||||
$$P_A = \{ (i_{\text{start}} + 1) \bmod N, \dots, i_{\text{hover}} - 1 \bmod N \}$$
|
||||
* **Đường đi B (Ngược chiều kim đồng hồ):**
|
||||
$$P_B = \{ (i_{\text{start}} - 1 + N) \bmod N, \dots, i_{\text{hover}} + 1 \bmod N \}$$
|
||||
|
||||
### Khoảng cách hình chiếu Pixel (Smart Decision)
|
||||
Để tự động chọn cung đường người dùng muốn xóa:
|
||||
1. Xác định tọa độ trung điểm hình học của từng cung đường đi.
|
||||
* Nếu cung đường trống (xóa trực tiếp giữa 2 đỉnh kề nhau), trung điểm là trung điểm của đoạn thẳng nối 2 đỉnh neo:
|
||||
$$M = \left(\frac{lng_{\text{start}} + lng_{\text{hover}}}{2}, \frac{lat_{\text{start}} + lat_{\text{hover}}}{2}\right)$$
|
||||
* Nếu cung có chứa các đỉnh trung gian, lấy tọa độ của đỉnh nằm chính giữa mảng chỉ mục đó.
|
||||
2. Chiếu tọa độ trung điểm của $P_A$ và $P_B$ lên hệ tọa độ pixel của màn hình thiết bị thông qua phép chiếu MapLibre (`map.project`):
|
||||
$$M_{\text{pixel}, A} = \text{project}(M_A)$$
|
||||
$$M_{\text{pixel}, B} = \text{project}(M_B)$$
|
||||
3. Đo khoảng cách Euclid từ vị trí con trỏ chuột hiện tại $Cursor(x, y)$ đến hai hình chiếu trung điểm:
|
||||
$$d_A = \sqrt{(x - x_{M, A})^2 + (y - y_{M, A})^2}$$
|
||||
$$d_B = \sqrt{(x - x_{M, B})^2 + (y - y_{M, B})^2}$$
|
||||
|
||||
Cung đường nào có khoảng cách ngắn hơn ($d \le$ đối thủ) sẽ tự động được bôi đỏ để chuẩn bị xóa.
|
||||
|
||||
### Ghi đè bằng phím Alt (Alt Key Override)
|
||||
Nếu người dùng nhấn giữ phím **Alt**, hệ thống lập tức phủ quyết kết quả so sánh khoảng cách và chọn cung đường ngược lại:
|
||||
$$\text{DeleteRange} = \begin{cases}
|
||||
P_B, & \text{nếu } (d_A \le d_B \land \text{AltPressed}) \lor (d_A > d_B \land \neg\text{AltPressed}) \\
|
||||
P_A, & \text{nếu } (d_A \le d_B \land \neg\text{AltPressed}) \lor (d_A > d_B \land \text{AltPressed})
|
||||
\end{cases}$$
|
||||
@@ -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 | Giữ nguyên timeline filter hiện tại |
|
||||
| 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 bound_with
|
||||
|
||||
| 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 | `child.properties.bound_with = selectedGeometryId` | `properties` | `geometries[].bound_with` | Geometry con lưu id cha; không thay thế entity binding |
|
||||
| Unbind geometry-geometry | `GeometryBindingPanel` unlock button | `child.properties.bound_with = null` | `properties` | `geometries[].bound_with` | Không ảnh hưởng `geometry_entity[]` |
|
||||
| Bind nhiều geometry vào target | Map bind callback | `bound_with` của từng source geometry | `properties` hoặc `group` | `geometries[].bound_with` | Tự bỏ target id khỏi source ids và chặn cycle |
|
||||
| Toggle binding filter | `GeometryBindingPanel` filter checkbox | `geometryBindingFilterEnabled` | Không | Không | Chỉ lọc hiển thị map theo selection/bound_with |
|
||||
|
||||
## 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 | Áp dụng cho cả geometry mới trong session |
|
||||
| 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`, `bound_with`, `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,198 @@
|
||||
# UHM Editor - replay actions catalog
|
||||
|
||||
Cập nhật: 2026-05-25.
|
||||
|
||||
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
|
||||
- preview layer panel / zoom controls
|
||||
- temporary geometry effects
|
||||
- playback speed
|
||||
|
||||
Stop/reset preview khôi phục presentation state, map/timeline baseline, label visibility và dọn toàn bộ temporary geometry effects.
|
||||
|
||||
## 3. UI actions
|
||||
|
||||
| Action | Params | Runtime hiện tại |
|
||||
| --- | --- | --- |
|
||||
| `timeline` | `[visible: boolean]` | Ẩn/hiện TimelineBar trong preview |
|
||||
| `layer_panel` | `[visible: boolean]` | Ẩn/hiện panel layer trong preview |
|
||||
| `wiki_panel` | `[visible: boolean]` | Mở/đóng wiki sidebar preview |
|
||||
| `close_wiki_panel` | `[]` | Đóng wiki sidebar và clear active wiki |
|
||||
| `zoom_panel` | `[visible: boolean]` | Ẩn/hiện cụm zoom/projection control trên map preview |
|
||||
| `wiki` | `[wikiId: string]` | Mở wiki sidebar và active wiki id |
|
||||
| `toast` | `[message: string]` | Hiện toast tạm thờ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 |
|
||||
| `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?]` | Pulse overlay tạm thời, tự cleanup |
|
||||
| `animate_dashed_border` | `[geometryId, color?, width?, speed?, duration?]` | Dashed border overlay tạm thời, tự cleanup |
|
||||
| `set_geometry_style` | `[geometryIds, fill?, opacity?, stroke?, width?]` | Style overlay trong preview tới khi stop/reset |
|
||||
| `show_geometry_label` | `[geometryId, text?, color?, size?]` | Hiện label riêng trong preview tới khi stop/reset |
|
||||
| `follow_geometry_path` | `[geometryId, duration?, zoom?, pitch?]` | Camera chạy theo tọa độ path geometry |
|
||||
| `follow_geometries_path` | `[geometryIds, duration?, zoom?, pitch?]` | Camera chạy theo chuỗi path geometry |
|
||||
| `dim_other_geometries` | `[geometryIds]` | Chỉ hiện target ids, ẩn các geometry khác |
|
||||
| `set_as_background_geometries` | `[geometryIds]` | Đặt các geometry làm background (luôn hiển thị, không bị ẩn) |
|
||||
| `remove_from_background_geometries` | `[geometryIds]` | Loại bỏ các geometry khỏi danh sách background |
|
||||
|
||||
Các visual effect dùng overlay source/layer riêng và không mutate geometry draft.
|
||||
|
||||
## 6. Narrative actions
|
||||
|
||||
| Action | Params | Runtime hiện tại |
|
||||
| --- | --- | --- |
|
||||
| `set_dialog` | `[data: { text: string; image_url?: string } \| null]` | Set dialog box text and optional image, or clear it if null |
|
||||
| `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?]` | Legacy: hiện dialog, nay được normalize sang `set_dialog` |
|
||||
| `clear_dialog_box` | `[]` | Clear dialog |
|
||||
| `display_historical_image` | `[url, caption?]` | Legacy: hiện image overlay, nay được normalize sang `set_dialog` |
|
||||
| `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`
|
||||
- `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`
|
||||
- `set_as_background_geometries`
|
||||
- `remove_from_background_geometries`
|
||||
|
||||
Narrative composer hiện hỗ trợ đầy đủ các narrative actions ở mục 6.
|
||||
|
||||
Timeline action list hỗ trợ reorder, duplicate, delete và edit `params` trực tiếp bằng JSON array có validate nhẹ. Composer bên phải vẫn là đường chính để tạo action mới.
|
||||
|
||||
## 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,246 @@
|
||||
# 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` |
|
||||
| `bound_with` | `normalizeFeatureBoundWith(feature)` |
|
||||
| `time_start` / `time_end` | `feature.properties.time_start/time_end ?? null` |
|
||||
| `bbox` | BBox tính từ geometry, hoặc `null` |
|
||||
|
||||
Snapshot legacy có `binding: string[]` trên geometry cha được FE migrate khi load bằng cách invert sang `bound_with` trên từng geometry con.
|
||||
|
||||
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`
|
||||
- `bound_with`
|
||||
- `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/bound_with` 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 `bound_with` 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.
|
||||
@@ -0,0 +1,230 @@
|
||||
# UHM Editor - state replay hiện tại
|
||||
|
||||
Tài liệu này mô tả đúng flow replay mode hiện tại của `/editor/[id]`.
|
||||
|
||||
Nguồn thật:
|
||||
|
||||
- `src/app/editor/[id]/page.tsx`
|
||||
- `src/uhm/lib/editor/state/useEditorState.ts`
|
||||
- `src/uhm/lib/editor/project/useProjectCommands.ts`
|
||||
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
|
||||
- `src/uhm/doc/editor_replay_actions.md`
|
||||
|
||||
## 1. Kết luận ngắn
|
||||
|
||||
Replay mode hiện tại có 2 lớp state:
|
||||
|
||||
- `activeReplayDraft`
|
||||
- là `BattleReplay` đang chỉnh
|
||||
- chỉ chứa `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
|
||||
|
||||
Điểm quan trọng:
|
||||
|
||||
- `replayDraft` không còn được persist vào commit/API
|
||||
- commit chỉ lưu `replays[]` với `target_geometry_ids`
|
||||
- snapshot cũ còn `replay_features` sẽ được FE migrate sang `target_geometry_ids` khi load
|
||||
|
||||
## 2. Shape replay hiện tại
|
||||
|
||||
```ts
|
||||
type BattleReplay = {
|
||||
id: string;
|
||||
geometry_id: string;
|
||||
target_geometry_ids: string[];
|
||||
detail: ReplayStage[];
|
||||
};
|
||||
```
|
||||
|
||||
Ý nghĩa:
|
||||
|
||||
- `geometry_id`
|
||||
- MAIN geo của replay
|
||||
- cũng là key để tìm replay tương ứng
|
||||
- `id`
|
||||
- hiện luôn bằng `geometry_id`
|
||||
- thêm để schema replay có id riêng rõ ràng hơn
|
||||
- `target_geometry_ids`
|
||||
- toàn bộ geo được đưa vào replay
|
||||
- phần tử đầu nên luôn là MAIN geo
|
||||
- `detail`
|
||||
- stage/step/actions của kịch bản
|
||||
|
||||
## 3. Replay được mở như thế nào
|
||||
|
||||
Khi vào replay từ UI:
|
||||
|
||||
1. editor lấy `triggerId`
|
||||
- ưu tiên `selectedFeatureIds[0]`
|
||||
- nếu chưa có selection thì dùng `featureId` vừa click
|
||||
2. gọi `editor.switchReplayContext(triggerId, selectedFeatureIds)`
|
||||
3. `switchReplayContext()` sẽ:
|
||||
- flush replay cũ nếu đang mở replay khác
|
||||
- tìm replay đã tồn tại theo `geometry_id`
|
||||
- nếu chưa có thì tạo seed mới
|
||||
|
||||
## 4. Seed replay được tạo ra sao
|
||||
|
||||
Replay seed mới có dạng:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: triggerId,
|
||||
geometry_id: triggerId,
|
||||
target_geometry_ids: [...],
|
||||
detail: []
|
||||
}
|
||||
```
|
||||
|
||||
`target_geometry_ids` được build từ:
|
||||
|
||||
- MAIN geo
|
||||
- toàn bộ bulk selection hiện tại
|
||||
- toàn bộ geometry con có `bound_with` trỏ tới MAIN geo trong `mainDraft`
|
||||
|
||||
Rule hiện tại:
|
||||
|
||||
- MAIN geo luôn đứng đầu
|
||||
- geo trùng sẽ được dedupe
|
||||
- nếu replay đã tồn tại sẵn, FE giữ `detail` cũ và chỉ append thêm geo mới còn thiếu vào `target_geometry_ids`
|
||||
|
||||
## 5. `replayDraft` được hydrate thế nào
|
||||
|
||||
`replayDraft` không còn nằm trong snapshot.
|
||||
|
||||
Mỗi lần:
|
||||
|
||||
- mở replay
|
||||
- undo replay session
|
||||
- restore `activeReplayDraft`
|
||||
|
||||
FE sẽ hydrate lại:
|
||||
|
||||
```ts
|
||||
replayDraft = hydrate(mainDraft, activeReplayDraft.target_geometry_ids)
|
||||
```
|
||||
|
||||
Hydrate hiện tại:
|
||||
|
||||
- lấy feature từ `mainDraft` theo đúng thứ tự `target_geometry_ids`
|
||||
- clone ra `FeatureCollection` mới
|
||||
- flatten `bound_with` thành `null` để các geo trong replay bình đẳng với nhau
|
||||
|
||||
## 6. Trong replay mode map đang đọc gì
|
||||
|
||||
`useEditorState()` vẫn switch active draft như cũ:
|
||||
|
||||
```ts
|
||||
const activeDraft = mode === "replay" ? replayDraft : mainDraft;
|
||||
```
|
||||
|
||||
Nên khi `mode === "replay"`:
|
||||
|
||||
- `editor.draft` trỏ vào `replayDraft`
|
||||
- `editor.draftRef` trỏ vào `replayDraftRef`
|
||||
- map chỉ render tập geo đang nằm trong `target_geometry_ids`
|
||||
|
||||
`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.
|
||||
|
||||
Hiện tại state layer đã chặn toàn bộ nhánh mutate geometry trong replay mode:
|
||||
|
||||
- `createFeature`
|
||||
- `createFeatureWithSnapshotEntityRows`
|
||||
- `patchFeatureProperties`
|
||||
- `patchFeaturePropertiesBatch`
|
||||
- `updateFeature`
|
||||
- `deleteFeature`
|
||||
|
||||
Nghĩa là:
|
||||
|
||||
- replay mode chỉ còn là nơi viết script replay
|
||||
- không còn persist hay commit geometry edit riêng của replay
|
||||
|
||||
## 8. Cái gì vẫn được sửa trong replay mode
|
||||
|
||||
Replay sidebar vẫn sửa:
|
||||
|
||||
- `detail[]`
|
||||
- `stage`
|
||||
- `step`
|
||||
- các action `UI / map / geo / narrative`
|
||||
|
||||
Các thay đổi đó đi qua:
|
||||
|
||||
- `editor.mutateActiveReplay`
|
||||
- `applyReplaySessionMutation()`
|
||||
|
||||
Undo replay vẫn riêng ở:
|
||||
|
||||
- `replayUndoStack`
|
||||
|
||||
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ở.
|
||||
|
||||
Nó được flush về `replays[]` khi:
|
||||
|
||||
- thoát replay mode
|
||||
- chuyển sang replay khác
|
||||
|
||||
Hàm chịu trách nhiệm là:
|
||||
|
||||
- `finalizeActiveReplaySession()`
|
||||
|
||||
## 10. Commit lấy replay từ đâu
|
||||
|
||||
Commit không lấy `activeReplayDraft` trực tiếp.
|
||||
|
||||
Nó lấy:
|
||||
|
||||
- `editor.effectiveReplays`
|
||||
|
||||
`effectiveReplays` là:
|
||||
|
||||
- `replays`
|
||||
- cộng thêm overlay của `activeReplayDraft` nếu session hiện tại đã thay đổi nhưng chưa flush
|
||||
|
||||
Vì vậy:
|
||||
|
||||
- đang còn ở replay mode vẫn commit được replay mới nhất
|
||||
- không cần thoát replay mode mới lưu được script
|
||||
|
||||
## 11. Replay đi qua API ra sao
|
||||
|
||||
Payload commit hiện tại chỉ gửi:
|
||||
|
||||
- `geometry_id`
|
||||
- `target_geometry_ids`
|
||||
- `detail`
|
||||
|
||||
Không gửi:
|
||||
|
||||
- `replayDraft`
|
||||
- `replay_features`
|
||||
- `FeatureCollection` local của replay mode
|
||||
|
||||
## 12. Migrate dữ liệu cũ
|
||||
|
||||
Snapshot cũ nếu còn:
|
||||
|
||||
```ts
|
||||
replay_features?: FeatureCollection
|
||||
```
|
||||
|
||||
thì FE sẽ:
|
||||
|
||||
- đọc `replay_features.features[].properties.id`
|
||||
- chuyển chúng thành `target_geometry_ids`
|
||||
- bỏ `replay_features` khỏi runtime replay mới
|
||||
|
||||
Nên dữ liệu cũ vẫn mở được, nhưng commit mới sẽ ra schema mới.
|
||||
@@ -0,0 +1,348 @@
|
||||
# UHM Editor - state và vòng đời dữ liệu
|
||||
|
||||
Tài liệu này mô tả state thật đang được dùng bởi editor hiện tại.
|
||||
Entry point chính là `useEditorSessionState()` và `useEditorState()`.
|
||||
|
||||
## 1. Hai lớp state chính
|
||||
|
||||
Editor đang tách làm hai khối:
|
||||
|
||||
- `useEditorSessionState()`
|
||||
- state UI, session, form, project, timeline, background, wiki
|
||||
- `useEditorState(baselineFeatureCollection, snapshotUndo)`
|
||||
- state draft hình học, diff và undo
|
||||
|
||||
Nói ngắn gọn:
|
||||
|
||||
- `session state` quyết định editor đang nhìn cái gì và panel đang thao tác gì
|
||||
- `editor state` quyết định geometry nào đang tồn tại trong draft và khác baseline ra sao
|
||||
|
||||
## 2. State geometry trung tâm
|
||||
|
||||
### `baselineFeatureCollection`
|
||||
|
||||
- Nằm ở `useEditorSessionState()`
|
||||
- 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
|
||||
|
||||
### `mainDraft`
|
||||
|
||||
- Nằm trong `useEditorState()`
|
||||
- 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`
|
||||
|
||||
- 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ừ `baselineFeatureCollection`
|
||||
- Là baseline để tính diff giữa draft hiện tại và dữ liệu gốc của session
|
||||
|
||||
### `changes`
|
||||
|
||||
- Kết quả `diffDraftToInitial(draft, initialMapRef.current)`
|
||||
- Map theo `feature.properties.id`
|
||||
- Mỗi phần tử có thể là:
|
||||
- `create`
|
||||
- `update`
|
||||
- `delete`
|
||||
|
||||
Lưu ý: diff hiện chỉ là cơ chế nhận biết geometry nào đã thay đổi so với baseline. Snapshot commit thực tế vẫn được build từ toàn bộ `draft` cộng với các snapshot bảng phụ.
|
||||
|
||||
### `changeCount`
|
||||
|
||||
- Số lượng geometry thay đổi hiện tại
|
||||
- Được cộng thêm dirty state của wiki/entity/entity-wiki/replay để tạo `pendingSaveCount`
|
||||
|
||||
## 3. Undo state
|
||||
|
||||
Undo được quản lý bởi `useUndoStack()`.
|
||||
|
||||
Kiểu action hiện có:
|
||||
|
||||
- `create`
|
||||
- `delete`
|
||||
- `update`
|
||||
- `properties`
|
||||
- `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.
|
||||
|
||||
## 4. Session state theo nhóm
|
||||
|
||||
### 4.1. Mode và selection
|
||||
|
||||
- `mode: EditorMode`
|
||||
- `selectedFeatureIds`
|
||||
- `selectedGeometryEntityIds`
|
||||
|
||||
`selectedFeatureIds` là state gốc cho:
|
||||
|
||||
- panel metadata geometry
|
||||
- bind entity
|
||||
- bind geometry
|
||||
- focus geometry từ search/binding panel
|
||||
|
||||
### 4.2. Form state
|
||||
|
||||
- `entityForm`
|
||||
- dùng cho form tạo entity local
|
||||
- `geometryMetaForm`
|
||||
- `type_key`
|
||||
- `time_start`
|
||||
- `time_end`
|
||||
|
||||
Geometry-geometry bound state không nằm trong `geometryMetaForm`; `GeometryBindingPanel` chỉnh trực tiếp `feature.properties.bound_with` của geometry con.
|
||||
|
||||
### 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. Khi thoát các chế độ preview, editor sẽ dọn dẹp hoàn toàn các map effects, highlight, và khôi phục camera view state & projection (Globe/Flat) ban đầu trước khi vào preview.
|
||||
|
||||
### 4.4. Project/session task state
|
||||
|
||||
`useProjectSessionState()` gom các cờ async vào một state machine nhỏ:
|
||||
|
||||
- `sectionTask: "idle" | "saving" | "submitting" | "opening-project"`
|
||||
|
||||
Từ đó sinh ra:
|
||||
|
||||
- `isSaving`
|
||||
- `isSubmitting`
|
||||
- `isOpeningSection`
|
||||
|
||||
Ngoài ra còn có:
|
||||
|
||||
- `activeSection`
|
||||
- `projectState`
|
||||
- `sectionCommits`
|
||||
- `baselineSnapshot`
|
||||
- `commitTitle`
|
||||
|
||||
### 4.5. Timeline state
|
||||
|
||||
`useTimelineState()` giữ:
|
||||
|
||||
- `timelineYear`
|
||||
- `timelineDraftYear`
|
||||
- `isTimelineLoading`
|
||||
- `timelineStatus`
|
||||
|
||||
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.6. Background/session UI
|
||||
|
||||
`useBackgroundSessionState()` giữ:
|
||||
|
||||
- `backgroundVisibility`
|
||||
- `isBackgroundVisibilityReady`
|
||||
|
||||
Giá trị thật được load từ `localStorage` key `uhm.backgroundLayerVisibility.v1`.
|
||||
|
||||
### 4.7. Wiki/session state
|
||||
|
||||
`useWikiSessionState()` giữ:
|
||||
|
||||
- `snapshotWikis`
|
||||
- `snapshotEntityWikiLinks`
|
||||
|
||||
Đây là single source of truth cho phần wiki trong snapshot commit.
|
||||
|
||||
### 4.8. Preview session states và refs (Viewer / Replay Preview)
|
||||
|
||||
Các states và refs điều khiển preview được khai báo trực tiếp trong `page.tsx`:
|
||||
|
||||
- `previewSession: ReplayPreviewSession | null`
|
||||
- Đóng băng toàn bộ snapshot collections (replays, draft, entities, wikis, links) cùng timeline, filter và camera view state khi chạy preview.
|
||||
- `previewAutoplayMode: "start" | "selection" | null`
|
||||
- Trạng thái autoplay (bắt đầu từ đầu hay từ step được chọn) của Replay Preview.
|
||||
- `previewWikiCache`, `previewWikiError`, `isPreviewWikiLoading`
|
||||
- Cache và status để hiển thị nội dung Wiki tương tác trong sidebar preview.
|
||||
- `previewFeaturePopupAnchor: MapFeaturePayload | null`
|
||||
- Neo tọa độ/payload của popup hiển thị thông tin geometry khi click trên map ở preview mode.
|
||||
- `previewActiveEntityId`, `isPreviewEntitySidebarOpen`
|
||||
- Sidebar hiển thị chi tiết entity được chọn trong preview.
|
||||
- `previewLinkEntityPopup: PreviewLinkEntityPopupState | null`
|
||||
- Trạng thái popup điều hướng sang entity khác khi click vào link wiki trong preview.
|
||||
- `editorOriginalMapViewStateRef: ReturnType<MapHandle["getViewState"]> | null`
|
||||
- Ref lưu giữ camera view state và projection (Globe/Flat) ban đầu của editor trước khi bắt đầu preview, phục vụ việc khôi phục hoàn toàn bản đồ khi exit.
|
||||
- `replayPreviewReturnRef: { mode: "replay" | "preview"; session: ReplayPreviewSession | null }`
|
||||
- Ref ghi nhận session và mode trước đó khi chuyển tiếp từ Viewer Preview sang Replay Preview, cho phép quay trở lại đúng Viewer Preview khi click thoát Replay Preview.
|
||||
|
||||
## 5. Snapshot state
|
||||
|
||||
Editor đang làm việc với các snapshot collection chính ngoài geometry:
|
||||
|
||||
- `snapshotEntityRows`
|
||||
- `snapshotWikis`
|
||||
- `snapshotEntityWikiLinks`
|
||||
- `replays` / `effectiveReplays`
|
||||
|
||||
Chúng đại diện cho "current session snapshot", không phải danh sách delta thô.
|
||||
|
||||
Ví dụ:
|
||||
|
||||
- entity ref được giữ bằng `operation: "reference"`
|
||||
- entity/wiki local mới tạo có thể mang `operation: "create"`
|
||||
- link entity-wiki mới tạo dùng `operation: "binding"`
|
||||
|
||||
Khi commit, `buildEditorSnapshot()` sẽ so với `baselineSnapshot` để chuyển các collection này thành snapshot đúng semantic cho backend.
|
||||
|
||||
## 6. Baseline snapshot là gì
|
||||
|
||||
`baselineSnapshot` là snapshot đang được xem như gốc của session hiện tại.
|
||||
|
||||
Nó được cập nhật khi:
|
||||
|
||||
- mở project
|
||||
- commit thành công
|
||||
- restore từ một commit
|
||||
|
||||
`baselineSnapshot` được dùng để:
|
||||
|
||||
- biết link nào là `reference`, link nào là `binding`, link nào là `delete`
|
||||
- biết wiki/entity nào là thay đổi thực sự so với snapshot trước
|
||||
- giữ lại inline entity/wiki từ snapshot trước nếu user chưa xóa chúng
|
||||
|
||||
## 7. Derived state quan trọng trong page
|
||||
|
||||
### `mapRenderDraft`
|
||||
|
||||
- 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 cũng bị timeline filter xử lý như geometry baseline
|
||||
|
||||
### `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`
|
||||
|
||||
### `selectedFeatures`
|
||||
|
||||
- map từ `selectedFeatureIds` sang feature thật trong `editor.draft.features`
|
||||
|
||||
### `isMultiEditValid`
|
||||
|
||||
- chỉ `true` khi tất cả geometry đang chọn cùng `geometry.type`
|
||||
- một số thao tác bind sẽ chặn nếu giá trị này là `false`
|
||||
|
||||
### `pendingSaveCount`
|
||||
|
||||
Được tính như sau:
|
||||
|
||||
- `editor.changeCount`
|
||||
- `+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.
|
||||
|
||||
## 8. Dirty detection
|
||||
|
||||
Dirty check của:
|
||||
|
||||
- `snapshotWikis`
|
||||
- `snapshotEntityRows`
|
||||
- `snapshotEntityWikiLinks`
|
||||
- `editor.effectiveReplays`
|
||||
|
||||
đều đang làm bằng cách normalize trước rồi so `JSON.stringify`.
|
||||
|
||||
Điều này đủ thực dụng cho snapshot cỡ vừa, nhưng cần lưu ý:
|
||||
|
||||
- không tối ưu cho dữ liệu rất lớn
|
||||
- phụ thuộc vào tính ổn định của thứ tự mảng sau normalize
|
||||
|
||||
## 9. State được persist vào localStorage
|
||||
|
||||
Hiện editor chỉ persist hai nhóm nhỏ:
|
||||
|
||||
- background layer visibility
|
||||
- key: `uhm.backgroundLayerVisibility.v1`
|
||||
- map projection
|
||||
- key: `uhm:mapProjection`
|
||||
|
||||
Editor hiện không persist toàn bộ draft/project snapshot vào localStorage.
|
||||
Nếu cần autosave local draft, đó là tính năng phải làm thêm, không phải behavior hiện tại.
|
||||
|
||||
## 10. Khi nào state bị reset
|
||||
|
||||
### Reset toàn phần
|
||||
|
||||
Xảy ra khi:
|
||||
|
||||
- mở project khác
|
||||
- mở lại project
|
||||
- restore commit
|
||||
|
||||
Hiệu ứng:
|
||||
|
||||
- `baselineFeatureCollection` đổi
|
||||
- `useEditorState()` reset `draft`
|
||||
- `undoStack` bị clear
|
||||
- baseline map được build lại
|
||||
|
||||
### Reset cục bộ
|
||||
|
||||
- đổi selection có thể reset `geometryMetaForm`
|
||||
- đóng/mở wiki modal không reset snapshot wiki, chỉ reset form local của modal
|
||||
|
||||
## 11. Một số giới hạn hiện tại cần nhớ khi đọc code
|
||||
|
||||
- có `undo`, chưa có `redo`
|
||||
- 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, bound_with 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
|
||||
@@ -0,0 +1,253 @@
|
||||
# Replay Export JSON
|
||||
|
||||
Tài liệu này mô tả đúng payload mà nút `Export JSON` của replay đang xuất ra hiện tại.
|
||||
|
||||
Nguồn thật:
|
||||
|
||||
- `src/uhm/components/editor/ReplayTimelineSidebar.tsx`
|
||||
- `src/uhm/types/projects.ts`
|
||||
- `src/uhm/doc/editor_replay_actions.md`
|
||||
|
||||
## 1. Kết luận ngắn
|
||||
|
||||
Export hiện tại có dạng:
|
||||
|
||||
```json
|
||||
{
|
||||
"exported_at": "2026-05-17T12:34:56.000Z",
|
||||
"geometry_id": "geo-main-id",
|
||||
"current_replay": { "...": "BattleReplay hiện tại" },
|
||||
"snapshot_fragment": {
|
||||
"replays": [
|
||||
{ "...": "chính current_replay" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Trong đó:
|
||||
|
||||
- `current_replay` là replay đang edit
|
||||
- `snapshot_fragment.replays[0]` là cùng replay đó, nhưng đặt vào đúng chỗ trong commit snapshot
|
||||
|
||||
## 2. Root payload
|
||||
|
||||
```ts
|
||||
type ReplayExportPayload = {
|
||||
exported_at: string;
|
||||
geometry_id: string;
|
||||
current_replay: BattleReplay;
|
||||
snapshot_fragment: {
|
||||
replays: BattleReplay[];
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Ý nghĩa:
|
||||
|
||||
- `exported_at`
|
||||
- timestamp ISO lúc bấm export
|
||||
- chỉ để debug
|
||||
- `geometry_id`
|
||||
- copy nhanh từ `current_replay.geometry_id`
|
||||
- `current_replay`
|
||||
- replay draft hiện tại
|
||||
- `snapshot_fragment`
|
||||
- fragment để test replay này nếu đặt vào commit snapshot thật
|
||||
|
||||
## 3. Shape của `current_replay`
|
||||
|
||||
```ts
|
||||
type BattleReplay = {
|
||||
id: string;
|
||||
geometry_id: string;
|
||||
target_geometry_ids: string[];
|
||||
detail: ReplayStage[];
|
||||
};
|
||||
```
|
||||
|
||||
Ý nghĩa:
|
||||
|
||||
- `geometry_id`
|
||||
- MAIN geo của replay
|
||||
- `id`
|
||||
- hiện luôn bằng `geometry_id`
|
||||
- `target_geometry_ids`
|
||||
- toàn bộ geo thuộc replay
|
||||
- phần tử đầu nên luôn là MAIN geo
|
||||
- `detail`
|
||||
- stage/step/actions của replay script
|
||||
|
||||
## 4. `target_geometry_ids` là gì
|
||||
|
||||
Đây là phần thay thế cho `replay_features` cũ.
|
||||
|
||||
FE không còn export/persist cả `FeatureCollection` riêng của replay nữa. Thay vào đó chỉ lưu:
|
||||
|
||||
- geo MAIN
|
||||
- các geo được đưa vào replay từ bulk select
|
||||
- các geometry con có `bound_with` trỏ tới MAIN geo
|
||||
|
||||
Khi mở replay, FE sẽ hydrate lại `replayDraft` từ:
|
||||
|
||||
- `mainDraft`
|
||||
- `target_geometry_ids`
|
||||
|
||||
## 5. Shape của `detail`
|
||||
|
||||
```ts
|
||||
type ReplayStage = {
|
||||
id: number;
|
||||
title?: string;
|
||||
detail_time_start: string;
|
||||
detail_time_stop: string;
|
||||
steps: ReplayStep[];
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
type ReplayStep = {
|
||||
duration: number;
|
||||
use_UI_function: ReplayAction<UIOptionName>[];
|
||||
use_map_function: ReplayAction<MapFunctionName>[];
|
||||
use_geo_function: ReplayAction<GeoFunctionName>[];
|
||||
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
|
||||
};
|
||||
```
|
||||
|
||||
Ý nghĩa:
|
||||
|
||||
- `stage` là cụm lớn theo mốc thời gian hoặc nhịp kể chuyện
|
||||
- `step` là đơn vị phát nhỏ hơn trong một stage
|
||||
- `duration` là trọng số thời gian của step
|
||||
- action hiện tách thành 4 nhóm
|
||||
|
||||
## 6. Ví dụ JSON gần thực tế
|
||||
|
||||
```json
|
||||
{
|
||||
"exported_at": "2026-05-17T12:34:56.000Z",
|
||||
"geometry_id": "019e13ab-4823-76c5-afde-2391c0cf311d",
|
||||
"current_replay": {
|
||||
"id": "019e13ab-4823-76c5-afde-2391c0cf311d",
|
||||
"geometry_id": "019e13ab-4823-76c5-afde-2391c0cf311d",
|
||||
"target_geometry_ids": [
|
||||
"019e13ab-4823-76c5-afde-2391c0cf311d",
|
||||
"019e13ab-6063-713d-a28f-98a1556817a7",
|
||||
"019e13ab-5896-713a-111111111111"
|
||||
],
|
||||
"detail": [
|
||||
{
|
||||
"id": 0,
|
||||
"title": "Mở đầu chiến dịch",
|
||||
"detail_time_start": "1939",
|
||||
"detail_time_stop": "1940",
|
||||
"steps": [
|
||||
{
|
||||
"duration": 1000,
|
||||
"use_UI_function": [
|
||||
{
|
||||
"function_name": "timeline",
|
||||
"params": [false]
|
||||
}
|
||||
],
|
||||
"use_map_function": [
|
||||
{
|
||||
"function_name": "set_time_filter",
|
||||
"params": [1939]
|
||||
}
|
||||
],
|
||||
"use_geo_function": [
|
||||
{
|
||||
"function_name": "fly_to_geometries",
|
||||
"params": [
|
||||
[
|
||||
"019e13ab-4823-76c5-afde-2391c0cf311d",
|
||||
"019e13ab-6063-713d-a28f-98a1556817a7"
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"use_narrow_function": [
|
||||
{
|
||||
"function_name": "set_title",
|
||||
"params": ["Chiến dịch bắt đầu"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"snapshot_fragment": {
|
||||
"replays": [
|
||||
{
|
||||
"id": "019e13ab-4823-76c5-afde-2391c0cf311d",
|
||||
"geometry_id": "019e13ab-4823-76c5-afde-2391c0cf311d",
|
||||
"target_geometry_ids": [
|
||||
"019e13ab-4823-76c5-afde-2391c0cf311d",
|
||||
"019e13ab-6063-713d-a28f-98a1556817a7",
|
||||
"019e13ab-5896-713a-111111111111"
|
||||
],
|
||||
"detail": [
|
||||
{
|
||||
"id": 0,
|
||||
"title": "Mở đầu chiến dịch",
|
||||
"detail_time_start": "1939",
|
||||
"detail_time_stop": "1940",
|
||||
"steps": [
|
||||
{
|
||||
"duration": 1000,
|
||||
"use_UI_function": [
|
||||
{
|
||||
"function_name": "timeline",
|
||||
"params": [false]
|
||||
}
|
||||
],
|
||||
"use_map_function": [
|
||||
{
|
||||
"function_name": "set_time_filter",
|
||||
"params": [1939]
|
||||
}
|
||||
],
|
||||
"use_geo_function": [
|
||||
{
|
||||
"function_name": "fly_to_geometries",
|
||||
"params": [
|
||||
[
|
||||
"019e13ab-4823-76c5-afde-2391c0cf311d",
|
||||
"019e13ab-6063-713d-a28f-98a1556817a7"
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"use_narrow_function": [
|
||||
{
|
||||
"function_name": "set_title",
|
||||
"params": ["Chiến dịch bắt đầu"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Cách đọc file export
|
||||
|
||||
Khi nhìn file export:
|
||||
|
||||
- nếu cần biết replay bám vào geo nào, xem `geometry_id`
|
||||
- nếu cần biết replay gồm những geo nào, xem `target_geometry_ids`
|
||||
- nếu cần biết script sẽ làm gì, xem `detail[].steps[]`
|
||||
- nếu cần so với commit snapshot, xem `snapshot_fragment.replays`
|
||||
|
||||
## 8. Ghi chú quan trọng
|
||||
|
||||
- Export hiện tại không còn chứa `replay_features`
|
||||
- Nếu mở replay cũ từng dùng `replay_features`, FE sẽ migrate sang `target_geometry_ids` trước khi export
|
||||
- `current_replay` và `snapshot_fragment.replays[0]` hiện vẫn là cùng một replay, chỉ khác góc nhìn
|
||||
@@ -0,0 +1,449 @@
|
||||
# Goong APIs In Use
|
||||
|
||||
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 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:
|
||||
|
||||
- [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:1)
|
||||
- [tiles.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/tiles.ts:1)
|
||||
- [useMapLayers.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/components/map/useMapLayers.ts:1)
|
||||
- style JSON đã tải về:
|
||||
- [goong_map_web.json](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/tmp/goong-styles/goong_map_web.json)
|
||||
- [goong_satellite.json](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/tmp/goong-styles/goong_satellite.json)
|
||||
|
||||
## 1. Tóm tắt kỹ thuật
|
||||
|
||||
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 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. 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 đủ**
|
||||
- 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 style JSON qua proxy
|
||||
|
||||
Frontend gọi:
|
||||
|
||||
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_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:
|
||||
|
||||
- `goong_satellite.json`
|
||||
- app lấy ra raster source đầu tiên
|
||||
- dùng làm nền satellite
|
||||
- `goong_map_web.json`
|
||||
- app lấy ra các layer/source phục vụ:
|
||||
- `Country Borders`
|
||||
- `Province Borders`
|
||||
- `District Borders`
|
||||
- `Country Labels`
|
||||
- `Rivers`
|
||||
|
||||
### 2.2. Frontend fetch source manifests qua proxy
|
||||
|
||||
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:
|
||||
|
||||
#### Trong `goong_satellite.json`
|
||||
|
||||
- `https://tiles.goong.io/sources/satellite.json?api_key=...`
|
||||
- `https://tiles.goong.io/sources/base.json?api_key=...`
|
||||
- `https://tiles.goong.io/sources/goong.json?api_key=...`
|
||||
|
||||
#### Trong `goong_map_web.json`
|
||||
|
||||
- `https://tiles.goong.io/sources/base.json?api_key=...`
|
||||
- `https://tiles.goong.io/sources/goong.json?api_key=...`
|
||||
|
||||
Ý nghĩa:
|
||||
|
||||
- `sources/satellite.json`
|
||||
- raster source manifest cho nền satellite
|
||||
- `sources/base.json`
|
||||
- vector source manifest cho các lớp `boundary`, `worldcountriespoints`, `worldnationalcapitals`
|
||||
- `sources/goong.json`
|
||||
- vector source manifest cho các lớp `riversandlakes`, `vietnam_administrator`
|
||||
|
||||
### 2.3. MapLibre fetch tile URLs đã rewrite
|
||||
|
||||
Đây là phần dễ bị bỏ sót nhất.
|
||||
|
||||
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. 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ì 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
|
||||
|
||||
Tính theo runtime hiện tại, upstream Goong đang được dùng thật là:
|
||||
|
||||
### 3.1. Style JSON
|
||||
|
||||
- `assets/goong_satellite.json`
|
||||
- `assets/goong_map_web.json`
|
||||
|
||||
### 3.2. Source manifests
|
||||
|
||||
- `sources/satellite.json`
|
||||
- `sources/base.json`
|
||||
- `sources/goong.json`
|
||||
|
||||
### 3.3. Tile endpoints bên trong source manifests
|
||||
|
||||
- raster tile URLs nằm trong `sources/satellite.json`
|
||||
- vector tile URLs nằm trong `sources/base.json`
|
||||
- vector tile URLs nằm trong `sources/goong.json`
|
||||
|
||||
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
|
||||
|
||||
### 4.1. Goong glyphs / fonts
|
||||
|
||||
Style JSON của Goong có field:
|
||||
|
||||
- `glyphs: https://tiles.goong.io/fonts/{fontstack}/{range}.pbf?api_key=...`
|
||||
|
||||
Flow hiện tại **có dùng glyphs của Goong qua proxy**.
|
||||
|
||||
Map đang trỏ `glyphs` vào:
|
||||
|
||||
- `${API_BASE_URL}/proxy/tiles.goong.io/fonts/{fontstack}/{range}.pbf`
|
||||
|
||||
Nguồn trong code:
|
||||
|
||||
- [useMapLayers.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/components/map/useMapLayers.ts:17)
|
||||
- [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:12)
|
||||
|
||||
Kết luận:
|
||||
|
||||
- **backend proxy Goong fonts/glyphs là bắt buộc cho flow hiện tại**
|
||||
|
||||
### 4.2. Goong sprite
|
||||
|
||||
Style JSON của Goong có:
|
||||
|
||||
- `sprite: https://tiles.goong.io/sprite`
|
||||
|
||||
Nhưng flow hiện tại **không phụ thuộc sprite** vì:
|
||||
|
||||
- app không nạp toàn bộ Goong style vào map
|
||||
- app chỉ nhặt `sources` và `layers`
|
||||
- khi clone overlay labels, code còn chủ động loại bớt icon fields
|
||||
|
||||
Nguồn trong code:
|
||||
|
||||
- `cloneOverlayLayer(...)` ở [tiles.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/tiles.ts:411)
|
||||
|
||||
Kết luận:
|
||||
|
||||
- **không cần backend proxy Goong sprite cho flow hiện tại**
|
||||
|
||||
### 4.3. Các REST API khác của Goong
|
||||
|
||||
Preview search hiện có dùng trực tiếp các REST API này từ browser:
|
||||
|
||||
- `Place/AutoComplete`
|
||||
- `Place/Detail`
|
||||
- `Geocode` reverse geocoding với `latlng=lat,lng`
|
||||
|
||||
Nguồn trong code:
|
||||
|
||||
- [goongPlaces.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/goongPlaces.ts:1)
|
||||
- [PresentPlaceSearch.tsx](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/components/editor/PresentPlaceSearch.tsx:1)
|
||||
|
||||
Chưa dùng:
|
||||
|
||||
- directions
|
||||
- distance matrix
|
||||
- static map
|
||||
|
||||
## 5. Backend cần làm gì
|
||||
|
||||
### 5.1. Mục tiêu backend
|
||||
|
||||
Backend phải đảm bảo:
|
||||
|
||||
1. browser không gọi Goong trực tiếp
|
||||
2. browser không nhìn thấy `api_key`
|
||||
3. frontend vẫn nhận được dữ liệu theo format mà MapLibre/app hiện tại cần
|
||||
|
||||
### 5.2. Hai kiểu triển khai
|
||||
|
||||
Có 2 cách:
|
||||
|
||||
#### Cách A: Transparent proxy
|
||||
|
||||
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:
|
||||
|
||||
- gần với Goong
|
||||
- ít phải đổi frontend hơn
|
||||
|
||||
Nhược điểm:
|
||||
|
||||
- BE phải sanitize JSON response để không lộ key trong body response
|
||||
|
||||
#### Cách B: Normalize thành API nội bộ
|
||||
|
||||
BE không trả nguyên style/source của Goong mà trả dữ liệu đã xử lý sẵn cho FE.
|
||||
|
||||
Ưu điểm:
|
||||
|
||||
- hợp đồng BE-FE rõ hơn
|
||||
- ít phụ thuộc format Goong hơn
|
||||
|
||||
Nhược điểm:
|
||||
|
||||
- cần sửa frontend nhiều hơn
|
||||
|
||||
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/tiles.goong.io/assets/goong_satellite.json`
|
||||
|
||||
Upstream:
|
||||
|
||||
- `https://tiles.goong.io/assets/goong_satellite.json?api_key=<server-side-key>`
|
||||
|
||||
Backend phải:
|
||||
|
||||
- fetch upstream bằng key server-side
|
||||
- parse JSON
|
||||
- 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 đã sanitize, chưa rewrite sang `/proxy/...`
|
||||
|
||||
#### `GET /proxy/tiles.goong.io/assets/goong_map_web.json`
|
||||
|
||||
Upstream:
|
||||
|
||||
- `https://tiles.goong.io/assets/goong_map_web.json?api_key=<server-side-key>`
|
||||
|
||||
Backend phải:
|
||||
|
||||
- fetch upstream bằng key server-side
|
||||
- parse JSON
|
||||
- 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 đã sanitize, chưa rewrite sang `/proxy/...`
|
||||
|
||||
### 6.2. Proxy source manifests
|
||||
|
||||
#### `GET /proxy/tiles.goong.io/sources/satellite.json`
|
||||
|
||||
Upstream:
|
||||
|
||||
- `https://tiles.goong.io/sources/satellite.json?api_key=<server-side-key>`
|
||||
|
||||
Backend phải:
|
||||
|
||||
- fetch upstream
|
||||
- parse JSON
|
||||
- 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`
|
||||
- `maxzoom`
|
||||
- `bounds`
|
||||
- `scheme`
|
||||
- `attribution`
|
||||
|
||||
Response:
|
||||
|
||||
- `Content-Type: application/json`
|
||||
- body: source manifest đã sanitize, chưa rewrite sang `/proxy/...`
|
||||
|
||||
#### `GET /proxy/tiles.goong.io/sources/base.json`
|
||||
|
||||
Upstream:
|
||||
|
||||
- `https://tiles.goong.io/sources/base.json?api_key=<server-side-key>`
|
||||
|
||||
Backend phải:
|
||||
|
||||
- fetch upstream
|
||||
- parse JSON
|
||||
- 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/tiles.goong.io/sources/goong.json`
|
||||
|
||||
Upstream:
|
||||
|
||||
- `https://tiles.goong.io/sources/goong.json?api_key=<server-side-key>`
|
||||
|
||||
Backend phải:
|
||||
|
||||
- fetch upstream
|
||||
- parse JSON
|
||||
- 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.
|
||||
|
||||
Frontend hiện build URL proxy generic theo upstream target:
|
||||
|
||||
- `GET /proxy/tiles.goong.io/...`
|
||||
|
||||
Yêu cầu:
|
||||
|
||||
- request browser -> backend
|
||||
- backend -> upstream Goong bằng key server-side
|
||||
- stream response về browser
|
||||
- pass through hoặc preserve:
|
||||
- `Content-Type`
|
||||
- `Cache-Control`
|
||||
- `ETag`
|
||||
- `Last-Modified`
|
||||
|
||||
Response type có thể là:
|
||||
|
||||
- raster image
|
||||
- vector tile protobuf
|
||||
|
||||
## 7. Runtime dependency map cho BE
|
||||
|
||||
### 7.1. Satellite background
|
||||
|
||||
Luồng:
|
||||
|
||||
1. FE đọc `goong_satellite.json`
|
||||
2. FE lấy `sources.satellite`
|
||||
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:
|
||||
|
||||
- style JSON
|
||||
- source manifest
|
||||
- raster tile URLs
|
||||
|
||||
### 7.2. Overlay borders / labels / rivers
|
||||
|
||||
Luồng:
|
||||
|
||||
1. FE đọc `goong_map_web.json`
|
||||
2. FE lấy selected layers + selected sources
|
||||
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:
|
||||
|
||||
- style JSON
|
||||
- 2 source manifests
|
||||
- vector tile URLs tương ứng
|
||||
|
||||
## 8. Danh sách tối thiểu BE phải 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 `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 `sprite`
|
||||
- proxy geocoding / directions / autocomplete
|
||||
|
||||
Điều này chỉ đúng khi frontend vẫn giữ kiến trúc hiện tại.
|
||||
|
||||
Nếu sau này frontend chuyển sang `map.setStyle(goongStyleJson)` trực tiếp, hãy đánh giá lại:
|
||||
|
||||
- `glyphs`
|
||||
- `sprite`
|
||||
|
||||
vì khi đó chúng có thể trở thành dependency bắt buộc.
|
||||
|
||||
## 10. Gợi ý ngắn cho team BE
|
||||
|
||||
Nếu muốn làm ít rủi ro nhất:
|
||||
|
||||
1. làm proxy `assets/*.json`
|
||||
2. sanitize nested `api_key` trong style JSON
|
||||
3. làm proxy `sources/*.json`
|
||||
4. sanitize nested `api_key` trong source manifests
|
||||
5. làm proxy generic cho tile
|
||||
6. làm proxy Goong fonts/glyphs
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Goong Map Web Structure
|
||||
|
||||
Nguồn JSON gốc được tải về tại:
|
||||
|
||||
- `FrontEndUser/tmp/goong-styles/goong_map_web.json`
|
||||
|
||||
File này là style vector/label đầy đủ hơn, phù hợp để dò:
|
||||
|
||||
- water và water labels
|
||||
- boundary theo cấp
|
||||
- place labels cho lịch sử
|
||||
|
||||
## Mermaid overview
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
ROOT[goong_map_web.json]
|
||||
|
||||
ROOT --> S1[source: base]
|
||||
ROOT --> S2[source: composite]
|
||||
|
||||
S1 --> B1[source-layer: boundary]
|
||||
S1 --> B2[source-layer: worldcountriespoints]
|
||||
S1 --> B3[source-layer: worldnationalcapitals]
|
||||
|
||||
S2 --> C1[source-layer: riversandlakes]
|
||||
S2 --> C2[source-layer: rivernames]
|
||||
S2 --> C3[source-layer: lakenames]
|
||||
S2 --> C4[source-layer: vietnam_administrator]
|
||||
S2 --> C5[source-layer: streets_label]
|
||||
|
||||
B1 --> BL0[boundary-land-type-0 / type-0-bg]
|
||||
B1 --> BL1[boundary-land-type-1 / type-1-bg]
|
||||
B1 --> BL2[boundary-land-type-2 / type-2-bg]
|
||||
|
||||
B2 --> PC1[place-country-1]
|
||||
B2 --> PC2[place-country-2]
|
||||
|
||||
B3 --> CAP0[place-city-capital]
|
||||
|
||||
C1 --> W1[water]
|
||||
C1 --> W2[water-shadow]
|
||||
|
||||
C2 --> RN0[river-name-0]
|
||||
C2 --> RN1[river-name-1]
|
||||
C2 --> RN2[river-name-2]
|
||||
|
||||
C3 --> LN0[lake-name_priority_0]
|
||||
C3 --> LN1[lake-name_priority_1]
|
||||
C3 --> LN2[lake-name_priority_2]
|
||||
|
||||
C4 --> VA0[place-city-capital-vietnam]
|
||||
C4 --> VA1[place-city1 / place-city2]
|
||||
C4 --> VA2[place-town1 / place-town2]
|
||||
C4 --> VA3[place-suburb / borough / neighbourhood]
|
||||
C4 --> VA4[place-village]
|
||||
|
||||
C5 --> RD0[highway-name-minor]
|
||||
C5 --> RD1[highway-name-medium]
|
||||
C5 --> RD2[highway-name-major]
|
||||
```
|
||||
|
||||
## Boundary layers
|
||||
|
||||
Các layer boundary nổi bật:
|
||||
|
||||
- `boundary-land-type-0-bg`
|
||||
- `boundary-land-type-0`
|
||||
- `boundary-land-type-1-bg`
|
||||
- `boundary-land-type-1`
|
||||
- `boundary-land-type-2-bg`
|
||||
- `boundary-land-type-2`
|
||||
|
||||
Minzoom quan sát được:
|
||||
|
||||
- `type-0`: từ zoom `1`
|
||||
- `type-1`: từ zoom `5`
|
||||
- `type-2-bg`: từ zoom `7`
|
||||
- `type-2`: từ zoom `13`
|
||||
|
||||
Suy luận thực dụng:
|
||||
|
||||
- `type-0` có khả năng là biên giới quốc gia
|
||||
- `type-1` có khả năng là cấp tỉnh/thành
|
||||
- `type-2` có khả năng là cấp sâu hơn
|
||||
|
||||
## Water layers
|
||||
|
||||
Water fill:
|
||||
|
||||
- `water`
|
||||
- `water-shadow`
|
||||
|
||||
Water labels:
|
||||
|
||||
- `river-name-0`
|
||||
- `river-name-1`
|
||||
- `river-name-2`
|
||||
- `lake-name_priority_0`
|
||||
- `lake-name_priority_1`
|
||||
- `lake-name_priority_2`
|
||||
|
||||
## Place labels
|
||||
|
||||
Những label đáng quan tâm cho historical use:
|
||||
|
||||
- `place-country-1`
|
||||
- `place-country-2`
|
||||
- `place-city-capital`
|
||||
- `place-city-capital-vietnam`
|
||||
- `place-city1`
|
||||
- `place-city2`
|
||||
- `place-town1`
|
||||
- `place-town2`
|
||||
|
||||
Những label dễ gây rối nếu bật nhiều:
|
||||
|
||||
- `highway-name-*`
|
||||
- `place-suburb*`
|
||||
- `place-neighbourhood*`
|
||||
- `place-village`
|
||||
|
||||
## Gợi ý mapping cho UI
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,404 @@
|
||||
# Goong Proxy Backend Guide
|
||||
|
||||
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 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:
|
||||
|
||||
- [goong_apis_in_use.md](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/doc/goong_apis_in_use.md)
|
||||
- [goong_map_web_structure.md](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/doc/goong_map_web_structure.md)
|
||||
- [goong_satellite_structure.md](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/doc/goong_satellite_structure.md)
|
||||
|
||||
Code liên quan:
|
||||
|
||||
- [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:1)
|
||||
- [tiles.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/tiles.ts:1)
|
||||
- [useMapLayers.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/components/map/useMapLayers.ts:1)
|
||||
|
||||
## 1. Bối cảnh hiện tại
|
||||
|
||||
Frontend hiện tại không `setStyle(goongStyle)` trực tiếp cho MapLibre.
|
||||
|
||||
Thay vào đó:
|
||||
|
||||
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 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 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
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FE as Frontend
|
||||
participant GL as MapLibre
|
||||
participant BE as Backend Proxy
|
||||
participant GO as Goong
|
||||
|
||||
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->>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
|
||||
|
||||
FE->>GL: addSource(proxy tile URLs) + addLayer(...)
|
||||
|
||||
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
|
||||
|
||||
Nếu mục tiêu là:
|
||||
|
||||
- không lộ `api_key` ở browser
|
||||
- vẫn giữ frontend hiện tại gần như nguyên
|
||||
|
||||
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 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` có thể lộ ngay trong response JSON ở browser devtools
|
||||
|
||||
## 4. Những gì cần sanitize/rewrite
|
||||
|
||||
### 4.1. Style JSON
|
||||
|
||||
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 `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 sanitize:
|
||||
|
||||
- mọi phần tử trong `tiles[]`
|
||||
|
||||
Ví dụ:
|
||||
|
||||
- từ `https://.../{z}/{x}/{y}...api_key=...`
|
||||
- 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
|
||||
|
||||
Với kiến trúc frontend hiện tại:
|
||||
|
||||
- `glyphs` đang được FE dùng qua proxy
|
||||
- `sprite` hiện chưa dùng
|
||||
|
||||
Nghĩa là:
|
||||
|
||||
- BE **phải** proxy được `fonts/{fontstack}/{range}.pbf`
|
||||
- BE hiện **chưa cần** proxy `sprite`
|
||||
|
||||
Nếu sau này FE chuyển sang `map.setStyle(goongStyleJson)` trực tiếp thì phải đánh giá lại `sprite` ngay.
|
||||
|
||||
## 5. Backend endpoint được khuyến nghị
|
||||
|
||||
### 5.1. Style endpoints
|
||||
|
||||
- `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
|
||||
- strip `api_key` khỏi nested URL
|
||||
- trả JSON đã sanitize, chưa rewrite nested URL sang `/proxy/...`
|
||||
|
||||
### 5.2. Source endpoints
|
||||
|
||||
- `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
|
||||
- strip `api_key` khỏi `tiles[]`
|
||||
- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
|
||||
- giữ nguyên:
|
||||
- `bounds`
|
||||
- `minzoom`
|
||||
- `maxzoom`
|
||||
- `scheme`
|
||||
- `tileSize`
|
||||
- `attribution`
|
||||
|
||||
### 5.3. Tile endpoint
|
||||
|
||||
Route generic frontend hiện build:
|
||||
|
||||
- `GET /proxy/tiles.goong.io/...`
|
||||
|
||||
Nhiệm vụ:
|
||||
|
||||
- nhận tile request từ browser
|
||||
- map sang upstream tile URL tương ứng
|
||||
- gọi Goong bằng key server-side nếu upstream yêu cầu
|
||||
- stream response về browser
|
||||
|
||||
Điểm quan trọng:
|
||||
|
||||
- tile response không nên parse lại
|
||||
- tile response nên stream/pass-through
|
||||
- giữ cache headers càng nhiều càng tốt
|
||||
|
||||
## 6. Luồng request sau khi proxy
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FE as Frontend
|
||||
participant GL as MapLibre
|
||||
participant BE as Backend Proxy
|
||||
participant GO as Goong
|
||||
|
||||
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: sanitized style 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-->>FE: sanitized source manifests
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## 7. Trade-off hiệu suất
|
||||
|
||||
### 7.1. Sanitize JSON có chậm không?
|
||||
|
||||
Có overhead, nhưng **rất nhỏ** so với tile traffic.
|
||||
|
||||
JSON cần sanitize hiện tại chỉ gồm:
|
||||
|
||||
- 2 style JSON
|
||||
- 3 source manifests
|
||||
|
||||
Những file này nhỏ, số lượng ít, và có thể cache rất mạnh.
|
||||
|
||||
Kết luận:
|
||||
|
||||
- sanitize JSON không phải bottleneck chính
|
||||
|
||||
### 7.2. Tile proxy mới là chỗ đắt
|
||||
|
||||
Chi phí hiệu suất chính nằm ở:
|
||||
|
||||
- mọi tile phải đi qua backend
|
||||
- backend phải giữ thêm một hop mạng
|
||||
- mất lợi thế gọi trực tiếp CDN của Goong từ browser
|
||||
|
||||
Các ảnh hưởng có thể thấy:
|
||||
|
||||
- tăng latency
|
||||
- tăng bandwidth qua BE
|
||||
- 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 proxy tile/font URL
|
||||
|
||||
Nếu BE chỉ proxy style/source JSON nhưng thiếu tile/font route:
|
||||
|
||||
- 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à:
|
||||
|
||||
- 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 sanitized JSON ở BE
|
||||
|
||||
Khuyến nghị:
|
||||
|
||||
- cache in-memory hoặc Redis cho:
|
||||
- `goong_satellite.json`
|
||||
- `goong_map_web.json`
|
||||
- `sources/satellite.json`
|
||||
- `sources/base.json`
|
||||
- `sources/goong.json`
|
||||
|
||||
TTL có thể dài vì:
|
||||
|
||||
- style/source manifest không đổi liên tục
|
||||
|
||||
Tối ưu:
|
||||
|
||||
- chỉ sanitize một lần rồi reuse
|
||||
|
||||
### 8.2. Stream tile response
|
||||
|
||||
Cho tile route:
|
||||
|
||||
- không parse body
|
||||
- không buffer toàn bộ file vào memory nếu không cần
|
||||
- stream thẳng upstream -> client
|
||||
|
||||
### 8.3. Preserve cache headers
|
||||
|
||||
Với tile route, BE nên pass-through hoặc preserve:
|
||||
|
||||
- `Cache-Control`
|
||||
- `ETag`
|
||||
- `Last-Modified`
|
||||
- `Content-Type`
|
||||
|
||||
Nếu BE/ngược phía CDN có cache tốt, impact sẽ giảm rất nhiều.
|
||||
|
||||
### 8.4. Dùng CDN/reverse proxy trước BE nếu có thể
|
||||
|
||||
Nếu production có CDN/nginx/edge cache:
|
||||
|
||||
- cache mạnh cho:
|
||||
- sanitized style JSON
|
||||
- sanitized source manifests
|
||||
- tile responses
|
||||
|
||||
Điều này quan trọng hơn tối ưu code sanitize.
|
||||
|
||||
### 8.5. Đừng parse manifest ở mỗi tile request
|
||||
|
||||
Nên:
|
||||
|
||||
- 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:
|
||||
|
||||
- parse lại manifest ở mỗi tile request
|
||||
|
||||
## 9. Recommendation thực dụng
|
||||
|
||||
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, 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 vẫn dùng upstream target path sạch rồi tự wrap proxy URL
|
||||
|
||||
Nhược điểm:
|
||||
|
||||
- BE chịu toàn bộ traffic tile
|
||||
|
||||
### Option B. Hybrid
|
||||
|
||||
BE cover:
|
||||
|
||||
1. style JSON
|
||||
2. source manifests
|
||||
|
||||
Nhưng để tile/font đi trực tiếp upstream.
|
||||
|
||||
Ưu điểm:
|
||||
|
||||
- BE nhẹ hơn
|
||||
|
||||
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: **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. 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:
|
||||
|
||||
- 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
|
||||
|
||||
## 11. Checklist cho team BE
|
||||
|
||||
1. Tạo route proxy cho 2 style JSON
|
||||
2. Tạo route proxy cho 3 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. 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`
|
||||
@@ -0,0 +1,99 @@
|
||||
# Goong Satellite Structure
|
||||
|
||||
Nguồn JSON gốc được tải về tại:
|
||||
|
||||
- `FrontEndUser/tmp/goong-styles/goong_satellite.json`
|
||||
|
||||
File này là style satellite. Nó vẫn có boundary và labels, nhưng ít lớp nước hơn `goong_map_web.json`.
|
||||
|
||||
## Mermaid overview
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
ROOT[goong_satellite.json]
|
||||
|
||||
ROOT --> S0[source: satellite]
|
||||
ROOT --> S1[source: base]
|
||||
ROOT --> S2[source: composite]
|
||||
|
||||
S1 --> B1[source-layer: boundary]
|
||||
S1 --> B2[source-layer: worldcountriespoints]
|
||||
S1 --> B3[source-layer: worldnationalcapitals]
|
||||
|
||||
S2 --> C1[source-layer: vietnam_administrator]
|
||||
S2 --> C2[source-layer: streets_label]
|
||||
|
||||
B1 --> BL0[boundary-land-type-0 / type-0-bg]
|
||||
B1 --> BL1[boundary-land-type-1 / type-1-bg]
|
||||
B1 --> BL2[boundary-land-type-2 / type-2-bg]
|
||||
|
||||
B2 --> PC1[place-country-1]
|
||||
B2 --> PC2[place-country-2]
|
||||
|
||||
B3 --> CAP0[place-city-capital]
|
||||
|
||||
C1 --> VA0[place-city-capital-vietnam]
|
||||
C1 --> VA1[place-city1 / place-city2]
|
||||
C1 --> VA2[place-town1 / place-town2]
|
||||
C1 --> VA3[place-suburb / borough / neighbourhood]
|
||||
C1 --> VA4[place-village]
|
||||
|
||||
C2 --> RD0[highway-name-minor]
|
||||
C2 --> RD1[highway-name-medium]
|
||||
C2 --> RD2[highway-name-major]
|
||||
```
|
||||
|
||||
## Boundary layers
|
||||
|
||||
Các layer boundary nổi bật:
|
||||
|
||||
- `boundary-land-type-0-bg`
|
||||
- `boundary-land-type-0`
|
||||
- `boundary-land-type-1-bg`
|
||||
- `boundary-land-type-1`
|
||||
- `boundary-land-type-2-bg`
|
||||
- `boundary-land-type-2`
|
||||
|
||||
Minzoom quan sát được:
|
||||
|
||||
- `type-0`: từ zoom `1`
|
||||
- `type-1`: từ zoom `5`
|
||||
- `type-2-bg`: từ zoom `7`
|
||||
- `type-2`: từ zoom `7`
|
||||
|
||||
## Place labels
|
||||
|
||||
Labels hữu ích:
|
||||
|
||||
- `place-country-1`
|
||||
- `place-country-2`
|
||||
- `place-city-capital`
|
||||
- `place-city-capital-vietnam`
|
||||
- `place-city1`
|
||||
- `place-city2`
|
||||
- `place-town1`
|
||||
- `place-town2`
|
||||
|
||||
Labels dễ gây rối:
|
||||
|
||||
- `highway-name-*`
|
||||
- `place-suburb*`
|
||||
- `place-neighbourhood*`
|
||||
- `place-village`
|
||||
|
||||
## Khác biệt thực dụng so với goong_map_web
|
||||
|
||||
- Có `source: satellite`
|
||||
- Boundary vẫn hiện diện rõ
|
||||
- Labels hành chính vẫn có
|
||||
- Không lộ ra nhóm water chi tiết rõ như `goong_map_web`
|
||||
- Phù hợp làm raster/satellite nền hơn là style để dò water layers
|
||||
|
||||
## Gợi ý dùng thực tế
|
||||
|
||||
- Dùng `goong_satellite.json` cho nền satellite
|
||||
- Dùng `goong_map_web.json` để dò:
|
||||
- water
|
||||
- water labels
|
||||
- boundary theo cấp
|
||||
- labels hành chính
|
||||
@@ -0,0 +1,53 @@
|
||||
# Data Fetching Optimization: In-flight Promise Caching
|
||||
|
||||
## 1. Vấn đề (The Problem)
|
||||
Trong quá trình tương tác với bản đồ (ví dụ: kéo thả nhanh từ khu vực A sang B rồi sang C), các tính năng lấy dữ liệu quan hệ (entities, wikis) thường phải tải hàng chục đến hàng trăm item thông qua mảng ID (`geometryIds`).
|
||||
|
||||
Nếu chỉ sử dụng cơ chế Cache Data tĩnh (lưu kết quả sau khi API trả về), ta sẽ gặp phải bài toán **Race Condition** với các request đang bay (In-flight requests):
|
||||
- **A -> B**: Hệ thống gọi API xin 5 ID mới. Request mất 500ms để hoàn thành.
|
||||
- **B -> C** (xảy ra ở mốc 200ms): Lúc này request của B chưa xong, Cache tĩnh chưa có dữ liệu của 5 ID đó.
|
||||
- Hệ thống gửi tiếp API xin 10 ID mới (bao gồm 5 ID của C và **5 ID của B**).
|
||||
=> Hậu quả: Lãng phí băng thông, tải lại dữ liệu dư thừa.
|
||||
|
||||
## 2. Giải pháp (The Solution: DataLoader Pattern)
|
||||
Để khắc phục triệt để, hệ thống sử dụng **In-flight Promise Caching** tại tầng API (`src/uhm/api/relations.ts`). Thay vì chỉ lưu trữ Data, hệ thống lưu trữ **Tiến trình (Promise)**.
|
||||
|
||||
### Cơ chế hoạt động:
|
||||
1. **Kiểm tra Cache:** Khi nhận mảng `ids` cần tải, hệ thống kiểm tra xem ID nào đã có Promise tương ứng trong Cache (nghĩa là đang được tải hoặc đã tải xong).
|
||||
2. **Lọc Missing IDs:** Chỉ những ID chưa có Promise trong Cache mới được đưa vào mảng `missingIds` để gọi API.
|
||||
3. **Tạo Batch Promise:** Một HTTP Request duy nhất được gửi đi để tải `missingIds`. (Trả về `batchPromise`).
|
||||
4. **Chia tách Promise (Demultiplexing):** Với mỗi ID trong `missingIds`, hệ thống gán cho nó một Promise con (tách ra từ `batchPromise` cha) có nhiệm vụ chỉ extract dữ liệu của riêng ID đó. Các Promise con này lập tức được lưu vào Cache.
|
||||
5. **Đợi kết quả:** Hàm gọi `await Promise.all()` để chờ tất cả các Promise của `ids` yêu cầu hoàn thành và trả về.
|
||||
|
||||
## 3. Xử lý rủi ro (Trade-offs & Error Handling)
|
||||
- **Error Recovery:** Nếu một Promise bị reject (do đứt mạng, server lỗi), đoạn code tạo Promise con bắt buộc phải có khối `.catch()`. Trong khối này, ID lỗi **phải bị xóa khỏi Cache**. Nếu không xóa, UI sẽ vĩnh viễn tin rằng ID đó đã được xử lý xong và không bao giờ gọi lại (Deadlock).
|
||||
- **Memory Footprint:** Cache được lưu ở biến Global (`Map` hoặc `Record`). Nó sẽ tồn tại suốt phiên người dùng. Kích thước JSON là rất nhỏ, nên dung lượng RAM tăng lên không đáng kể.
|
||||
|
||||
## 4. Mã giả (Pseudocode)
|
||||
|
||||
```typescript
|
||||
const promiseCache: Record<string, Promise<any>> = {};
|
||||
|
||||
async function fetchCached(ids: string[]) {
|
||||
const missingIds = ids.filter(id => !promiseCache[id]);
|
||||
|
||||
if (missingIds.length > 0) {
|
||||
// 1. Tạo request cha
|
||||
const batchPromise = fetchFromServer(missingIds);
|
||||
|
||||
// 2. Chia nhỏ thành request con và lưu cache
|
||||
for (const id of missingIds) {
|
||||
promiseCache[id] = batchPromise
|
||||
.then(res => res[id])
|
||||
.catch(err => {
|
||||
delete promiseCache[id]; // QUAN TRỌNG: Xóa cache nếu lỗi
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Chờ tất cả Promise hoàn thành (kể cả cũ lẫn mới)
|
||||
const results = await Promise.all(ids.map(id => promiseCache[id]));
|
||||
return mergeResults(results);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
# Giải Pháp Tối Ưu Lighthouse Performance Cho Trang Bản Đồ `/`
|
||||
|
||||
Tệp bản đồ WebGL (MapLibre-GL / Goong-GL) kèm theo dữ liệu hình học (GeoJSON) rất nặng. Khi tải trang `/`, việc khởi tạo WebGL ngay lập tức sẽ chặn Main Thread, tăng thời gian **Total Blocking Time (TBT)**, trì hoãn **First Contentful Paint (FCP)** và **Largest Contentful Paint (LCP)**, khiến điểm số Google Lighthouse bị tụt giảm nghiêm trọng.
|
||||
|
||||
Dưới đây là các kỹ thuật "đánh lừa" và tối ưu hóa hiệu năng Lighthouse cho trang chủ `/` mà vẫn giữ nguyên trải nghiệm tốt nhất cho người dùng thật.
|
||||
|
||||
---
|
||||
|
||||
## Giải Pháp 1: Trì Hoãn Tải Bản Đồ Cho Đến Khi Có Tương Tác (Kỹ Thuật "Đánh Lừa" Hiệu Quả Nhất)
|
||||
|
||||
Lighthouse (hoặc bất kỳ Bot thu thập thông tin nào) chỉ tải trang một cách thụ động mà không thực hiện bất kỳ hành động cuộn chuột (scroll), di chuyển chuột (mousemove) hay nhấn phím (keydown/click) nào.
|
||||
|
||||
### Nguyên lý hoạt động:
|
||||
1. **Trạng thái ban đầu:** Hiển thị một ảnh chụp tĩnh của bản đồ (static map image/placeholder) hoặc một khung Skeleton Loading có giao diện giống hệt bản đồ thật để tránh lỗi dịch chuyển bố cục (**Cumulative Layout Shift - CLS**).
|
||||
2. **Kích hoạt tải thật:** Khi phát hiện bất kỳ tương tác nào từ người dùng thực tế (cuộn trang, rê chuột vào vùng bản đồ, chạm màn hình hoặc click), ứng dụng sẽ nạp mã nguồn bản đồ và khởi tạo canvas WebGL.
|
||||
|
||||
### Cách triển khai mã nguồn tại `src/app/page.tsx`:
|
||||
|
||||
```tsx
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Sử dụng dynamic import để Next.js tách nhỏ bundle bản đồ ra thành một file JS riêng
|
||||
const PreviewMapShell = dynamic(
|
||||
() => import("@/uhm/components/preview/PreviewMapShell"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <MapPlaceholder /> // Hiện placeholder tĩnh trong lúc tải
|
||||
}
|
||||
);
|
||||
|
||||
export default function Page() {
|
||||
const [loadRealMap, setLoadRealMap] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Lắng nghe tương tác người dùng để bắt đầu tải bản đồ thực tế
|
||||
const triggerInteractiveMap = () => {
|
||||
setLoadRealMap(true);
|
||||
cleanupListeners();
|
||||
};
|
||||
|
||||
const cleanupListeners = () => {
|
||||
window.removeEventListener("scroll", triggerInteractiveMap);
|
||||
window.removeEventListener("mousemove", triggerInteractiveMap);
|
||||
window.removeEventListener("touchstart", triggerInteractiveMap);
|
||||
window.removeEventListener("click", triggerInteractiveMap);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", triggerInteractiveMap, { passive: true });
|
||||
window.addEventListener("mousemove", triggerInteractiveMap, { passive: true });
|
||||
window.addEventListener("touchstart", triggerInteractiveMap, { passive: true });
|
||||
window.addEventListener("click", triggerInteractiveMap, { passive: true });
|
||||
|
||||
return () => cleanupListeners();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{loadRealMap ? (
|
||||
<PreviewMapShell {...mapProps} />
|
||||
) : (
|
||||
<MapPlaceholder />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MapPlaceholder() {
|
||||
return (
|
||||
<div className="relative h-screen w-full bg-[#0b1220] flex items-center justify-center overflow-hidden">
|
||||
{/*
|
||||
Sử dụng một hình ảnh chụp tĩnh bản đồ tuyệt đẹp làm hình nền.
|
||||
Ảnh này có dung lượng rất nhẹ (được nén WebP) giúp FCP/LCP đạt điểm tối đa.
|
||||
*/}
|
||||
<img
|
||||
src="/images/map_placeholder.webp"
|
||||
alt="Ultimate History Map Preview"
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-40 filter blur-[2px]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0b1220] via-transparent to-[#0b1220]/70" />
|
||||
|
||||
{/* UI loading ảo để người dùng thực cảm giác trang vẫn đang nạp mượt mà */}
|
||||
<div className="relative z-10 flex flex-col items-center gap-4">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-4 border-emerald-500/20 border-t-emerald-500" />
|
||||
<span className="text-sm font-semibold tracking-wider text-emerald-400">ĐANG TẢI DỮ LIỆU ĐỊA LÝ...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Giải Pháp 2: Trì Hoãn Nạp Bằng `requestIdleCallback` (Tránh Chặn Main Thread)
|
||||
|
||||
Nếu không muốn đợi tương tác người dùng, chúng ta có thể trì hoãn nạp bản đồ cho đến khi trình duyệt rơi vào trạng thái rảnh rỗi (idle).
|
||||
|
||||
### Nguyên lý:
|
||||
`requestIdleCallback` chỉ chạy khi luồng chính của trình duyệt không bận xử lý giao diện, kết hợp với trì hoãn cứng 1.5 - 2 giây để chắc chắn Lighthouse đã hoàn tất ghi nhận các chỉ số hiệu năng cơ bản.
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
const loadMapDeferred = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
const runLoad = () => {
|
||||
// Có thể bọc trong requestAnimationFrame để mượt mà hơn
|
||||
requestAnimationFrame(() => setLoadRealMap(true));
|
||||
};
|
||||
|
||||
if ("requestIdleCallback" in window) {
|
||||
window.requestIdleCallback(() => {
|
||||
setTimeout(runLoad, 1000);
|
||||
});
|
||||
} else {
|
||||
setTimeout(runLoad, 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadMapDeferred();
|
||||
}, []);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Giải Pháp 3: Tách Biệt Nhập CSS MapLibre
|
||||
|
||||
Tệp `maplibre-gl.css` hiện đang được import trực tiếp trong `Map.tsx`:
|
||||
```tsx
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
```
|
||||
Điều này khiến CSS của bản đồ bị gộp vào gói CSS chính của Next.js tải ngay khi người dùng vào trang đầu tiên.
|
||||
|
||||
### Giải pháp tối ưu:
|
||||
Chỉ tải CSS này động (dynamic load) khi bản đồ bắt đầu được nạp bằng cách chuyển dòng import này vào một effect của component `Map` hoặc tải qua thẻ `<link>` động chèn vào head.
|
||||
|
||||
---
|
||||
|
||||
## So Sánh Điểm Số Lighthouse Trước & Sau Khi Áp Dụng
|
||||
|
||||
| Chỉ số Lighthouse | Tải trực tiếp (Hiện tại) | Trì hoãn theo Tương tác (Giải pháp 1) | Trì hoãn Idle (Giải pháp 2) |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Performance Score** | **35 - 55** (Trung bình/Kém) | **95 - 100** (Xuất sắc) | **85 - 95** (Tốt) |
|
||||
| **First Contentful Paint (FCP)** | 1.8s - 2.5s | **0.3s - 0.5s** | 0.3s - 0.5s |
|
||||
| **Total Blocking Time (TBT)** | 400ms - 900ms | **0ms** | 50ms - 150ms |
|
||||
| **Speed Index** | 2.5s - 3.8s | **0.5s - 0.8s** | 0.8s - 1.2s |
|
||||
| **Cumulative Layout Shift (CLS)** | Thấp (nếu container cố định) | **0** (khớp kích thước placeholder) | 0 |
|
||||
@@ -0,0 +1,208 @@
|
||||
# UHM Map engine - kiến trúc hiện tại
|
||||
|
||||
Map editor hiện dùng `MapLibre GL` và được ghép từ 4 lớp chính:
|
||||
|
||||
- `useMapInstance`
|
||||
- `setupMapLayers`
|
||||
- `useMapInteraction`
|
||||
- `useMapSync`
|
||||
|
||||
Container chính là `src/uhm/components/Map.tsx`.
|
||||
|
||||
## 1. `useMapInstance`
|
||||
|
||||
Phụ trách lifecycle của đối tượng `maplibregl.Map`.
|
||||
|
||||
Các behavior đang có:
|
||||
|
||||
- khởi tạo map với `getBaseMapStyle()`
|
||||
- `center: [0, 20]`, `zoom: 2`
|
||||
- áp `minZoom` và `maxZoom`
|
||||
- lưu projection vào `localStorage` key `uhm:mapProjection`
|
||||
- cho phép chuyển giữa:
|
||||
- `mercator`
|
||||
- `globe`
|
||||
- theo dõi `zoomLevel`
|
||||
- thử center theo geolocation một lần khi map load xong
|
||||
|
||||
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()` chỉ dựng skeleton style MapLibre:
|
||||
|
||||
- `glyphs` trỏ vào Goong glyph proxy
|
||||
- `sources: {}`
|
||||
- một layer `background` màu nền tối
|
||||
|
||||
Background thật được thêm sau khi map load:
|
||||
|
||||
- `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
|
||||
|
||||
### Preview sources
|
||||
|
||||
- `draw-preview`
|
||||
- `draw-circle-preview`
|
||||
- `draw-line-preview`
|
||||
- `draw-path-preview`
|
||||
|
||||
Chúng chỉ dùng cho hình preview trong lúc user đang vẽ.
|
||||
|
||||
### Data sources
|
||||
|
||||
- `countries`
|
||||
- polygon + line-like features sau khi split/decorate
|
||||
- `places`
|
||||
- point features
|
||||
- `PATH_ARROW_SOURCE_ID`
|
||||
- shape phụ để render arrow cho path-like geometries
|
||||
- `POLYGON_LABEL_SOURCE_ID`
|
||||
- label source cho polygon names
|
||||
|
||||
### Editing overlay
|
||||
|
||||
- `edit-shape`
|
||||
- `edit-handles`
|
||||
|
||||
### Highlight/focus
|
||||
|
||||
- `entity-focus`
|
||||
|
||||
Source này dùng cho:
|
||||
|
||||
- highlight geometry khi cần focus
|
||||
- visual emphasis khi zoom từ search/binding panel
|
||||
|
||||
## 4. Tách dữ liệu trước khi đẩy lên map
|
||||
|
||||
`useMapSync()` chịu trách nhiệm:
|
||||
|
||||
1. nhận `renderDraft` đã được page áp timeline/replay/preview filter trước
|
||||
2. filter draft theo `bound_with` 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 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 bound_with filter, geometry visibility và label decoration
|
||||
|
||||
## 5. Map interaction layer
|
||||
|
||||
`useMapInteraction()` nối editor mode với các engine.
|
||||
|
||||
Binding hiện tại:
|
||||
|
||||
- `draw` -> `initDrawing`
|
||||
- `select` -> `initSelect`
|
||||
- `replay` -> `initSelect`
|
||||
- `add-line` -> `initLine`
|
||||
- `add-path` -> `initPath`
|
||||
- `add-circle` -> `initCircle`
|
||||
|
||||
`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`
|
||||
|
||||
- vẽ polygon bằng chuỗi click
|
||||
- preview fill + line
|
||||
- hỗ trợ snap bằng `Shift` hoặc `Alt`
|
||||
|
||||
### `initPoint`
|
||||
|
||||
- tạo point bằng một click
|
||||
|
||||
### `initLine`
|
||||
|
||||
- tạo line nhiều đỉnh
|
||||
- preview dashed line
|
||||
|
||||
### `initPath`
|
||||
|
||||
- giống line nhưng có path arrow layer khi preview/render
|
||||
|
||||
### `initCircle`
|
||||
|
||||
- tạo circle bằng kéo chuột
|
||||
- kết quả cuối là `Polygon` có metadata circle
|
||||
|
||||
### `createEditingEngine`
|
||||
|
||||
- chỉ edit `Polygon`
|
||||
- nếu polygon có `circle_center`, engine chuyển sang circle-edit mode
|
||||
- hỗ trợ kéo handle và chèn thêm đỉnh bằng `Ctrl/Cmd`
|
||||
|
||||
## 7. Chế độ `select` và `replay`
|
||||
|
||||
`initSelect` hiện đóng nhiều vai trò:
|
||||
|
||||
- chọn geometry
|
||||
- xóa geometry
|
||||
- bắt đầu edit geometry
|
||||
- chuyển sang `replay`
|
||||
|
||||
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:
|
||||
|
||||
- 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
|
||||
|
||||
`useMapSync()` xóa feature state cũ trên các source liên quan, sau đó set lại `selected` cho `selectedFeatureIds`.
|
||||
|
||||
Điều này giúp:
|
||||
|
||||
- selected style trên map không bị stale
|
||||
- selection vẫn đúng sau mỗi lần source data đổi
|
||||
|
||||
## 9. Fit/focus behavior
|
||||
|
||||
Map có hai kiểu focus khác nhau:
|
||||
|
||||
- `fitToDraftBounds`
|
||||
- dùng khi muốn fit toàn bộ draft
|
||||
- `focusFeatureCollection` + `focusRequestKey`
|
||||
- dùng khi zoom tới geometry cụ thể từ panel/search
|
||||
|
||||
Focus này đi qua `fitMapToFeatureCollection(...)`.
|
||||
|
||||
## 10. Geolocation
|
||||
|
||||
Sau khi map load:
|
||||
|
||||
- nếu chưa từng center theo geolocation trong session
|
||||
- và không bật `fitToDraftBounds`
|
||||
- và browser hỗ trợ geolocation
|
||||
|
||||
thì map sẽ thử `navigator.geolocation.getCurrentPosition(...)` một lần để dời tâm người dùng.
|
||||
|
||||
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
|
||||
- `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`
|
||||
@@ -0,0 +1,175 @@
|
||||
# UHM map styling - hệ thống layer và style
|
||||
|
||||
Tài liệu này mô tả styling thật đang được map editor dùng.
|
||||
|
||||
## 1. Hai nhóm style chính
|
||||
|
||||
Map hiện có hai nhóm style tách biệt:
|
||||
|
||||
- background/base map style
|
||||
- geotype style cho dữ liệu editor
|
||||
|
||||
### Background/base map
|
||||
|
||||
`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
|
||||
|
||||
Định nghĩa trong `src/uhm/lib/map/styles/`.
|
||||
|
||||
## 2. Background layers đang có
|
||||
|
||||
Danh sách layer toggle được expose ở `backgroundLayers.ts`:
|
||||
|
||||
- `raster-base-layer`
|
||||
- `bg-country-borders-line`
|
||||
- `bg-province-borders-line`
|
||||
- `bg-district-borders-line`
|
||||
- `country-labels`
|
||||
- `rivers-line`
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- `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:
|
||||
|
||||
- `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
|
||||
|
||||
Geotype render hiện được tập trung ở `getAllGeotypeLayers(...)` trong `geotypeLayers.ts`.
|
||||
|
||||
Các type đang được register:
|
||||
|
||||
- `defense_line`
|
||||
- `military_route`
|
||||
- `retreat_route`
|
||||
- `migration_route`
|
||||
- `trade_route`
|
||||
- `country`
|
||||
- `state`
|
||||
- `faction`
|
||||
- `battle`
|
||||
- `rebellion_zone`
|
||||
- `person_event`
|
||||
- `temple`
|
||||
- `capital`
|
||||
- `city`
|
||||
- `fortification`
|
||||
- `ruin`
|
||||
- `port`
|
||||
|
||||
`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
|
||||
|
||||
Style matcher trung tâm là:
|
||||
|
||||
- `TYPE_MATCH_EXPR = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""]`
|
||||
|
||||
Điều này cho phép layer match theo:
|
||||
|
||||
- `feature.properties.type`
|
||||
- fallback sang `entity_type_id` nếu cần
|
||||
|
||||
Với editor hiện tại, `type` là field chính.
|
||||
|
||||
## 5. Point, line, polygon và label sources
|
||||
|
||||
Map không render mọi thứ từ một source duy nhất theo nghĩa trực tiếp.
|
||||
Pipeline hiện tại tách ra:
|
||||
|
||||
- `countries`
|
||||
- polygon và line-like feature data
|
||||
- `places`
|
||||
- point data
|
||||
- `PATH_ARROW_SOURCE_ID`
|
||||
- arrow shapes cho route/path
|
||||
- `POLYGON_LABEL_SOURCE_ID`
|
||||
- polygon labels
|
||||
|
||||
Label layer cho polygon/line đi qua:
|
||||
|
||||
- `getAllGeotypeLabelLayers(...)`
|
||||
- helper trong `shared/polygonLabels.ts`
|
||||
- helper trong `shared/lineLabels.ts`
|
||||
|
||||
## 6. Icon point
|
||||
|
||||
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
|
||||
|
||||
Ngoài style dữ liệu chính, map còn có style riêng cho:
|
||||
|
||||
### Draw preview
|
||||
|
||||
- `draw-preview-fill`
|
||||
- `draw-preview-line`
|
||||
- `draw-circle-preview-fill`
|
||||
- `draw-circle-preview-line`
|
||||
- `draw-line-preview-line`
|
||||
- `draw-path-preview-line`
|
||||
- `draw-path-preview-arrows`
|
||||
|
||||
### Editing overlay
|
||||
|
||||
- `edit-shape-line`
|
||||
- `edit-handles-circle`
|
||||
|
||||
### Focus/highlight
|
||||
|
||||
- `entity-focus-fill`
|
||||
- `entity-focus-line`
|
||||
- `entity-focus-points`
|
||||
|
||||
Các layer này không đi qua geotype registry.
|
||||
|
||||
## 8. Visibility filtering
|
||||
|
||||
Có ba lớp filter hiển thị trong runtime:
|
||||
|
||||
1. background layer visibility
|
||||
2. geometry visibility theo type key từ panel phải
|
||||
3. bound_with filter / replay filter / timeline filter ở phía data trước khi set source
|
||||
|
||||
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:
|
||||
|
||||
1. Thêm mapping vào `geoTypeMap` nếu backend dùng numeric/type code.
|
||||
2. Thêm option vào `geometryTypeOptions.ts`.
|
||||
3. Tạo file style mới trong `styles/geotypes/`.
|
||||
4. Register nó trong `getAllGeotypeLayers(...)`.
|
||||
5. Nếu cần label riêng, cập nhật layer builder tương ứng.
|
||||
6. Nếu là point type, kiểm tra icon pipeline.
|
||||
7. Nếu muốn user tạo geometry mới với type đó mặc định từ tool nào đó, cập nhật `useMapInteraction.ts`.
|
||||
|
||||
## 10. Điều doc cũ mô tả chưa chính xác
|
||||
|
||||
Doc cũ nói tới filter thời gian ở từng layer như một biểu thức layer-level chuẩn.
|
||||
Implementation hiện tại không làm vậy.
|
||||
|
||||
Thay vào đó:
|
||||
|
||||
- timeline filter đang chạy phía data trong `page.tsx`
|
||||
- bound_with filter và geometry visibility cũng chủ yếu chạy trước khi set source
|
||||
|
||||
Tức là phần lớn filtering là `prepare data -> set source`, không phải `add layer filter expression per year`.
|
||||
@@ -0,0 +1,183 @@
|
||||
# Preview Place Search
|
||||
|
||||
Cập nhật: 2026-05-26.
|
||||
|
||||
Tính năng này cho phép người dùng search trong preview mode theo 2 chế độ:
|
||||
|
||||
- `Present`: search địa điểm hiện tại bằng Goong Place API.
|
||||
- `History`: search entity lịch sử bằng API domain, sau đó dùng Goong reverse geocode để đặt nhãn hành chính hiện tại cho từng geometry candidate khi cần.
|
||||
|
||||
Đây là UI điều hướng tạm thời, không chỉnh sửa draft và không đi vào commit snapshot.
|
||||
|
||||
## Phạm vi
|
||||
|
||||
- Chỉ hiển thị trong preview mode:
|
||||
- `preview`
|
||||
- `replay_preview`
|
||||
- Không hiển thị trong editor mode thường hoặc replay edit mode.
|
||||
- Không tạo geometry, entity, wiki, replay, hay undo action.
|
||||
- Khi thoát preview, state focus search được dọn khỏi UI.
|
||||
|
||||
## File liên quan
|
||||
|
||||
- `next.config.ts`
|
||||
- Expose tạm `SEARCH_MAP_API_KEY` cho browser qua `env`.
|
||||
- `src/uhm/api/goongPlaces.ts`
|
||||
- Gọi Goong Place Autocomplete, Detail, và Geocode reverse.
|
||||
- Chuẩn hóa response thành type nội bộ.
|
||||
- `src/uhm/components/editor/PresentPlaceSearch.tsx`
|
||||
- UI search/autocomplete với switch `Present` / `History`.
|
||||
- Debounce request.
|
||||
- Chọn kết quả và gọi callback focus.
|
||||
- `src/uhm/api/geometries.ts`
|
||||
- Gọi `/geometries/entity` cho search entity lịch sử.
|
||||
- `src/uhm/components/map/mapUtils.ts`
|
||||
- `getGeometryRepresentativePoint(...)` dùng polylabel cho polygon, midpoint cho line, và tọa độ thật cho point.
|
||||
- `src/app/editor/[id]/page.tsx`
|
||||
- Gắn UI vào preview overlay.
|
||||
- Gọi `map.flyTo(...)` tới tọa độ địa điểm.
|
||||
|
||||
## Cấu hình
|
||||
|
||||
Hiện tại API key được đọc từ `.env.local`:
|
||||
|
||||
```env
|
||||
SEARCH_MAP_API_KEY=...
|
||||
```
|
||||
|
||||
Do đang dùng trực tiếp trên frontend, `next.config.ts` expose biến này:
|
||||
|
||||
```ts
|
||||
env: {
|
||||
SEARCH_MAP_API_KEY: process.env.SEARCH_MAP_API_KEY,
|
||||
}
|
||||
```
|
||||
|
||||
Sau khi thêm hoặc đổi key trong `.env.local`, phải restart Next dev server để value được bundle lại vào client.
|
||||
|
||||
## Luồng Present
|
||||
|
||||
1. User vào preview mode.
|
||||
2. `PresentPlaceSearch` xuất hiện ở góc phải phía trên, ngang hàng với thanh zoom/map controls.
|
||||
3. User để switch ở `Present` và nhập ít nhất 2 ký tự.
|
||||
4. UI debounce khoảng 260ms rồi gọi:
|
||||
|
||||
```txt
|
||||
GET https://rsapi.goong.io/Place/AutoComplete
|
||||
```
|
||||
|
||||
5. User chọn một prediction.
|
||||
6. FE gọi:
|
||||
|
||||
```txt
|
||||
GET https://rsapi.goong.io/Place/Detail
|
||||
```
|
||||
|
||||
7. Response detail được chuẩn hóa thành:
|
||||
|
||||
```ts
|
||||
type PresentPlaceSelection = {
|
||||
placeId: string;
|
||||
name: string;
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
};
|
||||
```
|
||||
|
||||
8. Editor page:
|
||||
- gọi `map.flyTo({ center: [lng, lat], zoom: Math.max(currentZoom, 13.5) })`,
|
||||
- lưu `focusedPresentPlace` để clear trạng thái focus khi cần.
|
||||
|
||||
## Luồng History
|
||||
|
||||
1. User bấm switch `Present` để đổi sang `History`.
|
||||
2. User nhập tên entity lịch sử.
|
||||
3. UI debounce khoảng 260ms rồi gọi API domain:
|
||||
|
||||
```txt
|
||||
GET /geometries/entity?name=...&limit=12
|
||||
```
|
||||
|
||||
4. Nếu entity có đúng 1 geometry:
|
||||
- focus luôn geometry đó.
|
||||
5. Nếu entity có nhiều geometry:
|
||||
- mở danh sách geometry phụ.
|
||||
- với mỗi geometry, FE tính representative point:
|
||||
- `Polygon/MultiPolygon`: dùng `polylabel`.
|
||||
- `LineString/MultiLineString`: dùng midpoint theo chiều dài line.
|
||||
- `Point/MultiPoint`: dùng tọa độ point hoặc trung bình các point.
|
||||
- gọi Goong reverse geocode:
|
||||
|
||||
```txt
|
||||
GET https://rsapi.goong.io/Geocode?latlng=<lat>,<lng>&api_key=...
|
||||
```
|
||||
|
||||
- hiển thị nhãn hành chính hiện tại gần geometry đó.
|
||||
6. Khi user chọn geometry:
|
||||
- focus map vào bbox geometry bằng `fitMapToFeatureCollection`.
|
||||
- nếu timeline filter đang bật và geometry có `time_start`, kéo preview timeline tới `time_start`.
|
||||
- nếu geometry đó đang được render trên map, set selection cho geometry id tương ứng.
|
||||
|
||||
## UI behavior
|
||||
|
||||
- Thanh search nằm bên phải trên map preview.
|
||||
- Nhãn `Present` / `History` trong hàng input là switch mode; không có cụm tab riêng phía trên.
|
||||
- Khi replay preview đang mở wiki sidebar, search nhận `rightOffset = previewSidebarWidth + 48` để né sidebar.
|
||||
- `Escape` đóng dropdown.
|
||||
- `Enter` chọn kết quả đầu tiên nếu có.
|
||||
- Nút `x` clear query hiện tại và clear focus search.
|
||||
- Kết quả hiển thị dạng panel gọn, không dùng scrollbar nội bộ.
|
||||
- Focus search chỉ di chuyển camera/select geometry; không vẽ marker/dấu chấm tạm trên map.
|
||||
|
||||
## State và undo
|
||||
|
||||
State bị đổi:
|
||||
|
||||
- `focusedPresentPlace`
|
||||
- local state trong `PresentPlaceSearch`: query, results, loading, error
|
||||
- local state của mode History: query, entity results, expanded entity, admin labels
|
||||
|
||||
Không đổi:
|
||||
|
||||
- `editor.mainDraft`
|
||||
- `editor.replayDraft`
|
||||
- `snapshotEntityRows`
|
||||
- `snapshotWikis`
|
||||
- `snapshotEntityWikiLinks`
|
||||
- `replays`
|
||||
- `selectedFeatureIds`
|
||||
|
||||
Do đó không cần undo action.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Nếu thiếu `SEARCH_MAP_API_KEY`, input bị disable và hiển thị lỗi cấu hình.
|
||||
- Nếu Goong trả response không hợp lệ hoặc không có tọa độ, UI hiển thị lỗi trong dropdown hoặc fallback label cho geometry.
|
||||
- Request autocomplete được abort khi query đổi hoặc component unmount.
|
||||
- Response cũ bị bỏ qua bằng sequence ref để tránh race khi user gõ nhanh.
|
||||
|
||||
## Giới hạn hiện tại
|
||||
|
||||
- API key đang expose trên browser. Đây chỉ là giải pháp tạm.
|
||||
- Chưa có cache bền vững cho autocomplete/detail/reverse geocode.
|
||||
- Chưa giới hạn theo quốc gia/khu vực.
|
||||
- Chưa có keyboard navigation bằng mũi tên trong dropdown.
|
||||
- History reverse geocode đang chạy trên browser theo từng geometry candidate khi mở entity nhiều geometry.
|
||||
|
||||
## Kế hoạch chuyển sang proxy BE
|
||||
|
||||
Khi backend có proxy, bỏ expose key khỏi `next.config.ts` và đổi `src/uhm/api/goongPlaces.ts` sang gọi endpoint nội bộ, ví dụ:
|
||||
|
||||
```txt
|
||||
GET /api/map-search/place-autocomplete?input=...
|
||||
GET /api/map-search/place-detail?place_id=...
|
||||
GET /api/map-search/reverse-geocode?lat=...&lng=...
|
||||
```
|
||||
|
||||
Backend giữ `SEARCH_MAP_API_KEY` ở server env và gọi Goong thay frontend. Response nên giữ shape tương thích với `PresentPlacePrediction`, `PresentPlaceSelection`, và reverse geocode label để không phải sửa UI.
|
||||
|
||||
Tham khảo thêm:
|
||||
|
||||
- `src/uhm/doc/goong_proxy_backend_guide.md`
|
||||
- `src/uhm/doc/goong_apis_in_use.md`
|
||||
@@ -0,0 +1,183 @@
|
||||
# UHM Editor - project workflow hiện tại
|
||||
|
||||
Tài liệu này mô tả đúng luồng project editor đang chạy ở frontend hiện tại.
|
||||
|
||||
## 1. Mở project
|
||||
|
||||
Editor vào từ route `/editor/[id]`.
|
||||
|
||||
Luồng mở project:
|
||||
|
||||
1. `fetchCurrentUser()` để chắc phiên đăng nhập còn hợp lệ.
|
||||
2. `openSectionEditor(projectId)`:
|
||||
- gọi API project detail
|
||||
- gọi API commit list
|
||||
- lấy `latest_commit_id`
|
||||
- load `snapshot_json` của head commit nếu có
|
||||
3. `normalizeEditorSnapshot()` để đưa snapshot về shape editor hiện tại.
|
||||
4. `toEditorSessionSnapshot()` để chuyển snapshot thành session state:
|
||||
- entities
|
||||
- wikis
|
||||
- entity-wiki
|
||||
- feature collection đã rehydrate entity ids / names / metadata
|
||||
|
||||
Nếu project chưa có commit, editor mở với `EMPTY_FEATURE_COLLECTION`.
|
||||
|
||||
## 2. Rule khóa editor khi có pending submission
|
||||
|
||||
Backend mới chặn chỉnh sửa nếu project có submission `PENDING`.
|
||||
|
||||
Frontend xử lý như sau:
|
||||
|
||||
- `openSectionEditor()` ném `ApiError(409)` kèm `pending_submission_id`
|
||||
- page editor bắt lỗi đó
|
||||
- hiển thị màn hình lock riêng
|
||||
- cho phép xóa submission pending để mở khóa
|
||||
|
||||
Trong trạng thái này:
|
||||
|
||||
- không vào map editor
|
||||
- không commit
|
||||
- không submit mới
|
||||
|
||||
## 3. Trạng thái project mà editor thực sự dùng
|
||||
|
||||
`ProjectState` đang được FE dùng gồm:
|
||||
|
||||
- `status`
|
||||
- `head_commit_id`
|
||||
- `locked_by`
|
||||
|
||||
Editor page không tự dựng đầy đủ workflow `Approved/Rejected` ở UI.
|
||||
Phần nó thật sự quan tâm là:
|
||||
|
||||
- project có mở được không
|
||||
- có `head_commit_id` để submit không
|
||||
- có pending submission đang khóa project không
|
||||
|
||||
## 4. Vòng đời một phiên chỉnh sửa
|
||||
|
||||
### Bước 1: load baseline
|
||||
|
||||
- `baselineSnapshot` lấy từ head commit hoặc commit được restore
|
||||
- `baselineFeatureCollection` lấy từ `baselineSnapshot.editor_feature_collection`
|
||||
- `useEditorState()` reset draft và undo
|
||||
|
||||
### Bước 2: chỉnh sửa cục bộ
|
||||
|
||||
User có thể sửa:
|
||||
|
||||
- geometry
|
||||
- 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.
|
||||
|
||||
### Bước 3: commit
|
||||
|
||||
`commitSection()` chỉ chạy khi:
|
||||
|
||||
- đã mở được project
|
||||
- `pendingSaveCount > 0`
|
||||
- không còn orphan geometry
|
||||
|
||||
Luồng commit:
|
||||
|
||||
1. build geometry diff từ `editor.buildPayload()`
|
||||
2. build snapshot đầy đủ bằng `buildEditorSnapshot(...)`
|
||||
3. kiểm tra kích thước payload trước khi gửi
|
||||
4. gọi `createProjectCommit(projectId, { snapshot, edit_summary })`
|
||||
5. nếu thành công:
|
||||
- refresh `projectState`
|
||||
- refresh `sectionCommits`
|
||||
- cập nhật `baselineSnapshot`
|
||||
- set `baselineFeatureCollection = editor.mainDraft`
|
||||
- `editor.clearChanges()`
|
||||
- clear `commitTitle`
|
||||
|
||||
### Bước 4: submit
|
||||
|
||||
`submitCurrentSection(content)` chỉ chạy khi:
|
||||
|
||||
- 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.
|
||||
|
||||
## 5. Restore commit
|
||||
|
||||
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
|
||||
- nạp lại vào editor state
|
||||
|
||||
Restore này:
|
||||
|
||||
- không gọi endpoint đổi head commit
|
||||
- không thay đổi head trên backend
|
||||
- chủ yếu để user tiếp tục edit từ snapshot cũ
|
||||
|
||||
Nói cách khác, đây là `load snapshot into editor`, không phải `server-side restore`.
|
||||
|
||||
## 6. Snapshot commit được build như thế nào
|
||||
|
||||
`buildEditorSnapshot()` nhận:
|
||||
|
||||
- `draft`
|
||||
- `changes`
|
||||
- `snapshotEntityRows`
|
||||
- `snapshotWikis`
|
||||
- `snapshotEntityWikiLinks`
|
||||
- `effectiveReplays`
|
||||
- `previousSnapshot`
|
||||
|
||||
và sinh ra:
|
||||
|
||||
- `editor_feature_collection`
|
||||
- `entities`
|
||||
- `geometries`
|
||||
- `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`, `bound_with`, `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
|
||||
|
||||
Số ở nút `Commit` là `pendingSaveCount`.
|
||||
|
||||
Nó gồm:
|
||||
|
||||
- số geometry change thật
|
||||
- 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:
|
||||
|
||||
- `Commit (3)` không có nghĩa là backend sẽ nhận đúng 3 record thay đổi
|
||||
- nó là chỉ báo "có bao nhiêu nhóm thay đổi cần commit"
|
||||
|
||||
## 8. Những gì workflow hiện chưa làm
|
||||
|
||||
Editor hiện chưa có các behavior sau:
|
||||
|
||||
- autosave local draft toàn project
|
||||
- collaborative locking nhiều user ở FE
|
||||
- review UI cho `Approved/Rejected`
|
||||
- restore head commit trên backend từ trang editor
|
||||
- branch/merge nhiều phiên edit song song
|
||||
@@ -0,0 +1,78 @@
|
||||
# Hướng Dẫn Các Hàm Hành Động Replay (Replay Actions Guide)
|
||||
|
||||
Tài liệu này mô tả tác dụng thực tế (hiệu ứng hiển thị trên Bản đồ và Giao diện người dùng) của các hàm hành động được sử dụng để xây dựng nội dung phát lại lịch sử (Replay).
|
||||
|
||||
---
|
||||
|
||||
## 1. Nhóm Bản Đồ & Đối Tượng (Map & Geo Actions)
|
||||
|
||||
Các hàm trong nhóm này trực tiếp điều khiển camera bản đồ, thay đổi cách hiển thị hoặc tạo hiệu ứng hình ảnh cho các đối tượng địa lý (Geometries).
|
||||
|
||||
* **`set_camera_view` (Đặt góc nhìn camera)**
|
||||
* *Tác dụng:* Thay đổi ngay lập tức góc nhìn của bản đồ tới một vị trí cụ thể (tọa độ trung tâm, mức độ phóng to/thu nhỏ, độ nghiêng bản đồ, hướng quay bản đồ).
|
||||
|
||||
* **`set_labels_visible` (Bật/tắt nhãn bản đồ)**
|
||||
* *Tác dụng:* Hiển thị hoặc ẩn đi các tên địa danh, địa điểm mặc định của bản đồ nền.
|
||||
|
||||
* **`fly_to_geometries` (Di chuyển camera tới đối tượng)**
|
||||
* *Tác dụng:* Tạo hiệu ứng di chuyển camera mượt mà (bay tự động) để định vị và tự động điều chỉnh khung hình ôm trọn một hoặc nhiều đối tượng địa lý được chỉ định.
|
||||
|
||||
* **`set_geometry_visibility` (Bật/tắt hiển thị đối tượng)**
|
||||
* *Tác dụng:* Ẩn đi hoặc hiện lên một hoặc nhiều đối tượng địa lý cụ thể trên bản đồ.
|
||||
|
||||
* **`follow_geometries_path` (Di chuyển camera theo tuyến đường/đường đi)**
|
||||
* *Tác dụng:* Camera sẽ tự động di chuyển bám sát theo một lộ trình vẽ sẵn (tạo bởi các đối tượng địa lý) với tốc độ và độ cao phóng to xác định. Thường dùng để mô phỏng quá trình hành quân hoặc di chuyển.
|
||||
|
||||
* **`hide_others_geometries` (Ẩn tất cả các đối tượng khác)**
|
||||
* *Tác dụng:* Chỉ giữ lại các đối tượng được chọn hiển thị trên bản đồ, đồng thời ẩn toàn bộ các đối tượng địa lý còn lại để người xem tập trung vào khu vực quan trọng.
|
||||
|
||||
* **`pulse_geometry` (Tạo hiệu ứng nhấp nháy đối tượng)**
|
||||
* *Tác dụng:* Làm cho một đối tượng địa lý nhấp nháy (tỏa ánh sáng phát quang) với màu sắc tự chọn để thu hút sự chú ý của người xem.
|
||||
|
||||
* **`animate_dashed_border` (Hiệu ứng chuyển động viền nét đứt)**
|
||||
* *Tác dụng:* Tạo hiệu ứng viền nét đứt chạy chuyển động xung quanh một đối tượng địa lý. Thường dùng để làm nổi bật biên giới hoặc các tuyến phòng thủ đang hoạt động.
|
||||
|
||||
* **`set_geometry_style` (Thay đổi kiểu dáng đối tượng)**
|
||||
* *Tác dụng:* Đổi màu sắc, độ mờ (opacity), màu viền và độ dày viền của đối tượng địa lý ngay lập tức để biểu thị sự thay đổi trạng thái (ví dụ: chuyển từ vùng kiểm soát của phe này sang phe khác).
|
||||
|
||||
* **`orbit_camera_around_geometry` (Quay camera quanh đối tượng)**
|
||||
* *Tác dụng:* Camera tự động xoay vòng tròn xung quanh một đối tượng địa lý để tạo góc nhìn toàn cảnh 3D sinh động.
|
||||
|
||||
* **`set_as_background_geometries` (Đặt làm hình học nền / background)**
|
||||
* *Tác dụng:* Đánh dấu các đối tượng được chọn làm lớp nền (Background). Các đối tượng này sẽ **luôn luôn hiển thị** và không bị ảnh hưởng (không bị ẩn) bởi bất kỳ lệnh ẩn nào khác như `hide_others_geometries` hay `set_geometry_visibility(..., false)`.
|
||||
|
||||
* **`remove_from_background_geometries` (Loại bỏ khỏi hình học nền)**
|
||||
* *Tác dụng:* Hủy trạng thái làm nền (Background) của các đối tượng được chọn, đưa chúng trở lại thành các đối tượng bình thường chịu ảnh hưởng của các lệnh ẩn/hiện khác.
|
||||
|
||||
---
|
||||
|
||||
## 2. Nhóm Giao Diện Người Dùng (UI Actions)
|
||||
|
||||
Các hàm điều khiển việc đóng/mở hoặc thay đổi các thành phần giao diện xung quanh bản đồ.
|
||||
|
||||
* **`timeline` (Hiện/ẩn dòng thời gian)**
|
||||
* *Tác dụng:* Hiển thị hoặc ẩn đi thanh dòng thời gian (Timeline Bar) ở phía dưới màn hình.
|
||||
|
||||
* **`layer_panel` (Hiện/ẩn bảng điều khiển lớp bản đồ)**
|
||||
* *Tác dụng:* Hiển thị hoặc ẩn đi bảng cho phép người xem chọn bật/tắt các lớp bản đồ nền hoặc các loại đối tượng.
|
||||
|
||||
* **`zoom_panel` (Hiện/ẩn công cụ phóng to/thu nhỏ)**
|
||||
* *Tác dụng:* Hiển thị hoặc ẩn đi các nút điều khiển thu phóng nhanh trên màn hình.
|
||||
|
||||
* **`wiki` (Mở/đóng trang Wiki chi tiết)**
|
||||
* *Tác dụng:* Mở bảng thông tin chi tiết (Wiki Sidebar) của một bài viết lịch sử cụ thể, hoặc đóng lại nếu không truyền tham số bài viết.
|
||||
|
||||
* **`toast` (Hiển thị thông báo nhanh)**
|
||||
* *Tác dụng:* Hiện một ô thông báo nhỏ (Toast) tự biến mất ở góc màn hình để thông báo sự kiện nhanh cho người xem.
|
||||
|
||||
---
|
||||
|
||||
## 3. Nhóm Dẫn Chuyện (Narrative Actions)
|
||||
|
||||
Nhóm các hàm tương tác với hộp thoại thuyết minh và tư liệu hình ảnh.
|
||||
|
||||
* **`set_dialog` (Cấu hình hộp thoại thuyết minh)**
|
||||
* *Tác dụng:*
|
||||
* Hiển thị hoặc cập nhật nội dung văn bản trong hộp thoại thuyết minh của Replay.
|
||||
* Hiển thị hình ảnh tư liệu lịch sử đính kèm trong hộp thoại.
|
||||
* Ẩn hộp thoại thuyết minh hoàn toàn khi không truyền nội dung.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Hướng Dẫn Hệ Thống Undo / Redo Trong Replay Mode
|
||||
|
||||
Tài liệu này mô tả chi tiết kiến trúc hoạt động của hệ thống Undo/Redo khi người dùng thao tác trong chế độ Biên tập Replay (Replay Editor Mode), bao gồm danh sách các hành động (actions) được ghi nhận và các hàm tương ứng chịu trách nhiệm lưu trữ lịch sử.
|
||||
|
||||
---
|
||||
|
||||
## 1. Kiến Trúc Hai Nhánh Undo (Double Undo Stack)
|
||||
|
||||
Hệ thống editor sử dụng hai ngăn xếp (stack) undo độc lập quản lý bởi Hook `useEditorState.ts`:
|
||||
1. **`mainUndoStack`:** Quản lý các thay đổi trên bản đồ chính (thêm/sửa/xóa đối tượng địa lý, liên kết wiki...).
|
||||
2. **`replayUndoStack`:** Quản lý cục bộ các thay đổi bên trong kịch bản Replay đang mở.
|
||||
|
||||
### Cơ chế đóng gói phiên làm việc (Session Transaction)
|
||||
* Khi người dùng bắt đầu vào chế độ Replay (`switchReplayContext`), ngăn xếp `replayUndoStack` sẽ được làm sạch bằng `clearReplayUndo()`.
|
||||
* Tất cả các chỉnh sửa tạm thời trong Replay Editor sẽ chỉ được ghi vào `replayUndoStack`.
|
||||
* Khi người dùng thoát chế độ Replay hoặc chuyển sang Replay khác (`finalizeActiveReplaySession`), toàn bộ phiên thay đổi sẽ được so sánh với trạng thái gốc. Nếu có thay đổi, hệ thống sẽ đẩy **một hành động duy nhất** có kiểu `replay` vào ngăn xếp `mainUndoStack`.
|
||||
* Nếu người dùng nhấn **Undo** ở chế độ bản đồ chính, toàn bộ phiên sửa đổi replay đó sẽ được khôi phục về trạng thái trước khi mở chế độ Replay.
|
||||
|
||||
---
|
||||
|
||||
## 2. Hàm Ghi Nhận Lịch Sử Trung Tâm: `onMutateReplay`
|
||||
|
||||
Mọi hành động biên tập kịch bản Replay đều phải đi qua prop callback `onMutateReplay` (được ánh xạ từ `applyReplaySessionMutation` trong `useEditorState.ts`).
|
||||
|
||||
### Logic xử lý của `applyReplaySessionMutation`:
|
||||
1. Nhận vào mô tả hành động (`label`) và một hàm thay đổi (`mutator(draft)`).
|
||||
2. Tự động sao chép sâu (`deepClone`) trạng thái trước đó của replay.
|
||||
3. Thực hiện hàm `mutator` trên bản sao.
|
||||
4. So sánh trạng thái mới và cũ (`replayEquals`). Nếu không có sự thay đổi thực tế, hàm sẽ bỏ qua.
|
||||
5. Nếu có thay đổi, đẩy trạng thái cũ vào `replayUndoStack` kèm theo nhãn hành động tương ứng để hiển thị trong lịch sử.
|
||||
|
||||
---
|
||||
|
||||
## 3. Danh Sách Các Hành Động Được Ghi Nhận Vào Undo
|
||||
|
||||
Dưới đây là chi tiết các hàm trong hai Sidebar tương tác trực tiếp với `onMutateReplay`:
|
||||
|
||||
### 3.1. Các hành động trong `ReplayTimelineSidebar.tsx`
|
||||
|
||||
| Chức năng biên tập | Hàm xử lý trong code | Nhãn ghi nhận Undo (`label`) | Mô tả tác vụ |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Nhập JSON Replay** | *Nút Import JSON* | `"Replay: import JSON"` | Nhập toàn bộ dữ liệu cấu trúc kịch bản mới từ tệp bên ngoài. |
|
||||
| **Tạo Stage mới** | `handleCreateStage` | `"Replay: tạo stage #[ID]"` | Tạo mới một phân đoạn (stage), tự động chèn một step đầu tiên ẩn các geo không phải là background. |
|
||||
| **Sắp xếp thứ tự Stage** | `handleStagesReorder` | `"Replay: sắp xếp stage #[ID]"` | Thay đổi thứ tự hiển thị của các stage trong dòng thời gian. |
|
||||
| **Xóa Stage** | `handleDeleteStage` | `"Replay: xóa stage #[ID]"` | Loại bỏ một phân đoạn khỏi kịch bản. |
|
||||
| **Nhân bản Stage** | `handleDuplicateStage` | `"Replay: nhân bản stage #[ID]"` | Sao chép toàn bộ nội dung của stage cũ sang stage mới. |
|
||||
| **Tạo Step mới** | `handleCreateStep` | `"Replay: tạo step cho stage #[ID]"` | Thêm một bước mới vào trong một stage. |
|
||||
| **Cập nhật thời lượng Step** | `handleUpdateStepDuration` | `"Replay: cập nhật duration step [Index] của stage #[ID]"` | Thay đổi thời gian chờ/hiệu ứng của bước (đơn vị ms). |
|
||||
| **Xóa Step** | `handleDeleteStep` | `"Replay: xóa step [Index] của stage #[ID]"` | Loại bỏ một bước khỏi stage hiện tại. |
|
||||
| **Nhân bản Step** | `handleDuplicateStep` | `"Replay: nhân bản step [Index] của stage #[ID]"` | Sao chép toàn bộ các hành động trong bước đó. |
|
||||
| **Sắp xếp thứ tự Step** | `handleStepsReorder` | `"Replay: sắp xếp step của stage #[ID]"` | Đổi thứ tự thực hiện giữa các bước trong một stage. |
|
||||
| **Xóa hành động (Action)** | `handleDeleteAction` | `"Replay: xóa action [Tên hàm] ở step [Index] của stage #[ID]"` | Loại bỏ một hiệu ứng/câu thoại khỏi một bước cụ thể. |
|
||||
| **Sắp xếp thứ tự các Action** | *Hàm ẩn danh truyền vào ActionList* | `"Replay: sắp xếp actions ở step [Index] của stage #[ID]"` | Đổi thứ tự áp dụng hiệu ứng trong cùng một bước. |
|
||||
| **Cập nhật thông số Action** | `handleUpdateActionParams` | `"Replay: cập nhật params [Tên hàm] ở step [Index] của stage #[ID]"` | Chỉnh sửa trực tiếp tham số (params) của một hiệu ứng (qua giao diện hoặc mã JSON). |
|
||||
| **Sửa Metadata của Stage** | `handleApplyStageMetadata` | `"Replay: cập nhật stage #[ID]"` | Cập nhật tiêu đề, mốc thời gian bắt đầu và kết thúc của Stage. |
|
||||
|
||||
### 3.2. Các hành động trong `ReplayEffectsSidebar.tsx`
|
||||
|
||||
Tất cả các nút thao tác nhanh (shortcuts) trong bảng hiệu ứng bên phải đều sử dụng hàm `updateActionGroup` để cập nhật trạng thái bước hiện tại, từ đó tự động ghi nhận vào ngăn xếp Undo:
|
||||
|
||||
| Nút lệnh tác vụ nhanh | Nhãn ghi nhận Undo (`label`) | Hiệu ứng áp dụng |
|
||||
| :--- | :--- | :--- |
|
||||
| **Đặt Camera** | `"Map: set camera view"` | `set_camera_view` (lưu vị trí, góc xoay bản đồ hiện tại). |
|
||||
| **Hiện Nhãn Bản Đồ** | `"Map: show labels"` | `set_labels_visible(true)` |
|
||||
| **Ẩn Nhãn Bản Đồ** | `"Map: hide labels"` | `set_labels_visible(false)` |
|
||||
| **Xoay Bắc** | `"Map: reset camera north"` | `set_camera_view` (bearing = 0). |
|
||||
| **Hiện Tất Cả Hình Học** | `"Map: show all geometries"` | `show_geometries` (đối với toàn bộ geo). |
|
||||
| **Bay Tới Các Geo** | `"Geo: fly to [Số lượng] geo"` | `fly_to_geometries` |
|
||||
| **Chạy Theo Tuyến Đường** | `"Geo: chạy camera theo đường [Số lượng] geo"` | `follow_geometries_path` |
|
||||
| **Hiển Thị Các Geo** | `"Geo: hiện [Số lượng] geo"` | `show_geometries` |
|
||||
| **Ẩn Các Geo** | `"Geo: ẩn [Số lượng] geo"` | `hide_geometries` |
|
||||
| **Nhấp Nháy Geo** | `"Geo: pulse [Số lượng] geo"` | `pulse_geometry` |
|
||||
| **Hiệu Ứng Viền Nét Đứt** | `"Geo: chạy viền nét đứt [Số lượng] geo"` | `animate_dashed_border` |
|
||||
| **Đặt làm BG (Mới)** | `"Geo: đặt [Số lượng] geo làm background"` | `set_as_background_geometries` (Bảo vệ luôn hiển thị). |
|
||||
| **Loại khỏi BG (Mới)** | `"Geo: loại [Số lượng] geo khỏi background"` | `remove_from_background_geometries` |
|
||||
| **Ẩn Các Geo Khác** | `"Geo: hide others ngoài [Số lượng] geo"` | `hide_others_geometries` |
|
||||
| **Thay Đổi Kiểu Dáng** | `"Geo: đổi style [Số lượng] geo"` | `set_geometry_style` |
|
||||
| **Quay Camera 3D** | `"Geo: quay camera quanh geo"` | `orbit_camera_around_geometry` |
|
||||
| **Hiện Nhãn Riêng Cho Geo** | `"Geo: hiện nhãn cho geo"` | `show_geometry_label` |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 4. Danh Sách Các Hành Động Undo Của Editor Chính (Main Editor)
|
||||
|
||||
Khác với chế độ Replay có stack cục bộ, các thao tác chỉnh sửa bản đồ và metadata chính được đẩy thẳng vào `mainUndoStack` thông qua hàm `pushMainUndo` trong `useEditorState.ts`.
|
||||
|
||||
| Loại hành động (`type`) | Hàm kích hoạt trong code | Nhãn mặc định / Tác vụ | Chi tiết khôi phục |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **`create`** | `createFeature` | *Không nhãn* (Thêm Geo) | Xóa bỏ đối tượng địa lý mới tạo khỏi bản vẽ nháp (`mainDraft`). |
|
||||
| **`delete`** | `deleteFeature` | *Không nhãn* (Xóa Geo) | Khôi phục đối tượng địa lý đã xóa về đúng vị trí index cũ trong mảng. |
|
||||
| **`update`** | `updateFeature` | *Không nhãn* (Sửa Shape) | Khôi phục tọa độ (geometry shape) ban đầu của đối tượng địa lý. |
|
||||
| **`properties`** | `patchFeatureProperties` | *Không nhãn* (Sửa thông tin) | Khôi phục các thuộc tính metadata cũ (entity_ids, labels, style...). |
|
||||
| **`replay`** | `finalizeActiveReplaySession` | `"Replay #[ID]"` | Khôi phục toàn bộ kịch bản replay của đối tượng về trạng thái trước khi mở session biên tập. |
|
||||
| **`snapshot_entities`**| `setSnapshotEntityRowsUndoable` | `"Cập nhật entities"` hoặc tùy chỉnh | Khôi phục danh sách các Entity trong snapshot (đổi tên, thêm mới, xóa tạm thời). |
|
||||
| **`snapshot_wikis`** | `setSnapshotWikisUndoable` | `"Cập nhật wikis"` hoặc tùy chỉnh | Khôi phục danh sách các Wiki bài viết trong snapshot. |
|
||||
| **`snapshot_entity_wiki``**|`setSnapshotEntityWikiLinksUndoable`|`"Cập nhật liên kết"` hoặc tùy chỉnh| Khôi phục liên kết kết nối giữa Entity và Wiki. |
|
||||
| **`group`** | *Nhiều hàm ghép nhóm* (Xem bên dưới) | Tùy chỉnh theo tác vụ | Chạy hoàn tác đồng thời nhiều hành động thuộc các kiểu ở trên. |
|
||||
|
||||
### Các hàm sử dụng nhóm hành động hoàn tác (`group` action):
|
||||
1. **`createFeatureWithSnapshotEntityRows`**: Gom hành động tạo geometry (`create`) và tạo entity liên kết (`snapshot_entities`).
|
||||
2. **`patchFeaturePropertiesBatch`**: Gom các thay đổi thuộc tính (`properties`) của nhiều đối tượng cùng lúc.
|
||||
3. **`deleteFeatures`**: Gom các hành động xóa (`delete`) nhiều đối tượng địa lý cùng lúc.
|
||||
4. **`changeFeatureId`**: Gom hành động cập nhật ID đối tượng địa lý và cập nhật lại tham chiếu ID đó ở các liên kết wiki/entity.
|
||||
5. **`removeSnapshotWikiUndoable`**: Gom hành động xóa wiki (`snapshot_wikis`) và xóa các liên kết kết nối của wiki đó (`snapshot_entity_wiki`).
|
||||
6. **`deleteEntityAndRelations`**: Gom hành động xóa entity (`snapshot_entities`), xóa liên kết (`snapshot_entity_wiki`), và gỡ tham chiếu entity đó khỏi thuộc tính của đối tượng địa lý (`properties`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Kiểm Thử Thủ Công Trạng Thái Undo
|
||||
Để kiểm chứng hoạt động của Undo:
|
||||
1. **Trong Replay Mode:**
|
||||
* Thực hiện thêm Stage, sửa một Step, hoặc thêm hiệu ứng (Ví dụ: bấm nút "Đặt làm BG").
|
||||
* Kiểm tra danh sách lịch sử thay đổi hiển thị ở góc dưới Sidebar bên trái (được render từ `UndoListPanel`).
|
||||
* Bấm nút **Undo replay** để lùi lại thao tác. Quan sát dữ liệu trên dòng thời gian và bản đồ cập nhật tương ứng.
|
||||
2. **Trong Main Editor Mode:**
|
||||
* Thực hiện vẽ một đối tượng, sửa tên thuộc tính, hoặc liên kết bài viết Wiki.
|
||||
* Bấm nút **Undo** trên thanh công cụ của Main Editor.
|
||||
* Quan sát đối tượng biến mất/khôi phục lại thông tin cũ thành công.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Hướng Dẫn Phân Chia Tính Năng Giữa Route Trang Chủ `/` Và Editor Preview Mode
|
||||
|
||||
Tài liệu này làm rõ sự khác biệt kiến trúc, cấu trúc tệp tin, phân chia trách nhiệm và các lưu ý kỹ thuật khi phát triển/chỉnh sửa tính năng giữa trang bản đồ tổng quan công cộng (Route `/`) và Chế độ xem trước của Trình biên tập (Editor Preview Mode).
|
||||
|
||||
---
|
||||
|
||||
## 1. Bản Đồ Tổng Quan Kiến Trúc (Architecture Map)
|
||||
|
||||
Hệ thống có hai môi trường tương tác bản đồ độc lập sử dụng chung một số thành phần lõi:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Bản đồ số Lịch sử] --> B[Trang chủ công cộng Route /]
|
||||
A --> C[Môi trường Editor /editor/id]
|
||||
|
||||
B --> D[PreviewMapShell.tsx]
|
||||
D --> E[ReplayPreviewLayerPanel.tsx]
|
||||
|
||||
C --> F[PreviewLayout.tsx]
|
||||
F --> E
|
||||
```
|
||||
|
||||
### 1.1 Môi trường Trang Chủ công cộng (Route `/`)
|
||||
* **Tệp tin chính**: `src/app/page.tsx`
|
||||
* **Vỏ bọc bố cục (Shell Component)**: `src/uhm/components/preview/PreviewMapShell.tsx`
|
||||
* **Mục đích**: Dành cho khách vãng lai khám phá bản đồ lịch sử thế giới công cộng, tra cứu địa danh lịch sử, chạy thử các replay được xuất bản công khai.
|
||||
* **Đặc trưng**:
|
||||
* Chứa thanh tìm kiếm `PresentPlaceSearch` nằm ở vị trí tuyệt đối (`left: 80px`, `top: 10px`).
|
||||
* Có **Menu Cài đặt** gấp gọn ở góc trên bên trái, cung cấp 3 liên kết nhanh: Quản trị & Chỉnh sửa (`/user`), Hỏi đáp (`/faq`), và Giới thiệu (`/about-us`).
|
||||
* Trạng thái dòng thời gian (`timelineYear`) mặc định là **1000** và được đồng bộ tự động với `localStorage` (`timeline-year`).
|
||||
|
||||
### 1.2 Môi trường Xem trước của Trình biên tập (Editor Preview Mode)
|
||||
* **Tệp tin chính**: `src/app/editor/[id]/page.tsx`
|
||||
* **Vỏ bọc bố cục**: `src/uhm/components/preview/PreviewLayout.tsx`
|
||||
* **Mục đích**: Dành cho Nhà sử học / Người biên tập xem trước bản nháp (snapshot draft) của dự án hiện tại trước khi commit hoặc nộp lên hệ thống.
|
||||
* **Đặc trưng**:
|
||||
* Tích hợp sâu vào Zustand Store (`useEditorStore`) để chia sẻ trạng thái chỉnh sửa hình học, liên kết thực thể (entity binding), và cấu hình replay.
|
||||
* Hỗ trợ nút chuyển đổi dữ liệu cục bộ/toàn cầu (Local/Global View Mode) và đồng bộ tọa độ camera của trình chỉnh sửa.
|
||||
|
||||
---
|
||||
|
||||
## 2. Quy Tắc Phân Chia Tính Năng & Trách Nhiệm
|
||||
|
||||
Để tránh phá vỡ giao diện hoặc logic của môi trường còn lại khi chỉnh sửa, các Agent hoặc Developer cần tuân thủ quy tắc sau:
|
||||
|
||||
| Tính năng / Thành phần | Trang chủ (Route `/`) | Editor Preview | Lưu ý sửa đổi |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Thanh Tìm kiếm (`PresentPlaceSearch`)** | Khai báo tuyệt đối trực tiếp tại `src/app/page.tsx` | Khai báo bên trong `PreviewLayout.tsx` | Đảm bảo chiều rộng linh hoạt bằng cách sử dụng `min-width` / `max-width` thích hợp. |
|
||||
| **Menu Cài đặt (Bánh răng)** | Nằm tại `PreviewMapShell.tsx` (chứa 3 link: Edit, FAQ, About Us) | Không hiển thị | Menu này chỉ phục vụ điều hướng công cộng. |
|
||||
| **Layer Control Panel** | Nằm bên trong `PreviewMapShell.tsx` | Nằm bên trong `PreviewLayout.tsx` | Dùng component chung `ReplayPreviewLayerPanel.tsx`. |
|
||||
| **Trạng thái Timeline** | Mặc định năm 1000, tự động tải/lưu qua `localStorage` | Không lưu `localStorage` (theo trạng thái nháp) | Chỉ áp dụng logic lưu trữ tại `src/app/page.tsx`. |
|
||||
| **Bộ lọc Timeline (Toggle switch)** | Không hiển thị | Hiển thị và hoạt động trên cả hai chế độ (soạn thảo bình thường và xem trước) | Đảm bảo bộ lọc hoạt động đồng bộ với thực thể nháp (local draft). Tránh áp dụng lọc/truy vấn cho thực thể toàn cầu (global geometries) để không gây DDoS cho API backend. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Các Cơ Chế Kỹ Thuật Đặc Biệt (Lưu ý cho các Agent khác)
|
||||
|
||||
### 3.1 Cơ chế chống Hydration Mismatch & Race Condition khi dùng `localStorage`
|
||||
Tại Route `/`, `timelineYear` được lưu trong `localStorage`. Do Next.js chạy SSR trên server (nơi không có `window` và `localStorage`), ta phải xử lý tránh lệch HTML bằng cách:
|
||||
1. Khởi tạo state bằng giá trị tĩnh (`1000`) trên cả server và client.
|
||||
2. Dùng một `useEffect` chạy khi mount trên client để đọc dữ liệu từ `localStorage` ra nếu có.
|
||||
3. Dùng một `useRef(true)` làm cờ hiệu `isFirstMount` để ngăn chặn `useEffect` ghi đè giá trị mặc định `1000` vào `localStorage` trước khi client kịp đọc dữ liệu cũ ra:
|
||||
|
||||
```typescript
|
||||
const isFirstMount = useRef(true);
|
||||
|
||||
// 1. Đọc dữ liệu khi mount
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("timeline-year");
|
||||
if (saved) {
|
||||
const parsed = parseInt(saved, 10);
|
||||
if (!isNaN(parsed)) {
|
||||
setTimelineYear(parsed);
|
||||
setTimelineDraftYear(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 2. Ghi đè dữ liệu khi người dùng kéo thay đổi mốc năm
|
||||
useEffect(() => {
|
||||
if (isFirstMount.current) {
|
||||
isFirstMount.current = false;
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("timeline-year", String(timelineYear));
|
||||
}
|
||||
}, [timelineYear]);
|
||||
```
|
||||
|
||||
### 3.2 Cơ chế Bố cục Flexbox của Sidebar góc trái
|
||||
Thanh công cụ bên trái của trang chủ chứa cả **Menu Cài đặt** và **Layer Control Panel**. Để đảm bảo chúng không bao giờ đè lên nhau trên các màn hình có chiều cao thấp:
|
||||
* Sử dụng một container `<aside>` định vị tuyệt đối với thuộc tính flex dọc (`display: flex`, `flexDirection: column`, `height` giới hạn trong viewport).
|
||||
* Đặt thuộc tính `flexShrink: 1` và `minHeight: 0` cho vùng bao bọc `ReplayPreviewLayerPanel`.
|
||||
* Tại `ReplayPreviewLayerPanel.tsx`, thuộc tính `maxHeight` của thẻ bọc chính được thiết lập là `100%` và `overflowY: "auto"`.
|
||||
* **Kết quả**: Khi Menu Cài đặt mở rộng các nút tùy chọn xuống dưới, Layer Control Panel bên dưới sẽ tự động co nhỏ lại tương ứng và xuất hiện thanh cuộn nếu danh sách các lớp bản đồ vượt quá chiều cao còn lại.
|
||||
|
||||
### 3.3 Cơ chế Tự Động Co Giãn cho TimelineBar
|
||||
Để thanh Timeline kéo dài tối đa chiều ngang nhưng không bị đè bởi Layer Control Panel (ở bên trái) và Wiki Sidebar (ở bên phải) khi mở rộng:
|
||||
* Thuộc tính `left` được đặt cố định là `"88px"` để luôn đứng cách bên phải Layer Control Panel một khoảng an toàn (18px lề + 58px chiều rộng panel + 12px padding).
|
||||
* Thuộc tính `right` được tính toán động thông qua hàm `useMemo`:
|
||||
* **Khi đóng Sidebar**: `right` bằng `18px`.
|
||||
* **Khi mở Sidebar**: `right` bằng `${sidebarWidth + 32}px`.
|
||||
* Bằng cách đặt cả hai neo `left` và `right` mà không thiết lập `width` cố định hay `maxWidth`, trình duyệt sẽ tự động co giãn thanh Timeline giống như một phần tử `flex: 1` nằm giữa hai bên.
|
||||
* Đồng thời thêm thuộc tính hiệu ứng `transition: "right 0.3s, left 0.3s"` giúp việc co giãn diễn ra mượt mà cùng tốc độ với thanh Sidebar.
|
||||
|
||||
---
|
||||
|
||||
## 4. Checklist Khi Chỉnh Sửa Cho Các Agent Tiếp Theo
|
||||
|
||||
* [ ] **Chỉnh sửa UI Sidebar / Layer Panel**: Đảm bảo kiểm tra giao diện trên cả màn hình desktop rộng và màn hình laptop/tablet có chiều cao nhỏ.
|
||||
* [ ] **Sử dụng `localStorage`**: Tuyệt đối không đọc trực tiếp `localStorage` trong hàm khởi tạo `useState(() => localStorage.getItem(...))` vì sẽ gây ra lỗi Hydration Mismatch của Next.js SSR. Hãy luôn khởi tạo bằng giá trị tĩnh và cập nhật lại trong `useEffect` sau khi trang đã mount.
|
||||
* [ ] **Cập nhật Style**: Sử dụng hệ thống Tailwind CSS có sẵn hoặc các thuộc tính inline CSS an toàn. Hạn chế tối đa việc ghi đè trực tiếp các lớp CSS toàn cục có thể ảnh hưởng xuyên suốt cả dự án.
|
||||
* [ ] **Kiểm tra bộ lọc Timeline (Timeline Filter)**: Đảm bảo nút bật/tắt bộ lọc ở bên trái Timeline hoạt động chính xác trong cả chế độ soạn thảo bình thường và các chế độ Xem trước (chỉ trừ Replay Preview khi dòng thời gian bị khóa cứng theo replay). Khi bật bộ lọc, các hình học nháp (local draft) phải được lọc đồng bộ theo mốc năm đang hiển thị. Tuyệt đối không gộp/lọc các thực thể toàn cầu (global geometries) để tránh gọi API nặng nề gây DDoS cho backend.
|
||||
* [ ] **Đảm bảo TypeScript xanh**: Luôn kiểm tra build bằng lệnh `npx tsc --noEmit` trước khi hoàn tất công việc để chắc chắn không xảy ra lỗi kiểu dữ liệu hoặc import sai đường dẫn tương đối.
|
||||
@@ -0,0 +1,182 @@
|
||||
# UHM Wiki system - trạng thái hiện tại
|
||||
|
||||
Wiki trong UHM editor hiện chạy qua hai phần:
|
||||
|
||||
- editor: `WikiSidebarPanel.tsx`
|
||||
- viewer/sidebar public: `PublicWikiSidebar.tsx`
|
||||
|
||||
## 1. Storage format của wiki doc
|
||||
|
||||
Field `doc` trong `WikiSnapshot` hiện là `string | null`.
|
||||
Frontend hiện hỗ trợ hai dạng:
|
||||
|
||||
- HTML string
|
||||
- plain text fallback
|
||||
|
||||
Quy ước hiện tại:
|
||||
|
||||
- format ghi mới từ editor Quill là HTML
|
||||
|
||||
`normalizeWikiDocForQuill()` và `normalizeWikiContentToHtml()` hiện chỉ xử lý HTML hoặc plain text.
|
||||
|
||||
## 2. Editor hiện dùng Quill, không dùng Tiptap
|
||||
|
||||
Trong editor project, wiki đang dùng:
|
||||
|
||||
- `react-quill-new`
|
||||
- theme `snow`
|
||||
- toolbar custom
|
||||
- dynamic import để tránh SSR issues
|
||||
|
||||
Toolbar hiện có:
|
||||
|
||||
- heading `h1`, `h2`, `h3`
|
||||
- align
|
||||
- `bold`, `italic`, `underline`, `strike`
|
||||
- ordered list, bullet list
|
||||
- `blockquote`
|
||||
- `code-block`
|
||||
- `link`
|
||||
- `image`
|
||||
- `clean`
|
||||
|
||||
## 3. Tạo, sửa và xóa wiki trong project editor
|
||||
|
||||
`WikiSidebarPanel` hỗ trợ:
|
||||
|
||||
- tạo wiki local từ panel
|
||||
- sửa `title`, `slug`, `doc`
|
||||
- xóa wiki khỏi `snapshotWikis`
|
||||
- mở modal để sửa nội dung chi tiết
|
||||
|
||||
Quy ước operation:
|
||||
|
||||
- wiki mới local: `source: "inline"`, `operation: "create"`
|
||||
- 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
|
||||
|
||||
Slug trong editor hiện:
|
||||
|
||||
- không tự generate bắt buộc ở lúc save
|
||||
- có helper `slugifyWikiTitle()` để fill nhanh khi tạo mới
|
||||
- được kiểm tra uniqueness bằng `checkWikiSlugExists(slug)` khi tạo wiki mới
|
||||
|
||||
Với wiki mới:
|
||||
|
||||
- nếu slug trống thì không cho create
|
||||
- nếu slug đã tồn tại trên server thì chặn create/save
|
||||
|
||||
## 5. Import và export
|
||||
|
||||
### Import
|
||||
|
||||
Editor chỉ hỗ trợ import file HTML:
|
||||
|
||||
- chấp nhận `.html`, `.htm`, `text/html`
|
||||
- nội dung phải parse được như HTML thô
|
||||
- nếu file không phải HTML thì báo lỗi
|
||||
|
||||
### Export
|
||||
|
||||
Export hiện chỉ là download text từ `wikiDocHtml`.
|
||||
Định dạng file được đoán từ nội dung hiện tại:
|
||||
|
||||
- bắt đầu bằng `<` -> `html`
|
||||
- còn lại -> `txt`
|
||||
|
||||
Đây là export client-side, không có API export chuyên biệt.
|
||||
|
||||
## 6. Link nội bộ giữa các wiki
|
||||
|
||||
### Cách link đang được lưu
|
||||
|
||||
Quill link hiện lưu trực tiếp `href = slug`.
|
||||
|
||||
Ví dụ:
|
||||
|
||||
- `dai-viet`
|
||||
- `tran-dynasty`
|
||||
|
||||
Quill sanitize mặc định đã được patch để chấp nhận slug/relative href, miễn không phải `javascript:`.
|
||||
|
||||
### Modal chọn link
|
||||
|
||||
Khi bấm nút link trên toolbar:
|
||||
|
||||
- editor lấy selection hiện tại
|
||||
- mở modal search
|
||||
- tìm trong wiki local của project
|
||||
- đồng thời có thể search wiki global từ server
|
||||
|
||||
User có thể:
|
||||
|
||||
- chọn một wiki local/global để chèn link
|
||||
- chèn `__missing__` như một link placeholder
|
||||
- remove link hiện tại
|
||||
|
||||
### `__missing__`
|
||||
|
||||
`__missing__` là sentinel để đánh dấu một link chưa map được tới wiki cụ thể.
|
||||
|
||||
Trong editor:
|
||||
|
||||
- link này được tô đỏ
|
||||
|
||||
Trong viewer:
|
||||
|
||||
- link này không click được
|
||||
- vẫn được render như tín hiệu nội dung còn thiếu
|
||||
|
||||
## 7. Render wiki phía public/sidebar
|
||||
|
||||
`PublicWikiSidebar.tsx` xử lý wiki render như sau:
|
||||
|
||||
1. normalize `content` về HTML
|
||||
2. parse HTML bằng `DOMParser`
|
||||
3. remove `<script>`
|
||||
4. rewrite link nội bộ từ slug thành `#wiki:{slug}`
|
||||
5. giữ external links mở tab mới
|
||||
6. sinh TOC từ `h1..h6`
|
||||
|
||||
Viewer này còn hỗ trợ:
|
||||
|
||||
- auto tạo heading id
|
||||
- TOC dạng chip ngang
|
||||
- intercept click vào `a[data-wiki-slug]` để điều hướng wiki nội bộ bằng logic app
|
||||
|
||||
## 8. Wiki và entity-wiki binding
|
||||
|
||||
Link giữa entity và wiki không nằm trong field của chính wiki.
|
||||
Nó sống ở collection riêng:
|
||||
|
||||
- `snapshotEntityWikiLinks`
|
||||
- payload commit: `entity_wiki[]`
|
||||
|
||||
`EntityWikiBindingsPanel` cho phép:
|
||||
|
||||
- chọn entity
|
||||
- chọn wiki
|
||||
- link/unlink cặp đó
|
||||
|
||||
Khi build snapshot:
|
||||
|
||||
- cặp mới -> `binding`
|
||||
- cặp đã có từ baseline -> `reference`
|
||||
- cặp bị gỡ -> `delete`
|
||||
|
||||
## 9. Những gì wiki system hiện chưa có
|
||||
|
||||
Hiện tại chưa có:
|
||||
|
||||
- media upload workflow riêng lên server cho Quill image
|
||||
- version history riêng cho từng wiki ngoài commit history của project
|
||||
- markdown storage/render
|
||||
- schema block editor mới cho project wiki
|
||||
- cross-project link graph UI
|
||||
|
||||
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