import { useEffect, useRef } from "react"; import maplibregl from "maplibre-gl"; import { initDrawing } from "@/uhm/lib/map/engines/drawingEngine"; import { initSelect } from "@/uhm/lib/map/engines/selectingEngine"; import { initPoint } from "@/uhm/lib/map/engines/pointEngine"; import { initLine } from "@/uhm/lib/map/engines/lineEngine"; import { initPath } from "@/uhm/lib/map/engines/pathEngine"; import { initCircle } from "@/uhm/lib/map/engines/circleEngine"; import { createEditingEngine } from "@/uhm/lib/map/engines/editingEngine"; import { FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState"; import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes"; import { buildClientFeatureId } from "./mapUtils"; import type { MapFeaturePayload } from "../Map"; type EngineBinding = { cleanup: () => void; cancel?: () => void; clearSelection?: (skipNotify?: boolean) => void; syncSelection?: (ids: (string | number)[]) => void; }; type UseMapInteractionProps = { mapRef: React.MutableRefObject; mode: EditorMode; modeRef: React.MutableRefObject; // Rendered/interacted FeatureCollection from Map.tsx. This may already be filtered by // replay/timeline state, so do not treat it as the canonical commit/edit draft. renderDraftRef: React.MutableRefObject; allowGeometryEditing: boolean; selectedFeatureIds: (string | number)[]; onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>; onSetModeRef: React.MutableRefObject<((mode: EditorMode, featureId?: string | number) => void) | undefined>; onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>; onDeleteRef: React.MutableRefObject<((id: string | number | (string | number)[]) => void) | undefined>; onHideRef: React.MutableRefObject<((id: string | number) => void) | undefined>; 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>; allowFeatureSelection?: boolean; }; export function useMapInteraction({ mapRef, mode, modeRef, renderDraftRef, allowGeometryEditing, selectedFeatureIds, onSelectFeatureIdsRef, onSetModeRef, onCreateRef, onDeleteRef, onHideRef, onUpdateRef, onFeatureClickRef, onBindGeometriesRef, localFeatureIdsRef, onAddFeatureToProjectRef, allowFeatureSelection = true, }: UseMapInteractionProps) { const editingEngineRef = useRef | null>(null); const engineBindingsRef = useRef>>({}); const previousModeRef = useRef(mode); const mapCleanupFnsRef = useRef void>>([]); const allowGeometryEditingRef = useRef(allowGeometryEditing); allowGeometryEditingRef.current = allowGeometryEditing; const allowFeatureSelectionRef = useRef(allowFeatureSelection); allowFeatureSelectionRef.current = allowFeatureSelection; useEffect(() => { if (!editingEngineRef.current) { editingEngineRef.current = createEditingEngine({ mapRef, onUpdate: (id, geometry) => onUpdateRef.current?.(id, geometry), }); } }, [mapRef, onUpdateRef]); useEffect(() => { const allowsSelectionMode = mode === "select" || mode === "replay" || mode === "preview" || mode === "replay_preview"; if (!allowsSelectionMode || !selectedFeatureIds || selectedFeatureIds.length === 0) { editingEngineRef.current?.clearEditing(); // Clear the internal selection state of the select engine to stay in sync with React state engineBindingsRef.current.select?.clearSelection?.(false); } }, [mode, selectedFeatureIds]); useEffect(() => { const selectEngine = engineBindingsRef.current.select; if (selectEngine?.syncSelection) { selectEngine.syncSelection(selectedFeatureIds); } }, [selectedFeatureIds]); useEffect(() => { const previousMode = previousModeRef.current; if (previousMode !== mode) { engineBindingsRef.current[previousMode]?.cancel?.(); previousModeRef.current = mode; } const map = mapRef.current; if (!map || !map.isStyleLoaded()) return; if (mode !== "draw") { (map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({ type: "FeatureCollection", features: [], }); } if (mode !== "add-line") { (map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({ type: "FeatureCollection", features: [], }); } if (mode !== "add-path") { (map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({ type: "FeatureCollection", features: [], }); } if (mode !== "add-circle") { (map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData({ type: "FeatureCollection", features: [], }); } }, [mode, mapRef]); const setupMapInteractions = (map: maplibregl.Map) => { (map as any)._renderDraftRef = renderDraftRef; const drawingEngine = initDrawing( map, () => modeRef.current, (geometry: Geometry) => { const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { id, type: "country", geometry_preset: "polygon", entity_id: null, entity_ids: [], entity_name: null, entity_type_id: null, bound_with: null, }, geometry, }); } ); const selectEngine = initSelect( map, () => modeRef.current, (id: string | number | (string | number)[]) => { editingEngineRef.current?.clearEditing(); onSelectFeatureIdsRef.current?.([]); onDeleteRef.current?.(id); }, (feature) => { const rawId = feature.properties?.id ?? feature.id; const originalFeature = renderDraftRef.current.features.find( (item) => String(item.properties.id) === String(rawId) ); editingEngineRef.current?.beginEditing( (originalFeature || feature) as unknown as maplibregl.MapGeoJSONFeature ); }, (id: string | number) => { const originalFeature = renderDraftRef.current.features.find( (item) => String(item.properties.id) === String(id) ); if (!originalFeature) return; const nextFeature = buildDuplicatedFeatureShapeOnly(originalFeature); onCreateRef.current?.(nextFeature); }, (id: string | number) => { onHideRef.current?.(id); onSelectFeatureIdsRef.current?.([]); }, (ids) => onSelectFeatureIdsRef.current?.(ids), (id: string | number) => onSetModeRef.current?.("replay", id), () => Boolean(editingEngineRef.current?.editingRef.current), (targetId, sourceIds) => onBindGeometriesRef?.current?.(targetId, sourceIds), (payload) => { if (!payload) { onFeatureClickRef.current?.(null); return; } const currentFeature = renderDraftRef.current.features.find( (item) => String(item.properties.id) === String(payload.featureId) ) || null; onFeatureClickRef.current?.({ ...payload, feature: currentFeature, }); }, (feature) => { if (!onAddFeatureToProjectRef?.current) return; const rawId = feature.properties?.id ?? feature.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); }, (id) => { if (!onAddFeatureToProjectRef?.current) return true; const localIds = localFeatureIdsRef?.current; if (!Array.isArray(localIds)) return true; return localIds.some((localId) => String(localId) === String(id)); }, () => allowFeatureSelectionRef.current, () => allowGeometryEditingRef.current ); const cleanupPoint = initPoint( map, () => modeRef.current, (geometry: Geometry) => { const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { id, type: "city", geometry_preset: "point", entity_id: null, entity_ids: [], entity_name: null, entity_type_id: null, bound_with: null, }, geometry, }); } ); const lineEngine = initLine( map, () => modeRef.current, (geometry: Geometry) => { const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { id, type: "defense_line", geometry_preset: "line", entity_id: null, entity_ids: [], entity_name: null, entity_type_id: null, bound_with: null, }, geometry, }); } ); const pathEngine = initPath( map, () => modeRef.current, (geometry: Geometry) => { const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { id, type: "attack_route", geometry_preset: "line", entity_id: null, entity_ids: [], entity_name: null, entity_type_id: null, bound_with: null, }, geometry, }); } ); const circleEngine = initCircle( map, () => modeRef.current, (geometry: Geometry) => { const id = buildClientFeatureId(); onCreateRef.current?.({ type: "Feature", properties: { id, type: "war", geometry_preset: "circle-area", entity_id: null, entity_ids: [], entity_name: null, entity_type_id: null, bound_with: null, }, geometry, }); } ); engineBindingsRef.current = { draw: drawingEngine, select: selectEngine, preview: selectEngine, replay: selectEngine, replay_preview: selectEngine, "add-line": lineEngine, "add-path": pathEngine, "add-circle": circleEngine, }; mapCleanupFnsRef.current.push( circleEngine.cleanup, pathEngine.cleanup, lineEngine.cleanup, cleanupPoint, selectEngine.cleanup, drawingEngine.cleanup ); const editCleanup = editingEngineRef.current?.bindEditEvents(map); if (editCleanup) { mapCleanupFnsRef.current.push(editCleanup); } }; const cleanupMapInteractions = () => { for (const cleanupFn of mapCleanupFnsRef.current) { cleanupFn(); } mapCleanupFnsRef.current = []; engineBindingsRef.current = {}; }; return { editingEngineRef, setupMapInteractions, cleanupMapInteractions, }; } function buildDuplicatedFeatureShapeOnly( feature: FeatureCollection["features"][number] ): FeatureCollection["features"][number] { const geometry = cloneGeometry(feature.geometry); return { type: "Feature", properties: { id: buildClientFeatureId(), type: feature.properties.type ?? null, geometry_preset: feature.properties.geometry_preset ?? null, entity_id: null, entity_ids: [], entity_name: null, entity_names: [], bound_with: null, }, geometry, }; } function cloneGeometry(geometry: Geometry): Geometry { if (typeof structuredClone === "function") { return structuredClone(geometry); } return JSON.parse(JSON.stringify(geometry)) as Geometry; }