Refactor and other improvements

This commit is contained in:
Andras Schmelczer 2026-02-08 18:25:58 +00:00
parent 04a78e7bfe
commit 6c90cf3c0f
47 changed files with 2705 additions and 1568 deletions

View file

@ -2,14 +2,10 @@ import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo } from '@deck.gl/core';
import 'maplibre-gl/dist/maplibre-gl.css';
import type {
HexagonData,
PostcodeFeature,
PostcodeProperties,
ViewState,
ViewChangeParams,
POI,
@ -17,30 +13,13 @@ import type {
Bounds,
} from '../../types';
import {
GRADIENT,
normalizedToColor,
countToColor,
zoomToResolution,
getBoundsFromViewState,
emojiToTwemojiUrl,
getMapStyle,
DENSITY_GRADIENT,
DENSITY_GRADIENT_DARK,
} from '../../lib/map-utils';
import { zoomToResolution, getBoundsFromViewState, getMapStyle } from '../../lib/map-utils';
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../../lib/consts';
import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch';
import MapLegend from './MapLegend';
import HoverCard from './HoverCard';
import type { FeatureFilters } from '../../types';
/** 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<string, string> = { n: 'node', w: 'way', r: 'relation' };
return `https://www.openstreetmap.org/${typeMap[match[1]]}/${match[2]}`;
}
import { useDeckLayers, osmIdToUrl } from '../../hooks/useDeckLayers';
interface MapProps {
data: HexagonData[];
@ -123,7 +102,6 @@ export default memo(function Map({
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
const container = containerRef.current;
@ -165,9 +143,6 @@ export default memo(function Map({
setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
}, []);
const themeRef = useRef(theme);
themeRef.current = theme;
const handleMapLoad = useCallback(
(_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
// Road opacity is set in getMapStyle
@ -177,425 +152,31 @@ export default memo(function Map({
const mapStyle = useMemo(() => getMapStyle(theme), [theme]);
const [popupInfo, setPopupInfo] = useState<{
x: number;
y: number;
name: string;
category: string;
id: string;
} | null>(null);
const handlePoiHover = useCallback((info: PickingInfo<POI>) => {
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 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 colorFeatureMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features]
);
const viewFeatureRef = useRef(viewFeature);
viewFeatureRef.current = viewFeature;
const colorRangeRef = useRef(colorRange);
colorRangeRef.current = colorRange;
const filterRangeRef = useRef(filterRange);
filterRangeRef.current = filterRange;
const colorFeatureMetaRef = useRef(colorFeatureMeta);
colorFeatureMetaRef.current = colorFeatureMeta;
const countRangeRef = useRef(countRange);
countRangeRef.current = countRange;
const selectedHexagonIdRef = useRef(selectedHexagonId);
selectedHexagonIdRef.current = selectedHexagonId;
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
hoveredHexagonIdRef.current = hoveredHexagonId;
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);
}
}, []);
const handlePoiHoverRef = useRef(handlePoiHover);
handlePoiHoverRef.current = handlePoiHover;
const stablePoiHover = useCallback((info: PickingInfo<POI>) => {
handlePoiHoverRef.current(info);
}, []);
// Compute count range for postcodes (similar to hexagons)
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;
// Track selected/hovered postcode for styling
const [selectedPostcode, setSelectedPostcode] = useState<string | null>(null);
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
const selectedPostcodeRef = useRef(selectedPostcode);
selectedPostcodeRef.current = selectedPostcode;
const hoveredPostcodeRef = useRef(hoveredPostcode);
hoveredPostcodeRef.current = hoveredPostcode;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlePostcodeClick = useCallback((info: PickingInfo<any>) => {
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<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);
}
}, []);
const isDark = theme === 'dark';
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const densityGradientRef = useRef(densityGradient);
densityGradientRef.current = densityGradient;
const isDarkRef = useRef(isDark);
isDarkRef.current = isDark;
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}`;
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}|${theme}`;
const hexLayer = useMemo(
() =>
new H3HexagonLayer<HexagonData>({
id: 'h3-hexagons',
data,
getHexagon: (d) => d.h3,
getFillColor: (d) => {
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}`];
if (val == null)
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
number,
number,
number,
number,
];
if (fr) {
const minVal = d[`min_${vf}`] as number;
const maxVal = d[`max_${vf}`] as number;
if (maxVal < fr[0] || minVal > fr[1]) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [
number,
number,
number,
number,
];
}
}
const range = clr[1] - clr[0];
if (range === 0) return [...GRADIENT[0].color, 255] as [number, number, number, number];
const t = ((val as number) - clr[0]) / range;
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
return [...rgb, 255] as [number, number, number, number];
}
const cr = countRangeRef.current;
const c = d.count as number;
const t = (c - cr.min) / (cr.max - cr.min);
return [
...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current),
255,
] as [number, number, number, number];
},
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<PostcodeProperties>({
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}`];
if (val == null)
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
number,
number,
number,
number,
];
if (fr) {
const minVal = d[`min_${vf}`] as number;
const maxVal = d[`max_${vf}`] as number;
if (maxVal < fr[0] || minVal > fr[1]) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [
number,
number,
number,
number,
];
}
}
const range = clr[1] - clr[0];
if (range === 0) return [...GRADIENT[0].color, 180] as [number, number, number, number];
const t = ((val as number) - clr[0]) / range;
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
return [...rgb, 180] as [number, number, number, number];
}
const cr = postcodeCountRangeRef.current;
const c = d.count;
const t = (c - cr.min) / (cr.max - cr.min);
return [
...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current),
180,
] as [number, number, number, number];
},
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<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]
);
const poiLayer = useMemo(
() =>
new IconLayer<POI>({
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 layers = useMemo(() => {
const baseLayers = usePostcodeView
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
: [hexLayer, poiLayer];
if (searchedPostcodeHighlightLayer) {
return [...baseLayers, searchedPostcodeHighlightLayer];
}
return baseLayers;
}, [
const {
layers,
popupInfo,
hoverPosition,
countRange,
postcodeCountRange,
colorFeatureMeta,
handleMouseLeave,
} = useDeckLayers({
data,
postcodeData,
usePostcodeView,
hexLayer,
postcodeLayer,
postcodeLabelsLayer,
poiLayer,
searchedPostcodeHighlightLayer,
]);
const handleMouseLeave = useCallback(() => {
setHoverPosition(null);
setHoveredPostcode(null);
setPopupInfo(null);
onHexagonHoverRef.current(null);
}, []);
pois,
viewFeature,
colorRange,
filterRange,
features,
selectedHexagonId,
hoveredHexagonId,
onHexagonClick,
onHexagonHover,
theme,
searchedPostcode,
bounds: viewportBounds,
});
return (
<div className="flex-1 h-full relative" ref={containerRef} onMouseLeave={handleMouseLeave}>