"use client"; import MapLibreGL, { type PopupOptions, type MarkerOptions } from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import { createContext, forwardRef, useCallback, useContext, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { X, Minus, Plus, Locate, Maximize, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; const defaultStyles = { dark: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", light: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", }; type Theme = "light" | "dark"; // Check document class for theme (works with next-themes, etc.) function getDocumentTheme(): Theme | null { if (typeof document === "undefined") return null; if (document.documentElement.classList.contains("dark")) return "dark"; if (document.documentElement.classList.contains("light")) return "light"; return null; } // Get system preference function getSystemTheme(): Theme { if (typeof window === "undefined") return "light"; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } function useResolvedTheme(themeProp?: "light" | "dark"): Theme { const [detectedTheme, setDetectedTheme] = useState( () => getDocumentTheme() ?? getSystemTheme(), ); useEffect(() => { if (themeProp) return; // Skip detection if theme is provided via prop // Watch for document class changes (e.g., next-themes toggling dark class) const observer = new MutationObserver(() => { const docTheme = getDocumentTheme(); if (docTheme) { setDetectedTheme(docTheme); } }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"], }); // Also watch for system preference changes const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleSystemChange = (e: MediaQueryListEvent) => { // Only use system preference if no document class is set if (!getDocumentTheme()) { setDetectedTheme(e.matches ? "dark" : "light"); } }; mediaQuery.addEventListener("change", handleSystemChange); return () => { observer.disconnect(); mediaQuery.removeEventListener("change", handleSystemChange); }; }, [themeProp]); return themeProp ?? detectedTheme; } type MapContextValue = { map: MapLibreGL.Map | null; isLoaded: boolean; }; const MapContext = createContext(null); function useMap() { const context = useContext(MapContext); if (!context) { throw new Error("useMap must be used within a Map component"); } return context; } /** Map viewport state */ type MapViewport = { /** Center coordinates [longitude, latitude] */ center: [number, number]; /** Zoom level */ zoom: number; /** Bearing (rotation) in degrees */ bearing: number; /** Pitch (tilt) in degrees */ pitch: number; }; type MapStyleOption = string | MapLibreGL.StyleSpecification; type MapRef = MapLibreGL.Map; type MapProps = { children?: ReactNode; /** Additional CSS classes for the map container */ className?: string; /** * Theme for the map. If not provided, automatically detects system preference. * Pass your theme value here. */ theme?: Theme; /** Custom map styles for light and dark themes. Overrides the default Carto styles. */ styles?: { light?: MapStyleOption; dark?: MapStyleOption; }; /** Map projection type. Use `{ type: "globe" }` for 3D globe view. */ projection?: MapLibreGL.ProjectionSpecification; /** * Controlled viewport. When provided with onViewportChange, * the map becomes controlled and viewport is driven by this prop. */ viewport?: Partial; /** * Callback fired continuously as the viewport changes (pan, zoom, rotate, pitch). * Can be used standalone to observe changes, or with `viewport` prop * to enable controlled mode where the map viewport is driven by your state. */ onViewportChange?: (viewport: MapViewport) => void; /** Show a loading indicator on the map */ loading?: boolean; } & Omit; function DefaultLoader() { return (
); } function getViewport(map: MapLibreGL.Map): MapViewport { const center = map.getCenter(); return { center: [center.lng, center.lat], zoom: map.getZoom(), bearing: map.getBearing(), pitch: map.getPitch(), }; } const Map = forwardRef(function Map( { children, className, theme: themeProp, styles, projection, viewport, onViewportChange, loading = false, ...props }, ref, ) { const containerRef = useRef(null); const [mapInstance, setMapInstance] = useState(null); const [isLoaded, setIsLoaded] = useState(false); const [isStyleLoaded, setIsStyleLoaded] = useState(false); const currentStyleRef = useRef(null); const styleTimeoutRef = useRef | null>(null); const internalUpdateRef = useRef(false); const resolvedTheme = useResolvedTheme(themeProp); const isControlled = viewport !== undefined && onViewportChange !== undefined; const onViewportChangeRef = useRef(onViewportChange); onViewportChangeRef.current = onViewportChange; const mapStyles = useMemo( () => ({ dark: styles?.dark ?? defaultStyles.dark, light: styles?.light ?? defaultStyles.light, }), [styles], ); // Expose the map instance to the parent component useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]); const clearStyleTimeout = useCallback(() => { if (styleTimeoutRef.current) { clearTimeout(styleTimeoutRef.current); styleTimeoutRef.current = null; } }, []); // Initialize the map useEffect(() => { if (!containerRef.current) return; const initialStyle = resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light; currentStyleRef.current = initialStyle; const map = new MapLibreGL.Map({ container: containerRef.current, style: initialStyle, renderWorldCopies: false, attributionControl: { compact: true, }, ...props, ...viewport, }); const styleDataHandler = () => { clearStyleTimeout(); // Delay to ensure style is fully processed before allowing layer operations // This is a workaround to avoid race conditions with the style loading // else we have to force update every layer on setStyle change styleTimeoutRef.current = setTimeout(() => { setIsStyleLoaded(true); if (projection) { map.setProjection(projection); } }, 100); }; const loadHandler = () => setIsLoaded(true); // Viewport change handler - skip if triggered by internal update const handleMove = () => { if (internalUpdateRef.current) return; onViewportChangeRef.current?.(getViewport(map)); }; map.on("load", loadHandler); map.on("styledata", styleDataHandler); map.on("move", handleMove); setMapInstance(map); return () => { clearStyleTimeout(); map.off("load", loadHandler); map.off("styledata", styleDataHandler); map.off("move", handleMove); map.remove(); setIsLoaded(false); setIsStyleLoaded(false); setMapInstance(null); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Sync controlled viewport to map useEffect(() => { if (!mapInstance || !isControlled || !viewport) return; if (mapInstance.isMoving()) return; const current = getViewport(mapInstance); const next = { center: viewport.center ?? current.center, zoom: viewport.zoom ?? current.zoom, bearing: viewport.bearing ?? current.bearing, pitch: viewport.pitch ?? current.pitch, }; if ( next.center[0] === current.center[0] && next.center[1] === current.center[1] && next.zoom === current.zoom && next.bearing === current.bearing && next.pitch === current.pitch ) { return; } internalUpdateRef.current = true; mapInstance.jumpTo(next); internalUpdateRef.current = false; }, [mapInstance, isControlled, viewport]); // Handle style change useEffect(() => { if (!mapInstance || !resolvedTheme) return; const newStyle = resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light; if (currentStyleRef.current === newStyle) return; clearStyleTimeout(); currentStyleRef.current = newStyle; setIsStyleLoaded(false); mapInstance.setStyle(newStyle, { diff: true }); }, [mapInstance, resolvedTheme, mapStyles, clearStyleTimeout]); const contextValue = useMemo( () => ({ map: mapInstance, isLoaded: isLoaded && isStyleLoaded, }), [mapInstance, isLoaded, isStyleLoaded], ); return (
{(!isLoaded || loading) && } {/* SSR-safe: children render only when map is loaded on client */} {mapInstance && children}
); }); type MarkerContextValue = { marker: MapLibreGL.Marker; map: MapLibreGL.Map | null; }; const MarkerContext = createContext(null); function useMarkerContext() { const context = useContext(MarkerContext); if (!context) { throw new Error("Marker components must be used within MapMarker"); } return context; } type MapMarkerProps = { /** Longitude coordinate for marker position */ longitude: number; /** Latitude coordinate for marker position */ latitude: number; /** Marker subcomponents (MarkerContent, MarkerPopup, MarkerTooltip, MarkerLabel) */ children: ReactNode; /** Callback when marker is clicked */ onClick?: (e: MouseEvent) => void; /** Callback when mouse enters marker */ onMouseEnter?: (e: MouseEvent) => void; /** Callback when mouse leaves marker */ onMouseLeave?: (e: MouseEvent) => void; /** Callback when marker drag starts (requires draggable: true) */ onDragStart?: (lngLat: { lng: number; lat: number }) => void; /** Callback during marker drag (requires draggable: true) */ onDrag?: (lngLat: { lng: number; lat: number }) => void; /** Callback when marker drag ends (requires draggable: true) */ onDragEnd?: (lngLat: { lng: number; lat: number }) => void; } & Omit; function MapMarker({ longitude, latitude, children, onClick, onMouseEnter, onMouseLeave, onDragStart, onDrag, onDragEnd, draggable = false, ...markerOptions }: MapMarkerProps) { const { map } = useMap(); const callbacksRef = useRef({ onClick, onMouseEnter, onMouseLeave, onDragStart, onDrag, onDragEnd, }); callbacksRef.current = { onClick, onMouseEnter, onMouseLeave, onDragStart, onDrag, onDragEnd, }; const marker = useMemo(() => { const markerInstance = new MapLibreGL.Marker({ ...markerOptions, element: document.createElement("div"), draggable, }).setLngLat([longitude, latitude]); const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e); const handleMouseEnter = (e: MouseEvent) => callbacksRef.current.onMouseEnter?.(e); const handleMouseLeave = (e: MouseEvent) => callbacksRef.current.onMouseLeave?.(e); markerInstance.getElement()?.addEventListener("click", handleClick); markerInstance .getElement() ?.addEventListener("mouseenter", handleMouseEnter); markerInstance .getElement() ?.addEventListener("mouseleave", handleMouseLeave); const handleDragStart = () => { const lngLat = markerInstance.getLngLat(); callbacksRef.current.onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat }); }; const handleDrag = () => { const lngLat = markerInstance.getLngLat(); callbacksRef.current.onDrag?.({ lng: lngLat.lng, lat: lngLat.lat }); }; const handleDragEnd = () => { const lngLat = markerInstance.getLngLat(); callbacksRef.current.onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat }); }; markerInstance.on("dragstart", handleDragStart); markerInstance.on("drag", handleDrag); markerInstance.on("dragend", handleDragEnd); return markerInstance; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (!map) return; marker.addTo(map); return () => { marker.remove(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [map]); if ( marker.getLngLat().lng !== longitude || marker.getLngLat().lat !== latitude ) { marker.setLngLat([longitude, latitude]); } if (marker.isDraggable() !== draggable) { marker.setDraggable(draggable); } const currentOffset = marker.getOffset(); const newOffset = markerOptions.offset ?? [0, 0]; const [newOffsetX, newOffsetY] = Array.isArray(newOffset) ? newOffset : [newOffset.x, newOffset.y]; if (currentOffset.x !== newOffsetX || currentOffset.y !== newOffsetY) { marker.setOffset(newOffset); } if (marker.getRotation() !== markerOptions.rotation) { marker.setRotation(markerOptions.rotation ?? 0); } if (marker.getRotationAlignment() !== markerOptions.rotationAlignment) { marker.setRotationAlignment(markerOptions.rotationAlignment ?? "auto"); } if (marker.getPitchAlignment() !== markerOptions.pitchAlignment) { marker.setPitchAlignment(markerOptions.pitchAlignment ?? "auto"); } return ( {children} ); } type MarkerContentProps = { /** Custom marker content. Defaults to a blue dot if not provided */ children?: ReactNode; /** Additional CSS classes for the marker container */ className?: string; }; function MarkerContent({ children, className }: MarkerContentProps) { const { marker } = useMarkerContext(); return createPortal(
{children || }
, marker.getElement(), ); } function DefaultMarkerIcon() { return (
); } function PopupCloseButton({ onClick }: { onClick: () => void }) { return ( ); } type MarkerPopupProps = { /** Popup content */ children: ReactNode; /** Additional CSS classes for the popup container */ className?: string; /** Show a close button in the popup (default: false) */ closeButton?: boolean; } & Omit; function MarkerPopup({ children, className, closeButton = false, ...popupOptions }: MarkerPopupProps) { const { marker, map } = useMarkerContext(); const container = useMemo(() => document.createElement("div"), []); const prevPopupOptions = useRef(popupOptions); const popup = useMemo(() => { const popupInstance = new MapLibreGL.Popup({ offset: 16, ...popupOptions, closeButton: false, }) .setMaxWidth("none") .setDOMContent(container); return popupInstance; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (!map) return; popup.setDOMContent(container); marker.setPopup(popup); return () => { marker.setPopup(null); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [map]); if (popup.isOpen()) { const prev = prevPopupOptions.current; if (prev.offset !== popupOptions.offset) { popup.setOffset(popupOptions.offset ?? 16); } if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { popup.setMaxWidth(popupOptions.maxWidth ?? "none"); } prevPopupOptions.current = popupOptions; } const handleClose = () => popup.remove(); return createPortal(
{closeButton && } {children}
, container, ); } type MarkerTooltipProps = { /** Tooltip content */ children: ReactNode; /** Additional CSS classes for the tooltip container */ className?: string; } & Omit; function MarkerTooltip({ children, className, ...popupOptions }: MarkerTooltipProps) { const { marker, map } = useMarkerContext(); const container = useMemo(() => document.createElement("div"), []); const prevTooltipOptions = useRef(popupOptions); const tooltip = useMemo(() => { const tooltipInstance = new MapLibreGL.Popup({ offset: 16, ...popupOptions, closeOnClick: true, closeButton: false, }).setMaxWidth("none"); return tooltipInstance; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (!map) return; tooltip.setDOMContent(container); const handleMouseEnter = () => { tooltip.setLngLat(marker.getLngLat()).addTo(map); }; const handleMouseLeave = () => tooltip.remove(); marker.getElement()?.addEventListener("mouseenter", handleMouseEnter); marker.getElement()?.addEventListener("mouseleave", handleMouseLeave); return () => { marker.getElement()?.removeEventListener("mouseenter", handleMouseEnter); marker.getElement()?.removeEventListener("mouseleave", handleMouseLeave); tooltip.remove(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [map]); if (tooltip.isOpen()) { const prev = prevTooltipOptions.current; if (prev.offset !== popupOptions.offset) { tooltip.setOffset(popupOptions.offset ?? 16); } if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { tooltip.setMaxWidth(popupOptions.maxWidth ?? "none"); } prevTooltipOptions.current = popupOptions; } return createPortal(
{children}
, container, ); } type MarkerLabelProps = { /** Label text content */ children: ReactNode; /** Additional CSS classes for the label */ className?: string; /** Position of the label relative to the marker (default: "top") */ position?: "top" | "bottom"; }; function MarkerLabel({ children, className, position = "top", }: MarkerLabelProps) { const positionClasses = { top: "bottom-full mb-1", bottom: "top-full mt-1", }; return (
{children}
); } type MapControlsProps = { /** Position of the controls on the map (default: "bottom-right") */ position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"; /** Show zoom in/out buttons (default: true) */ showZoom?: boolean; /** Show compass button to reset bearing (default: false) */ showCompass?: boolean; /** Show locate button to find user's location (default: false) */ showLocate?: boolean; /** Show fullscreen toggle button (default: false) */ showFullscreen?: boolean; /** Additional CSS classes for the controls container */ className?: string; /** Callback with user coordinates when located */ onLocate?: (coords: { longitude: number; latitude: number }) => void; }; const positionClasses = { "top-left": "top-2 left-2", "top-right": "top-2 right-2", "bottom-left": "bottom-2 left-2", "bottom-right": "bottom-10 right-2", }; function ControlGroup({ children }: { children: React.ReactNode }) { return (
{children}
); } function ControlButton({ onClick, label, children, disabled = false, }: { onClick: () => void; label: string; children: React.ReactNode; disabled?: boolean; }) { return ( ); } function MapControls({ position = "bottom-right", showZoom = true, showCompass = false, showLocate = false, showFullscreen = false, className, onLocate, }: MapControlsProps) { const { map } = useMap(); const [waitingForLocation, setWaitingForLocation] = useState(false); const handleZoomIn = useCallback(() => { map?.zoomTo(map.getZoom() + 1, { duration: 300 }); }, [map]); const handleZoomOut = useCallback(() => { map?.zoomTo(map.getZoom() - 1, { duration: 300 }); }, [map]); const handleResetBearing = useCallback(() => { map?.resetNorthPitch({ duration: 300 }); }, [map]); const handleLocate = useCallback(() => { setWaitingForLocation(true); if ("geolocation" in navigator) { navigator.geolocation.getCurrentPosition( (pos) => { const coords = { longitude: pos.coords.longitude, latitude: pos.coords.latitude, }; map?.flyTo({ center: [coords.longitude, coords.latitude], zoom: 14, duration: 1500, }); onLocate?.(coords); setWaitingForLocation(false); }, (error) => { console.error("Error getting location:", error); setWaitingForLocation(false); }, ); } }, [map, onLocate]); const handleFullscreen = useCallback(() => { const container = map?.getContainer(); if (!container) return; if (document.fullscreenElement) { document.exitFullscreen(); } else { container.requestFullscreen(); } }, [map]); return (
{showZoom && ( )} {showCompass && ( )} {showLocate && ( {waitingForLocation ? ( ) : ( )} )} {showFullscreen && ( )}
); } function CompassButton({ onClick }: { onClick: () => void }) { const { map } = useMap(); const compassRef = useRef(null); useEffect(() => { if (!map || !compassRef.current) return; const compass = compassRef.current; const updateRotation = () => { const bearing = map.getBearing(); const pitch = map.getPitch(); compass.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`; }; map.on("rotate", updateRotation); map.on("pitch", updateRotation); updateRotation(); return () => { map.off("rotate", updateRotation); map.off("pitch", updateRotation); }; }, [map]); return ( ); } type MapPopupProps = { /** Longitude coordinate for popup position */ longitude: number; /** Latitude coordinate for popup position */ latitude: number; /** Callback when popup is closed */ onClose?: () => void; /** Popup content */ children: ReactNode; /** Additional CSS classes for the popup container */ className?: string; /** Show a close button in the popup (default: false) */ closeButton?: boolean; } & Omit; function MapPopup({ longitude, latitude, onClose, children, className, closeButton = false, ...popupOptions }: MapPopupProps) { const { map } = useMap(); const popupOptionsRef = useRef(popupOptions); const onCloseRef = useRef(onClose); onCloseRef.current = onClose; const container = useMemo(() => document.createElement("div"), []); const popup = useMemo(() => { const popupInstance = new MapLibreGL.Popup({ offset: 16, ...popupOptions, closeButton: false, }) .setMaxWidth("none") .setLngLat([longitude, latitude]); return popupInstance; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (!map) return; const onCloseProp = () => onCloseRef.current?.(); popup.on("close", onCloseProp); popup.setDOMContent(container); popup.addTo(map); return () => { popup.off("close", onCloseProp); if (popup.isOpen()) { popup.remove(); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [map]); if (popup.isOpen()) { const prev = popupOptionsRef.current; if ( popup.getLngLat().lng !== longitude || popup.getLngLat().lat !== latitude ) { popup.setLngLat([longitude, latitude]); } if (prev.offset !== popupOptions.offset) { popup.setOffset(popupOptions.offset ?? 16); } if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { popup.setMaxWidth(popupOptions.maxWidth ?? "none"); } popupOptionsRef.current = popupOptions; } const handleClose = () => { popup.remove(); }; return createPortal(
{closeButton && } {children}
, container, ); } type MapRouteProps = { /** Optional unique identifier for the route layer */ id?: string; /** Array of [longitude, latitude] coordinate pairs defining the route */ coordinates: [number, number][]; /** Line color as CSS color value (default: "#4285F4") */ color?: string; /** Line width in pixels (default: 3) */ width?: number; /** Line opacity from 0 to 1 (default: 0.8) */ opacity?: number; /** Dash pattern [dash length, gap length] for dashed lines */ dashArray?: [number, number]; /** Callback when the route line is clicked */ onClick?: () => void; /** Callback when mouse enters the route line */ onMouseEnter?: () => void; /** Callback when mouse leaves the route line */ onMouseLeave?: () => void; /** Whether the route is interactive - shows pointer cursor on hover (default: true) */ interactive?: boolean; }; function MapRoute({ id: propId, coordinates, color = "#4285F4", width = 3, opacity = 0.8, dashArray, onClick, onMouseEnter, onMouseLeave, interactive = true, }: MapRouteProps) { const { map, isLoaded } = useMap(); const autoId = useId(); const id = propId ?? autoId; const sourceId = `route-source-${id}`; const layerId = `route-layer-${id}`; // Add source and layer on mount useEffect(() => { if (!isLoaded || !map) return; map.addSource(sourceId, { type: "geojson", data: { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: [] }, }, }); map.addLayer({ id: layerId, type: "line", source: sourceId, layout: { "line-join": "round", "line-cap": "round" }, paint: { "line-color": color, "line-width": width, "line-opacity": opacity, ...(dashArray && { "line-dasharray": dashArray }), }, }); return () => { try { if (map.getLayer(layerId)) map.removeLayer(layerId); if (map.getSource(sourceId)) map.removeSource(sourceId); } catch { // ignore } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoaded, map]); // When coordinates change, update the source data useEffect(() => { if (!isLoaded || !map || coordinates.length < 2) return; const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; if (source) { source.setData({ type: "Feature", properties: {}, geometry: { type: "LineString", coordinates }, }); } }, [isLoaded, map, coordinates, sourceId]); useEffect(() => { if (!isLoaded || !map || !map.getLayer(layerId)) return; map.setPaintProperty(layerId, "line-color", color); map.setPaintProperty(layerId, "line-width", width); map.setPaintProperty(layerId, "line-opacity", opacity); if (dashArray) { map.setPaintProperty(layerId, "line-dasharray", dashArray); } }, [isLoaded, map, layerId, color, width, opacity, dashArray]); // Handle click and hover events useEffect(() => { if (!isLoaded || !map || !interactive) return; const handleClick = () => { onClick?.(); }; const handleMouseEnter = () => { map.getCanvas().style.cursor = "pointer"; onMouseEnter?.(); }; const handleMouseLeave = () => { map.getCanvas().style.cursor = ""; onMouseLeave?.(); }; map.on("click", layerId, handleClick); map.on("mouseenter", layerId, handleMouseEnter); map.on("mouseleave", layerId, handleMouseLeave); return () => { map.off("click", layerId, handleClick); map.off("mouseenter", layerId, handleMouseEnter); map.off("mouseleave", layerId, handleMouseLeave); }; }, [ isLoaded, map, layerId, onClick, onMouseEnter, onMouseLeave, interactive, ]); return null; } /** A single arc to render inside . */ type MapArcDatum = { /** Unique identifier for this arc. Required for hover state tracking and event payloads. */ id: string | number; /** Start coordinate as [longitude, latitude]. */ from: [number, number]; /** End coordinate as [longitude, latitude]. */ to: [number, number]; }; /** Event payload passed to MapArc interaction callbacks. */ type MapArcEvent = { /** The arc datum that was hovered or clicked. */ arc: T; /** Longitude of the cursor at the time of the event. */ longitude: number; /** Latitude of the cursor at the time of the event. */ latitude: number; /** The underlying MapLibre mouse event for advanced use cases. */ originalEvent: MapLibreGL.MapMouseEvent; }; type MapArcLinePaint = NonNullable; type MapArcLineLayout = NonNullable< MapLibreGL.LineLayerSpecification["layout"] >; type MapArcProps = { /** Array of arcs to render. Each arc must have a unique `id`. */ data: T[]; /** Optional unique identifier prefix for the arc source/layers. Auto-generated if not provided. */ id?: string; /** * How far each arc bows away from a straight line. `0` renders straight * lines; higher values bend further. Negative values bend to the opposite * side. Arcs are computed as a quadratic Bézier in lng/lat space and do not * account for the antimeridian. (default: 0.2) */ curvature?: number; /** Number of samples used to render each curve. Higher = smoother. (default: 64) */ samples?: number; /** * MapLibre paint properties for the arc layer. Merged on top of sensible * defaults (`line-color: #4285F4`, `line-width: 2`, `line-opacity: 0.85`). * Any value can be a MapLibre expression for per-feature styling, every * field on each arc datum (besides `from`/`to`) is exposed via `["get", ...]`. */ paint?: MapArcLinePaint; /** MapLibre layout properties for the arc layer. Defaults to rounded joins/caps. */ layout?: MapArcLineLayout; /** * Paint properties applied to the arc currently under the cursor. Each key * is merged into `paint` as a `case` expression keyed on per-feature hover * state, so only the hovered arc changes appearance. */ hoverPaint?: MapArcLinePaint; /** Callback when an arc is clicked. */ onClick?: (e: MapArcEvent) => void; /** * Callback fired when the hovered arc changes. Receives the cursor's * lng/lat at the moment of entry, and `null` when the cursor leaves the * last hovered arc. */ onHover?: (e: MapArcEvent | null) => void; /** Whether arcs respond to mouse events (default: true). */ interactive?: boolean; /** Optional MapLibre layer id to insert the arc layers before (z-order control). */ beforeId?: string; }; const DEFAULT_ARC_CURVATURE = 0.2; const DEFAULT_ARC_SAMPLES = 64; const ARC_HIT_MIN_WIDTH = 12; const ARC_HIT_PADDING = 6; const DEFAULT_ARC_PAINT: MapArcLinePaint = { "line-color": "#4285F4", "line-width": 2, "line-opacity": 0.85, }; const DEFAULT_ARC_LAYOUT: MapArcLineLayout = { "line-join": "round", "line-cap": "round", }; function mergeArcPaint( paint: MapArcLinePaint, hoverPaint: MapArcLinePaint | undefined, ): MapArcLinePaint { if (!hoverPaint) return paint; const merged: Record = { ...paint }; for (const [key, hoverValue] of Object.entries(hoverPaint)) { if (hoverValue === undefined) continue; const baseValue = merged[key]; merged[key] = baseValue === undefined ? hoverValue : [ "case", ["boolean", ["feature-state", "hover"], false], hoverValue, baseValue, ]; } return merged as MapArcLinePaint; } function buildArcCoordinates( from: [number, number], to: [number, number], curvature: number, samples: number, ): [number, number][] { const [x0, y0] = from; const [x2, y2] = to; const dx = x2 - x0; const dy = y2 - y0; const distance = Math.hypot(dx, dy); if (distance === 0 || curvature === 0) return [from, to]; const mx = (x0 + x2) / 2; const my = (y0 + y2) / 2; const nx = -dy / distance; const ny = dx / distance; const offset = distance * curvature; const cx = mx + nx * offset; const cy = my + ny * offset; const points: [number, number][] = []; const segments = Math.max(2, Math.floor(samples)); for (let i = 0; i <= segments; i += 1) { const t = i / segments; const inv = 1 - t; const x = inv * inv * x0 + 2 * inv * t * cx + t * t * x2; const y = inv * inv * y0 + 2 * inv * t * cy + t * t * y2; points.push([x, y]); } return points; } function MapArc({ data, id: propId, curvature = DEFAULT_ARC_CURVATURE, samples = DEFAULT_ARC_SAMPLES, paint, layout, hoverPaint, onClick, onHover, interactive = true, beforeId, }: MapArcProps) { const { map, isLoaded } = useMap(); const autoId = useId(); const id = propId ?? autoId; const sourceId = `arc-source-${id}`; const layerId = `arc-layer-${id}`; const hitLayerId = `arc-hit-layer-${id}`; const mergedPaint = useMemo( () => mergeArcPaint({ ...DEFAULT_ARC_PAINT, ...paint }, hoverPaint), [paint, hoverPaint], ); const mergedLayout = useMemo( () => ({ ...DEFAULT_ARC_LAYOUT, ...layout }), [layout], ); const hitWidth = useMemo(() => { const w = paint?.["line-width"] ?? DEFAULT_ARC_PAINT["line-width"]; const base = typeof w === "number" ? w : ARC_HIT_MIN_WIDTH; return Math.max(base + ARC_HIT_PADDING, ARC_HIT_MIN_WIDTH); }, [paint]); const geoJSON = useMemo>( () => ({ type: "FeatureCollection", features: data.map((arc) => { const { from, to, ...properties } = arc; return { type: "Feature", properties, geometry: { type: "LineString", coordinates: buildArcCoordinates(from, to, curvature, samples), }, }; }), }), [data, curvature, samples], ); const latestRef = useRef({ data, onClick, onHover }); latestRef.current = { data, onClick, onHover }; // Add source and layers on mount. useEffect(() => { if (!isLoaded || !map) return; map.addSource(sourceId, { type: "geojson", data: geoJSON, promoteId: "id", }); map.addLayer( { id: hitLayerId, type: "line", source: sourceId, layout: DEFAULT_ARC_LAYOUT, paint: { "line-color": "rgba(0, 0, 0, 0)", "line-width": hitWidth, "line-opacity": 1, }, }, beforeId, ); map.addLayer( { id: layerId, type: "line", source: sourceId, layout: mergedLayout, paint: mergedPaint, }, beforeId, ); return () => { try { if (map.getLayer(layerId)) map.removeLayer(layerId); if (map.getLayer(hitLayerId)) map.removeLayer(hitLayerId); if (map.getSource(sourceId)) map.removeSource(sourceId); } catch { // ignore } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoaded, map]); // Sync features when data / curvature / samples change. useEffect(() => { if (!isLoaded || !map) return; const source = map.getSource(sourceId) as | MapLibreGL.GeoJSONSource | undefined; source?.setData(geoJSON); }, [isLoaded, map, geoJSON, sourceId]); // Sync paint/layout when they change. useEffect(() => { if (!isLoaded || !map || !map.getLayer(layerId)) return; for (const [key, value] of Object.entries(mergedPaint)) { map.setPaintProperty( layerId, key as keyof MapArcLinePaint, value as never, ); } for (const [key, value] of Object.entries(mergedLayout)) { map.setLayoutProperty( layerId, key as keyof MapArcLineLayout, value as never, ); } if (map.getLayer(hitLayerId)) { map.setPaintProperty(hitLayerId, "line-width", hitWidth); } }, [isLoaded, map, layerId, hitLayerId, mergedPaint, mergedLayout, hitWidth]); // Interaction handlers useEffect(() => { if (!isLoaded || !map || !interactive) return; let hoveredId: string | number | null = null; const setHover = (next: string | number | null) => { if (next === hoveredId) return; const sourceExists = !!map.getSource(sourceId); if (hoveredId != null && sourceExists) { map.setFeatureState( { source: sourceId, id: hoveredId }, { hover: false }, ); } hoveredId = next; if (next != null && sourceExists) { map.setFeatureState({ source: sourceId, id: next }, { hover: true }); } }; const findArc = (featureId: string | number | undefined) => featureId == null ? undefined : latestRef.current.data.find( (arc) => String(arc.id) === String(featureId), ); const handleMouseMove = (e: MapLibreGL.MapLayerMouseEvent) => { const featureId = e.features?.[0]?.id as string | number | undefined; if (featureId == null || featureId === hoveredId) return; setHover(featureId); map.getCanvas().style.cursor = "pointer"; const arc = findArc(featureId); if (arc) { latestRef.current.onHover?.({ arc: arc as T, longitude: e.lngLat.lng, latitude: e.lngLat.lat, originalEvent: e, }); } }; const handleMouseLeave = () => { setHover(null); map.getCanvas().style.cursor = ""; latestRef.current.onHover?.(null); }; const handleClick = (e: MapLibreGL.MapLayerMouseEvent) => { const arc = findArc(e.features?.[0]?.id as string | number | undefined); if (!arc) return; latestRef.current.onClick?.({ arc: arc as T, longitude: e.lngLat.lng, latitude: e.lngLat.lat, originalEvent: e, }); }; map.on("mousemove", hitLayerId, handleMouseMove); map.on("mouseleave", hitLayerId, handleMouseLeave); map.on("click", hitLayerId, handleClick); return () => { map.off("mousemove", hitLayerId, handleMouseMove); map.off("mouseleave", hitLayerId, handleMouseLeave); map.off("click", hitLayerId, handleClick); setHover(null); map.getCanvas().style.cursor = ""; }; }, [isLoaded, map, hitLayerId, sourceId, interactive]); return null; } type MapClusterLayerProps< P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties, > = { /** GeoJSON FeatureCollection data or URL to fetch GeoJSON from */ data: string | GeoJSON.FeatureCollection; /** Maximum zoom level to cluster points on (default: 14) */ clusterMaxZoom?: number; /** Radius of each cluster when clustering points in pixels (default: 50) */ clusterRadius?: number; /** Colors for cluster circles: [small, medium, large] based on point count (default: ["#22c55e", "#eab308", "#ef4444"]) */ clusterColors?: [string, string, string]; /** Point count thresholds for color/size steps: [medium, large] (default: [100, 750]) */ clusterThresholds?: [number, number]; /** Color for unclustered individual points (default: "#3b82f6") */ pointColor?: string; /** Callback when an unclustered point is clicked */ onPointClick?: ( feature: GeoJSON.Feature, coordinates: [number, number], ) => void; /** Callback when a cluster is clicked. If not provided, zooms into the cluster */ onClusterClick?: ( clusterId: number, coordinates: [number, number], pointCount: number, ) => void; }; function MapClusterLayer< P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties, >({ data, clusterMaxZoom = 14, clusterRadius = 50, clusterColors = ["#22c55e", "#eab308", "#ef4444"], clusterThresholds = [100, 750], pointColor = "#3b82f6", onPointClick, onClusterClick, }: MapClusterLayerProps

) { const { map, isLoaded } = useMap(); const id = useId(); const sourceId = `cluster-source-${id}`; const clusterLayerId = `clusters-${id}`; const clusterCountLayerId = `cluster-count-${id}`; const unclusteredLayerId = `unclustered-point-${id}`; const stylePropsRef = useRef({ clusterColors, clusterThresholds, pointColor, }); // Add source and layers on mount useEffect(() => { if (!isLoaded || !map) return; // Add clustered GeoJSON source map.addSource(sourceId, { type: "geojson", data, cluster: true, clusterMaxZoom, clusterRadius, }); // Add cluster circles layer map.addLayer({ id: clusterLayerId, type: "circle", source: sourceId, filter: ["has", "point_count"], paint: { "circle-color": [ "step", ["get", "point_count"], clusterColors[0], clusterThresholds[0], clusterColors[1], clusterThresholds[1], clusterColors[2], ], "circle-radius": [ "step", ["get", "point_count"], 20, clusterThresholds[0], 30, clusterThresholds[1], 40, ], "circle-stroke-width": 1, "circle-stroke-color": "#fff", "circle-opacity": 0.85, }, }); // Add cluster count text layer map.addLayer({ id: clusterCountLayerId, type: "symbol", source: sourceId, filter: ["has", "point_count"], layout: { "text-field": "{point_count_abbreviated}", "text-font": ["Open Sans"], "text-size": 12, }, paint: { "text-color": "#fff", }, }); // Add unclustered point layer map.addLayer({ id: unclusteredLayerId, type: "circle", source: sourceId, filter: ["!", ["has", "point_count"]], paint: { "circle-color": pointColor, "circle-radius": 5, "circle-stroke-width": 2, "circle-stroke-color": "#fff", }, }); return () => { try { if (map.getLayer(clusterCountLayerId)) map.removeLayer(clusterCountLayerId); if (map.getLayer(unclusteredLayerId)) map.removeLayer(unclusteredLayerId); if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId); if (map.getSource(sourceId)) map.removeSource(sourceId); } catch { // ignore } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoaded, map, sourceId]); // Update source data when data prop changes (only for non-URL data) useEffect(() => { if (!isLoaded || !map || typeof data === "string") return; const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; if (source) { source.setData(data); } }, [isLoaded, map, data, sourceId]); // Update layer styles when props change useEffect(() => { if (!isLoaded || !map) return; const prev = stylePropsRef.current; const colorsChanged = prev.clusterColors !== clusterColors || prev.clusterThresholds !== clusterThresholds; // Update cluster layer colors and sizes if (map.getLayer(clusterLayerId) && colorsChanged) { map.setPaintProperty(clusterLayerId, "circle-color", [ "step", ["get", "point_count"], clusterColors[0], clusterThresholds[0], clusterColors[1], clusterThresholds[1], clusterColors[2], ]); map.setPaintProperty(clusterLayerId, "circle-radius", [ "step", ["get", "point_count"], 20, clusterThresholds[0], 30, clusterThresholds[1], 40, ]); } // Update unclustered point layer color if (map.getLayer(unclusteredLayerId) && prev.pointColor !== pointColor) { map.setPaintProperty(unclusteredLayerId, "circle-color", pointColor); } stylePropsRef.current = { clusterColors, clusterThresholds, pointColor }; }, [ isLoaded, map, clusterLayerId, unclusteredLayerId, clusterColors, clusterThresholds, pointColor, ]); // Handle click events useEffect(() => { if (!isLoaded || !map) return; // Cluster click handler - zoom into cluster const handleClusterClick = async ( e: MapLibreGL.MapMouseEvent & { features?: MapLibreGL.MapGeoJSONFeature[]; }, ) => { const features = map.queryRenderedFeatures(e.point, { layers: [clusterLayerId], }); if (!features.length) return; const feature = features[0]; const clusterId = feature.properties?.cluster_id as number; const pointCount = feature.properties?.point_count as number; const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [ number, number, ]; if (onClusterClick) { onClusterClick(clusterId, coordinates, pointCount); } else { // Default behavior: zoom to cluster expansion zoom const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; const zoom = await source.getClusterExpansionZoom(clusterId); map.easeTo({ center: coordinates, zoom, }); } }; // Unclustered point click handler const handlePointClick = ( e: MapLibreGL.MapMouseEvent & { features?: MapLibreGL.MapGeoJSONFeature[]; }, ) => { if (!onPointClick || !e.features?.length) return; const feature = e.features[0]; const coordinates = ( feature.geometry as GeoJSON.Point ).coordinates.slice() as [number, number]; // Handle world copies while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360; } onPointClick( feature as unknown as GeoJSON.Feature, coordinates, ); }; // Cursor style handlers const handleMouseEnterCluster = () => { map.getCanvas().style.cursor = "pointer"; }; const handleMouseLeaveCluster = () => { map.getCanvas().style.cursor = ""; }; const handleMouseEnterPoint = () => { if (onPointClick) { map.getCanvas().style.cursor = "pointer"; } }; const handleMouseLeavePoint = () => { map.getCanvas().style.cursor = ""; }; map.on("click", clusterLayerId, handleClusterClick); map.on("click", unclusteredLayerId, handlePointClick); map.on("mouseenter", clusterLayerId, handleMouseEnterCluster); map.on("mouseleave", clusterLayerId, handleMouseLeaveCluster); map.on("mouseenter", unclusteredLayerId, handleMouseEnterPoint); map.on("mouseleave", unclusteredLayerId, handleMouseLeavePoint); return () => { map.off("click", clusterLayerId, handleClusterClick); map.off("click", unclusteredLayerId, handlePointClick); map.off("mouseenter", clusterLayerId, handleMouseEnterCluster); map.off("mouseleave", clusterLayerId, handleMouseLeaveCluster); map.off("mouseenter", unclusteredLayerId, handleMouseEnterPoint); map.off("mouseleave", unclusteredLayerId, handleMouseLeavePoint); }; }, [ isLoaded, map, clusterLayerId, unclusteredLayerId, sourceId, onClusterClick, onPointClick, ]); return null; } export { Map, useMap, MapMarker, MarkerContent, MarkerPopup, MarkerTooltip, MarkerLabel, MapPopup, MapControls, MapRoute, MapArc, MapClusterLayer, }; export type { MapRef, MapViewport, MapArcDatum, MapArcEvent };