From 8f0e912d9e31ebf5d9e2a7251fe7d9d2804e9efe Mon Sep 17 00:00:00 2001 From: taDuc Date: Thu, 21 May 2026 18:39:50 +0700 Subject: [PATCH] feat: implement geometry binding functionality within the map interaction engine --- src/app/editor/[id]/page.tsx | 31 ++++ src/uhm/components/Map.tsx | 8 +- src/uhm/components/map/useMapInteraction.ts | 13 +- src/uhm/lib/map/engines/selectingEngine.ts | 149 +++++++++++++------- 4 files changed, 147 insertions(+), 54 deletions(-) diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 8a7ed8e..6fbfba1 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -1540,6 +1540,36 @@ function EditorPageContent() { setIsEntitySubmitting, ]); + // Bind nhiều geometries vào target geometry. + const handleBindGeometries = useCallback((targetId: string | number, sourceIds: (string | number)[]) => { + const idStr = String(targetId).trim(); + if (!idStr) return; + + const targetFeature = editor.draft.features.find((f) => String(f.properties.id) === idStr); + if (!targetFeature) { + flashGeoBindingStatus("Không tìm thấy geometry đích."); + return; + } + + const prevBindingIds = normalizeFeatureBindingIds(targetFeature); + + // 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])); + + editor.patchFeaturePropertiesBatch( + [{ + id: targetFeature.properties.id, + patch: { binding: merged }, + }], + "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); + }, [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 đó. const handleFocusGeometryFromBindingPanel = useCallback((geoId: string) => { const id = String(geoId || "").trim(); @@ -2019,6 +2049,7 @@ function EditorPageContent() { focusPadding={96} imageOverlay={imageOverlay} onImageOverlayChange={setImageOverlay} + onBindGeometries={handleBindGeometries} /> ) : (
diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index 2e5961c..7d25088 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -57,6 +57,7 @@ type MapProps = { focusPadding?: number | import("maplibre-gl").PaddingOptions; imageOverlay?: MapImageOverlay | null; onImageOverlayChange?: (overlay: MapImageOverlay) => void; + onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void; }; const Map = forwardRef(function Map({ @@ -85,6 +86,7 @@ const Map = forwardRef(function Map({ focusPadding, imageOverlay = null, onImageOverlayChange, + onBindGeometries, }, ref) { // Ref giữ mode mới nhất cho MapLibre handlers được register một lần. const modeRef = useRef(mode); @@ -108,7 +110,9 @@ const Map = forwardRef(function Map({ const imageOverlayRef = useRef(imageOverlay); // Ref callback update overlay mới nhất để interaction không stale. const onImageOverlayChangeRef = useRef(onImageOverlayChange); - + // Ref callback bind geometry mới nhất để interaction không stale. + const onBindGeometriesRef = useRef(onBindGeometries); + useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { draftRef.current = draft; }, [draft]); useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]); @@ -120,6 +124,7 @@ const Map = forwardRef(function Map({ useEffect(() => { onUpdateRef.current = onUpdateFeature; }, [onUpdateFeature]); useEffect(() => { imageOverlayRef.current = imageOverlay; }, [imageOverlay]); useEffect(() => { onImageOverlayChangeRef.current = onImageOverlayChange; }, [onImageOverlayChange]); + useEffect(() => { onBindGeometriesRef.current = onBindGeometries; }, [onBindGeometries]); // Hook sở hữu lifecycle MapLibre instance và các control camera/projection. const { @@ -164,6 +169,7 @@ const Map = forwardRef(function Map({ onHideRef, onUpdateRef, onHoverFeatureChangeRef, + onBindGeometriesRef, }); // Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer. diff --git a/src/uhm/components/map/useMapInteraction.ts b/src/uhm/components/map/useMapInteraction.ts index f51aa9f..e581ab3 100644 --- a/src/uhm/components/map/useMapInteraction.ts +++ b/src/uhm/components/map/useMapInteraction.ts @@ -16,6 +16,7 @@ type EngineBinding = { cleanup: () => void; cancel?: () => void; clearSelection?: (skipNotify?: boolean) => void; + syncSelection?: (ids: (string | number)[]) => void; }; type UseMapInteractionProps = { @@ -32,6 +33,7 @@ type UseMapInteractionProps = { onHideRef: React.MutableRefObject<((id: string | number) => void) | undefined>; onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>; onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>; + onBindGeometriesRef?: React.MutableRefObject<((targetId: string | number, sourceIds: (string | number)[]) => void) | undefined>; }; export function useMapInteraction({ @@ -48,6 +50,7 @@ export function useMapInteraction({ onHideRef, onUpdateRef, onHoverFeatureChangeRef, + onBindGeometriesRef, }: UseMapInteractionProps) { const editingEngineRef = useRef | null>(null); const engineBindingsRef = useRef>>({}); @@ -72,6 +75,13 @@ export function useMapInteraction({ } }, [mode, selectedFeatureIds]); + useEffect(() => { + const selectEngine = engineBindingsRef.current.select; + if (selectEngine?.syncSelection) { + selectEngine.syncSelection(selectedFeatureIds); + } + }, [selectedFeatureIds]); + useEffect(() => { const previousMode = previousModeRef.current; if (previousMode !== mode) { @@ -170,7 +180,8 @@ export function useMapInteraction({ : undefined, (ids) => onSelectFeatureIdsRef.current?.(ids), (id: string | number) => onSetModeRef.current?.("replay", id), - () => Boolean(editingEngineRef.current?.editingRef.current) + () => Boolean(editingEngineRef.current?.editingRef.current), + (targetId, sourceIds) => onBindGeometriesRef?.current?.(targetId, sourceIds) ); const cleanupPoint = initPoint( diff --git a/src/uhm/lib/map/engines/selectingEngine.ts b/src/uhm/lib/map/engines/selectingEngine.ts index fb643c9..c37d700 100644 --- a/src/uhm/lib/map/engines/selectingEngine.ts +++ b/src/uhm/lib/map/engines/selectingEngine.ts @@ -11,7 +11,8 @@ export function initSelect( onHide?: (id: string | number) => void, onSelectIds?: (ids: (string | number)[]) => void, onReplayEdit?: (id: string | number) => void, - isEditSessionActive?: () => boolean + isEditSessionActive?: () => boolean, + onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void ) { const FEATURE_STATE_SOURCES = [ @@ -20,7 +21,7 @@ export function initSelect( "path-arrow-shapes", ] as const; const selectedIds = new Set(); - const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit); + const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit || onBindGeometries); let contextMenu: HTMLDivElement | null = null; let docClickHandler: ((ev: MouseEvent) => void) | null = null; @@ -97,8 +98,13 @@ export function initSelect( const id = feature.id ?? feature.properties?.id; if (id === undefined || id === null) return; - // if right-clicked item not selected, make it the sole selection - if (!selectedIds.has(id)) { + const isRightClickedItemAlreadySelected = selectedIds.has(id); + const hasSelection = selectedIds.size > 0; + + // If the right-clicked item is not selected, and there is no active selection, + // make it the sole selection. If there is an active selection, do not clear it + // so we can bind the active selection to this target geometry. + if (!isRightClickedItemAlreadySelected && !hasSelection) { clearSelection(); selectFeature(feature, false); } @@ -106,7 +112,9 @@ export function initSelect( showContextMenu( e.originalEvent?.clientX ?? e.point.x, e.originalEvent?.clientY ?? e.point.y, - feature + feature, + isRightClickedItemAlreadySelected, + hasSelection ); } @@ -161,6 +169,21 @@ export function initSelect( return 0; } + // Đồng bộ selection state từ React. + function syncSelection(ids: (string | number)[]) { + const nextSet = new Set(ids); + selectedIds.forEach((id) => { + if (!nextSet.has(id)) { + setSelectionStateForId(id, false); + } + }); + selectedIds.clear(); + ids.forEach((id) => { + setSelectionStateForId(id, true); + selectedIds.add(id); + }); + } + map.on("click", onClick); map.on("mousemove", onMove); if (hasContextActions) { @@ -180,6 +203,7 @@ export function initSelect( return { cleanup, clearSelection, + syncSelection, }; // Ẩn và dọn dẹp context menu hiện tại. @@ -198,7 +222,9 @@ export function initSelect( function showContextMenu( x: number, y: number, - clickedFeature: maplibregl.MapGeoJSONFeature + clickedFeature: maplibregl.MapGeoJSONFeature, + isRightClickedItemAlreadySelected: boolean, + hasSelection: boolean ) { hideContextMenu(); @@ -231,67 +257,86 @@ export function initSelect( return item; }; - const selectedCount = selectedIds.size || 1; + const selectedCount = selectedIds.size; let hasMenuItems = false; - if ( - selectedCount === 1 && - clickedFeature.source === "countries" && - clickedFeature.geometry?.type === "Polygon" && - onEdit - ) { - const single = clickedFeature; - menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single))); - hasMenuItems = true; - } - - if (selectedCount === 1 && onDuplicate) { - const featureId = clickedFeature.id ?? clickedFeature.properties?.id; - if (featureId !== undefined && featureId !== null) { - menu.appendChild(createItem("Duplicate", () => onDuplicate(featureId))); + if (!isRightClickedItemAlreadySelected && hasSelection) { + if (onBindGeometries) { + const targetId = clickedFeature.id ?? clickedFeature.properties?.id; + if (targetId !== undefined && targetId !== null) { + const sourceIds = Array.from(selectedIds); + menu.appendChild( + createItem( + `Bind ${selectedCount} geo đang chọn vào geo này`, + () => { + onBindGeometries(targetId, sourceIds); + } + ) + ); + hasMenuItems = true; + } + } + } else { + const effectiveCount = selectedCount || 1; + if ( + effectiveCount === 1 && + clickedFeature.source === "countries" && + clickedFeature.geometry?.type === "Polygon" && + onEdit + ) { + const single = clickedFeature; + menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single))); hasMenuItems = true; } - } - if (selectedCount === 1 && onHide) { - const featureId = clickedFeature.id ?? clickedFeature.properties?.id; - if (featureId !== undefined && featureId !== null) { - menu.appendChild(createItem("Hide", () => onHide(featureId))); - hasMenuItems = true; + if (effectiveCount === 1 && onDuplicate) { + const featureId = clickedFeature.id ?? clickedFeature.properties?.id; + if (featureId !== undefined && featureId !== null) { + menu.appendChild(createItem("Duplicate", () => onDuplicate(featureId))); + hasMenuItems = true; + } } - } - if (onReplayEdit) { - const featureId = clickedFeature.id ?? clickedFeature.properties?.id; - if (featureId) { + if (effectiveCount === 1 && onHide) { + const featureId = clickedFeature.id ?? clickedFeature.properties?.id; + if (featureId !== undefined && featureId !== null) { + menu.appendChild(createItem("Hide", () => onHide(featureId))); + hasMenuItems = true; + } + } + + if (onReplayEdit) { + const featureId = clickedFeature.id ?? clickedFeature.properties?.id; + if (featureId) { + menu.appendChild( + createItem( + effectiveCount > 1 ? `Vào replay (${effectiveCount} geo)` : "Vào replay", + () => onReplayEdit(featureId) + ) + ); + hasMenuItems = true; + } + } + + if (onDelete) { menu.appendChild( createItem( - selectedCount > 1 ? `Vào replay (${selectedCount} geo)` : "Vào replay", - () => onReplayEdit(featureId) + effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa", + () => { + const ids = selectedIds.size + ? Array.from(selectedIds) + : [clickedFeature.id ?? clickedFeature.properties?.id]; + ids.forEach((eachId) => { + if (eachId !== undefined && eachId !== null) onDelete(eachId); + }); + clearSelection(); + } ) ); hasMenuItems = true; } } - if (onDelete) { - menu.appendChild( - createItem( - selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa", - () => { - const ids = selectedIds.size - ? Array.from(selectedIds) - : [clickedFeature.id ?? clickedFeature.properties?.id]; - ids.forEach((eachId) => { - if (eachId !== undefined && eachId !== null) onDelete(eachId); - }); - clearSelection(); - } - ) - ); - hasMenuItems = true; - } - if (!hasMenuItems) return; document.body.appendChild(menu);