feat: Complete Phase 5 dashboard implementation
Implement full 5-tab Toronto Neighbourhood Dashboard with real data connectivity: Dashboard Structure: - Overview tab with livability scores and rankings - Housing tab with affordability metrics - Safety tab with crime statistics - Demographics tab with population/income data - Amenities tab with parks, schools, transit Figure Factories (portfolio_app/figures/): - bar_charts.py: ranking, stacked, horizontal bars - scatter.py: scatter plots, bubble charts - radar.py: spider/radar charts - demographics.py: donut, age pyramid, income distribution Service Layer (portfolio_app/toronto/services/): - neighbourhood_service.py: queries dbt marts for all tab data - geometry_service.py: generates GeoJSON from PostGIS - Graceful error handling when database unavailable Callbacks (portfolio_app/pages/toronto/callbacks/): - map_callbacks.py: choropleth updates, map click handling - chart_callbacks.py: supporting chart updates - selection_callbacks.py: dropdown handlers, KPI updates Data Pipeline (scripts/data/): - load_toronto_data.py: orchestration script with CLI flags Lessons Learned: - Graceful error handling in service layers - Modular callback structure for multi-tab dashboards - Figure factory pattern for reusable charts Closes: #64, #65, #66, #67, #68, #69, #70 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
176
portfolio_app/toronto/services/geometry_service.py
Normal file
176
portfolio_app/toronto/services/geometry_service.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Service layer for generating GeoJSON from PostGIS geometry."""
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
from sqlalchemy import text
|
||||
|
||||
from portfolio_app.toronto.models import get_engine
|
||||
|
||||
|
||||
def _execute_query(sql: str, params: dict[str, Any] | None = None) -> pd.DataFrame:
|
||||
"""Execute SQL query and return DataFrame."""
|
||||
engine = get_engine()
|
||||
with engine.connect() as conn:
|
||||
return pd.read_sql(text(sql), conn, params=params)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def get_neighbourhoods_geojson(year: int = 2021) -> dict[str, Any]:
|
||||
"""Get GeoJSON FeatureCollection for all neighbourhoods.
|
||||
|
||||
Queries mart_neighbourhood_overview for geometries and basic properties.
|
||||
|
||||
Args:
|
||||
year: Year to query for joining properties.
|
||||
|
||||
Returns:
|
||||
GeoJSON FeatureCollection dictionary.
|
||||
"""
|
||||
# Query geometries with ST_AsGeoJSON
|
||||
sql = """
|
||||
SELECT
|
||||
neighbourhood_id,
|
||||
neighbourhood_name,
|
||||
ST_AsGeoJSON(geometry)::json as geom,
|
||||
population,
|
||||
livability_score
|
||||
FROM mart_neighbourhood_overview
|
||||
WHERE year = :year
|
||||
AND geometry IS NOT NULL
|
||||
"""
|
||||
|
||||
try:
|
||||
df = _execute_query(sql, {"year": year})
|
||||
except Exception:
|
||||
# Table might not exist or have data yet
|
||||
return _empty_geojson()
|
||||
|
||||
if df.empty:
|
||||
return _empty_geojson()
|
||||
|
||||
# Build GeoJSON features
|
||||
features = []
|
||||
for _, row in df.iterrows():
|
||||
geom = row["geom"]
|
||||
if geom is None:
|
||||
continue
|
||||
|
||||
# Handle geometry that might be a string or dict
|
||||
if isinstance(geom, str):
|
||||
geom = json.loads(geom)
|
||||
|
||||
feature = {
|
||||
"type": "Feature",
|
||||
"id": row["neighbourhood_id"],
|
||||
"properties": {
|
||||
"neighbourhood_id": int(row["neighbourhood_id"]),
|
||||
"neighbourhood_name": row["neighbourhood_name"],
|
||||
"population": int(row["population"])
|
||||
if pd.notna(row["population"])
|
||||
else None,
|
||||
"livability_score": float(row["livability_score"])
|
||||
if pd.notna(row["livability_score"])
|
||||
else None,
|
||||
},
|
||||
"geometry": geom,
|
||||
}
|
||||
features.append(feature)
|
||||
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": features,
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def get_cmhc_zones_geojson() -> dict[str, Any]:
|
||||
"""Get GeoJSON FeatureCollection for CMHC zones.
|
||||
|
||||
Queries dim_cmhc_zone for zone geometries.
|
||||
|
||||
Returns:
|
||||
GeoJSON FeatureCollection dictionary.
|
||||
"""
|
||||
sql = """
|
||||
SELECT
|
||||
zone_code,
|
||||
zone_name,
|
||||
ST_AsGeoJSON(geometry)::json as geom
|
||||
FROM dim_cmhc_zone
|
||||
WHERE geometry IS NOT NULL
|
||||
"""
|
||||
|
||||
try:
|
||||
df = _execute_query(sql, {})
|
||||
except Exception:
|
||||
return _empty_geojson()
|
||||
|
||||
if df.empty:
|
||||
return _empty_geojson()
|
||||
|
||||
features = []
|
||||
for _, row in df.iterrows():
|
||||
geom = row["geom"]
|
||||
if geom is None:
|
||||
continue
|
||||
|
||||
if isinstance(geom, str):
|
||||
geom = json.loads(geom)
|
||||
|
||||
feature = {
|
||||
"type": "Feature",
|
||||
"id": row["zone_code"],
|
||||
"properties": {
|
||||
"zone_code": row["zone_code"],
|
||||
"zone_name": row["zone_name"],
|
||||
},
|
||||
"geometry": geom,
|
||||
}
|
||||
features.append(feature)
|
||||
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": features,
|
||||
}
|
||||
|
||||
|
||||
def get_neighbourhood_geometry(neighbourhood_id: int) -> dict[str, Any] | None:
|
||||
"""Get GeoJSON geometry for a single neighbourhood.
|
||||
|
||||
Args:
|
||||
neighbourhood_id: The neighbourhood ID.
|
||||
|
||||
Returns:
|
||||
GeoJSON geometry dict, or None if not found.
|
||||
"""
|
||||
sql = """
|
||||
SELECT ST_AsGeoJSON(geometry)::json as geom
|
||||
FROM dim_neighbourhood
|
||||
WHERE neighbourhood_id = :neighbourhood_id
|
||||
AND geometry IS NOT NULL
|
||||
"""
|
||||
|
||||
try:
|
||||
df = _execute_query(sql, {"neighbourhood_id": neighbourhood_id})
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if df.empty:
|
||||
return None
|
||||
|
||||
geom = df.iloc[0]["geom"]
|
||||
if isinstance(geom, str):
|
||||
result: dict[str, Any] = json.loads(geom)
|
||||
return result
|
||||
return dict(geom) if geom is not None else None
|
||||
|
||||
|
||||
def _empty_geojson() -> dict[str, Any]:
|
||||
"""Return an empty GeoJSON FeatureCollection."""
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": [],
|
||||
}
|
||||
Reference in New Issue
Block a user