import { useCallback, useRef, useState, useMemo, useEffect } from 'react'; import { H3HexagonLayer } from '@deck.gl/geo-layers'; import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers'; import { cellToBoundary } from 'h3-js'; import Supercluster from 'supercluster'; import type { PickingInfo } from '@deck.gl/core'; import type { HexagonData, PostcodeFeature, PostcodeProperties, PostcodeGeometry, POI, FeatureMeta, Bounds, } from '../types'; import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK, 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, getFeatureFillColor } from '../lib/map-utils'; import type { TravelTimeEntry } from './useTravelTime'; import { MarchingAntsExtension } from '../lib/MarchingAntsExtension'; interface UseDeckLayersProps { data: HexagonData[]; postcodeData: PostcodeFeature[]; usePostcodeView: boolean; zoom: number; pois: POI[]; viewFeature: string | null; colorRange: [number, number] | null; filterRange: [number, number] | null; features: FeatureMeta[]; selectedHexagonId: string | null; hoveredHexagonId: string | null; onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void; onHexagonHover: (h3: string | null, x?: number, y?: number) => void; theme: 'light' | 'dark'; selectedPostcodeGeometry?: PostcodeGeometry | null; bounds?: Bounds | null; travelTimeEntries?: TravelTimeEntry[]; } 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; } export function useDeckLayers({ data, postcodeData, usePostcodeView, zoom, pois, viewFeature, colorRange, filterRange, features, selectedHexagonId, hoveredHexagonId, onHexagonClick, onHexagonHover, theme, selectedPostcodeGeometry, bounds: viewportBounds, travelTimeEntries = [], }: UseDeckLayersProps) { const [popupInfo, setPopupInfo] = useState(null); const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null); const [hoveredPostcode, setHoveredPostcode] = useState(null); // Marching ants animation const [marchTime, setMarchTime] = useState(0); const hasSelection = selectedPostcodeGeometry != null || selectedHexagonId != null; useEffect(() => { if (!hasSelection) return; setMarchTime(0); const id = setInterval(() => setMarchTime((t) => (t + 0.3) % 10000), 50); return () => clearInterval(id); }, [hasSelection]); const isDark = theme === 'dark'; const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT; // --- Refs for deck.gl accessors --- const viewFeatureRef = useRef(viewFeature); viewFeatureRef.current = viewFeature; const colorRangeRef = useRef(colorRange); colorRangeRef.current = colorRange; const filterRangeRef = useRef(filterRange); filterRangeRef.current = filterRange; const isDarkRef = useRef(isDark); isDarkRef.current = isDark; const densityGradientRef = useRef(densityGradient); densityGradientRef.current = densityGradient; const selectedHexagonIdRef = useRef(selectedHexagonId); selectedHexagonIdRef.current = selectedHexagonId; const hoveredHexagonIdRef = useRef(hoveredHexagonId); hoveredHexagonIdRef.current = hoveredHexagonId; const hoveredPostcodeRef = useRef(hoveredPostcode); hoveredPostcodeRef.current = hoveredPostcode; const colorFeatureMeta = useMemo( () => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null), [viewFeature, features] ); const colorFeatureMetaRef = useRef(colorFeatureMeta); colorFeatureMetaRef.current = colorFeatureMeta; // Track enum value count for discrete coloring (0 = numeric/continuous) const enumCountRef = useRef(0); enumCountRef.current = colorFeatureMeta?.type === 'enum' && colorFeatureMeta.values ? colorFeatureMeta.values.length : 0; // --- Count ranges --- const countRange = useMemo(() => { if (data.length === 0) return { min: 0, max: 1 }; let min = Infinity; let max = -Infinity; for (const d of data) { if (viewportBounds) { if ( d.lat < viewportBounds.south || d.lat > viewportBounds.north || d.lon < viewportBounds.west || d.lon > viewportBounds.east ) continue; } const c = d.count as number; if (c < min) min = c; if (c > max) max = c; } if (min === Infinity) return { min: 0, max: 1 }; if (min === max) return { min, max: min + 1 }; return { min, max }; }, [data, viewportBounds]); const countRangeRef = useRef(countRange); countRangeRef.current = countRange; const postcodeCountRange = useMemo(() => { if (postcodeData.length === 0) return { min: 0, max: 1 }; let min = Infinity; let max = -Infinity; for (const d of postcodeData) { if (viewportBounds) { const [lng, lat] = d.properties.centroid as [number, number]; if ( lat < viewportBounds.south || lat > viewportBounds.north || lng < viewportBounds.west || lng > viewportBounds.east ) continue; } const c = d.properties.count; if (c < min) min = c; if (c > max) max = c; } if (min === Infinity) return { min: 0, max: 1 }; if (min === max) return { min, max: min + 1 }; return { min, max }; }, [postcodeData, viewportBounds]); const postcodeCountRangeRef = useRef(postcodeCountRange); postcodeCountRangeRef.current = postcodeCountRange; // --- Click/hover handlers --- const onHexagonClickRef = useRef(onHexagonClick); onHexagonClickRef.current = onHexagonClick; const handleHexagonClick = useCallback((info: PickingInfo) => { if (info.object && 'h3' in info.object) { onHexagonClickRef.current(info.object.h3); } }, []); const onHexagonHoverRef = useRef(onHexagonHover); onHexagonHoverRef.current = onHexagonHover; const handleHexagonHover = useCallback((info: PickingInfo) => { if (info.object && 'h3' in info.object && info.x !== undefined && info.y !== undefined) { setHoverPosition({ x: info.x, y: info.y }); onHexagonHoverRef.current(info.object.h3, info.x, info.y); } else { setHoverPosition(null); onHexagonHoverRef.current(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); }, []); // eslint-disable-next-line @typescript-eslint/no-explicit-any const handlePostcodeClick = useCallback((info: PickingInfo) => { const pc = info.object?.properties?.postcode; if (pc) { onHexagonClickRef.current(pc, true, info.object?.geometry); } }, []); // eslint-disable-next-line @typescript-eslint/no-explicit-any const handlePostcodeHoverCallback = useCallback((info: PickingInfo) => { const pc = info.object?.properties?.postcode; if (pc && info.x !== undefined && info.y !== undefined) { setHoveredPostcode(pc); setHoverPosition({ x: info.x, y: info.y }); onHexagonHoverRef.current(pc, info.x, info.y); } else { setHoveredPostcode(null); setHoverPosition(null); onHexagonHoverRef.current(null); } }, []); // --- Color triggers --- const ttTrigger = useMemo(() => { const parts: string[] = []; for (let i = 0; i < travelTimeEntries.length; i++) { const entry = travelTimeEntries[i]; parts.push(`${i}:${entry.slug}|${entry.timeRange?.[0]}|${entry.timeRange?.[1]}`); } return parts.join(';'); }, [travelTimeEntries]); const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}|${ttTrigger}`; const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${hoveredPostcode}|${theme}|${ttTrigger}`; // --- Layers --- const hexLayer = useMemo( () => new H3HexagonLayer({ id: 'h3-hexagons', data, getHexagon: (d) => d.h3, getFillColor: (d) => { const dark = isDarkRef.current; const vf = viewFeatureRef.current; const clr = colorRangeRef.current; const fr = filterRangeRef.current; const cfm = colorFeatureMetaRef.current; if (vf && clr) { // Travel time feature: dim hexagons with no data if (vf.startsWith('tt_')) { const ttVal = d[`avg_${vf}`]; if (ttVal == null) { return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [ number, number, number, number, ]; } return getFeatureFillColor( ttVal as number, ttVal as number, ttVal as number, clr, fr, 0, densityGradientRef.current, dark, 255 ); } // Regular feature if (cfm) { const val = d[`avg_${vf}`] ?? d[`min_${vf}`]; const minVal = d[`min_${vf}`] as number | undefined; const maxVal = d[`max_${vf}`] as number | undefined; return getFeatureFillColor( val as number | null | undefined, minVal, maxVal, clr, fr, 0, densityGradientRef.current, dark, 255, enumCountRef.current ); } } // Density fallback const cr = countRangeRef.current; const c = d.count as number; const t = (c - cr.min) / (cr.max - cr.min); return getFeatureFillColor( null, undefined, undefined, null, null, t, densityGradientRef.current, dark, 255 ); }, getLineColor: (d) => { if (d.h3 === hoveredHexagonIdRef.current) return [29, 228, 195, 200] as [number, number, number, number]; return [0, 0, 0, 0] as [number, number, number, number]; }, getLineWidth: (d) => { if (d.h3 === hoveredHexagonIdRef.current) return 2; return 0; }, lineWidthUnits: 'pixels', updateTriggers: { getFillColor: [colorTrigger], getLineColor: [colorTrigger], getLineWidth: [colorTrigger], }, extruded: false, pickable: true, opacity: 1, highPrecision: true, onClick: handleHexagonClick, onHover: handleHexagonHover, // @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps beforeId: 'landuse_park', }), [data, colorTrigger, handleHexagonClick, handleHexagonHover] ); const postcodeLayer = useMemo( () => new GeoJsonLayer({ id: 'postcode-polygons', data: postcodeData as PostcodeFeature[], getFillColor: (f) => { const d = f.properties; const dark = isDarkRef.current; const vf = viewFeatureRef.current; const clr = colorRangeRef.current; const fr = filterRangeRef.current; const cfm = colorFeatureMetaRef.current; if (vf && clr) { // Travel time feature: dim postcodes with no data if (vf.startsWith('tt_')) { const ttVal = d[`avg_${vf}`]; if (ttVal == null) { return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [ number, number, number, number, ]; } return getFeatureFillColor( ttVal as number, ttVal as number, ttVal as number, clr, fr, 0, densityGradientRef.current, dark, 180 ); } // Regular feature if (cfm) { const val = d[`avg_${vf}`] ?? d[`min_${vf}`]; const minVal = d[`min_${vf}`] as number | undefined; const maxVal = d[`max_${vf}`] as number | undefined; return getFeatureFillColor( val as number | null | undefined, minVal, maxVal, clr, fr, 0, densityGradientRef.current, dark, 180, enumCountRef.current ); } } const cr = postcodeCountRangeRef.current; const c = d.count; const t = (c - cr.min) / (cr.max - cr.min); return getFeatureFillColor( null, undefined, undefined, null, null, t, densityGradientRef.current, dark, 180 ); }, getLineColor: (f) => { const pc = f.properties.postcode; const dark = isDarkRef.current; if (pc === hoveredPostcodeRef.current) return [29, 228, 195, 200] as [number, number, number, number]; return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [ number, number, number, number, ]; }, getLineWidth: (f) => { const pc = f.properties.postcode; if (pc === hoveredPostcodeRef.current) return 2; return 1; }, lineWidthUnits: 'pixels', updateTriggers: { getFillColor: [postcodeColorTrigger], getLineColor: [postcodeColorTrigger], getLineWidth: [postcodeColorTrigger], }, extruded: false, pickable: true, onClick: handlePostcodeClick, onHover: handlePostcodeHoverCallback, // @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps beforeId: 'landuse_park', }), [postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback] ); const postcodeLabelsLayer = useMemo( () => new TextLayer({ id: 'postcode-labels', data: postcodeData, getPosition: (f) => f.properties.centroid, getText: (f) => f.properties.postcode, getSize: 12, getColor: theme === 'dark' ? [255, 255, 255, 240] : [40, 40, 40, 220], getTextAnchor: 'middle', getAlignmentBaseline: 'center', fontFamily: 'Inter, system-ui, sans-serif', fontWeight: 600, outlineWidth: 2, outlineColor: theme === 'dark' ? [30, 30, 30, 200] : [255, 255, 255, 200], sizeUnits: 'pixels', sizeMinPixels: 10, sizeMaxPixels: 14, billboard: false, pickable: false, }), [postcodeData, theme] ); // --- POI clustering --- 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]); // --- Individual POI layers (shadow → background → emoji) --- 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] ); // --- Cluster layers --- 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] ); // Marching ants highlight layer for selected hexagon or postcode const marchingAntsLayer = useMemo(() => { let geometry: PostcodeGeometry | null = null; if (selectedPostcodeGeometry) { geometry = selectedPostcodeGeometry; } else if (selectedHexagonId) { const boundary = cellToBoundary(selectedHexagonId, true); geometry = { type: 'Polygon', coordinates: [boundary] }; } if (!geometry) return null; return new GeoJsonLayer({ id: 'marching-ants', data: [ { type: 'Feature' as const, geometry, properties: {}, }, ], filled: false, stroked: true, getLineColor: [29, 228, 195, 255], getLineWidth: 3, lineWidthUnits: 'pixels' as const, pickable: false, marchTime, extensions: [new MarchingAntsExtension()], }); }, [selectedPostcodeGeometry, selectedHexagonId, marchTime]); const poiLayers = useMemo( () => [poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer], [poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer] ); const layers = useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const baseLayers: any[] = usePostcodeView ? zoom >= 16 ? [postcodeLayer, postcodeLabelsLayer, ...poiLayers] : [postcodeLayer, ...poiLayers] : [hexLayer, ...poiLayers]; if (marchingAntsLayer) baseLayers.push(marchingAntsLayer); return baseLayers; }, [ usePostcodeView, zoom, hexLayer, postcodeLayer, postcodeLabelsLayer, poiLayers, marchingAntsLayer, ]); const handleMouseLeave = useCallback(() => { setHoverPosition(null); setHoveredPostcode(null); setPopupInfo(null); onHexagonHoverRef.current(null); }, []); const clearPopupInfo = useCallback(() => setPopupInfo(null), []); return { layers, popupInfo, clearPopupInfo, hoverPosition, countRange, postcodeCountRange, colorFeatureMeta, handleMouseLeave, hoveredPostcode, }; }