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

@ -8,6 +8,9 @@ import type {
HexagonData,
ViewChangeParams,
ApiResponse,
POI,
POIResponse,
POICategoryGroup,
} from './types';
const DEBOUNCE_MS = 150;
@ -47,6 +50,12 @@ export default function App() {
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
// POI state
const [pois, setPois] = useState<POI[]>([]);
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<POICategoryGroup>>(new Set());
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const poiAbortControllerRef = useRef<AbortController | null>(null);
// Debounced fetch when dependencies change
useEffect(() => {
if (!bounds) return;
@ -95,6 +104,49 @@ export default function App() {
};
}, [filters, resolution, bounds]);
// Fetch POIs when bounds or selected categories change
useEffect(() => {
if (!bounds || selectedPOICategories.size === 0) {
setPois([]);
return;
}
if (poiDebounceRef.current) {
clearTimeout(poiDebounceRef.current);
}
poiDebounceRef.current = setTimeout(async () => {
if (poiAbortControllerRef.current) {
poiAbortControllerRef.current.abort();
}
poiAbortControllerRef.current = new AbortController();
try {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const categoriesStr = Array.from(selectedPOICategories).join(',');
const params = new URLSearchParams({
categories: categoriesStr,
bounds: boundsStr,
});
const res = await fetch(`${getApiBaseUrl()}/api/pois?${params}`, {
signal: poiAbortControllerRef.current.signal,
});
const json: POIResponse = await res.json();
setPois(json.features || []);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Failed to fetch POIs:', err);
}
}
}, DEBOUNCE_MS);
return () => {
if (poiDebounceRef.current) {
clearTimeout(poiDebounceRef.current);
}
};
}, [bounds, selectedPOICategories]);
const handleViewChange = useCallback(
({ resolution: newRes, bounds: newBounds, zoom: newZoom }: ViewChangeParams) => {
setResolution(newRes);
@ -106,9 +158,15 @@ export default function App() {
return (
<div className="h-screen flex">
<Filters filters={filters} onChange={setFilters} zoom={zoom} />
<Filters
filters={filters}
onChange={setFilters}
zoom={zoom}
selectedPOICategories={selectedPOICategories}
onPOICategoriesChange={setSelectedPOICategories}
/>
<div className="flex-1 relative">
<Map data={data} onViewChange={handleViewChange} />
<Map data={data} pois={pois} onViewChange={handleViewChange} />
{loading && (
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">Loading...</div>
)}

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

View file

@ -38,3 +38,26 @@ export interface ViewChangeParams {
export interface ApiResponse {
features: HexagonData[];
}
export interface POI {
id: string;
name: string;
category: string;
lat: number;
lng: number;
}
export interface POIResponse {
features: POI[];
}
export const POI_CATEGORY_GROUPS = [
'schools',
'healthcare',
'transport',
'parks',
'emergency',
'supermarkets',
] as const;
export type POICategoryGroup = (typeof POI_CATEGORY_GROUPS)[number];

View file

@ -4,13 +4,14 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from server.routes import hexagons
from server.routes import hexagons, pois
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: preload all parquet files
hexagons.preload_dataframes()
pois.preload_pois()
yield
# Shutdown: nothing to clean up
@ -26,6 +27,7 @@ app.add_middleware(
)
app.include_router(hexagons.router, prefix="/api")
app.include_router(pois.router, prefix="/api")
# Mount static files for production (frontend build)
frontend_dist = Path(__file__).parent.parent / "frontend" / "dist"

122
server/routes/pois.py Normal file
View file

@ -0,0 +1,122 @@
"""POI (Points of Interest) API endpoint."""
import os
os.environ["POLARS_UNKNOWN_EXTENSION_TYPE_BEHAVIOR"] = "load_as_storage"
from pathlib import Path
from fastapi import APIRouter, Query
import polars as pl
router = APIRouter()
DATA_FILE = Path("data_sources/uk_pois.parquet")
# Categories useful for property buyers
POI_CATEGORIES = {
"schools": [
"elementary_school",
"school",
"high_school",
"preschool",
"college_university",
"private_school",
],
"healthcare": [
"doctor",
"dentist",
"pharmacy",
"hospital",
"public_health_clinic",
],
"transport": [
"train_station",
"bus_station",
"metro_station",
"light_rail_and_subway_stations",
],
"parks": ["park", "national_park", "dog_park"],
"emergency": ["police_department", "fire_department"],
"supermarkets": ["supermarket", "grocery_store", "convenience_store"],
}
# Flatten for quick lookup
ALL_CATEGORIES = {cat for cats in POI_CATEGORIES.values() for cat in cats}
# Cache the dataframe
_df_cache: pl.DataFrame | None = None
def get_df() -> pl.DataFrame | None:
"""Load and cache the POI dataframe."""
global _df_cache
if _df_cache is None:
if not DATA_FILE.exists():
return None
df = pl.read_parquet(DATA_FILE)
# Extract fields we need and filter to relevant categories
_df_cache = df.select(
pl.col("id"),
pl.col("names").struct.field("primary").alias("name"),
pl.col("categories").struct.field("primary").alias("category"),
pl.col("bbox").struct.field("xmin").alias("lng"),
pl.col("bbox").struct.field("ymin").alias("lat"),
).filter(pl.col("category").is_in(ALL_CATEGORIES))
return _df_cache
def preload_pois() -> None:
"""Preload POI data on startup."""
df = get_df()
if df is not None:
print(f"Loaded {len(df):,} POIs")
@router.get("/pois")
async def get_pois(
categories: str = Query(..., description="Comma-separated category groups"),
bounds: str = Query(..., description="Bounding box: south,west,north,east"),
) -> dict:
"""Get POIs within bounds for specified category groups."""
df = get_df()
if df is None:
return {"features": []}
# Parse bounds
try:
south, west, north, east = map(float, bounds.split(","))
except ValueError:
return {"features": []}
# Get categories to include
requested_groups = [g.strip() for g in categories.split(",")]
cats_to_include = set()
for group in requested_groups:
if group in POI_CATEGORIES:
cats_to_include.update(POI_CATEGORIES[group])
if not cats_to_include:
return {"features": []}
# Filter by bounds and categories
filtered = df.filter(
(pl.col("lat") >= south)
& (pl.col("lat") <= north)
& (pl.col("lng") >= west)
& (pl.col("lng") <= east)
& (pl.col("category").is_in(cats_to_include))
)
# Limit results to avoid overwhelming the frontend
MAX_POIS = 5000
if len(filtered) > MAX_POIS:
filtered = filtered.sample(n=MAX_POIS, seed=42)
return {"features": filtered.to_dicts()}
@router.get("/poi-categories")
async def get_poi_categories() -> dict:
"""Get available POI category groups."""
return {"categories": list(POI_CATEGORIES.keys())}