import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { cn } from '../../shared/utils'; import { useGoogleMapsLoader } from '../google-maps-loader'; import { useMapLayers, MapLayersConfig } from '../map-layers'; import { MapNotice } from '../map-notice'; import { DEFAULT_MAP_ID } from '../map-config'; import { useOptionalApiKey } from '../../../contexts/ApiKeyContext'; import { MapMarker, type MapMarkerData } from './map-marker'; import { MapStatic } from './map-static'; import { useMapSelection } from './use-map-selection'; import { useMapGeometry, type MapCircleShape, type MapPolygonShape, type MapPolylineShape, type MapSectorShape, } from './use-map-geometry'; import { useMapDrawing, type MapDrawingConfig } from './use-map-drawing'; import { resolveBasemapStyle, type MapBasemap, type MapBasemapStyles } from './map-basemap'; import { MapScaleBar, type MapScaleUnits } from '../map-scale-bar'; import { useMapHeatmap, type MapHeatmapConfig } from './use-map-heatmap'; import { useMapChoropleth, type MapChoroplethBand, type MapChoroplethConfig, } from './use-map-choropleth'; import { useCaptionCollision } from './use-caption-collision'; import { clusterMarkers, type MapClusterConfig } from './map-cluster'; import { isLayerVisible, resolveLayerVisibility, type MapDataLayer } from './map-layers-model'; import type { MapControl } from './map-control'; import type { GeoJsonFeatureCollection } from './map-geojson'; import type { GeoJsonInput } from './map-geojson'; import { useMapTokenColors } from './use-map-token-color'; import { useMapFit, type MapBounds, type MapFitTarget, type MapView } from './use-map-fit'; import { MapEmptyIcon, MapLoadingState, MapMessageState, MapPinIcon, MapStateFrame, MapWarningIcon, } from './map-states'; import type { ColorToken, LatLng, MapFeatureRef, MapOverlayPosition } from './map.types'; /** A pin authored by the user, distinct from the data markers. */ export interface MapAnnotation { id: string; position: LatLng; text: string; author?: string; createdAt?: string; } declare global { namespace JSX { interface IntrinsicElements { 'gmp-map': React.DetailedHTMLProps, HTMLElement> & { 'map-id'?: string; center?: string | google.maps.LatLng | google.maps.LatLngLiteral; zoom?: number; }; 'gmp-advanced-marker': React.DetailedHTMLProps< React.HTMLAttributes, HTMLElement > & { title?: string; position?: string | google.maps.LatLng | google.maps.LatLngLiteral; }; } } } export interface MapProps extends React.HTMLAttributes { center?: { lat: number; lng: number }; zoom?: number; mapTypeId?: string; mapId?: string; markers?: MapMarkerData[]; circle?: { center: { lat: number; lng: number }; radius: number; /** Literal color. Prefer `circleColorToken`. */ fillColor?: string; /** Literal color. Prefer `circleColorToken`. */ strokeColor?: string; }; /** Semantic token for the single `circle`. Wins over its literal colors. */ circleColorToken?: ColorToken; polygon?: { paths: Array<{ lat: number; lng: number }>[]; /** Literal color. Prefer `polygonColorToken`. */ fillColor?: string; /** Literal color. Prefer `polygonColorToken`. */ strokeColor?: string; }; /** Semantic token for the single `polygon`. Wins over its literal colors. */ polygonColorToken?: ColorToken; layers?: MapLayersConfig; height?: string; apiKey?: string; mapContainerClassName?: string; disableDefaultUI?: boolean; zoomControl?: boolean; streetViewControl?: boolean; mapTypeControl?: boolean; fullscreenControl?: boolean; gestureHandling?: 'cooperative' | 'greedy' | 'none' | 'auto'; onMapLoad?: (map: google.maps.Map) => void; /** Title shown when the Google Maps script fails to load. @default "Failed to load Google Maps" */ loadErrorTitle?: string; /** Hint shown below `loadErrorTitle`. @default "Check API key in Settings" */ loadErrorHint?: string; /* --- Selection and feature events --------------------------------------- */ /** Id of the currently selected feature. Requires `markers[].id`. */ selectedId?: string | null; /** Fires with `null` when the user clicks the map outside every feature. */ onFeatureSelect?: (id: string | null, feature: MapFeatureRef | null) => void; /** Fires with `null` when the pointer or focus leaves a feature. */ onFeatureHover?: (id: string | null, feature: MapFeatureRef | null) => void; /** Every click on the map surface, feature or not. */ onMapClick?: (position: LatLng) => void; /** Accessible name for the map region. @default "Map" */ ariaLabel?: string; /* --- Framing ------------------------------------------------------------- */ /** Explicit frame. Takes precedence over `fitTo`. */ bounds?: MapBounds; /** What to frame the camera around. @default "none" */ fitTo?: MapFitTarget; /** Padding around fitted content, in pixels. @default 34 */ fitPadding?: number; /** Upper zoom bound applied after fitting. */ maxZoomOnFit?: number; /** Named camera positions. Pair with `` to expose them. */ views?: MapView[]; /** Active entry of `views`. An active view wins over automatic fitting. */ activeViewId?: string; /** * Not consumed by `` itself — `` renders the buttons that * call it. Accepted here so the view triple can be spread onto both: * `` and ``. */ onViewChange?: (id: string) => void; /* --- Container states ---------------------------------------------------- */ /** Forces the loading skeleton regardless of script state. */ loading?: boolean; /** The filter returned nothing. A normal outcome, not an error. */ isEmpty?: boolean; /** Replaces the built-in empty state. */ emptyState?: React.ReactNode; /** Rendering it signals a data error. Outranks every state but `deniedState`. */ errorState?: React.ReactNode; /** Rendering it signals the viewer may not see this data. Outranks all states. */ deniedState?: React.ReactNode; /** @default "No results for the current filters" */ emptyTitle?: string; /** @default "Adjust the filters to see data on the map" */ emptyHint?: string; /** @default "Configure Google Maps API Key in Settings" */ missingKeyTitle?: string; /* --- Geometry, layers and analysis ---------------------------------------- */ /** Any number of polygons. The singular `polygon` keeps working alongside. */ polygons?: MapPolygonShape[]; /** Any number of circles. The singular `circle` keeps working alongside. */ circles?: MapCircleShape[]; /** Antenna coverage wedges: vertex on the site, opening around an azimuth. */ sectors?: MapSectorShape[]; /** Lines through arbitrary coordinates. Not Directions routes — see `RouteMap`. */ polylines?: MapPolylineShape[]; /** * Named, toggleable layers. Markers and geometries join one through `layerId`. * * Named `dataLayers` and not `layers` on purpose — `layers` already means * Google's native traffic / transit / bicycling overlays and could not change * meaning without breaking working code. */ dataLayers?: MapDataLayer[]; /** Territorial mesh painted by value. Requires `choropleth`. */ geojson?: GeoJsonFeatureCollection | null; choropleth?: MapChoroplethConfig; /** Density surface. Needs the Maps `visualization` library, which ships by default. */ heatmap?: MapHeatmapConfig; /** Collapses co-located records into markers carrying a count. */ cluster?: MapClusterConfig; /** * Privacy floor: groups holding fewer than this many records are merged * instead of plotted individually. Display anonymization only — it changes * what is drawn, never what the payload contains. */ minAggregation?: number; /** Hides cartographic captions that overlap. @default 'hide' */ captionCollision?: 'hide' | 'none'; /** Fired by controls rendered inside a marker's `popup`. */ onPopupAction?: (featureId: string, action: string) => void; /** * Receives the classes the choropleth actually painted, so a `MapLegend` can * be built from them instead of a second, independently computed set. */ onChoroplethBands?: (bands: MapChoroplethBand[]) => void; /** * Imperative handle. Separate from `ref`, which stays the container element. */ controlRef?: React.Ref; /* --- Field tools ----------------------------------------------------------- */ /** User-authored pins, distinct from data markers: they have authorship and can be removed. */ annotations?: MapAnnotation[]; /** Arms annotation placement — the next map click creates one. */ annotationMode?: boolean; onAnnotationAdd?: (position: LatLng) => void; onAnnotationRemove?: (id: string) => void; /** Free-hand geofence drawing. Pair the result with `pointsInShape`. */ drawing?: MapDrawingConfig; /** The device's own position, with its accuracy halo. */ deviceLocation?: { position: LatLng; accuracyMeters?: number; /** Renders in a destructive state — a simulated coordinate must be visible, not just logged. */ mocked?: boolean; } | null; /* --- Time ------------------------------------------------------------------ */ /** Field on `markers[].data` holding the timestamp used by `timeWindow`. */ timeField?: string; /** Inclusive `[from, to]`. Markers outside it are not drawn. */ timeWindow?: [number, number]; /* --- Chrome ---------------------------------------------------------------- */ /** Graphic scale bar. Required reading for anything printed. */ scaleBar?: { units?: MapScaleUnits; position?: MapOverlayPosition } | boolean; /* --- Basemap -------------------------------------------------------------- */ /** Cloud-styled or filtered base maps. `'none'` draws no basemap at all. */ basemapStyles?: MapBasemapStyles; /** * `'none'` draws the data with no Google basemap at all — markers, tooltips, * selection and keyboard navigation on a neutral surface. Use it for print, * for deployments that may not call Google, or to cut tile cost on a panel * where the map is secondary. */ basemap?: MapBasemap; /** * Reference outline drawn behind the markers when there is no basemap, in the * same projection. Without it the view answers "how many" but not "where", so * pass boundaries whenever the geography matters. */ baseGeojson?: GeoJsonInput | null; /** * When the Maps script cannot load — no API key, exhausted quota, a network * that blocks `maps.googleapis.com` — fall back to drawing the data without a * basemap instead of replacing the panel with a setup prompt. * @default true */ basemapFallback?: boolean; /** @default "Base cartográfica indisponível" */ noBasemapLabel?: string; /* --- Overlays ------------------------------------------------------------ */ /** Cartographic attribution, required by most tile licenses. */ attribution?: React.ReactNode; } /** The slice of the `gmp-map` custom element this component drives. */ type GmpMapElement = HTMLElement & { innerMap?: google.maps.Map; center?: google.maps.LatLngLiteral; zoom?: number; mapTypeId?: string; }; const DEFAULT_CENTER = { lat: -23.5505, lng: -46.6333 }; const DEFAULT_ZOOM = 12; const PRIMARY_TOKEN: ColorToken = '--primary'; const CHART2_TOKEN: ColorToken = '--chart-2'; const MUTED_FOREGROUND_TOKEN: ColorToken = '--muted-foreground'; const RING_TOKEN: ColorToken = '--ring'; const DESTRUCTIVE_TOKEN: ColorToken = '--destructive'; const ANNOTATION_TOKEN: ColorToken = '--chart-4'; /** Fallbacks preserve the exact colors the previous static block used. */ const PRIMARY_FALLBACK = '#4F46E5'; const CHART2_FALLBACK = '#10B981'; let hasWarnedAboutMapId = false; /** * Advanced Markers need a Map ID, so `` falls back to Google's demo id when * none is given. That keeps the component working out of the box but silently * degrades in production — no Cloud styling, and a shared rate limit — so say so * once, in development only. */ function warnAboutDefaultMapId() { if (hasWarnedAboutMapId) return; if (typeof process === 'undefined' || process.env?.NODE_ENV === 'production') return; hasWarnedAboutMapId = true; console.warn( `[xertica-ui] is using the fallback map-id "${DEFAULT_MAP_ID}". ` + 'Advanced Markers require a Map ID; the demo id has no Cloud styling and is ' + 'rate-limited. Pass your own via the `mapId` prop before shipping to production.' ); } const MapContent = React.forwardRef( ({ apiKey, ...props }, ref) => { const { isLoaded, loadError, load } = useGoogleMapsLoader(); const { center = DEFAULT_CENTER, zoom = DEFAULT_ZOOM, markers = [], circle, circleColorToken, polygon, polygonColorToken, polygons, circles, sectors, polylines, annotations, annotationMode, onAnnotationAdd, onAnnotationRemove, drawing, deviceLocation, timeField, timeWindow, scaleBar, basemap, basemapStyles, children, dataLayers, geojson, choropleth, heatmap, cluster, minAggregation = 0, captionCollision = 'hide', onPopupAction, controlRef, layers, height = '400px', mapContainerClassName, disableDefaultUI = false, zoomControl = true, streetViewControl = false, mapTypeControl = false, fullscreenControl = true, gestureHandling = 'cooperative', mapTypeId, onMapLoad, loadErrorTitle = 'Failed to load Google Maps', loadErrorHint = 'Check API key in Settings', selectedId, onFeatureSelect, onFeatureHover, onMapClick, ariaLabel = 'Map', bounds, fitTo, fitPadding, maxZoomOnFit, views, activeViewId, attribution, className, /* * Destructured for their exclusion only: these are component props, not * DOM attributes, and would otherwise land on the container div through * `{...divProps}` and trip React's unknown-attribute warning. The map * control booleans below have never been wired to the `gmp-map` element — * see the map documentation. */ /* eslint-disable @typescript-eslint/no-unused-vars */ onViewChange, loading, isEmpty, emptyState, errorState, deniedState, emptyTitle, emptyHint, missingKeyTitle, mapId, onChoroplethBands, baseGeojson, basemapFallback, noBasemapLabel, /* eslint-enable @typescript-eslint/no-unused-vars */ ...divProps } = props; /** * The map instance lives in state, not a ref. A ref does not re-run the * effects that depend on it, which is why the circle, polygon and layer * effects previously listed `mapRef.current` as a dependency and never * fired on the render where the instance actually arrived. */ const [map, setMap] = useState(null); const gmpMapRef = useRef(null); const infoWindowRef = useRef(null); const infoRootRef = useRef<{ root: Root; container: HTMLDivElement } | null>(null); const circleRef = useRef(null); const polygonRef = useRef(null); /* --- Colors ----------------------------------------------------------- */ const tokens = useMemo(() => { const set = new Set([ PRIMARY_TOKEN, CHART2_TOKEN, RING_TOKEN, DESTRUCTIVE_TOKEN, ANNOTATION_TOKEN, ]); sectors?.forEach(shape => shape.colorToken && set.add(shape.colorToken)); polylines?.forEach(shape => shape.colorToken && set.add(shape.colorToken)); markers.forEach(marker => marker.colorToken && set.add(marker.colorToken)); polygons?.forEach(shape => shape.colorToken && set.add(shape.colorToken)); circles?.forEach(shape => shape.colorToken && set.add(shape.colorToken)); dataLayers?.forEach(layer => layer.colorToken && set.add(layer.colorToken)); choropleth?.scaleTokens.forEach(token => set.add(token)); if (choropleth?.noDataToken) set.add(choropleth.noDataToken); heatmap?.gradientTokens?.forEach(token => set.add(token)); if (circleColorToken) set.add(circleColorToken); if (polygonColorToken) set.add(polygonColorToken); return Array.from(set); }, [ markers, polygons, circles, sectors, polylines, dataLayers, choropleth, heatmap, circleColorToken, polygonColorToken, ]); const colors = useMapTokenColors(tokens, PRIMARY_FALLBACK); const primaryColor = colors[PRIMARY_TOKEN] || PRIMARY_FALLBACK; const chart2Color = colors[CHART2_TOKEN] || CHART2_FALLBACK; /* --- Script loading --------------------------------------------------- */ useEffect(() => { if (!isLoaded && apiKey && !loadError && load) { load(apiKey).catch(console.error); } }, [isLoaded, apiKey, loadError, load]); /* --- Map instance ----------------------------------------------------- */ useEffect(() => { if (!isLoaded || !gmpMapRef.current) return; const gmpMap = gmpMapRef.current; if (gmpMap.innerMap) { setMap(gmpMap.innerMap); onMapLoad?.(gmpMap.innerMap); return; } // The custom element upgrades asynchronously; poll until innerMap exists. const interval = setInterval(() => { if (gmpMap.innerMap) { setMap(gmpMap.innerMap); onMapLoad?.(gmpMap.innerMap); clearInterval(interval); } }, 100); return () => clearInterval(interval); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoaded]); useEffect(() => { if (gmpMapRef.current && center) gmpMapRef.current.center = center; }, [center]); useEffect(() => { if (gmpMapRef.current && zoom !== undefined) gmpMapRef.current.zoom = zoom; }, [zoom]); /* * Control options are applied to the inner map instance. They were accepted * as props from the start but never reached the map — `gmp-map` exposes no * attribute for them, so they have to go through `setOptions` once the * instance exists. */ useEffect(() => { if (!map || typeof map.setOptions !== 'function') return; map.setOptions({ disableDefaultUI, zoomControl, streetViewControl, mapTypeControl, fullscreenControl, gestureHandling, }); }, [ map, disableDefaultUI, zoomControl, streetViewControl, mapTypeControl, fullscreenControl, gestureHandling, ]); useEffect(() => { if (!props.mapId) warnAboutDefaultMapId(); }, [props.mapId]); /* --- Framing ---------------------------------------------------------- */ /* * Clustering and the privacy floor run before rendering, so everything * downstream — fitting, the marker list, selection — sees the same set the * user sees. A point that was aggregated away must not reappear in a fit. */ /* * The time window is applied before clustering, so a cluster's badge counts * only the records actually inside the window — aggregating first and * filtering after would report totals the user cannot see. */ const timeFiltered = useMemo(() => { if (!timeField || !timeWindow) return markers; const [from, to] = timeWindow; return markers.filter(marker => { const raw = (marker.data as Record | undefined)?.[timeField]; const stamp = typeof raw === 'number' ? raw : Number(raw); // A record with no usable timestamp stays visible rather than vanishing // for a reason the user cannot see. if (!Number.isFinite(stamp)) return true; return stamp >= from && stamp <= to; }); }, [markers, timeField, timeWindow]); const clustered = useMemo( () => clusterMarkers(timeFiltered, cluster, minAggregation), [timeFiltered, cluster, minAggregation] ); const layerVisibility = useMemo(() => resolveLayerVisibility(dataLayers), [dataLayers]); const visibleMarkers = useMemo( () => clustered .map(entry => entry.marker) .filter(m => isLayerVisible(m.layerId, layerVisibility)), [clustered, layerVisibility] ); const markerPositions = useMemo( () => visibleMarkers.map(marker => marker.position), [visibleMarkers] ); const shapePositions = useMemo(() => { const points: LatLng[] = []; if (circle?.center) points.push(circle.center); polygon?.paths?.forEach(path => path.forEach(point => points.push(point))); circles?.forEach(shape => points.push(shape.center)); polygons?.forEach(shape => { const paths = (Array.isArray(shape.paths[0]) ? shape.paths : [shape.paths]) as LatLng[][]; paths.forEach(path => path.forEach(point => points.push(point))); }); return points; }, [circle, polygon, circles, polygons]); useMapFit({ map, bounds, fitTo, fitPadding, maxZoomOnFit, markerPositions, shapePositions, views, activeViewId, }); /* --- Geometry --------------------------------------------------------- */ const circleColor = circleColorToken ? colors[circleColorToken] : undefined; useEffect(() => { if (!map || !isLoaded) return; circleRef.current?.setMap(null); circleRef.current = null; if (circle && circle.center && circle.radius) { circleRef.current = new google.maps.Circle({ map, center: circle.center, radius: circle.radius, fillColor: circleColor || circle.fillColor || primaryColor, fillOpacity: 0.2, strokeColor: circleColor || circle.strokeColor || primaryColor, strokeOpacity: 0.8, strokeWeight: 2, }); } return () => { circleRef.current?.setMap(null); }; }, [circle, isLoaded, map, primaryColor, circleColor]); const polygonColor = polygonColorToken ? colors[polygonColorToken] : undefined; useEffect(() => { if (!map || !isLoaded) return; polygonRef.current?.setMap(null); polygonRef.current = null; if (polygon && polygon.paths) { polygonRef.current = new google.maps.Polygon({ map, paths: polygon.paths, fillColor: polygonColor || polygon.fillColor || chart2Color, fillOpacity: 0.2, strokeColor: polygonColor || polygon.strokeColor || chart2Color, strokeOpacity: 0.8, strokeWeight: 2, }); } return () => { polygonRef.current?.setMap(null); }; }, [polygon, isLoaded, map, chart2Color, polygonColor]); /* --- Plural geometry, choropleth and heat ------------------------------- */ useMapGeometry({ map, isLoaded, polygons, circles, sectors, polylines, colors, defaultPolygonColor: chart2Color, defaultCircleColor: primaryColor, dataLayers, layerVisibility, onSelect: (id, feature) => onFeatureSelect?.(id, feature), onHover: (id, feature) => onFeatureHover?.(id, feature), }); const choroplethBands = useMapChoropleth({ map, isLoaded, geojson, choropleth, colors, hoverColor: colors[RING_TOKEN] || primaryColor, onSelect: (id, feature) => onFeatureSelect?.(id, feature), onHover: (id, feature) => onFeatureHover?.(id, feature), }); useMapHeatmap({ map, isLoaded, heatmap, colors }); useMapDrawing({ map, isLoaded, drawing, colors, defaultColor: primaryColor }); // Surfaced through the callback so the consumer can feed MapLegend with the // exact classes that painted the map, rather than recomputing them. const onChoroplethBandsRef = useRef(props.onChoroplethBands); onChoroplethBandsRef.current = props.onChoroplethBands; useEffect(() => { onChoroplethBandsRef.current?.(choroplethBands); }, [choroplethBands]); // Now receives a real instance instead of `mapRef.current`, which was null // on the first render and never triggered a re-run. useMapLayers(map, layers || {}); /* --- Selection -------------------------------------------------------- */ const { selectionEnabled, selectedIndex, selectFeature, hoverFeature } = useMapSelection({ markers: visibleMarkers, selectedId, onFeatureSelect, onFeatureHover, }); const onMapClickRef = useRef(onMapClick); onMapClickRef.current = onMapClick; const onAnnotationRemoveRef = useRef(onAnnotationRemove); onAnnotationRemoveRef.current = onAnnotationRemove; const annotationModeRef = useRef(annotationMode); const onAnnotationAddRef = useRef(onAnnotationAdd); annotationModeRef.current = annotationMode; onAnnotationAddRef.current = onAnnotationAdd; /** Background clicks clear the selection — the standard "click away" gesture. */ useEffect(() => { if (!map || typeof google === 'undefined') return; const listener = map.addListener('click', (event: google.maps.MapMouseEvent) => { const position = event.latLng ? { lat: event.latLng.lat(), lng: event.latLng.lng() } : undefined; if (position) { onMapClickRef.current?.(position); if (annotationModeRef.current) { onAnnotationAddRef.current?.(position); return; } } selectFeature(null); }); return () => listener.remove(); }, [map, selectFeature]); const closeInfoWindow = useCallback(() => { infoWindowRef.current?.close(); // Unmount on a later task: React forbids unmounting a root while it is // rendering, which is exactly where a close-click handler runs. const active = infoRootRef.current; if (active) { infoRootRef.current = null; setTimeout(() => active.root.unmount(), 0); } }, []); useEffect(() => closeInfoWindow, [closeInfoWindow]); const handleMarkerActivate = useCallback( (index: number, markerElement: HTMLElement & { innerMarker?: unknown }) => { const markerData = visibleMarkers[index]; if (!markerData) return; selectFeature(index); if (markerData.popup) { setOpenPopupId(markerData.id ?? String(index)); return; } if (!map) return; let contentToRender = markerData.richContent; if (!contentToRender && (markerData.title || markerData.info)) { contentToRender = (
{markerData.title && (

{markerData.title}

)} {markerData.info && (

{markerData.info}

)} {markerData.precisionLabel && (

{markerData.precisionLabel}

)}
); } if (!contentToRender || !markerElement.innerMarker) return; // One InfoWindow and one React root for the whole map, reused across // clicks. The previous implementation created a fresh root per click and // only ever unmounted the one the user explicitly closed. closeInfoWindow(); if (!infoWindowRef.current) { infoWindowRef.current = new google.maps.InfoWindow(); } const container = document.createElement('div'); const root = createRoot(container); root.render(contentToRender); infoRootRef.current = { root, container }; infoWindowRef.current.setContent(container); infoWindowRef.current.open({ map, anchor: markerElement.innerMarker as google.maps.marker.AdvancedMarkerElement, }); google.maps.event.addListenerOnce(infoWindowRef.current, 'closeclick', () => { selectFeature(null); closeInfoWindow(); }); }, [visibleMarkers, map, closeInfoWindow, selectFeature] ); /* --- Popups and the imperative handle ----------------------------------- */ const [openPopupId, setOpenPopupId] = useState(null); const openPopup = useCallback( (featureId: string) => { const found = visibleMarkers.find( (marker, index) => (marker.id ?? String(index)) === featureId ); if (found?.popup) setOpenPopupId(featureId); }, [visibleMarkers] ); const closePopup = useCallback(() => setOpenPopupId(null), []); useImperativeHandle( controlRef, () => ({ openPopup, closePopup, getOpenPopupId: () => openPopupId, }), [openPopup, closePopup, openPopupId] ); const openPopupMarker = useMemo( () => visibleMarkers.find((marker, index) => (marker.id ?? String(index)) === openPopupId) ?? null, [visibleMarkers, openPopupId] ); /* * The popup delegates clicks rather than cloning its content: a consumer can * put any tree inside it, and anything carrying `data-map-action` reports * back through `onPopupAction`. Without a way back, a popup is decorative — * one application scrolls a table to the matching row from here, another * opens a modal. */ const handlePopupClick = useCallback( (event: React.MouseEvent) => { const target = (event.target as HTMLElement).closest('[data-map-action]'); const action = target?.getAttribute('data-map-action'); if (action && openPopupId) onPopupAction?.(openPopupId, action); }, [onPopupAction, openPopupId] ); /* --- Caption collision --------------------------------------------------- */ const [surface, setSurface] = useState(null); /* --- Basemap and scale --------------------------------------------------- */ const basemapStyle = useMemo( () => resolveBasemapStyle(basemap, basemapStyles), [basemap, basemapStyles] ); /* * One writer for `mapTypeId`. `basemap` wins over the raw `mapTypeId` prop: * picking "satellite" in the basemap control is a later, more specific * choice than the initial map type. Two effects used to write this field and * raced on every render. */ useEffect(() => { if (!gmpMapRef.current) return; const nativeType = basemapStyle.mapTypeId ?? mapTypeId; if (nativeType) gmpMapRef.current.mapTypeId = nativeType; }, [basemapStyle.mapTypeId, mapTypeId]); const scaleBarConfig = useMemo(() => (scaleBar === true ? {} : scaleBar || null), [scaleBar]); const [metersPerPixel, setMetersPerPixel] = useState(0); useEffect(() => { if (!map || !scaleBarConfig || typeof google === 'undefined') return; if (typeof map.getZoom !== 'function' || typeof map.getCenter !== 'function') return; const recompute = () => { const zoomLevel = map.getZoom(); const centre = map.getCenter(); if (typeof zoomLevel !== 'number' || !centre) return; // Web Mercator ground resolution at this latitude and zoom. setMetersPerPixel( (156543.03392 * Math.cos((centre.lat() * Math.PI) / 180)) / 2 ** zoomLevel ); }; recompute(); const listener = map.addListener('idle', recompute); return () => listener.remove(); }, [map, scaleBarConfig]); useCaptionCollision(surface, captionCollision === 'hide', [visibleMarkers, zoom], map); /* --- Render ----------------------------------------------------------- */ if (loadError) { return ( } tone="destructive" /> ); } if (!isLoaded) { return ; } return (
{ setSurface(node); if (typeof ref === 'function') ref(node); else if (ref) (ref as React.MutableRefObject).current = node; }} className={cn( 'relative rounded-[var(--radius-card)] border border-border overflow-hidden', className )} style={{ height }} {...divProps} > {visibleMarkers.map((markerData, index) => { const token = markerData.colorToken; const color = token ? colors[token] || primaryColor : markerData.customColor || primaryColor; return ( ); })} {annotations?.map((annotation, index) => ( onAnnotationRemoveRef.current?.(annotation.id)} onHoverChange={() => {}} /> ))} {deviceLocation ? ( {}} onHoverChange={() => {}} /> ) : null} {openPopupMarker?.popup ? (
{openPopupMarker.popup.content}
) : null} {scaleBarConfig && metersPerPixel > 0 ? ( ) : null} {attribution ? (
{attribution}
) : null} {/* Consumer overlays last, so they sit above the built-in chrome. */} {children}
); } ); MapContent.displayName = 'MapContent'; /** * Renders the data layer without a Google basemap. * * @description * Shares the selection hook and the token resolver with `MapContent`, so a * marker looks and behaves the same in both — same proportional size, same * tooltip, same keyboard activation, same accessible name. What is missing is * the tile imagery, and the notice says so rather than letting a bare scatter * of points pass for a map. */ const MapStaticView = React.forwardRef< HTMLDivElement, MapProps & { reason: 'requested' | 'unavailable' } >((props, ref) => { const { markers = [], baseGeojson, height = '400px', fitPadding = 34, selectedId, onFeatureSelect, onFeatureHover, ariaLabel = 'Map', attribution, noBasemapLabel = 'Base cartográfica indisponível', className, reason, } = props; const tokens = useMemo(() => { const set = new Set([PRIMARY_TOKEN, MUTED_FOREGROUND_TOKEN]); markers.forEach(marker => marker.colorToken && set.add(marker.colorToken)); return Array.from(set); // eslint-disable-next-line react-hooks/exhaustive-deps }, [markers.map(m => m.colorToken ?? '').join('|')]); const colors = useMapTokenColors(tokens, PRIMARY_FALLBACK); const { selectionEnabled, selectedIndex, selectFeature, hoverFeature } = useMapSelection({ markers, selectedId, onFeatureSelect, onFeatureHover, }); return (
{reason === 'requested' ? noBasemapLabel : `${noBasemapLabel} — dados preservados`} {attribution ? (
{attribution}
) : null}
); }); MapStaticView.displayName = 'MapStaticView'; /** * Primary Google Maps component. * * @description * Supports Advanced Markers with proportional symbology, per-feature selection * and hover, theme-aware colors through semantic tokens, content-driven framing, * and explicit empty/error/denied container states. Automatically loads the * Google Maps JavaScript API via `useGoogleMapsLoader`. * * @ai-rules * 1. REQUIRED: provide a valid `apiKey` via prop, ``, or storage. * 2. Color new surfaces with `colorToken` (e.g. `'--chart-1'`), never a hex literal — a token * follows the theme, a literal does not. * 3. Give every marker an `id` when using `selectedId` / `onFeatureSelect`. * 4. Use `shape: 'circle'` with `scale` to encode a quantity, and pair it with a * `` so the sizes are readable. * 5. Always set `height` explicitly (default is 400px) so the map is visible. */ export const Map = React.forwardRef((props, ref) => { const { isLoaded, loadError } = useGoogleMapsLoader(); const apiKeyContext = useOptionalApiKey(); // Nota: import.meta.env.VITE_GOOGLE_MAPS_API_KEY não pode ser lido aqui — // a lib é pré-buildada com Vite e esse valor já é inlinado (undefined) no // build da própria xertica-ui, não no build do app consumidor. O fallback // precisa vir de um contexto resolvido em runtime no bundle do consumidor. const effectiveApiKey = props.apiKey || apiKeyContext?.googleMapsApiKey || ''; const { height = '400px', loading, isEmpty, emptyState, errorState, deniedState, emptyTitle = 'No results for the current filters', emptyHint = 'Adjust the filters to see data on the map', missingKeyTitle = 'Configure Google Maps API Key in Settings', basemap, basemapFallback = true, className, } = props; /* * Container-state precedence, most specific first. Authorization outranks * everything (a blank map is not an acceptable way to say "you may not see * this"), then a data error, then loading, then an empty result — which is a * normal outcome and must never be styled as a failure. */ if (deniedState) { return ( {deniedState} ); } if (errorState) { return ( {errorState} ); } if (loading) { return ; } if (isEmpty) { return ( {emptyState ?? ( } /> )} ); } if (basemap === 'none') { return ; } const isValidKey = effectiveApiKey && effectiveApiKey !== 'YOUR_GOOGLE_MAPS_API_KEY_HERE' && effectiveApiKey.startsWith('AIza'); /* * A load failure is the case the fallback exists for: an exhausted quota, a * lapsed billing account, or a network that blocks maps.googleapis.com. Losing * the tiles should not also lose the data, so the points are kept and the * notice explains what is missing. Opt out with `basemapFallback={false}` to * get the previous error frame instead. */ if (loadError && basemapFallback) { return ; } if (isLoaded || isValidKey || loadError) { return ; } // Check if the script is injected in the DOM (loading via provider) const isScriptInjected = typeof document !== 'undefined' && !!document.querySelector('script[src*="maps.googleapis.com/maps/api/js"]'); if (isScriptInjected) { // Let MapContent show the loading skeleton return ; } if (basemapFallback) { return ; } return (

{missingKeyTitle}

); }); Map.displayName = 'Map'; export type { MapMarkerData } from './map-marker';