import { useEffect, useRef } from 'react'; import type { MapDrawnShape } from './map-geometry-math'; import type { ColorToken } from './map.types'; /* * Like `visualization.HeatmapLayer`, `drawing.DrawingManager` ships from * `@types/google.maps` as an empty class. These declarations describe the API * actually being called, spelled out rather than cast to `any` so a wrong * option name is still a compile error. */ interface DrawingShapeOptions { fillColor?: string; fillOpacity?: number; strokeColor?: string; strokeOpacity?: number; strokeWeight?: number; editable?: boolean; clickable?: boolean; } interface DrawingManagerOptions { drawingControl?: boolean; drawingMode?: string | null; polygonOptions?: DrawingShapeOptions; circleOptions?: DrawingShapeOptions; rectangleOptions?: DrawingShapeOptions; } interface DrawingManagerInstance { setMap(map: google.maps.Map | null): void; setOptions(options: DrawingManagerOptions): void; setDrawingMode(mode: string | null): void; addListener(event: string, handler: (...args: never[]) => void): google.maps.MapsEventListener; } type DrawingManagerConstructor = new (options?: DrawingManagerOptions) => DrawingManagerInstance; export type MapDrawMode = 'polygon' | 'circle' | 'rectangle'; export interface MapDrawingConfig { /** @default ['polygon'] */ modes?: MapDrawMode[]; /** The mode currently armed. `null` or `false` disarms the tool. */ active?: MapDrawMode | boolean | null; onComplete: (shape: MapDrawnShape) => void; onCancel?: () => void; /** Stroke and fill of the shape while it is being drawn. */ colorToken?: ColorToken; /** Leave the drawn overlay on the map after completion. @default false */ keepOverlay?: boolean; } export interface UseMapDrawingOptions { map: google.maps.Map | null; isLoaded: boolean; drawing?: MapDrawingConfig; colors: Record; defaultColor: string; } /** * Free-hand geofence drawing on top of `google.maps.drawing.DrawingManager`. * * @description * The `drawing` library was already being loaded and no component exposed it, * so this costs no extra bundle. Pair the result with `pointsInShape` to answer * "who was inside" without a single Maps call. * * The finished overlay is removed by default: the shape is reported as plain * coordinates, and leaving Google's object behind would put a second, untracked * geometry on a map that already renders its shapes declaratively. */ export function useMapDrawing({ map, isLoaded, drawing, colors, defaultColor, }: UseMapDrawingOptions) { const managerRef = useRef(null); const listenersRef = useRef([]); const onCompleteRef = useRef(drawing?.onComplete); onCompleteRef.current = drawing?.onComplete; useEffect(() => { if (!map || !isLoaded || typeof google === 'undefined') return; const drawingLib = google.maps.drawing; if (!drawingLib?.DrawingManager) { if (drawing && typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') { console.warn( '[xertica-ui] needs the Google Maps "drawing" library. ' + 'It ships in GOOGLE_MAPS_LIBRARIES — a custom loader must include it too.' ); } return; } listenersRef.current.forEach(listener => listener.remove()); listenersRef.current = []; if (!drawing) { managerRef.current?.setMap(null); return; } const color = drawing.colorToken ? colors[drawing.colorToken] || defaultColor : defaultColor; const activeMode = drawing.active === true ? (drawing.modes?.[0] ?? 'polygon') : drawing.active === false || drawing.active == null ? null : drawing.active; const shapeOptions = { fillColor: color, fillOpacity: 0.2, strokeColor: color, strokeOpacity: 0.9, strokeWeight: 2, // Vertices stay draggable while the shape is on screen. editable: true, clickable: true, }; const DrawingManager = drawingLib.DrawingManager as unknown as DrawingManagerConstructor; const manager = managerRef.current ?? new DrawingManager({ drawingControl: false, polygonOptions: shapeOptions, circleOptions: shapeOptions, rectangleOptions: shapeOptions, }); managerRef.current = manager; manager.setMap(map); manager.setOptions({ drawingMode: activeMode ? (activeMode as unknown as google.maps.drawing.OverlayType) : null, polygonOptions: shapeOptions, circleOptions: shapeOptions, rectangleOptions: shapeOptions, }); const finish = ( overlay: google.maps.Polygon | google.maps.Circle | google.maps.Rectangle, shape: MapDrawnShape ) => { onCompleteRef.current?.(shape); if (!drawing.keepOverlay) overlay.setMap(null); manager.setDrawingMode(null); }; listenersRef.current.push( manager.addListener('polygoncomplete', ((polygon: google.maps.Polygon) => { const path = polygon .getPath() .getArray() .map(point => ({ lat: point.lat(), lng: point.lng() })); finish(polygon, { type: 'polygon', path }); }) as (...args: never[]) => void), manager.addListener('circlecomplete', ((circle: google.maps.Circle) => { const center = circle.getCenter(); if (!center) return; finish(circle, { type: 'circle', center: { lat: center.lat(), lng: center.lng() }, radiusMeters: circle.getRadius(), }); }) as (...args: never[]) => void), manager.addListener('rectanglecomplete', ((rectangle: google.maps.Rectangle) => { const bounds = rectangle.getBounds(); if (!bounds) return; const ne = bounds.getNorthEast(); const sw = bounds.getSouthWest(); finish(rectangle, { type: 'rectangle', bounds: { north: ne.lat(), east: ne.lng(), south: sw.lat(), west: sw.lng() }, }); }) as (...args: never[]) => void) ); }, [map, isLoaded, drawing, colors, defaultColor]); useEffect(() => { const listeners = listenersRef; const manager = managerRef; return () => { listeners.current.forEach(listener => listener.remove()); listeners.current = []; manager.current?.setMap(null); manager.current = null; }; }, []); }