diff --git a/EDITOR_LOCAL_STORAGE.md b/EDITOR_LOCAL_STORAGE.md index c57499c..07b7af7 100644 --- a/EDITOR_LOCAL_STORAGE.md +++ b/EDITOR_LOCAL_STORAGE.md @@ -83,7 +83,7 @@ Nguồn: `const editor = useEditorState(initialData)` - `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/binding). + - 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` @@ -247,7 +247,7 @@ Khi bấm **Import** một geometry từ kết quả search: 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/binding` + - 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 đó. diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index f1044ad..60d779d 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -30,10 +30,14 @@ import { import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes"; import { getDefaultTypeIdForFeature, - normalizeFeatureBindingIds, normalizeFeatureEntityIds, uniqueEntityIds, } from "@/uhm/lib/editor/snapshot/editorSnapshot"; +import { + getDirectGeometryChildIds, + normalizeFeatureBoundWith, + wouldCreateGeometryBoundWithCycle, +} from "@/uhm/lib/editor/geometry/geometryBinding"; import { buildClientEntityId, mergeEntitySearchResults, @@ -71,7 +75,7 @@ import { isFeatureVisibleAtYear, normalizeEntitiesForCompare, normalizeEntityWikiLinksForCompare, - normalizeGeoSearchBindingIds, + normalizeGeoSearchBoundWith, normalizeGeoSearchGeometry, normalizeReplaysForCompare, normalizeWikisForCompare, @@ -630,11 +634,11 @@ function EditorPageContent() { return rows; }, [activeTimelineFilterEnabled, editor, mapRenderDraft.features]); - // Binding ids của geometry đại diện đang chọn. - const selectedGeometryBindingIds = useMemo(() => { + // Child ids bound to the selected geometry via child.properties.bound_with. + const selectedGeometryChildIds = useMemo(() => { if (!selectedFeature) return []; - return normalizeFeatureBindingIds(selectedFeature); - }, [selectedFeature]); + return getDirectGeometryChildIds(editor.draft, selectedFeature.properties.id); + }, [editor.draft, selectedFeature]); // Choices wiki dùng trong replay actions và binding panel. const wikiChoices = useMemo(() => { @@ -1317,7 +1321,6 @@ function EditorPageContent() { type_key: "", time_start: "", time_end: "", - binding: "", }); setEntityFormStatus(null); lastSelectedFeatureIdRef.current = null; @@ -1336,7 +1339,6 @@ function EditorPageContent() { type_key: nextTypeKey, time_start: timeStart != null ? String(timeStart) : "", time_end: timeEnd != null ? String(timeEnd) : "", - binding: normalizeFeatureBindingIds(selectedFeature).join(", "), }); // Only clear status when switching to a different geometry, not when patching metadata/bindings // on the same selected geometry (otherwise messages will blink). @@ -1518,58 +1520,55 @@ function EditorPageContent() { setSelectedGeometryEntityIds, ]); - // Bind/unbind geometry id vào trường binding của selected geometry. + // Bind/unbind geometry con vào selected geometry qua field child.bound_with. const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => { if (!selectedFeatures || selectedFeatures.length === 0) { flashGeoBindingStatus("Chưa chọn geometry để bind."); return; } + if (selectedFeatures.length !== 1 || !selectedFeature) { + flashGeoBindingStatus("Chỉ bind geometry-geometry khi chọn đúng một geometry cha."); + return; + } if (!isMultiEditValid) { flashGeoBindingStatus("Không thể bind geometry cho nhiều geometry khác loại."); return; } const id = String(geoId || "").trim(); if (!id) return; - if (selectedFeatures.some(f => String(f.properties.id) === id)) return; - - + const parentId = String(selectedFeature.properties.id); + if (parentId === id) return; + const childFeature = editor.draft.features.find((f) => String(f.properties.id) === id); + if (!childFeature) { + flashGeoBindingStatus("Không tìm thấy geometry con."); + return; + } + if (nextChecked && wouldCreateGeometryBoundWithCycle(editor.draft.features, id, parentId)) { + flashGeoBindingStatus("Không thể bind vì sẽ tạo vòng lặp bound_with."); + return; + } setIsEntitySubmitting(true); flashGeoBindingStatus(null, 0); try { - const bindingPatches = selectedFeatures.map((feature) => { - const prevBindingIds = normalizeFeatureBindingIds(feature); - const has = prevBindingIds.includes(id); - const nextBindingIds = (() => { - if (nextChecked) { - if (has) return prevBindingIds; - return [...prevBindingIds, id]; - } - if (!has) return prevBindingIds; - return prevBindingIds.filter((x) => x !== id); - })(); - return { - id: feature.properties.id, - patch: { binding: nextBindingIds }, - }; - }); + const currentParentId = normalizeFeatureBoundWith(childFeature); + const nextBoundWith = nextChecked + ? parentId + : currentParentId === parentId + ? null + : currentParentId; editor.patchFeaturePropertiesBatch( - bindingPatches, + [{ + id: childFeature.properties.id, + patch: { bound_with: nextBoundWith }, + }], nextChecked ? "Bind geometry vào GEO" : "Unbind geometry khỏi GEO" ); - // Assume selectedFeature (the first one) reflects the representative binding in UI - const firstFeaturePrevBindings = normalizeFeatureBindingIds(selectedFeatures[0]); - const firstFeatureHas = firstFeaturePrevBindings.includes(id); - const nextBindingIdsForUI = (() => { - if (nextChecked) return firstFeatureHas ? firstFeaturePrevBindings : [...firstFeaturePrevBindings, id]; - return firstFeatureHas ? firstFeaturePrevBindings.filter(x => x !== id) : firstFeaturePrevBindings; - })(); - setGeometryMetaForm((prev) => ({ ...prev, binding: nextBindingIdsForUI.join(", ") })); flashGeoBindingStatus( nextChecked - ? "Đã bind geometry vào binding. Commit khi sẵn sàng." - : "Đã gỡ binding geometry. Commit khi sẵn sàng.", + ? "Đã set bound_with cho geometry con. Commit khi sẵn sàng." + : "Đã gỡ bound_with khỏi geometry con. Commit khi sẵn sàng.", 3000 ); } finally { @@ -1578,13 +1577,13 @@ function EditorPageContent() { }, [ editor, flashGeoBindingStatus, + selectedFeature, selectedFeatures, isMultiEditValid, - setGeometryMetaForm, setIsEntitySubmitting, ]); - // Bind nhiều geometries vào target geometry. + // Bind nhiều geometries con vào target geometry. const handleBindGeometries = useCallback((targetId: string | number, sourceIds: (string | number)[]) => { const idStr = String(targetId).trim(); if (!idStr) return; @@ -1595,23 +1594,27 @@ function EditorPageContent() { return; } - const prevBindingIds = normalizeFeatureBindingIds(targetFeature); + const sourceFeatures = sourceIds + .map((sourceId) => editor.draft.features.find((f) => String(f.properties.id) === String(sourceId))) + .filter((feature): feature is Feature => Boolean(feature)) + .filter((feature) => String(feature.properties.id) !== idStr) + .filter((feature) => !wouldCreateGeometryBoundWithCycle(editor.draft.features, feature.properties.id, idStr)); - // Merge prevBindingIds with sourceIds (which are strings of selected features) - // filter out targetId itself (we can't bind a geometry to itself) - const newSources = sourceIds.map(String).filter((x) => x !== idStr); - const merged = Array.from(new Set([...prevBindingIds, ...newSources])); + if (!sourceFeatures.length) { + flashGeoBindingStatus("Không có geometry con hợp lệ để bind."); + return; + } editor.patchFeaturePropertiesBatch( - [{ - id: targetFeature.properties.id, - patch: { binding: merged }, - }], + sourceFeatures.map((feature) => ({ + id: feature.properties.id, + patch: { bound_with: idStr }, + })), "Bind các geometry đã chọn vào GEO" ); setSelectedFeatureIds([targetFeature.properties.id]); - flashGeoBindingStatus(`Đã bind ${newSources.length} geometry vào GEO này. Commit khi sẵn sàng.`, 3000); + flashGeoBindingStatus(`Đã set bound_with cho ${sourceFeatures.length} geometry con. Commit khi sẵn sàng.`, 3000); }, [editor, flashGeoBindingStatus, setSelectedFeatureIds]); // Focus/zoom tới geometry từ binding panel; nếu geo có time_start thì kéo year filter về năm đó. @@ -1787,7 +1790,7 @@ function EditorPageContent() { return; } - const bindingIds = normalizeGeoSearchBindingIds(geo.binding); + const boundWith = normalizeGeoSearchBoundWith(geo.bound_with); const typeKey = geo.type || null; const feature: Feature = { @@ -1797,7 +1800,7 @@ function EditorPageContent() { type: typeKey, time_start: normalizeTimelineYearValue(geo.time_start), time_end: normalizeTimelineYearValue(geo.time_end), - binding: bindingIds.length ? bindingIds : undefined, + bound_with: boundWith, entity_id: entityItem.entity_id, entity_ids: [entityItem.entity_id], entity_name: (entityItem.name || "").trim() || entityItem.entity_id, @@ -2345,7 +2348,7 @@ function EditorPageContent() { diff --git a/src/uhm/api/geometries.ts b/src/uhm/api/geometries.ts index 639b8a2..fc67ea5 100644 --- a/src/uhm/api/geometries.ts +++ b/src/uhm/api/geometries.ts @@ -10,7 +10,7 @@ export type EntityGeometrySearchGeo = { id: string; type: string | null; draw_geometry: Geometry; - binding?: string[]; + bound_with?: string | null; time_start?: number | null; time_end?: number | null; @@ -96,7 +96,7 @@ export async function searchGeometriesByEntityName( id: geometry.id, type: geoTypeCodeToTypeKey(geometry.geo_type) || null, draw_geometry: geometry.draw_geometry, - binding: geometry.binding, + bound_with: normalizeBoundWith(geometry.bound_with), time_start: geometry.time_start ?? null, time_end: geometry.time_end ?? null, })), @@ -108,7 +108,7 @@ type GeometryRow = { id: string; geo_type: number; draw_geometry: Geometry; - binding?: string[]; + bound_with?: string | null; time_start?: number; time_end?: number; @@ -127,7 +127,7 @@ function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection { const geometry = normalizeGeometry(row.draw_geometry); if (!geometry) continue; - const binding = normalizeBinding(row.binding); + const boundWith = normalizeBoundWith(row.bound_with); const typeKey = geoTypeCodeToTypeKey(row.geo_type) || null; const properties: FeatureProperties = { @@ -135,7 +135,7 @@ function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection { type: typeKey, time_start: row.time_start ?? null, time_end: row.time_end ?? null, - binding: binding.length ? binding : undefined, + bound_with: boundWith, }; features.push({ @@ -156,11 +156,9 @@ function normalizeGeometry(value: unknown): Geometry | null { return value as Geometry; } -function normalizeBinding(value: unknown): string[] { - if (!value) return []; - if (Array.isArray(value)) { - return value.map((v) => String(v)).filter((v) => v.length > 0); - } - // Some deployments may return binding as an object; ignore it for FE properties.binding. - return []; +function normalizeBoundWith(value: unknown): string | null { + if (value == null) return null; + if (typeof value !== "string" && typeof value !== "number") return null; + const id = String(value).trim(); + return id.length ? id : null; } diff --git a/src/uhm/components/editor/GeometryBindingPanel.tsx b/src/uhm/components/editor/GeometryBindingPanel.tsx index e945acf..488db5e 100644 --- a/src/uhm/components/editor/GeometryBindingPanel.tsx +++ b/src/uhm/components/editor/GeometryBindingPanel.tsx @@ -29,7 +29,7 @@ type GeometryRow = Required void; onFocusGeometry?: (geometryId: string) => void; }; @@ -37,7 +37,7 @@ type Props = { export default function GeometryBindingPanel({ geometries, selectedGeometryId, - selectedGeometryBindingIds, + selectedGeometryChildIds, onToggleBindGeometryForSelectedGeometry, onFocusGeometry, }: Props) { @@ -85,7 +85,7 @@ export default function GeometryBindingPanel({ return cleaned; }, [geometries]); - const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]); + const childSet = useMemo(() => new Set(selectedGeometryChildIds || []), [selectedGeometryChildIds]); const selectedGeometry = useMemo(() => { if (!effectiveSelectedGeometryId) return null; return rows.find((g) => g.id === effectiveSelectedGeometryId) || null; @@ -94,12 +94,12 @@ export default function GeometryBindingPanel({ return rows .filter((g) => g.id !== effectiveSelectedGeometryId) .sort((a, b) => { - const aBound = bindingSet.has(a.id); - const bBound = bindingSet.has(b.id); + const aBound = childSet.has(a.id); + const bBound = childSet.has(b.id); if (aBound !== bBound) return aBound ? -1 : 1; return a.id.localeCompare(b.id); }); - }, [bindingSet, effectiveSelectedGeometryId, rows]); + }, [childSet, effectiveSelectedGeometryId, rows]); const summary = useMemo(() => { let orphan = 0; let missingTime = 0; @@ -247,7 +247,7 @@ export default function GeometryBindingPanel({ {collapsed ? null : selectedGeometry ? ( (() => { const isHidden = geometryVisibility[selectedGeometry.id] === false; - const isBound = bindingSet.has(selectedGeometry.id); + const isBound = childSet.has(selectedGeometry.id); const title = buildGeometryTitle(selectedGeometry, isHidden, isBound); return (
{visibleRows .map((g) => { - const isBound = bindingSet.has(g.id); + const isBound = childSet.has(g.id); const isHidden = geometryVisibility[g.id] === false; const title = buildGeometryTitle(g, isHidden, isBound); return ( diff --git a/src/uhm/components/editor/SelectedGeometryPanel.tsx b/src/uhm/components/editor/SelectedGeometryPanel.tsx index 8e8b0bb..e95de34 100644 --- a/src/uhm/components/editor/SelectedGeometryPanel.tsx +++ b/src/uhm/components/editor/SelectedGeometryPanel.tsx @@ -58,10 +58,8 @@ export default function SelectedGeometryPanel({ geometryMetaForm.type_key, geometryMetaForm.time_start, geometryMetaForm.time_end, - geometryMetaForm.binding, ].join("|"); }, [ - geometryMetaForm.binding, geometryMetaForm.time_end, geometryMetaForm.time_start, geometryMetaForm.type_key, diff --git a/src/uhm/components/map/mapUtils.ts b/src/uhm/components/map/mapUtils.ts index e8382d6..84f8ef0 100644 --- a/src/uhm/components/map/mapUtils.ts +++ b/src/uhm/components/map/mapUtils.ts @@ -13,6 +13,7 @@ import { PATH_RENDER_BY_TYPE } from "@/uhm/lib/map/styles/style"; import { getBackgroundRasterSourceSpecification } from "@/uhm/api/tiles"; import { newId } from "@/uhm/lib/utils/id"; import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap"; +import { normalizeBoundWithId, normalizeFeatureBoundWith } from "@/uhm/lib/editor/geometry/geometryBinding"; import type { EntityLabelCandidate } from "@/uhm/types/geo"; type Coordinate = [number, number]; @@ -149,9 +150,18 @@ export function filterDraftByBinding( } const childIds = new Set(); + const selectedChildren = new Set(); + const selectedParents = new Set(); for (const feature of fc.features) { - for (const id of normalizeBindingIds(feature.properties.binding)) { - childIds.add(id); + const featureId = String(feature.properties.id); + const parentId = normalizeFeatureBoundWith(feature); + if (!parentId) continue; + childIds.add(featureId); + if (selectedIds.has(parentId)) { + selectedChildren.add(featureId); + } + if (selectedIds.has(featureId)) { + selectedParents.add(parentId); } } @@ -159,21 +169,13 @@ export function filterDraftByBinding( return { ...fc, features: fc.features.filter((f) => !childIds.has(String(f.properties.id))) }; } - const selectedChildren = new Set(); - for (const feature of fc.features) { - if (selectedIds.has(String(feature.properties.id))) { - for (const id of normalizeBindingIds(feature.properties.binding)) { - selectedChildren.add(id); - } - } - } - return { ...fc, features: fc.features.filter((feature) => { const featureId = String(feature.properties.id); if (selectedIds.has(featureId)) return true; if (selectedChildren.has(featureId)) return true; + if (selectedParents.has(featureId)) return true; return !childIds.has(featureId); }), }; @@ -200,20 +202,6 @@ export function filterDraftByGeometryVisibility( }; } -export function normalizeBindingIds(rawBinding: unknown): string[] { - if (!Array.isArray(rawBinding)) return []; - const deduped: string[] = []; - const seen = new Set(); - for (const rawId of rawBinding) { - if (typeof rawId !== "string" && typeof rawId !== "number") continue; - const id = String(rawId).trim(); - if (!id || seen.has(id)) continue; - seen.add(id); - deduped.push(id); - } - return deduped; -} - export function splitDraftFeatures(fc: FeatureCollection) { const polygons = { type: "FeatureCollection", @@ -683,21 +671,13 @@ function createFeatureLabelResolver( } for (const feature of fc.features) { - const parentLabel = directLabelsByFeatureId.get(String(feature.properties.id)); const featureId = String(feature.properties.id); - const bindingIds = normalizeBindingIds(feature.properties.binding); + const parentId = normalizeBoundWithId(feature.properties.bound_with); + if (!parentId) continue; + const parentLabel = directLabelsByFeatureId.get(parentId); if (parentLabel) { - for (const childId of bindingIds) { - mergeInheritedFeatureLabel(inheritedLabelsByChildId, childId, parentLabel); - } - } - - for (const parentId of bindingIds) { - const linkedParentLabel = directLabelsByFeatureId.get(parentId); - if (linkedParentLabel) { - mergeInheritedFeatureLabel(inheritedLabelsByChildId, featureId, linkedParentLabel); - } + mergeInheritedFeatureLabel(inheritedLabelsByChildId, featureId, parentLabel); } } diff --git a/src/uhm/components/map/useMapInteraction.ts b/src/uhm/components/map/useMapInteraction.ts index b56729a..f039ce0 100644 --- a/src/uhm/components/map/useMapInteraction.ts +++ b/src/uhm/components/map/useMapInteraction.ts @@ -135,7 +135,7 @@ export function useMapInteraction({ entity_ids: [], entity_name: null, entity_type_id: null, - binding: [], + bound_with: null, }, geometry, }); @@ -201,7 +201,7 @@ export function useMapInteraction({ entity_ids: [], entity_name: null, entity_type_id: null, - binding: [], + bound_with: null, }, geometry, }); @@ -223,7 +223,7 @@ export function useMapInteraction({ entity_ids: [], entity_name: null, entity_type_id: null, - binding: [], + bound_with: null, }, geometry, }); @@ -245,7 +245,7 @@ export function useMapInteraction({ entity_ids: [], entity_name: null, entity_type_id: null, - binding: [], + bound_with: null, }, geometry, }); @@ -267,7 +267,7 @@ export function useMapInteraction({ entity_ids: [], entity_name: null, entity_type_id: null, - binding: [], + bound_with: null, }, geometry, }); @@ -372,7 +372,7 @@ function buildDuplicatedFeatureShapeOnly( entity_ids: [], entity_name: null, entity_names: [], - binding: [], + bound_with: null, }, geometry, }; diff --git a/src/uhm/doc/commit_snapshot.ts b/src/uhm/doc/commit_snapshot.ts index 8b472d1..f1ca78d 100644 --- a/src/uhm/doc/commit_snapshot.ts +++ b/src/uhm/doc/commit_snapshot.ts @@ -47,7 +47,7 @@ export type FeatureProperties = { geometry_preset?: GeometryPreset | null; time_start?: number | null; time_end?: number | null; - binding?: string[]; + bound_with?: string | null; // UI/editor-only denormalized fields. entity_id?: string | null; @@ -103,7 +103,7 @@ export type GeometrySnapshot = { type?: string | null; draw_geometry?: Geometry; geometry?: Geometry; - binding?: string[]; + bound_with?: string | null; time_start?: number | null; time_end?: number | null; bbox?: { diff --git a/src/uhm/doc/developer_guide.md b/src/uhm/doc/developer_guide.md index cf7b81b..5d0d574 100644 --- a/src/uhm/doc/developer_guide.md +++ b/src/uhm/doc/developer_guide.md @@ -76,7 +76,7 @@ Checklist an toàn: - `type` - `geometry_preset` - `entity_ids` - - `binding` + - `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ũ. @@ -101,7 +101,7 @@ File quan trọng nhất là `editorSnapshot.ts`. - `normalizeEditorSnapshot(raw)` - đọc payload từ backend - - rehydrate fields UI như `entity_ids`, `entity_name`, `binding`, `time_start`, `time_end` + - 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[]` @@ -175,7 +175,7 @@ Có thể do: - timeline filter - geometry visibility theo type -- binding filter +- bound_with filter Không phải lúc nào cũng là bug render layer. diff --git a/src/uhm/doc/editor_data_roles.md b/src/uhm/doc/editor_data_roles.md index b0e868c..65a2f37 100644 --- a/src/uhm/doc/editor_data_roles.md +++ b/src/uhm/doc/editor_data_roles.md @@ -94,9 +94,9 @@ Các link entity-wiki hiện tại của snapshot. Snapshot builder sẽ tự si Join table persist quan hệ geometry-entity trong snapshot commit. `feature.properties.entity_ids` chỉ là field denormalized cho UI. -### `binding` +### `bound_with` -Field geometry-geometry binding trên feature. Binding này không tính là entity binding; geometry không có `entity_ids/entity_id` hợp lệ vẫn là orphan. +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` @@ -104,7 +104,7 @@ Map local visibility override. Key có thể là geometry id hoặc semantic geo ### `applyGeometryBindingFilter` -Filter map theo selection/binding. Chỉ ảnh hưởng render trên map, không đổi draft và không đi snapshot. +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 diff --git a/src/uhm/doc/editor_features.md b/src/uhm/doc/editor_features.md index ebb8e32..2c714ae 100644 --- a/src/uhm/doc/editor_features.md +++ b/src/uhm/doc/editor_features.md @@ -75,7 +75,7 @@ Geometry mới mặc định có: - `type: "country"` - `geometry_preset: "polygon"` - `entity_ids: []` -- `binding: []` +- `bound_with: null` ### Point (`add-point`) @@ -143,7 +143,7 @@ Khi đang ở `select`, editor có thể sửa polygon/circle qua `editingEngine - `time_start` - `time_end` -`binding` đang được hiển thị trong state form nhưng không có input edit trực tiếp trong panel; việc bind/unbind geometry hiện đi qua `GeometryBindingPanel`. +`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ó: @@ -203,12 +203,12 @@ Panel `ProjectEntityRefsPanel` là nơi bind/unbind entity theo geometry đang c ### Geometry ↔ Geometry -`GeometryBindingPanel` thao tác trên `feature.properties.binding`. +`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. +- 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 binding đang bật. +- 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`. diff --git a/src/uhm/doc/editor_manual_test_checklist.md b/src/uhm/doc/editor_manual_test_checklist.md index 3d13200..0d4102b 100644 --- a/src/uhm/doc/editor_manual_test_checklist.md +++ b/src/uhm/doc/editor_manual_test_checklist.md @@ -52,7 +52,7 @@ Checklist này dùng sau mỗi lần sửa editor. Không thay thế typecheck/l - Chọn một geometry, bind geometry khác trong `GeometryBindingPanel`. - Panel hiện chip `bound` cho geometry liên quan. - Toggle Filter: map chỉ hiện selection, selected children và parent/root phù hợp. -- Undo bind/unbind geometry phải khôi phục `properties.binding`. +- 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 @@ -69,7 +69,7 @@ Checklist này dùng sau mỗi lần sửa editor. Không thay thế typecheck/l ## 7. Replay - Chọn geometry có entity, bấm replay. -- Replay mở với MAIN geo và các target ids liên quan binding. +- 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`. diff --git a/src/uhm/doc/editor_operations.md b/src/uhm/doc/editor_operations.md index de3bd43..2b78974 100644 --- a/src/uhm/doc/editor_operations.md +++ b/src/uhm/doc/editor_operations.md @@ -79,16 +79,16 @@ Một thao tác không cần undo nếu nó chỉ đổi trạng thái xem/đi | Ẩn/hiện geometry local | Eye button, map hide callback | `geometryVisibility` | Không | Không | Local UI only, không đi snapshot | | Geometry status panel | `GeometryBindingPanel` | Derived từ draft/timeline/visibility | Không | Không | Hiện `no entity`, `no time`, `partial time`, `timeline`, `out timeline`, `hidden`, `bound`, `new`; ID chỉ nằm trong tooltip | -## 3. Geometry binding +## 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 | `feature.properties.binding` | `properties` hoặc `group` | `geometries[].binding` | Binding geometry không thay thế entity binding | -| Unbind geometry-geometry | `GeometryBindingPanel` unlock button | `feature.properties.binding` | `properties` hoặc `group` | `geometries[].binding` | Không ảnh hưởng `geometry_entity[]` | -| Bind nhiều geometry vào target | Map bind callback | `binding` của target geometry | `properties` | `geometries[].binding` | Tự bỏ target id khỏi source ids | -| Toggle binding filter | `GeometryBindingPanel` filter checkbox | `geometryBindingFilterEnabled` | Không | Không | Chỉ lọc hiển thị map theo selection/binding | +| 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 @@ -189,7 +189,7 @@ Khi một thao tác cần đi vào commit, kiểm output snapshot: - Wiki rows nằm trong `wikis[]`. - Entity-wiki rows nằm trong `entity_wiki[]`. - Replay script nằm trong `replays[]`, không lưu `replayDraft`. -- Generate-only fields trên feature như `entity_id`, `entity_ids`, `entity_name`, `entity_names`, `entity_label_candidates`, `time_start`, `time_end`, `binding`, `type` được snapshot builder xử lý/loại bỏ đúng chỗ trước API payload. +- 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 diff --git a/src/uhm/doc/editor_snapshot_contract.md b/src/uhm/doc/editor_snapshot_contract.md index cc620f7..f2b8667 100644 --- a/src/uhm/doc/editor_snapshot_contract.md +++ b/src/uhm/doc/editor_snapshot_contract.md @@ -63,10 +63,12 @@ Mỗi feature trong `mainDraft.features` sinh một row: | `operation` | `"create"`, `"update"` hoặc `"reference"` theo baseline/diff | | `type` | FE type key trước `toApiEditorSnapshot()`, backend code string sau normalize API | | `draw_geometry` | `feature.geometry` | -| `binding` | `normalizeFeatureBindingIds(feature)` | +| `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 @@ -93,7 +95,7 @@ Geometry đã bị xóa sinh row: - `type` - `time_start` - `time_end` -- `binding` +- `bound_with` - `entity_id` - `entity_ids` - `entity_name` @@ -103,7 +105,7 @@ Geometry đã bị xóa sinh row: Các field này được lưu ở collection chuẩn hơn: -- `type/time/binding` nằm ở `geometries[]`. +- `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. @@ -125,7 +127,7 @@ Build rule: Rows được dedupe/sort theo `geometry_id`, rồi `entity_id`. -Commit/submit hiện chặn nếu có geometry không có entity ids hợp lệ. Geometry-geometry `binding` không được tính là đã bind entity. +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 diff --git a/src/uhm/doc/editor_state_replay.md b/src/uhm/doc/editor_state_replay.md index b15acca..d996560 100644 --- a/src/uhm/doc/editor_state_replay.md +++ b/src/uhm/doc/editor_state_replay.md @@ -82,7 +82,7 @@ Replay seed mới có dạng: - MAIN geo - toàn bộ bulk selection hiện tại -- toàn bộ `binding` của MAIN geo trong `mainDraft` +- toàn bộ geometry con có `bound_with` trỏ tới MAIN geo trong `mainDraft` Rule hiện tại: @@ -110,7 +110,7 @@ Hydrate hiện tại: - lấy feature từ `mainDraft` theo đúng thứ tự `target_geometry_ids` - clone ra `FeatureCollection` mới -- flatten `binding` thành `[]` để các geo trong replay bình đẳng với nhau +- 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ì diff --git a/src/uhm/doc/editor_states.md b/src/uhm/doc/editor_states.md index df07224..52113a7 100644 --- a/src/uhm/doc/editor_states.md +++ b/src/uhm/doc/editor_states.md @@ -116,9 +116,8 @@ Editor hiện có `undo`, nhưng chưa có redo. - `type_key` - `time_start` - `time_end` - - `binding` -`geometryMetaForm.binding` hiện chủ yếu là giá trị hiển thị/đồng bộ UI, còn chỉnh sửa binding thật đi qua `GeometryBindingPanel`. +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 @@ -324,5 +323,5 @@ Hiệu ứng: - 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, binding filter và timeline filter đều là state client-side +- 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 diff --git a/src/uhm/doc/export_json_replay.md b/src/uhm/doc/export_json_replay.md index d613712..ac9630e 100644 --- a/src/uhm/doc/export_json_replay.md +++ b/src/uhm/doc/export_json_replay.md @@ -86,7 +86,7 @@ FE không còn export/persist cả `FeatureCollection` riêng của replay nữa - geo MAIN - các geo được đưa vào replay từ bulk select -- binding của MAIN geo +- các geometry con có `bound_with` trỏ tới MAIN geo Khi mở replay, FE sẽ hydrate lại `replayDraft` từ: diff --git a/src/uhm/doc/map_engine.md b/src/uhm/doc/map_engine.md index 5fdfea4..3cd40d1 100644 --- a/src/uhm/doc/map_engine.md +++ b/src/uhm/doc/map_engine.md @@ -88,7 +88,7 @@ Source này dùng cho: `useMapSync()` chịu trách nhiệm: 1. nhận `renderDraft` đã được page áp timeline/replay/preview filter trước -2. filter draft theo binding nếu `applyGeometryBindingFilter = true` +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 @@ -100,7 +100,7 @@ Source này dùng cho: - data mà map render không phải raw `mainDraft` nguyên xi - `renderDraft` là nguồn quyết định geometry nào xuất hiện trên map - `labelContextDraft` chỉ dùng để lookup label/entity name, có thể chứa geometry đã bị timeline filter ẩn, và không được dùng để quyết định render -- source MapLibre cuối cùng là `renderDraft` sau khi đã qua binding filter, geometry visibility và label decoration +- source MapLibre cuối cùng là `renderDraft` sau khi đã qua bound_with filter, geometry visibility và label decoration ## 5. Map interaction layer diff --git a/src/uhm/doc/map_styling.md b/src/uhm/doc/map_styling.md index d868fb3..61dfdd3 100644 --- a/src/uhm/doc/map_styling.md +++ b/src/uhm/doc/map_styling.md @@ -144,7 +144,7 @@ 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. binding filter / replay filter / timeline filter ở phía data trước khi set source +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. @@ -170,6 +170,6 @@ 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` -- binding filter và geometry visibility cũng chủ yếu chạy trước khi set source +- 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`. diff --git a/src/uhm/doc/project_workflow.md b/src/uhm/doc/project_workflow.md index ee744a4..fd3e430 100644 --- a/src/uhm/doc/project_workflow.md +++ b/src/uhm/doc/project_workflow.md @@ -151,7 +151,7 @@ và sinh ra: Các điểm quan trọng: - geometry many-to-many với entity được persist ở `geometry_entity[]` -- denormalized fields trên feature như `entity_ids`, `entity_name`, `binding`, `time_start` sẽ bị strip khỏi `editor_feature_collection` trước khi gửi API +- 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 diff --git a/src/uhm/lib/editor/editorPageUtils.ts b/src/uhm/lib/editor/editorPageUtils.ts index 110e3c6..78d5a09 100644 --- a/src/uhm/lib/editor/editorPageUtils.ts +++ b/src/uhm/lib/editor/editorPageUtils.ts @@ -126,17 +126,10 @@ export function normalizeGeoSearchGeometry(value: unknown): Geometry | null { return value as Geometry; } -// Chuẩn hóa danh sách binding id từ API search GEO. -export function normalizeGeoSearchBindingIds(value: unknown): string[] { - if (!Array.isArray(value)) return []; - const deduped: string[] = []; - const seen = new Set(); - for (const rawId of value) { - if (typeof rawId !== "string" && typeof rawId !== "number") continue; - const id = String(rawId).trim(); - if (!id || seen.has(id)) continue; - seen.add(id); - deduped.push(id); - } - return deduped; +// Chuẩn hóa parent id từ API search GEO. +export function normalizeGeoSearchBoundWith(value: unknown): string | null { + if (value == null) return null; + if (typeof value !== "string" && typeof value !== "number") return null; + const id = String(value).trim(); + return id.length ? id : null; } diff --git a/src/uhm/lib/editor/geometry/geometryBinding.ts b/src/uhm/lib/editor/geometry/geometryBinding.ts new file mode 100644 index 0000000..0c58640 --- /dev/null +++ b/src/uhm/lib/editor/geometry/geometryBinding.ts @@ -0,0 +1,65 @@ +import type { Feature, FeatureCollection } from "@/uhm/types/geo"; + +export function normalizeBoundWithId(value: unknown): string | null { + if (value == null) return null; + if (typeof value !== "string" && typeof value !== "number") return null; + const id = String(value).trim(); + return id.length ? id : null; +} + +export function normalizeFeatureBoundWith(feature: Feature): string | null { + return normalizeBoundWithId(feature.properties.bound_with); +} + +export function normalizeLegacyGeometryBindingIds(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const ids: string[] = []; + const seen = new Set(); + for (const rawId of value) { + const id = normalizeBoundWithId(rawId); + if (!id || seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + return ids; +} + +export function getDirectGeometryChildIds( + fc: FeatureCollection, + parentId: string | number | null | undefined +): string[] { + const normalizedParentId = normalizeBoundWithId(parentId); + if (!normalizedParentId) return []; + + return fc.features + .filter((feature) => normalizeFeatureBoundWith(feature) === normalizedParentId) + .map((feature) => String(feature.properties.id)); +} + +export function wouldCreateGeometryBoundWithCycle( + features: Feature[], + childId: string | number, + parentId: string | number +): boolean { + const normalizedChildId = normalizeBoundWithId(childId); + const normalizedParentId = normalizeBoundWithId(parentId); + if (!normalizedChildId || !normalizedParentId) return false; + + const parentByChild = new Map(); + for (const feature of features) { + const id = String(feature.properties.id); + const boundWith = normalizeFeatureBoundWith(feature); + if (boundWith) parentByChild.set(id, boundWith); + } + + const seen = new Set(); + let cursor: string | null = normalizedParentId; + while (cursor) { + if (cursor === normalizedChildId) return true; + if (seen.has(cursor)) return false; + seen.add(cursor); + cursor = parentByChild.get(cursor) || null; + } + + return false; +} diff --git a/src/uhm/lib/editor/geometry/geometryMetadata.ts b/src/uhm/lib/editor/geometry/geometryMetadata.ts index 0e0d1d7..08d1b67 100644 --- a/src/uhm/lib/editor/geometry/geometryMetadata.ts +++ b/src/uhm/lib/editor/geometry/geometryMetadata.ts @@ -1,9 +1,6 @@ import type { Feature, FeatureProperties } from "@/uhm/types/geo"; import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes"; -import { - normalizeFeatureBindingIds, - parseBindingInput, -} from "@/uhm/lib/editor/snapshot/editorSnapshot"; +import { normalizeFeatureBoundWith } from "@/uhm/lib/editor/geometry/geometryBinding"; export type GeometryMetadataPatch = { patch: Partial; @@ -18,27 +15,22 @@ export function buildGeometryMetadataPatch(form: GeometryMetaFormState): Geometr throw new Error("time_start phải <= time_end."); } - const bindingIds = parseBindingInput(form.binding); return { patch: { type: typeKey.length ? typeKey : undefined, time_start: timeStart, time_end: timeEnd, - binding: bindingIds, }, formState: { type_key: typeKey, time_start: timeStart != null ? String(timeStart) : "", time_end: timeEnd != null ? String(timeEnd) : "", - binding: bindingIds.join(", "), }, }; } -export function formatBindingIdsForDisplay(feature: Feature): string { - const bindingIds = normalizeFeatureBindingIds(feature); - if (!bindingIds.length) return "Không có"; - return bindingIds.join(", "); +export function formatBoundWithForDisplay(feature: Feature): string { + return normalizeFeatureBoundWith(feature) || "Không có"; } function parseOptionalYearInput(raw: string, fieldName: string): number | null { diff --git a/src/uhm/lib/editor/geometry/useFeatureCommands.ts b/src/uhm/lib/editor/geometry/useFeatureCommands.ts index ae5fb88..b35e977 100644 --- a/src/uhm/lib/editor/geometry/useFeatureCommands.ts +++ b/src/uhm/lib/editor/geometry/useFeatureCommands.ts @@ -43,7 +43,7 @@ export function useFeatureCommands(options: Options) { setEntityFormStatus, } = options; - // Áp metadata GEO (type/time/binding) cho toàn bộ selectedFeatures. + // Áp metadata GEO (type/time) cho toàn bộ selectedFeatures. const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => { if (!selectedFeatures || selectedFeatures.length === 0) { const msg = "Hãy chọn ít nhất một geometry trước."; diff --git a/src/uhm/lib/editor/project/useProjectCommands.ts b/src/uhm/lib/editor/project/useProjectCommands.ts index cde3376..53ba572 100644 --- a/src/uhm/lib/editor/project/useProjectCommands.ts +++ b/src/uhm/lib/editor/project/useProjectCommands.ts @@ -11,7 +11,6 @@ import { import { buildEditorSnapshot, normalizeEditorSnapshot, - normalizeFeatureEntityIds, toApiEditorSnapshot, } from "@/uhm/lib/editor/snapshot/editorSnapshot"; import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline"; @@ -230,7 +229,7 @@ export function useProjectCommands(options: Options) { } finally { state.setIsSubmitting(false); } - }, [options.editor.mainDraft, options.pendingSaveCount, options.store]); + }, [options.pendingSaveCount, options.store]); const restoreCommit = useCallback(async (commitId: string) => { const state = options.store.getState(); @@ -344,7 +343,7 @@ function toEditorSessionGeometries(input: EditorSnapshot["geometries"]): Geometr type: g.type ?? undefined, draw_geometry: g.draw_geometry, geometry: g.geometry, - binding: Array.isArray(g.binding) ? [...g.binding] : undefined, + bound_with: g.bound_with ?? null, time_start: normalizeTimelineYearValue(g.time_start) ?? undefined, time_end: normalizeTimelineYearValue(g.time_end) ?? undefined, bbox: g.bbox diff --git a/src/uhm/lib/editor/session/sessionTypes.ts b/src/uhm/lib/editor/session/sessionTypes.ts index 2023b2f..9c6618e 100644 --- a/src/uhm/lib/editor/session/sessionTypes.ts +++ b/src/uhm/lib/editor/session/sessionTypes.ts @@ -27,7 +27,6 @@ export type GeometryMetaFormState = { type_key: string; time_start: string; time_end: string; - binding: string; }; export type PendingEntityCreate = { diff --git a/src/uhm/lib/editor/session/useEntitySessionState.ts b/src/uhm/lib/editor/session/useEntitySessionState.ts index 72fc036..604ee9b 100644 --- a/src/uhm/lib/editor/session/useEntitySessionState.ts +++ b/src/uhm/lib/editor/session/useEntitySessionState.ts @@ -25,12 +25,11 @@ export function useEntitySessionState() { }); // Danh sách entity IDs đang chọn để bind vào geometry hiện tại. const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState([]); - // Form metadata geometry (time range + binding ids). + // Form metadata geometry (type + time range). const [geometryMetaForm, setGeometryMetaForm] = useState({ type_key: "", time_start: "", time_end: "", - binding: "", }); // Cờ loading khi apply entity/metadata (local submit). const [isEntitySubmitting, setIsEntitySubmitting] = useState(false); diff --git a/src/uhm/lib/editor/snapshot/editorSnapshot.ts b/src/uhm/lib/editor/snapshot/editorSnapshot.ts index ef49ed7..1c935cf 100644 --- a/src/uhm/lib/editor/snapshot/editorSnapshot.ts +++ b/src/uhm/lib/editor/snapshot/editorSnapshot.ts @@ -2,6 +2,11 @@ import { DEFAULT_GEOMETRY_TYPE_ID } from "@/uhm/lib/map/geo/geometryTypeOptions" import { normalizeGeoTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/map/geo/geoTypeMap"; import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline"; import type { Change } from "@/uhm/lib/editor/draft/editorTypes"; +import { + normalizeBoundWithId, + normalizeFeatureBoundWith, + normalizeLegacyGeometryBindingIds, +} from "@/uhm/lib/editor/geometry/geometryBinding"; import type { EntitySnapshot } from "@/uhm/types/entities"; import type { EntitySnapshotOperation } from "@/uhm/types/entities"; import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo"; @@ -55,6 +60,8 @@ interface RawGeometryRow extends UnknownRecord { geo_type?: unknown; draw_geometry?: unknown; geometry?: unknown; + bound_with?: unknown; + // Legacy parent->children geometry binding, migrated to child.bound_with on load. binding?: unknown; time_start?: unknown; time_end?: unknown; @@ -100,6 +107,35 @@ function normalizeApiTimeFields(row: UnknownRecord): void { if ("time_end" in row) row.time_end = normalizeTimelineYearValue(row.time_end); } +function buildLegacyBoundWithByChildId( + geometriesRaw: unknown, + fc: FeatureCollection | undefined +): Map { + const boundWithByChildId = new Map(); + + const ingestParentBinding = (parentId: unknown, rawBinding: unknown) => { + const normalizedParentId = normalizeBoundWithId(parentId); + if (!normalizedParentId) return; + for (const childId of normalizeLegacyGeometryBindingIds(rawBinding)) { + if (childId === normalizedParentId || boundWithByChildId.has(childId)) continue; + boundWithByChildId.set(childId, normalizedParentId); + } + }; + + if (Array.isArray(geometriesRaw)) { + for (const raw of geometriesRaw) { + if (!isRecord(raw)) continue; + ingestParentBinding(raw.id, raw.binding); + } + } + + for (const feature of fc?.features || []) { + ingestParentBinding(feature.properties?.id, (feature.properties as unknown as UnknownRecord)?.binding); + } + + return boundWithByChildId; +} + export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null { if (!isRecord(raw)) return null; const snapshot = raw as UnknownRecord; @@ -139,6 +175,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null { : undefined; const geometriesRaw = snapshot.geometries; + const legacyBoundWithByChildId = buildLegacyBoundWithByChildId(geometriesRaw, fc); const geometries: GeometrySnapshot[] | undefined = Array.isArray(geometriesRaw) ? geometriesRaw .filter(isRecord) @@ -153,6 +190,10 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null { const hasInlineGeometry = "draw_geometry" in row || "geometry" in row; const source: "inline" | "ref" = existingSource || (refId || !hasInlineGeometry ? "ref" : "inline"); const typeKey = normalizeGeoTypeKey(row.type) || normalizeGeoTypeKey(row.geo_type); + const canonicalBoundWith = "bound_with" in row ? normalizeBoundWithId(row.bound_with) : undefined; + const boundWith = canonicalBoundWith !== undefined + ? canonicalBoundWith + : legacyBoundWithByChildId.get(id); return { id, @@ -161,7 +202,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null { type: typeKey, draw_geometry: row.draw_geometry as GeometrySnapshot["draw_geometry"], geometry: row.geometry as GeometrySnapshot["geometry"], - binding: Array.isArray(row.binding) ? row.binding as string[] : undefined, + bound_with: boundWith, time_start: normalizeTimelineYearValue(row.time_start) ?? undefined, time_end: normalizeTimelineYearValue(row.time_end) ?? undefined, bbox: isRecord(row.bbox) @@ -299,6 +340,17 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null { const gid = String(feature.properties.id); const entity_ids = byGeom.get(gid) || []; const p = feature.properties as unknown as UnknownRecord; + const existingBoundWith = "bound_with" in p ? normalizeBoundWithId(p.bound_with) : undefined; + const migratedBoundWith = legacyBoundWithByChildId.get(gid); + if (existingBoundWith !== undefined) { + p.bound_with = existingBoundWith; + } else if (migratedBoundWith !== undefined) { + p.bound_with = migratedBoundWith; + } else { + delete p.bound_with; + } + delete p.binding; + const existingTimeStart = normalizeTimelineYearValue(p.time_start); const existingTimeEnd = normalizeTimelineYearValue(p.time_end); if (existingTimeStart !== null) { @@ -351,7 +403,9 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null { || existingTypeKey || fallbackTypeKey; if (typeKey) p.type = typeKey; - if (Array.isArray(geo.binding) && geo.binding.length) p.binding = geo.binding; + if (geo.bound_with !== undefined) { + p.bound_with = geo.bound_with; + } const timeStart = normalizeTimelineYearValue(geo.time_start); const timeEnd = normalizeTimelineYearValue(geo.time_end); if (timeStart !== null) { @@ -519,7 +573,7 @@ export function buildEditorSnapshot(options: { source: "inline", type: typeKey, draw_geometry: feature.geometry, - binding: normalizeFeatureBindingIds(feature), + bound_with: normalizeFeatureBoundWith(feature), time_start: timeStart, time_end: timeEnd, bbox: bbox @@ -588,6 +642,7 @@ export function buildEditorSnapshot(options: { delete p.type; delete p.time_start; delete p.time_end; + delete p.bound_with; delete p.binding; delete p.entity_id; delete p.entity_ids; @@ -1113,27 +1168,6 @@ export function normalizeFeatureEntityIds(feature: Feature): string[] { return []; } -export function normalizeFeatureBindingIds(feature: Feature): string[] { - const rawBinding = feature.properties.binding; - if (!Array.isArray(rawBinding)) return []; - return uniqueEntityIds(rawBinding - .map((id) => { - if (typeof id !== "string" && typeof id !== "number") return ""; - return String(id).trim(); - }) - .filter((id) => id.length > 0)); -} - -export function parseBindingInput(raw: string): string[] { - if (!raw.trim().length) return []; - return uniqueEntityIds( - raw - .split(/[,\n]/) - .map((item) => item.trim()) - .filter((item) => item.length > 0) - ); -} - export function uniqueEntityIds(ids: string[]): string[] { const deduped: string[] = []; const seen = new Set(); diff --git a/src/uhm/lib/editor/state/useEditorState.ts b/src/uhm/lib/editor/state/useEditorState.ts index 8ac9bab..f87e30f 100644 --- a/src/uhm/lib/editor/state/useEditorState.ts +++ b/src/uhm/lib/editor/state/useEditorState.ts @@ -14,7 +14,7 @@ import type { WikiSnapshot } from "@/uhm/types/wiki"; import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects"; import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants"; import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes"; -import { newId } from "@/uhm/lib/utils/id"; +import { normalizeFeatureBoundWith } from "@/uhm/lib/editor/geometry/geometryBinding"; export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo"; export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes"; @@ -552,12 +552,25 @@ export function useEditorState( if (idx === -1) return; const feature = mainDraftRef.current.features[idx]; + const deletedId = String(feature.properties.id); const nextFeatures = [...mainDraftRef.current.features]; nextFeatures.splice(idx, 1); const undoActions: UndoAction[] = []; const replayUndoAction = pruneReplaysForDeletedGeometryIds([feature.properties.id], `Xóa replay theo GEO #${feature.properties.id}`); if (replayUndoAction) undoActions.push(replayUndoAction); + for (let i = 0; i < nextFeatures.length; i += 1) { + if (normalizeFeatureBoundWith(nextFeatures[i]) !== deletedId) continue; + const prevProperties = deepClone(nextFeatures[i].properties); + nextFeatures[i] = { + ...nextFeatures[i], + properties: { + ...nextFeatures[i].properties, + bound_with: null, + }, + }; + undoActions.push({ type: "properties", id: nextFeatures[i].properties.id, prevProperties }); + } undoActions.push({ type: "delete", feature: deepClone(feature), index: idx }); pushMainUndo( undoActions.length === 1 @@ -574,28 +587,45 @@ export function useEditorState( const idsSet = new Set(ids.map(String)); const nextFeatures: Feature[] = []; - const undoActions: UndoAction[] = []; + const deleteUndoActions: UndoAction[] = []; + const boundWithUndoActions: UndoAction[] = []; mainDraftRef.current.features.forEach((feature, index) => { if (idsSet.has(String(feature.properties.id))) { - undoActions.push({ type: "delete", feature: deepClone(feature), index }); + deleteUndoActions.push({ type: "delete", feature: deepClone(feature), index }); } else { - nextFeatures.push(feature); + const parentId = normalizeFeatureBoundWith(feature); + if (parentId && idsSet.has(parentId)) { + boundWithUndoActions.push({ + type: "properties", + id: feature.properties.id, + prevProperties: deepClone(feature.properties), + }); + nextFeatures.push({ + ...feature, + properties: { + ...feature.properties, + bound_with: null, + }, + }); + } else { + nextFeatures.push(feature); + } } }); - if (undoActions.length === 0) return; + if (deleteUndoActions.length === 0) return; - const replayUndoAction = pruneReplaysForDeletedGeometryIds(ids, `Xóa replay theo ${undoActions.length} GEO`); + const replayUndoAction = pruneReplaysForDeletedGeometryIds(ids, `Xóa replay theo ${deleteUndoActions.length} GEO`); const groupedActions = replayUndoAction - ? [replayUndoAction, ...undoActions.slice().reverse()] - : undoActions.length === 1 - ? undoActions - : undoActions.slice().reverse(); + ? [replayUndoAction, ...boundWithUndoActions, ...deleteUndoActions.slice().reverse()] + : deleteUndoActions.length === 1 && boundWithUndoActions.length === 0 + ? deleteUndoActions + : [...boundWithUndoActions, ...deleteUndoActions.slice().reverse()]; pushMainUndo( groupedActions.length === 1 ? groupedActions[0] - : { type: "group", label: `Xóa ${undoActions.length} geometry`, actions: groupedActions } + : { type: "group", label: `Xóa ${deleteUndoActions.length} geometry`, actions: groupedActions } ); commitMainDraft({ ...mainDraftRef.current, features: nextFeatures }); } @@ -620,6 +650,21 @@ export function useEditorState( }; nextFeatures[idx] = newFeature; + const boundWithUndoActions: UndoAction[] = []; + for (let i = 0; i < nextFeatures.length; i += 1) { + if (i === idx) continue; + if (normalizeFeatureBoundWith(nextFeatures[i]) !== String(oldId)) continue; + const prevProperties = deepClone(nextFeatures[i].properties); + nextFeatures[i] = { + ...nextFeatures[i], + properties: { + ...nextFeatures[i].properties, + bound_with: String(newId), + }, + }; + boundWithUndoActions.push({ type: "properties", id: nextFeatures[i].properties.id, prevProperties }); + } + // Cập nhật replays nếu có const prevReplays = replaysRef.current || []; const nextReplays = prevReplays.map((replay) => { @@ -640,6 +685,7 @@ export function useEditorState( type: "group", label: `Đổi ID GEO #${oldId} -> #${newId}`, actions: [ + ...boundWithUndoActions, { type: "replays", label: "Phục hồi replay cũ", prevReplays: deepClone(prevReplays) }, { type: "create", id: newId }, { type: "delete", feature: deepClone(prevFeature), index: idx }, @@ -910,7 +956,7 @@ function createReplaySessionSeed( id: geometryId, geometry_id: geometryId, target_geometry_ids: buildReplaySeedTargetIds( - sourceDraft.features.find((feature) => String(feature.properties.id) === geometryId), + sourceDraft, geometryId, selectedIds ), @@ -926,8 +972,7 @@ function normalizeReplaySessionSeed( ): BattleReplay { const nextReplay = deepClone(replay); nextReplay.id = geometryId; - const triggerFeature = sourceDraft.features.find((feature) => String(feature.properties.id) === geometryId); - const seedTargetIds = buildReplaySeedTargetIds(triggerFeature, geometryId, selectedIds); + const seedTargetIds = buildReplaySeedTargetIds(sourceDraft, geometryId, selectedIds); nextReplay.target_geometry_ids = normalizeReplayTargetGeometryIds( nextReplay.target_geometry_ids, geometryId, @@ -937,7 +982,7 @@ function normalizeReplaySessionSeed( } function buildReplaySeedTargetIds( - triggerFeature: Feature | undefined, + sourceDraft: FeatureCollection, featureId: string, selectedIds: (string | number)[] ) { @@ -958,9 +1003,9 @@ function buildReplaySeedTargetIds( pushId(rawId); } - if (Array.isArray(triggerFeature?.properties?.binding)) { - for (const rawId of triggerFeature.properties.binding) { - pushId(rawId); + for (const feature of sourceDraft.features) { + if (normalizeFeatureBoundWith(feature) === featureId) { + pushId(feature.properties.id); } } @@ -1020,7 +1065,7 @@ function sanitizeReplayFeature(feature: Feature): Feature { ...feature, properties: { ...feature.properties, - binding: [], + bound_with: null, }, }; } diff --git a/src/uhm/store/editorStore.tsx b/src/uhm/store/editorStore.tsx index 95dd745..8ede540 100644 --- a/src/uhm/store/editorStore.tsx +++ b/src/uhm/store/editorStore.tsx @@ -257,7 +257,6 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi { type_key: "", time_start: "", time_end: "", - binding: "", }, isEntitySubmitting: false, entityFormStatus: null, diff --git a/src/uhm/types/geo.ts b/src/uhm/types/geo.ts index 43720d2..c2726f7 100644 --- a/src/uhm/types/geo.ts +++ b/src/uhm/types/geo.ts @@ -21,7 +21,7 @@ export type FeatureProperties = { geometry_preset?: GeometryPreset | null; time_start?: number | null; time_end?: number | null; - binding?: string[]; + bound_with?: string | null; entity_id?: string | null; entity_ids?: string[]; entity_name?: string | null; @@ -60,7 +60,7 @@ export type GeometrySnapshot = { type?: string | null; draw_geometry?: Geometry; geometry?: Geometry; - binding?: string[]; + bound_with?: string | null; time_start?: number | null; time_end?: number | null; bbox?: {