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);