Lint & small changes

This commit is contained in:
Andras Schmelczer 2026-04-04 22:59:07 +01:00
parent 0c6d207967
commit 55238f59aa
21 changed files with 2522 additions and 423 deletions

View file

@ -1,6 +1,6 @@
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 { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer, PolygonLayer } from '@deck.gl/layers';
import { cellToBoundary } from 'h3-js';
import Supercluster from 'supercluster';
import type { PickingInfo } from '@deck.gl/core';
@ -26,6 +26,7 @@ import {
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
import type { TravelTimeEntry } from './useTravelTime';
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
import { PieHexExtension } from '../lib/PieHexExtension';
interface UseDeckLayersProps {
data: HexagonData[];
@ -66,6 +67,17 @@ interface ClusterPoint {
clusterId: number;
}
/** 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;
}
export function useDeckLayers({
data,
postcodeData,
@ -294,215 +306,331 @@ export function useDeckLayers({
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${hoveredPostcode}|${theme}|${ttTrigger}`;
// --- Layers ---
const hexLayer = useMemo(
() =>
new H3HexagonLayer<HexagonData>({
// For enum features, we bypass H3HexagonLayer and use PolygonLayer directly.
// H3HexagonLayer has double CompositeLayer nesting (H3 → PolygonLayer → SolidPolygonLayer)
// which prevents custom binary attributes from reaching the fill sublayer.
// PolygonLayer has only one level of nesting, so _subLayerProps.fill works reliably.
const hexLayer = useMemo(() => {
const isEnum = enumCountRef.current > 0;
if (isEnum) {
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
const n = data.length;
// Pre-compute hex boundaries and binary attribute buffers
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const polyData: any[] = new Array(n);
const centers = new Float32Array(n * 2);
const r0 = new Float32Array(n * 4);
const r1 = new Float32Array(n * 4);
const r2 = new Float32Array(n * 2);
for (let i = 0; i < n; i++) {
const d = data[i];
polyData[i] = { ...d, polygon: cellToBoundary(d.h3, true) };
centers[i * 2] = d.lon as number;
centers[i * 2 + 1] = d.lat as number;
const r = distToRatios(d[distKey]);
r0[i * 4] = r[0];
r0[i * 4 + 1] = r[1];
r0[i * 4 + 2] = r[2];
r0[i * 4 + 3] = r[3];
r1[i * 4] = r[4];
r1[i * 4 + 1] = r[5];
r1[i * 4 + 2] = r[6];
r1[i * 4 + 3] = r[7];
r2[i * 2] = r[8];
r2[i * 2 + 1] = r[9];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new (PolygonLayer as any)({
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,
];
}
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
);
}
// 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) => {
data: polyData,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getPolygon: (d: any) => d.polygon,
getFillColor: [200, 200, 200],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getLineColor: (d: any) => {
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];
return [29, 228, 195, 200];
return [0, 0, 0, 0];
},
getLineWidth: (d) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getLineWidth: (d: any) => {
if (d.h3 === hoveredHexagonIdRef.current) return 2;
return 0;
},
lineWidthUnits: 'pixels',
updateTriggers: {
getFillColor: [colorTrigger],
getLineColor: [colorTrigger],
getLineWidth: [colorTrigger],
},
extensions: [new PieHexExtension()],
_subLayerProps: {
fill: {
instancePieCenter: { value: centers, size: 2 },
instanceRatios0: { value: r0, size: 4 },
instanceRatios1: { value: r1, size: 4 },
instanceRatios2: { value: r2, size: 2 },
},
},
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<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;
// Non-enum: use H3HexagonLayer as normal
return new H3HexagonLayer<HexagonData>({
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 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
);
}
// 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
);
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
);
}
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
);
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
);
}
}
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(() => {
const isEnum = enumCountRef.current > 0;
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
// Same binary buffer approach as hexagons, routed via _subLayerProps.
// GeoJsonLayer → 'polygons-fill' (PolygonLayer) → 'fill' (SolidPolygonLayer)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let pieProps: any = {};
if (isEnum) {
const n = postcodeData.length;
const centers = new Float32Array(n * 2);
const r0 = new Float32Array(n * 4);
const r1 = new Float32Array(n * 4);
const r2 = new Float32Array(n * 2);
for (let i = 0; i < n; i++) {
const centroid = postcodeData[i].properties.centroid as [number, number];
centers[i * 2] = centroid[0];
centers[i * 2 + 1] = centroid[1];
const r = distToRatios(postcodeData[i].properties[distKey]);
r0[i * 4] = r[0];
r0[i * 4 + 1] = r[1];
r0[i * 4 + 2] = r[2];
r0[i * 4 + 3] = r[3];
r1[i * 4] = r[4];
r1[i * 4 + 1] = r[5];
r1[i * 4 + 2] = r[6];
r1[i * 4 + 3] = r[7];
r2[i * 2] = r[8];
r2[i * 2 + 1] = r[9];
}
const fillAttrs = {
instancePieCenter: { value: centers, size: 2 },
instanceRatios0: { value: r0, size: 4 },
instanceRatios1: { value: r1, size: 4 },
instanceRatios2: { value: r2, size: 2 },
};
pieProps = {
extensions: [new PieHexExtension()],
_subLayerProps: {
'polygons-fill': {
_subLayerProps: {
fill: fillAttrs,
},
},
},
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]
);
};
}
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
);
}
// 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
);
}
}
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,
beforeId: 'landuse_park',
...pieProps,
});
}, [postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]);
const postcodeLabelsLayer = useMemo(
() =>