import { useEffect, useMemo, useRef } from 'react'; import { classifyValues, classIndexOf, formatClassLabel, interpolateRamp, type MapClass, type MapClassifyMethod, } from './map-classify'; import type { GeoJsonFeatureCollection } from './map-geojson'; import type { ColorToken, MapFeatureRef } from './map.types'; export interface MapChoroplethConfig { /** Feature property holding the value to classify. */ field: string; /** Number of classes. @default 5 */ steps?: number; /** @default 'quantile' */ method?: MapClassifyMethod; /** Ramp stops, cold to hot. Intermediate shades are interpolated. */ scaleTokens: ColorToken[]; /** Fill for features whose value is missing or unclassifiable. */ noDataToken?: ColorToken; /** @default 0.72 */ fillOpacity?: number; /** @default 0.9 */ strokeOpacity?: number; /** Decimal places in the generated legend labels. @default 0 */ labelDigits?: number; } /** One computed class, ready to become a legend row. */ export interface MapChoroplethBand { index: number; min: number; max: number; color: string; label: string; } export interface UseMapChoroplethOptions { map: google.maps.Map | null; isLoaded: boolean; geojson?: GeoJsonFeatureCollection | null; choropleth?: MapChoroplethConfig; colors: Record; /** Highlight color for the feature under the cursor. */ hoverColor: string; visible?: boolean; zIndex?: number; onSelect?: (id: string, feature: MapFeatureRef) => void; onHover?: (id: string | null, feature: MapFeatureRef | null) => void; } function readValue(feature: google.maps.Data.Feature, field: string): number { const raw = feature.getProperty(field); return typeof raw === 'number' ? raw : Number(raw); } function featureId(feature: google.maps.Data.Feature, index: number): string { const id = feature.getId(); return id === undefined ? `feature-${index}` : String(id); } /** * Paints a territorial mesh by value. * * @description * Uses `google.maps.Data`, which reads GeoJSON directly and gives per-feature * events, rather than converting every polygon by hand. * * The computed bands are returned so the caller can hand them to `MapLegend`. * A choropleth whose legend was written separately is a choropleth that will * eventually lie about its own classes, so the bands come from the same * classification that painted the map. */ export function useMapChoropleth({ map, isLoaded, geojson, choropleth, colors, hoverColor, visible = true, zIndex, onSelect, onHover, }: UseMapChoroplethOptions): MapChoroplethBand[] { const dataRef = useRef(null); const listenersRef = useRef([]); const hoveredRef = useRef(null); const onSelectRef = useRef(onSelect); const onHoverRef = useRef(onHover); onSelectRef.current = onSelect; onHoverRef.current = onHover; // Classes are derived from the raw GeoJSON rather than from the Data layer, so // the legend is available on the very first render — before Google has parsed // anything — and stays correct even if the map never loads. const classes: MapClass[] = useMemo(() => { if (!geojson || !choropleth) return []; const values = geojson.features .map(feature => Number(feature.properties?.[choropleth.field])) .filter(value => Number.isFinite(value)); return classifyValues(values, choropleth.steps ?? 5, choropleth.method ?? 'quantile'); }, [geojson, choropleth]); const ramp = useMemo(() => { if (!choropleth || classes.length === 0) return []; const stops = choropleth.scaleTokens.map(token => colors[token]).filter(Boolean); return interpolateRamp(stops, classes.length); }, [choropleth, classes, colors]); const bands: MapChoroplethBand[] = useMemo( () => classes.map(mapClass => ({ index: mapClass.index, min: mapClass.min, max: mapClass.max, color: ramp[mapClass.index] ?? '', label: formatClassLabel(mapClass, choropleth?.labelDigits ?? 0), })), [classes, ramp, choropleth] ); useEffect(() => { if (!map || !isLoaded || typeof google === 'undefined') return; const layer = dataRef.current ?? new google.maps.Data(); dataRef.current = layer; listenersRef.current.forEach(listener => listener.remove()); listenersRef.current = []; // Data has no "replace everything" call, so clear before re-adding. layer.forEach(feature => layer.remove(feature)); if (!geojson || !visible) { layer.setMap(null); return; } layer.setMap(map); layer.addGeoJson(geojson); const noDataColor = choropleth?.noDataToken ? colors[choropleth.noDataToken] : undefined; layer.setStyle(feature => { const value = choropleth ? readValue(feature, choropleth.field) : NaN; const index = classIndexOf(value, classes); const isHovered = hoveredRef.current === featureId(feature, 0); const fill = index === -1 ? (noDataColor ?? '') : (ramp[index] ?? ''); return { fillColor: fill, fillOpacity: choropleth?.fillOpacity ?? 0.72, strokeColor: isHovered ? hoverColor : fill, strokeOpacity: choropleth?.strokeOpacity ?? 0.9, strokeWeight: isHovered ? 2.5 : 0.8, zIndex: isHovered ? (zIndex ?? 0) + 1 : zIndex, visible: true, }; }); const refFor = (feature: google.maps.Data.Feature): MapFeatureRef => ({ id: featureId(feature, 0), kind: 'geojson', data: choropleth ? feature.getProperty(choropleth.field) : undefined, }); listenersRef.current.push( layer.addListener('click', (event: google.maps.Data.MouseEvent) => { const ref = refFor(event.feature); onSelectRef.current?.(ref.id, ref); }), layer.addListener('mouseover', (event: google.maps.Data.MouseEvent) => { hoveredRef.current = featureId(event.feature, 0); // Re-running setStyle is how Data re-evaluates the styler per feature. layer.revertStyle(); layer.overrideStyle(event.feature, { strokeColor: hoverColor, strokeWeight: 2.5, }); const ref = refFor(event.feature); onHoverRef.current?.(ref.id, ref); }), layer.addListener('mouseout', () => { hoveredRef.current = null; layer.revertStyle(); onHoverRef.current?.(null, null); }) ); }, [map, isLoaded, geojson, choropleth, colors, classes, ramp, hoverColor, visible, zIndex]); useEffect(() => { const listeners = listenersRef; const data = dataRef; return () => { listeners.current.forEach(listener => listener.remove()); listeners.current = []; data.current?.setMap(null); data.current = null; }; }, []); return bands; }