355 lines
12 KiB
TypeScript
355 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>
|
|
);
|
|
}
|