Add POIs and journey times to map
This commit is contained in:
parent
7bfb1729bf
commit
500b9ef2aa
11 changed files with 914 additions and 177 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue