{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "mapjsx", "type": "registry:ui", "title": "Map", "description": "A MapLibre-powered map component for React Vite with markers, popups, tooltips, routes, and controls.", "dependencies": [ "maplibre-gl", "lucide-react" ], "registryDependencies": [], "files": [ { "path": "components/ui/map.jsx", "type": "registry:ui", "content": "\"use client\";\n\nimport React, {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport MapLibreGL from \"maplibre-gl\";\nimport \"maplibre-gl/dist/maplibre-gl.css\";\nimport { X, Minus, Plus, Locate, Maximize, Loader2 } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Custom hook for Vite to detect dark mode without next-themes\n */\nfunction useResolvedTheme() {\n const [theme, setTheme] = useState(\n typeof document !== \"undefined\" && document.documentElement.classList.contains(\"dark\")\n ? \"dark\"\n : \"light\"\n );\n\n useEffect(() => {\n const observer = new MutationObserver(() => {\n const isDark = document.documentElement.classList.contains(\"dark\");\n setTheme(isDark ? \"dark\" : \"light\");\n });\n\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\"],\n });\n\n return () => observer.disconnect();\n }, []);\n\n return theme;\n}\n\nconst MapContext = createContext(null);\n\nexport function 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\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\nconst DefaultLoader = () => (\n
\n
\n \n \n \n
\n
\n);\n\nexport function Map({ children, styles, ...props }) {\n const containerRef = useRef(null);\n const mapRef = useRef(null);\n const [isMounted, setIsMounted] = useState(false);\n const [isLoaded, setIsLoaded] = useState(false);\n const [isStyleLoaded, setIsStyleLoaded] = useState(false);\n const resolvedTheme = useResolvedTheme();\n\n const mapStyles = useMemo(\n () => ({\n dark: styles?.dark ?? defaultStyles.dark,\n light: styles?.light ?? defaultStyles.light,\n }),\n [styles]\n );\n\n useEffect(() => {\n setIsMounted(true);\n }, []);\n\n useEffect(() => {\n if (!isMounted || !containerRef.current) return;\n\n const mapStyle = resolvedTheme === \"dark\" ? mapStyles.dark : mapStyles.light;\n\n const mapInstance = new MapLibreGL.Map({\n container: containerRef.current,\n style: mapStyle,\n renderWorldCopies: false,\n attributionControl: {\n compact: true,\n },\n ...props,\n });\n\n const styleDataHandler = () => setIsStyleLoaded(true);\n const loadHandler = () => setIsLoaded(true);\n\n mapInstance.on(\"load\", loadHandler);\n mapInstance.on(\"styledata\", styleDataHandler);\n mapRef.current = mapInstance;\n\n return () => {\n mapInstance.off(\"load\", loadHandler);\n mapInstance.off(\"styledata\", styleDataHandler);\n mapInstance.remove();\n mapRef.current = null;\n };\n }, [isMounted]);\n\n useEffect(() => {\n if (mapRef.current) {\n setIsStyleLoaded(false);\n mapRef.current.setStyle(\n resolvedTheme === \"dark\" ? mapStyles.dark : mapStyles.light,\n { diff: true }\n );\n }\n }, [resolvedTheme, mapStyles]);\n\n const isLoading = !isMounted || !isLoaded || !isStyleLoaded;\n\n return (\n \n
\n {isLoading && }\n {isMounted && children}\n
\n \n );\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\nexport function 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}) {\n const { map, isLoaded } = useMap();\n const markerRef = useRef(null);\n const markerElementRef = useRef(null);\n const [isReady, setIsReady] = useState(false);\n const markerOptionsRef = useRef(markerOptions);\n\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n const container = document.createElement(\"div\");\n markerElementRef.current = container;\n\n const marker = new MapLibreGL.Marker({\n ...markerOptions,\n element: container,\n draggable,\n })\n .setLngLat([longitude, latitude])\n .addTo(map);\n\n markerRef.current = marker;\n\n const handleClick = (e) => onClick?.(e);\n const handleMouseEnter = (e) => onMouseEnter?.(e);\n const handleMouseLeave = (e) => onMouseLeave?.(e);\n\n container.addEventListener(\"click\", handleClick);\n container.addEventListener(\"mouseenter\", handleMouseEnter);\n container.addEventListener(\"mouseleave\", handleMouseLeave);\n\n const handleDragStart = () => {\n const lngLat = marker.getLngLat();\n onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat });\n };\n const handleDrag = () => {\n const lngLat = marker.getLngLat();\n onDrag?.({ lng: lngLat.lng, lat: lngLat.lat });\n };\n const handleDragEnd = () => {\n const lngLat = marker.getLngLat();\n onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat });\n };\n\n marker.on(\"dragstart\", handleDragStart);\n marker.on(\"drag\", handleDrag);\n marker.on(\"dragend\", handleDragEnd);\n\n setIsReady(true);\n\n return () => {\n container.removeEventListener(\"click\", handleClick);\n container.removeEventListener(\"mouseenter\", handleMouseEnter);\n container.removeEventListener(\"mouseleave\", handleMouseLeave);\n marker.off(\"dragstart\", handleDragStart);\n marker.off(\"drag\", handleDrag);\n marker.off(\"dragend\", handleDragEnd);\n marker.remove();\n markerRef.current = null;\n markerElementRef.current = null;\n setIsReady(false);\n };\n }, [map, isLoaded]);\n\n useEffect(() => {\n markerRef.current?.setLngLat([longitude, latitude]);\n }, [longitude, latitude]);\n\n useEffect(() => {\n markerRef.current?.setDraggable(draggable);\n }, [draggable]);\n\n useEffect(() => {\n if (!markerRef.current) return;\n const prev = markerOptionsRef.current;\n if (prev.offset !== markerOptions.offset) markerRef.current.setOffset(markerOptions.offset ?? [0, 0]);\n if (prev.rotation !== markerOptions.rotation) markerRef.current.setRotation(markerOptions.rotation ?? 0);\n if (prev.rotationAlignment !== markerOptions.rotationAlignment) markerRef.current.setRotationAlignment(markerOptions.rotationAlignment ?? \"auto\");\n if (prev.pitchAlignment !== markerOptions.pitchAlignment) markerRef.current.setPitchAlignment(markerOptions.pitchAlignment ?? \"auto\");\n markerOptionsRef.current = markerOptions;\n }, [markerOptions]);\n\n return (\n \n {children}\n \n );\n}\n\nexport function MarkerContent({ children, className }) {\n const { markerElementRef, isReady } = useMarkerContext();\n if (!isReady || !markerElementRef.current) return null;\n return createPortal(\n
\n {children || }\n
,\n markerElementRef.current\n );\n}\n\nfunction DefaultMarkerIcon() {\n return
;\n}\n\nexport function MarkerPopup({ children, className, closeButton = false, ...popupOptions }) {\n const { markerRef, isReady } = useMarkerContext();\n const containerRef = useRef(null);\n const popupRef = useRef(null);\n const [mounted, setMounted] = useState(false);\n const popupOptionsRef = useRef(popupOptions);\n\n useEffect(() => {\n if (!isReady || !markerRef.current) return;\n const container = document.createElement(\"div\");\n containerRef.current = container;\n const popup = new MapLibreGL.Popup({ offset: 16, ...popupOptions, closeButton: false })\n .setMaxWidth(\"none\")\n .setDOMContent(container);\n popupRef.current = popup;\n markerRef.current.setPopup(popup);\n setMounted(true);\n return () => {\n popup.remove();\n popupRef.current = null;\n containerRef.current = null;\n setMounted(false);\n };\n }, [isReady]);\n\n useEffect(() => {\n if (!popupRef.current) return;\n const prev = popupOptionsRef.current;\n if (prev.offset !== popupOptions.offset) popupRef.current.setOffset(popupOptions.offset ?? 16);\n if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) popupRef.current.setMaxWidth(popupOptions.maxWidth ?? \"none\");\n popupOptionsRef.current = popupOptions;\n }, [popupOptions]);\n\n if (!mounted || !containerRef.current) return null;\n return createPortal(\n
\n {closeButton && (\n \n )}\n {children}\n
,\n containerRef.current\n );\n}\n\nexport function MarkerTooltip({ children, className, ...popupOptions }) {\n const { markerRef, markerElementRef, map, isReady } = useMarkerContext();\n const containerRef = useRef(null);\n const popupRef = useRef(null);\n const [mounted, setMounted] = useState(false);\n const popupOptionsRef = useRef(popupOptions);\n\n useEffect(() => {\n if (!isReady || !markerRef.current || !markerElementRef.current || !map) return;\n const container = document.createElement(\"div\");\n containerRef.current = container;\n const popup = new MapLibreGL.Popup({ offset: 16, ...popupOptions, closeOnClick: true, closeButton: false })\n .setMaxWidth(\"none\")\n .setDOMContent(container);\n popupRef.current = popup;\n const markerElement = markerElementRef.current;\n const marker = markerRef.current;\n const handleMouseEnter = () => popup.setLngLat(marker.getLngLat()).addTo(map);\n const handleMouseLeave = () => popup.remove();\n markerElement.addEventListener(\"mouseenter\", handleMouseEnter);\n markerElement.addEventListener(\"mouseleave\", handleMouseLeave);\n setMounted(true);\n return () => {\n markerElement.removeEventListener(\"mouseenter\", handleMouseEnter);\n markerElement.removeEventListener(\"mouseleave\", handleMouseLeave);\n popup.remove();\n popupRef.current = null;\n containerRef.current = null;\n setMounted(false);\n };\n }, [isReady, map]);\n\n if (!mounted || !containerRef.current) return null;\n return createPortal(\n
\n {children}\n
,\n containerRef.current\n );\n}\n\nexport function MarkerLabel({ children, className, position = \"top\" }) {\n const positionClasses = { top: \"bottom-full mb-1\", bottom: \"top-full mt-1\" };\n return (\n
\n {children}\n
\n );\n}\n\nconst mapControlPositionClasses = {\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 }) {\n return
button:not(:last-child)]:border-b [&>button:not(:last-child)]:border-border\">{children}
;\n}\n\nfunction ControlButton({ onClick, label, children, disabled = false }) {\n return (\n \n );\n}\n\nexport function MapControls({ position = \"bottom-right\", showZoom = true, showCompass = false, showLocate = false, showFullscreen = false, className, onLocate }) {\n const { map, isLoaded } = useMap();\n const [waitingForLocation, setWaitingForLocation] = useState(false);\n\n const handleZoomIn = useCallback(() => map?.zoomTo(map.getZoom() + 1, { duration: 300 }), [map]);\n const handleZoomOut = useCallback(() => map?.zoomTo(map.getZoom() - 1, { duration: 300 }), [map]);\n const handleResetBearing = useCallback(() => map?.resetNorthPitch({ duration: 300 }), [map]);\n const handleLocate = useCallback(() => {\n setWaitingForLocation(true);\n if (\"geolocation\" in navigator) {\n navigator.geolocation.getCurrentPosition((pos) => {\n const coords = { longitude: pos.coords.longitude, latitude: pos.coords.latitude };\n map?.flyTo({ center: [coords.longitude, coords.latitude], zoom: 14, duration: 1500 });\n onLocate?.(coords);\n setWaitingForLocation(false);\n }, () => setWaitingForLocation(false));\n }\n }, [map, onLocate]);\n const handleFullscreen = useCallback(() => {\n const container = map?.getContainer();\n if (!container) return;\n document.fullscreenElement ? document.exitFullscreen() : container.requestFullscreen();\n }, [map]);\n\n if (!isLoaded) return null;\n return (\n
\n {showZoom && }\n {showCompass && }\n {showLocate && {waitingForLocation ? : }}\n {showFullscreen && }\n
\n );\n}\n\nfunction CompassButton({ onClick }) {\n const { isLoaded, map } = useMap();\n const compassRef = useRef(null);\n useEffect(() => {\n if (!isLoaded || !map || !compassRef.current) return;\n const updateRotation = () => {\n compassRef.current.style.transform = `rotateX(${map.getPitch()}deg) rotateZ(${-map.getBearing()}deg)`;\n };\n map.on(\"rotate\", updateRotation);\n map.on(\"pitch\", updateRotation);\n updateRotation();\n return () => { map.off(\"rotate\", updateRotation); map.off(\"pitch\", updateRotation); };\n }, [isLoaded, map]);\n return (\n \n \n \n \n \n \n );\n}\n\nexport function MapPopup({ longitude, latitude, onClose, children, className, closeButton = false, ...popupOptions }) {\n const { map } = useMap();\n const popupRef = useRef(null);\n const container = useMemo(() => document.createElement(\"div\"), []);\n useEffect(() => {\n if (!map) return;\n const popup = new MapLibreGL.Popup({ offset: 16, ...popupOptions, closeButton: false }).setDOMContent(container).setLngLat([longitude, latitude]).addTo(map);\n popup.on(\"close\", () => onClose?.());\n popupRef.current = popup;\n return () => { if (popup.isOpen()) popup.remove(); popupRef.current = null; };\n }, [map]);\n useEffect(() => { popupRef.current?.setLngLat([longitude, latitude]); }, [longitude, latitude]);\n return createPortal(\n
\n {closeButton && (\n \n )}\n {children}\n
,\n container\n );\n}\n\nexport function MapRoute({ coordinates, color = \"#4285F4\", width = 3, opacity = 0.8, dashArray }) {\n const { map, isLoaded } = useMap();\n const id = useId();\n const sourceId = `route-source-${id}`;\n const layerId = `route-layer-${id}`;\n useEffect(() => {\n if (!isLoaded || !map) return;\n map.addSource(sourceId, { type: \"geojson\", data: { type: \"Feature\", properties: {}, geometry: { type: \"LineString\", coordinates: [] } } });\n map.addLayer({ id: layerId, type: \"line\", source: sourceId, layout: { \"line-join\": \"round\", \"line-cap\": \"round\" }, paint: { \"line-color\": color, \"line-width\": width, \"line-opacity\": opacity, ...(dashArray && { \"line-dasharray\": dashArray }) } });\n return () => { try { if (map.getLayer(layerId)) map.removeLayer(layerId); if (map.getSource(sourceId)) map.removeSource(sourceId); } catch {} };\n }, [isLoaded, map]);\n useEffect(() => {\n if (!isLoaded || !map || coordinates.length < 2) return;\n const source = map.getSource(sourceId);\n if (source) source.setData({ type: \"Feature\", properties: {}, geometry: { type: \"LineString\", coordinates } });\n }, [isLoaded, map, coordinates]);\n return null;\n}\n" } ], "css": { "@layer base": { ".maplibregl-popup-content": { "@apply bg-transparent! shadow-none! p-0! rounded-none!": {} }, ".maplibregl-popup-tip": { "@apply hidden!": {} } } } }