import { useEffect, useRef } from 'react'; import { sectorPath } from './map-geometry-math'; import type { ColorToken, LatLng, MapFeatureRef } from './map.types'; import { effectiveOpacity, effectiveZIndex, isLayerVisible, layerById, type MapDataLayer, type MapLayerVisibility, } from './map-layers-model'; export interface MapGeometryBase { id: string; /** Semantic token. Wins over the literal color fields. */ colorToken?: ColorToken; /** Literal escape hatch, kept for parity with the singular `circle`/`polygon`. */ fillColor?: string; strokeColor?: string; /** @default 0.2 */ fillOpacity?: number; /** @default 0.8 */ strokeOpacity?: number; /** @default 2 */ strokeWeight?: number; /** Ties the shape to a `dataLayers` entry. */ layerId?: string; zIndex?: number; /** Multiplied with the layer's opacity. @default 1 */ opacity?: number; /** Native tooltip text shown on hover. */ title?: string; data?: unknown; } export interface MapPolygonShape extends MapGeometryBase { /** A single ring, or a ring plus holes. */ paths: LatLng[][] | LatLng[]; } export interface MapCircleShape extends MapGeometryBase { center: LatLng; radiusMeters: number; } /** * An antenna coverage wedge: vertex on the site, opening `beamWidth` degrees * around `azimuth` and reaching `radiusMeters`. */ export interface MapSectorShape extends MapGeometryBase { center: LatLng; /** Degrees clockwise from north. */ azimuth: number; /** Angular width of the beam, in degrees. 360 or more draws a full circle. */ beamWidth: number; radiusMeters: number; /** Arc resolution. Raise it for very wide beams. @default 24 */ steps?: number; } /** A line through arbitrary coordinates — not a Directions route. */ export interface MapPolylineShape extends MapGeometryBase { path: LatLng[]; /** Stroke width in pixels. @default 2.5 */ weightPx?: number; /** * Dash pattern as `"dash gap"` in pixels, e.g. `"6 5"`. Rendered with Maps * symbol icons, since Polyline has no native dash support. */ dash?: string; } export interface UseMapGeometryOptions { map: google.maps.Map | null; isLoaded: boolean; polygons?: MapPolygonShape[]; circles?: MapCircleShape[]; sectors?: MapSectorShape[]; polylines?: MapPolylineShape[]; /** Resolved literal colors keyed by token name. */ colors: Record; defaultPolygonColor: string; defaultCircleColor: string; dataLayers?: MapDataLayer[]; layerVisibility: MapLayerVisibility; onSelect?: (id: string, feature: MapFeatureRef) => void; onHover?: (id: string | null, feature: MapFeatureRef | null) => void; } function shapeColor( shape: MapGeometryBase, colors: Record, fallback: string ): string { if (shape.colorToken) return colors[shape.colorToken] || fallback; return shape.fillColor || shape.strokeColor || fallback; } /** * Renders any number of polygons and circles onto the map. * * @description * Replaces the one-polygon / one-circle limit. Applications need antenna * coverage sectors per station, a geofence and convergence circles on screen at * the same time — with a single slot each, they could not. * * Instances are reconciled by `id` rather than torn down and rebuilt, so a * change to one shape does not make every other shape flicker, and Google's * event listeners survive unrelated re-renders. */ export function useMapGeometry({ map, isLoaded, polygons, circles, sectors, polylines, colors, defaultPolygonColor, defaultCircleColor, dataLayers, layerVisibility, onSelect, onHover, }: UseMapGeometryOptions) { const polygonsRef = useRef(new Map()); const circlesRef = useRef(new Map()); const polylinesRef = useRef(new Map()); const listenersRef = useRef([]); const onSelectRef = useRef(onSelect); const onHoverRef = useRef(onHover); onSelectRef.current = onSelect; onHoverRef.current = onHover; useEffect(() => { if (!map || !isLoaded || typeof google === 'undefined') return; listenersRef.current.forEach(listener => listener.remove()); listenersRef.current = []; const attach = ( instance: google.maps.Polygon | google.maps.Circle, shape: MapGeometryBase, kind: 'polygon' | 'circle' | 'polyline', position?: LatLng ) => { const ref: MapFeatureRef = { id: shape.id, kind, position, data: shape.data }; listenersRef.current.push( instance.addListener('click', () => onSelectRef.current?.(shape.id, ref)), instance.addListener('mouseover', () => onHoverRef.current?.(shape.id, ref)), instance.addListener('mouseout', () => onHoverRef.current?.(null, null)) ); }; /* --- Polygons ------------------------------------------------------- */ const livePolygons = new Set(); const sectorsAsPolygons: MapPolygonShape[] = sectors?.map(sector => ({ ...sector, paths: sectorPath({ center: sector.center, azimuth: sector.azimuth, beamWidth: sector.beamWidth, radiusMeters: sector.radiusMeters, steps: sector.steps, }), })) ?? []; [...(polygons ?? []), ...sectorsAsPolygons].forEach(shape => { livePolygons.add(shape.id); const layer = layerById(shape.layerId, dataLayers); const alpha = effectiveOpacity(shape.opacity, layer); const visible = isLayerVisible(shape.layerId, layerVisibility); const color = shapeColor(shape, colors, defaultPolygonColor); const options: google.maps.PolygonOptions = { map, paths: shape.paths as google.maps.LatLngLiteral[] | google.maps.LatLngLiteral[][], fillColor: color, fillOpacity: (shape.fillOpacity ?? 0.2) * alpha, strokeColor: shape.strokeColor || color, strokeOpacity: (shape.strokeOpacity ?? 0.8) * alpha, strokeWeight: shape.strokeWeight ?? 2, zIndex: effectiveZIndex(shape.zIndex, layer), visible, clickable: true, }; const existing = polygonsRef.current.get(shape.id); if (existing) { existing.setOptions(options); attach(existing, shape, 'polygon'); } else { const created = new google.maps.Polygon(options); polygonsRef.current.set(shape.id, created); attach(created, shape, 'polygon'); } }); polygonsRef.current.forEach((instance, id) => { if (livePolygons.has(id)) return; instance.setMap(null); polygonsRef.current.delete(id); }); /* --- Circles -------------------------------------------------------- */ const liveCircles = new Set(); circles?.forEach(shape => { liveCircles.add(shape.id); const layer = layerById(shape.layerId, dataLayers); const alpha = effectiveOpacity(shape.opacity, layer); const visible = isLayerVisible(shape.layerId, layerVisibility); const color = shapeColor(shape, colors, defaultCircleColor); const options: google.maps.CircleOptions = { map, center: shape.center, radius: shape.radiusMeters, fillColor: color, fillOpacity: (shape.fillOpacity ?? 0.2) * alpha, strokeColor: shape.strokeColor || color, strokeOpacity: (shape.strokeOpacity ?? 0.8) * alpha, strokeWeight: shape.strokeWeight ?? 2, zIndex: effectiveZIndex(shape.zIndex, layer), visible, clickable: true, }; const existing = circlesRef.current.get(shape.id); if (existing) { existing.setOptions(options); attach(existing, shape, 'circle', shape.center); } else { const created = new google.maps.Circle(options); circlesRef.current.set(shape.id, created); attach(created, shape, 'circle', shape.center); } }); circlesRef.current.forEach((instance, id) => { if (liveCircles.has(id)) return; instance.setMap(null); circlesRef.current.delete(id); }); /* --- Polylines ------------------------------------------------------ */ const livePolylines = new Set(); polylines?.forEach(shape => { livePolylines.add(shape.id); const layer = layerById(shape.layerId, dataLayers); const alpha = effectiveOpacity(shape.opacity, layer); const visible = isLayerVisible(shape.layerId, layerVisibility); const color = shapeColor(shape, colors, defaultPolygonColor); /* * Polyline has no dash property. A dashed line is drawn as a repeated * dot symbol along an otherwise invisible stroke — the documented Maps * technique, and the reason `strokeOpacity` drops to 0 here. */ const dashed = shape.dash ? (() => { const [dash = 6, gap = 5] = shape.dash!.split(/\s+/).map(Number); return { strokeOpacity: 0, icons: [ { icon: { path: 'M 0,-1 0,1', strokeOpacity: (shape.strokeOpacity ?? 1) * alpha, strokeWeight: shape.weightPx ?? 2.5, scale: Math.max(1, (shape.weightPx ?? 2.5) / 2), }, offset: '0', repeat: `${dash + gap}px`, }, ], }; })() : { strokeOpacity: (shape.strokeOpacity ?? 1) * alpha }; const options: google.maps.PolylineOptions = { map, path: shape.path, strokeColor: color, strokeWeight: shape.weightPx ?? 2.5, zIndex: effectiveZIndex(shape.zIndex, layer), visible, clickable: true, ...dashed, }; const existing = polylinesRef.current.get(shape.id); if (existing) { existing.setOptions(options); attach(existing as unknown as google.maps.Polygon, shape, 'polyline'); } else { const created = new google.maps.Polyline(options); polylinesRef.current.set(shape.id, created); attach(created as unknown as google.maps.Polygon, shape, 'polyline'); } }); polylinesRef.current.forEach((instance, id) => { if (livePolylines.has(id)) return; instance.setMap(null); polylinesRef.current.delete(id); }); }, [ map, isLoaded, polygons, circles, sectors, polylines, colors, defaultPolygonColor, defaultCircleColor, dataLayers, layerVisibility, ]); // Detach everything on unmount, including instances the diff never removed. useEffect(() => { const livePolygons = polygonsRef.current; const liveCircles = circlesRef.current; const liveLines = polylinesRef.current; const liveListeners = listenersRef; return () => { liveListeners.current.forEach(listener => listener.remove()); liveListeners.current = []; livePolygons.forEach(instance => instance.setMap(null)); livePolygons.clear(); liveCircles.forEach(instance => instance.setMap(null)); liveCircles.clear(); liveLines.forEach(instance => instance.setMap(null)); liveLines.clear(); }; }, []); }