Lint & small changes
This commit is contained in:
parent
0c6d207967
commit
55238f59aa
21 changed files with 2522 additions and 423 deletions
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { latLngToCell } from 'h3-js';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
import type {
|
||||
FeatureMeta,
|
||||
|
|
@ -287,25 +288,68 @@ export function useHexagonSelection({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filterStr, selectedHexagon, fetchHexagonStats, fetchPostcodeStats, rightPaneTab, fetchHexagonProperties, fetchPostcodeProperties]);
|
||||
}, [
|
||||
filterStr,
|
||||
selectedHexagon,
|
||||
fetchHexagonStats,
|
||||
fetchPostcodeStats,
|
||||
rightPaneTab,
|
||||
fetchHexagonProperties,
|
||||
fetchPostcodeProperties,
|
||||
]);
|
||||
|
||||
const handleLocationSearch = useCallback(
|
||||
(postcode: string, geometry: PostcodeGeometry) => {
|
||||
(postcode: string, geometry: PostcodeGeometry, lat?: number, lng?: number) => {
|
||||
trackEvent('Postcode Search');
|
||||
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
|
||||
setSelectedPostcodeGeometry(geometry);
|
||||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setRightPaneTab('area');
|
||||
|
||||
setLoadingAreaStats(true);
|
||||
|
||||
// First try the postcode; if it has no properties, fall back to hexagons
|
||||
fetchPostcodeStats(postcode)
|
||||
.then((stats) => setAreaStats(stats))
|
||||
.then(async (stats) => {
|
||||
if (stats.count > 0) {
|
||||
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
|
||||
setSelectedPostcodeGeometry(geometry);
|
||||
setAreaStats(stats);
|
||||
return;
|
||||
}
|
||||
|
||||
// No properties in this postcode — fall back to hexagons
|
||||
if (lat == null || lng == null) {
|
||||
// No coordinates available, show empty postcode anyway
|
||||
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
|
||||
setSelectedPostcodeGeometry(geometry);
|
||||
setAreaStats(stats);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try progressively coarser H3 resolutions until we find >1 property
|
||||
const resolutions = [9, 8, 7, 6, 5];
|
||||
for (const res of resolutions) {
|
||||
const h3 = latLngToCell(lat, lng, res);
|
||||
const hexStats = await fetchHexagonStats(h3, res);
|
||||
if (hexStats.count > 1) {
|
||||
setSelectedHexagon({ id: h3, type: 'hexagon', resolution: res });
|
||||
setSelectedPostcodeGeometry(null);
|
||||
setAreaStats(hexStats);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Even the coarsest hexagon has ≤1 property — show whatever the finest has
|
||||
const h3 = latLngToCell(lat, lng, 9);
|
||||
const fallbackStats = await fetchHexagonStats(h3, 9);
|
||||
setSelectedHexagon({ id: h3, type: 'hexagon', resolution: 9 });
|
||||
setSelectedPostcodeGeometry(null);
|
||||
setAreaStats(fallbackStats);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
},
|
||||
[resolution, fetchPostcodeStats]
|
||||
[resolution, fetchPostcodeStats, fetchHexagonStats]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -75,6 +75,12 @@ export function useMapData({
|
|||
|
||||
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
|
||||
|
||||
// Determine if the current viewFeature is an enum (for enum_dist param)
|
||||
const viewFeatureIsEnum = useMemo(
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature)?.type === 'enum' : false),
|
||||
[viewFeature, features]
|
||||
);
|
||||
|
||||
const buildFilterParam = useCallback(
|
||||
(): string => buildFilterString(filters, features),
|
||||
[filters, features]
|
||||
|
|
@ -134,6 +140,7 @@ export function useMapData({
|
|||
if (filtersStr) params.set('filters', filtersStr);
|
||||
params.set('fields', fieldsParam);
|
||||
if (dragTravelParam) params.set('travel', dragTravelParam);
|
||||
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
|
||||
|
||||
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||||
.then((res) => res.json())
|
||||
|
|
@ -151,6 +158,7 @@ export function useMapData({
|
|||
if (filtersStr) params.set('filters', filtersStr);
|
||||
params.set('fields', fieldsParam);
|
||||
if (dragTravelParam) params.set('travel', dragTravelParam);
|
||||
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
|
||||
|
||||
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||||
.then((res) => res.json())
|
||||
|
|
@ -168,7 +176,18 @@ export function useMapData({
|
|||
dragAbortRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeFeature, bounds, resolution, filters, features, usePostcodeView, travelParam, buildTravelParam]);
|
||||
}, [
|
||||
activeFeature,
|
||||
bounds,
|
||||
resolution,
|
||||
filters,
|
||||
features,
|
||||
usePostcodeView,
|
||||
travelParam,
|
||||
buildTravelParam,
|
||||
viewFeature,
|
||||
viewFeatureIsEnum,
|
||||
]);
|
||||
|
||||
// Fetch hexagons or postcodes when bounds/filters change
|
||||
useEffect(() => {
|
||||
|
|
@ -196,6 +215,7 @@ export function useMapData({
|
|||
if (travelParam) {
|
||||
params.set('travel', travelParam);
|
||||
}
|
||||
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
|
||||
const res = await fetch(
|
||||
apiUrl('postcodes', params),
|
||||
authHeaders({
|
||||
|
|
@ -226,6 +246,7 @@ export function useMapData({
|
|||
if (travelParam) {
|
||||
params.set('travel', travelParam);
|
||||
}
|
||||
if (viewFeatureIsEnum && viewFeature) params.set('enum_dist', viewFeature);
|
||||
const res = await fetch(
|
||||
apiUrl('hexagons', params),
|
||||
authHeaders({
|
||||
|
|
@ -268,7 +289,16 @@ export function useMapData({
|
|||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelParam]);
|
||||
}, [
|
||||
resolution,
|
||||
bounds,
|
||||
filters,
|
||||
buildFilterParam,
|
||||
viewFeature,
|
||||
viewFeatureIsEnum,
|
||||
usePostcodeView,
|
||||
travelParam,
|
||||
]);
|
||||
|
||||
// Use drag data when it matches the current view feature, otherwise fall back to rawData
|
||||
const data =
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useState, useCallback, useRef, useLayoutEffect } from 'react';
|
||||
|
||||
interface PaneResizeHandlers {
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
|
|
@ -22,9 +22,24 @@ export function usePaneResize(
|
|||
const isVertical = side === 'top' || side === 'bottom';
|
||||
const styleProp = isVertical ? 'height' : 'width';
|
||||
|
||||
const targetCallbackRef = useCallback((el: HTMLElement | null) => {
|
||||
targetRef.current = el;
|
||||
}, []);
|
||||
const targetCallbackRef = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
targetRef.current = el;
|
||||
if (el) {
|
||||
el.style[styleProp] = `${liveSizeRef.current}px`;
|
||||
}
|
||||
},
|
||||
[styleProp]
|
||||
);
|
||||
|
||||
// Keep DOM in sync when React state commits (e.g. on pointerUp).
|
||||
// This ensures the ref-managed element always reflects the latest size
|
||||
// without relying on React-controlled style props.
|
||||
useLayoutEffect(() => {
|
||||
if (targetRef.current) {
|
||||
targetRef.current.style[styleProp] = `${size}px`;
|
||||
}
|
||||
}, [size, styleProp]);
|
||||
|
||||
const computeSize = useCallback(
|
||||
(e: React.PointerEvent): number => {
|
||||
|
|
|
|||
|
|
@ -35,12 +35,22 @@ export function useTranslatedModes() {
|
|||
const { t } = useTranslation();
|
||||
const label = useCallback(
|
||||
(mode: TransportMode): string =>
|
||||
({ car: t('travel.modeCar'), bicycle: t('travel.modeBicycle'), walking: t('travel.modeWalking'), transit: t('travel.modeTransit') })[mode],
|
||||
({
|
||||
car: t('travel.modeCar'),
|
||||
bicycle: t('travel.modeBicycle'),
|
||||
walking: t('travel.modeWalking'),
|
||||
transit: t('travel.modeTransit'),
|
||||
})[mode],
|
||||
[t]
|
||||
);
|
||||
const desc = useCallback(
|
||||
(mode: TransportMode): string =>
|
||||
({ car: t('travel.modeCarDesc'), bicycle: t('travel.modeBicycleDesc'), walking: t('travel.modeWalkingDesc'), transit: t('travel.modeTransitDesc') })[mode],
|
||||
({
|
||||
car: t('travel.modeCarDesc'),
|
||||
bicycle: t('travel.modeBicycleDesc'),
|
||||
walking: t('travel.modeWalkingDesc'),
|
||||
transit: t('travel.modeTransitDesc'),
|
||||
})[mode],
|
||||
[t]
|
||||
);
|
||||
return { label, desc };
|
||||
|
|
|
|||
|
|
@ -8,14 +8,54 @@ const STORAGE_KEY = 'tutorial_completed';
|
|||
export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked = false) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const steps: Step[] = useMemo(() => [
|
||||
{ target: '[data-tutorial="filters"]', title: t('tutorial.step1Title'), content: t('tutorial.step1Content'), placement: 'right' as const, disableBeacon: true },
|
||||
{ target: '[data-tutorial="ai-filters"]', title: t('tutorial.step2Title'), content: t('tutorial.step2Content'), placement: 'right' as const, disableBeacon: true },
|
||||
{ target: '[data-tutorial="map"]', title: t('tutorial.step3Title'), content: t('tutorial.step3Content'), placement: 'bottom' as const, disableBeacon: true },
|
||||
{ target: '[data-tutorial="search"]', title: t('tutorial.step4Title'), content: t('tutorial.step4Content'), placement: 'bottom' as const, disableBeacon: true },
|
||||
{ target: '[data-tutorial="right-pane"]', title: t('tutorial.step5Title'), content: t('tutorial.step5Content'), placement: 'left' as const, disableBeacon: true },
|
||||
{ target: '[data-tutorial="poi-button"]', title: t('tutorial.step6Title'), content: t('tutorial.step6Content'), placement: 'left' as const, disableBeacon: true, styles: { tooltip: { transform: 'translateY(-50px)' } } },
|
||||
], [t]);
|
||||
const steps: Step[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
target: '[data-tutorial="filters"]',
|
||||
title: t('tutorial.step1Title'),
|
||||
content: t('tutorial.step1Content'),
|
||||
placement: 'right' as const,
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="ai-filters"]',
|
||||
title: t('tutorial.step2Title'),
|
||||
content: t('tutorial.step2Content'),
|
||||
placement: 'right' as const,
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="map"]',
|
||||
title: t('tutorial.step3Title'),
|
||||
content: t('tutorial.step3Content'),
|
||||
placement: 'bottom' as const,
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="search"]',
|
||||
title: t('tutorial.step4Title'),
|
||||
content: t('tutorial.step4Content'),
|
||||
placement: 'bottom' as const,
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="right-pane"]',
|
||||
title: t('tutorial.step5Title'),
|
||||
content: t('tutorial.step5Content'),
|
||||
placement: 'left' as const,
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="poi-button"]',
|
||||
title: t('tutorial.step6Title'),
|
||||
content: t('tutorial.step6Content'),
|
||||
placement: 'left' as const,
|
||||
disableBeacon: true,
|
||||
styles: { tooltip: { transform: 'translateY(-50px)' } },
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const [run, setRun] = useState(() => {
|
||||
if (isMobile) return false;
|
||||
|
|
@ -50,6 +90,6 @@ export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked
|
|||
handleCallback,
|
||||
resetTutorial,
|
||||
}),
|
||||
[shouldRun, handleCallback, resetTutorial]
|
||||
[steps, shouldRun, handleCallback, resetTutorial]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue