Add filter info

This commit is contained in:
Andras Schmelczer 2026-01-31 20:26:14 +00:00
parent 01ec17ff04
commit f7d586a1e9
5 changed files with 682 additions and 234 deletions

View file

@ -1,4 +1,4 @@
import { useCallback, useRef, useEffect, useState, useMemo } from 'react';
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';
@ -17,6 +17,7 @@ interface MapProps {
features: FeatureMeta[];
selectedHexagonId: string | null;
onHexagonClick: (h3: string) => void;
initialViewState?: ViewState;
}
// Twemoji CDN base URL
@ -118,9 +119,6 @@ interface Dimensions {
height: number;
}
// First label layer in the Carto Positron style — hexagons render below this
const LABEL_LAYER_ID = 'waterway_label';
function DeckOverlay({
layers,
getTooltip,
@ -130,7 +128,13 @@ function DeckOverlay({
getTooltip: any;
}) {
const overlay = useControl(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers, getTooltip });
const prevLayersRef = useRef(layers);
const prevTooltipRef = useRef(getTooltip);
if (layers !== prevLayersRef.current || getTooltip !== prevTooltipRef.current) {
prevLayersRef.current = layers;
prevTooltipRef.current = getTooltip;
overlay.setProps({ layers, getTooltip });
}
return null;
}
@ -143,7 +147,81 @@ function countToColor(t: number): [number, number, number] {
return [r, g, b];
}
export default function Map({
function PostcodeSearch({
onFlyTo,
}: {
onFlyTo: (lat: number, lng: number, zoom: number) => void;
}) {
const [query, setQuery] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = query.trim();
if (!trimmed) return;
setError(null);
setLoading(true);
try {
const res = await fetch(
`https://api.postcodes.io/postcodes/${encodeURIComponent(trimmed)}`
);
if (!res.ok) {
setError('Postcode not found');
return;
}
const json = await res.json();
if (json.status === 200 && json.result) {
onFlyTo(json.result.latitude, json.result.longitude, 14);
setQuery('');
} else {
setError('Postcode not found');
}
} catch {
setError('Lookup failed');
} finally {
setLoading(false);
}
},
[query, onFlyTo]
);
return (
<form
onSubmit={handleSubmit}
className="absolute top-3 left-3 z-10 flex flex-col gap-1"
>
<div className="flex shadow-lg rounded overflow-hidden">
<input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setError(null);
}}
placeholder="Search postcode..."
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white"
/>
<button
type="submit"
disabled={loading}
className="px-3 py-2 bg-blue-500 text-white text-sm hover:bg-blue-600 disabled:opacity-50"
>
{loading ? '...' : 'Go'}
</button>
</div>
{error && (
<span className="text-xs text-red-600 bg-white/90 rounded px-2 py-0.5 shadow">
{error}
</span>
)}
</form>
);
}
export default memo(function Map({
data,
pois,
onViewChange,
@ -152,9 +230,10 @@ export default function Map({
features,
selectedHexagonId,
onHexagonClick,
initialViewState,
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(INITIAL_VIEW);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
// Track container dimensions with ResizeObserver
@ -177,16 +256,29 @@ export default function Map({
useEffect(() => {
if (dimensions.width === 0 || dimensions.height === 0) return;
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
const raw = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
const resolution = zoomToResolution(viewState.zoom);
onViewChange({ resolution, bounds, zoom: viewState.zoom });
// Quantize bounds to 0.01° to reduce state churn and improve backend cache hits
const QUANT = 0.01;
const bounds: Bounds = {
south: Math.floor(raw.south / QUANT) * QUANT,
west: Math.floor(raw.west / QUANT) * QUANT,
north: Math.ceil(raw.north / QUANT) * QUANT,
east: Math.ceil(raw.east / QUANT) * QUANT,
};
onViewChange({ resolution, bounds, zoom: viewState.zoom, latitude: viewState.latitude, longitude: viewState.longitude });
}, [viewState, dimensions, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => {
setViewState(evt.viewState);
}, []);
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
}, []);
// Make place labels more legible over the colored hexagons
const handleMapLoad = useCallback(
(evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
@ -197,6 +289,8 @@ export default function Map({
map.setPaintProperty(layer.id, 'text-halo-width', 2);
map.setPaintProperty(layer.id, 'text-color', '#222');
}
map.setLayoutProperty('building', 'visibility', 'none');
map.setLayoutProperty('building-top', 'visibility', 'none');
},
[]
);
@ -236,63 +330,107 @@ export default function Map({
return { min, max };
}, [data]);
// Determine color mode
const colorFeatureMeta = activeFeature
? features.find((f) => f.name === activeFeature) || null
: null;
// Memoize feature lookup to avoid new reference each render
const colorFeatureMeta = useMemo(
() => (activeFeature ? features.find((f) => f.name === activeFeature) || null : null),
[activeFeature, features]
);
// Use refs for values that change during drag so layers aren't recreated
const activeFeatureRef = useRef(activeFeature);
activeFeatureRef.current = activeFeature;
const dragValueRef = useRef(dragValue);
dragValueRef.current = dragValue;
const colorFeatureMetaRef = useRef(colorFeatureMeta);
colorFeatureMetaRef.current = colorFeatureMeta;
const countRangeRef = useRef(countRange);
countRangeRef.current = countRange;
const selectedHexagonIdRef = useRef(selectedHexagonId);
selectedHexagonIdRef.current = selectedHexagonId;
// Stable click handler using ref
const onHexagonClickRef = useRef(onHexagonClick);
onHexagonClickRef.current = onHexagonClick;
const handleHexagonClick = useCallback(
(info: PickingInfo<HexagonData>) => {
if (info.object && 'h3' in info.object) {
onHexagonClick(info.object.h3);
onHexagonClickRef.current(info.object.h3);
}
},
[onHexagonClick]
[]
);
const layers = useMemo(
() => [
// Stable hover handler using ref
const handlePoiHoverRef = useRef(handlePoiHover);
handlePoiHoverRef.current = handlePoiHover;
const stablePoiHover = useCallback((info: PickingInfo<POI>) => {
handlePoiHoverRef.current(info);
}, []);
// Derive a trigger value from color-affecting state — avoids useEffect+setState double-render
const colorTrigger = `${activeFeature}|${dragValue?.[0]}|${dragValue?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`;
// Hexagon layer — only recreated when data or color trigger changes
const hexLayer = useMemo(
() =>
new H3HexagonLayer<HexagonData>({
id: 'h3-hexagons',
data,
getHexagon: (d) => d.h3,
getFillColor: (d) => {
if (activeFeature && dragValue && colorFeatureMeta) {
// Drag mode: color by feature value using gradient
const val = d[`min_${activeFeature}`];
if (val == null) return [128, 128, 128] as [number, number, number];
const range = dragValue[1] - dragValue[0];
if (range === 0) return GRADIENT[0].color;
const t = ((val as number) - dragValue[0]) / range;
return normalizedToColor(Math.max(0, Math.min(1, t)));
const af = activeFeatureRef.current;
const dv = dragValueRef.current;
const cfm = colorFeatureMetaRef.current;
if (af && dv && cfm) {
const val = d[`min_${af}`];
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
const min = dv[0];
const max = dv[1];
const minVal = d[`min_${af}`] as number;
const maxVal = d[`max_${af}`] as number;
// Gray out hexagons outside drag range
if (maxVal < min || minVal > max) {
return [180, 180, 180, 60] as [number, number, number, number];
}
const range = max - min;
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
const t = ((val as number) - min) / range;
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
return [...rgb, 200] as [number, number, number, number];
}
// Normal mode: color by count using blue scale
const cr = countRangeRef.current;
const c = d.count as number;
const t = (c - countRange.min) / (countRange.max - countRange.min);
return countToColor(Math.max(0, Math.min(1, t)));
const t = (c - cr.min) / (cr.max - cr.min);
return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [number, number, number, number];
},
getLineColor: (d) =>
(d.h3 === selectedHexagonId ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [
(d.h3 === selectedHexagonIdRef.current ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [
number,
number,
number,
number,
],
getLineWidth: (d) => (d.h3 === selectedHexagonId ? 2 : 0),
getLineWidth: (d) => (d.h3 === selectedHexagonIdRef.current ? 2 : 0),
lineWidthUnits: 'pixels',
updateTriggers: {
getFillColor: [activeFeature, dragValue, countRange, colorFeatureMeta],
getLineColor: [selectedHexagonId],
getLineWidth: [selectedHexagonId],
getFillColor: [colorTrigger],
getLineColor: [colorTrigger],
getLineWidth: [colorTrigger],
},
extruded: false,
pickable: true,
opacity: 0.5,
opacity: 1,
highPrecision: true,
onClick: handleHexagonClick,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: LABEL_LAYER_ID,
beforeId: "waterway_label",
}),
[data, colorTrigger, handleHexagonClick]
);
// POI layer — independent, only recreated when POI data changes
const poiLayer = useMemo(
() =>
new IconLayer<POI>({
id: 'poi-icons',
data: pois,
@ -306,22 +444,17 @@ export default function Map({
sizeMinPixels: 20,
sizeMaxPixels: 40,
pickable: true,
onHover: handlePoiHover,
onHover: stablePoiHover,
}),
],
[
data,
pois,
handlePoiHover,
handleHexagonClick,
activeFeature,
dragValue,
countRange,
colorFeatureMeta,
selectedHexagonId,
]
[pois, stablePoiHover]
);
const layers = useMemo(() => [hexLayer, poiLayer], [hexLayer, poiLayer]);
// Tooltip uses refs to avoid being a layer dependency
const featuresRef = useRef(features);
featuresRef.current = features;
const getTooltip = useCallback(
({ object }: { object?: HexagonData }) => {
if (!object || !('h3' in object)) return null;
@ -330,7 +463,7 @@ export default function Map({
const lines: string[] = [];
lines.push(`<strong>${(hex.count as number).toLocaleString()} properties</strong>`);
for (const f of features) {
for (const f of featuresRef.current) {
const minVal = hex[`min_${f.name}`];
const maxVal = hex[`max_${f.name}`];
if (minVal != null && maxVal != null) {
@ -342,7 +475,7 @@ export default function Map({
typeof maxVal === 'number'
? maxVal.toLocaleString(undefined, { maximumFractionDigits: 1 })
: String(maxVal);
const highlight = f.name === activeFeature ? 'font-weight: bold;' : '';
const highlight = f.name === activeFeatureRef.current ? 'font-weight: bold;' : '';
lines.push(`<div style="${highlight}">${f.label}: ${minStr} - ${maxStr}</div>`);
}
}
@ -356,7 +489,7 @@ export default function Map({
},
};
},
[features, activeFeature]
[]
);
return (
@ -371,6 +504,7 @@ export default function Map({
>
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
</MapGL>
<PostcodeSearch onFlyTo={handleFlyTo} />
{popupInfo && (
<div
className="absolute pointer-events-none bg-white rounded shadow-lg p-2 text-sm"
@ -387,4 +521,4 @@ export default function Map({
)}
</div>
);
}
});