import { useCallback, useRef, useState, useMemo, useEffect } from 'react'; import { H3HexagonLayer } from '@deck.gl/geo-layers'; import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers'; import type { PickingInfo } from '@deck.gl/core'; import type { HexagonData, PostcodeFeature, PostcodeProperties, PostcodeGeometry, POI, FeatureMeta, Bounds, } from '../types'; import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../lib/consts'; import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils'; import { type TravelTimeEntry, travelFieldKey, } from './useTravelTime'; import { MarchingAntsExtension } from '../lib/MarchingAntsExtension'; /** Convert POI id (e.g. "n12345") to OpenStreetMap URL */ function osmIdToUrl(id: string): string | null { const match = id.match(/^([nwr])(\d+)$/); if (!match) return null; const typeMap: Record = { n: 'node', w: 'way', r: 'relation' }; return `https://www.openstreetmap.org/${typeMap[match[1]]}/${match[2]}`; } export { osmIdToUrl }; interface UseDeckLayersProps { data: HexagonData[]; postcodeData: PostcodeFeature[]; usePostcodeView: boolean; 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[]; travelTimeColorRanges?: Map; } export interface PopupInfo { x: number; y: number; name: string; category: string; id: string; } /** Find the primary travel time entry: first entry with a slug and color range. */ function getPrimaryTravelIndex( entries: TravelTimeEntry[], colorRanges: Map ): number { for (let i = 0; i < entries.length; i++) { if (entries[i].slug && colorRanges.has(i)) return i; } return -1; } export function useDeckLayers({ data, postcodeData, usePostcodeView, pois, viewFeature, colorRange, filterRange, features, selectedHexagonId, hoveredHexagonId, onHexagonClick, onHexagonHover, theme, selectedPostcodeGeometry, bounds: viewportBounds, travelTimeEntries = [], travelTimeColorRanges = new Map(), }: 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 hasPostcodeGeometry = selectedPostcodeGeometry != null; useEffect(() => { if (!hasPostcodeGeometry) return; setMarchTime(0); const id = setInterval(() => setMarchTime((t) => t + 0.3), 50); return () => clearInterval(id); }, [hasPostcodeGeometry]); 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 travelTimeEntriesRef = useRef(travelTimeEntries); travelTimeEntriesRef.current = travelTimeEntries; const travelTimeColorRangesRef = useRef(travelTimeColorRanges); travelTimeColorRangesRef.current = travelTimeColorRanges; const primaryTravelIndex = useMemo( () => getPrimaryTravelIndex(travelTimeEntries, travelTimeColorRanges), [travelTimeEntries, travelTimeColorRanges] ); const primaryTravelIndexRef = useRef(primaryTravelIndex); primaryTravelIndexRef.current = primaryTravelIndex; const colorFeatureMeta = useMemo( () => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null), [viewFeature, features] ); const colorFeatureMetaRef = useRef(colorFeatureMeta); colorFeatureMetaRef.current = colorFeatureMeta; // --- 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, id: info.object.id, }); } else { setPopupInfo(null); } }, []); const handlePoiHoverRef = useRef(handlePoiHover); handlePoiHoverRef.current = handlePoiHover; const stablePoiHover = useCallback((info: PickingInfo) => { handlePoiHoverRef.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]; const cr = travelTimeColorRanges.get(i); parts.push(`${i}:${entry.slug}|${cr?.[0]}|${cr?.[1]}|${entry.timeRange?.[0]}|${entry.timeRange?.[1]}`); } return parts.join(';'); }, [travelTimeEntries, travelTimeColorRanges]); 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 pti = primaryTravelIndexRef.current; const entries = travelTimeEntriesRef.current; const colorRanges = travelTimeColorRangesRef.current; // Travel time coloring: primary entry colors, others dim-filter if (pti >= 0) { const primaryEntry = entries[pti]; const fieldKey = travelFieldKey(primaryEntry); const ttVal = d[`avg_${fieldKey}`]; const ttClr = colorRanges.get(pti); if (ttVal == null) { return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number]; } // Check all entries with time ranges as filters for (let i = 0; i < entries.length; i++) { const entry = entries[i]; if (!entry.timeRange || !entry.slug) continue; const fk = travelFieldKey(entry); const modeVal = d[`avg_${fk}`]; if (modeVal == null || (modeVal as number) < entry.timeRange[0] || (modeVal as number) > entry.timeRange[1]) { return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number]; } } if (ttClr) { return getFeatureFillColor( ttVal as number, ttVal as number, ttVal as number, ttClr, null, 0, densityGradientRef.current, dark, 255 ); } } const vf = viewFeatureRef.current; const clr = colorRangeRef.current; const fr = filterRangeRef.current; const cfm = colorFeatureMetaRef.current; if (vf && clr && 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 ); } 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 === selectedHexagonIdRef.current) return [255, 255, 255, 255] as [number, number, number, number]; 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 === selectedHexagonIdRef.current) return 3; 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 vf = viewFeatureRef.current; const clr = colorRangeRef.current; const fr = filterRangeRef.current; const cfm = colorFeatureMetaRef.current; const dark = isDarkRef.current; if (vf && clr && 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 ); } 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] ); const poiLayer = useMemo( () => new IconLayer({ id: 'poi-icons', data: pois, getPosition: (d) => [d.lng, d.lat], getIcon: (d) => ({ url: emojiToTwemojiUrl(d.emoji), width: 72, height: 72, }), getSize: 24, sizeMinPixels: 20, sizeMaxPixels: 40, pickable: true, onHover: stablePoiHover, }), [pois, stablePoiHover] ); // Marching ants highlight layer for selected postcode const marchingAntsLayer = useMemo(() => { if (!selectedPostcodeGeometry) return null; return new GeoJsonLayer({ id: 'marching-ants', data: [ { type: 'Feature' as const, geometry: selectedPostcodeGeometry, properties: {}, }, ], filled: false, stroked: true, getLineColor: [29, 228, 195, 255], getLineWidth: 3, lineWidthUnits: 'pixels' as const, pickable: false, marchTime, extensions: [new MarchingAntsExtension()], }); }, [selectedPostcodeGeometry, marchTime]); const layers = useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const baseLayers: any[] = usePostcodeView ? [postcodeLayer, postcodeLabelsLayer, poiLayer] : [hexLayer, poiLayer]; if (marchingAntsLayer) baseLayers.push(marchingAntsLayer); return baseLayers; }, [ usePostcodeView, hexLayer, postcodeLayer, postcodeLabelsLayer, poiLayer, marchingAntsLayer, ]); const handleMouseLeave = useCallback(() => { setHoverPosition(null); setHoveredPostcode(null); setPopupInfo(null); onHexagonHoverRef.current(null); }, []); return { layers, popupInfo, hoverPosition, countRange, postcodeCountRange, colorFeatureMeta, handleMouseLeave, hoveredPostcode, primaryTravelIndex, }; }