'use client' import { useCallback, useState, useEffect, useMemo, memo, type ReactNode } from 'react' import { Source, Layer, useMap, Popup } from 'react-map-gl/maplibre' import type { GeoJSONSource, MapMouseEvent } from 'maplibre-gl' import { createClusterLayers } from '../layers' import type { ClusterLayerOptions } from '../types' // Inject popup styles once const POPUP_STYLE_ID = 'map-cluster-popup-styles' function injectPopupStyles() { if (typeof document === 'undefined') return if (document.getElementById(POPUP_STYLE_ID)) return const style = document.createElement('style') style.id = POPUP_STYLE_ID style.textContent = ` .maplibregl-popup.map-popup-clean .maplibregl-popup-content { padding: 0 !important; background: transparent !important; box-shadow: none !important; border-radius: 0 !important; } .maplibregl-popup.map-popup-clean .maplibregl-popup-tip { display: none !important; } ` document.head.appendChild(style) } export interface MapClusterProps { sourceId: string data: GeoJSON.FeatureCollection clusterRadius?: number clusterMaxZoom?: number onClusterClick?: (clusterId: number, coordinates: [number, number]) => void onPointClick?: (feature: GeoJSON.Feature) => void /** Render prop for popup content - handles popup state automatically */ renderPopup?: (feature: GeoJSON.Feature, onClose: () => void) => ReactNode /** Popup anchor position */ popupAnchor?: 'top' | 'bottom' | 'left' | 'right' /** Popup offset */ popupOffset?: number | [number, number] /** Pan offset X when clicking point (pixels). Positive = point shifts right. */ panOffsetX?: number /** Pan offset Y when clicking point (pixels). Positive = point shifts down. */ panOffsetY?: number colors?: [string, string, string] radii?: [number, number, number] thresholds?: [number, number] /** Color when marker/cluster is hovered */ hoverColor?: string } export const MapCluster = memo(function MapCluster({ sourceId, data, clusterRadius = 50, clusterMaxZoom = 14, onClusterClick, onPointClick, renderPopup, popupAnchor = 'bottom', popupOffset = 15, panOffsetX = 0, panOffsetY = 150, colors, radii, thresholds, hoverColor, }: MapClusterProps) { const { current: map } = useMap() const [selectedFeature, setSelectedFeature] = useState(null) const [popupCoords, setPopupCoords] = useState<[number, number] | null>(null) // Inject popup styles on mount useEffect(() => { injectPopupStyles() }, []) // Memoize layers so layer ids stay referentially stable across renders. // Without this, the click/hover effect below re-subscribes on every render. const { cluster, clusterCount, unclusteredPoint } = useMemo(() => { const layerOptions: ClusterLayerOptions = { sourceId, colors, radii, thresholds, hoverColor, } return createClusterLayers(layerOptions) }, [sourceId, colors, radii, thresholds, hoverColor]) const clusterLayerId = cluster.id as string const pointLayerId = unclusteredPoint.id as string const handleClosePopup = useCallback(() => { setSelectedFeature(null) setPopupCoords(null) }, []) const handleClick = useCallback( async (event: MapMouseEvent) => { if (!map) return const features = map.queryRenderedFeatures(event.point, { layers: [clusterLayerId, pointLayerId], }) if (!features || features.length === 0) return const feature = features[0] const geometry = feature.geometry if (geometry.type !== 'Point') return const coordinates = geometry.coordinates as [number, number] const clusterId = feature.properties?.cluster_id if (clusterId) { const source = map.getSource(sourceId) as GeoJSONSource if (!source) return try { const zoom = await source.getClusterExpansionZoom(clusterId) map.easeTo({ center: coordinates, zoom, duration: 500, }) onClusterClick?.(clusterId, coordinates) } catch (error) { console.error('Error expanding cluster:', error) } } else { // Handle point click onPointClick?.(feature) // If renderPopup is provided, show popup and pan to point if (renderPopup) { // Pan map to center on point with offset for popup visibility map.easeTo({ center: coordinates, duration: 300, offset: [panOffsetX, panOffsetY], }) setSelectedFeature(feature) setPopupCoords(coordinates) } } }, [map, sourceId, clusterLayerId, pointLayerId, onClusterClick, onPointClick, renderPopup, panOffsetX, panOffsetY] ) // Close popup when clicking empty area const handleMapClick = useCallback( (event: MapMouseEvent) => { if (!map) return const features = map.queryRenderedFeatures(event.point, { layers: [clusterLayerId, pointLayerId], }) // Close popup if clicked on empty area (no features) if (!features || features.length === 0) { handleClosePopup() } }, [map, clusterLayerId, pointLayerId, handleClosePopup] ) // Register click and hover handlers with proper cleanup useEffect(() => { if (!map) return let currentHoveredId: number | string | null = null // Helper to clear hover state. // Guarded: on unmount the source may already be gone, which would throw. const clearHoverState = () => { if (currentHoveredId !== null) { if (map.getSource(sourceId)) { map.setFeatureState( { source: sourceId, id: currentHoveredId }, { hover: false } ) } currentHoveredId = null } } // Hover handler for both clusters and points const handleMouseMove = (e: MapMouseEvent) => { const features = map.queryRenderedFeatures(e.point, { layers: [clusterLayerId, pointLayerId], }) if (features.length > 0) { map.getCanvas().style.cursor = 'pointer' const feature = features[0] const featureId = feature.id if (featureId !== undefined && featureId !== currentHoveredId) { clearHoverState() currentHoveredId = featureId map.setFeatureState( { source: sourceId, id: featureId }, { hover: true } ) } } else { map.getCanvas().style.cursor = '' clearHoverState() } } const handleMouseLeave = () => { map.getCanvas().style.cursor = '' clearHoverState() } map.on('click', clusterLayerId, handleClick) map.on('click', pointLayerId, handleClick) map.on('click', handleMapClick) map.on('mousemove', clusterLayerId, handleMouseMove) map.on('mousemove', pointLayerId, handleMouseMove) map.on('mouseleave', clusterLayerId, handleMouseLeave) map.on('mouseleave', pointLayerId, handleMouseLeave) // Cleanup on unmount return () => { clearHoverState() map.off('click', clusterLayerId, handleClick) map.off('click', pointLayerId, handleClick) map.off('click', handleMapClick) map.off('mousemove', clusterLayerId, handleMouseMove) map.off('mousemove', pointLayerId, handleMouseMove) map.off('mouseleave', clusterLayerId, handleMouseLeave) map.off('mouseleave', pointLayerId, handleMouseLeave) } }, [map, sourceId, clusterLayerId, pointLayerId, handleClick, handleMapClick]) return ( <> {/* Popup rendered via render prop */} {renderPopup && selectedFeature && popupCoords && ( {renderPopup(selectedFeature, handleClosePopup)} )} ) })