Refactor
This commit is contained in:
parent
2c613dc0d1
commit
a677b9331f
28 changed files with 1647 additions and 1498 deletions
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue