Update UI
This commit is contained in:
parent
2ac37ece97
commit
5f311233e4
10 changed files with 663 additions and 408 deletions
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue