diff --git a/public/r/logistics-network.json b/public/r/logistics-network.json index 96a22f6..d9a65b4 100644 --- a/public/r/logistics-network.json +++ b/public/r/logistics-network.json @@ -24,12 +24,6 @@ "type": "registry:component", "target": "app/logistics/data.ts" }, - { - "path": "src/registry/blocks/logistics-network/components/map-arcs.tsx", - "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo } from \"react\";\nimport { useMap } from \"@/components/ui/map\";\nimport type MapLibreGL from \"maplibre-gl\";\nimport { hubs, modeConfig, statusConfig, type Route } from \"../data\";\n\nconst SOURCE_ID = \"logistics-arcs-source\";\nconst LAYER_ID = \"logistics-arcs-layer\";\n\nfunction generateArc(\n start: [number, number],\n end: [number, number],\n segments = 50,\n): number[][] {\n const [x1, y1] = start;\n const [x2, y2] = end;\n\n const mx = (x1 + x2) / 2;\n const my = (y1 + y2) / 2;\n\n const dx = x2 - x1;\n const dy = y2 - y1;\n const dist = Math.sqrt(dx * dx + dy * dy);\n\n const nx = -dy / dist;\n const ny = dx / dist;\n const height = dist * 0.3;\n\n const cx = mx + nx * height;\n const cy = my + ny * height;\n\n const coords: number[][] = [];\n for (let i = 0; i <= segments; i++) {\n const t = i / segments;\n const px = (1 - t) * (1 - t) * x1 + 2 * (1 - t) * t * cx + t * t * x2;\n const py = (1 - t) * (1 - t) * y1 + 2 * (1 - t) * t * cy + t * t * y2;\n coords.push([px, py]);\n }\n return coords;\n}\n\nfunction getHubById(id: string) {\n return hubs.find((h) => h.id === id)!;\n}\n\ninterface MapArcsProps {\n routes: Route[];\n}\n\nexport function MapArcs({ routes: arcRoutes }: MapArcsProps) {\n const { map, isLoaded } = useMap();\n\n const geoJSON = useMemo(() => {\n const features: GeoJSON.Feature[] = arcRoutes.map((route) => {\n const fromHub = getHubById(route.from);\n const toHub = getHubById(route.to);\n const coordinates = generateArc(\n [fromHub.lng, fromHub.lat],\n [toHub.lng, toHub.lat],\n );\n return {\n type: \"Feature\" as const,\n properties: {\n id: `${route.from}-${route.to}`,\n mode: route.mode,\n status: route.status,\n color:\n route.status === \"delayed\"\n ? statusConfig.delayed.color\n : modeConfig[route.mode].color,\n },\n geometry: {\n type: \"LineString\" as const,\n coordinates,\n },\n };\n });\n return { type: \"FeatureCollection\", features };\n }, [arcRoutes]);\n\n const addLayer = useCallback(() => {\n if (!map) return;\n\n if (!map.getSource(SOURCE_ID)) {\n map.addSource(SOURCE_ID, { type: \"geojson\", data: geoJSON });\n map.addLayer({\n id: LAYER_ID,\n type: \"line\",\n source: SOURCE_ID,\n layout: { \"line-join\": \"round\", \"line-cap\": \"round\" },\n paint: {\n \"line-color\": [\"get\", \"color\"],\n \"line-width\": 2,\n \"line-opacity\": 0.65,\n },\n });\n } else {\n (map.getSource(SOURCE_ID) as MapLibreGL.GeoJSONSource).setData(geoJSON);\n }\n }, [map, geoJSON]);\n\n useEffect(() => {\n if (!map || !isLoaded) return;\n\n addLayer();\n\n return () => {\n try {\n if (map.getLayer(LAYER_ID)) map.removeLayer(LAYER_ID);\n if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID);\n } catch {\n // ignore\n }\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [map, isLoaded]);\n\n useEffect(() => {\n if (!map || !isLoaded) return;\n const source = map.getSource(SOURCE_ID) as MapLibreGL.GeoJSONSource;\n if (source) source.setData(geoJSON);\n }, [map, isLoaded, geoJSON]);\n\n return null;\n}\n", - "type": "registry:component", - "target": "app/logistics/components/map-arcs.tsx" - }, { "path": "src/registry/blocks/logistics-network/components/filter-sidebar.tsx", "content": "\"use client\";\n\nimport {\n Sidebar,\n SidebarContent,\n SidebarFooter,\n SidebarGroup,\n SidebarGroupContent,\n SidebarGroupLabel,\n SidebarHeader,\n SidebarMenu,\n SidebarMenuBadge,\n SidebarMenuButton,\n SidebarMenuItem,\n SidebarSeparator,\n} from \"@/components/ui/sidebar\";\nimport { Network, Plane, Truck } from \"lucide-react\";\nimport { regionLabels, statusConfig, type Hub, type Route } from \"../data\";\n\nconst regionIcons: Record = {\n west: \"W\",\n midwest: \"MW\",\n south: \"S\",\n northeast: \"NE\",\n};\n\ninterface FilterSidebarProps {\n hubs: Hub[];\n routes: Route[];\n}\n\nexport function FilterSidebar({ hubs, routes }: FilterSidebarProps) {\n const totalShipments = routes.reduce((s, r) => s + r.shipments, 0);\n const activeCount = routes.filter((r) => r.status === \"active\").length;\n const delayedCount = routes.filter((r) => r.status === \"delayed\").length;\n const airRouteCount = routes.filter((r) => r.mode === \"air\").length;\n const groundRouteCount = routes.filter((r) => r.mode === \"ground\").length;\n\n return (\n \n \n
\n
\n \n
\n
\n Logistics Network\n \n Domestic Routes\n \n
\n
\n
\n
\n

\n {hubs.length}\n

\n

Hubs

\n
\n
\n

\n {activeCount}\n

\n

Active

\n
\n
\n

\n {delayedCount}\n

\n

Delayed

\n
\n
\n
\n\n \n\n \n \n Transport Mode\n \n \n \n \n \n Air Freight\n \n {airRouteCount}\n \n \n \n \n Ground\n \n {groundRouteCount}\n \n \n \n \n\n \n Status\n \n \n \n \n \n \n \n {statusConfig.active.label}\n \n {activeCount}\n \n \n \n \n \n \n {statusConfig.delayed.label}\n \n {delayedCount}\n \n \n \n \n\n \n Region\n \n \n {([\"west\", \"midwest\", \"south\", \"northeast\"] as const).map(\n (region) => {\n const hubsInRegion = hubs.filter((h) => h.region === region);\n return (\n \n \n \n {regionIcons[region]}\n \n {regionLabels[region]}\n \n {hubsInRegion.length}\n \n );\n },\n )}\n \n \n \n \n\n \n

\n Summary\n

\n
\n
\n Shipments\n \n {totalShipments.toLocaleString()}\n \n
\n
\n Routes\n \n {routes.length}\n \n
\n
\n
\n
\n );\n}\n", @@ -38,7 +32,7 @@ }, { "path": "src/registry/blocks/logistics-network/components/network-map.tsx", - "content": "\"use client\";\n\nimport {\n Map,\n MapControls,\n MapMarker,\n MarkerContent,\n MarkerTooltip,\n} from \"@/components/ui/map\";\nimport { SidebarTrigger } from \"@/components/ui/sidebar\";\nimport {\n modeConfig,\n regionLabels,\n statusConfig,\n type Hub,\n type Route,\n} from \"../data\";\nimport { MapArcs } from \"./map-arcs\";\nimport { Separator } from \"@/components/ui/separator\";\n\ninterface NetworkMapProps {\n hubs: Hub[];\n routes: Route[];\n}\n\nfunction MapControlsCard() {\n return (\n
\n \n \n
\n
\n \n {modeConfig.air.label}\n
\n
\n \n {modeConfig.ground.label}\n
\n
\n \n {statusConfig.delayed.label}\n
\n
\n
\n
\n Hub\n
\n
\n
\n );\n}\n\nexport function NetworkMap({ hubs, routes }: NetworkMapProps) {\n return (\n
\n \n\n \n \n \n\n {hubs.map((hub) => (\n \n \n
\n \n \n

{hub.city}

\n

\n {hub.shipments.toLocaleString()} shipments\n \n {regionLabels[hub.region]}\n

\n \n \n ))}\n \n
\n );\n}\n", + "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport {\n Map,\n MapArc,\n MapControls,\n MapMarker,\n MarkerContent,\n MarkerTooltip,\n} from \"@/components/ui/map\";\nimport { SidebarTrigger } from \"@/components/ui/sidebar\";\nimport {\n modeConfig,\n regionLabels,\n statusConfig,\n type Hub,\n type Route,\n} from \"../data\";\nimport { Separator } from \"@/components/ui/separator\";\n\ninterface NetworkMapProps {\n hubs: Hub[];\n routes: Route[];\n}\n\nfunction MapControlsCard() {\n return (\n
\n \n \n
\n
\n \n {modeConfig.air.label}\n
\n
\n \n {modeConfig.ground.label}\n
\n
\n \n {statusConfig.delayed.label}\n
\n
\n
\n
\n Hub\n
\n
\n
\n );\n}\n\nexport function NetworkMap({ hubs, routes }: NetworkMapProps) {\n const arcs = useMemo(() => {\n const hubById: Record = Object.fromEntries(\n hubs.map((hub) => [hub.id, hub]),\n );\n return routes.flatMap((route) => {\n const fromHub = hubById[route.from];\n const toHub = hubById[route.to];\n if (!fromHub || !toHub) return [];\n return [\n {\n id: `${route.from}-${route.to}`,\n from: [fromHub.lng, fromHub.lat] as [number, number],\n to: [toHub.lng, toHub.lat] as [number, number],\n color:\n route.status === \"delayed\"\n ? statusConfig.delayed.color\n : modeConfig[route.mode].color,\n },\n ];\n });\n }, [hubs, routes]);\n\n return (\n
\n \n\n \n \n \n\n {hubs.map((hub) => (\n \n \n
\n \n \n

{hub.city}

\n

\n {hub.shipments.toLocaleString()} shipments\n \n {regionLabels[hub.region]}\n

\n \n \n ))}\n \n
\n );\n}\n", "type": "registry:component", "target": "app/logistics/components/network-map.tsx" } diff --git a/public/r/map.json b/public/r/map.json index 42a1327..feb4d17 100644 --- a/public/r/map.json +++ b/public/r/map.json @@ -11,7 +11,7 @@ "files": [ { "path": "src/registry/map.tsx", - "content": "\"use client\";\n\nimport MapLibreGL, { type PopupOptions, type MarkerOptions } from \"maplibre-gl\";\nimport \"maplibre-gl/dist/maplibre-gl.css\";\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { X, Minus, Plus, Locate, Maximize, Loader2 } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst defaultStyles = {\n dark: \"https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json\",\n light: \"https://basemaps.cartocdn.com/gl/positron-gl-style/style.json\",\n};\n\ntype Theme = \"light\" | \"dark\";\n\n// Check document class for theme (works with next-themes, etc.)\nfunction getDocumentTheme(): Theme | null {\n if (typeof document === \"undefined\") return null;\n if (document.documentElement.classList.contains(\"dark\")) return \"dark\";\n if (document.documentElement.classList.contains(\"light\")) return \"light\";\n return null;\n}\n\n// Get system preference\nfunction getSystemTheme(): Theme {\n if (typeof window === \"undefined\") return \"light\";\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n ? \"dark\"\n : \"light\";\n}\n\nfunction useResolvedTheme(themeProp?: \"light\" | \"dark\"): Theme {\n const [detectedTheme, setDetectedTheme] = useState(\n () => getDocumentTheme() ?? getSystemTheme(),\n );\n\n useEffect(() => {\n if (themeProp) return; // Skip detection if theme is provided via prop\n\n // Watch for document class changes (e.g., next-themes toggling dark class)\n const observer = new MutationObserver(() => {\n const docTheme = getDocumentTheme();\n if (docTheme) {\n setDetectedTheme(docTheme);\n }\n });\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\"],\n });\n\n // Also watch for system preference changes\n const mediaQuery = window.matchMedia(\"(prefers-color-scheme: dark)\");\n const handleSystemChange = (e: MediaQueryListEvent) => {\n // Only use system preference if no document class is set\n if (!getDocumentTheme()) {\n setDetectedTheme(e.matches ? \"dark\" : \"light\");\n }\n };\n mediaQuery.addEventListener(\"change\", handleSystemChange);\n\n return () => {\n observer.disconnect();\n mediaQuery.removeEventListener(\"change\", handleSystemChange);\n };\n }, [themeProp]);\n\n return themeProp ?? detectedTheme;\n}\n\ntype MapContextValue = {\n map: MapLibreGL.Map | null;\n isLoaded: boolean;\n};\n\nconst MapContext = createContext(null);\n\nfunction useMap() {\n const context = useContext(MapContext);\n if (!context) {\n throw new Error(\"useMap must be used within a Map component\");\n }\n return context;\n}\n\n/** Map viewport state */\ntype MapViewport = {\n /** Center coordinates [longitude, latitude] */\n center: [number, number];\n /** Zoom level */\n zoom: number;\n /** Bearing (rotation) in degrees */\n bearing: number;\n /** Pitch (tilt) in degrees */\n pitch: number;\n};\n\ntype MapStyleOption = string | MapLibreGL.StyleSpecification;\n\ntype MapRef = MapLibreGL.Map;\n\ntype MapProps = {\n children?: ReactNode;\n /** Additional CSS classes for the map container */\n className?: string;\n /**\n * Theme for the map. If not provided, automatically detects system preference.\n * Pass your theme value here.\n */\n theme?: Theme;\n /** Custom map styles for light and dark themes. Overrides the default Carto styles. */\n styles?: {\n light?: MapStyleOption;\n dark?: MapStyleOption;\n };\n /** Map projection type. Use `{ type: \"globe\" }` for 3D globe view. */\n projection?: MapLibreGL.ProjectionSpecification;\n /**\n * Controlled viewport. When provided with onViewportChange,\n * the map becomes controlled and viewport is driven by this prop.\n */\n viewport?: Partial;\n /**\n * Callback fired continuously as the viewport changes (pan, zoom, rotate, pitch).\n * Can be used standalone to observe changes, or with `viewport` prop\n * to enable controlled mode where the map viewport is driven by your state.\n */\n onViewportChange?: (viewport: MapViewport) => void;\n /** Show a loading indicator on the map */\n loading?: boolean;\n} & Omit;\n\nfunction DefaultLoader() {\n return (\n
\n
\n \n \n \n
\n
\n );\n}\n\nfunction getViewport(map: MapLibreGL.Map): MapViewport {\n const center = map.getCenter();\n return {\n center: [center.lng, center.lat],\n zoom: map.getZoom(),\n bearing: map.getBearing(),\n pitch: map.getPitch(),\n };\n}\n\nconst Map = forwardRef(function Map(\n {\n children,\n className,\n theme: themeProp,\n styles,\n projection,\n viewport,\n onViewportChange,\n loading = false,\n ...props\n },\n ref,\n) {\n const containerRef = useRef(null);\n const [mapInstance, setMapInstance] = useState(null);\n const [isLoaded, setIsLoaded] = useState(false);\n const [isStyleLoaded, setIsStyleLoaded] = useState(false);\n const currentStyleRef = useRef(null);\n const styleTimeoutRef = useRef | null>(null);\n const internalUpdateRef = useRef(false);\n const resolvedTheme = useResolvedTheme(themeProp);\n\n const isControlled = viewport !== undefined && onViewportChange !== undefined;\n\n const onViewportChangeRef = useRef(onViewportChange);\n onViewportChangeRef.current = onViewportChange;\n\n const mapStyles = useMemo(\n () => ({\n dark: styles?.dark ?? defaultStyles.dark,\n light: styles?.light ?? defaultStyles.light,\n }),\n [styles],\n );\n\n // Expose the map instance to the parent component\n useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]);\n\n const clearStyleTimeout = useCallback(() => {\n if (styleTimeoutRef.current) {\n clearTimeout(styleTimeoutRef.current);\n styleTimeoutRef.current = null;\n }\n }, []);\n\n // Initialize the map\n useEffect(() => {\n if (!containerRef.current) return;\n\n const initialStyle =\n resolvedTheme === \"dark\" ? mapStyles.dark : mapStyles.light;\n currentStyleRef.current = initialStyle;\n\n const map = new MapLibreGL.Map({\n container: containerRef.current,\n style: initialStyle,\n renderWorldCopies: false,\n attributionControl: {\n compact: true,\n },\n ...props,\n ...viewport,\n });\n\n const styleDataHandler = () => {\n clearStyleTimeout();\n // Delay to ensure style is fully processed before allowing layer operations\n // This is a workaround to avoid race conditions with the style loading\n // else we have to force update every layer on setStyle change\n styleTimeoutRef.current = setTimeout(() => {\n setIsStyleLoaded(true);\n if (projection) {\n map.setProjection(projection);\n }\n }, 100);\n };\n const loadHandler = () => setIsLoaded(true);\n\n // Viewport change handler - skip if triggered by internal update\n const handleMove = () => {\n if (internalUpdateRef.current) return;\n onViewportChangeRef.current?.(getViewport(map));\n };\n\n map.on(\"load\", loadHandler);\n map.on(\"styledata\", styleDataHandler);\n map.on(\"move\", handleMove);\n setMapInstance(map);\n\n return () => {\n clearStyleTimeout();\n map.off(\"load\", loadHandler);\n map.off(\"styledata\", styleDataHandler);\n map.off(\"move\", handleMove);\n map.remove();\n setIsLoaded(false);\n setIsStyleLoaded(false);\n setMapInstance(null);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Sync controlled viewport to map\n useEffect(() => {\n if (!mapInstance || !isControlled || !viewport) return;\n if (mapInstance.isMoving()) return;\n\n const current = getViewport(mapInstance);\n const next = {\n center: viewport.center ?? current.center,\n zoom: viewport.zoom ?? current.zoom,\n bearing: viewport.bearing ?? current.bearing,\n pitch: viewport.pitch ?? current.pitch,\n };\n\n if (\n next.center[0] === current.center[0] &&\n next.center[1] === current.center[1] &&\n next.zoom === current.zoom &&\n next.bearing === current.bearing &&\n next.pitch === current.pitch\n ) {\n return;\n }\n\n internalUpdateRef.current = true;\n mapInstance.jumpTo(next);\n internalUpdateRef.current = false;\n }, [mapInstance, isControlled, viewport]);\n\n // Handle style change\n useEffect(() => {\n if (!mapInstance || !resolvedTheme) return;\n\n const newStyle =\n resolvedTheme === \"dark\" ? mapStyles.dark : mapStyles.light;\n\n if (currentStyleRef.current === newStyle) return;\n\n clearStyleTimeout();\n currentStyleRef.current = newStyle;\n setIsStyleLoaded(false);\n\n mapInstance.setStyle(newStyle, { diff: true });\n }, [mapInstance, resolvedTheme, mapStyles, clearStyleTimeout]);\n\n const contextValue = useMemo(\n () => ({\n map: mapInstance,\n isLoaded: isLoaded && isStyleLoaded,\n }),\n [mapInstance, isLoaded, isStyleLoaded],\n );\n\n return (\n \n \n {(!isLoaded || loading) && }\n {/* SSR-safe: children render only when map is loaded on client */}\n {mapInstance && children}\n
\n \n );\n});\n\ntype MarkerContextValue = {\n marker: MapLibreGL.Marker;\n map: MapLibreGL.Map | null;\n};\n\nconst MarkerContext = createContext(null);\n\nfunction useMarkerContext() {\n const context = useContext(MarkerContext);\n if (!context) {\n throw new Error(\"Marker components must be used within MapMarker\");\n }\n return context;\n}\n\ntype MapMarkerProps = {\n /** Longitude coordinate for marker position */\n longitude: number;\n /** Latitude coordinate for marker position */\n latitude: number;\n /** Marker subcomponents (MarkerContent, MarkerPopup, MarkerTooltip, MarkerLabel) */\n children: ReactNode;\n /** Callback when marker is clicked */\n onClick?: (e: MouseEvent) => void;\n /** Callback when mouse enters marker */\n onMouseEnter?: (e: MouseEvent) => void;\n /** Callback when mouse leaves marker */\n onMouseLeave?: (e: MouseEvent) => void;\n /** Callback when marker drag starts (requires draggable: true) */\n onDragStart?: (lngLat: { lng: number; lat: number }) => void;\n /** Callback during marker drag (requires draggable: true) */\n onDrag?: (lngLat: { lng: number; lat: number }) => void;\n /** Callback when marker drag ends (requires draggable: true) */\n onDragEnd?: (lngLat: { lng: number; lat: number }) => void;\n} & Omit;\n\nfunction MapMarker({\n longitude,\n latitude,\n children,\n onClick,\n onMouseEnter,\n onMouseLeave,\n onDragStart,\n onDrag,\n onDragEnd,\n draggable = false,\n ...markerOptions\n}: MapMarkerProps) {\n const { map } = useMap();\n\n const callbacksRef = useRef({\n onClick,\n onMouseEnter,\n onMouseLeave,\n onDragStart,\n onDrag,\n onDragEnd,\n });\n callbacksRef.current = {\n onClick,\n onMouseEnter,\n onMouseLeave,\n onDragStart,\n onDrag,\n onDragEnd,\n };\n\n const marker = useMemo(() => {\n const markerInstance = new MapLibreGL.Marker({\n ...markerOptions,\n element: document.createElement(\"div\"),\n draggable,\n }).setLngLat([longitude, latitude]);\n\n const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e);\n const handleMouseEnter = (e: MouseEvent) =>\n callbacksRef.current.onMouseEnter?.(e);\n const handleMouseLeave = (e: MouseEvent) =>\n callbacksRef.current.onMouseLeave?.(e);\n\n markerInstance.getElement()?.addEventListener(\"click\", handleClick);\n markerInstance\n .getElement()\n ?.addEventListener(\"mouseenter\", handleMouseEnter);\n markerInstance\n .getElement()\n ?.addEventListener(\"mouseleave\", handleMouseLeave);\n\n const handleDragStart = () => {\n const lngLat = markerInstance.getLngLat();\n callbacksRef.current.onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat });\n };\n const handleDrag = () => {\n const lngLat = markerInstance.getLngLat();\n callbacksRef.current.onDrag?.({ lng: lngLat.lng, lat: lngLat.lat });\n };\n const handleDragEnd = () => {\n const lngLat = markerInstance.getLngLat();\n callbacksRef.current.onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat });\n };\n\n markerInstance.on(\"dragstart\", handleDragStart);\n markerInstance.on(\"drag\", handleDrag);\n markerInstance.on(\"dragend\", handleDragEnd);\n\n return markerInstance;\n\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n if (!map) return;\n\n marker.addTo(map);\n\n return () => {\n marker.remove();\n };\n\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [map]);\n\n if (\n marker.getLngLat().lng !== longitude ||\n marker.getLngLat().lat !== latitude\n ) {\n marker.setLngLat([longitude, latitude]);\n }\n if (marker.isDraggable() !== draggable) {\n marker.setDraggable(draggable);\n }\n\n const currentOffset = marker.getOffset();\n const newOffset = markerOptions.offset ?? [0, 0];\n const [newOffsetX, newOffsetY] = Array.isArray(newOffset)\n ? newOffset\n : [newOffset.x, newOffset.y];\n if (currentOffset.x !== newOffsetX || currentOffset.y !== newOffsetY) {\n marker.setOffset(newOffset);\n }\n\n if (marker.getRotation() !== markerOptions.rotation) {\n marker.setRotation(markerOptions.rotation ?? 0);\n }\n if (marker.getRotationAlignment() !== markerOptions.rotationAlignment) {\n marker.setRotationAlignment(markerOptions.rotationAlignment ?? \"auto\");\n }\n if (marker.getPitchAlignment() !== markerOptions.pitchAlignment) {\n marker.setPitchAlignment(markerOptions.pitchAlignment ?? \"auto\");\n }\n\n return (\n \n {children}\n \n );\n}\n\ntype MarkerContentProps = {\n /** Custom marker content. Defaults to a blue dot if not provided */\n children?: ReactNode;\n /** Additional CSS classes for the marker container */\n className?: string;\n};\n\nfunction MarkerContent({ children, className }: MarkerContentProps) {\n const { marker } = useMarkerContext();\n\n return createPortal(\n
\n {children || }\n
,\n marker.getElement(),\n );\n}\n\nfunction DefaultMarkerIcon() {\n return (\n
\n );\n}\n\nfunction PopupCloseButton({ onClick }: { onClick: () => void }) {\n return (\n \n \n \n );\n}\n\ntype MarkerPopupProps = {\n /** Popup content */\n children: ReactNode;\n /** Additional CSS classes for the popup container */\n className?: string;\n /** Show a close button in the popup (default: false) */\n closeButton?: boolean;\n} & Omit;\n\nfunction MarkerPopup({\n children,\n className,\n closeButton = false,\n ...popupOptions\n}: MarkerPopupProps) {\n const { marker, map } = useMarkerContext();\n const container = useMemo(() => document.createElement(\"div\"), []);\n const prevPopupOptions = useRef(popupOptions);\n\n const popup = useMemo(() => {\n const popupInstance = new MapLibreGL.Popup({\n offset: 16,\n ...popupOptions,\n closeButton: false,\n })\n .setMaxWidth(\"none\")\n .setDOMContent(container);\n\n return popupInstance;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n if (!map) return;\n\n popup.setDOMContent(container);\n marker.setPopup(popup);\n\n return () => {\n marker.setPopup(null);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [map]);\n\n if (popup.isOpen()) {\n const prev = prevPopupOptions.current;\n\n if (prev.offset !== popupOptions.offset) {\n popup.setOffset(popupOptions.offset ?? 16);\n }\n if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) {\n popup.setMaxWidth(popupOptions.maxWidth ?? \"none\");\n }\n\n prevPopupOptions.current = popupOptions;\n }\n\n const handleClose = () => popup.remove();\n\n return createPortal(\n \n {closeButton && }\n {children}\n
,\n container,\n );\n}\n\ntype MarkerTooltipProps = {\n /** Tooltip content */\n children: ReactNode;\n /** Additional CSS classes for the tooltip container */\n className?: string;\n} & Omit;\n\nfunction MarkerTooltip({\n children,\n className,\n ...popupOptions\n}: MarkerTooltipProps) {\n const { marker, map } = useMarkerContext();\n const container = useMemo(() => document.createElement(\"div\"), []);\n const prevTooltipOptions = useRef(popupOptions);\n\n const tooltip = useMemo(() => {\n const tooltipInstance = new MapLibreGL.Popup({\n offset: 16,\n ...popupOptions,\n closeOnClick: true,\n closeButton: false,\n }).setMaxWidth(\"none\");\n\n return tooltipInstance;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n if (!map) return;\n\n tooltip.setDOMContent(container);\n\n const handleMouseEnter = () => {\n tooltip.setLngLat(marker.getLngLat()).addTo(map);\n };\n const handleMouseLeave = () => tooltip.remove();\n\n marker.getElement()?.addEventListener(\"mouseenter\", handleMouseEnter);\n marker.getElement()?.addEventListener(\"mouseleave\", handleMouseLeave);\n\n return () => {\n marker.getElement()?.removeEventListener(\"mouseenter\", handleMouseEnter);\n marker.getElement()?.removeEventListener(\"mouseleave\", handleMouseLeave);\n tooltip.remove();\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [map]);\n\n if (tooltip.isOpen()) {\n const prev = prevTooltipOptions.current;\n\n if (prev.offset !== popupOptions.offset) {\n tooltip.setOffset(popupOptions.offset ?? 16);\n }\n if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) {\n tooltip.setMaxWidth(popupOptions.maxWidth ?? \"none\");\n }\n\n prevTooltipOptions.current = popupOptions;\n }\n\n return createPortal(\n \n {children}\n
,\n container,\n );\n}\n\ntype MarkerLabelProps = {\n /** Label text content */\n children: ReactNode;\n /** Additional CSS classes for the label */\n className?: string;\n /** Position of the label relative to the marker (default: \"top\") */\n position?: \"top\" | \"bottom\";\n};\n\nfunction MarkerLabel({\n children,\n className,\n position = \"top\",\n}: MarkerLabelProps) {\n const positionClasses = {\n top: \"bottom-full mb-1\",\n bottom: \"top-full mt-1\",\n };\n\n return (\n \n {children}\n
\n );\n}\n\ntype MapControlsProps = {\n /** Position of the controls on the map (default: \"bottom-right\") */\n position?: \"top-left\" | \"top-right\" | \"bottom-left\" | \"bottom-right\";\n /** Show zoom in/out buttons (default: true) */\n showZoom?: boolean;\n /** Show compass button to reset bearing (default: false) */\n showCompass?: boolean;\n /** Show locate button to find user's location (default: false) */\n showLocate?: boolean;\n /** Show fullscreen toggle button (default: false) */\n showFullscreen?: boolean;\n /** Additional CSS classes for the controls container */\n className?: string;\n /** Callback with user coordinates when located */\n onLocate?: (coords: { longitude: number; latitude: number }) => void;\n};\n\nconst positionClasses = {\n \"top-left\": \"top-2 left-2\",\n \"top-right\": \"top-2 right-2\",\n \"bottom-left\": \"bottom-2 left-2\",\n \"bottom-right\": \"bottom-10 right-2\",\n};\n\nfunction ControlGroup({ children }: { children: React.ReactNode }) {\n return (\n
button:not(:last-child)]:border-border flex flex-col overflow-hidden rounded-md border shadow-sm [&>button:not(:last-child)]:border-b\">\n {children}\n
\n );\n}\n\nfunction ControlButton({\n onClick,\n label,\n children,\n disabled = false,\n}: {\n onClick: () => void;\n label: string;\n children: React.ReactNode;\n disabled?: boolean;\n}) {\n return (\n \n {children}\n \n );\n}\n\nfunction MapControls({\n position = \"bottom-right\",\n showZoom = true,\n showCompass = false,\n showLocate = false,\n showFullscreen = false,\n className,\n onLocate,\n}: MapControlsProps) {\n const { map } = useMap();\n const [waitingForLocation, setWaitingForLocation] = useState(false);\n\n const handleZoomIn = useCallback(() => {\n map?.zoomTo(map.getZoom() + 1, { duration: 300 });\n }, [map]);\n\n const handleZoomOut = useCallback(() => {\n map?.zoomTo(map.getZoom() - 1, { duration: 300 });\n }, [map]);\n\n const handleResetBearing = useCallback(() => {\n map?.resetNorthPitch({ duration: 300 });\n }, [map]);\n\n const handleLocate = useCallback(() => {\n setWaitingForLocation(true);\n if (\"geolocation\" in navigator) {\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const coords = {\n longitude: pos.coords.longitude,\n latitude: pos.coords.latitude,\n };\n map?.flyTo({\n center: [coords.longitude, coords.latitude],\n zoom: 14,\n duration: 1500,\n });\n onLocate?.(coords);\n setWaitingForLocation(false);\n },\n (error) => {\n console.error(\"Error getting location:\", error);\n setWaitingForLocation(false);\n },\n );\n }\n }, [map, onLocate]);\n\n const handleFullscreen = useCallback(() => {\n const container = map?.getContainer();\n if (!container) return;\n if (document.fullscreenElement) {\n document.exitFullscreen();\n } else {\n container.requestFullscreen();\n }\n }, [map]);\n\n return (\n \n {showZoom && (\n \n \n \n \n \n \n \n \n )}\n {showCompass && (\n \n \n \n )}\n {showLocate && (\n \n \n {waitingForLocation ? (\n \n ) : (\n \n )}\n \n \n )}\n {showFullscreen && (\n \n \n \n \n \n )}\n
\n );\n}\n\nfunction CompassButton({ onClick }: { onClick: () => void }) {\n const { map } = useMap();\n const compassRef = useRef(null);\n\n useEffect(() => {\n if (!map || !compassRef.current) return;\n\n const compass = compassRef.current;\n\n const updateRotation = () => {\n const bearing = map.getBearing();\n const pitch = map.getPitch();\n compass.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`;\n };\n\n map.on(\"rotate\", updateRotation);\n map.on(\"pitch\", updateRotation);\n updateRotation();\n\n return () => {\n map.off(\"rotate\", updateRotation);\n map.off(\"pitch\", updateRotation);\n };\n }, [map]);\n\n return (\n \n \n \n \n \n \n \n \n );\n}\n\ntype MapPopupProps = {\n /** Longitude coordinate for popup position */\n longitude: number;\n /** Latitude coordinate for popup position */\n latitude: number;\n /** Callback when popup is closed */\n onClose?: () => void;\n /** Popup content */\n children: ReactNode;\n /** Additional CSS classes for the popup container */\n className?: string;\n /** Show a close button in the popup (default: false) */\n closeButton?: boolean;\n} & Omit;\n\nfunction MapPopup({\n longitude,\n latitude,\n onClose,\n children,\n className,\n closeButton = false,\n ...popupOptions\n}: MapPopupProps) {\n const { map } = useMap();\n const popupOptionsRef = useRef(popupOptions);\n const onCloseRef = useRef(onClose);\n onCloseRef.current = onClose;\n const container = useMemo(() => document.createElement(\"div\"), []);\n\n const popup = useMemo(() => {\n const popupInstance = new MapLibreGL.Popup({\n offset: 16,\n ...popupOptions,\n closeButton: false,\n })\n .setMaxWidth(\"none\")\n .setLngLat([longitude, latitude]);\n\n return popupInstance;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n if (!map) return;\n\n const onCloseProp = () => onCloseRef.current?.();\n\n popup.on(\"close\", onCloseProp);\n\n popup.setDOMContent(container);\n popup.addTo(map);\n\n return () => {\n popup.off(\"close\", onCloseProp);\n if (popup.isOpen()) {\n popup.remove();\n }\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [map]);\n\n if (popup.isOpen()) {\n const prev = popupOptionsRef.current;\n\n if (\n popup.getLngLat().lng !== longitude ||\n popup.getLngLat().lat !== latitude\n ) {\n popup.setLngLat([longitude, latitude]);\n }\n\n if (prev.offset !== popupOptions.offset) {\n popup.setOffset(popupOptions.offset ?? 16);\n }\n if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) {\n popup.setMaxWidth(popupOptions.maxWidth ?? \"none\");\n }\n popupOptionsRef.current = popupOptions;\n }\n\n const handleClose = () => {\n popup.remove();\n };\n\n return createPortal(\n \n {closeButton && }\n {children}\n
,\n container,\n );\n}\n\ntype MapRouteProps = {\n /** Optional unique identifier for the route layer */\n id?: string;\n /** Array of [longitude, latitude] coordinate pairs defining the route */\n coordinates: [number, number][];\n /** Line color as CSS color value (default: \"#4285F4\") */\n color?: string;\n /** Line width in pixels (default: 3) */\n width?: number;\n /** Line opacity from 0 to 1 (default: 0.8) */\n opacity?: number;\n /** Dash pattern [dash length, gap length] for dashed lines */\n dashArray?: [number, number];\n /** Callback when the route line is clicked */\n onClick?: () => void;\n /** Callback when mouse enters the route line */\n onMouseEnter?: () => void;\n /** Callback when mouse leaves the route line */\n onMouseLeave?: () => void;\n /** Whether the route is interactive - shows pointer cursor on hover (default: true) */\n interactive?: boolean;\n};\n\nfunction MapRoute({\n id: propId,\n coordinates,\n color = \"#4285F4\",\n width = 3,\n opacity = 0.8,\n dashArray,\n onClick,\n onMouseEnter,\n onMouseLeave,\n interactive = true,\n}: MapRouteProps) {\n const { map, isLoaded } = useMap();\n const autoId = useId();\n const id = propId ?? autoId;\n const sourceId = `route-source-${id}`;\n const layerId = `route-layer-${id}`;\n\n // Add source and layer on mount\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n map.addSource(sourceId, {\n type: \"geojson\",\n data: {\n type: \"Feature\",\n properties: {},\n geometry: { type: \"LineString\", coordinates: [] },\n },\n });\n\n map.addLayer({\n id: layerId,\n type: \"line\",\n source: sourceId,\n layout: { \"line-join\": \"round\", \"line-cap\": \"round\" },\n paint: {\n \"line-color\": color,\n \"line-width\": width,\n \"line-opacity\": opacity,\n ...(dashArray && { \"line-dasharray\": dashArray }),\n },\n });\n\n return () => {\n try {\n if (map.getLayer(layerId)) map.removeLayer(layerId);\n if (map.getSource(sourceId)) map.removeSource(sourceId);\n } catch {\n // ignore\n }\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [isLoaded, map]);\n\n // When coordinates change, update the source data\n useEffect(() => {\n if (!isLoaded || !map || coordinates.length < 2) return;\n\n const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;\n if (source) {\n source.setData({\n type: \"Feature\",\n properties: {},\n geometry: { type: \"LineString\", coordinates },\n });\n }\n }, [isLoaded, map, coordinates, sourceId]);\n\n useEffect(() => {\n if (!isLoaded || !map || !map.getLayer(layerId)) return;\n\n map.setPaintProperty(layerId, \"line-color\", color);\n map.setPaintProperty(layerId, \"line-width\", width);\n map.setPaintProperty(layerId, \"line-opacity\", opacity);\n if (dashArray) {\n map.setPaintProperty(layerId, \"line-dasharray\", dashArray);\n }\n }, [isLoaded, map, layerId, color, width, opacity, dashArray]);\n\n // Handle click and hover events\n useEffect(() => {\n if (!isLoaded || !map || !interactive) return;\n\n const handleClick = () => {\n onClick?.();\n };\n const handleMouseEnter = () => {\n map.getCanvas().style.cursor = \"pointer\";\n onMouseEnter?.();\n };\n const handleMouseLeave = () => {\n map.getCanvas().style.cursor = \"\";\n onMouseLeave?.();\n };\n\n map.on(\"click\", layerId, handleClick);\n map.on(\"mouseenter\", layerId, handleMouseEnter);\n map.on(\"mouseleave\", layerId, handleMouseLeave);\n\n return () => {\n map.off(\"click\", layerId, handleClick);\n map.off(\"mouseenter\", layerId, handleMouseEnter);\n map.off(\"mouseleave\", layerId, handleMouseLeave);\n };\n }, [\n isLoaded,\n map,\n layerId,\n onClick,\n onMouseEnter,\n onMouseLeave,\n interactive,\n ]);\n\n return null;\n}\n\ntype MapClusterLayerProps<\n P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties,\n> = {\n /** GeoJSON FeatureCollection data or URL to fetch GeoJSON from */\n data: string | GeoJSON.FeatureCollection;\n /** Maximum zoom level to cluster points on (default: 14) */\n clusterMaxZoom?: number;\n /** Radius of each cluster when clustering points in pixels (default: 50) */\n clusterRadius?: number;\n /** Colors for cluster circles: [small, medium, large] based on point count (default: [\"#22c55e\", \"#eab308\", \"#ef4444\"]) */\n clusterColors?: [string, string, string];\n /** Point count thresholds for color/size steps: [medium, large] (default: [100, 750]) */\n clusterThresholds?: [number, number];\n /** Color for unclustered individual points (default: \"#3b82f6\") */\n pointColor?: string;\n /** Callback when an unclustered point is clicked */\n onPointClick?: (\n feature: GeoJSON.Feature,\n coordinates: [number, number],\n ) => void;\n /** Callback when a cluster is clicked. If not provided, zooms into the cluster */\n onClusterClick?: (\n clusterId: number,\n coordinates: [number, number],\n pointCount: number,\n ) => void;\n};\n\nfunction MapClusterLayer<\n P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties,\n>({\n data,\n clusterMaxZoom = 14,\n clusterRadius = 50,\n clusterColors = [\"#22c55e\", \"#eab308\", \"#ef4444\"],\n clusterThresholds = [100, 750],\n pointColor = \"#3b82f6\",\n onPointClick,\n onClusterClick,\n}: MapClusterLayerProps

) {\n const { map, isLoaded } = useMap();\n const id = useId();\n const sourceId = `cluster-source-${id}`;\n const clusterLayerId = `clusters-${id}`;\n const clusterCountLayerId = `cluster-count-${id}`;\n const unclusteredLayerId = `unclustered-point-${id}`;\n\n const stylePropsRef = useRef({\n clusterColors,\n clusterThresholds,\n pointColor,\n });\n\n // Add source and layers on mount\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n // Add clustered GeoJSON source\n map.addSource(sourceId, {\n type: \"geojson\",\n data,\n cluster: true,\n clusterMaxZoom,\n clusterRadius,\n });\n\n // Add cluster circles layer\n map.addLayer({\n id: clusterLayerId,\n type: \"circle\",\n source: sourceId,\n filter: [\"has\", \"point_count\"],\n paint: {\n \"circle-color\": [\n \"step\",\n [\"get\", \"point_count\"],\n clusterColors[0],\n clusterThresholds[0],\n clusterColors[1],\n clusterThresholds[1],\n clusterColors[2],\n ],\n \"circle-radius\": [\n \"step\",\n [\"get\", \"point_count\"],\n 20,\n clusterThresholds[0],\n 30,\n clusterThresholds[1],\n 40,\n ],\n \"circle-stroke-width\": 1,\n \"circle-stroke-color\": \"#fff\",\n \"circle-opacity\": 0.85,\n },\n });\n\n // Add cluster count text layer\n map.addLayer({\n id: clusterCountLayerId,\n type: \"symbol\",\n source: sourceId,\n filter: [\"has\", \"point_count\"],\n layout: {\n \"text-field\": \"{point_count_abbreviated}\",\n \"text-font\": [\"Open Sans\"],\n \"text-size\": 12,\n },\n paint: {\n \"text-color\": \"#fff\",\n },\n });\n\n // Add unclustered point layer\n map.addLayer({\n id: unclusteredLayerId,\n type: \"circle\",\n source: sourceId,\n filter: [\"!\", [\"has\", \"point_count\"]],\n paint: {\n \"circle-color\": pointColor,\n \"circle-radius\": 5,\n \"circle-stroke-width\": 2,\n \"circle-stroke-color\": \"#fff\",\n },\n });\n\n return () => {\n try {\n if (map.getLayer(clusterCountLayerId))\n map.removeLayer(clusterCountLayerId);\n if (map.getLayer(unclusteredLayerId))\n map.removeLayer(unclusteredLayerId);\n if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId);\n if (map.getSource(sourceId)) map.removeSource(sourceId);\n } catch {\n // ignore\n }\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [isLoaded, map, sourceId]);\n\n // Update source data when data prop changes (only for non-URL data)\n useEffect(() => {\n if (!isLoaded || !map || typeof data === \"string\") return;\n\n const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;\n if (source) {\n source.setData(data);\n }\n }, [isLoaded, map, data, sourceId]);\n\n // Update layer styles when props change\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n const prev = stylePropsRef.current;\n const colorsChanged =\n prev.clusterColors !== clusterColors ||\n prev.clusterThresholds !== clusterThresholds;\n\n // Update cluster layer colors and sizes\n if (map.getLayer(clusterLayerId) && colorsChanged) {\n map.setPaintProperty(clusterLayerId, \"circle-color\", [\n \"step\",\n [\"get\", \"point_count\"],\n clusterColors[0],\n clusterThresholds[0],\n clusterColors[1],\n clusterThresholds[1],\n clusterColors[2],\n ]);\n map.setPaintProperty(clusterLayerId, \"circle-radius\", [\n \"step\",\n [\"get\", \"point_count\"],\n 20,\n clusterThresholds[0],\n 30,\n clusterThresholds[1],\n 40,\n ]);\n }\n\n // Update unclustered point layer color\n if (map.getLayer(unclusteredLayerId) && prev.pointColor !== pointColor) {\n map.setPaintProperty(unclusteredLayerId, \"circle-color\", pointColor);\n }\n\n stylePropsRef.current = { clusterColors, clusterThresholds, pointColor };\n }, [\n isLoaded,\n map,\n clusterLayerId,\n unclusteredLayerId,\n clusterColors,\n clusterThresholds,\n pointColor,\n ]);\n\n // Handle click events\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n // Cluster click handler - zoom into cluster\n const handleClusterClick = async (\n e: MapLibreGL.MapMouseEvent & {\n features?: MapLibreGL.MapGeoJSONFeature[];\n },\n ) => {\n const features = map.queryRenderedFeatures(e.point, {\n layers: [clusterLayerId],\n });\n if (!features.length) return;\n\n const feature = features[0];\n const clusterId = feature.properties?.cluster_id as number;\n const pointCount = feature.properties?.point_count as number;\n const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [\n number,\n number,\n ];\n\n if (onClusterClick) {\n onClusterClick(clusterId, coordinates, pointCount);\n } else {\n // Default behavior: zoom to cluster expansion zoom\n const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;\n const zoom = await source.getClusterExpansionZoom(clusterId);\n map.easeTo({\n center: coordinates,\n zoom,\n });\n }\n };\n\n // Unclustered point click handler\n const handlePointClick = (\n e: MapLibreGL.MapMouseEvent & {\n features?: MapLibreGL.MapGeoJSONFeature[];\n },\n ) => {\n if (!onPointClick || !e.features?.length) return;\n\n const feature = e.features[0];\n const coordinates = (\n feature.geometry as GeoJSON.Point\n ).coordinates.slice() as [number, number];\n\n // Handle world copies\n while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {\n coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;\n }\n\n onPointClick(\n feature as unknown as GeoJSON.Feature,\n coordinates,\n );\n };\n\n // Cursor style handlers\n const handleMouseEnterCluster = () => {\n map.getCanvas().style.cursor = \"pointer\";\n };\n const handleMouseLeaveCluster = () => {\n map.getCanvas().style.cursor = \"\";\n };\n const handleMouseEnterPoint = () => {\n if (onPointClick) {\n map.getCanvas().style.cursor = \"pointer\";\n }\n };\n const handleMouseLeavePoint = () => {\n map.getCanvas().style.cursor = \"\";\n };\n\n map.on(\"click\", clusterLayerId, handleClusterClick);\n map.on(\"click\", unclusteredLayerId, handlePointClick);\n map.on(\"mouseenter\", clusterLayerId, handleMouseEnterCluster);\n map.on(\"mouseleave\", clusterLayerId, handleMouseLeaveCluster);\n map.on(\"mouseenter\", unclusteredLayerId, handleMouseEnterPoint);\n map.on(\"mouseleave\", unclusteredLayerId, handleMouseLeavePoint);\n\n return () => {\n map.off(\"click\", clusterLayerId, handleClusterClick);\n map.off(\"click\", unclusteredLayerId, handlePointClick);\n map.off(\"mouseenter\", clusterLayerId, handleMouseEnterCluster);\n map.off(\"mouseleave\", clusterLayerId, handleMouseLeaveCluster);\n map.off(\"mouseenter\", unclusteredLayerId, handleMouseEnterPoint);\n map.off(\"mouseleave\", unclusteredLayerId, handleMouseLeavePoint);\n };\n }, [\n isLoaded,\n map,\n clusterLayerId,\n unclusteredLayerId,\n sourceId,\n onClusterClick,\n onPointClick,\n ]);\n\n return null;\n}\n\nexport {\n Map,\n useMap,\n MapMarker,\n MarkerContent,\n MarkerPopup,\n MarkerTooltip,\n MarkerLabel,\n MapPopup,\n MapControls,\n MapRoute,\n MapClusterLayer,\n};\n\nexport type { MapRef, MapViewport };\n", + "content": "\"use client\";\n\nimport MapLibreGL, { type PopupOptions, type MarkerOptions } from \"maplibre-gl\";\nimport \"maplibre-gl/dist/maplibre-gl.css\";\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { X, Minus, Plus, Locate, Maximize, Loader2 } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst defaultStyles = {\n dark: \"https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json\",\n light: \"https://basemaps.cartocdn.com/gl/positron-gl-style/style.json\",\n};\n\ntype Theme = \"light\" | \"dark\";\n\n// Check document class for theme (works with next-themes, etc.)\nfunction getDocumentTheme(): Theme | null {\n if (typeof document === \"undefined\") return null;\n if (document.documentElement.classList.contains(\"dark\")) return \"dark\";\n if (document.documentElement.classList.contains(\"light\")) return \"light\";\n return null;\n}\n\n// Get system preference\nfunction getSystemTheme(): Theme {\n if (typeof window === \"undefined\") return \"light\";\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n ? \"dark\"\n : \"light\";\n}\n\nfunction useResolvedTheme(themeProp?: \"light\" | \"dark\"): Theme {\n const [detectedTheme, setDetectedTheme] = useState(\n () => getDocumentTheme() ?? getSystemTheme(),\n );\n\n useEffect(() => {\n if (themeProp) return; // Skip detection if theme is provided via prop\n\n // Watch for document class changes (e.g., next-themes toggling dark class)\n const observer = new MutationObserver(() => {\n const docTheme = getDocumentTheme();\n if (docTheme) {\n setDetectedTheme(docTheme);\n }\n });\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\"],\n });\n\n // Also watch for system preference changes\n const mediaQuery = window.matchMedia(\"(prefers-color-scheme: dark)\");\n const handleSystemChange = (e: MediaQueryListEvent) => {\n // Only use system preference if no document class is set\n if (!getDocumentTheme()) {\n setDetectedTheme(e.matches ? \"dark\" : \"light\");\n }\n };\n mediaQuery.addEventListener(\"change\", handleSystemChange);\n\n return () => {\n observer.disconnect();\n mediaQuery.removeEventListener(\"change\", handleSystemChange);\n };\n }, [themeProp]);\n\n return themeProp ?? detectedTheme;\n}\n\ntype MapContextValue = {\n map: MapLibreGL.Map | null;\n isLoaded: boolean;\n};\n\nconst MapContext = createContext(null);\n\nfunction useMap() {\n const context = useContext(MapContext);\n if (!context) {\n throw new Error(\"useMap must be used within a Map component\");\n }\n return context;\n}\n\n/** Map viewport state */\ntype MapViewport = {\n /** Center coordinates [longitude, latitude] */\n center: [number, number];\n /** Zoom level */\n zoom: number;\n /** Bearing (rotation) in degrees */\n bearing: number;\n /** Pitch (tilt) in degrees */\n pitch: number;\n};\n\ntype MapStyleOption = string | MapLibreGL.StyleSpecification;\n\ntype MapRef = MapLibreGL.Map;\n\ntype MapProps = {\n children?: ReactNode;\n /** Additional CSS classes for the map container */\n className?: string;\n /**\n * Theme for the map. If not provided, automatically detects system preference.\n * Pass your theme value here.\n */\n theme?: Theme;\n /** Custom map styles for light and dark themes. Overrides the default Carto styles. */\n styles?: {\n light?: MapStyleOption;\n dark?: MapStyleOption;\n };\n /** Map projection type. Use `{ type: \"globe\" }` for 3D globe view. */\n projection?: MapLibreGL.ProjectionSpecification;\n /**\n * Controlled viewport. When provided with onViewportChange,\n * the map becomes controlled and viewport is driven by this prop.\n */\n viewport?: Partial;\n /**\n * Callback fired continuously as the viewport changes (pan, zoom, rotate, pitch).\n * Can be used standalone to observe changes, or with `viewport` prop\n * to enable controlled mode where the map viewport is driven by your state.\n */\n onViewportChange?: (viewport: MapViewport) => void;\n /** Show a loading indicator on the map */\n loading?: boolean;\n} & Omit;\n\nfunction DefaultLoader() {\n return (\n

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

) {\n const { map, isLoaded } = useMap();\n const id = useId();\n const sourceId = `cluster-source-${id}`;\n const clusterLayerId = `clusters-${id}`;\n const clusterCountLayerId = `cluster-count-${id}`;\n const unclusteredLayerId = `unclustered-point-${id}`;\n\n const stylePropsRef = useRef({\n clusterColors,\n clusterThresholds,\n pointColor,\n });\n\n // Add source and layers on mount\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n // Add clustered GeoJSON source\n map.addSource(sourceId, {\n type: \"geojson\",\n data,\n cluster: true,\n clusterMaxZoom,\n clusterRadius,\n });\n\n // Add cluster circles layer\n map.addLayer({\n id: clusterLayerId,\n type: \"circle\",\n source: sourceId,\n filter: [\"has\", \"point_count\"],\n paint: {\n \"circle-color\": [\n \"step\",\n [\"get\", \"point_count\"],\n clusterColors[0],\n clusterThresholds[0],\n clusterColors[1],\n clusterThresholds[1],\n clusterColors[2],\n ],\n \"circle-radius\": [\n \"step\",\n [\"get\", \"point_count\"],\n 20,\n clusterThresholds[0],\n 30,\n clusterThresholds[1],\n 40,\n ],\n \"circle-stroke-width\": 1,\n \"circle-stroke-color\": \"#fff\",\n \"circle-opacity\": 0.85,\n },\n });\n\n // Add cluster count text layer\n map.addLayer({\n id: clusterCountLayerId,\n type: \"symbol\",\n source: sourceId,\n filter: [\"has\", \"point_count\"],\n layout: {\n \"text-field\": \"{point_count_abbreviated}\",\n \"text-font\": [\"Open Sans\"],\n \"text-size\": 12,\n },\n paint: {\n \"text-color\": \"#fff\",\n },\n });\n\n // Add unclustered point layer\n map.addLayer({\n id: unclusteredLayerId,\n type: \"circle\",\n source: sourceId,\n filter: [\"!\", [\"has\", \"point_count\"]],\n paint: {\n \"circle-color\": pointColor,\n \"circle-radius\": 5,\n \"circle-stroke-width\": 2,\n \"circle-stroke-color\": \"#fff\",\n },\n });\n\n return () => {\n try {\n if (map.getLayer(clusterCountLayerId))\n map.removeLayer(clusterCountLayerId);\n if (map.getLayer(unclusteredLayerId))\n map.removeLayer(unclusteredLayerId);\n if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId);\n if (map.getSource(sourceId)) map.removeSource(sourceId);\n } catch {\n // ignore\n }\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [isLoaded, map, sourceId]);\n\n // Update source data when data prop changes (only for non-URL data)\n useEffect(() => {\n if (!isLoaded || !map || typeof data === \"string\") return;\n\n const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;\n if (source) {\n source.setData(data);\n }\n }, [isLoaded, map, data, sourceId]);\n\n // Update layer styles when props change\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n const prev = stylePropsRef.current;\n const colorsChanged =\n prev.clusterColors !== clusterColors ||\n prev.clusterThresholds !== clusterThresholds;\n\n // Update cluster layer colors and sizes\n if (map.getLayer(clusterLayerId) && colorsChanged) {\n map.setPaintProperty(clusterLayerId, \"circle-color\", [\n \"step\",\n [\"get\", \"point_count\"],\n clusterColors[0],\n clusterThresholds[0],\n clusterColors[1],\n clusterThresholds[1],\n clusterColors[2],\n ]);\n map.setPaintProperty(clusterLayerId, \"circle-radius\", [\n \"step\",\n [\"get\", \"point_count\"],\n 20,\n clusterThresholds[0],\n 30,\n clusterThresholds[1],\n 40,\n ]);\n }\n\n // Update unclustered point layer color\n if (map.getLayer(unclusteredLayerId) && prev.pointColor !== pointColor) {\n map.setPaintProperty(unclusteredLayerId, \"circle-color\", pointColor);\n }\n\n stylePropsRef.current = { clusterColors, clusterThresholds, pointColor };\n }, [\n isLoaded,\n map,\n clusterLayerId,\n unclusteredLayerId,\n clusterColors,\n clusterThresholds,\n pointColor,\n ]);\n\n // Handle click events\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n // Cluster click handler - zoom into cluster\n const handleClusterClick = async (\n e: MapLibreGL.MapMouseEvent & {\n features?: MapLibreGL.MapGeoJSONFeature[];\n },\n ) => {\n const features = map.queryRenderedFeatures(e.point, {\n layers: [clusterLayerId],\n });\n if (!features.length) return;\n\n const feature = features[0];\n const clusterId = feature.properties?.cluster_id as number;\n const pointCount = feature.properties?.point_count as number;\n const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [\n number,\n number,\n ];\n\n if (onClusterClick) {\n onClusterClick(clusterId, coordinates, pointCount);\n } else {\n // Default behavior: zoom to cluster expansion zoom\n const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;\n const zoom = await source.getClusterExpansionZoom(clusterId);\n map.easeTo({\n center: coordinates,\n zoom,\n });\n }\n };\n\n // Unclustered point click handler\n const handlePointClick = (\n e: MapLibreGL.MapMouseEvent & {\n features?: MapLibreGL.MapGeoJSONFeature[];\n },\n ) => {\n if (!onPointClick || !e.features?.length) return;\n\n const feature = e.features[0];\n const coordinates = (\n feature.geometry as GeoJSON.Point\n ).coordinates.slice() as [number, number];\n\n // Handle world copies\n while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {\n coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;\n }\n\n onPointClick(\n feature as unknown as GeoJSON.Feature,\n coordinates,\n );\n };\n\n // Cursor style handlers\n const handleMouseEnterCluster = () => {\n map.getCanvas().style.cursor = \"pointer\";\n };\n const handleMouseLeaveCluster = () => {\n map.getCanvas().style.cursor = \"\";\n };\n const handleMouseEnterPoint = () => {\n if (onPointClick) {\n map.getCanvas().style.cursor = \"pointer\";\n }\n };\n const handleMouseLeavePoint = () => {\n map.getCanvas().style.cursor = \"\";\n };\n\n map.on(\"click\", clusterLayerId, handleClusterClick);\n map.on(\"click\", unclusteredLayerId, handlePointClick);\n map.on(\"mouseenter\", clusterLayerId, handleMouseEnterCluster);\n map.on(\"mouseleave\", clusterLayerId, handleMouseLeaveCluster);\n map.on(\"mouseenter\", unclusteredLayerId, handleMouseEnterPoint);\n map.on(\"mouseleave\", unclusteredLayerId, handleMouseLeavePoint);\n\n return () => {\n map.off(\"click\", clusterLayerId, handleClusterClick);\n map.off(\"click\", unclusteredLayerId, handlePointClick);\n map.off(\"mouseenter\", clusterLayerId, handleMouseEnterCluster);\n map.off(\"mouseleave\", clusterLayerId, handleMouseLeaveCluster);\n map.off(\"mouseenter\", unclusteredLayerId, handleMouseEnterPoint);\n map.off(\"mouseleave\", unclusteredLayerId, handleMouseLeavePoint);\n };\n }, [\n isLoaded,\n map,\n clusterLayerId,\n unclusteredLayerId,\n sourceId,\n onClusterClick,\n onPointClick,\n ]);\n\n return null;\n}\n\nexport {\n Map,\n useMap,\n MapMarker,\n MarkerContent,\n MarkerPopup,\n MarkerTooltip,\n MarkerLabel,\n MapPopup,\n MapControls,\n MapRoute,\n MapArc,\n MapClusterLayer,\n};\n\nexport type { MapRef, MapViewport, MapArcDatum, MapArcEvent };\n", "type": "registry:ui", "target": "components/ui/map.tsx" } diff --git a/public/r/registry.json b/public/r/registry.json index 7eaf9c8..725d6c7 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -80,11 +80,6 @@ "type": "registry:component", "target": "app/logistics/data.ts" }, - { - "path": "src/registry/blocks/logistics-network/components/map-arcs.tsx", - "type": "registry:component", - "target": "app/logistics/components/map-arcs.tsx" - }, { "path": "src/registry/blocks/logistics-network/components/filter-sidebar.tsx", "type": "registry:component", diff --git a/registry.json b/registry.json index 7eaf9c8..725d6c7 100644 --- a/registry.json +++ b/registry.json @@ -80,11 +80,6 @@ "type": "registry:component", "target": "app/logistics/data.ts" }, - { - "path": "src/registry/blocks/logistics-network/components/map-arcs.tsx", - "type": "registry:component", - "target": "app/logistics/components/map-arcs.tsx" - }, { "path": "src/registry/blocks/logistics-network/components/filter-sidebar.tsx", "type": "registry:component", diff --git a/src/app/(main)/docs/_components/examples/arc-example.tsx b/src/app/(main)/docs/_components/examples/arc-example.tsx new file mode 100644 index 0000000..9a10a24 --- /dev/null +++ b/src/app/(main)/docs/_components/examples/arc-example.tsx @@ -0,0 +1,70 @@ +import { cn } from "@/lib/utils"; +import { + Map, + MapArc, + MapMarker, + MarkerContent, + MarkerLabel, +} from "@/registry/map"; + +const hub = { name: "London", lng: -0.1276, lat: 51.5074 }; + +const destinations = [ + { name: "New York", lng: -74.006, lat: 40.7128 }, + { name: "São Paulo", lng: -46.6333, lat: -23.5505 }, + { name: "Cape Town", lng: 18.4241, lat: -33.9249 }, + { name: "Dubai", lng: 55.2708, lat: 25.2048 }, + { name: "Mumbai", lng: 72.8777, lat: 19.076 }, + { name: "Singapore", lng: 103.8198, lat: 1.3521 }, + { name: "Tokyo", lng: 139.6917, lat: 35.6895 }, + { name: "Sydney", lng: 151.2093, lat: -33.8688 }, +]; + +const arcs = destinations.map((dest) => ({ + id: dest.name, + from: [hub.lng, hub.lat] as [number, number], + to: [dest.lng, dest.lat] as [number, number], +})); + +export function ArcExample() { + return ( +

+ + + + + +
+ + {hub.name} + + + + + {destinations.map((dest) => ( + + +
+ {dest.name} + + + ))} + +
+ ); +} diff --git a/src/app/(main)/docs/_components/examples/interactive-arc-example.tsx b/src/app/(main)/docs/_components/examples/interactive-arc-example.tsx new file mode 100644 index 0000000..cd37064 --- /dev/null +++ b/src/app/(main)/docs/_components/examples/interactive-arc-example.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { ExpressionSpecification } from "maplibre-gl"; +import { + Map, + MapArc, + MapMarker, + MapPopup, + MarkerContent, + MarkerLabel, + type MapArcDatum, +} from "@/registry/map"; + +interface Lane extends MapArcDatum { + origin: string; + destination: string; + volume: string; + mode: "air" | "sea"; +} + +const lanes: Lane[] = [ + { + id: "shg-lax", + origin: "Shanghai", + destination: "Los Angeles", + from: [121.4737, 31.2304], + to: [-118.2437, 34.0522], + volume: "24.8k TEU", + mode: "sea", + }, + { + id: "sin-rtm", + origin: "Singapore", + destination: "Rotterdam", + from: [103.8198, 1.3521], + to: [4.4777, 51.9244], + volume: "9.4k TEU", + mode: "sea", + }, + { + id: "san-cpt", + origin: "Santos", + destination: "Cape Town", + from: [-46.3322, -23.9608], + to: [18.4241, -33.9249], + volume: "3.2k TEU", + mode: "sea", + }, + { + id: "syd-nrt", + origin: "Sydney", + destination: "Tokyo", + from: [151.2093, -33.8688], + to: [139.6917, 35.6895], + volume: "640 tons", + mode: "air", + }, + { + id: "dxb-jfk", + origin: "Dubai", + destination: "New York", + from: [55.2708, 25.2048], + to: [-74.006, 40.7128], + volume: "980 tons", + mode: "air", + }, + { + id: "dxb-bom", + origin: "Dubai", + destination: "Mumbai", + from: [55.2708, 25.2048], + to: [72.8777, 19.076], + volume: "1.2k tons", + mode: "sea", + }, +]; + +const modeColors = { + air: "#a78bfa", + sea: "#34d399", +}; + +interface SelectedLane { + lane: Lane; + popupLngLat: { longitude: number; latitude: number }; +} + +const modeColorExpression: ExpressionSpecification = [ + "match", + ["get", "mode"], + "air", + modeColors.air, + "sea", + modeColors.sea, + "#888", +]; + +export function InteractiveArcExample() { + const [selected, setSelected] = useState(null); + + const endpoints = useMemo(() => { + const points: { name: string; coords: [number, number] }[] = []; + const seen = new Set(); + for (const lane of lanes) { + if (!seen.has(lane.origin)) { + seen.add(lane.origin); + points.push({ name: lane.origin, coords: lane.from }); + } + if (!seen.has(lane.destination)) { + seen.add(lane.destination); + points.push({ name: lane.destination, coords: lane.to }); + } + } + return points; + }, []); + + return ( +
+ + + data={lanes} + paint={{ + "line-color": modeColorExpression, + "line-width": 1.5, + }} + hoverPaint={{ + "line-width": 3, + "line-opacity": 1, + }} + onHover={(event) => + setSelected( + event + ? { + lane: event.arc, + popupLngLat: { + longitude: event.longitude, + latitude: event.latitude, + }, + } + : null, + ) + } + /> + + {endpoints.map((point) => ( + + +
+ + {point.name} + + + + ))} + + {selected && ( + +
+ + + {selected.lane.origin} → {selected.lane.destination} + + + {selected.lane.volume} + +
+
+ )} + + +
+
+ + Air +
+ +
+ + Sea +
+
+
+ ); +} diff --git a/src/app/(main)/docs/_components/examples/map-controls-example.tsx b/src/app/(main)/docs/_components/examples/map-controls-example.tsx index 40ed241..af98a94 100644 --- a/src/app/(main)/docs/_components/examples/map-controls-example.tsx +++ b/src/app/(main)/docs/_components/examples/map-controls-example.tsx @@ -3,7 +3,7 @@ import { Map, MapControls } from "@/registry/map"; export function MapControlsExample() { return (
- + + `; @@ -49,6 +50,7 @@ export default function ApiReferencePage() { { title: "MarkerLabel", slug: "markerlabel" }, { title: "MapPopup", slug: "mappopup" }, { title: "MapRoute", slug: "maproute" }, + { title: "MapArc", slug: "maparc" }, { title: "MapClusterLayer", slug: "mapclusterlayer" }, ]} > @@ -559,6 +561,114 @@ export default function ApiReferencePage() { /> + {/* MapArc */} + +

+ Renders curved lines between coordinate pairs using a quadratic Bézier + in longitude/latitude space. Must be used inside{" "} + Map. Supports click and hover interactions for + building arc selection UIs. +

+

+ Built on a MapLibre{" "} + + line layer + {" "} + — the paint and layout props + accept any field from LineLayerSpecification{" "} + (e.g. line-color, line-width + , line-opacity,{" "} + line-dasharray, line-blur). +

+

+ Style per arc by passing a{" "} + + MapLibre expression + {" "} + as any paint value. Reference fields on each datum with{" "} + {`["get", "fieldName"]`}. +

+ + void", + description: "Fired when an arc is clicked.", + }, + { + name: "onHover", + type: "(e: MapArcEvent | null) => void", + description: + "Fired when the hovered arc changes, with the cursor's lng/lat at entry. Receives null when the cursor leaves all arcs.", + }, + { + name: "interactive", + type: "boolean", + default: "true", + description: + "Respond to mouse events (hover, cursor, callbacks).", + }, + { + name: "beforeId", + type: "string", + description: "Insert the arc layers before this layer id.", + }, + ]} + /> +
+ {/* MapClusterLayer */}

diff --git a/src/app/(main)/docs/arcs/page.tsx b/src/app/(main)/docs/arcs/page.tsx new file mode 100644 index 0000000..b85771e --- /dev/null +++ b/src/app/(main)/docs/arcs/page.tsx @@ -0,0 +1,62 @@ +import { DocsLayout, DocsSection, DocsCode } from "../_components/docs"; +import { ComponentPreview } from "../_components/component-preview"; +import { ArcExample } from "../_components/examples/arc-example"; +import { InteractiveArcExample } from "../_components/examples/interactive-arc-example"; +import { getExampleSource } from "../_components/get-example-source"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Arcs", +}; + +export default function ArcsPage() { + const arcSource = getExampleSource("arc-example.tsx"); + const interactiveArcSource = getExampleSource("interactive-arc-example.tsx"); + + return ( + + +

+ Use MapArc to draw curved lines between + coordinate pairs. Arcs are great for showing flight paths, shipping + lanes, or any origin–destination connection where a straight line + would feel flat. +

+
+ + +

+ Pass an array of arcs to the data prop. Each arc + needs a unique id and from /{" "} + to coordinates as{" "} + [longitude, latitude] tuples. +

+ + + +
+ + +

+ Combine hoverPaint with{" "} + onHover to highlight an arc and surface details + in a MapPopup. Use a match{" "} + expression on line-color to style arcs by + category. Here, air and sea lanes are styled differently. +

+ + + +
+ + ); +} diff --git a/src/app/(main)/docs/clusters/page.tsx b/src/app/(main)/docs/clusters/page.tsx index 59b2221..cc8674e 100644 --- a/src/app/(main)/docs/clusters/page.tsx +++ b/src/app/(main)/docs/clusters/page.tsx @@ -15,7 +15,7 @@ export default function ClustersPage() { diff --git a/src/app/(main)/docs/routes/page.tsx b/src/app/(main)/docs/routes/page.tsx index eb98c0c..0fd4b52 100644 --- a/src/app/(main)/docs/routes/page.tsx +++ b/src/app/(main)/docs/routes/page.tsx @@ -23,7 +23,7 @@ export default function RoutesPage() { title="Routes" description="Draw lines and paths connecting coordinates on the map." prev={{ title: "Popups", href: "/docs/popups" }} - next={{ title: "Clusters", href: "/docs/clusters" }} + next={{ title: "Arcs", href: "/docs/arcs" }} toc={[ { title: "Basic Route", slug: "basic-route" }, { title: "Route Planning", slug: "route-planning" }, diff --git a/src/lib/site-navigation.ts b/src/lib/site-navigation.ts index b4c2892..dc86326 100644 --- a/src/lib/site-navigation.ts +++ b/src/lib/site-navigation.ts @@ -34,6 +34,7 @@ export const docsNavigation: SiteNavigationGroup[] = [ { title: "Markers", href: "/docs/markers", icon: Layers2 }, { title: "Popups", href: "/docs/popups", icon: Layers2 }, { title: "Routes", href: "/docs/routes", icon: Layers2 }, + { title: "Arcs", href: "/docs/arcs", icon: Layers2 }, { title: "Clusters", href: "/docs/clusters", icon: Layers2 }, { title: "Advanced", href: "/docs/advanced-usage", icon: Layers2 }, ], diff --git a/src/registry/blocks/logistics-network/components/map-arcs.tsx b/src/registry/blocks/logistics-network/components/map-arcs.tsx deleted file mode 100644 index 2ec1a3f..0000000 --- a/src/registry/blocks/logistics-network/components/map-arcs.tsx +++ /dev/null @@ -1,126 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo } from "react"; -import { useMap } from "@/registry/map"; -import type MapLibreGL from "maplibre-gl"; -import { hubs, modeConfig, statusConfig, type Route } from "../data"; - -const SOURCE_ID = "logistics-arcs-source"; -const LAYER_ID = "logistics-arcs-layer"; - -function generateArc( - start: [number, number], - end: [number, number], - segments = 50, -): number[][] { - const [x1, y1] = start; - const [x2, y2] = end; - - const mx = (x1 + x2) / 2; - const my = (y1 + y2) / 2; - - const dx = x2 - x1; - const dy = y2 - y1; - const dist = Math.sqrt(dx * dx + dy * dy); - - const nx = -dy / dist; - const ny = dx / dist; - const height = dist * 0.3; - - const cx = mx + nx * height; - const cy = my + ny * height; - - const coords: number[][] = []; - for (let i = 0; i <= segments; i++) { - const t = i / segments; - const px = (1 - t) * (1 - t) * x1 + 2 * (1 - t) * t * cx + t * t * x2; - const py = (1 - t) * (1 - t) * y1 + 2 * (1 - t) * t * cy + t * t * y2; - coords.push([px, py]); - } - return coords; -} - -function getHubById(id: string) { - return hubs.find((h) => h.id === id)!; -} - -interface MapArcsProps { - routes: Route[]; -} - -export function MapArcs({ routes: arcRoutes }: MapArcsProps) { - const { map, isLoaded } = useMap(); - - const geoJSON = useMemo(() => { - const features: GeoJSON.Feature[] = arcRoutes.map((route) => { - const fromHub = getHubById(route.from); - const toHub = getHubById(route.to); - const coordinates = generateArc( - [fromHub.lng, fromHub.lat], - [toHub.lng, toHub.lat], - ); - return { - type: "Feature" as const, - properties: { - id: `${route.from}-${route.to}`, - mode: route.mode, - status: route.status, - color: - route.status === "delayed" - ? statusConfig.delayed.color - : modeConfig[route.mode].color, - }, - geometry: { - type: "LineString" as const, - coordinates, - }, - }; - }); - return { type: "FeatureCollection", features }; - }, [arcRoutes]); - - const addLayer = useCallback(() => { - if (!map) return; - - if (!map.getSource(SOURCE_ID)) { - map.addSource(SOURCE_ID, { type: "geojson", data: geoJSON }); - map.addLayer({ - id: LAYER_ID, - type: "line", - source: SOURCE_ID, - layout: { "line-join": "round", "line-cap": "round" }, - paint: { - "line-color": ["get", "color"], - "line-width": 2, - "line-opacity": 0.65, - }, - }); - } else { - (map.getSource(SOURCE_ID) as MapLibreGL.GeoJSONSource).setData(geoJSON); - } - }, [map, geoJSON]); - - useEffect(() => { - if (!map || !isLoaded) return; - - addLayer(); - - return () => { - try { - if (map.getLayer(LAYER_ID)) map.removeLayer(LAYER_ID); - if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID); - } catch { - // ignore - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [map, isLoaded]); - - useEffect(() => { - if (!map || !isLoaded) return; - const source = map.getSource(SOURCE_ID) as MapLibreGL.GeoJSONSource; - if (source) source.setData(geoJSON); - }, [map, isLoaded, geoJSON]); - - return null; -} diff --git a/src/registry/blocks/logistics-network/components/network-map.tsx b/src/registry/blocks/logistics-network/components/network-map.tsx index e5a465e..f057b73 100644 --- a/src/registry/blocks/logistics-network/components/network-map.tsx +++ b/src/registry/blocks/logistics-network/components/network-map.tsx @@ -1,7 +1,9 @@ "use client"; +import { useMemo } from "react"; import { Map, + MapArc, MapControls, MapMarker, MarkerContent, @@ -15,7 +17,6 @@ import { type Hub, type Route, } from "../data"; -import { MapArcs } from "./map-arcs"; import { Separator } from "@/components/ui/separator"; interface NetworkMapProps { @@ -61,13 +62,44 @@ function MapControlsCard() { } export function NetworkMap({ hubs, routes }: NetworkMapProps) { + const arcs = useMemo(() => { + const hubById: Record = Object.fromEntries( + hubs.map((hub) => [hub.id, hub]), + ); + return routes.flatMap((route) => { + const fromHub = hubById[route.from]; + const toHub = hubById[route.to]; + if (!fromHub || !toHub) return []; + return [ + { + id: `${route.from}-${route.to}`, + from: [fromHub.lng, fromHub.lat] as [number, number], + to: [toHub.lng, toHub.lat] as [number, number], + color: + route.status === "delayed" + ? statusConfig.delayed.color + : modeConfig[route.mode].color, + }, + ]; + }); + }, [hubs, routes]); + return (
- + {hubs.map((hub) => ( diff --git a/src/registry/map.tsx b/src/registry/map.tsx index 927d562..a69471c 100644 --- a/src/registry/map.tsx +++ b/src/registry/map.tsx @@ -1167,6 +1167,362 @@ function MapRoute({ 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, > = { @@ -1481,7 +1837,8 @@ export { MapPopup, MapControls, MapRoute, + MapArc, MapClusterLayer, }; -export type { MapRef, MapViewport }; +export type { MapRef, MapViewport, MapArcDatum, MapArcEvent };