diff --git a/registry.json b/registry.json index f8aa142..59a7583 100644 --- a/registry.json +++ b/registry.json @@ -12,9 +12,49 @@ "registryDependencies": [], "files": [ { - "path": "src/registry/map.tsx", + "path": "src/registry/map/index.tsx", "type": "registry:ui", - "target": "components/ui/map.tsx" + "target": "components/ui/map/index.tsx" + }, + { + "path": "src/registry/map/types.ts", + "type": "registry:ui", + "target": "components/ui/map/types.ts" + }, + { + "path": "src/registry/map/hooks.ts", + "type": "registry:ui", + "target": "components/ui/map/hooks.ts" + }, + { + "path": "src/registry/map/map.tsx", + "type": "registry:ui", + "target": "components/ui/map/map.tsx" + }, + { + "path": "src/registry/map/marker.tsx", + "type": "registry:ui", + "target": "components/ui/map/marker.tsx" + }, + { + "path": "src/registry/map/controls.tsx", + "type": "registry:ui", + "target": "components/ui/map/controls.tsx" + }, + { + "path": "src/registry/map/popup.tsx", + "type": "registry:ui", + "target": "components/ui/map/popup.tsx" + }, + { + "path": "src/registry/map/route.tsx", + "type": "registry:ui", + "target": "components/ui/map/route.tsx" + }, + { + "path": "src/registry/map/cluster.tsx", + "type": "registry:ui", + "target": "components/ui/map/cluster.tsx" } ], "css": { diff --git a/src/registry/map.tsx b/src/registry/map.tsx deleted file mode 100644 index 471a4f4..0000000 --- a/src/registry/map.tsx +++ /dev/null @@ -1,1247 +0,0 @@ -"use client"; - -import MapLibreGL, { type PopupOptions, type MarkerOptions } from "maplibre-gl"; -import "maplibre-gl/dist/maplibre-gl.css"; -import { useTheme } from "next-themes"; -import { - createContext, - useCallback, - useContext, - useEffect, - useId, - useMemo, - useRef, - useState, - type ReactNode, -} from "react"; -import { createPortal } from "react-dom"; -import { X, Minus, Plus, Locate, Maximize, Loader2 } from "lucide-react"; - -import { cn } from "@/lib/utils"; - -type MapContextValue = { - map: MapLibreGL.Map | null; - isLoaded: boolean; -}; - -const MapContext = createContext(null); - -function useMap() { - const context = useContext(MapContext); - if (!context) { - throw new Error("useMap must be used within a Map component"); - } - return context; -} - -const defaultStyles = { - dark: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", - light: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", -}; - -type MapStyleOption = string | MapLibreGL.StyleSpecification; - -type MapProps = { - children?: ReactNode; - /** Custom map styles for light and dark themes. Overrides the default Carto styles. */ - styles?: { - light?: MapStyleOption; - dark?: MapStyleOption; - }; -} & Omit; - -const DefaultLoader = () => ( -
-
- - - -
-
-); - -function Map({ children, styles, ...props }: MapProps) { - const containerRef = useRef(null); - const mapRef = useRef(null); - const [isLoaded, setIsLoaded] = useState(false); - const [isStyleLoaded, setIsStyleLoaded] = useState(false); - const { resolvedTheme } = useTheme(); - - const mapStyles = useMemo( - () => ({ - dark: styles?.dark ?? defaultStyles.dark, - light: styles?.light ?? defaultStyles.light, - }), - [styles] - ); - - useEffect(() => { - if (!containerRef.current) return; - - const mapStyle = - resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light; - - const mapInstance = new MapLibreGL.Map({ - container: containerRef.current, - style: mapStyle, - renderWorldCopies: false, - attributionControl: { - compact: true, - }, - ...props, - }); - - const styleDataHandler = () => setIsStyleLoaded(true); - const loadHandler = () => setIsLoaded(true); - - mapInstance.on("load", loadHandler); - mapInstance.on("styledata", styleDataHandler); - mapRef.current = mapInstance; - - return () => { - mapInstance.off("load", loadHandler); - mapInstance.off("styledata", styleDataHandler); - mapInstance.remove(); - mapRef.current = null; - setIsLoaded(false); - setIsStyleLoaded(false); - }; - }, []); - - useEffect(() => { - if (mapRef.current) { - setIsStyleLoaded(false); - mapRef.current.setStyle( - resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light, - { diff: true } - ); - } - }, [resolvedTheme, mapStyles]); - - const isLoading = !isLoaded || !isStyleLoaded; - - const contextValue = useMemo( - () => ({ - map: mapRef.current, - isLoaded: isLoaded && isStyleLoaded, - }), - [isLoaded, isStyleLoaded] - ); - - return ( - -
- {isLoading && } - {/* SSR-safe: children render only when map exists on client */} - {mapRef.current && children} -
-
- ); -} - -type MarkerContextValue = { - markerRef: React.RefObject; - markerElementRef: React.RefObject; - map: MapLibreGL.Map | null; - isReady: boolean; -}; - -const MarkerContext = createContext(null); - -function useMarkerContext() { - const context = useContext(MarkerContext); - if (!context) { - throw new Error("Marker components must be used within MapMarker"); - } - return context; -} - -type MapMarkerProps = { - /** Longitude coordinate for marker position */ - longitude: number; - /** Latitude coordinate for marker position */ - latitude: number; - /** Marker subcomponents (MarkerContent, MarkerPopup, MarkerTooltip, MarkerLabel) */ - children: ReactNode; - /** Callback when marker is clicked */ - onClick?: (e: MouseEvent) => void; - /** Callback when mouse enters marker */ - onMouseEnter?: (e: MouseEvent) => void; - /** Callback when mouse leaves marker */ - onMouseLeave?: (e: MouseEvent) => void; - /** Callback when marker drag starts (requires draggable: true) */ - onDragStart?: (lngLat: { lng: number; lat: number }) => void; - /** Callback during marker drag (requires draggable: true) */ - onDrag?: (lngLat: { lng: number; lat: number }) => void; - /** Callback when marker drag ends (requires draggable: true) */ - onDragEnd?: (lngLat: { lng: number; lat: number }) => void; -} & Omit; - -function MapMarker({ - longitude, - latitude, - children, - onClick, - onMouseEnter, - onMouseLeave, - onDragStart, - onDrag, - onDragEnd, - draggable = false, - ...markerOptions -}: MapMarkerProps) { - const { map, isLoaded } = useMap(); - const markerRef = useRef(null); - const markerElementRef = useRef(null); - const [isReady, setIsReady] = useState(false); - const markerOptionsRef = useRef(markerOptions); - - useEffect(() => { - if (!isLoaded || !map) return; - - const container = document.createElement("div"); - markerElementRef.current = container; - - const marker = new MapLibreGL.Marker({ - ...markerOptions, - element: container, - draggable, - }) - .setLngLat([longitude, latitude]) - .addTo(map); - - markerRef.current = marker; - - const handleClick = (e: MouseEvent) => onClick?.(e); - const handleMouseEnter = (e: MouseEvent) => onMouseEnter?.(e); - const handleMouseLeave = (e: MouseEvent) => onMouseLeave?.(e); - - container.addEventListener("click", handleClick); - container.addEventListener("mouseenter", handleMouseEnter); - container.addEventListener("mouseleave", handleMouseLeave); - - const handleDragStart = () => { - const lngLat = marker.getLngLat(); - onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat }); - }; - const handleDrag = () => { - const lngLat = marker.getLngLat(); - onDrag?.({ lng: lngLat.lng, lat: lngLat.lat }); - }; - const handleDragEnd = () => { - const lngLat = marker.getLngLat(); - onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat }); - }; - - marker.on("dragstart", handleDragStart); - marker.on("drag", handleDrag); - marker.on("dragend", handleDragEnd); - - setIsReady(true); - - return () => { - container.removeEventListener("click", handleClick); - container.removeEventListener("mouseenter", handleMouseEnter); - container.removeEventListener("mouseleave", handleMouseLeave); - - marker.off("dragstart", handleDragStart); - marker.off("drag", handleDrag); - marker.off("dragend", handleDragEnd); - - marker.remove(); - markerRef.current = null; - markerElementRef.current = null; - setIsReady(false); - }; - }, [map, isLoaded]); - - useEffect(() => { - markerRef.current?.setLngLat([longitude, latitude]); - }, [longitude, latitude]); - - useEffect(() => { - markerRef.current?.setDraggable(draggable); - }, [draggable]); - - useEffect(() => { - if (!markerRef.current) return; - const prev = markerOptionsRef.current; - - if (prev.offset !== markerOptions.offset) { - markerRef.current.setOffset(markerOptions.offset ?? [0, 0]); - } - if (prev.rotation !== markerOptions.rotation) { - markerRef.current.setRotation(markerOptions.rotation ?? 0); - } - if (prev.rotationAlignment !== markerOptions.rotationAlignment) { - markerRef.current.setRotationAlignment( - markerOptions.rotationAlignment ?? "auto" - ); - } - if (prev.pitchAlignment !== markerOptions.pitchAlignment) { - markerRef.current.setPitchAlignment( - markerOptions.pitchAlignment ?? "auto" - ); - } - - markerOptionsRef.current = markerOptions; - }, [markerOptions]); - - return ( - - {children} - - ); -} - -type MarkerContentProps = { - /** Custom marker content. Defaults to a blue dot if not provided */ - children?: ReactNode; - /** Additional CSS classes for the marker container */ - className?: string; -}; - -function MarkerContent({ children, className }: MarkerContentProps) { - const { markerElementRef, isReady } = useMarkerContext(); - - if (!isReady || !markerElementRef.current) return null; - - return createPortal( -
- {children || } -
, - markerElementRef.current - ); -} - -function DefaultMarkerIcon() { - return ( -
- ); -} - -type MarkerPopupProps = { - /** Popup content */ - children: ReactNode; - /** Additional CSS classes for the popup container */ - className?: string; - /** Show a close button in the popup (default: false) */ - closeButton?: boolean; -} & Omit; - -function MarkerPopup({ - children, - className, - closeButton = false, - ...popupOptions -}: MarkerPopupProps) { - const { markerRef, isReady } = useMarkerContext(); - const containerRef = useRef(null); - const popupRef = useRef(null); - const [mounted, setMounted] = useState(false); - const popupOptionsRef = useRef(popupOptions); - - useEffect(() => { - if (!isReady || !markerRef.current) return; - - const container = document.createElement("div"); - containerRef.current = container; - - const popup = new MapLibreGL.Popup({ - offset: 16, - ...popupOptions, - closeButton: false, - }) - .setMaxWidth("none") - .setDOMContent(container); - - popupRef.current = popup; - markerRef.current.setPopup(popup); - setMounted(true); - - return () => { - popup.remove(); - popupRef.current = null; - containerRef.current = null; - setMounted(false); - }; - }, [isReady]); - - useEffect(() => { - if (!popupRef.current) return; - const prev = popupOptionsRef.current; - - if (prev.offset !== popupOptions.offset) { - popupRef.current.setOffset(popupOptions.offset ?? 16); - } - if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { - popupRef.current.setMaxWidth(popupOptions.maxWidth ?? "none"); - } - - popupOptionsRef.current = popupOptions; - }, [popupOptions]); - - const handleClose = () => popupRef.current?.remove(); - - if (!mounted || !containerRef.current) return null; - - return createPortal( -
- {closeButton && ( - - )} - {children} -
, - containerRef.current - ); -} - -type MarkerTooltipProps = { - /** Tooltip content */ - children: ReactNode; - /** Additional CSS classes for the tooltip container */ - className?: string; -} & Omit; - -function MarkerTooltip({ - children, - className, - ...popupOptions -}: MarkerTooltipProps) { - const { markerRef, markerElementRef, map, isReady } = useMarkerContext(); - const containerRef = useRef(null); - const popupRef = useRef(null); - const [mounted, setMounted] = useState(false); - const popupOptionsRef = useRef(popupOptions); - - useEffect(() => { - if (!isReady || !markerRef.current || !markerElementRef.current || !map) - return; - - const container = document.createElement("div"); - containerRef.current = container; - - const popup = new MapLibreGL.Popup({ - offset: 16, - ...popupOptions, - closeOnClick: true, - closeButton: false, - }) - .setMaxWidth("none") - .setDOMContent(container); - - popupRef.current = popup; - - const markerElement = markerElementRef.current; - const marker = markerRef.current; - - const handleMouseEnter = () => { - popup.setLngLat(marker.getLngLat()).addTo(map); - }; - const handleMouseLeave = () => popup.remove(); - - markerElement.addEventListener("mouseenter", handleMouseEnter); - markerElement.addEventListener("mouseleave", handleMouseLeave); - setMounted(true); - - return () => { - markerElement.removeEventListener("mouseenter", handleMouseEnter); - markerElement.removeEventListener("mouseleave", handleMouseLeave); - popup.remove(); - popupRef.current = null; - containerRef.current = null; - setMounted(false); - }; - }, [isReady, map]); - - useEffect(() => { - if (!popupRef.current) return; - const prev = popupOptionsRef.current; - - if (prev.offset !== popupOptions.offset) { - popupRef.current.setOffset(popupOptions.offset ?? 16); - } - if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { - popupRef.current.setMaxWidth(popupOptions.maxWidth ?? "none"); - } - - popupOptionsRef.current = popupOptions; - }, [popupOptions]); - - if (!mounted || !containerRef.current) return null; - - return createPortal( -
- {children} -
, - containerRef.current - ); -} - -type MarkerLabelProps = { - /** Label text content */ - children: ReactNode; - /** Additional CSS classes for the label */ - className?: string; - /** Position of the label relative to the marker (default: "top") */ - position?: "top" | "bottom"; -}; - -function MarkerLabel({ - children, - className, - position = "top", -}: MarkerLabelProps) { - const positionClasses = { - top: "bottom-full mb-1", - bottom: "top-full mt-1", - }; - - return ( -
- {children} -
- ); -} - -type MapControlsProps = { - /** Position of the controls on the map (default: "bottom-right") */ - position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"; - /** Show zoom in/out buttons (default: true) */ - showZoom?: boolean; - /** Show compass button to reset bearing (default: false) */ - showCompass?: boolean; - /** Show locate button to find user's location (default: false) */ - showLocate?: boolean; - /** Show fullscreen toggle button (default: false) */ - showFullscreen?: boolean; - /** Additional CSS classes for the controls container */ - className?: string; - /** Callback with user coordinates when located */ - onLocate?: (coords: { longitude: number; latitude: number }) => void; -}; - -const positionClasses = { - "top-left": "top-2 left-2", - "top-right": "top-2 right-2", - "bottom-left": "bottom-2 left-2", - "bottom-right": "bottom-10 right-2", -}; - -function ControlGroup({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} - -function ControlButton({ - onClick, - label, - children, - disabled = false, -}: { - onClick: () => void; - label: string; - children: React.ReactNode; - disabled?: boolean; -}) { - return ( - - ); -} - -function MapControls({ - position = "bottom-right", - showZoom = true, - showCompass = false, - showLocate = false, - showFullscreen = false, - className, - onLocate, -}: MapControlsProps) { - const { map, isLoaded } = useMap(); - const [waitingForLocation, setWaitingForLocation] = useState(false); - - const handleZoomIn = useCallback(() => { - map?.zoomTo(map.getZoom() + 1, { duration: 300 }); - }, [map]); - - const handleZoomOut = useCallback(() => { - map?.zoomTo(map.getZoom() - 1, { duration: 300 }); - }, [map]); - - const handleResetBearing = useCallback(() => { - map?.resetNorthPitch({ duration: 300 }); - }, [map]); - - const handleLocate = useCallback(() => { - setWaitingForLocation(true); - if ("geolocation" in navigator) { - navigator.geolocation.getCurrentPosition( - (pos) => { - const coords = { - longitude: pos.coords.longitude, - latitude: pos.coords.latitude, - }; - map?.flyTo({ - center: [coords.longitude, coords.latitude], - zoom: 14, - duration: 1500, - }); - onLocate?.(coords); - setWaitingForLocation(false); - }, - (error) => { - console.error("Error getting location:", error); - setWaitingForLocation(false); - } - ); - } - }, [map, onLocate]); - - const handleFullscreen = useCallback(() => { - const container = map?.getContainer(); - if (!container) return; - if (document.fullscreenElement) { - document.exitFullscreen(); - } else { - container.requestFullscreen(); - } - }, [map]); - - if (!isLoaded) return null; - - return ( -
- {showZoom && ( - - - - - - - - - )} - {showCompass && ( - - - - )} - {showLocate && ( - - - {waitingForLocation ? ( - - ) : ( - - )} - - - )} - {showFullscreen && ( - - - - - - )} -
- ); -} - -function CompassButton({ onClick }: { onClick: () => void }) { - const { isLoaded, map } = useMap(); - const compassRef = useRef(null); - - useEffect(() => { - if (!isLoaded || !map || !compassRef.current) return; - - const compass = compassRef.current; - - const updateRotation = () => { - const bearing = map.getBearing(); - const pitch = map.getPitch(); - compass.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`; - }; - - map.on("rotate", updateRotation); - map.on("pitch", updateRotation); - updateRotation(); - - return () => { - map.off("rotate", updateRotation); - map.off("pitch", updateRotation); - }; - }, [isLoaded, map]); - - return ( - - - - - - - - - ); -} - -type MapPopupProps = { - /** Longitude coordinate for popup position */ - longitude: number; - /** Latitude coordinate for popup position */ - latitude: number; - /** Callback when popup is closed */ - onClose?: () => void; - /** Popup content */ - children: ReactNode; - /** Additional CSS classes for the popup container */ - className?: string; - /** Show a close button in the popup (default: false) */ - closeButton?: boolean; -} & Omit; - -function MapPopup({ - longitude, - latitude, - onClose, - children, - className, - closeButton = false, - ...popupOptions -}: MapPopupProps) { - const { map } = useMap(); - const popupRef = useRef(null); - const popupOptionsRef = useRef(popupOptions); - - const container = useMemo(() => document.createElement("div"), []); - - useEffect(() => { - if (!map) return; - - const popup = new MapLibreGL.Popup({ - offset: 16, - ...popupOptions, - closeButton: false, - }) - .setMaxWidth("none") - .setDOMContent(container) - .setLngLat([longitude, latitude]) - .addTo(map); - - const onCloseProp = () => onClose?.(); - - popup.on("close", onCloseProp); - - popupRef.current = popup; - - return () => { - popup.off("close", onCloseProp); - if (popup.isOpen()) { - popup.remove(); - } - popupRef.current = null; - }; - }, [map]); - - useEffect(() => { - popupRef.current?.setLngLat([longitude, latitude]); - }, [longitude, latitude]); - - useEffect(() => { - if (!popupRef.current) return; - const prev = popupOptionsRef.current; - - if (prev.offset !== popupOptions.offset) { - popupRef.current.setOffset(popupOptions.offset ?? 16); - } - if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { - popupRef.current.setMaxWidth(popupOptions.maxWidth ?? "none"); - } - - popupOptionsRef.current = popupOptions; - }, [popupOptions]); - - const handleClose = () => { - popupRef.current?.remove(); - onClose?.(); - }; - - return createPortal( -
- {closeButton && ( - - )} - {children} -
, - container - ); -} - -type MapRouteProps = { - /** Array of [longitude, latitude] coordinate pairs defining the route */ - coordinates: [number, number][]; - /** Line color as CSS color value (default: "#4285F4") */ - color?: string; - /** Line width in pixels (default: 3) */ - width?: number; - /** Line opacity from 0 to 1 (default: 0.8) */ - opacity?: number; - /** Dash pattern [dash length, gap length] for dashed lines */ - dashArray?: [number, number]; -}; - -function MapRoute({ - coordinates, - color = "#4285F4", - width = 3, - opacity = 0.8, - dashArray, -}: MapRouteProps) { - const { map, isLoaded } = useMap(); - const id = useId(); - const sourceId = `route-source-${id}`; - const layerId = `route-layer-${id}`; - - // Add source and layer on mount - useEffect(() => { - if (!isLoaded || !map) return; - - map.addSource(sourceId, { - type: "geojson", - data: { - type: "Feature", - properties: {}, - geometry: { type: "LineString", coordinates: [] }, - }, - }); - - map.addLayer({ - id: layerId, - type: "line", - source: sourceId, - layout: { "line-join": "round", "line-cap": "round" }, - paint: { - "line-color": color, - "line-width": width, - "line-opacity": opacity, - ...(dashArray && { "line-dasharray": dashArray }), - }, - }); - - return () => { - try { - if (map.getLayer(layerId)) map.removeLayer(layerId); - if (map.getSource(sourceId)) map.removeSource(sourceId); - } catch { - // ignore - } - }; - }, [isLoaded, map, sourceId, layerId]); - - // When coordinates change, update the source data - useEffect(() => { - if (!isLoaded || !map || coordinates.length < 2) return; - - const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; - if (source) { - source.setData({ - type: "Feature", - properties: {}, - geometry: { type: "LineString", coordinates }, - }); - } - }, [isLoaded, map, coordinates, sourceId]); - - useEffect(() => { - if (!isLoaded || !map || !map.getLayer(layerId)) return; - - map.setPaintProperty(layerId, "line-color", color); - map.setPaintProperty(layerId, "line-width", width); - map.setPaintProperty(layerId, "line-opacity", opacity); - if (dashArray) { - map.setPaintProperty(layerId, "line-dasharray", dashArray); - } - }, [isLoaded, map, layerId, color, width, opacity, dashArray]); - - return null; -} - -type MapClusterLayerProps< - P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties -> = { - /** GeoJSON FeatureCollection data or URL to fetch GeoJSON from */ - data: string | GeoJSON.FeatureCollection; - /** Maximum zoom level to cluster points on (default: 14) */ - clusterMaxZoom?: number; - /** Radius of each cluster when clustering points in pixels (default: 50) */ - clusterRadius?: number; - /** Colors for cluster circles: [small, medium, large] based on point count (default: ["#51bbd6", "#f1f075", "#f28cb1"]) */ - clusterColors?: [string, string, string]; - /** Point count thresholds for color/size steps: [medium, large] (default: [100, 750]) */ - clusterThresholds?: [number, number]; - /** Color for unclustered individual points (default: "#3b82f6") */ - pointColor?: string; - /** Callback when an unclustered point is clicked */ - onPointClick?: ( - feature: GeoJSON.Feature, - coordinates: [number, number] - ) => void; - /** Callback when a cluster is clicked. If not provided, zooms into the cluster */ - onClusterClick?: ( - clusterId: number, - coordinates: [number, number], - pointCount: number - ) => void; -}; - -function MapClusterLayer< - P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties ->({ - data, - clusterMaxZoom = 14, - clusterRadius = 50, - clusterColors = ["#51bbd6", "#f1f075", "#f28cb1"], - clusterThresholds = [100, 750], - pointColor = "#3b82f6", - onPointClick, - onClusterClick, -}: MapClusterLayerProps

) { - const { map, isLoaded } = useMap(); - const id = useId(); - const sourceId = `cluster-source-${id}`; - const clusterLayerId = `clusters-${id}`; - const clusterCountLayerId = `cluster-count-${id}`; - const unclusteredLayerId = `unclustered-point-${id}`; - - const stylePropsRef = useRef({ - clusterColors, - clusterThresholds, - pointColor, - }); - - // Add source and layers on mount - useEffect(() => { - if (!isLoaded || !map) return; - - // Add clustered GeoJSON source - map.addSource(sourceId, { - type: "geojson", - data: typeof data === "string" ? data : data, - cluster: true, - clusterMaxZoom, - clusterRadius, - }); - - // Add cluster circles layer - map.addLayer({ - id: clusterLayerId, - type: "circle", - source: sourceId, - filter: ["has", "point_count"], - paint: { - "circle-color": [ - "step", - ["get", "point_count"], - clusterColors[0], - clusterThresholds[0], - clusterColors[1], - clusterThresholds[1], - clusterColors[2], - ], - "circle-radius": [ - "step", - ["get", "point_count"], - 20, - clusterThresholds[0], - 30, - clusterThresholds[1], - 40, - ], - }, - }); - - // Add cluster count text layer - map.addLayer({ - id: clusterCountLayerId, - type: "symbol", - source: sourceId, - filter: ["has", "point_count"], - layout: { - "text-field": "{point_count_abbreviated}", - "text-size": 12, - }, - paint: { - "text-color": "#fff", - }, - }); - - // Add unclustered point layer - map.addLayer({ - id: unclusteredLayerId, - type: "circle", - source: sourceId, - filter: ["!", ["has", "point_count"]], - paint: { - "circle-color": pointColor, - "circle-radius": 6, - }, - }); - - return () => { - try { - if (map.getLayer(clusterCountLayerId)) - map.removeLayer(clusterCountLayerId); - if (map.getLayer(unclusteredLayerId)) - map.removeLayer(unclusteredLayerId); - if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId); - if (map.getSource(sourceId)) map.removeSource(sourceId); - } catch { - // ignore - } - }; - }, [isLoaded, map, sourceId]); - - // Update source data when data prop changes (only for non-URL data ) - useEffect(() => { - if (!isLoaded || !map || typeof data === "string") return; - - const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; - if (source) { - source.setData(data); - } - }, [isLoaded, map, data, sourceId]); - - // Update layer styles when props change - useEffect(() => { - if (!isLoaded || !map) return; - - const prev = stylePropsRef.current; - const colorsChanged = - prev.clusterColors !== clusterColors || - prev.clusterThresholds !== clusterThresholds; - - // Update cluster layer colors and sizes - if (map.getLayer(clusterLayerId) && colorsChanged) { - map.setPaintProperty(clusterLayerId, "circle-color", [ - "step", - ["get", "point_count"], - clusterColors[0], - clusterThresholds[0], - clusterColors[1], - clusterThresholds[1], - clusterColors[2], - ]); - map.setPaintProperty(clusterLayerId, "circle-radius", [ - "step", - ["get", "point_count"], - 20, - clusterThresholds[0], - 30, - clusterThresholds[1], - 40, - ]); - } - - // Update unclustered point layer color - if (map.getLayer(unclusteredLayerId) && prev.pointColor !== pointColor) { - map.setPaintProperty(unclusteredLayerId, "circle-color", pointColor); - } - - stylePropsRef.current = { clusterColors, clusterThresholds, pointColor }; - }, [ - isLoaded, - map, - clusterLayerId, - unclusteredLayerId, - clusterColors, - clusterThresholds, - pointColor, - ]); - - // Handle click events - useEffect(() => { - if (!isLoaded || !map) return; - - // Cluster click handler - zoom into cluster - const handleClusterClick = async ( - e: MapLibreGL.MapMouseEvent & { - features?: MapLibreGL.MapGeoJSONFeature[]; - } - ) => { - const features = map.queryRenderedFeatures(e.point, { - layers: [clusterLayerId], - }); - if (!features.length) return; - - const feature = features[0]; - const clusterId = feature.properties?.cluster_id as number; - const pointCount = feature.properties?.point_count as number; - const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [ - number, - number - ]; - - if (onClusterClick) { - onClusterClick(clusterId, coordinates, pointCount); - } else { - // Default behavior: zoom to cluster expansion zoom - const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; - const zoom = await source.getClusterExpansionZoom(clusterId); - map.easeTo({ - center: coordinates, - zoom, - }); - } - }; - - // Unclustered point click handler - const handlePointClick = ( - e: MapLibreGL.MapMouseEvent & { - features?: MapLibreGL.MapGeoJSONFeature[]; - } - ) => { - if (!onPointClick || !e.features?.length) return; - - const feature = e.features[0]; - const coordinates = ( - feature.geometry as GeoJSON.Point - ).coordinates.slice() as [number, number]; - - // Handle world copies - while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { - coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360; - } - - onPointClick( - feature as unknown as GeoJSON.Feature, - coordinates - ); - }; - - // Cursor style handlers - const handleMouseEnterCluster = () => { - map.getCanvas().style.cursor = "pointer"; - }; - const handleMouseLeaveCluster = () => { - map.getCanvas().style.cursor = ""; - }; - const handleMouseEnterPoint = () => { - if (onPointClick) { - map.getCanvas().style.cursor = "pointer"; - } - }; - const handleMouseLeavePoint = () => { - map.getCanvas().style.cursor = ""; - }; - - map.on("click", clusterLayerId, handleClusterClick); - map.on("click", unclusteredLayerId, handlePointClick); - map.on("mouseenter", clusterLayerId, handleMouseEnterCluster); - map.on("mouseleave", clusterLayerId, handleMouseLeaveCluster); - map.on("mouseenter", unclusteredLayerId, handleMouseEnterPoint); - map.on("mouseleave", unclusteredLayerId, handleMouseLeavePoint); - - return () => { - map.off("click", clusterLayerId, handleClusterClick); - map.off("click", unclusteredLayerId, handlePointClick); - map.off("mouseenter", clusterLayerId, handleMouseEnterCluster); - map.off("mouseleave", clusterLayerId, handleMouseLeaveCluster); - map.off("mouseenter", unclusteredLayerId, handleMouseEnterPoint); - map.off("mouseleave", unclusteredLayerId, handleMouseLeavePoint); - }; - }, [ - isLoaded, - map, - clusterLayerId, - unclusteredLayerId, - sourceId, - onClusterClick, - onPointClick, - ]); - - return null; -} - -export { - Map, - useMap, - MapMarker, - MarkerContent, - MarkerPopup, - MarkerTooltip, - MarkerLabel, - MapPopup, - MapControls, - MapRoute, - MapClusterLayer, -}; diff --git a/src/registry/map/cluster.tsx b/src/registry/map/cluster.tsx new file mode 100644 index 0000000..459aa0a --- /dev/null +++ b/src/registry/map/cluster.tsx @@ -0,0 +1,301 @@ +"use client"; + +import MapLibreGL from "maplibre-gl"; +import { useEffect, useId, useRef } from "react"; +import { useMap } from "./hooks"; + +type MapClusterLayerProps< + P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties +> = { + /** GeoJSON FeatureCollection data or URL to fetch GeoJSON from */ + data: string | GeoJSON.FeatureCollection; + /** Maximum zoom level to cluster points on (default: 14) */ + clusterMaxZoom?: number; + /** Radius of each cluster when clustering points in pixels (default: 50) */ + clusterRadius?: number; + /** Colors for cluster circles: [small, medium, large] based on point count (default: ["#51bbd6", "#f1f075", "#f28cb1"]) */ + clusterColors?: [string, string, string]; + /** Point count thresholds for color/size steps: [medium, large] (default: [100, 750]) */ + clusterThresholds?: [number, number]; + /** Color for unclustered individual points (default: "#3b82f6") */ + pointColor?: string; + /** Callback when an unclustered point is clicked */ + onPointClick?: ( + feature: GeoJSON.Feature, + coordinates: [number, number] + ) => void; + /** Callback when a cluster is clicked. If not provided, zooms into the cluster */ + onClusterClick?: ( + clusterId: number, + coordinates: [number, number], + pointCount: number + ) => void; +}; + +export function MapClusterLayer< + P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties +>({ + data, + clusterMaxZoom = 14, + clusterRadius = 50, + clusterColors = ["#51bbd6", "#f1f075", "#f28cb1"], + clusterThresholds = [100, 750], + pointColor = "#3b82f6", + onPointClick, + onClusterClick, +}: MapClusterLayerProps

) { + const { map, isLoaded } = useMap(); + const id = useId(); + const sourceId = `cluster-source-${id}`; + const clusterLayerId = `clusters-${id}`; + const clusterCountLayerId = `cluster-count-${id}`; + const unclusteredLayerId = `unclustered-point-${id}`; + + const stylePropsRef = useRef({ + clusterColors, + clusterThresholds, + pointColor, + }); + + // Add source and layers on mount + useEffect(() => { + if (!isLoaded || !map) return; + + // Add clustered GeoJSON source + map.addSource(sourceId, { + type: "geojson", + data: typeof data === "string" ? data : data, + cluster: true, + clusterMaxZoom, + clusterRadius, + }); + + // Add cluster circles layer + map.addLayer({ + id: clusterLayerId, + type: "circle", + source: sourceId, + filter: ["has", "point_count"], + paint: { + "circle-color": [ + "step", + ["get", "point_count"], + clusterColors[0], + clusterThresholds[0], + clusterColors[1], + clusterThresholds[1], + clusterColors[2], + ], + "circle-radius": [ + "step", + ["get", "point_count"], + 20, + clusterThresholds[0], + 30, + clusterThresholds[1], + 40, + ], + }, + }); + + // Add cluster count text layer + map.addLayer({ + id: clusterCountLayerId, + type: "symbol", + source: sourceId, + filter: ["has", "point_count"], + layout: { + "text-field": "{point_count_abbreviated}", + "text-size": 12, + }, + paint: { + "text-color": "#fff", + }, + }); + + // Add unclustered point layer + map.addLayer({ + id: unclusteredLayerId, + type: "circle", + source: sourceId, + filter: ["!", ["has", "point_count"]], + paint: { + "circle-color": pointColor, + "circle-radius": 6, + }, + }); + + return () => { + try { + if (map.getLayer(clusterCountLayerId)) + map.removeLayer(clusterCountLayerId); + if (map.getLayer(unclusteredLayerId)) + map.removeLayer(unclusteredLayerId); + if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId); + if (map.getSource(sourceId)) map.removeSource(sourceId); + } catch { + // ignore + } + }; + }, [isLoaded, map, sourceId]); + + // Update source data when data prop changes (only for non-URL data ) + useEffect(() => { + if (!isLoaded || !map || typeof data === "string") return; + + const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; + if (source) { + source.setData(data); + } + }, [isLoaded, map, data, sourceId]); + + // Update layer styles when props change + useEffect(() => { + if (!isLoaded || !map) return; + + const prev = stylePropsRef.current; + const colorsChanged = + prev.clusterColors !== clusterColors || + prev.clusterThresholds !== clusterThresholds; + + // Update cluster layer colors and sizes + if (map.getLayer(clusterLayerId) && colorsChanged) { + map.setPaintProperty(clusterLayerId, "circle-color", [ + "step", + ["get", "point_count"], + clusterColors[0], + clusterThresholds[0], + clusterColors[1], + clusterThresholds[1], + clusterColors[2], + ]); + map.setPaintProperty(clusterLayerId, "circle-radius", [ + "step", + ["get", "point_count"], + 20, + clusterThresholds[0], + 30, + clusterThresholds[1], + 40, + ]); + } + + // Update unclustered point layer color + if (map.getLayer(unclusteredLayerId) && prev.pointColor !== pointColor) { + map.setPaintProperty(unclusteredLayerId, "circle-color", pointColor); + } + + stylePropsRef.current = { clusterColors, clusterThresholds, pointColor }; + }, [ + isLoaded, + map, + clusterLayerId, + unclusteredLayerId, + clusterColors, + clusterThresholds, + pointColor, + ]); + + // Handle click events + useEffect(() => { + if (!isLoaded || !map) return; + + // Cluster click handler - zoom into cluster + const handleClusterClick = async ( + e: MapLibreGL.MapMouseEvent & { + features?: MapLibreGL.MapGeoJSONFeature[]; + } + ) => { + const features = map.queryRenderedFeatures(e.point, { + layers: [clusterLayerId], + }); + if (!features.length) return; + + const feature = features[0]; + const clusterId = feature.properties?.cluster_id as number; + const pointCount = feature.properties?.point_count as number; + const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [ + number, + number + ]; + + if (onClusterClick) { + onClusterClick(clusterId, coordinates, pointCount); + } else { + // Default behavior: zoom to cluster expansion zoom + const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; + const zoom = await source.getClusterExpansionZoom(clusterId); + map.easeTo({ + center: coordinates, + zoom, + }); + } + }; + + // Unclustered point click handler + const handlePointClick = ( + e: MapLibreGL.MapMouseEvent & { + features?: MapLibreGL.MapGeoJSONFeature[]; + } + ) => { + if (!onPointClick || !e.features?.length) return; + + const feature = e.features[0]; + const coordinates = ( + feature.geometry as GeoJSON.Point + ).coordinates.slice() as [number, number]; + + // Handle world copies + while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { + coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360; + } + + onPointClick( + feature as unknown as GeoJSON.Feature, + coordinates + ); + }; + + // Cursor style handlers + const handleMouseEnterCluster = () => { + map.getCanvas().style.cursor = "pointer"; + }; + const handleMouseLeaveCluster = () => { + map.getCanvas().style.cursor = ""; + }; + const handleMouseEnterPoint = () => { + if (onPointClick) { + map.getCanvas().style.cursor = "pointer"; + } + }; + const handleMouseLeavePoint = () => { + map.getCanvas().style.cursor = ""; + }; + + map.on("click", clusterLayerId, handleClusterClick); + map.on("click", unclusteredLayerId, handlePointClick); + map.on("mouseenter", clusterLayerId, handleMouseEnterCluster); + map.on("mouseleave", clusterLayerId, handleMouseLeaveCluster); + map.on("mouseenter", unclusteredLayerId, handleMouseEnterPoint); + map.on("mouseleave", unclusteredLayerId, handleMouseLeavePoint); + + return () => { + map.off("click", clusterLayerId, handleClusterClick); + map.off("click", unclusteredLayerId, handlePointClick); + map.off("mouseenter", clusterLayerId, handleMouseEnterCluster); + map.off("mouseleave", clusterLayerId, handleMouseLeaveCluster); + map.off("mouseenter", unclusteredLayerId, handleMouseEnterPoint); + map.off("mouseleave", unclusteredLayerId, handleMouseLeavePoint); + }; + }, [ + isLoaded, + map, + clusterLayerId, + unclusteredLayerId, + sourceId, + onClusterClick, + onPointClick, + ]); + + return null; +} diff --git a/src/registry/map/controls.tsx b/src/registry/map/controls.tsx new file mode 100644 index 0000000..de685ae --- /dev/null +++ b/src/registry/map/controls.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Minus, Plus, Locate, Maximize, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useMap } from "./hooks"; + +type MapControlsProps = { + /** Position of the controls on the map (default: "bottom-right") */ + position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"; + /** Show zoom in/out buttons (default: true) */ + showZoom?: boolean; + /** Show compass button to reset bearing (default: false) */ + showCompass?: boolean; + /** Show locate button to find user's location (default: false) */ + showLocate?: boolean; + /** Show fullscreen toggle button (default: false) */ + showFullscreen?: boolean; + /** Additional CSS classes for the controls container */ + className?: string; + /** Callback with user coordinates when located */ + onLocate?: (coords: { longitude: number; latitude: number }) => void; +}; + +const positionClasses = { + "top-left": "top-2 left-2", + "top-right": "top-2 right-2", + "bottom-left": "bottom-2 left-2", + "bottom-right": "bottom-10 right-2", +}; + +function ControlGroup({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +
+ ); +} + +function ControlButton({ + onClick, + label, + children, + disabled = false, +}: { + onClick: () => void; + label: string; + children: React.ReactNode; + disabled?: boolean; +}) { + return ( + + ); +} + +export function MapControls({ + position = "bottom-right", + showZoom = true, + showCompass = false, + showLocate = false, + showFullscreen = false, + className, + onLocate, +}: MapControlsProps) { + const { map, isLoaded } = useMap(); + const [waitingForLocation, setWaitingForLocation] = useState(false); + + const handleZoomIn = useCallback(() => { + map?.zoomTo(map.getZoom() + 1, { duration: 300 }); + }, [map]); + + const handleZoomOut = useCallback(() => { + map?.zoomTo(map.getZoom() - 1, { duration: 300 }); + }, [map]); + + const handleResetBearing = useCallback(() => { + map?.resetNorthPitch({ duration: 300 }); + }, [map]); + + const handleLocate = useCallback(() => { + setWaitingForLocation(true); + if ("geolocation" in navigator) { + navigator.geolocation.getCurrentPosition( + (pos) => { + const coords = { + longitude: pos.coords.longitude, + latitude: pos.coords.latitude, + }; + map?.flyTo({ + center: [coords.longitude, coords.latitude], + zoom: 14, + duration: 1500, + }); + onLocate?.(coords); + setWaitingForLocation(false); + }, + (error) => { + console.error("Error getting location:", error); + setWaitingForLocation(false); + } + ); + } + }, [map, onLocate]); + + const handleFullscreen = useCallback(() => { + const container = map?.getContainer(); + if (!container) return; + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + container.requestFullscreen(); + } + }, [map]); + + if (!isLoaded) return null; + + return ( +
+ {showZoom && ( + + + + + + + + + )} + {showCompass && ( + + + + )} + {showLocate && ( + + + {waitingForLocation ? ( + + ) : ( + + )} + + + )} + {showFullscreen && ( + + + + + + )} +
+ ); +} + +function CompassButton({ onClick }: { onClick: () => void }) { + const { isLoaded, map } = useMap(); + const compassRef = useRef(null); + + useEffect(() => { + if (!isLoaded || !map || !compassRef.current) return; + + const compass = compassRef.current; + + const updateRotation = () => { + const bearing = map.getBearing(); + const pitch = map.getPitch(); + compass.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`; + }; + + map.on("rotate", updateRotation); + map.on("pitch", updateRotation); + updateRotation(); + + return () => { + map.off("rotate", updateRotation); + map.off("pitch", updateRotation); + }; + }, [isLoaded, map]); + + return ( + + + + + + + + + ); +} diff --git a/src/registry/map/hooks.ts b/src/registry/map/hooks.ts new file mode 100644 index 0000000..45926f2 --- /dev/null +++ b/src/registry/map/hooks.ts @@ -0,0 +1,19 @@ +import { useContext } from "react"; +import { MapContext } from "./map"; +import { MarkerContext } from "./marker"; + +export function useMap() { + const context = useContext(MapContext); + if (!context) { + throw new Error("useMap must be used within a Map component"); + } + return context; +} + +export function useMarkerContext() { + const context = useContext(MarkerContext); + if (!context) { + throw new Error("Marker components must be used within MapMarker"); + } + return context; +} diff --git a/src/registry/map/index.tsx b/src/registry/map/index.tsx new file mode 100644 index 0000000..3ab21ab --- /dev/null +++ b/src/registry/map/index.tsx @@ -0,0 +1,13 @@ +export { Map } from "./map"; +export { useMap } from "./hooks"; +export { + MapMarker, + MarkerContent, + MarkerPopup, + MarkerTooltip, + MarkerLabel, +} from "./marker"; +export { MapControls } from "./controls"; +export { MapPopup } from "./popup"; +export { MapRoute } from "./route"; +export { MapClusterLayer } from "./cluster"; diff --git a/src/registry/map/map.tsx b/src/registry/map/map.tsx new file mode 100644 index 0000000..e4d8c28 --- /dev/null +++ b/src/registry/map/map.tsx @@ -0,0 +1,118 @@ +"use client"; + +import MapLibreGL from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; +import { useTheme } from "next-themes"; +import { + createContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { + defaultStyles, + type MapContextValue, + type MapStyleOption, +} from "./types"; + +export const MapContext = createContext(null); + +const DefaultLoader = () => ( +
+
+ + + +
+
+); + +type MapProps = { + children?: ReactNode; + /** Custom map styles for light and dark themes. Overrides the default Carto styles. */ + styles?: { + light?: MapStyleOption; + dark?: MapStyleOption; + }; +} & Omit; + +export function Map({ children, styles, ...props }: MapProps) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const [isLoaded, setIsLoaded] = useState(false); + const [isStyleLoaded, setIsStyleLoaded] = useState(false); + const { resolvedTheme } = useTheme(); + + const mapStyles = useMemo( + () => ({ + dark: styles?.dark ?? defaultStyles.dark, + light: styles?.light ?? defaultStyles.light, + }), + [styles] + ); + + useEffect(() => { + if (!containerRef.current) return; + + const mapStyle = + resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light; + + const mapInstance = new MapLibreGL.Map({ + container: containerRef.current, + style: mapStyle, + renderWorldCopies: false, + attributionControl: { + compact: true, + }, + ...props, + }); + + const styleDataHandler = () => setIsStyleLoaded(true); + const loadHandler = () => setIsLoaded(true); + + mapInstance.on("load", loadHandler); + mapInstance.on("styledata", styleDataHandler); + mapRef.current = mapInstance; + + return () => { + mapInstance.off("load", loadHandler); + mapInstance.off("styledata", styleDataHandler); + mapInstance.remove(); + mapRef.current = null; + setIsLoaded(false); + setIsStyleLoaded(false); + }; + }, []); + + useEffect(() => { + if (mapRef.current) { + setIsStyleLoaded(false); + mapRef.current.setStyle( + resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light, + { diff: true } + ); + } + }, [resolvedTheme, mapStyles]); + + const isLoading = !isLoaded || !isStyleLoaded; + + const contextValue = useMemo( + () => ({ + map: mapRef.current, + isLoaded: isLoaded && isStyleLoaded, + }), + [isLoaded, isStyleLoaded] + ); + + return ( + +
+ {isLoading && } + {/* SSR-safe: children render only when map exists on client */} + {mapRef.current && children} +
+
+ ); +} diff --git a/src/registry/map/marker.tsx b/src/registry/map/marker.tsx new file mode 100644 index 0000000..96feb48 --- /dev/null +++ b/src/registry/map/marker.tsx @@ -0,0 +1,401 @@ +"use client"; + +import MapLibreGL, { type MarkerOptions, type PopupOptions } from "maplibre-gl"; +import { + createContext, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useMap } from "./hooks"; +import { type MarkerContextValue } from "./types"; + +export const MarkerContext = createContext(null); + +function useMarkerContext() { + const context = useContext(MarkerContext); + if (!context) { + throw new Error("Marker components must be used within MapMarker"); + } + return context; +} + +type MapMarkerProps = { + /** Longitude coordinate for marker position */ + longitude: number; + /** Latitude coordinate for marker position */ + latitude: number; + /** Marker subcomponents (MarkerContent, MarkerPopup, MarkerTooltip, MarkerLabel) */ + children: ReactNode; + /** Callback when marker is clicked */ + onClick?: (e: MouseEvent) => void; + /** Callback when mouse enters marker */ + onMouseEnter?: (e: MouseEvent) => void; + /** Callback when mouse leaves marker */ + onMouseLeave?: (e: MouseEvent) => void; + /** Callback when marker drag starts (requires draggable: true) */ + onDragStart?: (lngLat: { lng: number; lat: number }) => void; + /** Callback during marker drag (requires draggable: true) */ + onDrag?: (lngLat: { lng: number; lat: number }) => void; + /** Callback when marker drag ends (requires draggable: true) */ + onDragEnd?: (lngLat: { lng: number; lat: number }) => void; +} & Omit; + +export function MapMarker({ + longitude, + latitude, + children, + onClick, + onMouseEnter, + onMouseLeave, + onDragStart, + onDrag, + onDragEnd, + draggable = false, + ...markerOptions +}: MapMarkerProps) { + const { map, isLoaded } = useMap(); + const markerRef = useRef(null); + const markerElementRef = useRef(null); + const [isReady, setIsReady] = useState(false); + const markerOptionsRef = useRef(markerOptions); + + useEffect(() => { + if (!isLoaded || !map) return; + + const container = document.createElement("div"); + markerElementRef.current = container; + + const marker = new MapLibreGL.Marker({ + ...markerOptions, + element: container, + draggable, + }) + .setLngLat([longitude, latitude]) + .addTo(map); + + markerRef.current = marker; + + const handleClick = (e: MouseEvent) => onClick?.(e); + const handleMouseEnter = (e: MouseEvent) => onMouseEnter?.(e); + const handleMouseLeave = (e: MouseEvent) => onMouseLeave?.(e); + + container.addEventListener("click", handleClick); + container.addEventListener("mouseenter", handleMouseEnter); + container.addEventListener("mouseleave", handleMouseLeave); + + const handleDragStart = () => { + const lngLat = marker.getLngLat(); + onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat }); + }; + const handleDrag = () => { + const lngLat = marker.getLngLat(); + onDrag?.({ lng: lngLat.lng, lat: lngLat.lat }); + }; + const handleDragEnd = () => { + const lngLat = marker.getLngLat(); + onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat }); + }; + + marker.on("dragstart", handleDragStart); + marker.on("drag", handleDrag); + marker.on("dragend", handleDragEnd); + + setIsReady(true); + + return () => { + container.removeEventListener("click", handleClick); + container.removeEventListener("mouseenter", handleMouseEnter); + container.removeEventListener("mouseleave", handleMouseLeave); + + marker.off("dragstart", handleDragStart); + marker.off("drag", handleDrag); + marker.off("dragend", handleDragEnd); + + marker.remove(); + markerRef.current = null; + markerElementRef.current = null; + setIsReady(false); + }; + }, [map, isLoaded]); + + useEffect(() => { + markerRef.current?.setLngLat([longitude, latitude]); + }, [longitude, latitude]); + + useEffect(() => { + markerRef.current?.setDraggable(draggable); + }, [draggable]); + + useEffect(() => { + if (!markerRef.current) return; + const prev = markerOptionsRef.current; + + if (prev.offset !== markerOptions.offset) { + markerRef.current.setOffset(markerOptions.offset ?? [0, 0]); + } + if (prev.rotation !== markerOptions.rotation) { + markerRef.current.setRotation(markerOptions.rotation ?? 0); + } + if (prev.rotationAlignment !== markerOptions.rotationAlignment) { + markerRef.current.setRotationAlignment( + markerOptions.rotationAlignment ?? "auto" + ); + } + if (prev.pitchAlignment !== markerOptions.pitchAlignment) { + markerRef.current.setPitchAlignment( + markerOptions.pitchAlignment ?? "auto" + ); + } + + markerOptionsRef.current = markerOptions; + }, [markerOptions]); + + return ( + + {children} + + ); +} + +type MarkerContentProps = { + /** Custom marker content. Defaults to a blue dot if not provided */ + children?: ReactNode; + /** Additional CSS classes for the marker container */ + className?: string; +}; + +function DefaultMarkerIcon() { + return ( +
+ ); +} + +export function MarkerContent({ children, className }: MarkerContentProps) { + const { markerElementRef, isReady } = useMarkerContext(); + + if (!isReady || !markerElementRef.current) return null; + + return createPortal( +
+ {children || } +
, + markerElementRef.current + ); +} + +type MarkerPopupProps = { + /** Popup content */ + children: ReactNode; + /** Additional CSS classes for the popup container */ + className?: string; + /** Show a close button in the popup (default: false) */ + closeButton?: boolean; +} & Omit; + +export function MarkerPopup({ + children, + className, + closeButton = false, + ...popupOptions +}: MarkerPopupProps) { + const { markerRef, isReady } = useMarkerContext(); + const containerRef = useRef(null); + const popupRef = useRef(null); + const [mounted, setMounted] = useState(false); + const popupOptionsRef = useRef(popupOptions); + + useEffect(() => { + if (!isReady || !markerRef.current) return; + + const container = document.createElement("div"); + containerRef.current = container; + + const popup = new MapLibreGL.Popup({ + offset: 16, + ...popupOptions, + closeButton: false, + }) + .setMaxWidth("none") + .setDOMContent(container); + + popupRef.current = popup; + markerRef.current.setPopup(popup); + setMounted(true); + + return () => { + popup.remove(); + popupRef.current = null; + containerRef.current = null; + setMounted(false); + }; + }, [isReady]); + + useEffect(() => { + if (!popupRef.current) return; + const prev = popupOptionsRef.current; + + if (prev.offset !== popupOptions.offset) { + popupRef.current.setOffset(popupOptions.offset ?? 16); + } + if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { + popupRef.current.setMaxWidth(popupOptions.maxWidth ?? "none"); + } + + popupOptionsRef.current = popupOptions; + }, [popupOptions]); + + const handleClose = () => popupRef.current?.remove(); + + if (!mounted || !containerRef.current) return null; + + return createPortal( +
+ {closeButton && ( + + )} + {children} +
, + containerRef.current + ); +} + +type MarkerTooltipProps = { + /** Tooltip content */ + children: ReactNode; + /** Additional CSS classes for the tooltip container */ + className?: string; +} & Omit; + +export function MarkerTooltip({ + children, + className, + ...popupOptions +}: MarkerTooltipProps) { + const { markerRef, markerElementRef, map, isReady } = useMarkerContext(); + const containerRef = useRef(null); + const popupRef = useRef(null); + const [mounted, setMounted] = useState(false); + const popupOptionsRef = useRef(popupOptions); + + useEffect(() => { + if (!isReady || !markerRef.current || !markerElementRef.current || !map) + return; + + const container = document.createElement("div"); + containerRef.current = container; + + const popup = new MapLibreGL.Popup({ + offset: 16, + ...popupOptions, + closeOnClick: true, + closeButton: false, + }) + .setMaxWidth("none") + .setDOMContent(container); + + popupRef.current = popup; + + const markerElement = markerElementRef.current; + const marker = markerRef.current; + + const handleMouseEnter = () => { + popup.setLngLat(marker.getLngLat()).addTo(map); + }; + const handleMouseLeave = () => popup.remove(); + + markerElement.addEventListener("mouseenter", handleMouseEnter); + markerElement.addEventListener("mouseleave", handleMouseLeave); + setMounted(true); + + return () => { + markerElement.removeEventListener("mouseenter", handleMouseEnter); + markerElement.removeEventListener("mouseleave", handleMouseLeave); + popup.remove(); + popupRef.current = null; + containerRef.current = null; + setMounted(false); + }; + }, [isReady, map]); + + useEffect(() => { + if (!popupRef.current) return; + const prev = popupOptionsRef.current; + + if (prev.offset !== popupOptions.offset) { + popupRef.current.setOffset(popupOptions.offset ?? 16); + } + if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { + popupRef.current.setMaxWidth(popupOptions.maxWidth ?? "none"); + } + + popupOptionsRef.current = popupOptions; + }, [popupOptions]); + + if (!mounted || !containerRef.current) return null; + + return createPortal( +
+ {children} +
, + containerRef.current + ); +} + +type MarkerLabelProps = { + /** Label text content */ + children: ReactNode; + /** Additional CSS classes for the label */ + className?: string; + /** Position of the label relative to the marker (default: "top") */ + position?: "top" | "bottom"; +}; + +export function MarkerLabel({ + children, + className, + position = "top", +}: MarkerLabelProps) { + const positionClasses = { + top: "bottom-full mb-1", + bottom: "top-full mt-1", + }; + + return ( +
+ {children} +
+ ); +} diff --git a/src/registry/map/popup.tsx b/src/registry/map/popup.tsx new file mode 100644 index 0000000..d36d514 --- /dev/null +++ b/src/registry/map/popup.tsx @@ -0,0 +1,113 @@ +"use client"; + +import MapLibreGL, { type PopupOptions } from "maplibre-gl"; +import { useEffect, useMemo, useRef, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useMap } from "./hooks"; + +type MapPopupProps = { + /** Longitude coordinate for popup position */ + longitude: number; + /** Latitude coordinate for popup position */ + latitude: number; + /** Callback when popup is closed */ + onClose?: () => void; + /** Popup content */ + children: ReactNode; + /** Additional CSS classes for the popup container */ + className?: string; + /** Show a close button in the popup (default: false) */ + closeButton?: boolean; +} & Omit; + +export function MapPopup({ + longitude, + latitude, + onClose, + children, + className, + closeButton = false, + ...popupOptions +}: MapPopupProps) { + const { map } = useMap(); + const popupRef = useRef(null); + const popupOptionsRef = useRef(popupOptions); + + const container = useMemo(() => document.createElement("div"), []); + + useEffect(() => { + if (!map) return; + + const popup = new MapLibreGL.Popup({ + offset: 16, + ...popupOptions, + closeButton: false, + }) + .setMaxWidth("none") + .setDOMContent(container) + .setLngLat([longitude, latitude]) + .addTo(map); + + const onCloseProp = () => onClose?.(); + + popup.on("close", onCloseProp); + + popupRef.current = popup; + + return () => { + popup.off("close", onCloseProp); + if (popup.isOpen()) { + popup.remove(); + } + popupRef.current = null; + }; + }, [map]); + + useEffect(() => { + popupRef.current?.setLngLat([longitude, latitude]); + }, [longitude, latitude]); + + useEffect(() => { + if (!popupRef.current) return; + const prev = popupOptionsRef.current; + + if (prev.offset !== popupOptions.offset) { + popupRef.current.setOffset(popupOptions.offset ?? 16); + } + if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { + popupRef.current.setMaxWidth(popupOptions.maxWidth ?? "none"); + } + + popupOptionsRef.current = popupOptions; + }, [popupOptions]); + + const handleClose = () => { + popupRef.current?.remove(); + onClose?.(); + }; + + return createPortal( +
+ {closeButton && ( + + )} + {children} +
, + container + ); +} diff --git a/src/registry/map/route.tsx b/src/registry/map/route.tsx new file mode 100644 index 0000000..a63fcd3 --- /dev/null +++ b/src/registry/map/route.tsx @@ -0,0 +1,94 @@ +"use client"; + +import MapLibreGL from "maplibre-gl"; +import { useEffect, useId } from "react"; +import { useMap } from "./hooks"; + +type MapRouteProps = { + /** Array of [longitude, latitude] coordinate pairs defining the route */ + coordinates: [number, number][]; + /** Line color as CSS color value (default: "#4285F4") */ + color?: string; + /** Line width in pixels (default: 3) */ + width?: number; + /** Line opacity from 0 to 1 (default: 0.8) */ + opacity?: number; + /** Dash pattern [dash length, gap length] for dashed lines */ + dashArray?: [number, number]; +}; + +export function MapRoute({ + coordinates, + color = "#4285F4", + width = 3, + opacity = 0.8, + dashArray, +}: MapRouteProps) { + const { map, isLoaded } = useMap(); + const id = useId(); + const sourceId = `route-source-${id}`; + const layerId = `route-layer-${id}`; + + // Add source and layer on mount + useEffect(() => { + if (!isLoaded || !map) return; + + map.addSource(sourceId, { + type: "geojson", + data: { + type: "Feature", + properties: {}, + geometry: { type: "LineString", coordinates: [] }, + }, + }); + + map.addLayer({ + id: layerId, + type: "line", + source: sourceId, + layout: { "line-join": "round", "line-cap": "round" }, + paint: { + "line-color": color, + "line-width": width, + "line-opacity": opacity, + ...(dashArray && { "line-dasharray": dashArray }), + }, + }); + + return () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + if (map.getSource(sourceId)) map.removeSource(sourceId); + } catch { + // ignore + } + }; + }, [isLoaded, map, sourceId, layerId]); + + // When coordinates change, update the source data + useEffect(() => { + if (!isLoaded || !map || coordinates.length < 2) return; + + const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; + if (source) { + source.setData({ + type: "Feature", + properties: {}, + geometry: { type: "LineString", coordinates }, + }); + } + }, [isLoaded, map, coordinates, sourceId]); + + useEffect(() => { + if (!isLoaded || !map || !map.getLayer(layerId)) return; + + map.setPaintProperty(layerId, "line-color", color); + map.setPaintProperty(layerId, "line-width", width); + map.setPaintProperty(layerId, "line-opacity", opacity); + if (dashArray) { + map.setPaintProperty(layerId, "line-dasharray", dashArray); + } + }, [isLoaded, map, layerId, color, width, opacity, dashArray]); + + return null; +} diff --git a/src/registry/map/types.ts b/src/registry/map/types.ts new file mode 100644 index 0000000..1cf1d75 --- /dev/null +++ b/src/registry/map/types.ts @@ -0,0 +1,21 @@ +import MapLibreGL from "maplibre-gl"; +import { type RefObject } from "react"; + +export type MapContextValue = { + map: MapLibreGL.Map | null; + isLoaded: boolean; +}; + +export type MarkerContextValue = { + markerRef: RefObject; + markerElementRef: RefObject; + map: MapLibreGL.Map | null; + isReady: boolean; +}; + +export const defaultStyles = { + dark: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", + light: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", +}; + +export type MapStyleOption = string | MapLibreGL.StyleSpecification; \ No newline at end of file