feat: implement geometry binding functionality within the map interaction engine
This commit is contained in:
@@ -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" }} />
|
||||||
|
|||||||
@@ -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,6 +110,8 @@ 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]);
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,11 +257,29 @@ export function initSelect(
|
|||||||
return item;
|
return item;
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedCount = selectedIds.size || 1;
|
const selectedCount = selectedIds.size;
|
||||||
let hasMenuItems = false;
|
let hasMenuItems = false;
|
||||||
|
|
||||||
|
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 (
|
if (
|
||||||
selectedCount === 1 &&
|
effectiveCount === 1 &&
|
||||||
clickedFeature.source === "countries" &&
|
clickedFeature.source === "countries" &&
|
||||||
clickedFeature.geometry?.type === "Polygon" &&
|
clickedFeature.geometry?.type === "Polygon" &&
|
||||||
onEdit
|
onEdit
|
||||||
@@ -245,7 +289,7 @@ export function initSelect(
|
|||||||
hasMenuItems = true;
|
hasMenuItems = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedCount === 1 && onDuplicate) {
|
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("Duplicate", () => onDuplicate(featureId)));
|
menu.appendChild(createItem("Duplicate", () => onDuplicate(featureId)));
|
||||||
@@ -253,7 +297,7 @@ export function initSelect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedCount === 1 && onHide) {
|
if (effectiveCount === 1 && onHide) {
|
||||||
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("Hide", () => onHide(featureId)));
|
||||||
@@ -266,7 +310,7 @@ export function initSelect(
|
|||||||
if (featureId) {
|
if (featureId) {
|
||||||
menu.appendChild(
|
menu.appendChild(
|
||||||
createItem(
|
createItem(
|
||||||
selectedCount > 1 ? `Vào replay (${selectedCount} geo)` : "Vào replay",
|
effectiveCount > 1 ? `Vào replay (${effectiveCount} geo)` : "Vào replay",
|
||||||
() => onReplayEdit(featureId)
|
() => onReplayEdit(featureId)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -277,7 +321,7 @@ export function initSelect(
|
|||||||
if (onDelete) {
|
if (onDelete) {
|
||||||
menu.appendChild(
|
menu.appendChild(
|
||||||
createItem(
|
createItem(
|
||||||
selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa",
|
effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa",
|
||||||
() => {
|
() => {
|
||||||
const ids = selectedIds.size
|
const ids = selectedIds.size
|
||||||
? Array.from(selectedIds)
|
? Array.from(selectedIds)
|
||||||
@@ -291,6 +335,7 @@ export function initSelect(
|
|||||||
);
|
);
|
||||||
hasMenuItems = true;
|
hasMenuItems = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasMenuItems) return;
|
if (!hasMenuItems) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user