import React, { useCallback, useEffect, useRef, useState } from 'react'; import DOMPurify from 'dompurify'; import { cn } from '../../shared/utils'; import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip'; import { DEFAULT_MARKER_RADIUS_PX, resolveScale } from './map-scale'; import { withAlpha } from './use-map-token-color'; import type { ColorToken, LatLng, MapPrecision, MapScale } from './map.types'; /** Shape of the rendered mark. `'pin'` reproduces the classic teardrop. */ export type MapMarkerShape = 'pin' | 'circle' | 'square'; export type MapTooltipDirection = 'top' | 'right' | 'bottom' | 'left'; export interface MapMarkerData { position: LatLng; /* --- Established API. None of these change behavior. --------------------- */ /** Single glyph printed *inside* the pin. Not a cartographic caption. */ label?: string; title?: string; info?: string; /** Literal color. Prefer `colorToken` so the mark follows the theme. */ customColor?: string; icon?: string; iconSvg?: string; iconColor?: string; /** Custom HTML for the InfoWindow. */ infoWindowContent?: string; /** Custom React content for the InfoWindow. */ richContent?: React.ReactNode; /* --- Added in the cartographic layer ------------------------------------- */ /** Stable identity. Required for selection and hover events. */ id?: string; /** @default 'pin' */ shape?: MapMarkerShape; /** Fixed radius in pixels for `shape: 'circle' | 'square'`. */ radiusPx?: number; /** Derives the radius from a data value — proportional symbology. */ scale?: MapScale; /** Semantic color token, e.g. `'--chart-1'`. Wins over `customColor`. */ colorToken?: ColorToken; /** Fill translucency for non-pin shapes. @default 0.85 */ fillOpacity?: number; /** Instant label on hover and on keyboard focus. Does not open the InfoWindow. */ tooltip?: React.ReactNode; /** @default 'top' */ tooltipDirection?: MapTooltipDirection; /** Overrides the generated accessible name. */ ariaLabel?: string; /** How this position was obtained. Surfaced to the user, never inferred. */ precision?: MapPrecision; /** Human-readable explanation of `precision`. */ precisionLabel?: string; /** Arbitrary payload echoed back on selection and hover events. */ data?: unknown; /* --- Aggregation, captions and popups ------------------------------------ */ /** Count shown on the marker when it stands for several records. */ badge?: number; /** Accessible description of the badge, e.g. `"12 unidades"`. */ badgeLabel?: string; /** * Cartographic caption drawn *beside* the marker. * * Distinct from `label`, which is the glyph printed *inside* the pin — that * field already exists with that meaning and could not be repurposed. */ caption?: string; /** @default 'none' */ captionMode?: 'permanent' | 'hover' | 'none'; /** @default 'right' */ captionDirection?: 'top' | 'right' | 'bottom' | 'left'; /** Rich content opened on activation, with a way back to the parent. */ popup?: MapMarkerPopup; /** Ties the marker to a `dataLayers` entry. */ layerId?: string; } export interface MapMarkerPopup { content: React.ReactNode; /** Width in pixels. @default 320 */ width?: number; } /** Minimal surface of the `gmp-advanced-marker` custom element we depend on. */ type AdvancedMarkerElement = HTMLElement & { position?: LatLng; innerMarker?: unknown; }; export interface MapMarkerProps { marker: MapMarkerData; index: number; /** Already resolved to a literal — markers never resolve tokens themselves. */ color: string; iconColor: string; selected: boolean; /** True when the map has selection wired up at all. */ selectable: boolean; onActivate: (index: number, element: AdvancedMarkerElement) => void; onHoverChange: (index: number, hovering: boolean) => void; } export interface MapMarkerContentProps { marker: MapMarkerData; index: number; color: string; iconColor: string; selected: boolean; selectable: boolean; onActivate: () => void; onHoverChange: (hovering: boolean) => void; } const DEFAULT_FILL_OPACITY = 0.85; const CAPTION_POSITION: Record<'top' | 'right' | 'bottom' | 'left', string> = { top: 'bottom-full left-1/2 mb-1 -translate-x-1/2', right: 'left-full top-1/2 ml-1 -translate-y-1/2', bottom: 'top-full left-1/2 mt-1 -translate-x-1/2', left: 'right-full top-1/2 mr-1 -translate-y-1/2', }; function resolveRadius(marker: MapMarkerData): number { if (marker.scale) return resolveScale(marker.scale); if (typeof marker.radiusPx === 'number') return marker.radiusPx; return DEFAULT_MARKER_RADIUS_PX; } function accessibleName(marker: MapMarkerData, index: number): string { return marker.ariaLabel ?? marker.title ?? marker.label ?? `Map marker ${index + 1}`; } /** * One marker on the map: its shape, its color, its interaction and its label. * * @description * Extracted into its own component so each marker owns a real `useEffect` with a * cleanup — the previous inline `ref` callback added a `gmp-click` listener on * every render and never removed any of them. * * Interaction is handled on the React content element rather than only on the * custom element, so clicking, focusing and keyboard activation behave the same * whether or not Google has upgraded `gmp-advanced-marker` yet. */ export function MapMarker({ marker, index, color, iconColor, selected, selectable, onActivate, onHoverChange, }: MapMarkerProps) { const [element, setElement] = useState(null); // Must be stable: an inline arrow would change identity on every render, // making React call ref(null) then ref(el) each time and loop the state. const captureElement = useCallback((el: AdvancedMarkerElement | null) => setElement(el), []); // Keeping the callbacks in refs means the listener effect below subscribes // once per element instead of resubscribing on every parent render. const onActivateRef = useRef(onActivate); const onHoverChangeRef = useRef(onHoverChange); onActivateRef.current = onActivate; onHoverChangeRef.current = onHoverChange; // Guards against a single user gesture being counted twice when Google's // `gmp-click` and React's `onClick` both fire for the same interaction. const lastActivationRef = useRef(0); const activate = useCallback(() => { const now = Date.now(); if (now - lastActivationRef.current < 50) return; lastActivationRef.current = now; if (element) onActivateRef.current(index, element); }, [element, index]); // `position` is a property on the custom element, not an attribute, so it // cannot be set through JSX. useEffect(() => { if (element) element.position = marker.position; // Depending on the coordinates rather than the object keeps a new-but-equal // position literal from re-assigning on every parent render. // eslint-disable-next-line react-hooks/exhaustive-deps }, [element, marker.position.lat, marker.position.lng]); useEffect(() => { if (!element) return; const handler = () => activate(); element.addEventListener('gmp-click', handler); return () => { element.removeEventListener('gmp-click', handler); }; }, [element, activate]); return ( onHoverChangeRef.current(index, hovering)} /> ); } /** * The marker itself: shape, color, interaction, accessibility and tooltip. * * @description * Deliberately free of any `google.maps` reference. `MapMarker` hosts it inside * `gmp-advanced-marker`, and the keyless renderer hosts it inside a plain * positioned div — same pixels, same keyboard behavior, same ARIA, whether or * not the Maps script ever loads. */ export function MapMarkerContent({ marker, index, color, iconColor, selected, selectable, onActivate, onHoverChange, }: MapMarkerContentProps) { const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); onActivate(); }; const shape = marker.shape ?? 'pin'; const name = accessibleName(marker, index); const interaction = { role: 'button' as const, tabIndex: 0, 'aria-label': name, ...(selectable ? { 'aria-pressed': selected } : {}), onClick: onActivate, onKeyDown: handleKeyDown, onMouseEnter: () => onHoverChange(true), onMouseLeave: () => onHoverChange(false), onFocus: () => onHoverChange(true), onBlur: () => onHoverChange(false), }; // `motion-reduce` disables the hover/selection transform for users who ask // their system for reduced motion. const focusRing = 'outline-none focus-visible:ring-[3px] focus-visible:ring-ring/70 focus-visible:ring-offset-1 focus-visible:ring-offset-background'; const selectedRing = selected ? 'ring-[3px] ring-ring/70 ring-offset-1 ring-offset-background' : ''; let content: React.ReactNode; if (shape === 'pin') { // Unchanged from the original render so existing maps look identical. content = (
{marker.iconSvg ? (
) : ( {marker.icon || marker.label || ''} )}
); } else { const radius = resolveRadius(marker); const size = Math.max(2, radius * 2); const fill = withAlpha(color, marker.fillOpacity ?? DEFAULT_FILL_OPACITY); content = (
{marker.label && size >= 20 ? ( {marker.label} ) : null}
); } if (marker.tooltip) { content = ( {content} {marker.tooltip} ); } const captionMode = marker.captionMode ?? (marker.caption ? 'permanent' : 'none'); const hasCaption = Boolean(marker.caption) && captionMode !== 'none'; if (marker.badge !== undefined || hasCaption) { content = ( {content} {marker.badge !== undefined ? ( {marker.badge} ) : null} {hasCaption ? ( ) : null} ); } return <>{content}; }