import { useCallback, useRef, useEffect, useState, useMemo } from 'react'; import { Map as MapGL } from 'react-map-gl/maplibre'; import DeckGL from '@deck.gl/react'; 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, ColorMode } from '../types'; interface MapProps { data: HexagonData[]; pois: POI[]; onViewChange: (params: ViewChangeParams) => void; colorMode: ColorMode; } // Twemoji CDN base URL const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/'; // Map category to Twemoji codepoint (emoji unicode -> hex) const POI_EMOJI_CODES: Record = { // Schools elementary_school: '1f3eb', // 🏫 school: '1f3eb', high_school: '1f393', // 🎓 preschool: '1f476', // 👶 college_university: '1f393', private_school: '1f3eb', // Healthcare doctor: '1f3e5', // 🏥 dentist: '1f9b7', // 🦷 pharmacy: '1f48a', // 💊 hospital: '1f3e5', public_health_clinic: '1f3e5', // Transport train_station: '1f689', // 🚉 bus_station: '1f68c', // 🚌 metro_station: '1f687', // 🚇 light_rail_and_subway_stations: '1f687', // Parks park: '1f333', // 🌳 national_park: '1f3de', // 🏞 dog_park: '1f415', // 🐕 // Emergency police_department: '1f694', // 🚔 fire_department: '1f692', // 🚒 // Supermarkets supermarket: '1f6d2', // 🛒 grocery_store: '1f6d2', convenience_store: '1f3ea', // 🏪 }; function getPOIIconUrl(category: string): string { const code = POI_EMOJI_CODES[category] || '1f4cd'; // 📍 default return `${TWEMOJI_BASE}${code}.png`; } // Tooltip emojis (these render fine in HTML) const TOOLTIP_EMOJIS: Record = { elementary_school: '🏫', school: '🏫', high_school: '🎓', preschool: '👶', college_university: '🎓', private_school: '🏫', doctor: '👨‍⚕️', dentist: '🦷', pharmacy: '💊', hospital: '🏥', public_health_clinic: '🏥', train_station: '🚉', bus_station: '🚌', metro_station: '🚇', light_rail_and_subway_stations: '🚇', park: '🌳', national_park: '🏞️', dog_park: '🐕', police_department: '🚔', fire_department: '🚒', supermarket: '🛒', grocery_store: '🛒', convenience_store: '🏪', }; function getTooltipEmoji(category: string): string { return TOOLTIP_EMOJIS[category] || '📍'; } 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'; interface ColorStop { price: number; color: [number, number, number]; } // Continuous color scale from green (low) -> yellow -> red -> purple (high) const COLOR_SCALE: ColorStop[] = [ { price: 0, color: [46, 204, 113] }, // Green { price: 200000, color: [241, 196, 15] }, // Yellow { price: 400000, color: [231, 76, 60] }, // Red { price: 800000, color: [142, 68, 173] }, // Purple ]; function interpolateColor( c1: [number, number, number], c2: [number, number, number], t: number ): [number, number, number] { return [ Math.round(c1[0] + (c2[0] - c1[0]) * t), Math.round(c1[1] + (c2[1] - c1[1]) * t), Math.round(c1[2] + (c2[2] - c1[2]) * t), ]; } function scaleToColor( value: number | null | undefined, scale: ColorStop[] ): [number, number, number] { if (value == null || isNaN(value)) return [128, 128, 128]; if (value <= scale[0].price) return scale[0].color; if (value >= scale[scale.length - 1].price) return scale[scale.length - 1].color; for (let i = 0; i < scale.length - 1; i++) { const lower = scale[i]; const upper = scale[i + 1]; if (value >= lower.price && value <= upper.price) { const t = (value - lower.price) / (upper.price - lower.price); return interpolateColor(lower.color, upper.color, t); } } return scale[scale.length - 1].color; } function priceToColor(price: number | null | undefined): [number, number, number] { return scaleToColor(price, COLOR_SCALE); } // Journey time color scale: green (short) -> yellow -> orange -> red (long) const JOURNEY_COLOR_SCALE: ColorStop[] = [ { price: 0, color: [46, 204, 113] }, // Green { price: 30, color: [241, 196, 15] }, // Yellow { price: 60, color: [231, 76, 60] }, // Red { price: 120, color: [142, 68, 173] }, // Purple ]; function journeyTimeToColor(minutes: number | null | undefined): [number, number, number] { return scaleToColor(minutes, JOURNEY_COLOR_SCALE); } function zoomToResolution(zoom: number): number { if (zoom < 8.5) 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) // Convert center lat to pixel y, offset by half height, convert back to lat 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)); // Clamp to avoid edge cases 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; } export default function Map({ data, pois, onViewChange, colorMode }: 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 handleViewStateChange = useCallback((params: { viewState: unknown }) => { const newViewState = params.viewState as ViewState; setViewState(newViewState); }, []); // Popup state for POI hover (using screen coordinates) 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); } }, []); const layers = useMemo( () => [ new H3HexagonLayer({ id: 'h3-hexagons', data, getHexagon: (d) => d.h3, getFillColor: (d) => colorMode === 'journey_time' ? journeyTimeToColor(d.median_journey_minutes) : priceToColor(d.avg_price), updateTriggers: { getFillColor: colorMode, }, extruded: false, pickable: true, opacity: 0.5, highPrecision: true, }), new IconLayer({ id: 'poi-icons', data: pois, getPosition: (d) => [d.lng, d.lat], getIcon: (d) => ({ url: getPOIIconUrl(d.category), width: 72, height: 72, }), getSize: 24, sizeMinPixels: 20, sizeMaxPixels: 40, pickable: true, onHover: handlePoiHover, }), ], [data, pois, handlePoiHover, colorMode] ); // Tooltip for hexagons only (POIs use MapLibre popup) const getTooltip = useCallback(({ object }: { object?: HexagonData }) => { if (!object || !('h3' in object)) return null; const hex = object as HexagonData; const journeyLines: string[] = []; if (hex.median_pt_quick_minutes != null) journeyLines.push(`🚇 Quick PT: ${hex.median_pt_quick_minutes} min`); if (hex.median_pt_easy_minutes != null) journeyLines.push(`🚌 Easy PT: ${hex.median_pt_easy_minutes} min`); if (hex.median_cycling_minutes != null) journeyLines.push(`🚲 Cycling: ${hex.median_cycling_minutes} min`); const journeyTimeHtml = journeyLines.length > 0 ? `
${journeyLines.join('
')}
` : ''; return { html: `
Avg: £${hex.avg_price?.toLocaleString() || 'N/A'}
${hex.count} sales
Range: £${hex.min_price?.toLocaleString()} - £${hex.max_price?.toLocaleString()}
${journeyTimeHtml}
`, style: { backgroundColor: 'white', borderRadius: '4px', boxShadow: '0 2px 4px rgba(0,0,0,0.2)', }, }; }, []); return (
{popupInfo && (
{getTooltipEmoji(popupInfo.category)} {popupInfo.name}
{popupInfo.category.replace(/_/g, ' ')}
)}
); }