select and addable geometry global

This commit is contained in:
taDuc
2026-05-26 10:44:49 +07:00
parent e403413965
commit faf5c56219
4 changed files with 242 additions and 26 deletions
+13 -1
View File
@@ -46,6 +46,7 @@ type MapProps = {
labelContextDraft?: FeatureCollection;
labelTimelineYear?: number | null;
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
onAddFeatureToProject?: (feature: FeatureCollection["features"][number]) => void;
onDeleteFeature?: (id: string | number | (string | number)[]) => void;
onHideFeature?: (id: string | number) => void;
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
@@ -61,6 +62,7 @@ type MapProps = {
imageOverlay?: MapImageOverlay | null;
onImageOverlayChange?: (overlay: MapImageOverlay) => void;
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void;
localFeatureIds?: (string | number)[];
showViewportControls?: boolean;
isPreviewMode?: boolean;
onEnterPreview?: () => void;
@@ -81,6 +83,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
labelContextDraft,
labelTimelineYear,
onCreateFeature,
onAddFeatureToProject,
onDeleteFeature,
onHideFeature,
onUpdateFeature,
@@ -96,6 +99,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
imageOverlay = null,
onImageOverlayChange,
onBindGeometries,
localFeatureIds,
showViewportControls = true,
isPreviewMode = false,
onEnterPreview,
@@ -116,6 +120,8 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
const onFeatureClickRef = useRef<MapProps["onFeatureClick"]>(onFeatureClick);
// Ref callback create mới nhất khi drawing engine tạo feature.
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
// Ref callback add geometry global vào project mới nhất cho context menu select.
const onAddFeatureToProjectRef = useRef<MapProps["onAddFeatureToProject"]>(onAddFeatureToProject);
// Ref callback delete mới nhất khi editing engine xóa feature.
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
// Ref callback hide local mới nhất khi context menu select ẩn feature khỏi map.
@@ -128,19 +134,23 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
const onImageOverlayChangeRef = useRef<MapProps["onImageOverlayChange"]>(onImageOverlayChange);
// Ref callback bind geometry mới nhất để interaction không stale.
const onBindGeometriesRef = useRef<MapProps["onBindGeometries"]>(onBindGeometries);
// Ref danh sách geometry thuộc local project để context menu phân biệt global-only feature.
const localFeatureIdsRef = useRef<MapProps["localFeatureIds"]>(localFeatureIds);
useEffect(() => { modeRef.current = mode; }, [mode]);
useEffect(() => { renderDraftRef.current = renderDraft; }, [renderDraft]);
useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]);
useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]);
useEffect(() => { onFeatureClickRef.current = onFeatureClick; }, [onFeatureClick]);
useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]);
useEffect(() => { onAddFeatureToProjectRef.current = onAddFeatureToProject; }, [onAddFeatureToProject]);
useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]);
useEffect(() => { onHideRef.current = onHideFeature; }, [onHideFeature]);
useEffect(() => { onUpdateRef.current = onUpdateFeature; }, [onUpdateFeature]);
useEffect(() => { imageOverlayRef.current = imageOverlay; }, [imageOverlay]);
useEffect(() => { onImageOverlayChangeRef.current = onImageOverlayChange; }, [onImageOverlayChange]);
useEffect(() => { onBindGeometriesRef.current = onBindGeometries; }, [onBindGeometries]);
useEffect(() => { localFeatureIdsRef.current = localFeatureIds; }, [localFeatureIds]);
// Hook sở hữu lifecycle MapLibre instance và các control camera/projection.
const {
@@ -189,6 +199,8 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
onUpdateRef,
onFeatureClickRef,
onBindGeometriesRef,
localFeatureIdsRef,
onAddFeatureToProjectRef,
});
// Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer.
+25 -1
View File
@@ -36,6 +36,8 @@ type UseMapInteractionProps = {
onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>;
onFeatureClickRef: React.MutableRefObject<((payload: MapFeaturePayload | null) => void) | undefined>;
onBindGeometriesRef?: React.MutableRefObject<((targetId: string | number, sourceIds: (string | number)[]) => void) | undefined>;
localFeatureIdsRef?: React.MutableRefObject<(string | number)[] | undefined>;
onAddFeatureToProjectRef?: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
};
export function useMapInteraction({
@@ -53,6 +55,8 @@ export function useMapInteraction({
onUpdateRef,
onFeatureClickRef,
onBindGeometriesRef,
localFeatureIdsRef,
onAddFeatureToProjectRef,
}: UseMapInteractionProps) {
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
const engineBindingsRef = useRef<Partial<Record<EditorMode, EngineBinding>>>({});
@@ -199,7 +203,27 @@ export function useMapInteraction({
...payload,
feature: currentFeature,
});
}
},
onAddFeatureToProjectRef?.current
? (feature) => {
const rawId = feature.id ?? feature.properties?.id;
if (rawId === undefined || rawId === null) return;
const originalFeature = renderDraftRef.current.features.find(
(item) => String(item.properties.id) === String(rawId)
);
if (!originalFeature) return;
onAddFeatureToProjectRef.current?.(originalFeature);
}
: undefined,
onAddFeatureToProjectRef?.current
? (id) => {
if (!onAddFeatureToProjectRef?.current) return true;
const localIds = localFeatureIdsRef?.current;
if (!Array.isArray(localIds)) return true;
return localIds.some((localId) => String(localId) === String(id));
}
: undefined
);
const cleanupPoint = initPoint(
+40 -24
View File
@@ -19,7 +19,9 @@ export function initSelect(
onReplayEdit?: (id: string | number) => void,
isEditSessionActive?: () => boolean,
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void,
onFeatureClick?: (payload: SelectFeatureClickPayload | null) => void
onFeatureClick?: (payload: SelectFeatureClickPayload | null) => void,
onAddToProject?: (feature: maplibregl.MapGeoJSONFeature) => void,
isLocalFeature?: (id: string | number) => boolean
) {
const FEATURE_STATE_SOURCES = [
@@ -28,7 +30,7 @@ export function initSelect(
"path-arrow-shapes",
] as const;
const selectedIds = new Set<number | string>();
const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit || onBindGeometries);
const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit || onBindGeometries || onAddToProject);
let contextMenu: HTMLDivElement | null = null;
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
let cursorTimer: number | null = null;
@@ -306,31 +308,48 @@ export function initSelect(
return item;
};
const selectedCount = selectedIds.size;
const effectiveCount = selectedCount || 1;
const targetId = clickedFeature.id ?? clickedFeature.properties?.id;
const hasTargetId = targetId !== undefined && targetId !== null;
const isLocalTarget = hasTargetId ? (isLocalFeature?.(targetId) ?? true) : false;
const selectedLocalIds = Array.from(selectedIds).filter((id) => isLocalFeature?.(id) ?? true);
const localActionIds = selectedLocalIds.length
? selectedLocalIds
: isLocalTarget && hasTargetId
? [targetId]
: [];
const effectiveCount = localActionIds.length;
const isClickOutsideSelection = !isRightClickedItemAlreadySelected && hasSelection;
type MenuItem = {
label: string;
onClick: () => void;
group: "edit" | "bind" | "replay" | "delete";
group: "add" | "edit" | "bind" | "replay" | "delete";
};
const items: MenuItem[] = [];
if (isClickOutsideSelection && onBindGeometries && targetId !== undefined && targetId !== null) {
const sourceIds = Array.from(selectedIds);
if (onAddToProject && hasTargetId && !isLocalTarget) {
items.push({
group: "bind",
label: `Bind ${selectedCount} geo đang chọn vào geo này`,
onClick: () => {
onBindGeometries(targetId, sourceIds);
},
group: "add",
label: "Add",
onClick: () => onAddToProject(clickedFeature),
});
}
if (!isClickOutsideSelection) {
if (isClickOutsideSelection && onBindGeometries && isLocalTarget && hasTargetId) {
const sourceIds = selectedLocalIds.filter((id) => String(id) !== String(targetId));
if (sourceIds.length) {
items.push({
group: "bind",
label: `Bind ${sourceIds.length} geo đang chọn vào geo này`,
onClick: () => {
onBindGeometries(targetId, sourceIds);
},
});
}
}
if (isLocalTarget && !isClickOutsideSelection) {
if (
effectiveCount === 1 &&
clickedFeature.source === "countries" &&
@@ -345,7 +364,7 @@ export function initSelect(
});
}
if (effectiveCount === 1 && onDuplicate && targetId !== undefined && targetId !== null) {
if (effectiveCount === 1 && onDuplicate && hasTargetId) {
items.push({
group: "edit",
label: "Duplicate",
@@ -353,7 +372,7 @@ export function initSelect(
});
}
if (effectiveCount === 1 && onHide && targetId !== undefined && targetId !== null) {
if (effectiveCount === 1 && onHide && hasTargetId) {
items.push({
group: "edit",
label: "Hide",
@@ -362,10 +381,10 @@ export function initSelect(
}
}
if (onReplayEdit) {
if (isLocalTarget && onReplayEdit) {
const replayId = targetId;
if (replayId !== undefined && replayId !== null) {
const totalCount = isClickOutsideSelection ? selectedIds.size + 1 : effectiveCount;
const totalCount = isClickOutsideSelection ? selectedLocalIds.length + 1 : effectiveCount;
items.push({
group: "replay",
label: totalCount > 1 ? `Vào replay (${totalCount} geo)` : "Vào replay",
@@ -374,18 +393,15 @@ export function initSelect(
}
}
if (onDelete) {
if (isLocalTarget && onDelete && effectiveCount > 0) {
items.push({
group: "delete",
label: effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa",
onClick: () => {
const ids = selectedIds.size
? Array.from(selectedIds)
: [targetId];
if (ids.length === 1) {
onDelete(ids[0]);
if (localActionIds.length === 1) {
onDelete(localActionIds[0]);
} else {
onDelete(ids);
onDelete(localActionIds);
}
clearSelection();
},