This commit is contained in:
Andras Schmelczer 2026-02-02 21:56:35 +00:00
parent 2c613dc0d1
commit a677b9331f
28 changed files with 1647 additions and 1498 deletions

View file

@ -7,6 +7,18 @@ 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 } from '../types';
import {
GRADIENT,
normalizedToColor,
countToColor,
zoomToResolution,
getBoundsFromViewState,
emojiToTwemojiUrl,
MAP_STYLE_LIGHT,
MAP_STYLE_DARK,
} from '../lib/map-utils';
import PostcodeSearch from './PostcodeSearch';
import MapLegend from './MapLegend';
interface MapProps {
data: HexagonData[];
@ -26,18 +38,6 @@ interface MapProps {
theme?: 'light' | 'dark';
}
// Twemoji CDN base URL
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/';
// Convert emoji to Twemoji URL
function emojiToTwemojiUrl(emoji: string): string {
// Convert emoji to Unicode codepoint hex
const codePoint = emoji.codePointAt(0);
if (!codePoint) return `${TWEMOJI_BASE}1f4cd.png`; // Default pin
const hex = codePoint.toString(16);
return `${TWEMOJI_BASE}${hex}.png`;
}
const INITIAL_VIEW: ViewState = {
longitude: -1.5,
latitude: 53.5,
@ -45,84 +45,6 @@ const INITIAL_VIEW: ViewState = {
pitch: 0,
};
const MAP_STYLE_LIGHT = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json';
const MAP_STYLE_DARK = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
// Gradient stops for normalized [0,1] values
const GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [46, 204, 113] }, // Green
{ t: 0.33, color: [241, 196, 15] }, // Yellow
{ t: 0.66, color: [231, 76, 60] }, // Red
{ t: 1, color: [142, 68, 173] }, // Purple
];
function normalizedToColor(t: number): [number, number, number] {
if (t <= 0) return GRADIENT[0].color;
if (t >= 1) return GRADIENT[GRADIENT.length - 1].color;
for (let i = 0; i < GRADIENT.length - 1; i++) {
const lo = GRADIENT[i];
const hi = GRADIENT[i + 1];
if (t >= lo.t && t <= hi.t) {
const frac = (t - lo.t) / (hi.t - lo.t);
return [
Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac),
Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac),
Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac),
];
}
}
return GRADIENT[GRADIENT.length - 1].color;
}
function zoomToResolution(zoom: number): number {
if (zoom < 6) return 5;
if (zoom < 7) return 6;
if (zoom < 9.5) return 8;
if (zoom < 11) return 9;
if (zoom < 13) return 10;
if (zoom < 15) return 11;
return 12;
}
function getBoundsFromViewState(viewState: ViewState, width: number, height: number): Bounds {
const { longitude, latitude, zoom } = viewState;
// Clamp latitude to valid Mercator range to avoid math errors
const clampedLat = Math.max(-85, Math.min(85, latitude));
// Web Mercator projection math
const TILE_SIZE = 256;
const scale = Math.pow(2, zoom);
const worldSize = TILE_SIZE * scale;
// Longitude is linear
const degreesPerPixelLng = 360 / worldSize;
const halfWidthDeg = (width / 2) * degreesPerPixelLng;
// Latitude uses Mercator projection (non-linear)
const latRad = (clampedLat * Math.PI) / 180;
const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
const centerPixelY = mercatorY * worldSize;
const topPixelY = centerPixelY - height / 2;
const bottomPixelY = centerPixelY + height / 2;
// Convert pixel Y back to latitude
const pixelYToLat = (pixelY: number): number => {
const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize));
const latRadians = Math.atan(Math.sinh(Math.PI * (1 - 2 * mercY)));
return (latRadians * 180) / Math.PI;
};
const north = Math.min(85, pixelYToLat(topPixelY));
const south = Math.max(-85, pixelYToLat(bottomPixelY));
const west = Math.max(-180, longitude - halfWidthDeg);
const east = Math.min(180, longitude + halfWidthDeg);
return { south, west, north, east };
}
interface Dimensions {
width: number;
height: number;
@ -148,176 +70,6 @@ function DeckOverlay({
return null;
}
// Vibrant density scale: light cyan → teal → deep indigo
const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [130, 234, 220] }, // Light cyan (few)
{ t: 0.5, color: [20, 140, 180] }, // Ocean blue (moderate)
{ t: 1, color: [88, 28, 140] }, // Deep indigo (many)
];
function countToColor(t: number): [number, number, number] {
if (t <= 0) return DENSITY_GRADIENT[0].color;
if (t >= 1) return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
for (let i = 0; i < DENSITY_GRADIENT.length - 1; i++) {
const lo = DENSITY_GRADIENT[i];
const hi = DENSITY_GRADIENT[i + 1];
if (t >= lo.t && t <= hi.t) {
const frac = (t - lo.t) / (hi.t - lo.t);
return [
Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac),
Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac),
Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac),
];
}
}
return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
}
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 dark:bg-navy-800 dark:text-warm-100 dark:placeholder-warm-500"
/>
<button
type="submit"
disabled={loading}
className="px-3 py-2 bg-teal-600 text-white text-sm hover:bg-teal-700 disabled:opacity-50"
>
{loading ? '...' : 'Go'}
</button>
</div>
{error && (
<span className="text-xs text-red-600 dark:text-red-400 bg-white/90 dark:bg-navy-800/90 rounded px-2 py-0.5 shadow">{error}</span>
)}
</form>
);
}
function MapLegend({
featureLabel,
range,
showCancel,
onCancel,
mode,
enumValues,
}: {
featureLabel: string;
range: [number, number];
showCancel: boolean;
onCancel: () => void;
mode: 'feature' | 'density';
enumValues?: string[];
}) {
const formatVal = (v: number) => {
if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
if (Math.abs(v) >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
if (Number.isInteger(v)) return v.toString();
return v.toFixed(1);
};
const gradientStyle =
mode === 'density'
? 'linear-gradient(to right, rgb(130, 234, 220), rgb(20, 140, 180), rgb(88, 28, 140))'
: 'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))';
return (
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-warm-200 rounded shadow-lg p-3 text-xs min-w-[160px]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-sm">{featureLabel}</span>
{showCancel && (
<button
onClick={onCancel}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 ml-2"
title="Clear color view"
>
<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>
)}
</div>
<div
className="h-3 rounded"
style={{ background: gradientStyle }}
/>
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-400">
{mode === 'density' ? (
<>
<span>Few</span>
<span>Many</span>
</>
) : enumValues && enumValues.length > 0 ? (
<>
<span>{enumValues[0]}</span>
<span>{enumValues[enumValues.length - 1]}</span>
</>
) : (
<>
<span>{formatVal(range[0])}</span>
<span>{formatVal(range[1])}</span>
</>
)}
</div>
</div>
);
}
export default memo(function Map({
data,
pois,
@ -339,7 +91,6 @@ export default memo(function Map({
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
// Track container dimensions with ResizeObserver
useEffect(() => {
const container = containerRef.current;
if (!container) return;
@ -355,14 +106,12 @@ export default memo(function Map({
return () => observer.disconnect();
}, []);
// Notify parent when view or dimensions change
useEffect(() => {
if (dimensions.width === 0 || dimensions.height === 0) return;
const raw = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
const resolution = zoomToResolution(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,
@ -391,7 +140,6 @@ export default memo(function Map({
const themeRef = useRef(theme);
themeRef.current = theme;
// Make place labels more legible over the colored hexagons
const handleMapLoad = useCallback(
(evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
const map = evt.target;
@ -402,7 +150,6 @@ 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');
@ -421,7 +168,6 @@ export default memo(function Map({
const mapStyle = theme === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
// Popup state for POI hover
const [popupInfo, setPopupInfo] = useState<{
x: number;
y: number;
@ -442,7 +188,6 @@ export default memo(function Map({
}
}, []);
// Compute count range for count-based coloring
const countRange = useMemo(() => {
if (data.length === 0) return { min: 0, max: 1 };
let min = Infinity;
@ -456,13 +201,11 @@ export default memo(function Map({
return { min, max };
}, [data]);
// Memoize feature lookup to avoid new reference each render
const colorFeatureMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features]
);
// Use refs for values that change during drag so layers aren't recreated
const viewFeatureRef = useRef(viewFeature);
viewFeatureRef.current = viewFeature;
const colorRangeRef = useRef(colorRange);
@ -478,7 +221,6 @@ export default memo(function Map({
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
hoveredHexagonIdRef.current = hoveredHexagonId;
// Stable click handler using ref
const onHexagonClickRef = useRef(onHexagonClick);
onHexagonClickRef.current = onHexagonClick;
const handleHexagonClick = useCallback((info: PickingInfo<HexagonData>) => {
@ -487,7 +229,6 @@ export default memo(function Map({
}
}, []);
// Stable hover handler using ref
const onHexagonHoverRef = useRef(onHexagonHover);
onHexagonHoverRef.current = onHexagonHover;
const handleHexagonHover = useCallback((info: PickingInfo<HexagonData>) => {
@ -498,17 +239,14 @@ export default memo(function Map({
}
}, []);
// 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 = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}`;
// Hexagon layer — only recreated when data or color trigger changes
const hexLayer = useMemo(
() =>
new H3HexagonLayer<HexagonData>({
@ -523,7 +261,6 @@ export default memo(function Map({
if (vf && clr && cfm) {
const val = d[`min_${vf}`];
if (val == null) return [128, 128, 128, 80] 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;
@ -531,7 +268,6 @@ export default memo(function Map({
return [180, 180, 180, 60] as [number, number, number, number];
}
}
// 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) - clr[0]) / range;
@ -549,8 +285,10 @@ export default memo(function Map({
];
},
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];
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) => {
@ -576,7 +314,6 @@ export default memo(function Map({
[data, colorTrigger, handleHexagonClick, handleHexagonHover]
);
// POI layer — independent, only recreated when POI data changes
const poiLayer = useMemo(
() =>
new IconLayer<POI>({
@ -597,7 +334,6 @@ 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]