Add POIs and journey times to map

This commit is contained in:
Andras Schmelczer 2026-01-28 22:10:41 +00:00
parent 7bfb1729bf
commit 500b9ef2aa
11 changed files with 914 additions and 177 deletions

View file

@ -10,7 +10,7 @@ import type {
ApiResponse,
POI,
POIResponse,
POICategoryGroup,
POICategoriesMap,
ColorMode,
} from './types';
@ -55,23 +55,30 @@ export default function App() {
// POI state
const [pois, setPois] = useState<POI[]>([]);
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<POICategoryGroup>>(
new Set()
);
const [poiCategories, setPOICategories] = useState<POICategoriesMap>({});
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(new Set());
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const poiAbortControllerRef = useRef<AbortController | null>(null);
// Fetch POI category definitions from server on mount
useEffect(() => {
fetch(`${getApiBaseUrl()}/api/poi-categories`)
.then((res) => res.json())
.then((json: { categories: POICategoriesMap }) => {
setPOICategories(json.categories);
})
.catch((err) => console.error('Failed to fetch POI categories:', err));
}, []);
// Debounced fetch when dependencies change
useEffect(() => {
if (!bounds) return;
// Clear previous debounce timer
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(async () => {
// Cancel any in-flight request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
@ -167,6 +174,7 @@ export default function App() {
filters={filters}
onChange={setFilters}
zoom={zoom}
poiCategories={poiCategories}
selectedPOICategories={selectedPOICategories}
onPOICategoriesChange={setSelectedPOICategories}
colorMode={colorMode}

View file

@ -1,32 +1,25 @@
import { useState, useRef, useEffect } from 'react';
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, POICategoryGroup, ColorMode } from '../types';
import { POI_CATEGORY_GROUPS } from '../types';
import type { Filters as FiltersType, POICategoriesMap, ColorMode } from '../types';
interface FiltersProps {
filters: FiltersType;
onChange: (filters: FiltersType) => void;
zoom: number;
selectedPOICategories: Set<POICategoryGroup>;
onPOICategoriesChange: (categories: Set<POICategoryGroup>) => void;
poiCategories: POICategoriesMap;
selectedPOICategories: Set<string>;
onPOICategoriesChange: (categories: Set<string>) => void;
colorMode: ColorMode;
onColorModeChange: (mode: ColorMode) => void;
}
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,
poiCategories,
selectedPOICategories,
onPOICategoriesChange,
colorMode,
@ -34,16 +27,41 @@ export default function Filters({
}: FiltersProps) {
const update = (key: keyof FiltersType, value: number) => onChange({ ...filters, [key]: value });
const togglePOICategory = (category: POICategoryGroup) => {
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const toggleCategory = (key: string) => {
const newSet = new Set(selectedPOICategories);
if (newSet.has(category)) {
newSet.delete(category);
if (newSet.has(key)) {
newSet.delete(key);
} else {
newSet.add(category);
newSet.add(key);
}
onPOICategoriesChange(newSet);
};
const selectAll = () => {
onPOICategoriesChange(new Set(Object.keys(poiCategories)));
};
const selectNone = () => {
onPOICategoriesChange(new Set());
};
const categoryKeys = Object.keys(poiCategories);
const selectedCount = selectedPOICategories.size;
return (
<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>
@ -139,21 +157,69 @@ export default function Filters({
</div>
)}
<div className="space-y-2">
<div className="space-y-2" ref={dropdownRef}>
<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>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-slate-300 rounded hover:border-slate-400 bg-white"
>
<span className="truncate text-left">
{selectedCount === 0
? 'Select categories...'
: selectedCount === categoryKeys.length
? 'All categories'
: `${selectedCount} selected`}
</span>
<svg
className={`w-4 h-4 ml-2 flex-shrink-0 transition-transform ${dropdownOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{dropdownOpen && (
<div className="border border-slate-300 rounded shadow-lg bg-white">
<div className="flex gap-2 px-3 py-2 border-b border-slate-200">
<button
onClick={selectAll}
className="text-xs text-blue-600 hover:text-blue-800"
>
All
</button>
<span className="text-xs text-slate-300">|</span>
<button
onClick={selectNone}
className="text-xs text-blue-600 hover:text-blue-800"
>
None
</button>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{categoryKeys.map((key) => {
const { emoji, label } = poiCategories[key];
return (
<label
key={key}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-slate-50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedPOICategories.has(key)}
onChange={() => toggleCategory(key)}
className="rounded"
/>
<span className="text-sm">
{emoji} {label}
</span>
</label>
);
})}
</div>
</div>
)}
</div>
</div>
);

View file

@ -1,6 +1,7 @@
import { useCallback, useRef, useEffect, useState, useMemo } from 'react';
import { Map as MapGL } from 'react-map-gl/maplibre';
import DeckGL from '@deck.gl/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';
@ -19,35 +20,119 @@ const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/
// Map category to Twemoji codepoint (emoji unicode -> hex)
const POI_EMOJI_CODES: Record<string, string> = {
// Schools
elementary_school: '1f3eb', // 🏫
school: '1f3eb',
high_school: '1f393', // 🎓
// Education
school: '1f3eb', // 🏫
preschool: '1f476', // 👶
college_university: '1f393',
private_school: '1f3eb',
college_university: '1f393', // 🎓
library: '1f4da', // 📚
// Healthcare
doctor: '1f3e5', // 🏥
dentist: '1f9b7', // 🦷
pharmacy: '1f48a', // 💊
hospital: '1f3e5',
public_health_clinic: '1f3e5',
veterinary: '1f43e', // 🐾
nursing_home: '1f3e0', // 🏠
social_facility: '1f91d', // 🤝
// Transport
train_station: '1f689', // 🚉
bus_station: '1f68c', // 🚌
bus_stop: '1f68f', // 🚏
metro_station: '1f687', // 🚇
light_rail_and_subway_stations: '1f687',
// Parks
light_rail_station: '1f687',
tram_stop: '1f68a', // 🚊
ferry_terminal: '26f4', // ⛴
airport: '2708', // ✈
// Parks & Leisure
park: '1f333', // 🌳
national_park: '1f3de', // 🏞
nature_reserve: '1f33f', // 🌿
dog_park: '1f415', // 🐕
playground: '1f3a0', // 🎠
garden: '1f33a', // 🌺
sports_centre: '1f3c3', // 🏃
swimming_pool: '1f3ca', // 🏊
gym: '1f4aa', // 💪
golf_course: '26f3', // ⛳
marina: '26f5', // ⛵
// Emergency
police_department: '1f694', // 🚔
fire_department: '1f692', // 🚒
// Supermarkets
// Supermarkets & Grocery
supermarket: '1f6d2', // 🛒
grocery_store: '1f6d2',
convenience_store: '1f3ea', // 🏪
bakery: '1f35e', // 🍞
butcher: '1f969', // 🥩
greengrocer: '1f966', // 🥦
deli: '1f9c0', // 🧀
// Shopping
department_store: '1f3ec', // 🏬
clothing_store: '1f455', // 👕
shoe_store: '1f45f', // 👟
electronics_store: '1f4f1', // 📱
hardware_store: '1f527', // 🔧
furniture_store: '1fa91', // 🪑
bookshop: '1f4d6', // 📖
newsagent: '1f4f0', // 📰
charity_shop: '1f49c', // 💜
shopping_centre: '1f6cd', // 🛍
optician: '1f453', // 👓
off_licence: '1f37a', // 🍺
// Food & Drink
restaurant: '1f37d', // 🍽
cafe: '2615', // ☕
pub: '1f37b', // 🍻
bar: '1f378', // 🍸
fast_food: '1f354', // 🍔
food_court: '1f372', // 🍲
ice_cream: '1f366', // 🍦
beer_garden: '1f37a', // 🍺
// Personal Care
hairdresser: '1f487', // 💇
beauty_salon: '1f484', // 💄
laundry: '1f9fa', // 🧺
dry_cleaning: '1f455', // 👕
// Finance
bank: '1f3e6', // 🏦
atm: '1f4b3', // 💳
bureau_de_change: '1f4b1', // 💱
// Entertainment & Culture
cinema: '1f3ac', // 🎬
theatre: '1f3ad', // 🎭
nightclub: '1f483', // 💃
community_centre: '1f3db', // 🏛
arts_centre: '1f3a8', // 🎨
museum: '1f3db', // 🏛
gallery: '1f5bc', // 🖼
attraction: '2b50', // ⭐
zoo: '1f418', // 🐘
theme_park: '1f3a2', // 🎢
viewpoint: '1f301', // 🌁
// Accommodation
hotel: '1f3e8', // 🏨
hostel: '1f6cf', // 🛏
guest_house: '1f3e1', // 🏡
campsite: '26fa', // ⛺
caravan_site: '1f699', // 🚙
// Religion
place_of_worship: '1f6d0', // 🛐
// Government & Public
town_hall: '1f3db', // 🏛
courthouse: '2696', // ⚖
post_office: '1f4ee', // 📮
prison: '1f513', // 🔓
public_toilets: '1f6bb', // 🚻
// Automotive
petrol_station: '26fd', // ⛽
ev_charging: '1f50c', // 🔌
car_dealer: '1f697', // 🚗
car_repair: '1f527', // 🔧
parking: '1f17f', // 🅿
bicycle_parking: '1f6b2', // 🚲
// Recycling & Waste
recycling: '267b', // ♻
waste_disposal: '1f5d1', // 🗑
};
function getPOIIconUrl(category: string): string {
@ -57,29 +142,34 @@ function getPOIIconUrl(category: string): string {
// 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: '🏪',
school: '🏫', preschool: '👶', college_university: '🎓', library: '📚',
doctor: '🏥', dentist: '🦷', pharmacy: '💊', hospital: '🏥',
public_health_clinic: '🏥', veterinary: '🐾', nursing_home: '🏠', social_facility: '🤝',
train_station: '🚉', bus_station: '🚌', bus_stop: '🚏', metro_station: '🚇',
light_rail_station: '🚇', tram_stop: '🚊', ferry_terminal: '⛴️', airport: '✈️',
park: '🌳', national_park: '🏞️', nature_reserve: '🌿', dog_park: '🐕',
playground: '🎠', garden: '🌺', sports_centre: '🏃', swimming_pool: '🏊',
gym: '💪', golf_course: '⛳', marina: '⛵',
police_department: '🚔', fire_department: '🚒',
supermarket: '🛒', grocery_store: '🛒', convenience_store: '🏪',
bakery: '🍞', butcher: '🥩', greengrocer: '🥦', deli: '🧀',
department_store: '🏬', clothing_store: '👕', shoe_store: '👟',
electronics_store: '📱', hardware_store: '🔧', furniture_store: '🪑',
bookshop: '📖', newsagent: '📰', charity_shop: '💜', shopping_centre: '🛍️',
optician: '👓', off_licence: '🍺',
restaurant: '🍽️', cafe: '☕', pub: '🍻', bar: '🍸',
fast_food: '🍔', food_court: '🍲', ice_cream: '🍦', beer_garden: '🍺',
hairdresser: '💇', beauty_salon: '💄', laundry: '🧺', dry_cleaning: '👕',
bank: '🏦', atm: '💳', bureau_de_change: '💱',
cinema: '🎬', theatre: '🎭', nightclub: '💃', community_centre: '🏛️',
arts_centre: '🎨', museum: '🏛️', gallery: '🖼️', attraction: '⭐',
zoo: '🐘', theme_park: '🎢', viewpoint: '🌁',
hotel: '🏨', hostel: '🛏️', guest_house: '🏡', campsite: '⛺', caravan_site: '🚙',
place_of_worship: '🛐',
town_hall: '🏛️', courthouse: '⚖️', post_office: '📮', prison: '🔓', public_toilets: '🚻',
petrol_station: '⛽', ev_charging: '🔌', car_dealer: '🚗', car_repair: '🔧',
parking: '🅿️', bicycle_parking: '🚲',
recycling: '♻️', waste_disposal: '🗑️',
};
function getTooltipEmoji(category: string): string {
@ -158,7 +248,7 @@ function journeyTimeToColor(minutes: number | null | undefined): [number, number
}
function zoomToResolution(zoom: number): number {
if (zoom < 8.5) return 7;
if (zoom < 7) return 7;
if (zoom < 9.5) return 8;
if (zoom < 11) return 9;
if (zoom < 13) return 10;
@ -209,6 +299,22 @@ interface Dimensions {
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<HexagonData> | IconLayer<POI>)[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getTooltip: any;
}) {
const overlay = useControl(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers, getTooltip });
return null;
}
export default function Map({ data, pois, onViewChange, colorMode }: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(INITIAL_VIEW);
@ -240,12 +346,23 @@ export default function Map({ data, pois, onViewChange, colorMode }: MapProps) {
onViewChange({ resolution, bounds, zoom: viewState.zoom });
}, [viewState, dimensions, onViewChange]);
const handleViewStateChange = useCallback((params: { viewState: unknown }) => {
const newViewState = params.viewState as ViewState;
setViewState(newViewState);
const handleMove = useCallback((evt: { viewState: ViewState }) => {
setViewState(evt.viewState);
}, []);
// Popup state for POI hover (using screen coordinates)
// 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;
// Stronger white halo so text pops over hex fills
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;
@ -283,6 +400,9 @@ export default function Map({ data, pois, onViewChange, colorMode }: MapProps) {
pickable: true,
opacity: 0.5,
highPrecision: true,
// Render below labels so road names, place names etc. stay visible
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: LABEL_LAYER_ID,
}),
new IconLayer<POI>({
id: 'poi-icons',
@ -303,7 +423,6 @@ export default function Map({ data, pois, onViewChange, colorMode }: MapProps) {
[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;
@ -339,15 +458,15 @@ export default function Map({ data, pois, onViewChange, colorMode }: MapProps) {
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
{...viewState}
onMove={handleMove}
onLoad={handleMapLoad as never}
mapStyle={MAP_STYLE}
style={{ width: '100%', height: '100%' }}
>
<MapGL mapStyle={MAP_STYLE} />
</DeckGL>
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
</MapGL>
{popupInfo && (
<div
className="absolute pointer-events-none bg-white rounded shadow-lg p-2 text-sm"

View file

@ -57,13 +57,9 @@ export interface POIResponse {
features: POI[];
}
export const POI_CATEGORY_GROUPS = [
'schools',
'healthcare',
'transport',
'parks',
'emergency',
'supermarkets',
] as const;
export interface POICategoryInfo {
emoji: string;
label: string;
}
export type POICategoryGroup = (typeof POI_CATEGORY_GROUPS)[number];
export type POICategoriesMap = Record<string, POICategoryInfo>;