import { useCallback, useRef, useState, useMemo } from 'react'; import { H3HexagonLayer } from '@deck.gl/geo-layers'; import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers'; import type { PickingInfo } from '@deck.gl/core'; import type { HexagonData, PostcodeFeature, PostcodeProperties, POI, FeatureMeta, Bounds, } from '../types'; import type { SearchedPostcode } from '../components/map/PostcodeSearch'; import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../lib/consts'; import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils'; /** 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) => void; onHexagonHover: (h3: string | null, x?: number, y?: number) => void; theme: 'light' | 'dark'; searchedPostcode?: SearchedPostcode | null; bounds?: Bounds | null; travelTimeEnabled?: boolean; travelTimeDestination?: [number, number] | null; travelTimeColorRange?: [number, number] | null; travelTimeRange?: [number, number] | null; } export interface PopupInfo { x: number; y: number; name: string; category: string; id: string; } export function useDeckLayers({ data, postcodeData, usePostcodeView, pois, viewFeature, colorRange, filterRange, features, selectedHexagonId, hoveredHexagonId, onHexagonClick, onHexagonHover, theme, searchedPostcode, bounds: viewportBounds, travelTimeEnabled = false, travelTimeDestination, travelTimeColorRange, travelTimeRange, }: UseDeckLayersProps) { const [popupInfo, setPopupInfo] = useState(null); const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null); const [selectedPostcode, setSelectedPostcode] = useState(null); const [hoveredPostcode, setHoveredPostcode] = useState(null); const isDark = theme === 'dark'; const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT; // --- Refs for deck.gl accessors (avoid re-creating layers on every change) --- 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 selectedPostcodeRef = useRef(selectedPostcode); selectedPostcodeRef.current = selectedPostcode; const hoveredPostcodeRef = useRef(hoveredPostcode); hoveredPostcodeRef.current = hoveredPostcode; const travelTimeEnabledRef = useRef(travelTimeEnabled); travelTimeEnabledRef.current = travelTimeEnabled; const travelTimeDestinationRef = useRef(travelTimeDestination); travelTimeDestinationRef.current = travelTimeDestination; const travelTimeColorRangeRef = useRef(travelTimeColorRange); travelTimeColorRangeRef.current = travelTimeColorRange; const travelTimeRangeRef = useRef(travelTimeRange); travelTimeRangeRef.current = travelTimeRange; 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) { setSelectedPostcode((prev) => (prev === pc ? null : pc)); onHexagonClickRef.current(pc, true); } }, []); // 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 = `${travelTimeEnabled}|${travelTimeColorRange?.[0]}|${travelTimeColorRange?.[1]}|${travelTimeRange?.[0]}|${travelTimeRange?.[1]}|${travelTimeDestination?.[0]}|${travelTimeDestination?.[1]}`; 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}|${selectedPostcode}|${hoveredPostcode}|${theme}|${ttTrigger}`; // --- Layers --- const hexLayer = useMemo( () => new H3HexagonLayer({ id: 'h3-hexagons', data, getHexagon: (d) => d.h3, getFillColor: (d) => { const dark = isDarkRef.current; // Travel time coloring takes priority if (travelTimeEnabledRef.current && travelTimeDestinationRef.current) { const ttVal = d.travel_time; const ttClr = travelTimeColorRangeRef.current; if (ttVal == null) { return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number]; } const ttFr = travelTimeRangeRef.current; if (ttFr && ((ttVal as number) < ttFr[0] || (ttVal as number) > ttFr[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 === selectedPostcodeRef.current) return [255, 255, 255, 255] as [number, number, number, number]; 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 === selectedPostcodeRef.current) return 3; 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] ); // Check if the searched postcode has data (passes current filters) const searchedPostcodeHasData = useMemo(() => { if (!searchedPostcode) return false; return postcodeData.some((f) => f.properties.postcode === searchedPostcode.postcode); }, [searchedPostcode, postcodeData]); // Highlight layer for searched postcode const searchedPostcodeHighlightLayer = useMemo(() => { if (!searchedPostcode) return null; const hasData = searchedPostcodeHasData; const feature = { type: 'Feature' as const, geometry: searchedPostcode.geometry, properties: {}, }; return new GeoJsonLayer({ id: 'searched-postcode-highlight', data: [feature], getFillColor: hasData ? [29, 228, 195, 40] // teal tint when has data : [255, 180, 0, 30], // orange tint when filtered out getLineColor: hasData ? [29, 228, 195, 255] // solid teal when has data : [255, 180, 0, 200], // orange when filtered out (no matching properties) getLineWidth: hasData ? 4 : 3, lineWidthUnits: 'pixels', stroked: true, filled: true, pickable: false, }); }, [searchedPostcode, searchedPostcodeHasData]); const destinationMarkerLayer = useMemo(() => { if (!travelTimeEnabled || !travelTimeDestination) return null; return new ScatterplotLayer({ id: 'travel-time-destination', data: [{ position: [travelTimeDestination[1], travelTimeDestination[0]] }], getPosition: (d: { position: [number, number] }) => d.position, getRadius: 8, getFillColor: [239, 68, 68, 220], getLineColor: [255, 255, 255, 255], getLineWidth: 2, lineWidthUnits: 'pixels' as const, radiusUnits: 'pixels' as const, stroked: true, pickable: false, }); }, [travelTimeEnabled, travelTimeDestination]); const layers = useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const baseLayers: any[] = usePostcodeView ? [postcodeLayer, postcodeLabelsLayer, poiLayer] : [hexLayer, poiLayer]; if (searchedPostcodeHighlightLayer) baseLayers.push(searchedPostcodeHighlightLayer); if (destinationMarkerLayer) baseLayers.push(destinationMarkerLayer); return baseLayers; }, [ usePostcodeView, hexLayer, postcodeLayer, postcodeLabelsLayer, poiLayer, searchedPostcodeHighlightLayer, destinationMarkerLayer, ]); const handleMouseLeave = useCallback(() => { setHoverPosition(null); setHoveredPostcode(null); setPopupInfo(null); onHexagonHoverRef.current(null); }, []); return { layers, popupInfo, hoverPosition, countRange, postcodeCountRange, colorFeatureMeta, handleMouseLeave, selectedPostcode, hoveredPostcode, }; }