feat: implement geometry binding functionality within the map interaction engine

This commit is contained in:
taDuc
2026-05-21 18:39:50 +07:00
parent 7e025fb449
commit 8f0e912d9e
4 changed files with 147 additions and 54 deletions
+31
View File
@@ -1540,6 +1540,36 @@ function EditorPageContent() {
setIsEntitySubmitting, 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 đó. // 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 handleFocusGeometryFromBindingPanel = useCallback((geoId: string) => {
const id = String(geoId || "").trim(); const id = String(geoId || "").trim();
@@ -2019,6 +2049,7 @@ function EditorPageContent() {
focusPadding={96} focusPadding={96}
imageOverlay={imageOverlay} imageOverlay={imageOverlay}
onImageOverlayChange={setImageOverlay} onImageOverlayChange={setImageOverlay}
onBindGeometries={handleBindGeometries}
/> />
) : ( ) : (
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} /> <div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
+7 -1
View File
@@ -57,6 +57,7 @@ type MapProps = {
focusPadding?: number | import("maplibre-gl").PaddingOptions; focusPadding?: number | import("maplibre-gl").PaddingOptions;
imageOverlay?: MapImageOverlay | null; imageOverlay?: MapImageOverlay | null;
onImageOverlayChange?: (overlay: MapImageOverlay) => void; onImageOverlayChange?: (overlay: MapImageOverlay) => void;
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void;
}; };
const Map = forwardRef<MapHandle, MapProps>(function Map({ const Map = forwardRef<MapHandle, MapProps>(function Map({
@@ -85,6 +86,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
focusPadding, focusPadding,
imageOverlay = null, imageOverlay = null,
onImageOverlayChange, onImageOverlayChange,
onBindGeometries,
}, ref) { }, ref) {
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần. // Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
const modeRef = useRef<MapProps["mode"]>(mode); const modeRef = useRef<MapProps["mode"]>(mode);
@@ -108,7 +110,9 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay); const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay);
// Ref callback update overlay mới nhất để interaction không stale. // Ref callback update overlay mới nhất để interaction không stale.
const onImageOverlayChangeRef = useRef<MapProps["onImageOverlayChange"]>(onImageOverlayChange); const onImageOverlayChangeRef = useRef<MapProps["onImageOverlayChange"]>(onImageOverlayChange);
// Ref callback bind geometry mới nhất để interaction không stale.
const onBindGeometriesRef = useRef<MapProps["onBindGeometries"]>(onBindGeometries);
useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { modeRef.current = mode; }, [mode]);
useEffect(() => { draftRef.current = draft; }, [draft]); useEffect(() => { draftRef.current = draft; }, [draft]);
useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]); useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]);
@@ -120,6 +124,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
useEffect(() => { onUpdateRef.current = onUpdateFeature; }, [onUpdateFeature]); useEffect(() => { onUpdateRef.current = onUpdateFeature; }, [onUpdateFeature]);
useEffect(() => { imageOverlayRef.current = imageOverlay; }, [imageOverlay]); useEffect(() => { imageOverlayRef.current = imageOverlay; }, [imageOverlay]);
useEffect(() => { onImageOverlayChangeRef.current = onImageOverlayChange; }, [onImageOverlayChange]); useEffect(() => { onImageOverlayChangeRef.current = onImageOverlayChange; }, [onImageOverlayChange]);
useEffect(() => { onBindGeometriesRef.current = onBindGeometries; }, [onBindGeometries]);
// Hook sở hữu lifecycle MapLibre instance và các control camera/projection. // Hook sở hữu lifecycle MapLibre instance và các control camera/projection.
const { const {
@@ -164,6 +169,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
onHideRef, onHideRef,
onUpdateRef, onUpdateRef,
onHoverFeatureChangeRef, onHoverFeatureChangeRef,
onBindGeometriesRef,
}); });
// Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer. // Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer.
+12 -1
View File
@@ -16,6 +16,7 @@ type EngineBinding = {
cleanup: () => void; cleanup: () => void;
cancel?: () => void; cancel?: () => void;
clearSelection?: (skipNotify?: boolean) => void; clearSelection?: (skipNotify?: boolean) => void;
syncSelection?: (ids: (string | number)[]) => void;
}; };
type UseMapInteractionProps = { type UseMapInteractionProps = {
@@ -32,6 +33,7 @@ type UseMapInteractionProps = {
onHideRef: React.MutableRefObject<((id: string | number) => void) | undefined>; onHideRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>; onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>;
onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>; onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>;
onBindGeometriesRef?: React.MutableRefObject<((targetId: string | number, sourceIds: (string | number)[]) => void) | undefined>;
}; };
export function useMapInteraction({ export function useMapInteraction({
@@ -48,6 +50,7 @@ export function useMapInteraction({
onHideRef, onHideRef,
onUpdateRef, onUpdateRef,
onHoverFeatureChangeRef, onHoverFeatureChangeRef,
onBindGeometriesRef,
}: UseMapInteractionProps) { }: UseMapInteractionProps) {
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null); const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
const engineBindingsRef = useRef<Partial<Record<EditorMode, EngineBinding>>>({}); const engineBindingsRef = useRef<Partial<Record<EditorMode, EngineBinding>>>({});
@@ -72,6 +75,13 @@ export function useMapInteraction({
} }
}, [mode, selectedFeatureIds]); }, [mode, selectedFeatureIds]);
useEffect(() => {
const selectEngine = engineBindingsRef.current.select;
if (selectEngine?.syncSelection) {
selectEngine.syncSelection(selectedFeatureIds);
}
}, [selectedFeatureIds]);
useEffect(() => { useEffect(() => {
const previousMode = previousModeRef.current; const previousMode = previousModeRef.current;
if (previousMode !== mode) { if (previousMode !== mode) {
@@ -170,7 +180,8 @@ export function useMapInteraction({
: undefined, : undefined,
(ids) => onSelectFeatureIdsRef.current?.(ids), (ids) => onSelectFeatureIdsRef.current?.(ids),
(id: string | number) => onSetModeRef.current?.("replay", id), (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( const cleanupPoint = initPoint(
+97 -52
View File
@@ -11,7 +11,8 @@ export function initSelect(
onHide?: (id: string | number) => void, onHide?: (id: string | number) => void,
onSelectIds?: (ids: (string | number)[]) => void, onSelectIds?: (ids: (string | number)[]) => void,
onReplayEdit?: (id: string | number) => void, onReplayEdit?: (id: string | number) => void,
isEditSessionActive?: () => boolean isEditSessionActive?: () => boolean,
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void
) { ) {
const FEATURE_STATE_SOURCES = [ const FEATURE_STATE_SOURCES = [
@@ -20,7 +21,7 @@ export function initSelect(
"path-arrow-shapes", "path-arrow-shapes",
] as const; ] as const;
const selectedIds = new Set<number | string>(); const selectedIds = new Set<number | string>();
const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit); const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit || onBindGeometries);
let contextMenu: HTMLDivElement | null = null; let contextMenu: HTMLDivElement | null = null;
let docClickHandler: ((ev: MouseEvent) => void) | null = null; let docClickHandler: ((ev: MouseEvent) => void) | null = null;
@@ -97,8 +98,13 @@ export function initSelect(
const id = feature.id ?? feature.properties?.id; const id = feature.id ?? feature.properties?.id;
if (id === undefined || id === null) return; if (id === undefined || id === null) return;
// if right-clicked item not selected, make it the sole selection const isRightClickedItemAlreadySelected = selectedIds.has(id);
if (!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(); clearSelection();
selectFeature(feature, false); selectFeature(feature, false);
} }
@@ -106,7 +112,9 @@ export function initSelect(
showContextMenu( showContextMenu(
e.originalEvent?.clientX ?? e.point.x, e.originalEvent?.clientX ?? e.point.x,
e.originalEvent?.clientY ?? e.point.y, e.originalEvent?.clientY ?? e.point.y,
feature feature,
isRightClickedItemAlreadySelected,
hasSelection
); );
} }
@@ -161,6 +169,21 @@ export function initSelect(
return 0; 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("click", onClick);
map.on("mousemove", onMove); map.on("mousemove", onMove);
if (hasContextActions) { if (hasContextActions) {
@@ -180,6 +203,7 @@ export function initSelect(
return { return {
cleanup, cleanup,
clearSelection, clearSelection,
syncSelection,
}; };
// Ẩn và dọn dẹp context menu hiện tại. // Ẩn và dọn dẹp context menu hiện tại.
@@ -198,7 +222,9 @@ export function initSelect(
function showContextMenu( function showContextMenu(
x: number, x: number,
y: number, y: number,
clickedFeature: maplibregl.MapGeoJSONFeature clickedFeature: maplibregl.MapGeoJSONFeature,
isRightClickedItemAlreadySelected: boolean,
hasSelection: boolean
) { ) {
hideContextMenu(); hideContextMenu();
@@ -231,67 +257,86 @@ export function initSelect(
return item; return item;
}; };
const selectedCount = selectedIds.size || 1; const selectedCount = selectedIds.size;
let hasMenuItems = false; let hasMenuItems = false;
if ( if (!isRightClickedItemAlreadySelected && hasSelection) {
selectedCount === 1 && if (onBindGeometries) {
clickedFeature.source === "countries" && const targetId = clickedFeature.id ?? clickedFeature.properties?.id;
clickedFeature.geometry?.type === "Polygon" && if (targetId !== undefined && targetId !== null) {
onEdit const sourceIds = Array.from(selectedIds);
) { menu.appendChild(
const single = clickedFeature; createItem(
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single))); `Bind ${selectedCount} geo đang chọn vào geo này`,
hasMenuItems = true; () => {
} onBindGeometries(targetId, sourceIds);
}
if (selectedCount === 1 && onDuplicate) { )
const featureId = clickedFeature.id ?? clickedFeature.properties?.id; );
if (featureId !== undefined && featureId !== null) { hasMenuItems = true;
menu.appendChild(createItem("Duplicate", () => onDuplicate(featureId))); }
}
} 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; hasMenuItems = true;
} }
}
if (selectedCount === 1 && onHide) { if (effectiveCount === 1 && onDuplicate) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id; const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
if (featureId !== undefined && featureId !== null) { if (featureId !== undefined && featureId !== null) {
menu.appendChild(createItem("Hide", () => onHide(featureId))); menu.appendChild(createItem("Duplicate", () => onDuplicate(featureId)));
hasMenuItems = true; hasMenuItems = true;
}
} }
}
if (onReplayEdit) { if (effectiveCount === 1 && onHide) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id; const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
if (featureId) { 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( menu.appendChild(
createItem( createItem(
selectedCount > 1 ? `Vào replay (${selectedCount} geo)` : "Vào replay", effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa",
() => onReplayEdit(featureId) () => {
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; 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; if (!hasMenuItems) return;
document.body.appendChild(menu); document.body.appendChild(menu);