import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { cn } from '../../shared/utils'; import { MapMarkerContent, type MapMarkerData } from './map-marker'; import { createMercatorProjection } from './map-projection'; import { geoJsonPositions, geoJsonRings, type GeoJsonInput } from './map-geojson'; import type { LatLng } from './map.types'; export interface MapStaticProps { markers: MapMarkerData[]; /** Reference outline drawn behind the markers, in the same projection. */ baseGeojson?: GeoJsonInput | null; /** Resolved literal colors, keyed by token name. */ colors: Record; /** Fallback marker color when a marker declares no token. */ defaultColor: string; outlineColor: string; selectedIndex: number | null; selectable: boolean; onActivate: (index: number) => void; onHoverChange: (index: number, hovering: boolean) => void; fitPadding: number; } /** * Plots the data layer with no Google Maps script at all. * * @description * The basemap tiles are the one thing an API key genuinely buys; everything else * the map draws is ordinary DOM. When the key is missing — a blocked corporate * network, an exhausted quota, a deployment that may not call Google — this * renderer keeps the markers, their proportional sizes, tooltips, selection and * keyboard navigation intact instead of replacing the whole panel with a setup * prompt. * * Positions come from a shared Web Mercator projection, so a `baseGeojson` * outline stays registered with the points drawn on top of it. Without that * outline the view answers "how many" but not "where": pass boundaries whenever * the geography matters. */ export function MapStatic({ markers, baseGeojson, colors, defaultColor, outlineColor, selectedIndex, selectable, onActivate, onHoverChange, fitPadding, }: MapStaticProps) { const containerRef = useRef(null); const [size, setSize] = useState({ width: 0, height: 0 }); // The projection needs real pixels to keep the aspect ratio honest, so the // container is measured rather than assumed. Measuring in a layout effect // means the first paint already has the points in place, with no empty flash. useLayoutEffect(() => { const element = containerRef.current; if (!element) return; const measure = () => { setSize({ width: element.clientWidth, height: element.clientHeight }); }; measure(); if (typeof ResizeObserver === 'undefined') return; const observer = new ResizeObserver(measure); observer.observe(element); return () => observer.disconnect(); }, []); const outlineRings = useMemo(() => geoJsonRings(baseGeojson), [baseGeojson]); const projection = useMemo(() => { const points: LatLng[] = [ ...markers.map(marker => marker.position), ...geoJsonPositions(baseGeojson), ]; return createMercatorProjection(points, size.width, size.height, fitPadding); }, [markers, baseGeojson, size.width, size.height, fitPadding]); const toPath = useCallback( (points: LatLng[], closed: boolean) => { if (!projection) return ''; const commands = points.map((point, index) => { const { x, y } = projection.project(point); return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)} ${y.toFixed(2)}`; }); return commands.join(' ') + (closed ? ' Z' : ''); }, [projection] ); return (
{projection && outlineRings.length > 0 ? ( ) : null} {projection ? markers.map((marker, index) => { const { x, y } = projection.project(marker.position); const token = marker.colorToken; const color = token ? colors[token] || defaultColor : marker.customColor || defaultColor; return (
onActivate(index)} onHoverChange={hovering => onHoverChange(index, hovering)} />
); }) : null}
); } /** Classes that make the keyless surface visibly not a real map. */ export const MAP_STATIC_FRAME_CLASS = cn( 'relative overflow-hidden rounded-[var(--radius-card)] border border-dashed border-border' );