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 | IconLayer)[]; // 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(null); const [viewState, setViewState] = useState(INITIAL_VIEW); const [dimensions, setDimensions] = useState({ 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) => { 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) => { if (info.object && 'h3' in info.object) { onHexagonClick(info.object.h3); } }, [onHexagonClick] ); const layers = useMemo( () => [ new H3HexagonLayer({ 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({ 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(`${(hex.count as number).toLocaleString()} properties`); 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(`
${f.label}: ${minStr} - ${maxStr}
`); } } return { html: `
${lines.join('')}
`, style: { backgroundColor: 'white', borderRadius: '4px', boxShadow: '0 2px 4px rgba(0,0,0,0.2)', }, }; }, [features, activeFeature] ); return (
{popupInfo && (
{popupInfo.name}
{popupInfo.category}
)}
); }