import { useCallback, useRef, useState, useMemo, useEffect } from 'react'; import { H3HexagonLayer } from '@deck.gl/geo-layers'; import { GeoJsonLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers'; import { cellToBoundary } from 'h3-js'; import type { PickingInfo } from '@deck.gl/core'; import type { HexagonData, PostcodeFeature, PostcodeProperties, PostcodeGeometry, POI, FeatureMeta, Bounds, ActualListing, } from '../types'; import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK, getEnumPaletteForFeature, getFeatureGradient, } from '../lib/consts'; import { getFeatureFillColor } from '../lib/map-utils'; import type { TravelTimeEntry } from './useTravelTime'; import { usePoiLayers } from './usePoiLayers'; import { useListingLayers } from './useListingLayers'; import { MarchingAntsExtension } from '../lib/MarchingAntsExtension'; import { PieHexExtension } from '../lib/PieHexExtension'; interface UseDeckLayersProps { data: HexagonData[]; postcodeData: PostcodeFeature[]; usePostcodeView: boolean; zoom: number; pois: POI[]; actualListings: ActualListing[]; 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; currentLocation?: { lat: number; lng: number } | null; bounds?: Bounds | null; travelTimeEntries?: TravelTimeEntry[]; } /** Normalize a distribution count array to [0..1] ratios, padded to 10 values. */ function distToRatios(dist: unknown): number[] { if (!Array.isArray(dist) || dist.length === 0) return [1, 0, 0, 0, 0, 0, 0, 0, 0, 0]; let total = 0; for (let i = 0; i < dist.length; i++) total += (dist[i] as number) || 0; if (total === 0) return [1, 0, 0, 0, 0, 0, 0, 0, 0, 0]; const r = new Array(10).fill(0); for (let i = 0; i < Math.min(dist.length, 10); i++) r[i] = ((dist[i] as number) || 0) / total; return r; } function requireEnumPalette( palette: [number, number, number][] | null ): [number, number, number][] { if (!palette) { throw new Error('Enum layer requested without an enum color palette'); } return palette; } export function useDeckLayers({ data, postcodeData, usePostcodeView, zoom, pois, actualListings, viewFeature, colorRange, filterRange, features, selectedHexagonId, hoveredHexagonId, onHexagonClick, onHexagonHover, theme, selectedPostcodeGeometry, currentLocation, bounds: viewportBounds, travelTimeEntries = [], }: UseDeckLayersProps) { 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; const { poiLayers, popupInfo, clearPopupInfo } = usePoiLayers({ pois, zoom, isDark }); const { listingLayers, listingPopup, clearListingPopup } = useListingLayers({ listings: actualListings, zoom, isDark, hexagonData: data, postcodeData, resolution: usePostcodeView ? 0 : Math.round(zoom), usePostcodeView, }); // --- 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 featureGradientRef = useRef(getFeatureGradient(viewFeature)); featureGradientRef.current = getFeatureGradient(viewFeature); 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; const enumPalette = viewFeature && colorFeatureMeta?.type === 'enum' && colorFeatureMeta.values ? getEnumPaletteForFeature(viewFeature, colorFeatureMeta.values) : null; const enumPaletteRef = useRef(enumPalette); enumPaletteRef.current = enumPalette; const countRange = useMemo(() => { if (data.length === 0) return { min: 0, max: 1, total: 0 }; let min = Infinity; let max = -Infinity; let total = 0; 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; total += c; if (c <= 0) continue; if (c < min) min = c; if (c > max) max = c; } if (min === Infinity) return { min: 0, max: 1, total: 0 }; if (min === max) return { min, max: min + 1, total }; return { min, max, total }; }, [data, viewportBounds]); const countRangeRef = useRef(countRange); countRangeRef.current = countRange; const postcodeCountRange = useMemo(() => { if (postcodeData.length === 0) return { min: 0, max: 1, total: 0 }; let min = Infinity; let max = -Infinity; let total = 0; 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; total += c; if (c <= 0) continue; if (c < min) min = c; if (c > max) max = c; } if (min === Infinity) return { min: 0, max: 1, total: 0 }; if (min === max) return { min, max: min + 1, total }; return { min, max, total }; }, [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); } }, []); // 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 --- // PieHexExtension uses the canonical deck.gl v9 pattern: defaultProps with // type:'accessor' + stepMode:'dynamic'. LayerExtension.getSubLayerProps() // wraps accessors via getSubLayerAccessor() which unwraps __source.object, // letting accessor functions work through CompositeLayer sublayer chains. const hexLayer = useMemo(() => { const isEnum = enumCountRef.current > 0; const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : ''; // eslint-disable-next-line @typescript-eslint/no-explicit-any const pieProps: any = isEnum ? { extensions: [new PieHexExtension(requireEnumPalette(enumPaletteRef.current))], getCenter: (d: HexagonData) => [d.lon, d.lat], getRatios0: (d: HexagonData) => { const r = distToRatios(d[distKey]); return [r[0], r[1], r[2], r[3]]; }, getRatios1: (d: HexagonData) => { const r = distToRatios(d[distKey]); return [r[4], r[5], r[6], r[7]]; }, getRatios2: (d: HexagonData) => { const r = distToRatios(d[distKey]); return [r[8], r[9]]; }, updateTriggers: { getCenter: [colorTrigger, data], getRatios0: [colorTrigger, data], getRatios1: [colorTrigger, data], getRatios2: [colorTrigger, data], }, } : {}; return new H3HexagonLayer({ id: isEnum ? 'h3-hexagons-pie' : 'h3-hexagons', data, getHexagon: (d) => d.h3, getFillColor: (d) => { if ((d.count as number) <= 0) { return [0, 0, 0, 0] as [number, number, number, number]; } const dark = isDarkRef.current; const vf = viewFeatureRef.current; const clr = colorRangeRef.current; const fr = filterRangeRef.current; const cfm = colorFeatureMetaRef.current; if (vf && clr) { 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, ]; } const ttMin = (d[`min_${vf}`] as number) ?? ttVal; const ttMax = (d[`max_${vf}`] as number) ?? ttVal; return getFeatureFillColor( ttVal as number, ttMin as number, ttMax as number, clr, fr, 0, densityGradientRef.current, dark, 255, 0, undefined, featureGradientRef.current ); } 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, enumPaletteRef.current, featureGradientRef.current ); } } 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, data], getLineColor: [colorTrigger], getLineWidth: [colorTrigger], ...(pieProps.updateTriggers || {}), }, extruded: false, pickable: true, opacity: 1, highPrecision: true, onClick: handleHexagonClick, onHover: handleHexagonHover, beforeId: 'landuse_park', ...pieProps, }); }, [data, colorTrigger, handleHexagonClick, handleHexagonHover]); const postcodeLayer = useMemo(() => { return new GeoJsonLayer({ id: 'postcode-polygons', data: postcodeData as PostcodeFeature[], getFillColor: (f) => { const d = f.properties; if (d.count <= 0) { return [0, 0, 0, 0] as [number, number, number, number]; } 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, ]; } const ttMin = (d[`min_${vf}`] as number) ?? ttVal; const ttMax = (d[`max_${vf}`] as number) ?? ttVal; return getFeatureFillColor( ttVal as number, ttMin as number, ttMax as number, clr, fr, 0, densityGradientRef.current, dark, 180, 0, undefined, featureGradientRef.current ); } // Regular feature (for enum, the extension overrides this color) 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, enumPaletteRef.current, featureGradientRef.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]; if (f.properties.count <= 0) return [0, 0, 0, 0] 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; if (f.properties.count <= 0) return 0; 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 labeledPostcodeData = useMemo( () => postcodeData.filter((feature) => feature.properties.count > 0), [postcodeData] ); const postcodeLabelsLayer = useMemo( () => new TextLayer({ id: 'postcode-labels', data: labeledPostcodeData, 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, }), [labeledPostcodeData, theme] ); // 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 currentLocationLayer = useMemo(() => { if (!currentLocation) return null; return new ScatterplotLayer<{ lat: number; lng: number; kind: 'ring' | 'dot' }>({ id: 'current-location-dot', data: [ { ...currentLocation, kind: 'ring' }, { ...currentLocation, kind: 'dot' }, ], getPosition: (d) => [d.lng, d.lat], getRadius: (d) => (d.kind === 'ring' ? 16 : 5), radiusUnits: 'pixels', getFillColor: (d) => (d.kind === 'ring' ? [20, 184, 166, 45] : [220, 38, 38, 255]), getLineColor: (d) => (d.kind === 'ring' ? [20, 184, 166, 240] : [255, 255, 255, 240]), getLineWidth: 2, lineWidthUnits: 'pixels', stroked: true, pickable: false, }); }, [currentLocation]); const layers = useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const baseLayers: any[] = []; if (usePostcodeView) { baseLayers.push(postcodeLayer); if (zoom >= 16) baseLayers.push(postcodeLabelsLayer); baseLayers.push(...poiLayers); } else { baseLayers.push(hexLayer); baseLayers.push(...poiLayers); } if (marchingAntsLayer) baseLayers.push(marchingAntsLayer); if (currentLocationLayer) baseLayers.push(currentLocationLayer); if (listingLayers.length > 0) baseLayers.push(...listingLayers); return baseLayers; }, [ usePostcodeView, zoom, hexLayer, postcodeLayer, postcodeLabelsLayer, poiLayers, marchingAntsLayer, currentLocationLayer, listingLayers, ]); const handleMouseLeave = useCallback(() => { setHoverPosition(null); setHoveredPostcode(null); clearPopupInfo(); clearListingPopup(); onHexagonHoverRef.current(null); }, [clearPopupInfo, clearListingPopup]); return { layers, popupInfo, clearPopupInfo, listingPopup, clearListingPopup, hoverPosition, countRange, postcodeCountRange, colorFeatureMeta, handleMouseLeave, hoveredPostcode, }; }