diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b3644be --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,92 @@ +# AGENTS.md + +## Project Overview + +**mapcn** is a free, open-source library of ready-to-use, customizable map components for React. Built on [MapLibre GL](https://maplibre.org/), styled with [Tailwind CSS](https://tailwindcss.com/), works seamlessly with [shadcn/ui](https://ui.shadcn.com/). + +The repo serves dual purpose: the core map component registry (`src/registry/`) and the documentation site at [mapcn.dev](https://mapcn.dev). + +## Tech Stack + +- **Framework**: Next.js 16 (App Router, React 19) +- **Language**: TypeScript (strict) +- **Styling**: Tailwind CSS v4, shadcn/ui, Radix UI +- **Map Engine**: MapLibre GL JS v5 +- **Fonts**: Geist (sans) + Geist Mono +- **Code Highlighting**: Shiki (dual light/dark theme) +- **Registry**: shadcn CLI for building distributable components + +## Project Structure + +``` +src/ +├── app/ +│ ├── layout.tsx # Root layout (fonts, metadata, providers) +│ ├── (main)/ # Main layout group (header + content) +│ │ ├── layout.tsx # Header wrapper +│ │ ├── (home)/ # Landing page +│ │ │ └── _components/ # Homepage examples grid +│ │ ├── docs/ # Documentation pages +│ │ │ └── _components/ # Docs layout, sidebar, TOC, code blocks +│ │ └── blocks/ # Full-page block demos +│ └── (view)/ # Standalone block viewer +├── components/ # Shared app components (header, footer, logo, nav) +│ └── ui/ # shadcn/ui primitives +├── registry/ # Core map components (the library itself) +│ ├── map.tsx # Main map component + all sub-components +│ └── blocks/ # Full-page block examples +├── lib/ # Utilities, navigation config, helpers +└── styles/ + └── globals.css # Theme tokens, animations, base styles +``` + +## Key Conventions + +### Components + +- Use functional components with named exports +- Prefer React Server Components; use `"use client"` only when Web APIs are needed +- Structure files: exported component → subcomponents → helpers → static content → types +- Use `cn()` from `@/lib/utils` for conditional class merging + +### Styling + +- Tailwind utility classes only — no CSS modules, no styled-components +- Mobile-first responsive design +- Theme tokens defined as CSS custom properties in `globals.css` (oklch color space) +- No hardcoded colors — always use semantic tokens (`foreground`, `muted-foreground`, `border`, etc.) + +### Registry (`src/registry/`) + +- `map.tsx` is the single-file component library containing Map, Marker, Popup, Route, Controls, etc. +- Blocks live in `src/registry/blocks/` — each block is a self-contained page component +- Registry is built via `npm run registry:build` using the shadcn CLI + +### Naming + +- Directories: lowercase with dashes (`docs-sidebar`, `block-preview`) +- Components: PascalCase exports (`DocsLayout`, `PageHeader`) +- Files: kebab-case (`command-search.tsx`, `page-header.tsx`) + +### Navigation + +- All site navigation is centralized in `src/lib/site-navigation.ts` +- Docs sidebar nav uses `docsNavigation`, command search uses `siteNavigation` + +## Design System + +- Color palette is monochrome (grayscale oklch) +- Radius: `0.625rem` base with computed variants +- Animations: `fade-up`, `fade-in`, `scale-in` with staggered delays (100ms intervals) +- Header: sticky, backdrop-blur, gradient bottom border +- Footer: gradient top border matching header + +## Commands + +```bash +npm run dev # Start dev server +npm run build # Production build +npm run lint # ESLint +npm run registry:build # Build distributable registry to public/r/ +``` + diff --git a/public/r/delivery-tracker.json b/public/r/delivery-tracker.json index 1a1a7de..4443069 100644 --- a/public/r/delivery-tracker.json +++ b/public/r/delivery-tracker.json @@ -15,7 +15,7 @@ "files": [ { "path": "src/registry/blocks/delivery-tracker/page.tsx", - "content": "\"use client\";\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport { Clock3, Utensils, Truck, UserRound } from \"lucide-react\";\n\nimport {\n Map,\n MapMarker,\n MapRoute,\n MarkerContent,\n MarkerTooltip,\n} from \"@/components/ui/map\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\n\ninterface DeliveryMeal {\n name: string;\n price: string;\n quantity: number;\n}\n\ninterface OsrmRouteData {\n coordinates: [number, number][];\n duration: number;\n distance: number;\n}\n\nconst deliveryMeals: DeliveryMeal[] = [\n {\n name: \"Spicy Tofu Grain Bowl\",\n price: \"$44.00\",\n quantity: 1,\n },\n {\n name: \"Herb Chicken Rice Box\",\n price: \"$58.00\",\n quantity: 2,\n },\n {\n name: \"Roasted Veggie Wrap\",\n price: \"$29.00\",\n quantity: 1,\n },\n];\n\nconst pickup = { lng: -122.466, lat: 37.716 };\nconst dropoff = { lng: -122.399, lat: 37.683 };\n\nfunction formatDistance(meters?: number) {\n if (!meters) return \"--\";\n if (meters < 1000) return `${Math.round(meters)} m`;\n return `${(meters / 1000).toFixed(1)} km`;\n}\n\nfunction formatDuration(seconds?: number) {\n if (!seconds) return \"--\";\n const minutes = Math.round(seconds / 60);\n if (minutes < 60) return `${minutes} min`;\n const hours = Math.floor(minutes / 60);\n const remainingMinutes = minutes % 60;\n return `${hours}h ${remainingMinutes}m`;\n}\n\nexport default function Page() {\n const [routeData, setRouteData] = useState(null);\n const [loading, setLoading] = useState(true);\n\n useEffect(() => {\n async function fetchRoute() {\n setLoading(true);\n try {\n const response = await fetch(\n `https://router.project-osrm.org/route/v1/driving/${pickup.lng},${pickup.lat};${dropoff.lng},${dropoff.lat}?overview=full&geometries=geojson`,\n );\n const data = await response.json();\n const route = data?.routes?.[0];\n if (!route?.geometry?.coordinates) return;\n\n setRouteData({\n coordinates: route.geometry.coordinates as [number, number][],\n duration: route.duration as number,\n distance: route.distance as number,\n });\n } catch (error) {\n console.error(\"Failed to fetch route:\", error);\n } finally {\n setLoading(false);\n }\n }\n\n fetchRoute();\n }, []);\n\n const progressCoordinates = useMemo(() => {\n const progressCount = Math.max(\n 2,\n Math.floor(\n (routeData?.coordinates?.length ?? 0) * (routeData ? 0.62 : 0.66),\n ),\n );\n return routeData?.coordinates?.slice(0, progressCount) ?? [];\n }, [routeData]);\n\n const courierPosition = progressCoordinates[progressCoordinates.length - 1];\n\n return (\n
\n
\n
\n
\n

\n Track Delivery\n

\n

Mon Feb 10 - 2-3 PM

\n
\n\n \n \n \n Order items ({deliveryMeals.length})\n \n \n \n {deliveryMeals.map((meal) => (\n
\n
\n \n
\n
\n

\n {meal.name}\n

\n

\n {meal.price}\n

\n
\n \n x{meal.quantity}\n \n
\n ))}\n
\n Bundle total\n $189.00\n
\n
\n
\n\n
\n \n \n

\n Pickup confirmed\n

\n

Mon, Feb 10 at 1:48 PM

\n
\n
\n \n \n

\n Remaining travel\n

\n

\n {formatDuration(routeData?.duration)}\n

\n
\n
\n
\n\n
\n \n \n
\n
\n\n
\n \n \n \n\n {courierPosition && (\n \n \n
\n \n
\n
\n \n
\n

\n Order {formatDuration(routeData?.duration)} away\n

\n

\n Route {formatDistance(routeData?.distance)}\n

\n
\n
\n \n )}\n\n \n \n
\n \n Origin\n \n\n \n \n
\n \n Destination\n \n \n
\n
\n
\n );\n}\n", + "content": "\"use client\";\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport { Clock3, Utensils, Truck, UserRound } from \"lucide-react\";\n\nimport {\n Map,\n MapMarker,\n MapRoute,\n MarkerContent,\n MarkerTooltip,\n} from \"@/components/ui/map\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\n\ninterface DeliveryMeal {\n name: string;\n price: string;\n quantity: number;\n}\n\ninterface OsrmRouteData {\n coordinates: [number, number][];\n duration: number;\n distance: number;\n}\n\nconst deliveryMeals: DeliveryMeal[] = [\n {\n name: \"Spicy Tofu Grain Bowl\",\n price: \"$44.00\",\n quantity: 1,\n },\n {\n name: \"Herb Chicken Rice Box\",\n price: \"$58.00\",\n quantity: 2,\n },\n {\n name: \"Roasted Veggie Wrap\",\n price: \"$29.00\",\n quantity: 1,\n },\n];\n\nconst pickup = { lng: -122.466, lat: 37.716 };\nconst dropoff = { lng: -122.399, lat: 37.683 };\n\nfunction formatDistance(meters?: number) {\n if (!meters) return \"--\";\n if (meters < 1000) return `${Math.round(meters)} m`;\n return `${(meters / 1000).toFixed(1)} km`;\n}\n\nfunction formatDuration(seconds?: number) {\n if (!seconds) return \"--\";\n const minutes = Math.round(seconds / 60);\n if (minutes < 60) return `${minutes} min`;\n const hours = Math.floor(minutes / 60);\n const remainingMinutes = minutes % 60;\n return `${hours}h ${remainingMinutes}m`;\n}\n\nexport default function Page() {\n const [routeData, setRouteData] = useState(null);\n const [loading, setLoading] = useState(true);\n\n useEffect(() => {\n async function fetchRoute() {\n setLoading(true);\n try {\n const response = await fetch(\n `https://router.project-osrm.org/route/v1/driving/${pickup.lng},${pickup.lat};${dropoff.lng},${dropoff.lat}?overview=full&geometries=geojson`,\n );\n const data = await response.json();\n const route = data?.routes?.[0];\n if (!route?.geometry?.coordinates) return;\n\n setRouteData({\n coordinates: route.geometry.coordinates as [number, number][],\n duration: route.duration as number,\n distance: route.distance as number,\n });\n } catch (error) {\n console.error(\"Failed to fetch route:\", error);\n } finally {\n setLoading(false);\n }\n }\n\n fetchRoute();\n }, []);\n\n const progressCoordinates = useMemo(() => {\n const progressCount = Math.max(\n 2,\n Math.floor(\n (routeData?.coordinates?.length ?? 0) * (routeData ? 0.62 : 0.66),\n ),\n );\n return routeData?.coordinates?.slice(0, progressCount) ?? [];\n }, [routeData]);\n\n const courierPosition = progressCoordinates[progressCoordinates.length - 1];\n\n return (\n
\n
\n
\n
\n

\n Track Delivery\n

\n

Mon Feb 10 - 2-3 PM

\n
\n\n \n \n \n Order items ({deliveryMeals.length})\n \n \n \n {deliveryMeals.map((meal) => (\n
\n
\n \n
\n
\n

\n {meal.name}\n

\n

\n {meal.price}\n

\n
\n \n x{meal.quantity}\n \n
\n ))}\n
\n Bundle total\n $189.00\n
\n
\n
\n\n
\n \n \n

\n Pickup confirmed\n

\n

Mon, Feb 10 at 1:48 PM

\n
\n
\n \n \n

\n Remaining travel\n

\n

\n {formatDuration(routeData?.duration)}\n

\n
\n
\n
\n\n
\n \n \n
\n
\n\n
\n \n \n \n\n {courierPosition && (\n \n \n
\n \n
\n
\n \n
\n

\n Order {formatDuration(routeData?.duration)} away\n

\n

\n Route {formatDistance(routeData?.distance)}\n

\n
\n
\n \n )}\n\n \n \n
\n \n Origin\n \n\n \n \n
\n \n Destination\n \n \n
\n
\n
\n );\n}\n", "type": "registry:page", "target": "app/delivery/page.tsx" } diff --git a/public/r/map.json b/public/r/map.json index c010b39..42a1327 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\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 \n \n Close\n \n )}\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 \n \n Close\n \n )}\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\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", "type": "registry:ui", "target": "components/ui/map.tsx" } diff --git a/src/app/(main)/(home)/_components/examples/delivery-example.tsx b/src/app/(main)/(home)/_components/examples/delivery-example.tsx index 46ba944..336c595 100644 --- a/src/app/(main)/(home)/_components/examples/delivery-example.tsx +++ b/src/app/(main)/(home)/_components/examples/delivery-example.tsx @@ -46,7 +46,6 @@ export function DeliveryExample() { return ( diff --git a/src/app/(main)/(home)/_components/examples/ev-charging-example.tsx b/src/app/(main)/(home)/_components/examples/ev-charging-example.tsx index a6fe83a..3dc4623 100644 --- a/src/app/(main)/(home)/_components/examples/ev-charging-example.tsx +++ b/src/app/(main)/(home)/_components/examples/ev-charging-example.tsx @@ -117,7 +117,7 @@ const statusConfig: Record< export function EVChargingExample() { return ( - + {stations.map((station) => { const config = statusConfig[station.status]; diff --git a/src/app/(main)/(home)/_components/examples/example-card.tsx b/src/app/(main)/(home)/_components/examples/example-card.tsx index 855b00d..cad9abc 100644 --- a/src/app/(main)/(home)/_components/examples/example-card.tsx +++ b/src/app/(main)/(home)/_components/examples/example-card.tsx @@ -4,14 +4,12 @@ import { CSSProperties } from "react"; import { cn } from "@/lib/utils"; interface ExampleCardProps { - label?: string; className?: string; stagger?: number; children: React.ReactNode; } export function ExampleCard({ - label, className, stagger = 5, children, @@ -28,11 +26,6 @@ export function ExampleCard({ } as CSSProperties } > - {label && ( -

- {label} -
- )} {children} ); diff --git a/src/app/(main)/(home)/_components/examples/flyto-example.tsx b/src/app/(main)/(home)/_components/examples/flyto-example.tsx index e12a00c..a767ff8 100644 --- a/src/app/(main)/(home)/_components/examples/flyto-example.tsx +++ b/src/app/(main)/(home)/_components/examples/flyto-example.tsx @@ -23,10 +23,10 @@ export function FlyToExample() { const mapRef = useRef(null); return ( - + diff --git a/src/app/(main)/(home)/_components/examples/trending-example.tsx b/src/app/(main)/(home)/_components/examples/trending-example.tsx index be36568..6716d39 100644 --- a/src/app/(main)/(home)/_components/examples/trending-example.tsx +++ b/src/app/(main)/(home)/_components/examples/trending-example.tsx @@ -6,7 +6,7 @@ import { ExampleCard } from "./example-card"; export function TrendingExample() { return ( - + diff --git a/src/app/(main)/docs/_components/docs-toc.tsx b/src/app/(main)/docs/_components/docs-toc.tsx index db3e3f2..57849f0 100644 --- a/src/app/(main)/docs/_components/docs-toc.tsx +++ b/src/app/(main)/docs/_components/docs-toc.tsx @@ -58,7 +58,7 @@ export function DocsToc({ items, className }: DocsTocProps) { return (