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
|
|
@ -77,14 +77,28 @@ def query_hexagons_cached(
|
|||
# Filter by year range
|
||||
df = df.filter((pl.col("year") >= min_year) & (pl.col("year") <= max_year))
|
||||
|
||||
# Check which journey time columns exist
|
||||
journey_cols = [
|
||||
"median_journey_minutes",
|
||||
"median_pt_easy_minutes",
|
||||
"median_pt_quick_minutes",
|
||||
"median_cycling_minutes",
|
||||
]
|
||||
available_journey_cols = [c for c in journey_cols if c in df.columns]
|
||||
|
||||
# Aggregate across years (weighted by count)
|
||||
df = df.group_by("h3").agg(
|
||||
agg_exprs = [
|
||||
pl.col("count").sum().alias("count"),
|
||||
(pl.col("avg_price") * pl.col("count")).sum().alias("weighted_price_sum"),
|
||||
pl.col("median_price").median().alias("median_price"),
|
||||
pl.col("min_price").min().alias("min_price"),
|
||||
pl.col("max_price").max().alias("max_price"),
|
||||
)
|
||||
]
|
||||
for jc in available_journey_cols:
|
||||
# Journey time is same across years, just take first non-null
|
||||
agg_exprs.append(pl.col(jc).first())
|
||||
|
||||
df = df.group_by("h3").agg(agg_exprs)
|
||||
|
||||
# Calculate weighted average price
|
||||
df = df.with_columns(
|
||||
|
|
@ -97,16 +111,18 @@ def query_hexagons_cached(
|
|||
)
|
||||
|
||||
# Build response efficiently using Polars
|
||||
df = df.select(
|
||||
[
|
||||
pl.col("h3"),
|
||||
pl.col("count"),
|
||||
pl.col("avg_price").round(2),
|
||||
pl.col("median_price").round(2),
|
||||
pl.col("min_price"),
|
||||
pl.col("max_price"),
|
||||
]
|
||||
)
|
||||
select_cols = [
|
||||
pl.col("h3"),
|
||||
pl.col("count"),
|
||||
pl.col("avg_price").round(2),
|
||||
pl.col("median_price").round(2),
|
||||
pl.col("min_price"),
|
||||
pl.col("max_price"),
|
||||
]
|
||||
for jc in available_journey_cols:
|
||||
select_cols.append(pl.col(jc).round(0))
|
||||
|
||||
df = df.select(select_cols)
|
||||
|
||||
return df.to_dicts()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue