From edae1be1ed50a69faefa324e565da053d3b3dee4 Mon Sep 17 00:00:00 2001 From: Alex Timms Date: Mon, 5 Jan 2026 21:58:28 +0000 Subject: [PATCH 1/4] feat(clusters): Introduced custom point rendering support --- src/registry/map.tsx | 419 ++++++++++++++++++++++++++++++------------- 1 file changed, 294 insertions(+), 125 deletions(-) diff --git a/src/registry/map.tsx b/src/registry/map.tsx index b87384c..f3a5247 100644 --- a/src/registry/map.tsx +++ b/src/registry/map.tsx @@ -973,27 +973,28 @@ function MapRoute({ return null; } +type RenderPointProps

= { + feature: GeoJSON.Feature; + coordinates: [number, number]; +}; + 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 */ + pointRadius?: number; + pointStrokeColor?: string; + pointStrokeWidth?: number; + renderPoint?: (props: RenderPointProps

) => ReactNode; 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], @@ -1001,6 +1002,12 @@ type MapClusterLayerProps< ) => void; }; +type MarkerEntry = { + marker: MapLibreGL.Marker; + element: HTMLDivElement; + feature: GeoJSON.Feature; +}; + function MapClusterLayer< P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties >({ @@ -1010,6 +1017,10 @@ function MapClusterLayer< clusterColors = ["#51bbd6", "#f1f075", "#f28cb1"], clusterThresholds = [100, 750], pointColor = "#3b82f6", + pointRadius = 6, + pointStrokeColor, + pointStrokeWidth = 0, + renderPoint, onPointClick, onClusterClick, }: MapClusterLayerProps

) { @@ -1020,85 +1031,128 @@ function MapClusterLayer< const clusterCountLayerId = `cluster-count-${id}`; const unclusteredLayerId = `unclustered-point-${id}`; + const markersRef = useRef(new globalThis.Map()); + const [visibleFeatures, setVisibleFeatures] = useState< + GeoJSON.Feature[] + >([]); + const stylePropsRef = useRef({ clusterColors, clusterThresholds, pointColor, }); - // Add source and layers on mount + const hasCustomRenderer = Boolean(renderPoint); + + const addLayers = useCallback(() => { + if (!map) return; + + if (!map.getSource(sourceId)) { + map.addSource(sourceId, { + type: "geojson", + data: typeof data === "string" ? data : data, + cluster: true, + clusterMaxZoom, + clusterRadius, + }); + } + + if (!map.getLayer(clusterLayerId)) { + 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, + ], + }, + }); + } + + if (!map.getLayer(clusterCountLayerId)) { + 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", + }, + }); + } + + if (!hasCustomRenderer && !map.getLayer(unclusteredLayerId)) { + map.addLayer({ + id: unclusteredLayerId, + type: "circle", + source: sourceId, + filter: ["!", ["has", "point_count"]], + paint: { + "circle-color": pointColor, + "circle-radius": pointRadius, + ...(pointStrokeColor && { "circle-stroke-color": pointStrokeColor }), + ...(pointStrokeWidth && { "circle-stroke-width": pointStrokeWidth }), + }, + }); + } + }, [ + map, + sourceId, + clusterLayerId, + clusterCountLayerId, + unclusteredLayerId, + clusterColors, + clusterThresholds, + pointColor, + pointRadius, + pointStrokeColor, + pointStrokeWidth, + clusterMaxZoom, + clusterRadius, + data, + hasCustomRenderer, + ]); + useEffect(() => { if (!isLoaded || !map) return; - // Add clustered GeoJSON source - map.addSource(sourceId, { - type: "geojson", - data, - cluster: true, - clusterMaxZoom, - clusterRadius, - }); + addLayers(); - // 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", - }, - }); + const handleStyleData = () => { + if (!map.getSource(sourceId)) { + addLayers(); + } + }; - // Add unclustered point layer - map.addLayer({ - id: unclusteredLayerId, - type: "circle", - source: sourceId, - filter: ["!", ["has", "point_count"]], - paint: { - "circle-color": pointColor, - "circle-radius": 6, - }, - }); + map.on("styledata", handleStyleData); return () => { + map.off("styledata", handleStyleData); try { if (map.getLayer(clusterCountLayerId)) map.removeLayer(clusterCountLayerId); - if (map.getLayer(unclusteredLayerId)) + if (!hasCustomRenderer && map.getLayer(unclusteredLayerId)) map.removeLayer(unclusteredLayerId); if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId); if (map.getSource(sourceId)) map.removeSource(sourceId); @@ -1106,10 +1160,17 @@ function MapClusterLayer< // ignore } }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isLoaded, map, sourceId]); + }, [ + isLoaded, + map, + addLayers, + sourceId, + clusterCountLayerId, + unclusteredLayerId, + clusterLayerId, + hasCustomRenderer, + ]); - // Update source data when data prop changes (only for non-URL data) useEffect(() => { if (!isLoaded || !map || typeof data === "string") return; @@ -1119,7 +1180,6 @@ function MapClusterLayer< } }, [isLoaded, map, data, sourceId]); - // Update layer styles when props change useEffect(() => { if (!isLoaded || !map) return; @@ -1128,7 +1188,6 @@ function MapClusterLayer< prev.clusterColors !== clusterColors || prev.clusterThresholds !== clusterThresholds; - // Update cluster layer colors and sizes if (map.getLayer(clusterLayerId) && colorsChanged) { map.setPaintProperty(clusterLayerId, "circle-color", [ "step", @@ -1150,8 +1209,11 @@ function MapClusterLayer< ]); } - // Update unclustered point layer color - if (map.getLayer(unclusteredLayerId) && prev.pointColor !== pointColor) { + if ( + !hasCustomRenderer && + map.getLayer(unclusteredLayerId) && + prev.pointColor !== pointColor + ) { map.setPaintProperty(unclusteredLayerId, "circle-color", pointColor); } @@ -1164,24 +1226,115 @@ function MapClusterLayer< clusterColors, clusterThresholds, pointColor, + hasCustomRenderer, ]); - // Handle click events + const getFeatureKey = useCallback( + (feature: GeoJSON.Feature): string => { + const coords = feature.geometry.coordinates; + const id = feature.id ?? feature.properties?.id; + return id ? String(id) : `${coords[0]}_${coords[1]}`; + }, + [] + ); + + const syncMarkers = useCallback(() => { + if (!map || !hasCustomRenderer) return; + + const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; + if (!source) return; + + const rawFeatures = map.querySourceFeatures(sourceId, { + filter: ["!", ["has", "point_count"]], + }); + + const features = rawFeatures as unknown as GeoJSON.Feature< + GeoJSON.Point, + P + >[]; + + const uniqueFeatures = new globalThis.Map< + string, + GeoJSON.Feature + >(); + for (const feature of features) { + const key = getFeatureKey(feature); + if (!uniqueFeatures.has(key)) { + uniqueFeatures.set(key, feature); + } + } + + const currentKeys = new globalThis.Set(uniqueFeatures.keys()); + const existingKeys = new globalThis.Set(markersRef.current.keys()); + + for (const key of existingKeys) { + if (!currentKeys.has(key)) { + const entry = markersRef.current.get(key); + if (entry) { + entry.marker.remove(); + markersRef.current.delete(key); + } + } + } + + for (const [key, feature] of uniqueFeatures) { + if (!markersRef.current.has(key)) { + const coords = feature.geometry.coordinates as [number, number]; + const element = document.createElement("div"); + element.style.cursor = onPointClick ? "pointer" : "default"; + + const marker = new MapLibreGL.Marker({ element }) + .setLngLat(coords) + .addTo(map); + + if (onPointClick) { + element.addEventListener("click", (e) => { + e.stopPropagation(); + onPointClick(feature, coords); + }); + } + + markersRef.current.set(key, { marker, element, feature }); + } + } + + setVisibleFeatures(Array.from(uniqueFeatures.values())); + }, [map, sourceId, hasCustomRenderer, getFeatureKey, onPointClick]); + + useEffect(() => { + if (!isLoaded || !map || !hasCustomRenderer) return; + + const handleUpdate = () => { + requestAnimationFrame(syncMarkers); + }; + + map.on("moveend", handleUpdate); + map.on("zoomend", handleUpdate); + map.on("sourcedata", handleUpdate); + + handleUpdate(); + + return () => { + map.off("moveend", handleUpdate); + map.off("zoomend", handleUpdate); + map.off("sourcedata", handleUpdate); + + markersRef.current.forEach((entry) => entry.marker.remove()); + markersRef.current.clear(); + setVisibleFeatures([]); + }; + }, [isLoaded, map, hasCustomRenderer, syncMarkers]); + useEffect(() => { if (!isLoaded || !map) return; - // Cluster click handler - zoom into cluster - const handleClusterClick = async ( - e: MapLibreGL.MapMouseEvent & { - features?: MapLibreGL.MapGeoJSONFeature[]; - } - ) => { + const handleClusterClick = async (e: MapLibreGL.MapMouseEvent) => { const features = map.queryRenderedFeatures(e.point, { layers: [clusterLayerId], }); - if (!features.length) return; - const feature = features[0]; + if (!feature) return; + 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 [ @@ -1192,30 +1345,24 @@ function MapClusterLayer< 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, - }); + map.easeTo({ center: coordinates, zoom }); } }; - // Unclustered point click handler const handlePointClick = ( e: MapLibreGL.MapMouseEvent & { features?: MapLibreGL.MapGeoJSONFeature[]; } ) => { - if (!onPointClick || !e.features?.length) return; + if (!onPointClick || hasCustomRenderer) return; + const feature = e.features?.[0]; + if (!feature) 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; } @@ -1226,36 +1373,38 @@ function MapClusterLayer< ); }; - // 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 = ""; - }; + const setPointer = () => (map.getCanvas().style.cursor = "pointer"); + const resetPointer = () => (map.getCanvas().style.cursor = ""); + + if (map.getLayer(clusterLayerId)) { + map.on("click", clusterLayerId, handleClusterClick); + map.on("mouseenter", clusterLayerId, setPointer); + map.on("mouseleave", clusterLayerId, resetPointer); + } - 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); + if (!hasCustomRenderer && map.getLayer(unclusteredLayerId)) { + map.on("click", unclusteredLayerId, handlePointClick); + map.on("mouseenter", unclusteredLayerId, () => { + if (onPointClick) setPointer(); + }); + map.on("mouseleave", unclusteredLayerId, resetPointer); + } 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); + try { + if (map.getLayer(clusterLayerId)) { + map.off("click", clusterLayerId, handleClusterClick); + map.off("mouseenter", clusterLayerId, setPointer); + map.off("mouseleave", clusterLayerId, resetPointer); + } + if (!hasCustomRenderer && map.getLayer(unclusteredLayerId)) { + map.off("click", unclusteredLayerId, handlePointClick); + map.off("mouseenter", unclusteredLayerId, setPointer); + map.off("mouseleave", unclusteredLayerId, resetPointer); + } + } catch { + // ignore + } }; }, [ isLoaded, @@ -1265,9 +1414,29 @@ function MapClusterLayer< sourceId, onClusterClick, onPointClick, + hasCustomRenderer, ]); - return null; + if (!hasCustomRenderer) { + return null; + } + + return ( + <> + {visibleFeatures.map((feature) => { + const key = getFeatureKey(feature); + const entry = markersRef.current.get(key); + if (!entry) return null; + + const coords = feature.geometry.coordinates as [number, number]; + return createPortal( + renderPoint!({ feature, coordinates: coords }), + entry.element, + key + ); + })} + + ); } export { From 6097e0e89586857b2471b87f954bee3a9ccee1c1 Mon Sep 17 00:00:00 2001 From: Alex Timms Date: Mon, 5 Jan 2026 22:37:05 +0000 Subject: [PATCH 2/4] refactor(map): Simplify map component structure and improve marker handling --- src/registry/map.tsx | 549 +++++++++++++++++++++---------------------- 1 file changed, 262 insertions(+), 287 deletions(-) diff --git a/src/registry/map.tsx b/src/registry/map.tsx index f3a5247..527e761 100644 --- a/src/registry/map.tsx +++ b/src/registry/map.tsx @@ -1,26 +1,23 @@ "use client"; -import MapLibreGL, { type PopupOptions, type MarkerOptions } from "maplibre-gl"; +import { Locate, Maximize, Minus, Plus, Loader2, X } from "lucide-react"; +import MapLibreGL, { type MarkerOptions, type PopupOptions } from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import { useTheme } from "next-themes"; import { + type ReactNode, createContext, - forwardRef, useCallback, useContext, useEffect, useId, - useImperativeHandle, 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"; -import React from "react"; type MapContextValue = { map: MapLibreGL.Map | null; @@ -46,35 +43,29 @@ 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; -type MapRef = MapLibreGL.Map; - const DefaultLoader = () => (

- - - + + +
); -const Map = forwardRef(function Map( - { children, styles, ...props }, - ref -) { +function Map({ children, styles, ...props }: MapProps) { const containerRef = useRef(null); - const [mapInstance, setMapInstance] = useState(null); + const mapRef = useRef(null); + const [isLoaded, setIsLoaded] = useState(false); const [isStyleLoaded, setIsStyleLoaded] = useState(false); const { resolvedTheme } = useTheme(); - const currentStyleRef = useRef(null); const mapStyles = useMemo( () => ({ @@ -84,18 +75,15 @@ const Map = forwardRef(function Map( [styles] ); - useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]); - useEffect(() => { if (!containerRef.current) return; - const initialStyle = + const mapStyle = resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light; - currentStyleRef.current = initialStyle; - const map = new MapLibreGL.Map({ + const mapInstance = new MapLibreGL.Map({ container: containerRef.current, - style: initialStyle, + style: mapStyle, renderWorldCopies: false, attributionControl: { compact: true, @@ -106,63 +94,66 @@ const Map = forwardRef(function Map( const styleDataHandler = () => setIsStyleLoaded(true); const loadHandler = () => setIsLoaded(true); - map.on("load", loadHandler); - map.on("styledata", styleDataHandler); - setMapInstance(map); + mapInstance.on("load", loadHandler); + mapInstance.on("styledata", styleDataHandler); + mapRef.current = mapInstance; return () => { - map.off("load", loadHandler); - map.off("styledata", styleDataHandler); - map.remove(); + mapInstance.off("load", loadHandler); + mapInstance.off("styledata", styleDataHandler); + mapInstance.remove(); + mapRef.current = null; setIsLoaded(false); setIsStyleLoaded(false); - setMapInstance(null); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { - if (!mapInstance || !resolvedTheme) return; - - const newStyle = - resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light; - - if (currentStyleRef.current === newStyle) return; - - currentStyleRef.current = newStyle; - setIsStyleLoaded(false); - - const frameId = requestAnimationFrame(() => { - mapInstance.setStyle(newStyle, { diff: true }); - }); - - return () => cancelAnimationFrame(frameId); - }, [mapInstance, resolvedTheme, mapStyles]); + if (mapRef.current) { + // If the map style is satellite, we do not need to invalidate style loaded state + const style = mapRef.current.getStyle(); + if ( + style?.name === "satellite" || + style?.name === "ocean" || + style?.name === "natgeo" + ) { + return; + } + // When theme changes, we invalidate style loaded state + setIsStyleLoaded(false); + mapRef.current.setStyle( + resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light, + { diff: true } + ); + } + }, [resolvedTheme, mapStyles]); const isLoading = !isLoaded || !isStyleLoaded; const contextValue = useMemo( () => ({ - map: mapInstance, - isLoaded: isLoaded && isStyleLoaded, + map: mapRef.current, + // We expose the map even if style isn't fully loaded yet to allow listeners to attach + isLoaded: isLoaded, }), - [mapInstance, isLoaded, isStyleLoaded] + [isLoaded, isStyleLoaded] ); return ( -
+
{isLoading && } - {/* SSR-safe: children render only when map is loaded on client */} - {mapInstance && children} + {mapRef.current && children}
); -}); +} type MarkerContextValue = { - marker: MapLibreGL.Marker; + markerRef: React.RefObject; + markerElementRef: React.RefObject; map: MapLibreGL.Map | null; + isReady: boolean; }; const MarkerContext = createContext(null); @@ -209,92 +200,107 @@ function MapMarker({ draggable = false, ...markerOptions }: MapMarkerProps) { - const { map } = useMap(); + 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 marker = useMemo(() => { - const markerInstance = new MapLibreGL.Marker({ + const container = document.createElement("div"); + markerElementRef.current = container; + + const marker = new MapLibreGL.Marker({ ...markerOptions, - element: document.createElement("div"), + element: container, draggable, - }).setLngLat([longitude, latitude]); + }) + .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); - markerInstance.getElement()?.addEventListener("click", handleClick); - markerInstance - .getElement() - ?.addEventListener("mouseenter", handleMouseEnter); - markerInstance - .getElement() - ?.addEventListener("mouseleave", handleMouseLeave); + container.addEventListener("click", handleClick); + container.addEventListener("mouseenter", handleMouseEnter); + container.addEventListener("mouseleave", handleMouseLeave); const handleDragStart = () => { - const lngLat = markerInstance.getLngLat(); + const lngLat = marker.getLngLat(); onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat }); }; const handleDrag = () => { - const lngLat = markerInstance.getLngLat(); + const lngLat = marker.getLngLat(); onDrag?.({ lng: lngLat.lng, lat: lngLat.lat }); }; const handleDragEnd = () => { - const lngLat = markerInstance.getLngLat(); + const lngLat = marker.getLngLat(); onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat }); }; - markerInstance.on("dragstart", handleDragStart); - markerInstance.on("drag", handleDrag); - markerInstance.on("dragend", handleDragEnd); - - return markerInstance; + marker.on("dragstart", handleDragStart); + marker.on("drag", handleDrag); + marker.on("dragend", handleDragEnd); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + setIsReady(true); - useEffect(() => { - if (!map) return; + return () => { + container.removeEventListener("click", handleClick); + container.removeEventListener("mouseenter", handleMouseEnter); + container.removeEventListener("mouseleave", handleMouseLeave); - marker.addTo(map); + marker.off("dragstart", handleDragStart); + marker.off("drag", handleDrag); + marker.off("dragend", handleDragEnd); - return () => { marker.remove(); + markerRef.current = null; + markerElementRef.current = null; + setIsReady(false); }; + }, [map, isLoaded]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [map]); + useEffect(() => { + markerRef.current?.setLngLat([longitude, latitude]); + }, [longitude, latitude]); - if ( - marker.getLngLat().lng !== longitude || - marker.getLngLat().lat !== latitude - ) { - marker.setLngLat([longitude, latitude]); - } - if (marker.isDraggable() !== draggable) { - marker.setDraggable(draggable); - } + useEffect(() => { + markerRef.current?.setDraggable(draggable); + }, [draggable]); - const currentOffset = marker.getOffset(); - const newOffset = markerOptions.offset ?? [0, 0]; - const [newOffsetX, newOffsetY] = Array.isArray(newOffset) - ? newOffset - : [newOffset.x, newOffset.y]; - if (currentOffset.x !== newOffsetX || currentOffset.y !== newOffsetY) { - marker.setOffset(newOffset); - } + useEffect(() => { + if (!markerRef.current) return; + const prev = markerOptionsRef.current; - if (marker.getRotation() !== markerOptions.rotation) { - marker.setRotation(markerOptions.rotation ?? 0); - } - if (marker.getRotationAlignment() !== markerOptions.rotationAlignment) { - marker.setRotationAlignment(markerOptions.rotationAlignment ?? "auto"); - } - if (marker.getPitchAlignment() !== markerOptions.pitchAlignment) { - marker.setPitchAlignment(markerOptions.pitchAlignment ?? "auto"); - } + 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} ); @@ -308,13 +314,15 @@ type MarkerContentProps = { }; function MarkerContent({ children, className }: MarkerContentProps) { - const { marker } = useMarkerContext(); + const { markerElementRef, isReady } = useMarkerContext(); + + if (!isReady || !markerElementRef.current) return null; return createPortal(
{children || }
, - marker.getElement() + markerElementRef.current ); } @@ -339,12 +347,19 @@ function MarkerPopup({ closeButton = false, ...popupOptions }: MarkerPopupProps) { - const { marker, map } = useMarkerContext(); - const container = useMemo(() => document.createElement("div"), []); - const prevPopupOptions = useRef(popupOptions); + 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 = useMemo(() => { - const popupInstance = new MapLibreGL.Popup({ + const popup = new MapLibreGL.Popup({ offset: 16, ...popupOptions, closeButton: false, @@ -352,41 +367,40 @@ function MarkerPopup({ .setMaxWidth("none") .setDOMContent(container); - return popupInstance; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (!map) return; - - popup.setDOMContent(container); - marker.setPopup(popup); + popupRef.current = popup; + markerRef.current.setPopup(popup); + setMounted(true); return () => { - marker.setPopup(null); + popup.remove(); + popupRef.current = null; + containerRef.current = null; + setMounted(false); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [map]); + }, [isReady]); - if (popup.isOpen()) { - const prev = prevPopupOptions.current; + useEffect(() => { + if (!popupRef.current) return; + const prev = popupOptionsRef.current; if (prev.offset !== popupOptions.offset) { - popup.setOffset(popupOptions.offset ?? 16); + popupRef.current.setOffset(popupOptions.offset ?? 16); } if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { - popup.setMaxWidth(popupOptions.maxWidth ?? "none"); + popupRef.current.setMaxWidth(popupOptions.maxWidth ?? "none"); } - prevPopupOptions.current = popupOptions; - } + popupOptionsRef.current = popupOptions; + }, [popupOptions]); - const handleClose = () => popup.remove(); + const handleClose = () => popupRef.current?.remove(); + + if (!mounted || !containerRef.current) return null; return createPortal(
@@ -394,7 +408,7 @@ function MarkerPopup({
, - container + containerRef.current ); } @@ -419,66 +433,78 @@ function MarkerTooltip({ className, ...popupOptions }: MarkerTooltipProps) { - const { marker, map } = useMarkerContext(); - const container = useMemo(() => document.createElement("div"), []); - const prevTooltipOptions = useRef(popupOptions); + 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 tooltip = useMemo(() => { - const tooltipInstance = new MapLibreGL.Popup({ + const container = document.createElement("div"); + containerRef.current = container; + + const popup = new MapLibreGL.Popup({ offset: 16, ...popupOptions, closeOnClick: true, closeButton: false, - }).setMaxWidth("none"); - - return tooltipInstance; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }) + .setMaxWidth("none") + .setDOMContent(container); - useEffect(() => { - if (!map) return; + popupRef.current = popup; - tooltip.setDOMContent(container); + const markerElement = markerElementRef.current; + const marker = markerRef.current; const handleMouseEnter = () => { - tooltip.setLngLat(marker.getLngLat()).addTo(map); + popup.setLngLat(marker.getLngLat()).addTo(map); }; - const handleMouseLeave = () => tooltip.remove(); + const handleMouseLeave = () => popup.remove(); - marker.getElement()?.addEventListener("mouseenter", handleMouseEnter); - marker.getElement()?.addEventListener("mouseleave", handleMouseLeave); + markerElement.addEventListener("mouseenter", handleMouseEnter); + markerElement.addEventListener("mouseleave", handleMouseLeave); + setMounted(true); return () => { - marker.getElement()?.removeEventListener("mouseenter", handleMouseEnter); - marker.getElement()?.removeEventListener("mouseleave", handleMouseLeave); - tooltip.remove(); + markerElement.removeEventListener("mouseenter", handleMouseEnter); + markerElement.removeEventListener("mouseleave", handleMouseLeave); + popup.remove(); + popupRef.current = null; + containerRef.current = null; + setMounted(false); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [map]); + }, [isReady, map]); - if (tooltip.isOpen()) { - const prev = prevTooltipOptions.current; + useEffect(() => { + if (!popupRef.current) return; + const prev = popupOptionsRef.current; if (prev.offset !== popupOptions.offset) { - tooltip.setOffset(popupOptions.offset ?? 16); + popupRef.current.setOffset(popupOptions.offset ?? 16); } if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { - tooltip.setMaxWidth(popupOptions.maxWidth ?? "none"); + popupRef.current.setMaxWidth(popupOptions.maxWidth ?? "none"); } - prevTooltipOptions.current = popupOptions; - } + popupOptionsRef.current = popupOptions; + }, [popupOptions]); + + if (!mounted || !containerRef.current) return null; return createPortal(
{children}
, - container + containerRef.current ); } @@ -505,7 +531,7 @@ function MarkerLabel({
+
{children}
); @@ -564,8 +590,7 @@ function ControlButton({ aria-label={label} type="button" className={cn( - "flex items-center justify-center size-8 hover:bg-accent dark:hover:bg-accent/40 transition-colors", - disabled && "opacity-50 pointer-events-none cursor-not-allowed" + "hover:bg-accent dark:hover:bg-accent/40 flex size-8 items-center justify-center transition-colors disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50" )} disabled={disabled} > @@ -638,7 +663,7 @@ function MapControls({ return (
(null); const popupOptionsRef = useRef(popupOptions); + const container = useMemo(() => document.createElement("div"), []); - const popup = useMemo(() => { - const popupInstance = new MapLibreGL.Popup({ + useEffect(() => { + if (!map) return; + + const popup = new MapLibreGL.Popup({ offset: 16, ...popupOptions, closeButton: false, }) .setMaxWidth("none") - .setLngLat([longitude, latitude]); - - return popupInstance; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (!map) return; + .setDOMContent(container) + .setLngLat([longitude, latitude]) + .addTo(map); const onCloseProp = () => onClose?.(); + popup.on("close", onCloseProp); - popup.setDOMContent(container); - popup.addTo(map); + popupRef.current = popup; return () => { popup.off("close", onCloseProp); if (popup.isOpen()) { popup.remove(); } + popupRef.current = null; }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [map]); - if (popup.isOpen()) { - const prev = popupOptionsRef.current; + useEffect(() => { + popupRef.current?.setLngLat([longitude, latitude]); + }, [longitude, latitude]); - if ( - popup.getLngLat().lng !== longitude || - popup.getLngLat().lat !== latitude - ) { - popup.setLngLat([longitude, latitude]); - } + useEffect(() => { + if (!popupRef.current) return; + const prev = popupOptionsRef.current; if (prev.offset !== popupOptions.offset) { - popup.setOffset(popupOptions.offset ?? 16); + popupRef.current.setOffset(popupOptions.offset ?? 16); } if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { - popup.setMaxWidth(popupOptions.maxWidth ?? "none"); + popupRef.current.setMaxWidth(popupOptions.maxWidth ?? "none"); } + popupOptionsRef.current = popupOptions; - } + }, [popupOptions]); const handleClose = () => { - popup.remove(); + popupRef.current?.remove(); onClose?.(); }; return createPortal(
@@ -820,7 +843,7 @@ function MapPopup({
, - containerRef.current + container ); } @@ -433,78 +419,66 @@ function MarkerTooltip({ 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 { marker, map } = useMarkerContext(); + const container = useMemo(() => document.createElement("div"), []); + const prevTooltipOptions = useRef(popupOptions); - const popup = new MapLibreGL.Popup({ + const tooltip = useMemo(() => { + const tooltipInstance = new MapLibreGL.Popup({ offset: 16, ...popupOptions, closeOnClick: true, closeButton: false, - }) - .setMaxWidth("none") - .setDOMContent(container); + }).setMaxWidth("none"); - popupRef.current = popup; + return tooltipInstance; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const markerElement = markerElementRef.current; - const marker = markerRef.current; + useEffect(() => { + if (!map) return; + + tooltip.setDOMContent(container); const handleMouseEnter = () => { - popup.setLngLat(marker.getLngLat()).addTo(map); + tooltip.setLngLat(marker.getLngLat()).addTo(map); }; - const handleMouseLeave = () => popup.remove(); + const handleMouseLeave = () => tooltip.remove(); - markerElement.addEventListener("mouseenter", handleMouseEnter); - markerElement.addEventListener("mouseleave", handleMouseLeave); - setMounted(true); + marker.getElement()?.addEventListener("mouseenter", handleMouseEnter); + marker.getElement()?.addEventListener("mouseleave", handleMouseLeave); return () => { - markerElement.removeEventListener("mouseenter", handleMouseEnter); - markerElement.removeEventListener("mouseleave", handleMouseLeave); - popup.remove(); - popupRef.current = null; - containerRef.current = null; - setMounted(false); + marker.getElement()?.removeEventListener("mouseenter", handleMouseEnter); + marker.getElement()?.removeEventListener("mouseleave", handleMouseLeave); + tooltip.remove(); }; - }, [isReady, map]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); - useEffect(() => { - if (!popupRef.current) return; - const prev = popupOptionsRef.current; + if (tooltip.isOpen()) { + const prev = prevTooltipOptions.current; if (prev.offset !== popupOptions.offset) { - popupRef.current.setOffset(popupOptions.offset ?? 16); + tooltip.setOffset(popupOptions.offset ?? 16); } if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { - popupRef.current.setMaxWidth(popupOptions.maxWidth ?? "none"); + tooltip.setMaxWidth(popupOptions.maxWidth ?? "none"); } - popupOptionsRef.current = popupOptions; - }, [popupOptions]); - - if (!mounted || !containerRef.current) return null; + prevTooltipOptions.current = popupOptions; + } return createPortal(
{children}
, - containerRef.current + container ); } @@ -531,7 +505,7 @@ function MarkerLabel({
+
{children}
); @@ -590,7 +564,8 @@ function ControlButton({ aria-label={label} type="button" className={cn( - "hover:bg-accent dark:hover:bg-accent/40 flex size-8 items-center justify-center transition-colors disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50" + "flex items-center justify-center size-8 hover:bg-accent dark:hover:bg-accent/40 transition-colors", + disabled && "opacity-50 pointer-events-none cursor-not-allowed" )} disabled={disabled} > @@ -663,7 +638,7 @@ function MapControls({ return (
(null); const popupOptionsRef = useRef(popupOptions); - const container = useMemo(() => document.createElement("div"), []); - useEffect(() => { - if (!map) return; - - const popup = new MapLibreGL.Popup({ + const popup = useMemo(() => { + const popupInstance = new MapLibreGL.Popup({ offset: 16, ...popupOptions, closeButton: false, }) .setMaxWidth("none") - .setDOMContent(container) - .setLngLat([longitude, latitude]) - .addTo(map); + .setLngLat([longitude, latitude]); - const onCloseProp = () => onClose?.(); + return popupInstance; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!map) return; + const onCloseProp = () => onClose?.(); popup.on("close", onCloseProp); - popupRef.current = popup; + popup.setDOMContent(container); + popup.addTo(map); return () => { popup.off("close", onCloseProp); if (popup.isOpen()) { popup.remove(); } - popupRef.current = null; }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [map]); - useEffect(() => { - popupRef.current?.setLngLat([longitude, latitude]); - }, [longitude, latitude]); - - useEffect(() => { - if (!popupRef.current) return; + if (popup.isOpen()) { const prev = popupOptionsRef.current; + if ( + popup.getLngLat().lng !== longitude || + popup.getLngLat().lat !== latitude + ) { + popup.setLngLat([longitude, latitude]); + } + if (prev.offset !== popupOptions.offset) { - popupRef.current.setOffset(popupOptions.offset ?? 16); + popup.setOffset(popupOptions.offset ?? 16); } if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { - popupRef.current.setMaxWidth(popupOptions.maxWidth ?? "none"); + popup.setMaxWidth(popupOptions.maxWidth ?? "none"); } - popupOptionsRef.current = popupOptions; - }, [popupOptions]); + } const handleClose = () => { - popupRef.current?.remove(); + popup.remove(); onClose?.(); }; return createPortal(
@@ -843,7 +820,7 @@ function MapPopup({