perfect-postcode/frontend/src/hooks/useDeckLayers.ts
2026-05-09 10:25:27 +01:00

624 lines
20 KiB
TypeScript

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,
} 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 { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
import { PieHexExtension } from '../lib/PieHexExtension';
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;
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<number>(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,
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<string | null>(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 });
// --- 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;
if (c < min) min = c;
if (c > max) max = c;
total += 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;
if (c < min) min = c;
if (c > max) max = c;
total += 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<HexagonData>) => {
if (info.object && 'h3' in info.object) {
onHexagonClickRef.current(info.object.h3);
}
}, []);
const onHexagonHoverRef = useRef(onHexagonHover);
onHexagonHoverRef.current = onHexagonHover;
const handleHexagonHover = useCallback((info: PickingInfo<HexagonData>) => {
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<any>) => {
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<any>) => {
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<HexagonData>({
id: isEnum ? 'h3-hexagons-pie' : '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) {
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<PostcodeProperties>({
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,
];
}
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];
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<PostcodeFeature>({
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]
);
// 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);
return baseLayers;
}, [
usePostcodeView,
zoom,
hexLayer,
postcodeLayer,
postcodeLabelsLayer,
poiLayers,
marchingAntsLayer,
currentLocationLayer,
]);
const handleMouseLeave = useCallback(() => {
setHoverPosition(null);
setHoveredPostcode(null);
clearPopupInfo();
onHexagonHoverRef.current(null);
}, [clearPopupInfo]);
return {
layers,
popupInfo,
clearPopupInfo,
hoverPosition,
countRange,
postcodeCountRange,
colorFeatureMeta,
handleMouseLeave,
hoveredPostcode,
};
}