Display pois

This commit is contained in:
Andras Schmelczer 2026-01-26 22:02:17 +00:00
parent c157c2d5ec
commit 433fca64ad
6 changed files with 412 additions and 10 deletions

View file

@ -2,14 +2,89 @@ 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 } from '../types';
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI } from '../types';
interface MapProps {
data: HexagonData[];
pois: POI[];
onViewChange: (params: ViewChangeParams) => void;
}
// 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,
@ -118,7 +193,7 @@ interface Dimensions {
height: number;
}
export default function Map({ data, onViewChange }: MapProps) {
export default function Map({ data, pois, onViewChange }: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(INITIAL_VIEW);
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
@ -154,6 +229,27 @@ export default function Map({ data, onViewChange }: MapProps) {
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>({
@ -166,20 +262,76 @@ export default function Map({ data, onViewChange }: MapProps) {
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]
[data, pois, handlePoiHover]
);
// 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;
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>
</div>`,
style: {
backgroundColor: 'white',
borderRadius: '4px',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
},
};
}, []);
return (
<div className="flex-1 h-full" ref={containerRef}>
<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>
);
}