8 Commits

Author SHA1 Message Date
d64f90b3d3 Merge branch 'feature/7-nav-theme-modernization' into development 2026-01-15 11:53:22 -05:00
b3fb94c7cb feat: Add floating sidebar navigation and dark theme support
- Add floating pill-shaped sidebar with navigation icons
- Implement dark/light theme toggle with localStorage persistence
- Update all figure factories for transparent backgrounds
- Use carto-darkmatter map style for choropleths
- Add methodology link button to Toronto dashboard header
- Add back to dashboard button on methodology page
- Remove social links from home page (now in sidebar)
- Update CLAUDE.md to Sprint 7

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 11:53:13 -05:00
1e0ea9cca2 Merge pull request 'feat: Add GeoJSON parsers and choropleth map visualization' (#26) from feature/geo-parsers-choropleth into development 2026-01-14 23:02:21 +00:00
9dfa24fb76 feat: add GeoJSON parsers and choropleth map visualization
- Add geo.py parser module with CMHCZoneParser, TRREBDistrictParser,
  and NeighbourhoodParser for loading geographic boundaries
- Add coordinate reprojection support (EPSG:3857 to WGS84)
- Organize geo data in data/toronto/raw/geo/ directory
- Add CMHC zones GeoJSON (31 zones) for rental market choropleth
- Add Toronto neighbourhoods GeoJSON (158) as purchase market proxy
- Update callbacks with real CMHC 2024 rental data
- Add sample purchase data for all 158 neighbourhoods
- Update pre-commit config to exclude geo data files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 17:58:13 -05:00
8701a12b41 Merge pull request 'Upload files to "/"' (#24) from lmiranda-cmhc-zones into development
Reviewed-on: lmiranda/personal-portfolio#24
2026-01-14 21:04:24 +00:00
6ef5460ad0 Upload files to "/" 2026-01-14 21:04:06 +00:00
19ffc04573 Merge pull request 'fix: Toronto page registration for Dash Pages' (#23) from fix/toronto-page-registration into development 2026-01-12 03:19:49 +00:00
08aa61f85e fix: rename Toronto page __init__.py to dashboard.py for Dash Pages
Dash Pages does not auto-discover __init__.py files as page modules.
Renamed to dashboard.py so the page registers correctly at /toronto.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:08:49 -05:00
22 changed files with 2719 additions and 380 deletions

View File

@@ -7,7 +7,7 @@ repos:
- id: check-yaml
- id: check-added-large-files
args: ['--maxkb=1000']
exclude: ^data/raw/
exclude: ^data/(raw/|toronto/raw/geo/)
- id: check-merge-conflict
- repo: https://github.com/astral-sh/ruff-pre-commit

View File

@@ -6,7 +6,7 @@ Working context for Claude Code on the Analytics Portfolio project.
## Project Status
**Current Sprint**: 1 (Project Bootstrap)
**Current Sprint**: 7 (Navigation & Theme Modernization)
**Phase**: 1 - Toronto Housing Dashboard
**Branch**: `development` (feature branches merge here)
@@ -254,4 +254,4 @@ All scripts in `scripts/`:
---
*Last Updated: Sprint 1*
*Last Updated: Sprint 7*

View File

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,9 @@
import dash
import dash_mantine_components as dmc
from dash import dcc, html
from .components import create_sidebar
from .config import get_settings
@@ -17,14 +19,31 @@ def create_app() -> dash.Dash:
)
app.layout = dmc.MantineProvider(
dash.page_container,
id="mantine-provider",
children=[
dcc.Location(id="url", refresh=False),
dcc.Store(id="theme-store", storage_type="local", data="dark"),
dcc.Store(id="theme-init-dummy"), # Dummy store for theme init callback
html.Div(
[
create_sidebar(),
html.Div(
dash.page_container,
className="page-content-wrapper",
),
],
),
],
theme={
"primaryColor": "blue",
"fontFamily": "'Inter', sans-serif",
},
forceColorScheme="light",
defaultColorScheme="dark",
)
# Import callbacks to register them
from . import callbacks # noqa: F401
return app

View File

@@ -0,0 +1,139 @@
/* Floating sidebar navigation styles */
/* Sidebar container */
.floating-sidebar {
position: fixed;
left: 16px;
top: 50%;
transform: translateY(-50%);
width: 60px;
padding: 16px 8px;
border-radius: 32px;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: background-color 0.2s ease;
}
/* Page content offset to prevent sidebar overlap */
.page-content-wrapper {
margin-left: 92px; /* sidebar width (60px) + left margin (16px) + gap (16px) */
min-height: 100vh;
}
/* Dark theme (default) */
[data-mantine-color-scheme="dark"] .floating-sidebar {
background-color: #141414;
}
[data-mantine-color-scheme="dark"] body {
background-color: #000000;
}
/* Light theme */
[data-mantine-color-scheme="light"] .floating-sidebar {
background-color: #f0f0f0;
}
[data-mantine-color-scheme="light"] body {
background-color: #ffffff;
}
/* Brand initials styling */
.sidebar-brand {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: var(--mantine-color-blue-filled);
margin-bottom: 4px;
transition: transform 0.2s ease;
}
.sidebar-brand:hover {
transform: scale(1.05);
}
.sidebar-brand-link {
font-weight: 700;
font-size: 16px;
color: white;
text-decoration: none;
line-height: 1;
}
/* Divider between sections */
.sidebar-divider {
width: 32px;
height: 1px;
background-color: var(--mantine-color-dimmed);
margin: 4px 0;
opacity: 0.3;
}
/* Active nav icon indicator */
.nav-icon-active {
background-color: var(--mantine-color-blue-filled) !important;
}
/* Navigation icon hover effects */
.floating-sidebar .mantine-ActionIcon-root {
transition: transform 0.15s ease, background-color 0.15s ease;
}
.floating-sidebar .mantine-ActionIcon-root:hover {
transform: scale(1.1);
}
/* Ensure links don't have underlines */
.floating-sidebar a {
text-decoration: none;
}
/* Theme toggle specific styling */
#theme-toggle {
transition: transform 0.3s ease;
}
#theme-toggle:hover {
transform: rotate(15deg) scale(1.1);
}
/* Responsive adjustments for smaller screens */
@media (max-width: 768px) {
.floating-sidebar {
left: 8px;
width: 50px;
padding: 12px 6px;
border-radius: 25px;
}
.page-content-wrapper {
margin-left: 70px;
}
.sidebar-brand {
width: 34px;
height: 34px;
}
.sidebar-brand-link {
font-size: 14px;
}
}
/* Very small screens - hide sidebar, show minimal navigation */
@media (max-width: 480px) {
.floating-sidebar {
display: none;
}
.page-content-wrapper {
margin-left: 0;
}
}

View File

@@ -0,0 +1,5 @@
"""Application-level callbacks for the portfolio app."""
from . import theme
__all__ = ["theme"]

View File

@@ -0,0 +1,38 @@
"""Theme toggle callbacks using clientside JavaScript."""
from dash import Input, Output, State, clientside_callback
# Toggle theme on button click
# Stores new theme value and updates the DOM attribute
clientside_callback(
"""
function(n_clicks, currentTheme) {
if (n_clicks === undefined || n_clicks === null) {
return window.dash_clientside.no_update;
}
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-mantine-color-scheme', newTheme);
return newTheme;
}
""",
Output("theme-store", "data"),
Input("theme-toggle", "n_clicks"),
State("theme-store", "data"),
prevent_initial_call=True,
)
# Initialize theme from localStorage on page load
# Uses a dummy output since we only need the side effect of setting the DOM attribute
clientside_callback(
"""
function(theme) {
if (theme) {
document.documentElement.setAttribute('data-mantine-color-scheme', theme);
}
return theme;
}
""",
Output("theme-init-dummy", "data"),
Input("theme-store", "data"),
prevent_initial_call=False,
)

View File

@@ -2,11 +2,13 @@
from .map_controls import create_map_controls, create_metric_selector
from .metric_card import MetricCard, create_metric_cards_row
from .sidebar import create_sidebar
from .time_slider import create_time_slider, create_year_selector
__all__ = [
"create_map_controls",
"create_metric_selector",
"create_sidebar",
"create_time_slider",
"create_year_selector",
"MetricCard",

View File

@@ -0,0 +1,179 @@
"""Floating sidebar navigation component."""
import dash_mantine_components as dmc
from dash import dcc, html
from dash_iconify import DashIconify
# Navigation items configuration
NAV_ITEMS = [
{"path": "/", "icon": "tabler:home", "label": "Home"},
{"path": "/toronto", "icon": "tabler:map-2", "label": "Toronto Housing"},
]
# External links configuration
EXTERNAL_LINKS = [
{
"url": "https://github.com/leomiranda",
"icon": "tabler:brand-github",
"label": "GitHub",
},
{
"url": "https://linkedin.com/in/leobmiranda",
"icon": "tabler:brand-linkedin",
"label": "LinkedIn",
},
]
def create_brand_logo() -> html.Div:
"""Create the brand initials logo."""
return html.Div(
dcc.Link(
"LM",
href="/",
className="sidebar-brand-link",
),
className="sidebar-brand",
)
def create_nav_icon(
icon: str,
label: str,
path: str,
current_path: str,
) -> dmc.Tooltip:
"""Create a navigation icon with tooltip.
Args:
icon: Iconify icon string.
label: Tooltip label.
path: Navigation path.
current_path: Current page path for active state.
Returns:
Tooltip-wrapped navigation icon.
"""
is_active = current_path == path or (path != "/" and current_path.startswith(path))
return dmc.Tooltip(
dcc.Link(
dmc.ActionIcon(
DashIconify(icon=icon, width=20),
variant="subtle" if not is_active else "filled",
size="lg",
radius="xl",
color="blue" if is_active else "gray",
className="nav-icon-active" if is_active else "",
),
href=path,
),
label=label,
position="right",
withArrow=True,
)
def create_theme_toggle(current_theme: str = "dark") -> dmc.Tooltip:
"""Create the theme toggle button.
Args:
current_theme: Current theme ('dark' or 'light').
Returns:
Tooltip-wrapped theme toggle icon.
"""
icon = "tabler:sun" if current_theme == "dark" else "tabler:moon"
label = "Switch to light mode" if current_theme == "dark" else "Switch to dark mode"
return dmc.Tooltip(
dmc.ActionIcon(
DashIconify(icon=icon, width=20, id="theme-toggle-icon"),
id="theme-toggle",
variant="subtle",
size="lg",
radius="xl",
color="gray",
),
label=label,
position="right",
withArrow=True,
)
def create_external_link(url: str, icon: str, label: str) -> dmc.Tooltip:
"""Create an external link icon with tooltip.
Args:
url: External URL.
icon: Iconify icon string.
label: Tooltip label.
Returns:
Tooltip-wrapped external link icon.
"""
return dmc.Tooltip(
dmc.Anchor(
dmc.ActionIcon(
DashIconify(icon=icon, width=20),
variant="subtle",
size="lg",
radius="xl",
color="gray",
),
href=url,
target="_blank",
),
label=label,
position="right",
withArrow=True,
)
def create_sidebar_divider() -> html.Div:
"""Create a horizontal divider for the sidebar."""
return html.Div(className="sidebar-divider")
def create_sidebar(current_path: str = "/", current_theme: str = "dark") -> html.Div:
"""Create the floating sidebar navigation.
Args:
current_path: Current page path for active state highlighting.
current_theme: Current theme for toggle icon state.
Returns:
Complete sidebar component.
"""
return html.Div(
[
# Brand logo
create_brand_logo(),
create_sidebar_divider(),
# Navigation icons
*[
create_nav_icon(
icon=item["icon"],
label=item["label"],
path=item["path"],
current_path=current_path,
)
for item in NAV_ITEMS
],
create_sidebar_divider(),
# Theme toggle
create_theme_toggle(current_theme),
create_sidebar_divider(),
# External links
*[
create_external_link(
url=link["url"],
icon=link["icon"],
label=link["label"],
)
for link in EXTERNAL_LINKS
],
],
className="floating-sidebar",
id="floating-sidebar",
)

View File

@@ -39,6 +39,10 @@ def create_choropleth_figure(
if center is None:
center = {"lat": 43.7, "lon": -79.4}
# Use dark-mode friendly map style by default
if map_style == "carto-positron":
map_style = "carto-darkmatter"
# If no geojson provided, create a placeholder map
if geojson is None or not data:
fig = go.Figure(go.Scattermapbox())
@@ -51,6 +55,9 @@ def create_choropleth_figure(
margin={"l": 0, "r": 0, "t": 40, "b": 0},
title=title or "Toronto Housing Map",
height=500,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
)
fig.add_annotation(
text="No geometry data available. Complete QGIS digitization to enable map.",
@@ -59,7 +66,7 @@ def create_choropleth_figure(
x=0.5,
y=0.5,
showarrow=False,
font={"size": 14, "color": "gray"},
font={"size": 14, "color": "#888888"},
)
return fig
@@ -68,6 +75,11 @@ def create_choropleth_figure(
df = pd.DataFrame(data)
# Use dark-mode friendly map style
effective_map_style = (
"carto-darkmatter" if map_style == "carto-positron" else map_style
)
fig = px.choropleth_mapbox(
df,
geojson=geojson,
@@ -76,7 +88,7 @@ def create_choropleth_figure(
color=color_column,
color_continuous_scale=color_scale,
hover_data=hover_data,
mapbox_style=map_style,
mapbox_style=effective_map_style,
center=center,
zoom=zoom,
opacity=0.7,
@@ -86,10 +98,17 @@ def create_choropleth_figure(
margin={"l": 0, "r": 0, "t": 40, "b": 0},
title=title,
height=500,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
coloraxis_colorbar={
"title": color_column.replace("_", " ").title(),
"title": {
"text": color_column.replace("_", " ").title(),
"font": {"color": "#c9c9c9"},
},
"thickness": 15,
"len": 0.7,
"tickfont": {"color": "#c9c9c9"},
},
)

View File

@@ -69,7 +69,8 @@ def create_metric_card_figure(
height=120,
margin={"l": 20, "r": 20, "t": 40, "b": 20},
paper_bgcolor="rgba(0,0,0,0)",
font={"family": "Inter, sans-serif"},
plot_bgcolor="rgba(0,0,0,0)",
font={"family": "Inter, sans-serif", "color": "#c9c9c9"},
)
return fig

View File

@@ -38,8 +38,15 @@ def create_price_time_series(
x=0.5,
y=0.5,
showarrow=False,
font={"color": "#888888"},
)
fig.update_layout(
title=title,
height=350,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
)
fig.update_layout(title=title, height=350)
return fig
df = pd.DataFrame(data)
@@ -69,6 +76,11 @@ def create_price_time_series(
yaxis_tickprefix="$",
yaxis_tickformat=",",
hovermode="x unified",
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
xaxis={"gridcolor": "#333333", "linecolor": "#444444"},
yaxis={"gridcolor": "#333333", "linecolor": "#444444"},
)
return fig
@@ -106,8 +118,15 @@ def create_volume_time_series(
x=0.5,
y=0.5,
showarrow=False,
font={"color": "#888888"},
)
fig.update_layout(
title=title,
height=350,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
)
fig.update_layout(title=title, height=350)
return fig
df = pd.DataFrame(data)
@@ -153,6 +172,11 @@ def create_volume_time_series(
yaxis_title=volume_column.replace("_", " ").title(),
yaxis_tickformat=",",
hovermode="x unified",
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
xaxis={"gridcolor": "#333333", "linecolor": "#444444"},
yaxis={"gridcolor": "#333333", "linecolor": "#444444"},
)
return fig
@@ -187,8 +211,15 @@ def create_market_comparison_chart(
x=0.5,
y=0.5,
showarrow=False,
font={"color": "#888888"},
)
fig.update_layout(
title=title,
height=400,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
)
fig.update_layout(title=title, height=400)
return fig
if metrics is None:
@@ -221,12 +252,18 @@ def create_market_comparison_chart(
height=400,
margin={"l": 40, "r": 40, "t": 50, "b": 40},
hovermode="x unified",
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
xaxis={"gridcolor": "#333333", "linecolor": "#444444"},
yaxis={"gridcolor": "#333333", "linecolor": "#444444"},
legend={
"orientation": "h",
"yanchor": "bottom",
"y": 1.02,
"xanchor": "right",
"x": 1,
"font": {"color": "#c9c9c9"},
},
)

View File

@@ -2,7 +2,6 @@
import dash
import dash_mantine_components as dmc
from dash_iconify import DashIconify
dash.register_page(__name__, path="/", name="Home")
@@ -52,19 +51,6 @@ PROJECTS = [
},
]
SOCIAL_LINKS = [
{
"platform": "LinkedIn",
"url": "https://linkedin.com/in/leobmiranda",
"icon": "mdi:linkedin",
},
{
"platform": "GitHub",
"url": "https://github.com/leomiranda",
"icon": "mdi:github",
},
]
AVAILABILITY = "Open to Senior Data Analyst, Analytics Engineer, and BI Developer opportunities in Toronto or remote."
@@ -160,27 +146,6 @@ def create_projects_section() -> dmc.Paper:
)
def create_social_links() -> dmc.Group:
"""Create social media links."""
return dmc.Group(
[
dmc.Anchor(
dmc.Button(
link["platform"],
leftSection=DashIconify(icon=link["icon"], width=20),
variant="outline",
size="md",
),
href=link["url"],
target="_blank",
)
for link in SOCIAL_LINKS
],
justify="center",
gap="md",
)
def create_availability_section() -> dmc.Text:
"""Create the availability statement."""
return dmc.Text(AVAILABILITY, size="sm", c="dimmed", ta="center", fs="italic")
@@ -193,7 +158,6 @@ layout = dmc.Container(
create_summary_section(),
create_tech_stack_section(),
create_projects_section(),
create_social_links(),
dmc.Divider(my="lg"),
create_availability_section(),
dmc.Space(h=40),

View File

@@ -1,282 +1 @@
"""Toronto Housing Dashboard page."""
import dash
import dash_mantine_components as dmc
from dash import dcc, html
from portfolio_app.components import (
create_map_controls,
create_metric_cards_row,
create_time_slider,
create_year_selector,
)
dash.register_page(__name__, path="/toronto", name="Toronto Housing")
# 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 = [
{
"title": "Avg. Price",
"value": 1125000,
"delta": 2.3,
"prefix": "$",
"format_spec": ",.0f",
},
{
"title": "Sales Volume",
"value": 4850,
"delta": -5.1,
"format_spec": ",",
},
{
"title": "Avg. DOM",
"value": 18,
"delta": 3,
"suffix": " days",
"positive_is_good": False,
},
{
"title": "Avg. Rent",
"value": 2450,
"delta": 4.2,
"prefix": "$",
"format_spec": ",.0f",
},
]
def create_header() -> dmc.Group:
"""Create the dashboard header with title and controls."""
return dmc.Group(
[
dmc.Stack(
[
dmc.Title("Toronto Housing Dashboard", order=1),
dmc.Text(
"Real estate market analysis for the Greater Toronto Area",
c="dimmed",
),
],
gap="xs",
),
dmc.Group(
[
create_year_selector(
id_prefix="toronto",
min_year=2020,
default_year=2024,
label="Year",
),
],
gap="md",
),
],
justify="space-between",
align="flex-start",
)
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."""
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",
radius="sm",
withBorder=True,
)
def create_data_notice() -> dmc.Alert:
"""Create a notice about data availability."""
return dmc.Alert(
children=[
dmc.Text(
"This dashboard uses TRREB and CMHC data. "
"Geographic boundaries require QGIS digitization to enable choropleth maps. "
"Sample data is shown below.",
size="sm",
),
],
title="Data Notice",
color="blue",
variant="light",
)
# Register callbacks
from portfolio_app.pages.toronto import callbacks # noqa: E402, F401
layout = dmc.Container(
dmc.Stack(
[
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(),
dmc.Space(h=40),
],
gap="lg",
),
size="xl",
py="xl",
)
"""Toronto Housing Dashboard pages."""

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,294 @@
"""Toronto Housing Dashboard page."""
import dash
import dash_mantine_components as dmc
from dash import dcc, html
from dash_iconify import DashIconify
from portfolio_app.components import (
create_map_controls,
create_metric_cards_row,
create_time_slider,
create_year_selector,
)
dash.register_page(__name__, path="/toronto", name="Toronto Housing")
# 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 = [
{
"title": "Avg. Price",
"value": 1125000,
"delta": 2.3,
"prefix": "$",
"format_spec": ",.0f",
},
{
"title": "Sales Volume",
"value": 4850,
"delta": -5.1,
"format_spec": ",",
},
{
"title": "Avg. DOM",
"value": 18,
"delta": 3,
"suffix": " days",
"positive_is_good": False,
},
{
"title": "Avg. Rent",
"value": 2450,
"delta": 4.2,
"prefix": "$",
"format_spec": ",.0f",
},
]
def create_header() -> dmc.Group:
"""Create the dashboard header with title and controls."""
return dmc.Group(
[
dmc.Stack(
[
dmc.Title("Toronto Housing Dashboard", order=1),
dmc.Text(
"Real estate market analysis for the Greater Toronto Area",
c="dimmed",
),
],
gap="xs",
),
dmc.Group(
[
dcc.Link(
dmc.Button(
"Methodology",
leftSection=DashIconify(
icon="tabler:info-circle", width=18
),
variant="subtle",
color="gray",
),
href="/toronto/methodology",
),
create_year_selector(
id_prefix="toronto",
min_year=2020,
default_year=2024,
label="Year",
),
],
gap="md",
),
],
justify="space-between",
align="flex-start",
)
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."""
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",
radius="sm",
withBorder=True,
)
def create_data_notice() -> dmc.Alert:
"""Create a notice about data availability."""
return dmc.Alert(
children=[
dmc.Text(
"This dashboard uses TRREB and CMHC data. "
"Geographic boundaries require QGIS digitization to enable choropleth maps. "
"Sample data is shown below.",
size="sm",
),
],
title="Data Notice",
color="blue",
variant="light",
)
# Register callbacks
from portfolio_app.pages.toronto import callbacks # noqa: E402, F401
layout = dmc.Container(
dmc.Stack(
[
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(),
dmc.Space(h=40),
],
gap="lg",
),
size="xl",
py="xl",
)

View File

@@ -2,7 +2,8 @@
import dash
import dash_mantine_components as dmc
from dash import html
from dash import dcc, html
from dash_iconify import DashIconify
dash.register_page(
__name__,
@@ -18,8 +19,18 @@ def layout() -> dmc.Container:
size="md",
py="xl",
children=[
# Back to Dashboard button
dcc.Link(
dmc.Button(
"Back to Dashboard",
leftSection=DashIconify(icon="tabler:arrow-left", width=18),
variant="subtle",
color="gray",
),
href="/toronto",
),
# Header
dmc.Title("Methodology", order=1, mb="lg"),
dmc.Title("Methodology", order=1, mb="lg", mt="md"),
dmc.Text(
"This page documents the data sources, processing methodology, "
"and known limitations of the Toronto Housing Dashboard.",

View File

@@ -1,9 +1,20 @@
"""Parsers for Toronto housing data sources."""
from .cmhc import CMHCParser
from .geo import (
CMHCZoneParser,
NeighbourhoodParser,
TRREBDistrictParser,
load_geojson,
)
from .trreb import TRREBParser
__all__ = [
"TRREBParser",
"CMHCParser",
# GeoJSON parsers
"CMHCZoneParser",
"TRREBDistrictParser",
"NeighbourhoodParser",
"load_geojson",
]

View File

@@ -0,0 +1,463 @@
"""GeoJSON parser for geographic boundary files.
This module provides parsers for loading geographic boundary files
(GeoJSON format) and converting them to Pydantic schemas for database
loading or direct use in Plotly choropleth maps.
"""
import json
from pathlib import Path
from typing import Any
from pyproj import Transformer
from shapely.geometry import mapping, shape
from shapely.ops import transform
from portfolio_app.toronto.schemas import CMHCZone, Neighbourhood, TRREBDistrict
from portfolio_app.toronto.schemas.dimensions import AreaType
# Transformer for reprojecting from Web Mercator to WGS84
_TRANSFORMER_3857_TO_4326 = Transformer.from_crs(
"EPSG:3857", "EPSG:4326", always_xy=True
)
def load_geojson(path: Path) -> dict[str, Any]:
"""Load a GeoJSON file and return as dictionary.
Args:
path: Path to the GeoJSON file.
Returns:
GeoJSON as dictionary (FeatureCollection).
Raises:
FileNotFoundError: If file does not exist.
ValueError: If file is not valid GeoJSON.
"""
if not path.exists():
raise FileNotFoundError(f"GeoJSON file not found: {path}")
if path.suffix.lower() not in (".geojson", ".json"):
raise ValueError(f"Expected GeoJSON file, got: {path.suffix}")
with open(path, encoding="utf-8") as f:
data = json.load(f)
if data.get("type") != "FeatureCollection":
raise ValueError("GeoJSON must be a FeatureCollection")
return dict(data)
def geometry_to_wkt(geometry: dict[str, Any]) -> str:
"""Convert GeoJSON geometry to WKT string.
Args:
geometry: GeoJSON geometry dictionary.
Returns:
WKT representation of the geometry.
"""
return str(shape(geometry).wkt)
def reproject_geometry(
geometry: dict[str, Any], source_crs: str = "EPSG:3857"
) -> dict[str, Any]:
"""Reproject a GeoJSON geometry to WGS84 (EPSG:4326).
Args:
geometry: GeoJSON geometry dictionary.
source_crs: Source CRS (default EPSG:3857 Web Mercator).
Returns:
GeoJSON geometry in WGS84 coordinates.
"""
if source_crs == "EPSG:3857":
transformer = _TRANSFORMER_3857_TO_4326
else:
transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True)
geom = shape(geometry)
reprojected = transform(transformer.transform, geom)
return dict(mapping(reprojected))
class CMHCZoneParser:
"""Parser for CMHC zone boundary GeoJSON files.
CMHC zone boundaries are extracted from the R `cmhc` package using
`get_cmhc_geography(geography_type="ZONE", cma="Toronto")`.
Expected GeoJSON properties:
- zone_code or Zone_Code: Zone identifier
- zone_name or Zone_Name: Zone name
"""
# Property name mappings for different GeoJSON formats
CODE_PROPERTIES = ["zone_code", "Zone_Code", "ZONE_CODE", "zonecode", "code"]
NAME_PROPERTIES = [
"zone_name",
"Zone_Name",
"ZONE_NAME",
"ZONE_NAME_EN",
"NAME_EN",
"zonename",
"name",
"NAME",
]
def __init__(self, geojson_path: Path) -> None:
"""Initialize parser with path to GeoJSON file.
Args:
geojson_path: Path to the CMHC zones GeoJSON file.
"""
self.geojson_path = geojson_path
self._geojson: dict[str, Any] | None = None
@property
def geojson(self) -> dict[str, Any]:
"""Lazy-load and return raw GeoJSON data."""
if self._geojson is None:
self._geojson = load_geojson(self.geojson_path)
return self._geojson
def _find_property(
self, properties: dict[str, Any], candidates: list[str]
) -> str | None:
"""Find a property value by checking multiple candidate names."""
for name in candidates:
if name in properties and properties[name] is not None:
return str(properties[name])
return None
def parse(self) -> list[CMHCZone]:
"""Parse GeoJSON and return list of CMHCZone schemas.
Returns:
List of validated CMHCZone objects.
Raises:
ValueError: If required properties are missing.
"""
zones = []
for feature in self.geojson.get("features", []):
props = feature.get("properties", {})
geom = feature.get("geometry")
zone_code = self._find_property(props, self.CODE_PROPERTIES)
zone_name = self._find_property(props, self.NAME_PROPERTIES)
if not zone_code:
raise ValueError(
f"Zone code not found in properties: {list(props.keys())}"
)
if not zone_name:
zone_name = zone_code # Fallback to code if name missing
geometry_wkt = geometry_to_wkt(geom) if geom else None
zones.append(
CMHCZone(
zone_code=zone_code,
zone_name=zone_name,
geometry_wkt=geometry_wkt,
)
)
return zones
def _needs_reprojection(self) -> bool:
"""Check if GeoJSON needs reprojection to WGS84."""
crs = self.geojson.get("crs", {})
crs_name = crs.get("properties", {}).get("name", "")
# EPSG:3857 or Web Mercator needs reprojection
return "3857" in crs_name or "900913" in crs_name
def get_geojson_for_choropleth(
self, key_property: str = "zone_code"
) -> dict[str, Any]:
"""Get GeoJSON formatted for Plotly choropleth maps.
Ensures the feature properties include a standardized key for
joining with data. Automatically reprojects from EPSG:3857 to
WGS84 if needed.
Args:
key_property: Property name to use as feature identifier.
Returns:
GeoJSON FeatureCollection with standardized properties in WGS84.
"""
needs_reproject = self._needs_reprojection()
features = []
for feature in self.geojson.get("features", []):
props = feature.get("properties", {})
new_props = dict(props)
# Ensure standardized property names exist
zone_code = self._find_property(props, self.CODE_PROPERTIES)
zone_name = self._find_property(props, self.NAME_PROPERTIES)
new_props["zone_code"] = zone_code
new_props["zone_name"] = zone_name or zone_code
# Reproject geometry if needed
geometry = feature.get("geometry")
if needs_reproject and geometry:
geometry = reproject_geometry(geometry)
features.append(
{
"type": "Feature",
"properties": new_props,
"geometry": geometry,
}
)
return {"type": "FeatureCollection", "features": features}
class TRREBDistrictParser:
"""Parser for TRREB district boundary GeoJSON files.
TRREB district boundaries are manually digitized from the TRREB PDF map
using QGIS.
Expected GeoJSON properties:
- district_code: District code (W01, C01, E01, etc.)
- district_name: District name
- area_type: West, Central, East, or North
"""
CODE_PROPERTIES = [
"district_code",
"District_Code",
"DISTRICT_CODE",
"districtcode",
"code",
]
NAME_PROPERTIES = [
"district_name",
"District_Name",
"DISTRICT_NAME",
"districtname",
"name",
"NAME",
]
AREA_PROPERTIES = [
"area_type",
"Area_Type",
"AREA_TYPE",
"areatype",
"area",
"type",
]
def __init__(self, geojson_path: Path) -> None:
"""Initialize parser with path to GeoJSON file."""
self.geojson_path = geojson_path
self._geojson: dict[str, Any] | None = None
@property
def geojson(self) -> dict[str, Any]:
"""Lazy-load and return raw GeoJSON data."""
if self._geojson is None:
self._geojson = load_geojson(self.geojson_path)
return self._geojson
def _find_property(
self, properties: dict[str, Any], candidates: list[str]
) -> str | None:
"""Find a property value by checking multiple candidate names."""
for name in candidates:
if name in properties and properties[name] is not None:
return str(properties[name])
return None
def _infer_area_type(self, district_code: str) -> AreaType:
"""Infer area type from district code prefix."""
prefix = district_code[0].upper()
mapping = {"W": AreaType.WEST, "C": AreaType.CENTRAL, "E": AreaType.EAST}
return mapping.get(prefix, AreaType.NORTH)
def parse(self) -> list[TRREBDistrict]:
"""Parse GeoJSON and return list of TRREBDistrict schemas."""
districts = []
for feature in self.geojson.get("features", []):
props = feature.get("properties", {})
geom = feature.get("geometry")
district_code = self._find_property(props, self.CODE_PROPERTIES)
district_name = self._find_property(props, self.NAME_PROPERTIES)
area_type_str = self._find_property(props, self.AREA_PROPERTIES)
if not district_code:
raise ValueError(
f"District code not found in properties: {list(props.keys())}"
)
if not district_name:
district_name = district_code
# Infer or parse area type
if area_type_str:
try:
area_type = AreaType(area_type_str)
except ValueError:
area_type = self._infer_area_type(district_code)
else:
area_type = self._infer_area_type(district_code)
geometry_wkt = geometry_to_wkt(geom) if geom else None
districts.append(
TRREBDistrict(
district_code=district_code,
district_name=district_name,
area_type=area_type,
geometry_wkt=geometry_wkt,
)
)
return districts
def get_geojson_for_choropleth(
self, key_property: str = "district_code"
) -> dict[str, Any]:
"""Get GeoJSON formatted for Plotly choropleth maps."""
features = []
for feature in self.geojson.get("features", []):
props = feature.get("properties", {})
new_props = dict(props)
district_code = self._find_property(props, self.CODE_PROPERTIES)
district_name = self._find_property(props, self.NAME_PROPERTIES)
new_props["district_code"] = district_code
new_props["district_name"] = district_name or district_code
features.append(
{
"type": "Feature",
"properties": new_props,
"geometry": feature.get("geometry"),
}
)
return {"type": "FeatureCollection", "features": features}
class NeighbourhoodParser:
"""Parser for City of Toronto neighbourhood boundary GeoJSON files.
Neighbourhood boundaries are from the City of Toronto Open Data portal.
Expected GeoJSON properties:
- neighbourhood_id or AREA_ID: Neighbourhood ID (1-158)
- name or AREA_NAME: Neighbourhood name
"""
ID_PROPERTIES = [
"neighbourhood_id",
"AREA_SHORT_CODE", # City of Toronto 158 neighbourhoods
"AREA_LONG_CODE",
"AREA_ID",
"area_id",
"id",
"ID",
"HOOD_ID",
]
NAME_PROPERTIES = [
"AREA_NAME", # City of Toronto 158 neighbourhoods
"name",
"NAME",
"area_name",
"neighbourhood_name",
]
def __init__(self, geojson_path: Path) -> None:
"""Initialize parser with path to GeoJSON file."""
self.geojson_path = geojson_path
self._geojson: dict[str, Any] | None = None
@property
def geojson(self) -> dict[str, Any]:
"""Lazy-load and return raw GeoJSON data."""
if self._geojson is None:
self._geojson = load_geojson(self.geojson_path)
return self._geojson
def _find_property(
self, properties: dict[str, Any], candidates: list[str]
) -> str | None:
"""Find a property value by checking multiple candidate names."""
for name in candidates:
if name in properties and properties[name] is not None:
return str(properties[name])
return None
def parse(self) -> list[Neighbourhood]:
"""Parse GeoJSON and return list of Neighbourhood schemas.
Note: This parser only extracts ID, name, and geometry.
Census enrichment data (population, income, etc.) should be
loaded separately and merged.
"""
neighbourhoods = []
for feature in self.geojson.get("features", []):
props = feature.get("properties", {})
geom = feature.get("geometry")
neighbourhood_id_str = self._find_property(props, self.ID_PROPERTIES)
name = self._find_property(props, self.NAME_PROPERTIES)
if not neighbourhood_id_str:
raise ValueError(
f"Neighbourhood ID not found in properties: {list(props.keys())}"
)
neighbourhood_id = int(neighbourhood_id_str)
if not name:
name = f"Neighbourhood {neighbourhood_id}"
geometry_wkt = geometry_to_wkt(geom) if geom else None
neighbourhoods.append(
Neighbourhood(
neighbourhood_id=neighbourhood_id,
name=name,
geometry_wkt=geometry_wkt,
)
)
return neighbourhoods
def get_geojson_for_choropleth(
self, key_property: str = "neighbourhood_id"
) -> dict[str, Any]:
"""Get GeoJSON formatted for Plotly choropleth maps."""
features = []
for feature in self.geojson.get("features", []):
props = feature.get("properties", {})
new_props = dict(props)
neighbourhood_id = self._find_property(props, self.ID_PROPERTIES)
name = self._find_property(props, self.NAME_PROPERTIES)
new_props["neighbourhood_id"] = (
int(neighbourhood_id) if neighbourhood_id else None
)
new_props["name"] = name
features.append(
{
"type": "Feature",
"properties": new_props,
"geometry": feature.get("geometry"),
}
)
return {"type": "FeatureCollection", "features": features}

View File

@@ -0,0 +1,6 @@
"""Placeholder test to ensure pytest collection succeeds."""
def test_placeholder():
"""Remove this once real tests are added."""
assert True