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:
15
portfolio_app/pages/toronto/tabs/__init__.py
Normal file
15
portfolio_app/pages/toronto/tabs/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Tab modules for Toronto Neighbourhood Dashboard."""
|
||||
|
||||
from .amenities import create_amenities_tab
|
||||
from .demographics import create_demographics_tab
|
||||
from .housing import create_housing_tab
|
||||
from .overview import create_overview_tab
|
||||
from .safety import create_safety_tab
|
||||
|
||||
__all__ = [
|
||||
"create_overview_tab",
|
||||
"create_housing_tab",
|
||||
"create_safety_tab",
|
||||
"create_demographics_tab",
|
||||
"create_amenities_tab",
|
||||
]
|
||||
207
portfolio_app/pages/toronto/tabs/amenities.py
Normal file
207
portfolio_app/pages/toronto/tabs/amenities.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""Amenities tab for Toronto Neighbourhood Dashboard.
|
||||
|
||||
Displays parks, schools, transit, and other amenity metrics.
|
||||
"""
|
||||
|
||||
import dash_mantine_components as dmc
|
||||
from dash import dcc
|
||||
|
||||
|
||||
def create_amenities_tab() -> dmc.Stack:
|
||||
"""Create the Amenities tab layout.
|
||||
|
||||
Layout:
|
||||
- Choropleth map (amenity score) | KPI cards
|
||||
- Amenity breakdown chart | Amenity comparison radar
|
||||
|
||||
Returns:
|
||||
Tab content as a Mantine Stack component.
|
||||
"""
|
||||
return dmc.Stack(
|
||||
[
|
||||
# Main content: Map + KPIs
|
||||
dmc.Grid(
|
||||
[
|
||||
# Choropleth map
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Title(
|
||||
"Neighbourhood Amenities",
|
||||
order=4,
|
||||
size="h5",
|
||||
),
|
||||
dmc.Select(
|
||||
id="amenities-metric-select",
|
||||
data=[
|
||||
{
|
||||
"value": "amenity_score",
|
||||
"label": "Amenity Score",
|
||||
},
|
||||
{
|
||||
"value": "parks_per_capita",
|
||||
"label": "Parks per 1K",
|
||||
},
|
||||
{
|
||||
"value": "schools_per_capita",
|
||||
"label": "Schools per 1K",
|
||||
},
|
||||
{
|
||||
"value": "transit_score",
|
||||
"label": "Transit Score",
|
||||
},
|
||||
],
|
||||
value="amenity_score",
|
||||
size="sm",
|
||||
w=180,
|
||||
),
|
||||
],
|
||||
justify="space-between",
|
||||
mb="sm",
|
||||
),
|
||||
dcc.Graph(
|
||||
id="amenities-choropleth",
|
||||
config={
|
||||
"scrollZoom": True,
|
||||
"displayModeBar": False,
|
||||
},
|
||||
style={"height": "450px"},
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "lg": 8},
|
||||
),
|
||||
# KPI cards
|
||||
dmc.GridCol(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Text(
|
||||
"City Amenity Score", size="xs", c="dimmed"
|
||||
),
|
||||
dmc.Title(
|
||||
id="amenities-city-score",
|
||||
children="68",
|
||||
order=2,
|
||||
),
|
||||
dmc.Text(
|
||||
"Out of 100",
|
||||
size="sm",
|
||||
c="dimmed",
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Text("Total Parks", size="xs", c="dimmed"),
|
||||
dmc.Title(
|
||||
id="amenities-total-parks",
|
||||
children="1,500+",
|
||||
order=2,
|
||||
),
|
||||
dmc.Text(
|
||||
id="amenities-park-area",
|
||||
children="8,000+ hectares",
|
||||
size="sm",
|
||||
c="green",
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Text(
|
||||
"Selected Neighbourhood",
|
||||
size="xs",
|
||||
c="dimmed",
|
||||
),
|
||||
dmc.Title(
|
||||
id="amenities-selected-name",
|
||||
children="Click map to select",
|
||||
order=4,
|
||||
size="h5",
|
||||
),
|
||||
dmc.Stack(
|
||||
id="amenities-selected-details",
|
||||
children=[
|
||||
dmc.Text("—", c="dimmed"),
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
span={"base": 12, "lg": 4},
|
||||
),
|
||||
],
|
||||
gutter="md",
|
||||
),
|
||||
# Supporting charts
|
||||
dmc.Grid(
|
||||
[
|
||||
# Amenity breakdown
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Title(
|
||||
"Amenity Breakdown",
|
||||
order=4,
|
||||
size="h5",
|
||||
mb="sm",
|
||||
),
|
||||
dcc.Graph(
|
||||
id="amenities-breakdown-chart",
|
||||
config={"displayModeBar": False},
|
||||
style={"height": "300px"},
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "md": 6},
|
||||
),
|
||||
# Amenity comparison radar
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Title(
|
||||
"Amenity Comparison",
|
||||
order=4,
|
||||
size="h5",
|
||||
mb="sm",
|
||||
),
|
||||
dcc.Graph(
|
||||
id="amenities-radar-chart",
|
||||
config={"displayModeBar": False},
|
||||
style={"height": "300px"},
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "md": 6},
|
||||
),
|
||||
],
|
||||
gutter="md",
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
)
|
||||
211
portfolio_app/pages/toronto/tabs/demographics.py
Normal file
211
portfolio_app/pages/toronto/tabs/demographics.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Demographics tab for Toronto Neighbourhood Dashboard.
|
||||
|
||||
Displays population, income, age, and diversity metrics.
|
||||
"""
|
||||
|
||||
import dash_mantine_components as dmc
|
||||
from dash import dcc
|
||||
|
||||
|
||||
def create_demographics_tab() -> dmc.Stack:
|
||||
"""Create the Demographics tab layout.
|
||||
|
||||
Layout:
|
||||
- Choropleth map (demographic metric) | KPI cards
|
||||
- Age distribution chart | Income distribution chart
|
||||
|
||||
Returns:
|
||||
Tab content as a Mantine Stack component.
|
||||
"""
|
||||
return dmc.Stack(
|
||||
[
|
||||
# Main content: Map + KPIs
|
||||
dmc.Grid(
|
||||
[
|
||||
# Choropleth map
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Title(
|
||||
"Neighbourhood Demographics",
|
||||
order=4,
|
||||
size="h5",
|
||||
),
|
||||
dmc.Select(
|
||||
id="demographics-metric-select",
|
||||
data=[
|
||||
{
|
||||
"value": "population",
|
||||
"label": "Population",
|
||||
},
|
||||
{
|
||||
"value": "median_income",
|
||||
"label": "Median Income",
|
||||
},
|
||||
{
|
||||
"value": "median_age",
|
||||
"label": "Median Age",
|
||||
},
|
||||
{
|
||||
"value": "diversity_index",
|
||||
"label": "Diversity Index",
|
||||
},
|
||||
],
|
||||
value="population",
|
||||
size="sm",
|
||||
w=180,
|
||||
),
|
||||
],
|
||||
justify="space-between",
|
||||
mb="sm",
|
||||
),
|
||||
dcc.Graph(
|
||||
id="demographics-choropleth",
|
||||
config={
|
||||
"scrollZoom": True,
|
||||
"displayModeBar": False,
|
||||
},
|
||||
style={"height": "450px"},
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "lg": 8},
|
||||
),
|
||||
# KPI cards
|
||||
dmc.GridCol(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Text(
|
||||
"City Population", size="xs", c="dimmed"
|
||||
),
|
||||
dmc.Title(
|
||||
id="demographics-city-pop",
|
||||
children="2.79M",
|
||||
order=2,
|
||||
),
|
||||
dmc.Text(
|
||||
id="demographics-pop-change",
|
||||
children="+2.3% since 2016",
|
||||
size="sm",
|
||||
c="green",
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Text(
|
||||
"Median Household Income",
|
||||
size="xs",
|
||||
c="dimmed",
|
||||
),
|
||||
dmc.Title(
|
||||
id="demographics-city-income",
|
||||
children="$84,000",
|
||||
order=2,
|
||||
),
|
||||
dmc.Text(
|
||||
"City average",
|
||||
size="sm",
|
||||
c="dimmed",
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Text(
|
||||
"Selected Neighbourhood",
|
||||
size="xs",
|
||||
c="dimmed",
|
||||
),
|
||||
dmc.Title(
|
||||
id="demographics-selected-name",
|
||||
children="Click map to select",
|
||||
order=4,
|
||||
size="h5",
|
||||
),
|
||||
dmc.Stack(
|
||||
id="demographics-selected-details",
|
||||
children=[
|
||||
dmc.Text("—", c="dimmed"),
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
span={"base": 12, "lg": 4},
|
||||
),
|
||||
],
|
||||
gutter="md",
|
||||
),
|
||||
# Supporting charts
|
||||
dmc.Grid(
|
||||
[
|
||||
# Age distribution
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Title(
|
||||
"Age Distribution",
|
||||
order=4,
|
||||
size="h5",
|
||||
mb="sm",
|
||||
),
|
||||
dcc.Graph(
|
||||
id="demographics-age-chart",
|
||||
config={"displayModeBar": False},
|
||||
style={"height": "300px"},
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "md": 6},
|
||||
),
|
||||
# Income distribution
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Title(
|
||||
"Income Distribution",
|
||||
order=4,
|
||||
size="h5",
|
||||
mb="sm",
|
||||
),
|
||||
dcc.Graph(
|
||||
id="demographics-income-chart",
|
||||
config={"displayModeBar": False},
|
||||
style={"height": "300px"},
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "md": 6},
|
||||
),
|
||||
],
|
||||
gutter="md",
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
)
|
||||
209
portfolio_app/pages/toronto/tabs/housing.py
Normal file
209
portfolio_app/pages/toronto/tabs/housing.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Housing tab for Toronto Neighbourhood Dashboard.
|
||||
|
||||
Displays affordability metrics, rent trends, and housing indicators.
|
||||
"""
|
||||
|
||||
import dash_mantine_components as dmc
|
||||
from dash import dcc
|
||||
|
||||
|
||||
def create_housing_tab() -> dmc.Stack:
|
||||
"""Create the Housing tab layout.
|
||||
|
||||
Layout:
|
||||
- Choropleth map (affordability index) | KPI cards
|
||||
- Rent trend line chart | Dwelling types breakdown
|
||||
|
||||
Returns:
|
||||
Tab content as a Mantine Stack component.
|
||||
"""
|
||||
return dmc.Stack(
|
||||
[
|
||||
# Main content: Map + KPIs
|
||||
dmc.Grid(
|
||||
[
|
||||
# Choropleth map
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Title(
|
||||
"Housing Affordability",
|
||||
order=4,
|
||||
size="h5",
|
||||
),
|
||||
dmc.Select(
|
||||
id="housing-metric-select",
|
||||
data=[
|
||||
{
|
||||
"value": "affordability_index",
|
||||
"label": "Affordability Index",
|
||||
},
|
||||
{
|
||||
"value": "avg_rent_2bed",
|
||||
"label": "Avg Rent (2BR)",
|
||||
},
|
||||
{
|
||||
"value": "rent_to_income_pct",
|
||||
"label": "Rent-to-Income %",
|
||||
},
|
||||
{
|
||||
"value": "vacancy_rate",
|
||||
"label": "Vacancy Rate",
|
||||
},
|
||||
],
|
||||
value="affordability_index",
|
||||
size="sm",
|
||||
w=180,
|
||||
),
|
||||
],
|
||||
justify="space-between",
|
||||
mb="sm",
|
||||
),
|
||||
dcc.Graph(
|
||||
id="housing-choropleth",
|
||||
config={
|
||||
"scrollZoom": True,
|
||||
"displayModeBar": False,
|
||||
},
|
||||
style={"height": "450px"},
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "lg": 8},
|
||||
),
|
||||
# KPI cards
|
||||
dmc.GridCol(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Text(
|
||||
"City Avg 2BR Rent", size="xs", c="dimmed"
|
||||
),
|
||||
dmc.Title(
|
||||
id="housing-city-rent",
|
||||
children="$2,450",
|
||||
order=2,
|
||||
),
|
||||
dmc.Text(
|
||||
id="housing-rent-change",
|
||||
children="+4.2% YoY",
|
||||
size="sm",
|
||||
c="red",
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Text(
|
||||
"City Avg Vacancy", size="xs", c="dimmed"
|
||||
),
|
||||
dmc.Title(
|
||||
id="housing-city-vacancy",
|
||||
children="1.8%",
|
||||
order=2,
|
||||
),
|
||||
dmc.Text(
|
||||
"Below healthy rate (3%)",
|
||||
size="sm",
|
||||
c="orange",
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Text(
|
||||
"Selected Neighbourhood",
|
||||
size="xs",
|
||||
c="dimmed",
|
||||
),
|
||||
dmc.Title(
|
||||
id="housing-selected-name",
|
||||
children="Click map to select",
|
||||
order=4,
|
||||
size="h5",
|
||||
),
|
||||
dmc.Stack(
|
||||
id="housing-selected-details",
|
||||
children=[
|
||||
dmc.Text("—", c="dimmed"),
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
span={"base": 12, "lg": 4},
|
||||
),
|
||||
],
|
||||
gutter="md",
|
||||
),
|
||||
# Supporting charts
|
||||
dmc.Grid(
|
||||
[
|
||||
# Rent trend
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Title(
|
||||
"Rent Trends (5 Year)",
|
||||
order=4,
|
||||
size="h5",
|
||||
mb="sm",
|
||||
),
|
||||
dcc.Graph(
|
||||
id="housing-trend-chart",
|
||||
config={"displayModeBar": False},
|
||||
style={"height": "300px"},
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "md": 6},
|
||||
),
|
||||
# Dwelling types
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Title(
|
||||
"Dwelling Types",
|
||||
order=4,
|
||||
size="h5",
|
||||
mb="sm",
|
||||
),
|
||||
dcc.Graph(
|
||||
id="housing-types-chart",
|
||||
config={"displayModeBar": False},
|
||||
style={"height": "300px"},
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "md": 6},
|
||||
),
|
||||
],
|
||||
gutter="md",
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
)
|
||||
233
portfolio_app/pages/toronto/tabs/overview.py
Normal file
233
portfolio_app/pages/toronto/tabs/overview.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Overview tab for Toronto Neighbourhood Dashboard.
|
||||
|
||||
Displays composite livability score with safety, affordability, and amenity components.
|
||||
"""
|
||||
|
||||
import dash_mantine_components as dmc
|
||||
from dash import dcc, html
|
||||
|
||||
|
||||
def create_overview_tab() -> dmc.Stack:
|
||||
"""Create the Overview tab layout.
|
||||
|
||||
Layout:
|
||||
- Choropleth map (livability score) | KPI cards
|
||||
- Top/Bottom 10 bar chart | Income vs Crime scatter
|
||||
|
||||
Returns:
|
||||
Tab content as a Mantine Stack component.
|
||||
"""
|
||||
return dmc.Stack(
|
||||
[
|
||||
# Main content: Map + KPIs
|
||||
dmc.Grid(
|
||||
[
|
||||
# Choropleth map
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Title(
|
||||
"Neighbourhood Livability",
|
||||
order=4,
|
||||
size="h5",
|
||||
),
|
||||
dmc.Select(
|
||||
id="overview-metric-select",
|
||||
data=[
|
||||
{
|
||||
"value": "livability_score",
|
||||
"label": "Livability Score",
|
||||
},
|
||||
{
|
||||
"value": "safety_score",
|
||||
"label": "Safety Score",
|
||||
},
|
||||
{
|
||||
"value": "affordability_score",
|
||||
"label": "Affordability Score",
|
||||
},
|
||||
{
|
||||
"value": "amenity_score",
|
||||
"label": "Amenity Score",
|
||||
},
|
||||
],
|
||||
value="livability_score",
|
||||
size="sm",
|
||||
w=180,
|
||||
),
|
||||
],
|
||||
justify="space-between",
|
||||
mb="sm",
|
||||
),
|
||||
dcc.Graph(
|
||||
id="overview-choropleth",
|
||||
config={
|
||||
"scrollZoom": True,
|
||||
"displayModeBar": False,
|
||||
},
|
||||
style={"height": "450px"},
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "lg": 8},
|
||||
),
|
||||
# KPI cards
|
||||
dmc.GridCol(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Text("City Average", size="xs", c="dimmed"),
|
||||
dmc.Title(
|
||||
id="overview-city-avg",
|
||||
children="72",
|
||||
order=2,
|
||||
),
|
||||
dmc.Text("Livability Score", size="sm", fw=500),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Text(
|
||||
"Selected Neighbourhood",
|
||||
size="xs",
|
||||
c="dimmed",
|
||||
),
|
||||
dmc.Title(
|
||||
id="overview-selected-name",
|
||||
children="Click map to select",
|
||||
order=4,
|
||||
size="h5",
|
||||
),
|
||||
html.Div(
|
||||
id="overview-selected-scores",
|
||||
children=[
|
||||
dmc.Text("—", c="dimmed"),
|
||||
],
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Text(
|
||||
"Score Components", size="xs", c="dimmed"
|
||||
),
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Text("Safety", size="sm"),
|
||||
dmc.Text(
|
||||
"30%",
|
||||
size="sm",
|
||||
c="dimmed",
|
||||
),
|
||||
],
|
||||
justify="space-between",
|
||||
),
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Text(
|
||||
"Affordability", size="sm"
|
||||
),
|
||||
dmc.Text(
|
||||
"40%",
|
||||
size="sm",
|
||||
c="dimmed",
|
||||
),
|
||||
],
|
||||
justify="space-between",
|
||||
),
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Text(
|
||||
"Amenities", size="sm"
|
||||
),
|
||||
dmc.Text(
|
||||
"30%",
|
||||
size="sm",
|
||||
c="dimmed",
|
||||
),
|
||||
],
|
||||
justify="space-between",
|
||||
),
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
span={"base": 12, "lg": 4},
|
||||
),
|
||||
],
|
||||
gutter="md",
|
||||
),
|
||||
# Supporting charts
|
||||
dmc.Grid(
|
||||
[
|
||||
# Top/Bottom rankings
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Title(
|
||||
"Top & Bottom Neighbourhoods",
|
||||
order=4,
|
||||
size="h5",
|
||||
mb="sm",
|
||||
),
|
||||
dcc.Graph(
|
||||
id="overview-rankings-chart",
|
||||
config={"displayModeBar": False},
|
||||
style={"height": "300px"},
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "md": 6},
|
||||
),
|
||||
# Scatter plot
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Title(
|
||||
"Income vs Safety",
|
||||
order=4,
|
||||
size="h5",
|
||||
mb="sm",
|
||||
),
|
||||
dcc.Graph(
|
||||
id="overview-scatter-chart",
|
||||
config={"displayModeBar": False},
|
||||
style={"height": "300px"},
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "md": 6},
|
||||
),
|
||||
],
|
||||
gutter="md",
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
)
|
||||
211
portfolio_app/pages/toronto/tabs/safety.py
Normal file
211
portfolio_app/pages/toronto/tabs/safety.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Safety tab for Toronto Neighbourhood Dashboard.
|
||||
|
||||
Displays crime statistics, trends, and safety indicators.
|
||||
"""
|
||||
|
||||
import dash_mantine_components as dmc
|
||||
from dash import dcc
|
||||
|
||||
|
||||
def create_safety_tab() -> dmc.Stack:
|
||||
"""Create the Safety tab layout.
|
||||
|
||||
Layout:
|
||||
- Choropleth map (crime rate) | KPI cards
|
||||
- Crime trend line chart | Crime by type breakdown
|
||||
|
||||
Returns:
|
||||
Tab content as a Mantine Stack component.
|
||||
"""
|
||||
return dmc.Stack(
|
||||
[
|
||||
# Main content: Map + KPIs
|
||||
dmc.Grid(
|
||||
[
|
||||
# Choropleth map
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Title(
|
||||
"Crime Rate by Neighbourhood",
|
||||
order=4,
|
||||
size="h5",
|
||||
),
|
||||
dmc.Select(
|
||||
id="safety-metric-select",
|
||||
data=[
|
||||
{
|
||||
"value": "total_crime_rate",
|
||||
"label": "Total Crime Rate",
|
||||
},
|
||||
{
|
||||
"value": "violent_crime_rate",
|
||||
"label": "Violent Crime",
|
||||
},
|
||||
{
|
||||
"value": "property_crime_rate",
|
||||
"label": "Property Crime",
|
||||
},
|
||||
{
|
||||
"value": "theft_rate",
|
||||
"label": "Theft",
|
||||
},
|
||||
],
|
||||
value="total_crime_rate",
|
||||
size="sm",
|
||||
w=180,
|
||||
),
|
||||
],
|
||||
justify="space-between",
|
||||
mb="sm",
|
||||
),
|
||||
dcc.Graph(
|
||||
id="safety-choropleth",
|
||||
config={
|
||||
"scrollZoom": True,
|
||||
"displayModeBar": False,
|
||||
},
|
||||
style={"height": "450px"},
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "lg": 8},
|
||||
),
|
||||
# KPI cards
|
||||
dmc.GridCol(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Text(
|
||||
"City Crime Rate", size="xs", c="dimmed"
|
||||
),
|
||||
dmc.Title(
|
||||
id="safety-city-rate",
|
||||
children="4,250",
|
||||
order=2,
|
||||
),
|
||||
dmc.Text(
|
||||
id="safety-rate-change",
|
||||
children="-2.1% YoY",
|
||||
size="sm",
|
||||
c="green",
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Text(
|
||||
"Total Incidents (2023)",
|
||||
size="xs",
|
||||
c="dimmed",
|
||||
),
|
||||
dmc.Title(
|
||||
id="safety-total-incidents",
|
||||
children="125,430",
|
||||
order=2,
|
||||
),
|
||||
dmc.Text(
|
||||
"Per 100,000 residents",
|
||||
size="sm",
|
||||
c="dimmed",
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Text(
|
||||
"Selected Neighbourhood",
|
||||
size="xs",
|
||||
c="dimmed",
|
||||
),
|
||||
dmc.Title(
|
||||
id="safety-selected-name",
|
||||
children="Click map to select",
|
||||
order=4,
|
||||
size="h5",
|
||||
),
|
||||
dmc.Stack(
|
||||
id="safety-selected-details",
|
||||
children=[
|
||||
dmc.Text("—", c="dimmed"),
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
span={"base": 12, "lg": 4},
|
||||
),
|
||||
],
|
||||
gutter="md",
|
||||
),
|
||||
# Supporting charts
|
||||
dmc.Grid(
|
||||
[
|
||||
# Crime trend
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Title(
|
||||
"Crime Trends (5 Year)",
|
||||
order=4,
|
||||
size="h5",
|
||||
mb="sm",
|
||||
),
|
||||
dcc.Graph(
|
||||
id="safety-trend-chart",
|
||||
config={"displayModeBar": False},
|
||||
style={"height": "300px"},
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "md": 6},
|
||||
),
|
||||
# Crime by type
|
||||
dmc.GridCol(
|
||||
dmc.Paper(
|
||||
[
|
||||
dmc.Title(
|
||||
"Crime by Category",
|
||||
order=4,
|
||||
size="h5",
|
||||
mb="sm",
|
||||
),
|
||||
dcc.Graph(
|
||||
id="safety-types-chart",
|
||||
config={"displayModeBar": False},
|
||||
style={"height": "300px"},
|
||||
),
|
||||
],
|
||||
p="md",
|
||||
radius="sm",
|
||||
withBorder=True,
|
||||
),
|
||||
span={"base": 12, "md": 6},
|
||||
),
|
||||
],
|
||||
gutter="md",
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
)
|
||||
Reference in New Issue
Block a user