369 lines
11 KiB
TypeScript
369 lines
11 KiB
TypeScript
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<string, string> = {
|
|
// 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<string, string> = {
|
|
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<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 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<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);
|
|
}
|
|
}, []);
|
|
|
|
const layers = useMemo(
|
|
() => [
|
|
new H3HexagonLayer<HexagonData>({
|
|
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<POI>({
|
|
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
|
|
? `<div style="color: #0066cc; margin-top: 4px; font-size: 12px;">${journeyLines.join('<br/>')}</div>`
|
|
: '';
|
|
|
|
return {
|
|
html: `<div style="padding: 8px; font-size: 14px;">
|
|
<strong>Avg: £${hex.avg_price?.toLocaleString() || 'N/A'}</strong>
|
|
<div style="color: #666; font-size: 12px;">
|
|
${hex.count} sales<br/>
|
|
Range: £${hex.min_price?.toLocaleString()} - £${hex.max_price?.toLocaleString()}
|
|
</div>
|
|
${journeyTimeHtml}
|
|
</div>`,
|
|
style: {
|
|
backgroundColor: 'white',
|
|
borderRadius: '4px',
|
|
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
|
},
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="flex-1 h-full relative" ref={containerRef}>
|
|
<DeckGL
|
|
viewState={viewState}
|
|
controller
|
|
layers={layers}
|
|
onViewStateChange={handleViewStateChange as never}
|
|
getTooltip={getTooltip as never}
|
|
>
|
|
<MapGL mapStyle={MAP_STYLE} />
|
|
</DeckGL>
|
|
{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>
|
|
{getTooltipEmoji(popupInfo.category)} {popupInfo.name}
|
|
</strong>
|
|
<div className="text-gray-500 text-xs">{popupInfo.category.replace(/_/g, ' ')}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|