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:
@@ -1,62 +1,56 @@
|
||||
"""Toronto Housing Dashboard page."""
|
||||
"""Toronto Neighbourhood Dashboard page.
|
||||
|
||||
Displays neighbourhood-level data across 5 tabs: Overview, Housing, Safety,
|
||||
Demographics, and Amenities. Each tab provides interactive choropleth maps,
|
||||
KPI cards, and supporting charts.
|
||||
"""
|
||||
|
||||
import dash
|
||||
import dash_mantine_components as dmc
|
||||
from dash import dcc, html
|
||||
from dash import dcc
|
||||
from dash_iconify import DashIconify
|
||||
|
||||
from portfolio_app.components import (
|
||||
create_map_controls,
|
||||
create_metric_cards_row,
|
||||
create_time_slider,
|
||||
create_year_selector,
|
||||
from portfolio_app.pages.toronto.tabs import (
|
||||
create_amenities_tab,
|
||||
create_demographics_tab,
|
||||
create_housing_tab,
|
||||
create_overview_tab,
|
||||
create_safety_tab,
|
||||
)
|
||||
|
||||
dash.register_page(__name__, path="/toronto", name="Toronto Housing")
|
||||
dash.register_page(__name__, path="/toronto", name="Toronto Neighbourhoods")
|
||||
|
||||
# Metric options for the purchase market
|
||||
PURCHASE_METRIC_OPTIONS = [
|
||||
{"label": "Average Price", "value": "avg_price"},
|
||||
{"label": "Median Price", "value": "median_price"},
|
||||
{"label": "Sales Volume", "value": "sales_count"},
|
||||
{"label": "Days on Market", "value": "avg_dom"},
|
||||
]
|
||||
|
||||
# Metric options for the rental market
|
||||
RENTAL_METRIC_OPTIONS = [
|
||||
{"label": "Average Rent", "value": "avg_rent"},
|
||||
{"label": "Vacancy Rate", "value": "vacancy_rate"},
|
||||
{"label": "Rental Universe", "value": "rental_universe"},
|
||||
]
|
||||
|
||||
# Sample metrics for KPI cards (will be populated by callbacks)
|
||||
SAMPLE_METRICS = [
|
||||
# Tab configuration
|
||||
TAB_CONFIG = [
|
||||
{
|
||||
"title": "Avg. Price",
|
||||
"value": 1125000,
|
||||
"delta": 2.3,
|
||||
"prefix": "$",
|
||||
"format_spec": ",.0f",
|
||||
"value": "overview",
|
||||
"label": "Overview",
|
||||
"icon": "tabler:chart-pie",
|
||||
"color": "blue",
|
||||
},
|
||||
{
|
||||
"title": "Sales Volume",
|
||||
"value": 4850,
|
||||
"delta": -5.1,
|
||||
"format_spec": ",",
|
||||
"value": "housing",
|
||||
"label": "Housing",
|
||||
"icon": "tabler:home",
|
||||
"color": "teal",
|
||||
},
|
||||
{
|
||||
"title": "Avg. DOM",
|
||||
"value": 18,
|
||||
"delta": 3,
|
||||
"suffix": " days",
|
||||
"positive_is_good": False,
|
||||
"value": "safety",
|
||||
"label": "Safety",
|
||||
"icon": "tabler:shield-check",
|
||||
"color": "orange",
|
||||
},
|
||||
{
|
||||
"title": "Avg. Rent",
|
||||
"value": 2450,
|
||||
"delta": 4.2,
|
||||
"prefix": "$",
|
||||
"format_spec": ",.0f",
|
||||
"value": "demographics",
|
||||
"label": "Demographics",
|
||||
"icon": "tabler:users",
|
||||
"color": "violet",
|
||||
},
|
||||
{
|
||||
"value": "amenities",
|
||||
"label": "Amenities",
|
||||
"icon": "tabler:trees",
|
||||
"color": "green",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -67,9 +61,9 @@ def create_header() -> dmc.Group:
|
||||
[
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Title("Toronto Housing Dashboard", order=1),
|
||||
dmc.Title("Toronto Neighbourhood Dashboard", order=1),
|
||||
dmc.Text(
|
||||
"Real estate market analysis for the Greater Toronto Area",
|
||||
"Explore livability across 158 Toronto neighbourhoods",
|
||||
c="dimmed",
|
||||
),
|
||||
],
|
||||
@@ -88,11 +82,17 @@ def create_header() -> dmc.Group:
|
||||
),
|
||||
href="/toronto/methodology",
|
||||
),
|
||||
create_year_selector(
|
||||
id_prefix="toronto",
|
||||
min_year=2020,
|
||||
default_year=2024,
|
||||
label="Year",
|
||||
dmc.Select(
|
||||
id="toronto-year-select",
|
||||
data=[
|
||||
{"value": "2021", "label": "2021"},
|
||||
{"value": "2022", "label": "2022"},
|
||||
{"value": "2023", "label": "2023"},
|
||||
],
|
||||
value="2021",
|
||||
label="Census Year",
|
||||
size="sm",
|
||||
w=120,
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
@@ -103,187 +103,100 @@ def create_header() -> dmc.Group:
|
||||
)
|
||||
|
||||
|
||||
def create_kpi_section() -> dmc.Box:
|
||||
"""Create the KPI metrics row."""
|
||||
return dmc.Box(
|
||||
children=[
|
||||
dmc.Title("Key Metrics", order=3, size="h4", mb="sm"),
|
||||
html.Div(
|
||||
id="toronto-kpi-cards",
|
||||
children=[
|
||||
create_metric_cards_row(SAMPLE_METRICS, id_prefix="toronto-kpi")
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def create_purchase_map_section() -> dmc.Grid:
|
||||
"""Create the purchase market choropleth section."""
|
||||
return dmc.Grid(
|
||||
[
|
||||
dmc.GridCol(
|
||||
create_map_controls(
|
||||
id_prefix="purchase-map",
|
||||
metric_options=PURCHASE_METRIC_OPTIONS,
|
||||
default_metric="avg_price",
|
||||
),
|
||||
span={"base": 12, "md": 3},
|
||||
),
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
children=[
|
||||
dcc.Graph(
|
||||
id="purchase-choropleth",
|
||||
config={"scrollZoom": True},
|
||||
style={"height": "500px"},
|
||||
),
|
||||
],
|
||||
p="xs",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "md": 9},
|
||||
),
|
||||
],
|
||||
gutter="md",
|
||||
)
|
||||
|
||||
|
||||
def create_rental_map_section() -> dmc.Grid:
|
||||
"""Create the rental market choropleth section."""
|
||||
return dmc.Grid(
|
||||
[
|
||||
dmc.GridCol(
|
||||
create_map_controls(
|
||||
id_prefix="rental-map",
|
||||
metric_options=RENTAL_METRIC_OPTIONS,
|
||||
default_metric="avg_rent",
|
||||
),
|
||||
span={"base": 12, "md": 3},
|
||||
),
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
children=[
|
||||
dcc.Graph(
|
||||
id="rental-choropleth",
|
||||
config={"scrollZoom": True},
|
||||
style={"height": "500px"},
|
||||
),
|
||||
],
|
||||
p="xs",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "md": 9},
|
||||
),
|
||||
],
|
||||
gutter="md",
|
||||
)
|
||||
|
||||
|
||||
def create_time_series_section() -> dmc.Grid:
|
||||
"""Create the time series charts section."""
|
||||
return dmc.Grid(
|
||||
[
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
children=[
|
||||
dmc.Title("Price Trends", order=4, size="h5", mb="sm"),
|
||||
dcc.Graph(
|
||||
id="price-time-series",
|
||||
config={"displayModeBar": False},
|
||||
style={"height": "350px"},
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "md": 6},
|
||||
),
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
children=[
|
||||
dmc.Title("Sales Volume", order=4, size="h5", mb="sm"),
|
||||
dcc.Graph(
|
||||
id="volume-time-series",
|
||||
config={"displayModeBar": False},
|
||||
style={"height": "350px"},
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "md": 6},
|
||||
),
|
||||
],
|
||||
gutter="md",
|
||||
)
|
||||
|
||||
|
||||
def create_market_comparison_section() -> dmc.Paper:
|
||||
"""Create the market comparison chart section."""
|
||||
def create_neighbourhood_selector() -> dmc.Paper:
|
||||
"""Create the neighbourhood search/select component."""
|
||||
return dmc.Paper(
|
||||
children=[
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Title("Market Indicators", order=4, size="h5"),
|
||||
create_time_slider(
|
||||
id_prefix="market-comparison",
|
||||
min_year=2020,
|
||||
label="",
|
||||
),
|
||||
],
|
||||
justify="space-between",
|
||||
align="center",
|
||||
mb="md",
|
||||
),
|
||||
dcc.Graph(
|
||||
id="market-comparison-chart",
|
||||
config={"displayModeBar": False},
|
||||
style={"height": "400px"},
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
dmc.Group(
|
||||
[
|
||||
DashIconify(icon="tabler:search", width=20, color="gray"),
|
||||
dmc.Select(
|
||||
id="toronto-neighbourhood-select",
|
||||
placeholder="Search neighbourhoods...",
|
||||
searchable=True,
|
||||
clearable=True,
|
||||
data=[], # Populated by callback
|
||||
style={"flex": 1},
|
||||
),
|
||||
dmc.Button(
|
||||
"Compare",
|
||||
id="toronto-compare-btn",
|
||||
leftSection=DashIconify(icon="tabler:git-compare", width=16),
|
||||
variant="light",
|
||||
disabled=True,
|
||||
),
|
||||
],
|
||||
gap="sm",
|
||||
),
|
||||
p="sm",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
)
|
||||
|
||||
|
||||
def create_tab_navigation() -> dmc.Tabs:
|
||||
"""Create the tab navigation with icons."""
|
||||
return dmc.Tabs(
|
||||
[
|
||||
dmc.TabsList(
|
||||
[
|
||||
dmc.TabsTab(
|
||||
dmc.Group(
|
||||
[
|
||||
DashIconify(icon=tab["icon"], width=18),
|
||||
dmc.Text(tab["label"], size="sm"),
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
value=tab["value"],
|
||||
)
|
||||
for tab in TAB_CONFIG
|
||||
],
|
||||
grow=True,
|
||||
),
|
||||
# Tab panels
|
||||
dmc.TabsPanel(create_overview_tab(), value="overview", pt="md"),
|
||||
dmc.TabsPanel(create_housing_tab(), value="housing", pt="md"),
|
||||
dmc.TabsPanel(create_safety_tab(), value="safety", pt="md"),
|
||||
dmc.TabsPanel(create_demographics_tab(), value="demographics", pt="md"),
|
||||
dmc.TabsPanel(create_amenities_tab(), value="amenities", pt="md"),
|
||||
],
|
||||
id="toronto-tabs",
|
||||
value="overview",
|
||||
variant="default",
|
||||
)
|
||||
|
||||
|
||||
def create_data_notice() -> dmc.Alert:
|
||||
"""Create a notice about data availability."""
|
||||
"""Create a notice about data sources."""
|
||||
return dmc.Alert(
|
||||
children=[
|
||||
dmc.Text(
|
||||
"This dashboard displays Toronto neighbourhood and CMHC rental data. "
|
||||
"Sample data is shown for demonstration purposes.",
|
||||
"Data from Toronto Open Data (Census 2021, Crime Statistics) and "
|
||||
"CMHC Rental Market Reports. Click neighbourhoods on the map for details.",
|
||||
size="sm",
|
||||
),
|
||||
],
|
||||
title="Data Notice",
|
||||
title="Data Sources",
|
||||
color="blue",
|
||||
variant="light",
|
||||
icon=DashIconify(icon="tabler:info-circle", width=20),
|
||||
)
|
||||
|
||||
|
||||
# Store for selected neighbourhood
|
||||
neighbourhood_store = dcc.Store(id="toronto-selected-neighbourhood", data=None)
|
||||
|
||||
# Register callbacks
|
||||
from portfolio_app.pages.toronto import callbacks # noqa: E402, F401
|
||||
|
||||
layout = dmc.Container(
|
||||
dmc.Stack(
|
||||
[
|
||||
neighbourhood_store,
|
||||
create_header(),
|
||||
create_data_notice(),
|
||||
create_kpi_section(),
|
||||
dmc.Divider(my="md", label="Purchase Market", labelPosition="center"),
|
||||
create_purchase_map_section(),
|
||||
dmc.Divider(my="md", label="Rental Market", labelPosition="center"),
|
||||
create_rental_map_section(),
|
||||
dmc.Divider(my="md", label="Trends", labelPosition="center"),
|
||||
create_time_series_section(),
|
||||
create_market_comparison_section(),
|
||||
create_neighbourhood_selector(),
|
||||
create_tab_navigation(),
|
||||
dmc.Space(h=40),
|
||||
],
|
||||
gap="lg",
|
||||
|
||||
Reference in New Issue
Block a user