Update UI

This commit is contained in:
Andras Schmelczer 2026-02-01 11:07:58 +00:00
parent 2ac37ece97
commit 5f311233e4
10 changed files with 663 additions and 408 deletions

View file

@ -3,26 +3,24 @@ 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 { IconLayer, PolygonLayer } from '@deck.gl/layers';
import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo } from '@deck.gl/core';
import 'maplibre-gl/dist/maplibre-gl.css';
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta, PostcodeData } from '../types';
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta } from '../types';
interface MapProps {
data: HexagonData[];
pois: POI[];
onViewChange: (params: ViewChangeParams) => void;
viewFeature: string | null;
viewRange: [number, number] | null;
colorRange: [number, number] | null;
filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null;
onCancelPin: () => void;
features: FeatureMeta[];
selectedHexagonId: string | null;
onHexagonClick: (h3: string) => void;
initialViewState?: ViewState;
postcodeData: PostcodeData[];
selectedPostcode: string | null;
onPostcodeClick: (postcode: string) => void;
}
// Twemoji CDN base URL
@ -44,7 +42,7 @@ const INITIAL_VIEW: ViewState = {
pitch: 0,
};
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json';
// Gradient stops for normalized [0,1] values
const GRADIENT: { t: number; color: [number, number, number] }[] = [
@ -78,7 +76,8 @@ function zoomToResolution(zoom: number): number {
if (zoom < 9.5) return 8;
if (zoom < 11) return 9;
if (zoom < 13) return 10;
return 11;
if (zoom < 15) return 11;
return 12;
}
function getBoundsFromViewState(viewState: ViewState, width: number, height: number): Bounds {
@ -195,10 +194,7 @@ function PostcodeSearch({
);
return (
<form
onSubmit={handleSubmit}
className="absolute top-3 left-3 z-10 flex flex-col gap-1"
>
<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"
@ -219,9 +215,7 @@ function PostcodeSearch({
</button>
</div>
{error && (
<span className="text-xs text-red-600 bg-white/90 rounded px-2 py-0.5 shadow">
{error}
</span>
<span className="text-xs text-red-600 bg-white/90 rounded px-2 py-0.5 shadow">{error}</span>
)}
</form>
);
@ -255,7 +249,13 @@ function MapLegend({
className="text-warm-400 hover:text-warm-700 ml-2"
title="Clear color view"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@ -281,16 +281,14 @@ export default memo(function Map({
pois,
onViewChange,
viewFeature,
viewRange,
colorRange,
filterRange,
viewSource,
onCancelPin,
features,
selectedHexagonId,
onHexagonClick,
initialViewState,
postcodeData,
selectedPostcode,
onPostcodeClick,
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
@ -328,7 +326,13 @@ export default memo(function Map({
east: Math.ceil(raw.east / QUANT) * QUANT,
};
onViewChange({ resolution, bounds, zoom: viewState.zoom, latitude: viewState.latitude, longitude: viewState.longitude });
onViewChange({
resolution,
bounds,
zoom: viewState.zoom,
latitude: viewState.latitude,
longitude: viewState.longitude,
});
}, [viewState, dimensions, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => {
@ -349,6 +353,12 @@ export default memo(function Map({
map.setPaintProperty(layer.id, 'text-halo-width', 2);
map.setPaintProperty(layer.id, 'text-color', '#222');
}
// Make water more prominent
for (const layer of map.getStyle().layers || []) {
if (layer.id === 'water' || layer.id.startsWith('water')) {
map.setPaintProperty(layer.id, 'fill-color', '#6baed6');
}
}
map.setLayoutProperty('building', 'visibility', 'none');
map.setLayoutProperty('building-top', 'visibility', 'none');
},
@ -399,8 +409,10 @@ export default memo(function Map({
// Use refs for values that change during drag so layers aren't recreated
const viewFeatureRef = useRef(viewFeature);
viewFeatureRef.current = viewFeature;
const viewRangeRef = useRef(viewRange);
viewRangeRef.current = viewRange;
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);
@ -408,32 +420,14 @@ export default memo(function Map({
const selectedHexagonIdRef = useRef(selectedHexagonId);
selectedHexagonIdRef.current = selectedHexagonId;
// Postcode refs
const selectedPostcodeRef = useRef(selectedPostcode);
selectedPostcodeRef.current = selectedPostcode;
// 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) {
onHexagonClickRef.current(info.object.h3);
}
},
[]
);
const onPostcodeClickRef = useRef(onPostcodeClick);
onPostcodeClickRef.current = onPostcodeClick;
const handlePostcodeClick = useCallback(
(info: PickingInfo<PostcodeData>) => {
if (info.object && 'postcode' in info.object) {
onPostcodeClickRef.current(info.object.postcode);
}
},
[]
);
const handleHexagonClick = useCallback((info: PickingInfo<HexagonData>) => {
if (info.object && 'h3' in info.object) {
onHexagonClickRef.current(info.object.h3);
}
}, []);
// Stable hover handler using ref
const handlePoiHoverRef = useRef(handlePoiHover);
@ -443,7 +437,7 @@ export default memo(function Map({
}, []);
// Derive a trigger value from color-affecting state — avoids useEffect+setState double-render
const colorTrigger = `${viewFeature}|${viewRange?.[0]}|${viewRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`;
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`;
// Hexagon layer — only recreated when data or color trigger changes
const hexLayer = useMemo(
@ -454,29 +448,36 @@ export default memo(function Map({
getHexagon: (d) => d.h3,
getFillColor: (d) => {
const vf = viewFeatureRef.current;
const vr = viewRangeRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
if (vf && vr && cfm) {
if (vf && clr && cfm) {
const val = d[`min_${vf}`];
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
const min = vr[0];
const max = vr[1];
const minVal = d[`min_${vf}`] as number;
const maxVal = d[`max_${vf}`] as number;
// Gray out hexagons outside range
if (maxVal < min || minVal > max) {
return [180, 180, 180, 60] as [number, number, number, number];
// Gray out hexagons outside filter range
if (fr) {
const minVal = d[`min_${vf}`] as number;
const maxVal = d[`max_${vf}`] as number;
if (maxVal < fr[0] || minVal > fr[1]) {
return [180, 180, 180, 60] as [number, number, number, number];
}
}
const range = max - min;
// Color using full slider range
const range = clr[1] - clr[0];
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
const t = ((val as number) - min) / range;
const t = ((val as number) - clr[0]) / range;
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
return [...rgb, 200] 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))), 200] as [number, number, number, number];
return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [
number,
number,
number,
number,
];
},
getLineColor: (d) =>
(d.h3 === selectedHexagonIdRef.current ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [
@ -498,84 +499,11 @@ export default memo(function Map({
highPrecision: true,
onClick: handleHexagonClick,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: "waterway_label",
beforeId: 'waterway_label',
}),
[data, colorTrigger, handleHexagonClick]
);
// Postcode count range
const postcodeCountRange = useMemo(() => {
if (postcodeData.length === 0) return { min: 0, max: 1 };
let min = Infinity;
let max = -Infinity;
for (const d of postcodeData) {
const c = d.count as number;
if (c < min) min = c;
if (c > max) max = c;
}
if (min === max) return { min, max: min + 1 };
return { min, max };
}, [postcodeData]);
const postcodeCountRangeRef = useRef(postcodeCountRange);
postcodeCountRangeRef.current = postcodeCountRange;
// Postcode color trigger
const postcodeColorTrigger = `${viewFeature}|${viewRange?.[0]}|${viewRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}`;
// Postcode polygon layer
const postcodeLayer = useMemo(
() =>
new PolygonLayer<PostcodeData>({
id: 'postcode-polygons',
data: postcodeData,
getPolygon: (d) => d.polygon,
getFillColor: (d) => {
const vf = viewFeatureRef.current;
const vr = viewRangeRef.current;
const cfm = colorFeatureMetaRef.current;
if (vf && vr && cfm) {
const val = d[`min_${vf}`];
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
const min = vr[0];
const max = vr[1];
const minVal = d[`min_${vf}`] as number;
const maxVal = d[`max_${vf}`] as number;
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];
}
const cr = postcodeCountRangeRef.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))), 200] as [number, number, number, number];
},
getLineColor: (d) =>
(d.postcode === selectedPostcodeRef.current
? [255, 255, 255, 255]
: [160, 160, 160, 200]) as [number, number, number, number],
getLineWidth: (d) => (d.postcode === selectedPostcodeRef.current ? 2 : 1),
lineWidthUnits: 'pixels' as const,
stroked: true,
filled: true,
pickable: true,
updateTriggers: {
getFillColor: [postcodeColorTrigger],
getLineColor: [postcodeColorTrigger],
getLineWidth: [postcodeColorTrigger],
},
onClick: handlePostcodeClick,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: "waterway_label",
}),
[postcodeData, postcodeColorTrigger, handlePostcodeClick]
);
// POI layer — independent, only recreated when POI data changes
const poiLayer = useMemo(
() =>
@ -597,9 +525,41 @@ export default memo(function Map({
[pois, stablePoiHover]
);
// Postcode labels on high-res hexagons (resolution 11+, zoom >= 13)
const postcodeData = useMemo(
() => data.filter((d) => d.postcode && d.lat != null && d.lon != null),
[data]
);
const showPostcodes = viewState.zoom >= 13;
const postcodeLayer = useMemo(
() =>
showPostcodes
? new TextLayer<HexagonData>({
id: 'postcode-labels',
data: postcodeData,
getPosition: (d) => [d.lon as number, d.lat as number],
getText: (d) => d.postcode as string,
getSize: 11,
getColor: [30, 30, 30, 220],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: 600,
outlineWidth: 2,
outlineColor: [255, 255, 255, 200],
billboard: false,
sizeUnits: 'pixels',
sizeMinPixels: 10,
sizeMaxPixels: 14,
})
: null,
[postcodeData, showPostcodes]
);
const layers = useMemo(
() => (postcodeData.length > 0 ? [postcodeLayer, poiLayer] : [hexLayer, poiLayer]),
[postcodeData.length, postcodeLayer, hexLayer, poiLayer]
() => [hexLayer, poiLayer, ...(postcodeLayer ? [postcodeLayer] : [])],
[hexLayer, poiLayer, postcodeLayer]
);
// Tooltip uses refs to avoid being a layer dependency
@ -611,15 +571,9 @@ export default memo(function Map({
({ object }: { object?: any }) => {
if (!object) return null;
// Handle both hexagon and postcode objects
const isPostcode = 'postcode' in object;
const isHexagon = 'h3' in object;
if (!isPostcode && !isHexagon) return null;
if (!('h3' in object)) return null;
const lines: string[] = [];
if (isPostcode) {
lines.push(`<strong>${object.postcode}</strong>`);
}
lines.push(`<div>${(object.count as number).toLocaleString()} properties</div>`);
for (const f of featuresRef.current) {
@ -664,10 +618,10 @@ export default memo(function Map({
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
</MapGL>
<PostcodeSearch onFlyTo={handleFlyTo} />
{viewFeature && viewRange && colorFeatureMeta && (
{viewFeature && colorRange && colorFeatureMeta && (
<MapLegend
featureLabel={colorFeatureMeta.name}
range={viewRange}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
/>