import { useEffect, useRef } from 'react'; import type { ColorToken, LatLng } from './map.types'; /* * `@types/google.maps` ships `visualization.HeatmapLayer` as an empty class with * a no-argument constructor — the real runtime surface is simply untyped there. * 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 HeatmapLayerOptions { map?: google.maps.Map | null; data?: Array; radius?: number; opacity?: number; dissipating?: boolean; gradient?: string[]; maxIntensity?: number; } interface HeatmapLayerInstance { setMap(map: google.maps.Map | null): void; setOptions(options: HeatmapLayerOptions): void; } type HeatmapLayerConstructor = new (options?: HeatmapLayerOptions) => HeatmapLayerInstance; export interface MapHeatmapPoint extends LatLng { /** Relative intensity. @default 1 */ weight?: number; } export interface MapHeatmapConfig { points: MapHeatmapPoint[]; /** Influence radius of each point, in pixels. @default 28 */ radiusPx?: number; /** @default 0.7 */ opacity?: number; /** When true the radius is fixed in pixels, so density changes with zoom. @default true */ dissipating?: boolean; /** * Ramp for the density gradient, cold to hot. Tokens, not literals, so the * heat map follows the theme instead of freezing on Google's default orange. */ gradientTokens?: ColorToken[]; /** Stacking order relative to other overlays. */ zIndex?: number; } export interface UseMapHeatmapOptions { map: google.maps.Map | null; isLoaded: boolean; heatmap?: MapHeatmapConfig; /** Resolved literal colors keyed by token name. */ colors: Record; visible?: boolean; } /** * Renders a density surface with `google.maps.visualization.HeatmapLayer`. * * @description * Requires the `visualization` library, which is why it was added to * `GOOGLE_MAPS_LIBRARIES` — it is the one loading change the cartographic layer * makes. * * The gradient is built from semantic tokens rather than Google's stock orange * ramp, so the surface survives a theme change like everything else on the map. * Google expects the first gradient entry to be fully transparent (it is the * color of "no density"), so a transparent stop is prepended automatically — * passing an opaque first token would otherwise flood the whole viewport. */ export function useMapHeatmap({ map, isLoaded, heatmap, colors, visible = true, }: UseMapHeatmapOptions) { const layerRef = useRef(null); useEffect(() => { if (!map || !isLoaded || typeof google === 'undefined') return; const visualization = google.maps.visualization; if (!visualization?.HeatmapLayer) { // Loader misconfigured: fail loudly in development instead of rendering // an empty map with no explanation. if (heatmap && typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') { console.warn( '[xertica-ui] needs the Google Maps "visualization" library. ' + 'It ships in GOOGLE_MAPS_LIBRARIES — a custom loader must include it too.' ); } return; } if (!heatmap || heatmap.points.length === 0 || !visible) { layerRef.current?.setMap(null); return; } const data = heatmap.points.map(point => typeof point.weight === 'number' ? { location: new google.maps.LatLng(point.lat, point.lng), weight: point.weight } : new google.maps.LatLng(point.lat, point.lng) ); const gradient = heatmap.gradientTokens?.length ? ['rgba(0, 0, 0, 0)', ...heatmap.gradientTokens.map(token => colors[token]).filter(Boolean)] : undefined; const options: HeatmapLayerOptions = { map, data, radius: heatmap.radiusPx ?? 28, opacity: heatmap.opacity ?? 0.7, dissipating: heatmap.dissipating ?? true, ...(gradient && gradient.length > 1 ? { gradient } : {}), }; if (layerRef.current) { layerRef.current.setOptions(options); } else { const HeatmapLayer = visualization.HeatmapLayer as unknown as HeatmapLayerConstructor; layerRef.current = new HeatmapLayer(options); } }, [map, isLoaded, heatmap, colors, visible]); useEffect(() => { const layer = layerRef; return () => { layer.current?.setMap(null); layer.current = null; }; }, []); }