perfect-postcode/frontend/src/components/Map.tsx
2026-01-31 13:07:18 +00:00

390 lines
12 KiB
TypeScript

import { useCallback, useRef, useEffect, useState, useMemo } from 'react';
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 type { PickingInfo } from '@deck.gl/core';
import 'maplibre-gl/dist/maplibre-gl.css';
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta } from '../types';
interface MapProps {
data: HexagonData[];
pois: POI[];
onViewChange: (params: ViewChangeParams) => void;
activeFeature: string | null;
dragValue: [number, number] | null;
features: FeatureMeta[];
selectedHexagonId: string | null;
onHexagonClick: (h3: string) => void;
}
// 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,
zoom: 6,
pitch: 0,
};
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/positron-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 < 7) return 7;
if (zoom < 9.5) return 8;
if (zoom < 11) return 9;
if (zoom < 13) return 10;
return 11;
}
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;
}
// First label layer in the Carto Positron style — hexagons render below this
const LABEL_LAYER_ID = 'waterway_label';
function DeckOverlay({
layers,
getTooltip,
}: {
layers: (H3HexagonLayer<HexagonData> | IconLayer<POI>)[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getTooltip: any;
}) {
const overlay = useControl(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers, getTooltip });
return null;
}
// Sequential blue scale for count-based coloring
function countToColor(t: number): [number, number, number] {
// light blue (209, 226, 243) -> dark blue (33, 102, 172)
const r = Math.round(209 + (33 - 209) * t);
const g = Math.round(226 + (102 - 226) * t);
const b = Math.round(243 + (172 - 243) * t);
return [r, g, b];
}
export default function Map({
data,
pois,
onViewChange,
activeFeature,
dragValue,
features,
selectedHexagonId,
onHexagonClick,
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(INITIAL_VIEW);
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
// Track container dimensions with ResizeObserver
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
if (width > 0 && height > 0) {
setDimensions({ width, height });
}
});
observer.observe(container);
return () => observer.disconnect();
}, []);
// Notify parent when view or dimensions change
useEffect(() => {
if (dimensions.width === 0 || dimensions.height === 0) return;
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
const resolution = zoomToResolution(viewState.zoom);
onViewChange({ resolution, bounds, zoom: viewState.zoom });
}, [viewState, dimensions, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => {
setViewState(evt.viewState);
}, []);
// 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;
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');
}
},
[]
);
// Popup state for POI hover
const [popupInfo, setPopupInfo] = useState<{
x: number;
y: number;
name: string;
category: string;
} | null>(null);
const handlePoiHover = useCallback((info: PickingInfo<POI>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setPopupInfo({
x: info.x,
y: info.y,
name: info.object.name,
category: info.object.category,
});
} else {
setPopupInfo(null);
}
}, []);
// Compute count range for count-based coloring
const countRange = useMemo(() => {
if (data.length === 0) return { min: 0, max: 1 };
let min = Infinity;
let max = -Infinity;
for (const d of data) {
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 };
}, [data]);
// Determine color mode
const colorFeatureMeta = activeFeature
? features.find((f) => f.name === activeFeature) || null
: null;
const handleHexagonClick = useCallback(
(info: PickingInfo<HexagonData>) => {
if (info.object && 'h3' in info.object) {
onHexagonClick(info.object.h3);
}
},
[onHexagonClick]
);
const layers = useMemo(
() => [
new H3HexagonLayer<HexagonData>({
id: 'h3-hexagons',
data,
getHexagon: (d) => d.h3,
getFillColor: (d) => {
if (activeFeature && dragValue && colorFeatureMeta) {
// Drag mode: color by feature value using gradient
const val = d[`min_${activeFeature}`];
if (val == null) return [128, 128, 128] as [number, number, number];
const range = dragValue[1] - dragValue[0];
if (range === 0) return GRADIENT[0].color;
const t = ((val as number) - dragValue[0]) / range;
return normalizedToColor(Math.max(0, Math.min(1, t)));
}
// Normal mode: color by count using blue scale
const c = d.count as number;
const t = (c - countRange.min) / (countRange.max - countRange.min);
return countToColor(Math.max(0, Math.min(1, t)));
},
getLineColor: (d) =>
(d.h3 === selectedHexagonId ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [
number,
number,
number,
number,
],
getLineWidth: (d) => (d.h3 === selectedHexagonId ? 2 : 0),
lineWidthUnits: 'pixels',
updateTriggers: {
getFillColor: [activeFeature, dragValue, countRange, colorFeatureMeta],
getLineColor: [selectedHexagonId],
getLineWidth: [selectedHexagonId],
},
extruded: false,
pickable: true,
opacity: 0.5,
highPrecision: true,
onClick: handleHexagonClick,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: LABEL_LAYER_ID,
}),
new IconLayer<POI>({
id: 'poi-icons',
data: pois,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => ({
url: emojiToTwemojiUrl(d.emoji),
width: 72,
height: 72,
}),
getSize: 24,
sizeMinPixels: 20,
sizeMaxPixels: 40,
pickable: true,
onHover: handlePoiHover,
}),
],
[
data,
pois,
handlePoiHover,
handleHexagonClick,
activeFeature,
dragValue,
countRange,
colorFeatureMeta,
selectedHexagonId,
]
);
const getTooltip = useCallback(
({ object }: { object?: HexagonData }) => {
if (!object || !('h3' in object)) return null;
const hex = object;
const lines: string[] = [];
lines.push(`<strong>${(hex.count as number).toLocaleString()} properties</strong>`);
for (const f of features) {
const minVal = hex[`min_${f.name}`];
const maxVal = hex[`max_${f.name}`];
if (minVal != null && maxVal != null) {
const minStr =
typeof minVal === 'number'
? minVal.toLocaleString(undefined, { maximumFractionDigits: 1 })
: String(minVal);
const maxStr =
typeof maxVal === 'number'
? maxVal.toLocaleString(undefined, { maximumFractionDigits: 1 })
: String(maxVal);
const highlight = f.name === activeFeature ? 'font-weight: bold;' : '';
lines.push(`<div style="${highlight}">${f.label}: ${minStr} - ${maxStr}</div>`);
}
}
return {
html: `<div style="padding: 8px; font-size: 12px;">${lines.join('')}</div>`,
style: {
backgroundColor: 'white',
borderRadius: '4px',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
},
};
},
[features, activeFeature]
);
return (
<div className="flex-1 h-full relative" ref={containerRef}>
<MapGL
{...viewState}
onMove={handleMove}
onLoad={handleMapLoad as never}
mapStyle={MAP_STYLE}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
</MapGL>
{popupInfo && (
<div
className="absolute pointer-events-none bg-white rounded shadow-lg p-2 text-sm"
style={{
left: popupInfo.x,
top: popupInfo.y - 40,
transform: 'translateX(-50%)',
zIndex: 9999,
}}
>
<strong>{popupInfo.name}</strong>
<div className="text-gray-500 text-xs">{popupInfo.category}</div>
</div>
)}
</div>
);
}