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,9 +1,5 @@
"""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
@ -13,36 +9,190 @@ 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"],
# Category groups with emoji and member categories
POI_CATEGORY_GROUPS: dict[str, dict] = {
"schools": {
"emoji": "🏫",
"label": "Schools",
"categories": ["school", "preschool", "college_university", "library"],
},
"healthcare": {
"emoji": "🏥",
"label": "Healthcare",
"categories": [
"doctor",
"dentist",
"pharmacy",
"hospital",
"public_health_clinic",
"veterinary",
"nursing_home",
"social_facility",
],
},
"transport": {
"emoji": "🚉",
"label": "Transport",
"categories": [
"train_station",
"bus_station",
"bus_stop",
"metro_station",
"light_rail_station",
"tram_stop",
"ferry_terminal",
"airport",
],
},
"parks": {
"emoji": "🌳",
"label": "Parks & Leisure",
"categories": [
"park",
"national_park",
"nature_reserve",
"dog_park",
"playground",
"garden",
"sports_centre",
"swimming_pool",
"gym",
"golf_course",
"marina",
],
},
"emergency": {
"emoji": "🚨",
"label": "Emergency",
"categories": ["police_department", "fire_department"],
},
"supermarkets": {
"emoji": "🛒",
"label": "Supermarkets & Grocery",
"categories": [
"supermarket",
"grocery_store",
"convenience_store",
"bakery",
"butcher",
"greengrocer",
"deli",
],
},
"shopping": {
"emoji": "🛍️",
"label": "Shopping",
"categories": [
"department_store",
"clothing_store",
"shoe_store",
"electronics_store",
"hardware_store",
"furniture_store",
"bookshop",
"newsagent",
"charity_shop",
"shopping_centre",
"optician",
"off_licence",
],
},
"food_drink": {
"emoji": "🍽️",
"label": "Food & Drink",
"categories": [
"restaurant",
"cafe",
"pub",
"bar",
"fast_food",
"food_court",
"ice_cream",
"beer_garden",
],
},
"personal_care": {
"emoji": "💇",
"label": "Personal Care",
"categories": [
"hairdresser",
"beauty_salon",
"laundry",
"dry_cleaning",
],
},
"finance": {
"emoji": "🏦",
"label": "Finance",
"categories": ["bank", "atm", "bureau_de_change"],
},
"entertainment": {
"emoji": "🎭",
"label": "Entertainment & Culture",
"categories": [
"cinema",
"theatre",
"nightclub",
"community_centre",
"arts_centre",
"museum",
"gallery",
"attraction",
"zoo",
"theme_park",
"viewpoint",
],
},
"accommodation": {
"emoji": "🏨",
"label": "Accommodation",
"categories": [
"hotel",
"hostel",
"guest_house",
"campsite",
"caravan_site",
],
},
"religion": {
"emoji": "🛐",
"label": "Places of Worship",
"categories": ["place_of_worship"],
},
"government": {
"emoji": "🏛️",
"label": "Government & Public",
"categories": [
"town_hall",
"courthouse",
"post_office",
"prison",
"public_toilets",
],
},
"automotive": {
"emoji": "",
"label": "Automotive",
"categories": [
"petrol_station",
"ev_charging",
"car_dealer",
"car_repair",
"parking",
"bicycle_parking",
],
},
"recycling": {
"emoji": "♻️",
"label": "Recycling & Waste",
"categories": ["recycling", "waste_disposal"],
},
}
# Flatten for quick lookup
ALL_CATEGORIES = {cat for cats in POI_CATEGORIES.values() for cat in cats}
ALL_CATEGORIES = {
cat for group in POI_CATEGORY_GROUPS.values() for cat in group["categories"]
}
# Cache the dataframe
_df_cache: pl.DataFrame | None = None
@ -55,14 +205,9 @@ def get_df() -> pl.DataFrame | 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))
_df_cache = df.select("id", "name", "category", "lat", "lng").filter(
pl.col("category").is_in(ALL_CATEGORIES)
)
return _df_cache
@ -83,23 +228,20 @@ async def get_pois(
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 group in POI_CATEGORY_GROUPS:
cats_to_include.update(POI_CATEGORY_GROUPS[group]["categories"])
if not cats_to_include:
return {"features": []}
# Filter by bounds and categories
filtered = df.filter(
(pl.col("lat") >= south)
& (pl.col("lat") <= north)
@ -108,7 +250,6 @@ async def get_pois(
& (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)
@ -118,5 +259,10 @@ async def get_pois(
@router.get("/poi-categories")
async def get_poi_categories() -> dict:
"""Get available POI category groups."""
return {"categories": list(POI_CATEGORIES.keys())}
"""Get available POI category groups with emoji and labels."""
return {
"categories": {
key: {"emoji": group["emoji"], "label": group["label"]}
for key, group in POI_CATEGORY_GROUPS.items()
}
}