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

@ -1,19 +1,47 @@
import { Slider } from './ui/slider';
import { Label } from './ui/label';
import { YEAR_MIN, YEAR_MAX, YEAR_STEP, PRICE_MIN, PRICE_MAX, PRICE_STEP } from '../lib/constants';
import type { Filters as FiltersType } from '../types';
import type { Filters as FiltersType, POICategoryGroup } from '../types';
import { POI_CATEGORY_GROUPS } from '../types';
interface FiltersProps {
filters: FiltersType;
onChange: (filters: FiltersType) => void;
zoom: number;
selectedPOICategories: Set<POICategoryGroup>;
onPOICategoriesChange: (categories: Set<POICategoryGroup>) => void;
}
export default function Filters({ filters, onChange, zoom }: FiltersProps) {
const POI_LABELS: Record<POICategoryGroup, string> = {
schools: '🏫 Schools',
healthcare: '🏥 Healthcare',
transport: '🚉 Transport',
parks: '🌳 Parks',
emergency: '🚨 Emergency',
supermarkets: '🛒 Supermarkets',
};
export default function Filters({
filters,
onChange,
zoom,
selectedPOICategories,
onPOICategoriesChange,
}: FiltersProps) {
const update = (key: keyof FiltersType, value: number) => onChange({ ...filters, [key]: value });
const togglePOICategory = (category: POICategoryGroup) => {
const newSet = new Set(selectedPOICategories);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
onPOICategoriesChange(newSet);
};
return (
<div className="w-72 p-4 bg-white shadow-lg space-y-6">
<div className="w-72 p-4 bg-white shadow-lg space-y-6 overflow-y-auto max-h-screen">
<h1 className="text-xl font-bold">UK Property Prices</h1>
<div className="text-sm text-slate-500">Zoom: {zoom.toFixed(1)}</div>
@ -69,6 +97,23 @@ export default function Filters({ filters, onChange, zoom }: FiltersProps) {
<span>£800k+</span>
</div>
</div>
<div className="space-y-2">
<Label>Points of Interest</Label>
<div className="space-y-1">
{POI_CATEGORY_GROUPS.map((category) => (
<label key={category} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedPOICategories.has(category)}
onChange={() => togglePOICategory(category)}
className="rounded"
/>
<span className="text-sm">{POI_LABELS[category]}</span>
</label>
))}
</div>
</div>
</div>
);
}

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>
);
}