Improve map

This commit is contained in:
Andras Schmelczer 2026-01-25 21:39:13 +00:00
parent ced6b16140
commit a2e4c29839
10 changed files with 285 additions and 111 deletions

View file

@ -10,7 +10,7 @@ app = FastAPI(title="Property Map API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_credentials=False, # Cannot use True with wildcard origins
allow_methods=["*"],
allow_headers=["*"],
)

View file

@ -1,5 +1,5 @@
from typing import Any
from fastapi import APIRouter, Query
from fastapi import APIRouter, Query, HTTPException
import polars as pl
import h3
@ -16,19 +16,41 @@ from server.config import (
router = APIRouter()
def h3_to_geojson_feature(h3_index: str, properties: dict[str, Any]) -> dict:
"""Convert H3 index to GeoJSON feature with polygon geometry."""
boundary = h3.cell_to_boundary(h3_index)
# h3 returns (lat, lng) pairs, GeoJSON needs [lng, lat]
coordinates = [[lng, lat] for lat, lng in boundary]
# Close the polygon
coordinates.append(coordinates[0])
def get_h3_cells_for_bounds(
south: float, west: float, north: float, east: float, resolution: int
) -> set[str] | None:
"""Get all H3 cells that cover a bounding box. Returns None if area too large."""
# Clamp to valid ranges
south = max(-85, min(85, south))
north = max(-85, min(85, north))
west = max(-180, min(180, west))
east = max(-180, min(180, east))
return {
"type": "Feature",
"properties": {"h3": h3_index, **properties},
"geometry": {"type": "Polygon", "coordinates": [coordinates]},
}
# Ensure valid bounds
if south >= north or west >= east:
return set()
# If viewport is too large, return None to skip filtering
# This prevents H3 from trying to enumerate millions of cells
lat_span = north - south
lng_span = east - west
if lat_span > 20 or lng_span > 30:
return None
# Create polygon from bounds (counter-clockwise winding for H3/GeoJSON)
# Order: SW -> NW -> NE -> SE -> SW
polygon = [
(south, west),
(north, west),
(north, east),
(south, east),
(south, west),
]
try:
return h3.polygon_to_cells(h3.LatLngPoly(polygon), resolution)
except Exception:
return None
@router.get("/hexagons")
@ -44,20 +66,41 @@ async def get_hexagons(
min_price: float = Query(DEFAULT_MIN_PRICE, description="Minimum average price"),
max_price: float = Query(DEFAULT_MAX_PRICE, description="Maximum average price"),
bounds: str | None = Query(
None, description="Bounding box: lat1,lng1,lat2,lng2"
None, description="Bounding box: south,west,north,east"
),
) -> dict:
"""Get aggregated property data as GeoJSON hexagons."""
"""Get aggregated property data as GeoJSON hexagons within bounds."""
if resolution not in VALID_RESOLUTIONS:
resolution = DEFAULT_RESOLUTION
# Bounds are required for efficient queries
if not bounds:
raise HTTPException(status_code=400, detail="bounds parameter is required")
# Parse bounds
try:
south, west, north, east = map(float, bounds.split(","))
except ValueError:
raise HTTPException(
status_code=400, detail="Invalid bounds format. Use: south,west,north,east"
)
# Load the appropriate resolution file
parquet_path = AGGREGATES_DIR / f"res{resolution}.parquet"
if not parquet_path.exists():
return {"type": "FeatureCollection", "features": []}
return {"features": []}
df = pl.scan_parquet(parquet_path)
# Get H3 cells that cover the viewport (None if too large to enumerate)
viewport_cells = get_h3_cells_for_bounds(south, west, north, east, resolution)
# Filter to only cells in viewport (skip if viewport too large)
if viewport_cells is not None:
if len(viewport_cells) == 0:
return {"features": []}
df = df.filter(pl.col("h3").is_in(viewport_cells))
# Filter by year range
df = df.filter((pl.col("year") >= min_year) & (pl.col("year") <= max_year))
@ -80,19 +123,27 @@ async def get_hexagons(
(pl.col("avg_price") >= min_price) & (pl.col("avg_price") <= max_price)
)
# Collect and convert to GeoJSON
# Limit results to prevent browser crashes
MAX_HEXAGONS = 50000
df = df.limit(MAX_HEXAGONS)
# Collect results
result = df.collect()
features = []
for row in result.iter_rows(named=True):
h3_index = row["h3"]
properties = {
# Return lightweight response - just h3 index and properties
# Frontend H3HexagonLayer will render the geometry
# Use to_dicts() which is faster than iter_rows for large results
rows = result.to_dicts()
features = [
{
"h3": row["h3"],
"count": row["count"],
"avg_price": round(row["avg_price"], 2),
"median_price": round(row["median_price"], 2) if row["median_price"] else None,
"min_price": row["min_price"],
"max_price": row["max_price"],
}
features.append(h3_to_geojson_feature(h3_index, properties))
for row in rows
]
return {"type": "FeatureCollection", "features": features}
return {"features": features, "truncated": len(rows) >= MAX_HEXAGONS}