import { useCallback, useMemo, useRef, useState } from 'react'; import type { PickingInfo } from '@deck.gl/core'; import { IconLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers'; import Supercluster from 'supercluster'; import type { POI } from '../types'; import { POI_GROUP_COLORS, POI_DEFAULT_COLOR, MINOR_POI_CATEGORIES, MINOR_POI_ZOOM_THRESHOLD, POI_CLUSTER_RADIUS, POI_CLUSTER_MAX_ZOOM, } from '../lib/consts'; import { emojiToTwemojiUrl } from '../lib/map-utils'; export interface PopupInfo { x: number; y: number; name: string; category: string; group: string; emoji: string; id: string; isCluster?: boolean; clusterCount?: number; } interface ClusterPoint { lng: number; lat: number; count: number; clusterId: number; } interface UsePoiLayersProps { pois: POI[]; zoom: number; isDark: boolean; } export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) { const [popupInfo, setPopupInfo] = useState(null); const handlePoiHover = useCallback((info: PickingInfo) => { if (info.object && info.x !== undefined && info.y !== undefined) { setPopupInfo({ x: info.x, y: info.y, name: info.object.name, category: info.object.category, group: info.object.group, emoji: info.object.emoji, id: info.object.id, }); } else { setPopupInfo(null); } }, []); const handlePoiHoverRef = useRef(handlePoiHover); handlePoiHoverRef.current = handlePoiHover; const stablePoiHover = useCallback((info: PickingInfo) => { handlePoiHoverRef.current(info); }, []); const handleClusterHover = useCallback((info: PickingInfo) => { if (info.object && info.x !== undefined && info.y !== undefined) { setPopupInfo({ x: info.x, y: info.y, name: `${info.object.count} places`, category: 'Zoom in to see details', group: '', emoji: '', id: '', isCluster: true, clusterCount: info.object.count, }); } else { setPopupInfo(null); } }, []); const handleClusterHoverRef = useRef(handleClusterHover); handleClusterHoverRef.current = handleClusterHover; const stableClusterHover = useCallback((info: PickingInfo) => { handleClusterHoverRef.current(info); }, []); const clusterIndex = useMemo(() => { if (pois.length === 0) return null; const index = new Supercluster({ radius: POI_CLUSTER_RADIUS, maxZoom: POI_CLUSTER_MAX_ZOOM, }); const features: Supercluster.PointFeature[] = pois.map((poi) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [poi.lng, poi.lat] }, properties: poi, })); index.load(features); return index; }, [pois]); const clusterZoom = Math.floor(zoom); const showMinorPois = zoom >= MINOR_POI_ZOOM_THRESHOLD; const { visiblePois, clusters } = useMemo(() => { if (!clusterIndex || pois.length === 0) { return { visiblePois: [] as POI[], clusters: [] as ClusterPoint[] }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any const allFeatures = clusterIndex.getClusters([-180, -85, 180, 85], clusterZoom) as any[]; const individual: POI[] = []; const clusterPoints: ClusterPoint[] = []; for (const feature of allFeatures) { if (feature.properties.cluster) { clusterPoints.push({ lng: feature.geometry.coordinates[0], lat: feature.geometry.coordinates[1], count: feature.properties.point_count, clusterId: feature.properties.cluster_id, }); } else { const poi = feature.properties as POI; if (!showMinorPois && MINOR_POI_CATEGORIES.has(poi.category)) continue; individual.push(poi); } } return { visiblePois: individual, clusters: clusterPoints }; }, [clusterIndex, clusterZoom, showMinorPois, pois]); const poiShadowLayer = useMemo( () => new ScatterplotLayer({ id: 'poi-shadow', data: visiblePois, getPosition: (d) => [d.lng, d.lat], getRadius: 16, radiusUnits: 'pixels', getFillColor: isDark ? [0, 0, 0, 50] : [0, 0, 0, 25], pickable: false, transitions: { getRadius: { duration: 300, enter: () => [0] } }, }), [visiblePois, isDark] ); const poiBackgroundLayer = useMemo( () => new ScatterplotLayer({ id: 'poi-background', data: visiblePois, getPosition: (d) => [d.lng, d.lat], getRadius: 14, radiusUnits: 'pixels', getFillColor: isDark ? [41, 37, 36, 255] : [255, 255, 255, 255], getLineColor: (d) => { const c = POI_GROUP_COLORS[d.group] || POI_DEFAULT_COLOR; return [c[0], c[1], c[2], 255] as [number, number, number, number]; }, getLineWidth: 2.5, lineWidthUnits: 'pixels', stroked: true, pickable: true, onHover: stablePoiHover, transitions: { getRadius: { duration: 300, enter: () => [0] } }, }), [visiblePois, isDark, stablePoiHover] ); const poiIconLayer = useMemo( () => new IconLayer({ id: 'poi-icons', data: visiblePois, getPosition: (d) => [d.lng, d.lat], getIcon: (d) => ({ url: emojiToTwemojiUrl(d.emoji), width: 72, height: 72, }), getSize: 18, sizeUnits: 'pixels', pickable: false, transitions: { getSize: { duration: 300, enter: () => [0] } }, }), [visiblePois] ); const clusterCircleLayer = useMemo( () => new ScatterplotLayer({ id: 'poi-clusters', data: clusters, getPosition: (d) => [d.lng, d.lat], getRadius: (d) => Math.min(30, 14 + Math.sqrt(d.count) * 2), radiusUnits: 'pixels', getFillColor: isDark ? [5, 129, 114, 220] : [20, 184, 166, 220], getLineColor: [255, 255, 255, isDark ? 60 : 120], getLineWidth: 2, lineWidthUnits: 'pixels', stroked: true, pickable: true, onHover: stableClusterHover, transitions: { getRadius: { duration: 300, enter: () => [0] } }, }), [clusters, isDark, stableClusterHover] ); const clusterTextLayer = useMemo( () => new TextLayer({ id: 'poi-cluster-text', data: clusters, getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.count >= 1000 ? `${(d.count / 1000).toFixed(1)}k` : String(d.count)), getSize: 12, getColor: [255, 255, 255, 255], fontWeight: 700, fontFamily: 'Inter, system-ui, sans-serif', getTextAnchor: 'middle', getAlignmentBaseline: 'center', sizeUnits: 'pixels', pickable: false, }), [clusters] ); const poiLayers = useMemo( () => [poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer], [poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer] ); const clearPopupInfo = useCallback(() => setPopupInfo(null), []); return { poiLayers, popupInfo, clearPopupInfo }; }