refactor(important): change binđing geometry to bound_with(up side down tree)

This commit is contained in:
taDuc
2026-05-24 20:18:29 +07:00
parent a98e1ac5b0
commit 395eb3de47
31 changed files with 339 additions and 234 deletions
+2 -2
View File
@@ -83,7 +83,7 @@ Nguồn: `const editor = useEditorState(initialData)`
- `editor.draft: FeatureCollection` - `editor.draft: FeatureCollection`
- **Single source of truth** cho geometry đang hiển thị + chỉnh sửa. - **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. - Đây chính là **store runtime của GEO** trong session.
- `editor.changes: Map<id, Change>` - `editor.changes: Map<id, Change>`
@@ -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`: 3. Nếu geometry chưa có trong `editor.draft`:
- tạo `Feature` mới với `id = geometry.id` - tạo `Feature` mới với `id = geometry.id`
- set `properties.type` từ `geo_type` (map qua `geoTypeCodeToTypeKey`) - 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 - set denormalized `entity_id/entity_ids/entity_name/entity_names` để UI/joins hoạt động
4. `editor.createFeature(feature)` và auto select feature đó. 4. `editor.createFeature(feature)` và auto select feature đó.
+57 -54
View File
@@ -30,10 +30,14 @@ import {
import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes"; import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
import { import {
getDefaultTypeIdForFeature, getDefaultTypeIdForFeature,
normalizeFeatureBindingIds,
normalizeFeatureEntityIds, normalizeFeatureEntityIds,
uniqueEntityIds, uniqueEntityIds,
} from "@/uhm/lib/editor/snapshot/editorSnapshot"; } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import {
getDirectGeometryChildIds,
normalizeFeatureBoundWith,
wouldCreateGeometryBoundWithCycle,
} from "@/uhm/lib/editor/geometry/geometryBinding";
import { import {
buildClientEntityId, buildClientEntityId,
mergeEntitySearchResults, mergeEntitySearchResults,
@@ -71,7 +75,7 @@ import {
isFeatureVisibleAtYear, isFeatureVisibleAtYear,
normalizeEntitiesForCompare, normalizeEntitiesForCompare,
normalizeEntityWikiLinksForCompare, normalizeEntityWikiLinksForCompare,
normalizeGeoSearchBindingIds, normalizeGeoSearchBoundWith,
normalizeGeoSearchGeometry, normalizeGeoSearchGeometry,
normalizeReplaysForCompare, normalizeReplaysForCompare,
normalizeWikisForCompare, normalizeWikisForCompare,
@@ -630,11 +634,11 @@ function EditorPageContent() {
return rows; return rows;
}, [activeTimelineFilterEnabled, editor, mapRenderDraft.features]); }, [activeTimelineFilterEnabled, editor, mapRenderDraft.features]);
// Binding ids của geometry đại diện đang chọn. // Child ids bound to the selected geometry via child.properties.bound_with.
const selectedGeometryBindingIds = useMemo(() => { const selectedGeometryChildIds = useMemo(() => {
if (!selectedFeature) return []; if (!selectedFeature) return [];
return normalizeFeatureBindingIds(selectedFeature); return getDirectGeometryChildIds(editor.draft, selectedFeature.properties.id);
}, [selectedFeature]); }, [editor.draft, selectedFeature]);
// Choices wiki dùng trong replay actions và binding panel. // Choices wiki dùng trong replay actions và binding panel.
const wikiChoices = useMemo(() => { const wikiChoices = useMemo(() => {
@@ -1317,7 +1321,6 @@ function EditorPageContent() {
type_key: "", type_key: "",
time_start: "", time_start: "",
time_end: "", time_end: "",
binding: "",
}); });
setEntityFormStatus(null); setEntityFormStatus(null);
lastSelectedFeatureIdRef.current = null; lastSelectedFeatureIdRef.current = null;
@@ -1336,7 +1339,6 @@ function EditorPageContent() {
type_key: nextTypeKey, type_key: nextTypeKey,
time_start: timeStart != null ? String(timeStart) : "", time_start: timeStart != null ? String(timeStart) : "",
time_end: timeEnd != null ? String(timeEnd) : "", time_end: timeEnd != null ? String(timeEnd) : "",
binding: normalizeFeatureBindingIds(selectedFeature).join(", "),
}); });
// Only clear status when switching to a different geometry, not when patching metadata/bindings // Only clear status when switching to a different geometry, not when patching metadata/bindings
// on the same selected geometry (otherwise messages will blink). // on the same selected geometry (otherwise messages will blink).
@@ -1518,58 +1520,55 @@ function EditorPageContent() {
setSelectedGeometryEntityIds, 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) => { const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => {
if (!selectedFeatures || selectedFeatures.length === 0) { if (!selectedFeatures || selectedFeatures.length === 0) {
flashGeoBindingStatus("Chưa chọn geometry để bind."); flashGeoBindingStatus("Chưa chọn geometry để bind.");
return; return;
} }
if (selectedFeatures.length !== 1 || !selectedFeature) {
flashGeoBindingStatus("Chỉ bind geometry-geometry khi chọn đúng một geometry cha.");
return;
}
if (!isMultiEditValid) { if (!isMultiEditValid) {
flashGeoBindingStatus("Không thể bind geometry cho nhiều geometry khác loại."); flashGeoBindingStatus("Không thể bind geometry cho nhiều geometry khác loại.");
return; return;
} }
const id = String(geoId || "").trim(); const id = String(geoId || "").trim();
if (!id) return; 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); setIsEntitySubmitting(true);
flashGeoBindingStatus(null, 0); flashGeoBindingStatus(null, 0);
try { try {
const bindingPatches = selectedFeatures.map((feature) => { const currentParentId = normalizeFeatureBoundWith(childFeature);
const prevBindingIds = normalizeFeatureBindingIds(feature); const nextBoundWith = nextChecked
const has = prevBindingIds.includes(id); ? parentId
const nextBindingIds = (() => { : currentParentId === parentId
if (nextChecked) { ? null
if (has) return prevBindingIds; : currentParentId;
return [...prevBindingIds, id];
}
if (!has) return prevBindingIds;
return prevBindingIds.filter((x) => x !== id);
})();
return {
id: feature.properties.id,
patch: { binding: nextBindingIds },
};
});
editor.patchFeaturePropertiesBatch( editor.patchFeaturePropertiesBatch(
bindingPatches, [{
id: childFeature.properties.id,
patch: { bound_with: nextBoundWith },
}],
nextChecked ? "Bind geometry vào GEO" : "Unbind geometry khỏi GEO" 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( flashGeoBindingStatus(
nextChecked nextChecked
? "Đã bind geometry vào binding. Commit khi sẵn sàng." ? "Đã set bound_with cho geometry con. Commit khi sẵn sàng."
: "Đã gỡ binding geometry. Commit khi sẵn sàng.", : "Đã gỡ bound_with khỏi geometry con. Commit khi sẵn sàng.",
3000 3000
); );
} finally { } finally {
@@ -1578,13 +1577,13 @@ function EditorPageContent() {
}, [ }, [
editor, editor,
flashGeoBindingStatus, flashGeoBindingStatus,
selectedFeature,
selectedFeatures, selectedFeatures,
isMultiEditValid, isMultiEditValid,
setGeometryMetaForm,
setIsEntitySubmitting, 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 handleBindGeometries = useCallback((targetId: string | number, sourceIds: (string | number)[]) => {
const idStr = String(targetId).trim(); const idStr = String(targetId).trim();
if (!idStr) return; if (!idStr) return;
@@ -1595,23 +1594,27 @@ function EditorPageContent() {
return; 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) if (!sourceFeatures.length) {
// filter out targetId itself (we can't bind a geometry to itself) flashGeoBindingStatus("Không có geometry con hợp lệ để bind.");
const newSources = sourceIds.map(String).filter((x) => x !== idStr); return;
const merged = Array.from(new Set([...prevBindingIds, ...newSources])); }
editor.patchFeaturePropertiesBatch( editor.patchFeaturePropertiesBatch(
[{ sourceFeatures.map((feature) => ({
id: targetFeature.properties.id, id: feature.properties.id,
patch: { binding: merged }, patch: { bound_with: idStr },
}], })),
"Bind các geometry đã chọn vào GEO" "Bind các geometry đã chọn vào GEO"
); );
setSelectedFeatureIds([targetFeature.properties.id]); 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]); }, [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 đó. // 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; return;
} }
const bindingIds = normalizeGeoSearchBindingIds(geo.binding); const boundWith = normalizeGeoSearchBoundWith(geo.bound_with);
const typeKey = geo.type || null; const typeKey = geo.type || null;
const feature: Feature = { const feature: Feature = {
@@ -1797,7 +1800,7 @@ function EditorPageContent() {
type: typeKey, type: typeKey,
time_start: normalizeTimelineYearValue(geo.time_start), time_start: normalizeTimelineYearValue(geo.time_start),
time_end: normalizeTimelineYearValue(geo.time_end), time_end: normalizeTimelineYearValue(geo.time_end),
binding: bindingIds.length ? bindingIds : undefined, bound_with: boundWith,
entity_id: entityItem.entity_id, entity_id: entityItem.entity_id,
entity_ids: [entityItem.entity_id], entity_ids: [entityItem.entity_id],
entity_name: (entityItem.name || "").trim() || entityItem.entity_id, entity_name: (entityItem.name || "").trim() || entityItem.entity_id,
@@ -2345,7 +2348,7 @@ function EditorPageContent() {
<GeometryBindingPanel <GeometryBindingPanel
geometries={geometryChoices} geometries={geometryChoices}
selectedGeometryId={selectedFeature ? String(selectedFeature.properties.id) : null} selectedGeometryId={selectedFeature ? String(selectedFeature.properties.id) : null}
selectedGeometryBindingIds={selectedGeometryBindingIds} selectedGeometryChildIds={selectedGeometryChildIds}
onToggleBindGeometryForSelectedGeometry={handleToggleBindGeometryForSelectedGeometry} onToggleBindGeometryForSelectedGeometry={handleToggleBindGeometryForSelectedGeometry}
onFocusGeometry={handleFocusGeometryFromBindingPanel} onFocusGeometry={handleFocusGeometryFromBindingPanel}
/> />
+10 -12
View File
@@ -10,7 +10,7 @@ export type EntityGeometrySearchGeo = {
id: string; id: string;
type: string | null; type: string | null;
draw_geometry: Geometry; draw_geometry: Geometry;
binding?: string[]; bound_with?: string | null;
time_start?: number | null; time_start?: number | null;
time_end?: number | null; time_end?: number | null;
@@ -96,7 +96,7 @@ export async function searchGeometriesByEntityName(
id: geometry.id, id: geometry.id,
type: geoTypeCodeToTypeKey(geometry.geo_type) || null, type: geoTypeCodeToTypeKey(geometry.geo_type) || null,
draw_geometry: geometry.draw_geometry, draw_geometry: geometry.draw_geometry,
binding: geometry.binding, bound_with: normalizeBoundWith(geometry.bound_with),
time_start: geometry.time_start ?? null, time_start: geometry.time_start ?? null,
time_end: geometry.time_end ?? null, time_end: geometry.time_end ?? null,
})), })),
@@ -108,7 +108,7 @@ type GeometryRow = {
id: string; id: string;
geo_type: number; geo_type: number;
draw_geometry: Geometry; draw_geometry: Geometry;
binding?: string[]; bound_with?: string | null;
time_start?: number; time_start?: number;
time_end?: number; time_end?: number;
@@ -127,7 +127,7 @@ function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection {
const geometry = normalizeGeometry(row.draw_geometry); const geometry = normalizeGeometry(row.draw_geometry);
if (!geometry) continue; if (!geometry) continue;
const binding = normalizeBinding(row.binding); const boundWith = normalizeBoundWith(row.bound_with);
const typeKey = geoTypeCodeToTypeKey(row.geo_type) || null; const typeKey = geoTypeCodeToTypeKey(row.geo_type) || null;
const properties: FeatureProperties = { const properties: FeatureProperties = {
@@ -135,7 +135,7 @@ function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection {
type: typeKey, type: typeKey,
time_start: row.time_start ?? null, time_start: row.time_start ?? null,
time_end: row.time_end ?? null, time_end: row.time_end ?? null,
binding: binding.length ? binding : undefined, bound_with: boundWith,
}; };
features.push({ features.push({
@@ -156,11 +156,9 @@ function normalizeGeometry(value: unknown): Geometry | null {
return value as Geometry; return value as Geometry;
} }
function normalizeBinding(value: unknown): string[] { function normalizeBoundWith(value: unknown): string | null {
if (!value) return []; if (value == null) return null;
if (Array.isArray(value)) { if (typeof value !== "string" && typeof value !== "number") return null;
return value.map((v) => String(v)).filter((v) => v.length > 0); const id = String(value).trim();
} return id.length ? id : null;
// Some deployments may return binding as an object; ignore it for FE properties.binding.
return [];
} }
@@ -29,7 +29,7 @@ type GeometryRow = Required<Pick<GeometryChoice, "id" | "label" | "isOrphan" | "
type Props = { type Props = {
geometries: GeometryChoice[]; geometries: GeometryChoice[];
selectedGeometryId?: string | null; selectedGeometryId?: string | null;
selectedGeometryBindingIds: string[]; selectedGeometryChildIds: string[];
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void; onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
onFocusGeometry?: (geometryId: string) => void; onFocusGeometry?: (geometryId: string) => void;
}; };
@@ -37,7 +37,7 @@ type Props = {
export default function GeometryBindingPanel({ export default function GeometryBindingPanel({
geometries, geometries,
selectedGeometryId, selectedGeometryId,
selectedGeometryBindingIds, selectedGeometryChildIds,
onToggleBindGeometryForSelectedGeometry, onToggleBindGeometryForSelectedGeometry,
onFocusGeometry, onFocusGeometry,
}: Props) { }: Props) {
@@ -85,7 +85,7 @@ export default function GeometryBindingPanel({
return cleaned; return cleaned;
}, [geometries]); }, [geometries]);
const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]); const childSet = useMemo(() => new Set(selectedGeometryChildIds || []), [selectedGeometryChildIds]);
const selectedGeometry = useMemo(() => { const selectedGeometry = useMemo(() => {
if (!effectiveSelectedGeometryId) return null; if (!effectiveSelectedGeometryId) return null;
return rows.find((g) => g.id === effectiveSelectedGeometryId) || null; return rows.find((g) => g.id === effectiveSelectedGeometryId) || null;
@@ -94,12 +94,12 @@ export default function GeometryBindingPanel({
return rows return rows
.filter((g) => g.id !== effectiveSelectedGeometryId) .filter((g) => g.id !== effectiveSelectedGeometryId)
.sort((a, b) => { .sort((a, b) => {
const aBound = bindingSet.has(a.id); const aBound = childSet.has(a.id);
const bBound = bindingSet.has(b.id); const bBound = childSet.has(b.id);
if (aBound !== bBound) return aBound ? -1 : 1; if (aBound !== bBound) return aBound ? -1 : 1;
return a.id.localeCompare(b.id); return a.id.localeCompare(b.id);
}); });
}, [bindingSet, effectiveSelectedGeometryId, rows]); }, [childSet, effectiveSelectedGeometryId, rows]);
const summary = useMemo(() => { const summary = useMemo(() => {
let orphan = 0; let orphan = 0;
let missingTime = 0; let missingTime = 0;
@@ -247,7 +247,7 @@ export default function GeometryBindingPanel({
{collapsed ? null : selectedGeometry ? ( {collapsed ? null : selectedGeometry ? (
(() => { (() => {
const isHidden = geometryVisibility[selectedGeometry.id] === false; const isHidden = geometryVisibility[selectedGeometry.id] === false;
const isBound = bindingSet.has(selectedGeometry.id); const isBound = childSet.has(selectedGeometry.id);
const title = buildGeometryTitle(selectedGeometry, isHidden, isBound); const title = buildGeometryTitle(selectedGeometry, isHidden, isBound);
return ( return (
<div <div
@@ -306,7 +306,7 @@ export default function GeometryBindingPanel({
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}> <div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
{visibleRows {visibleRows
.map((g) => { .map((g) => {
const isBound = bindingSet.has(g.id); const isBound = childSet.has(g.id);
const isHidden = geometryVisibility[g.id] === false; const isHidden = geometryVisibility[g.id] === false;
const title = buildGeometryTitle(g, isHidden, isBound); const title = buildGeometryTitle(g, isHidden, isBound);
return ( return (
@@ -58,10 +58,8 @@ export default function SelectedGeometryPanel({
geometryMetaForm.type_key, geometryMetaForm.type_key,
geometryMetaForm.time_start, geometryMetaForm.time_start,
geometryMetaForm.time_end, geometryMetaForm.time_end,
geometryMetaForm.binding,
].join("|"); ].join("|");
}, [ }, [
geometryMetaForm.binding,
geometryMetaForm.time_end, geometryMetaForm.time_end,
geometryMetaForm.time_start, geometryMetaForm.time_start,
geometryMetaForm.type_key, geometryMetaForm.type_key,
+17 -37
View File
@@ -13,6 +13,7 @@ import { PATH_RENDER_BY_TYPE } from "@/uhm/lib/map/styles/style";
import { getBackgroundRasterSourceSpecification } from "@/uhm/api/tiles"; import { getBackgroundRasterSourceSpecification } from "@/uhm/api/tiles";
import { newId } from "@/uhm/lib/utils/id"; import { newId } from "@/uhm/lib/utils/id";
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap"; import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
import { normalizeBoundWithId, normalizeFeatureBoundWith } from "@/uhm/lib/editor/geometry/geometryBinding";
import type { EntityLabelCandidate } from "@/uhm/types/geo"; import type { EntityLabelCandidate } from "@/uhm/types/geo";
type Coordinate = [number, number]; type Coordinate = [number, number];
@@ -149,9 +150,18 @@ export function filterDraftByBinding(
} }
const childIds = new Set<string>(); const childIds = new Set<string>();
const selectedChildren = new Set<string>();
const selectedParents = new Set<string>();
for (const feature of fc.features) { for (const feature of fc.features) {
for (const id of normalizeBindingIds(feature.properties.binding)) { const featureId = String(feature.properties.id);
childIds.add(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))) }; return { ...fc, features: fc.features.filter((f) => !childIds.has(String(f.properties.id))) };
} }
const selectedChildren = new Set<string>();
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 { return {
...fc, ...fc,
features: fc.features.filter((feature) => { features: fc.features.filter((feature) => {
const featureId = String(feature.properties.id); const featureId = String(feature.properties.id);
if (selectedIds.has(featureId)) return true; if (selectedIds.has(featureId)) return true;
if (selectedChildren.has(featureId)) return true; if (selectedChildren.has(featureId)) return true;
if (selectedParents.has(featureId)) return true;
return !childIds.has(featureId); 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<string>();
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) { export function splitDraftFeatures(fc: FeatureCollection) {
const polygons = { const polygons = {
type: "FeatureCollection", type: "FeatureCollection",
@@ -683,21 +671,13 @@ function createFeatureLabelResolver(
} }
for (const feature of fc.features) { for (const feature of fc.features) {
const parentLabel = directLabelsByFeatureId.get(String(feature.properties.id));
const featureId = 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) { if (parentLabel) {
for (const childId of bindingIds) { mergeInheritedFeatureLabel(inheritedLabelsByChildId, featureId, parentLabel);
mergeInheritedFeatureLabel(inheritedLabelsByChildId, childId, parentLabel);
}
}
for (const parentId of bindingIds) {
const linkedParentLabel = directLabelsByFeatureId.get(parentId);
if (linkedParentLabel) {
mergeInheritedFeatureLabel(inheritedLabelsByChildId, featureId, linkedParentLabel);
}
} }
} }
+6 -6
View File
@@ -135,7 +135,7 @@ export function useMapInteraction({
entity_ids: [], entity_ids: [],
entity_name: null, entity_name: null,
entity_type_id: null, entity_type_id: null,
binding: [], bound_with: null,
}, },
geometry, geometry,
}); });
@@ -201,7 +201,7 @@ export function useMapInteraction({
entity_ids: [], entity_ids: [],
entity_name: null, entity_name: null,
entity_type_id: null, entity_type_id: null,
binding: [], bound_with: null,
}, },
geometry, geometry,
}); });
@@ -223,7 +223,7 @@ export function useMapInteraction({
entity_ids: [], entity_ids: [],
entity_name: null, entity_name: null,
entity_type_id: null, entity_type_id: null,
binding: [], bound_with: null,
}, },
geometry, geometry,
}); });
@@ -245,7 +245,7 @@ export function useMapInteraction({
entity_ids: [], entity_ids: [],
entity_name: null, entity_name: null,
entity_type_id: null, entity_type_id: null,
binding: [], bound_with: null,
}, },
geometry, geometry,
}); });
@@ -267,7 +267,7 @@ export function useMapInteraction({
entity_ids: [], entity_ids: [],
entity_name: null, entity_name: null,
entity_type_id: null, entity_type_id: null,
binding: [], bound_with: null,
}, },
geometry, geometry,
}); });
@@ -372,7 +372,7 @@ function buildDuplicatedFeatureShapeOnly(
entity_ids: [], entity_ids: [],
entity_name: null, entity_name: null,
entity_names: [], entity_names: [],
binding: [], bound_with: null,
}, },
geometry, geometry,
}; };
+2 -2
View File
@@ -47,7 +47,7 @@ export type FeatureProperties = {
geometry_preset?: GeometryPreset | null; geometry_preset?: GeometryPreset | null;
time_start?: number | null; time_start?: number | null;
time_end?: number | null; time_end?: number | null;
binding?: string[]; bound_with?: string | null;
// UI/editor-only denormalized fields. // UI/editor-only denormalized fields.
entity_id?: string | null; entity_id?: string | null;
@@ -103,7 +103,7 @@ export type GeometrySnapshot = {
type?: string | null; type?: string | null;
draw_geometry?: Geometry; draw_geometry?: Geometry;
geometry?: Geometry; geometry?: Geometry;
binding?: string[]; bound_with?: string | null;
time_start?: number | null; time_start?: number | null;
time_end?: number | null; time_end?: number | null;
bbox?: { bbox?: {
+3 -3
View File
@@ -76,7 +76,7 @@ Checklist an toàn:
- `type` - `type`
- `geometry_preset` - `geometry_preset`
- `entity_ids` - `entity_ids`
- `binding` - `bound_with`
6. Kiểm tra interaction cleanup khi chuyển mode. 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ũ. 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)` - `normalizeEditorSnapshot(raw)`
- đọc payload từ backend - đọ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)` - `buildEditorSnapshot(options)`
- strip các field generate-only khỏi `editor_feature_collection` - strip các field generate-only khỏi `editor_feature_collection`
- build `geometry_entity[]``entity_wiki[]` - build `geometry_entity[]``entity_wiki[]`
@@ -175,7 +175,7 @@ Có thể do:
- timeline filter - timeline filter
- geometry visibility theo type - geometry visibility theo type
- binding filter - bound_with filter
Không phải lúc nào cũng là bug render layer. Không phải lúc nào cũng là bug render layer.
+3 -3
View File
@@ -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. 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` ### `geometryVisibility`
@@ -104,7 +104,7 @@ Map local visibility override. Key có thể là geometry id hoặc semantic geo
### `applyGeometryBindingFilter` ### `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 ## Guard rails
+5 -5
View File
@@ -75,7 +75,7 @@ Geometry mới mặc định có:
- `type: "country"` - `type: "country"`
- `geometry_preset: "polygon"` - `geometry_preset: "polygon"`
- `entity_ids: []` - `entity_ids: []`
- `binding: []` - `bound_with: null`
### Point (`add-point`) ### Point (`add-point`)
@@ -143,7 +143,7 @@ Khi đang ở `select`, editor có thể sửa polygon/circle qua `editingEngine
- `time_start` - `time_start`
- `time_end` - `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ó: 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 ### 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. - 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ó 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: - Row geometry hiển thị chip trạng thái trong panel:
- `no entity` nếu geometry chưa bind entity. - `no entity` nếu geometry chưa bind entity.
- `no time` nếu thiếu cả `time_start``time_end`. - `no time` nếu thiếu cả `time_start``time_end`.
+2 -2
View File
@@ -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`. - Chọn một geometry, bind geometry khác trong `GeometryBindingPanel`.
- Panel hiện chip `bound` cho geometry liên quan. - 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. - 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. - 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 ## 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 ## 7. Replay
- Chọn geometry có entity, bấm 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 `bound_with` trỏ tới MAIN.
- Tạo stage, tạo step, đổi duration. - Tạo stage, tạo step, đổi duration.
- Thêm narrative action `set_title``set_descriptions`. - Thêm narrative action `set_title``set_descriptions`.
- Thêm map action `set_time_filter`, `show_labels`, `hide_labels`. - Thêm map action `set_time_filter`, `show_labels`, `hide_labels`.
+6 -6
View File
@@ -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 | | Ẩ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 | | 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ú | | 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 | | 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 | | 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 | | 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 | `feature.properties.binding` | `properties` hoặc `group` | `geometries[].binding` | Không ảnh hưởng `geometry_entity[]` | | 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 | `binding` của target geometry | `properties` | `geometries[].binding` | Tự bỏ target id khỏi source ids | | 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/binding | | 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 ## 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[]`. - Wiki rows nằm trong `wikis[]`.
- Entity-wiki rows nằm trong `entity_wiki[]`. - Entity-wiki rows nằm trong `entity_wiki[]`.
- Replay script nằm trong `replays[]`, không lưu `replayDraft`. - 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 ## 12. Các thao tác cần audit lại nếu editor đổi lớn
+6 -4
View File
@@ -63,10 +63,12 @@ Mỗi feature trong `mainDraft.features` sinh một row:
| `operation` | `"create"`, `"update"` hoặc `"reference"` theo baseline/diff | | `operation` | `"create"`, `"update"` hoặc `"reference"` theo baseline/diff |
| `type` | FE type key trước `toApiEditorSnapshot()`, backend code string sau normalize API | | `type` | FE type key trước `toApiEditorSnapshot()`, backend code string sau normalize API |
| `draw_geometry` | `feature.geometry` | | `draw_geometry` | `feature.geometry` |
| `binding` | `normalizeFeatureBindingIds(feature)` | | `bound_with` | `normalizeFeatureBoundWith(feature)` |
| `time_start` / `time_end` | `feature.properties.time_start/time_end ?? null` | | `time_start` / `time_end` | `feature.properties.time_start/time_end ?? null` |
| `bbox` | BBox tính từ geometry, hoặc `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: Geometry đã bị xóa sinh row:
```ts ```ts
@@ -93,7 +95,7 @@ Geometry đã bị xóa sinh row:
- `type` - `type`
- `time_start` - `time_start`
- `time_end` - `time_end`
- `binding` - `bound_with`
- `entity_id` - `entity_id`
- `entity_ids` - `entity_ids`
- `entity_name` - `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: 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 relation nằm ở `geometry_entity[]`.
- entity label/name được hydrate lại từ `entities[]` và join table khi load. - 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`. 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 ## 6. Entity contract
+2 -2
View File
@@ -82,7 +82,7 @@ Replay seed mới có dạng:
- MAIN geo - MAIN geo
- toàn bộ bulk selection hiện tại - 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: 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` - lấy feature từ `mainDraft` theo đúng thứ tự `target_geometry_ids`
- clone ra `FeatureCollection` mới - 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ì ## 6. Trong replay mode map đang đọc gì
+2 -3
View File
@@ -116,9 +116,8 @@ Editor hiện có `undo`, nhưng chưa có redo.
- `type_key` - `type_key`
- `time_start` - `time_start`
- `time_end` - `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 ### 4.3. Replay state
@@ -324,5 +323,5 @@ Hiệu ứng:
-`undo`, chưa có `redo` -`undo`, chưa có `redo`
- timeline state có `timelineYear`, nhưng page hiện dùng `timelineDraftYear` cho filtering - 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 - 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 - trạng thái orphan/time/timeline trong `GeometryBindingPanel` là derived từ draft + visibility, không phải field persist riêng
+1 -1
View File
@@ -86,7 +86,7 @@ FE không còn export/persist cả `FeatureCollection` riêng của replay nữa
- geo MAIN - geo MAIN
- các geo được đưa vào replay từ bulk select - 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ừ: Khi mở replay, FE sẽ hydrate lại `replayDraft` từ:
+2 -2
View File
@@ -88,7 +88,7 @@ Source này dùng cho:
`useMapSync()` chịu trách nhiệm: `useMapSync()` chịu trách nhiệm:
1. nhận `renderDraft` đã được page áp timeline/replay/preview filter trước 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 3. filter theo geometry visibility
4. split feature thành nhóm polygon/line/point 4. split feature thành nhóm polygon/line/point
5. decorate line/polygon/point cho label rendering 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 - 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 - `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 - `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 ## 5. Map interaction layer
+2 -2
View File
@@ -144,7 +144,7 @@ Có ba lớp filter hiển thị trong runtime:
1. background layer visibility 1. background layer visibility
2. geometry visibility theo type key từ panel phải 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. 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 đó: Thay vào đó:
- timeline filter đang chạy phía data trong `page.tsx` - 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`. Tức là phần lớn filtering là `prepare data -> set source`, không phải `add layer filter expression per year`.
+1 -1
View File
@@ -151,7 +151,7 @@ và sinh ra:
Các điểm quan trọng: Các điểm quan trọng:
- geometry many-to-many với entity được persist ở `geometry_entity[]` - 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 - 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 - replay script được persist ở `replays[]`; `replayDraft` không được gửi
+6 -13
View File
@@ -126,17 +126,10 @@ export function normalizeGeoSearchGeometry(value: unknown): Geometry | null {
return value as Geometry; return value as Geometry;
} }
// Chuẩn hóa danh sách binding id từ API search GEO. // Chuẩn hóa parent id từ API search GEO.
export function normalizeGeoSearchBindingIds(value: unknown): string[] { export function normalizeGeoSearchBoundWith(value: unknown): string | null {
if (!Array.isArray(value)) return []; if (value == null) return null;
const deduped: string[] = []; if (typeof value !== "string" && typeof value !== "number") return null;
const seen = new Set<string>(); const id = String(value).trim();
for (const rawId of value) { return id.length ? id : null;
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;
} }
@@ -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<string>();
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<string, string>();
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<string>();
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;
}
@@ -1,9 +1,6 @@
import type { Feature, FeatureProperties } from "@/uhm/types/geo"; import type { Feature, FeatureProperties } from "@/uhm/types/geo";
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes"; import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
import { import { normalizeFeatureBoundWith } from "@/uhm/lib/editor/geometry/geometryBinding";
normalizeFeatureBindingIds,
parseBindingInput,
} from "@/uhm/lib/editor/snapshot/editorSnapshot";
export type GeometryMetadataPatch = { export type GeometryMetadataPatch = {
patch: Partial<FeatureProperties>; patch: Partial<FeatureProperties>;
@@ -18,27 +15,22 @@ export function buildGeometryMetadataPatch(form: GeometryMetaFormState): Geometr
throw new Error("time_start phải <= time_end."); throw new Error("time_start phải <= time_end.");
} }
const bindingIds = parseBindingInput(form.binding);
return { return {
patch: { patch: {
type: typeKey.length ? typeKey : undefined, type: typeKey.length ? typeKey : undefined,
time_start: timeStart, time_start: timeStart,
time_end: timeEnd, time_end: timeEnd,
binding: bindingIds,
}, },
formState: { formState: {
type_key: typeKey, type_key: typeKey,
time_start: timeStart != null ? String(timeStart) : "", time_start: timeStart != null ? String(timeStart) : "",
time_end: timeEnd != null ? String(timeEnd) : "", time_end: timeEnd != null ? String(timeEnd) : "",
binding: bindingIds.join(", "),
}, },
}; };
} }
export function formatBindingIdsForDisplay(feature: Feature): string { export function formatBoundWithForDisplay(feature: Feature): string {
const bindingIds = normalizeFeatureBindingIds(feature); return normalizeFeatureBoundWith(feature) || "Không có";
if (!bindingIds.length) return "Không có";
return bindingIds.join(", ");
} }
function parseOptionalYearInput(raw: string, fieldName: string): number | null { function parseOptionalYearInput(raw: string, fieldName: string): number | null {
@@ -43,7 +43,7 @@ export function useFeatureCommands(options: Options) {
setEntityFormStatus, setEntityFormStatus,
} = options; } = 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 }> => { const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => {
if (!selectedFeatures || selectedFeatures.length === 0) { if (!selectedFeatures || selectedFeatures.length === 0) {
const msg = "Hãy chọn ít nhất một geometry trước."; const msg = "Hãy chọn ít nhất một geometry trước.";
@@ -11,7 +11,6 @@ import {
import { import {
buildEditorSnapshot, buildEditorSnapshot,
normalizeEditorSnapshot, normalizeEditorSnapshot,
normalizeFeatureEntityIds,
toApiEditorSnapshot, toApiEditorSnapshot,
} from "@/uhm/lib/editor/snapshot/editorSnapshot"; } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline"; import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
@@ -230,7 +229,7 @@ export function useProjectCommands(options: Options) {
} finally { } finally {
state.setIsSubmitting(false); state.setIsSubmitting(false);
} }
}, [options.editor.mainDraft, options.pendingSaveCount, options.store]); }, [options.pendingSaveCount, options.store]);
const restoreCommit = useCallback(async (commitId: string) => { const restoreCommit = useCallback(async (commitId: string) => {
const state = options.store.getState(); const state = options.store.getState();
@@ -344,7 +343,7 @@ function toEditorSessionGeometries(input: EditorSnapshot["geometries"]): Geometr
type: g.type ?? undefined, type: g.type ?? undefined,
draw_geometry: g.draw_geometry, draw_geometry: g.draw_geometry,
geometry: g.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_start: normalizeTimelineYearValue(g.time_start) ?? undefined,
time_end: normalizeTimelineYearValue(g.time_end) ?? undefined, time_end: normalizeTimelineYearValue(g.time_end) ?? undefined,
bbox: g.bbox bbox: g.bbox
@@ -27,7 +27,6 @@ export type GeometryMetaFormState = {
type_key: string; type_key: string;
time_start: string; time_start: string;
time_end: string; time_end: string;
binding: string;
}; };
export type PendingEntityCreate = { export type PendingEntityCreate = {
@@ -25,12 +25,11 @@ export function useEntitySessionState() {
}); });
// Danh sách entity IDs đang chọn để bind vào geometry hiện tại. // Danh sách entity IDs đang chọn để bind vào geometry hiện tại.
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]); const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
// Form metadata geometry (time range + binding ids). // Form metadata geometry (type + time range).
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({ const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
type_key: "", type_key: "",
time_start: "", time_start: "",
time_end: "", time_end: "",
binding: "",
}); });
// Cờ loading khi apply entity/metadata (local submit). // Cờ loading khi apply entity/metadata (local submit).
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false); const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
+58 -24
View File
@@ -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 { normalizeGeoTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/map/geo/geoTypeMap";
import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline"; import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
import type { Change } from "@/uhm/lib/editor/draft/editorTypes"; 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 { EntitySnapshot } from "@/uhm/types/entities";
import type { EntitySnapshotOperation } from "@/uhm/types/entities"; import type { EntitySnapshotOperation } from "@/uhm/types/entities";
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo"; import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
@@ -55,6 +60,8 @@ interface RawGeometryRow extends UnknownRecord {
geo_type?: unknown; geo_type?: unknown;
draw_geometry?: unknown; draw_geometry?: unknown;
geometry?: unknown; geometry?: unknown;
bound_with?: unknown;
// Legacy parent->children geometry binding, migrated to child.bound_with on load.
binding?: unknown; binding?: unknown;
time_start?: unknown; time_start?: unknown;
time_end?: 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); if ("time_end" in row) row.time_end = normalizeTimelineYearValue(row.time_end);
} }
function buildLegacyBoundWithByChildId(
geometriesRaw: unknown,
fc: FeatureCollection | undefined
): Map<string, string> {
const boundWithByChildId = new Map<string, string>();
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 { export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
if (!isRecord(raw)) return null; if (!isRecord(raw)) return null;
const snapshot = raw as UnknownRecord; const snapshot = raw as UnknownRecord;
@@ -139,6 +175,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
: undefined; : undefined;
const geometriesRaw = snapshot.geometries; const geometriesRaw = snapshot.geometries;
const legacyBoundWithByChildId = buildLegacyBoundWithByChildId(geometriesRaw, fc);
const geometries: GeometrySnapshot[] | undefined = Array.isArray(geometriesRaw) const geometries: GeometrySnapshot[] | undefined = Array.isArray(geometriesRaw)
? geometriesRaw ? geometriesRaw
.filter(isRecord) .filter(isRecord)
@@ -153,6 +190,10 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const hasInlineGeometry = "draw_geometry" in row || "geometry" in row; const hasInlineGeometry = "draw_geometry" in row || "geometry" in row;
const source: "inline" | "ref" = existingSource || (refId || !hasInlineGeometry ? "ref" : "inline"); const source: "inline" | "ref" = existingSource || (refId || !hasInlineGeometry ? "ref" : "inline");
const typeKey = normalizeGeoTypeKey(row.type) || normalizeGeoTypeKey(row.geo_type); 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 { return {
id, id,
@@ -161,7 +202,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
type: typeKey, type: typeKey,
draw_geometry: row.draw_geometry as GeometrySnapshot["draw_geometry"], draw_geometry: row.draw_geometry as GeometrySnapshot["draw_geometry"],
geometry: row.geometry as GeometrySnapshot["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_start: normalizeTimelineYearValue(row.time_start) ?? undefined,
time_end: normalizeTimelineYearValue(row.time_end) ?? undefined, time_end: normalizeTimelineYearValue(row.time_end) ?? undefined,
bbox: isRecord(row.bbox) bbox: isRecord(row.bbox)
@@ -299,6 +340,17 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const gid = String(feature.properties.id); const gid = String(feature.properties.id);
const entity_ids = byGeom.get(gid) || []; const entity_ids = byGeom.get(gid) || [];
const p = feature.properties as unknown as UnknownRecord; 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 existingTimeStart = normalizeTimelineYearValue(p.time_start);
const existingTimeEnd = normalizeTimelineYearValue(p.time_end); const existingTimeEnd = normalizeTimelineYearValue(p.time_end);
if (existingTimeStart !== null) { if (existingTimeStart !== null) {
@@ -351,7 +403,9 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|| existingTypeKey || existingTypeKey
|| fallbackTypeKey; || fallbackTypeKey;
if (typeKey) p.type = typeKey; 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 timeStart = normalizeTimelineYearValue(geo.time_start);
const timeEnd = normalizeTimelineYearValue(geo.time_end); const timeEnd = normalizeTimelineYearValue(geo.time_end);
if (timeStart !== null) { if (timeStart !== null) {
@@ -519,7 +573,7 @@ export function buildEditorSnapshot(options: {
source: "inline", source: "inline",
type: typeKey, type: typeKey,
draw_geometry: feature.geometry, draw_geometry: feature.geometry,
binding: normalizeFeatureBindingIds(feature), bound_with: normalizeFeatureBoundWith(feature),
time_start: timeStart, time_start: timeStart,
time_end: timeEnd, time_end: timeEnd,
bbox: bbox bbox: bbox
@@ -588,6 +642,7 @@ export function buildEditorSnapshot(options: {
delete p.type; delete p.type;
delete p.time_start; delete p.time_start;
delete p.time_end; delete p.time_end;
delete p.bound_with;
delete p.binding; delete p.binding;
delete p.entity_id; delete p.entity_id;
delete p.entity_ids; delete p.entity_ids;
@@ -1113,27 +1168,6 @@ export function normalizeFeatureEntityIds(feature: Feature): string[] {
return []; 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[] { export function uniqueEntityIds(ids: string[]): string[] {
const deduped: string[] = []; const deduped: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
+63 -18
View File
@@ -14,7 +14,7 @@ import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects"; import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants"; import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes"; 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 { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes"; export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
@@ -552,12 +552,25 @@ export function useEditorState(
if (idx === -1) return; if (idx === -1) return;
const feature = mainDraftRef.current.features[idx]; const feature = mainDraftRef.current.features[idx];
const deletedId = String(feature.properties.id);
const nextFeatures = [...mainDraftRef.current.features]; const nextFeatures = [...mainDraftRef.current.features];
nextFeatures.splice(idx, 1); nextFeatures.splice(idx, 1);
const undoActions: UndoAction[] = []; const undoActions: UndoAction[] = [];
const replayUndoAction = pruneReplaysForDeletedGeometryIds([feature.properties.id], `Xóa replay theo GEO #${feature.properties.id}`); const replayUndoAction = pruneReplaysForDeletedGeometryIds([feature.properties.id], `Xóa replay theo GEO #${feature.properties.id}`);
if (replayUndoAction) undoActions.push(replayUndoAction); 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 }); undoActions.push({ type: "delete", feature: deepClone(feature), index: idx });
pushMainUndo( pushMainUndo(
undoActions.length === 1 undoActions.length === 1
@@ -574,28 +587,45 @@ export function useEditorState(
const idsSet = new Set(ids.map(String)); const idsSet = new Set(ids.map(String));
const nextFeatures: Feature[] = []; const nextFeatures: Feature[] = [];
const undoActions: UndoAction[] = []; const deleteUndoActions: UndoAction[] = [];
const boundWithUndoActions: UndoAction[] = [];
mainDraftRef.current.features.forEach((feature, index) => { mainDraftRef.current.features.forEach((feature, index) => {
if (idsSet.has(String(feature.properties.id))) { if (idsSet.has(String(feature.properties.id))) {
undoActions.push({ type: "delete", feature: deepClone(feature), index }); deleteUndoActions.push({ type: "delete", feature: deepClone(feature), index });
} else {
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 { } else {
nextFeatures.push(feature); 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 const groupedActions = replayUndoAction
? [replayUndoAction, ...undoActions.slice().reverse()] ? [replayUndoAction, ...boundWithUndoActions, ...deleteUndoActions.slice().reverse()]
: undoActions.length === 1 : deleteUndoActions.length === 1 && boundWithUndoActions.length === 0
? undoActions ? deleteUndoActions
: undoActions.slice().reverse(); : [...boundWithUndoActions, ...deleteUndoActions.slice().reverse()];
pushMainUndo( pushMainUndo(
groupedActions.length === 1 groupedActions.length === 1
? groupedActions[0] ? 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 }); commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
} }
@@ -620,6 +650,21 @@ export function useEditorState(
}; };
nextFeatures[idx] = newFeature; 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ó // Cập nhật replays nếu có
const prevReplays = replaysRef.current || []; const prevReplays = replaysRef.current || [];
const nextReplays = prevReplays.map((replay) => { const nextReplays = prevReplays.map((replay) => {
@@ -640,6 +685,7 @@ export function useEditorState(
type: "group", type: "group",
label: `Đổi ID GEO #${oldId} -> #${newId}`, label: `Đổi ID GEO #${oldId} -> #${newId}`,
actions: [ actions: [
...boundWithUndoActions,
{ type: "replays", label: "Phục hồi replay cũ", prevReplays: deepClone(prevReplays) }, { type: "replays", label: "Phục hồi replay cũ", prevReplays: deepClone(prevReplays) },
{ type: "create", id: newId }, { type: "create", id: newId },
{ type: "delete", feature: deepClone(prevFeature), index: idx }, { type: "delete", feature: deepClone(prevFeature), index: idx },
@@ -910,7 +956,7 @@ function createReplaySessionSeed(
id: geometryId, id: geometryId,
geometry_id: geometryId, geometry_id: geometryId,
target_geometry_ids: buildReplaySeedTargetIds( target_geometry_ids: buildReplaySeedTargetIds(
sourceDraft.features.find((feature) => String(feature.properties.id) === geometryId), sourceDraft,
geometryId, geometryId,
selectedIds selectedIds
), ),
@@ -926,8 +972,7 @@ function normalizeReplaySessionSeed(
): BattleReplay { ): BattleReplay {
const nextReplay = deepClone(replay); const nextReplay = deepClone(replay);
nextReplay.id = geometryId; nextReplay.id = geometryId;
const triggerFeature = sourceDraft.features.find((feature) => String(feature.properties.id) === geometryId); const seedTargetIds = buildReplaySeedTargetIds(sourceDraft, geometryId, selectedIds);
const seedTargetIds = buildReplaySeedTargetIds(triggerFeature, geometryId, selectedIds);
nextReplay.target_geometry_ids = normalizeReplayTargetGeometryIds( nextReplay.target_geometry_ids = normalizeReplayTargetGeometryIds(
nextReplay.target_geometry_ids, nextReplay.target_geometry_ids,
geometryId, geometryId,
@@ -937,7 +982,7 @@ function normalizeReplaySessionSeed(
} }
function buildReplaySeedTargetIds( function buildReplaySeedTargetIds(
triggerFeature: Feature | undefined, sourceDraft: FeatureCollection,
featureId: string, featureId: string,
selectedIds: (string | number)[] selectedIds: (string | number)[]
) { ) {
@@ -958,9 +1003,9 @@ function buildReplaySeedTargetIds(
pushId(rawId); pushId(rawId);
} }
if (Array.isArray(triggerFeature?.properties?.binding)) { for (const feature of sourceDraft.features) {
for (const rawId of triggerFeature.properties.binding) { if (normalizeFeatureBoundWith(feature) === featureId) {
pushId(rawId); pushId(feature.properties.id);
} }
} }
@@ -1020,7 +1065,7 @@ function sanitizeReplayFeature(feature: Feature): Feature {
...feature, ...feature,
properties: { properties: {
...feature.properties, ...feature.properties,
binding: [], bound_with: null,
}, },
}; };
} }
-1
View File
@@ -257,7 +257,6 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
type_key: "", type_key: "",
time_start: "", time_start: "",
time_end: "", time_end: "",
binding: "",
}, },
isEntitySubmitting: false, isEntitySubmitting: false,
entityFormStatus: null, entityFormStatus: null,
+2 -2
View File
@@ -21,7 +21,7 @@ export type FeatureProperties = {
geometry_preset?: GeometryPreset | null; geometry_preset?: GeometryPreset | null;
time_start?: number | null; time_start?: number | null;
time_end?: number | null; time_end?: number | null;
binding?: string[]; bound_with?: string | null;
entity_id?: string | null; entity_id?: string | null;
entity_ids?: string[]; entity_ids?: string[];
entity_name?: string | null; entity_name?: string | null;
@@ -60,7 +60,7 @@ export type GeometrySnapshot = {
type?: string | null; type?: string | null;
draw_geometry?: Geometry; draw_geometry?: Geometry;
geometry?: Geometry; geometry?: Geometry;
binding?: string[]; bound_with?: string | null;
time_start?: number | null; time_start?: number | null;
time_end?: number | null; time_end?: number | null;
bbox?: { bbox?: {