Refactor UI

This commit is contained in:
Andras Schmelczer 2026-02-04 22:27:56 +00:00
parent ce4c0cc08c
commit 34a4d0ba86
32 changed files with 1726 additions and 845 deletions

View file

@ -3,10 +3,18 @@ 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 } from '@deck.gl/layers';
import { IconLayer, PolygonLayer } 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 } from '../types';
import type {
HexagonData,
PostcodeData,
ViewState,
ViewChangeParams,
Bounds,
POI,
FeatureMeta,
} from '../types';
import {
GRADIENT,
normalizedToColor,
@ -14,11 +22,14 @@ import {
zoomToResolution,
getBoundsFromViewState,
emojiToTwemojiUrl,
MAP_STYLE_LIGHT,
MAP_STYLE_DARK,
getMapStyle,
POSTCODE_ZOOM_THRESHOLD,
} from '../lib/map-utils';
import PostcodeSearch from './PostcodeSearch';
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 {
@ -30,6 +41,8 @@ function osmIdToUrl(id: string): string | null {
interface MapProps {
data: HexagonData[];
postcodeData: PostcodeData[];
usePostcodeView: boolean;
pois: POI[];
onViewChange: (params: ViewChangeParams) => void;
viewFeature: string | null;
@ -40,19 +53,16 @@ interface MapProps {
features: FeatureMeta[];
selectedHexagonId: string | null;
hoveredHexagonId: string | null;
onHexagonClick: (h3: string) => void;
onHexagonHover: (h3: string | null) => void;
onHexagonClick: (id: string, isPostcode?: boolean) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
initialViewState?: ViewState;
theme?: 'light' | 'dark';
screenshotMode?: boolean;
filters?: FeatureFilters;
searchedPostcode?: SearchedPostcode | null;
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
}
const INITIAL_VIEW: ViewState = {
longitude: -1.5,
latitude: 53.5,
zoom: 6,
pitch: 0,
};
interface Dimensions {
width: number;
@ -81,6 +91,8 @@ function DeckOverlay({
export default memo(function Map({
data,
postcodeData,
usePostcodeView,
pois,
onViewChange,
viewFeature,
@ -96,10 +108,14 @@ export default memo(function Map({
initialViewState,
theme = 'light',
screenshotMode = false,
filters = {},
searchedPostcode,
onPostcodeSearched,
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
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;
@ -119,17 +135,11 @@ export default memo(function Map({
useEffect(() => {
if (dimensions.width === 0 || dimensions.height === 0) return;
const raw = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
// Send exact viewport bounds - server will filter to only return
// hexagons/postcodes that intersect this precise AABB
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
const resolution = zoomToResolution(viewState.zoom);
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,
@ -153,30 +163,17 @@ export default memo(function Map({
const handleMapLoad = useCallback(
(evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
const map = evt.target;
if (themeRef.current === 'light') {
for (const layer of map.getStyle().layers || []) {
if (layer.type !== 'symbol') continue;
map.setPaintProperty(layer.id, 'text-halo-color', 'rgba(255,255,255,1)');
map.setPaintProperty(layer.id, 'text-halo-width', 2);
map.setPaintProperty(layer.id, 'text-color', '#222');
}
for (const layer of map.getStyle().layers || []) {
if (layer.id === 'water' || layer.id.startsWith('water')) {
map.setPaintProperty(layer.id, 'fill-color', '#6baed6');
}
}
}
// Hide buildings to reduce visual clutter over hexagons
try {
map.setLayoutProperty('building', 'visibility', 'none');
map.setLayoutProperty('building-top', 'visibility', 'none');
map.setLayoutProperty('buildings', 'visibility', 'none');
} catch {
// layers may not exist in dark style
// layer may not exist
}
},
[]
);
const mapStyle = theme === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
const mapStyle = useMemo(() => getMapStyle(theme), [theme]);
const [popupInfo, setPopupInfo] = useState<{
x: number;
@ -244,9 +241,11 @@ export default memo(function Map({
const onHexagonHoverRef = useRef(onHexagonHover);
onHexagonHoverRef.current = onHexagonHover;
const handleHexagonHover = useCallback((info: PickingInfo<HexagonData>) => {
if (info.object && 'h3' in info.object) {
onHexagonHoverRef.current(info.object.h3);
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);
}
}, []);
@ -257,7 +256,54 @@ export default memo(function Map({
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) {
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;
// 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;
const handlePostcodeClick = useCallback((info: PickingInfo<PostcodeData>) => {
if (info.object && 'postcode' in info.object) {
const pc = info.object.postcode;
setSelectedPostcode((prev) => (prev === pc ? null : pc));
// Also trigger the hexagon click handler with the postcode as identifier
onHexagonClickRef.current(pc, true);
}
}, []);
const handlePostcodeHoverCallback = useCallback((info: PickingInfo<PostcodeData>) => {
if (info.object && 'postcode' in info.object && info.x !== undefined && info.y !== undefined) {
setHoveredPostcode(info.object.postcode);
setHoverPosition({ x: info.x, y: info.y });
onHexagonHoverRef.current(info.object.postcode, info.x, info.y);
} else {
setHoveredPostcode(null);
setHoverPosition(null);
onHexagonHoverRef.current(null);
}
}, []);
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}`;
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}`;
const hexLayer = useMemo(
() =>
@ -321,11 +367,76 @@ export default memo(function Map({
onClick: handleHexagonClick,
onHover: handleHexagonHover,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'waterway_label',
beforeId: 'water_waterway_label',
}),
[data, colorTrigger, handleHexagonClick, handleHexagonHover]
);
const postcodeLayer = useMemo(
() =>
new PolygonLayer<PostcodeData>({
id: 'postcode-polygons',
data: postcodeData,
getPolygon: (d) => d.vertices,
getFillColor: (d) => {
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
if (vf && clr && cfm) {
const val = d[`min_${vf}`];
if (val == null) return [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 [180, 180, 180, 60] as [number, number, number, number];
}
}
const range = clr[1] - clr[0];
if (range === 0) return [...GRADIENT[0].color, 200] 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, 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) => {
if (d.postcode === selectedPostcodeRef.current)
return [255, 255, 255, 255] as [number, number, number, number];
if (d.postcode === hoveredPostcodeRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
return [100, 100, 100, 150] as [number, number, number, number];
},
getLineWidth: (d) => {
if (d.postcode === selectedPostcodeRef.current) return 3;
if (d.postcode === 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: 'water_waterway_label',
}),
[postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]
);
const poiLayer = useMemo(
() =>
new IconLayer<POI>({
@ -346,7 +457,43 @@ export default memo(function Map({
[pois, stablePoiHover]
);
const layers = useMemo(() => [hexLayer, poiLayer], [hexLayer, poiLayer]);
// Check if the searched postcode has data (passes current filters)
const searchedPostcodeHasData = useMemo(() => {
if (!searchedPostcode) return false;
return postcodeData.some((d) => d.postcode === searchedPostcode.postcode);
}, [searchedPostcode, postcodeData]);
// Highlight layer for searched postcode
const searchedPostcodeHighlightLayer = useMemo(() => {
if (!searchedPostcode) return null;
const hasData = searchedPostcodeHasData;
// Use different layers for dashed vs solid lines
return new PolygonLayer<{ vertices: [number, number][] }>({
id: 'searched-postcode-highlight',
data: [{ vertices: searchedPostcode.vertices }],
getPolygon: (d) => d.vertices,
// Transparent fill - just show outline
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, poiLayer] : [hexLayer, poiLayer];
if (searchedPostcodeHighlightLayer) {
return [...baseLayers, searchedPostcodeHighlightLayer];
}
return baseLayers;
}, [usePostcodeView, hexLayer, postcodeLayer, poiLayer, searchedPostcodeHighlightLayer]);
return (
<div className="flex-1 h-full relative" ref={containerRef}>
@ -362,8 +509,8 @@ export default memo(function Map({
touchPitch={false}
keyboard={true}
pitchWithRotate={false}
minZoom={5}
maxBounds={[-12, 49, 4, 62]}
minZoom={MAP_MIN_ZOOM}
maxBounds={MAP_BOUNDS}
>
<DeckOverlay layers={layers} getTooltip={null} />
</MapGL>
@ -378,7 +525,7 @@ export default memo(function Map({
</div>
) : (
<>
<PostcodeSearch onFlyTo={handleFlyTo} />
<PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} />
{viewSource === 'eye' && viewFeature && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-white dark:bg-warm-800 rounded-lg shadow-lg px-5 py-3">
<span className="text-lg font-semibold text-navy-950 dark:text-warm-100">
@ -434,6 +581,20 @@ export default memo(function Map({
)}
</div>
)}
{hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && (
<HoverCard
x={hoverPosition.x}
y={hoverPosition.y}
id={hoveredHexagonId}
isPostcode={usePostcodeView}
data={
usePostcodeView
? postcodeData.find((d) => d.postcode === hoveredHexagonId) || null
: data.find((d) => d.h3 === hoveredHexagonId) || null
}
filters={filters}
/>
)}
</>
)}
</div>