diff --git a/src/app/docs/_components/examples/cluster-example.tsx b/src/app/docs/_components/examples/cluster-example.tsx index 336523b..f8a2be0 100644 --- a/src/app/docs/_components/examples/cluster-example.tsx +++ b/src/app/docs/_components/examples/cluster-example.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Map, MapClusterLayer, MapPopup, MapControls } from "@/registry/map"; +import { PinIcon } from "lucide-react"; interface EarthquakeProperties { mag: number; @@ -30,6 +31,14 @@ export default function ClusterExample() { properties: feature.properties, }); }} + renderPoint={(props) => { + console.log(props); + return ( + <> + + + ); + }} /> {selectedPoint && ( diff --git a/src/app/docs/clusters/page.tsx b/src/app/docs/clusters/page.tsx index 8db6e36..17906ff 100644 --- a/src/app/docs/clusters/page.tsx +++ b/src/app/docs/clusters/page.tsx @@ -30,7 +30,7 @@ export default function ClustersPage() {

Click on clusters to zoom in. Click individual points to see details - in a popup. + in a popup. You can also provide a custom renderer to the MapClusterLayer component to render a custom icon for each point.

diff --git a/src/registry/map.tsx b/src/registry/map.tsx index b87384c..f36b6bf 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,81 +1031,111 @@ 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 (!map.getLayer(unclusteredLayerId)) { + map.addLayer({ + id: unclusteredLayerId, + type: "circle", + source: sourceId, + filter: ["!", ["has", "point_count"]], + paint: { + "circle-color": pointColor, + "circle-radius": pointRadius, + "circle-stroke-width": pointStrokeWidth, + "circle-stroke-color": pointStrokeColor || "transparent", + // Use opacity to hide the layer if we have custom markers + "circle-opacity": hasCustomRenderer ? 0 : 1, + "circle-stroke-opacity": hasCustomRenderer ? 0 : 1, + }, + }); + } + }, [map]); + useEffect(() => { if (!isLoaded || !map) return; - // Add clustered GeoJSON source - map.addSource(sourceId, { - type: "geojson", - 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, - ], - }, - }); + addLayers(); - // 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); @@ -1106,10 +1147,16 @@ function MapClusterLayer< // ignore } }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isLoaded, map, sourceId]); + }, [ + isLoaded, + map, + addLayers, + sourceId, + clusterCountLayerId, + unclusteredLayerId, + clusterLayerId, + ]); - // Update source data when data prop changes (only for non-URL data) useEffect(() => { if (!isLoaded || !map || typeof data === "string") return; @@ -1119,16 +1166,17 @@ function MapClusterLayer< } }, [isLoaded, map, data, sourceId]); - // Update layer styles when props change + // Update styles dynamically useEffect(() => { if (!isLoaded || !map) return; const prev = stylePropsRef.current; + + // Cluster colors update logic... 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", @@ -1150,9 +1198,18 @@ function MapClusterLayer< ]); } - // Update unclustered point layer color - if (map.getLayer(unclusteredLayerId) && prev.pointColor !== pointColor) { + if (map.getLayer(unclusteredLayerId)) { map.setPaintProperty(unclusteredLayerId, "circle-color", pointColor); + map.setPaintProperty( + unclusteredLayerId, + "circle-opacity", + hasCustomRenderer ? 0 : 1 + ); + map.setPaintProperty( + unclusteredLayerId, + "circle-stroke-opacity", + hasCustomRenderer ? 0 : 1 + ); } stylePropsRef.current = { clusterColors, clusterThresholds, pointColor }; @@ -1164,24 +1221,120 @@ 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; + + // querySourceFeatures will now work because the layer exists (even if invisible) + 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"; + + // IMPORTANT: Prevent map clicks from firing when clicking the marker + element.onclick = (e) => e.stopPropagation(); + + 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); + // sourcedata is crucial for loading new tiles + 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,25 @@ 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 +1374,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 = ""); - 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 (map.getLayer(clusterLayerId)) { + map.on("click", clusterLayerId, handleClusterClick); + map.on("mouseenter", clusterLayerId, setPointer); + map.on("mouseleave", clusterLayerId, resetPointer); + } + + if (map.getLayer(unclusteredLayerId)) { + map.on("click", unclusteredLayerId, handlePointClick); + map.on("mouseenter", unclusteredLayerId, () => { + if (onPointClick && !hasCustomRenderer) 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 (map.getLayer(unclusteredLayerId)) { + map.off("click", unclusteredLayerId, handlePointClick); + map.off("mouseenter", unclusteredLayerId, setPointer); + map.off("mouseleave", unclusteredLayerId, resetPointer); + } + } catch { + // ignore + } }; }, [ isLoaded, @@ -1265,9 +1415,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 {