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

@ -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"