"use client" import MapLibreGL, { type MarkerOptions, type PopupOptions } from "maplibre-gl" import "maplibre-gl/dist/maplibre-gl.css" import { Loader2, Locate, Maximize, Minus, Plus, X } from "lucide-react" import { createContext, forwardRef, type ReactNode, useCallback, useContext, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from "react" import { createPortal } from "react-dom" 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 } & 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, ...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 && } {/* 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 (
) } 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 } 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, MapClusterLayer } export type { MapRef, MapViewport }