Compare commits
6 Commits
19ffc04573
...
sprint-7-c
| Author | SHA1 | Date | |
|---|---|---|---|
| d64f90b3d3 | |||
| b3fb94c7cb | |||
| 1e0ea9cca2 | |||
| 9dfa24fb76 | |||
| 8701a12b41 | |||
| 6ef5460ad0 |
@@ -7,7 +7,7 @@ repos:
|
|||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
args: ['--maxkb=1000']
|
args: ['--maxkb=1000']
|
||||||
exclude: ^data/raw/
|
exclude: ^data/(raw/|toronto/raw/geo/)
|
||||||
- id: check-merge-conflict
|
- id: check-merge-conflict
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Working context for Claude Code on the Analytics Portfolio project.
|
|||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
**Current Sprint**: 1 (Project Bootstrap)
|
**Current Sprint**: 7 (Navigation & Theme Modernization)
|
||||||
**Phase**: 1 - Toronto Housing Dashboard
|
**Phase**: 1 - Toronto Housing Dashboard
|
||||||
**Branch**: `development` (feature branches merge here)
|
**Branch**: `development` (feature branches merge here)
|
||||||
|
|
||||||
@@ -254,4 +254,4 @@ All scripts in `scripts/`:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Last Updated: Sprint 1*
|
*Last Updated: Sprint 7*
|
||||||
|
|||||||
0
data/toronto/raw/geo/.gitkeep
Normal file
0
data/toronto/raw/geo/.gitkeep
Normal file
38
data/toronto/raw/geo/cmhc_zones.geojson
Normal file
38
data/toronto/raw/geo/cmhc_zones.geojson
Normal file
File diff suppressed because one or more lines are too long
1
data/toronto/raw/geo/toronto_neighbourhoods.geojson
Normal file
1
data/toronto/raw/geo/toronto_neighbourhoods.geojson
Normal file
File diff suppressed because one or more lines are too long
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import dash
|
import dash
|
||||||
import dash_mantine_components as dmc
|
import dash_mantine_components as dmc
|
||||||
|
from dash import dcc, html
|
||||||
|
|
||||||
|
from .components import create_sidebar
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
|
|
||||||
|
|
||||||
@@ -17,14 +19,31 @@ def create_app() -> dash.Dash:
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.layout = dmc.MantineProvider(
|
app.layout = dmc.MantineProvider(
|
||||||
|
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,
|
dash.page_container,
|
||||||
|
className="page-content-wrapper",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
theme={
|
theme={
|
||||||
"primaryColor": "blue",
|
"primaryColor": "blue",
|
||||||
"fontFamily": "'Inter', sans-serif",
|
"fontFamily": "'Inter', sans-serif",
|
||||||
},
|
},
|
||||||
forceColorScheme="light",
|
defaultColorScheme="dark",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Import callbacks to register them
|
||||||
|
from . import callbacks # noqa: F401
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
139
portfolio_app/assets/sidebar.css
Normal file
139
portfolio_app/assets/sidebar.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
portfolio_app/callbacks/__init__.py
Normal file
5
portfolio_app/callbacks/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Application-level callbacks for the portfolio app."""
|
||||||
|
|
||||||
|
from . import theme
|
||||||
|
|
||||||
|
__all__ = ["theme"]
|
||||||
38
portfolio_app/callbacks/theme.py
Normal file
38
portfolio_app/callbacks/theme.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
from .map_controls import create_map_controls, create_metric_selector
|
from .map_controls import create_map_controls, create_metric_selector
|
||||||
from .metric_card import MetricCard, create_metric_cards_row
|
from .metric_card import MetricCard, create_metric_cards_row
|
||||||
|
from .sidebar import create_sidebar
|
||||||
from .time_slider import create_time_slider, create_year_selector
|
from .time_slider import create_time_slider, create_year_selector
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"create_map_controls",
|
"create_map_controls",
|
||||||
"create_metric_selector",
|
"create_metric_selector",
|
||||||
|
"create_sidebar",
|
||||||
"create_time_slider",
|
"create_time_slider",
|
||||||
"create_year_selector",
|
"create_year_selector",
|
||||||
"MetricCard",
|
"MetricCard",
|
||||||
|
|||||||
179
portfolio_app/components/sidebar.py
Normal file
179
portfolio_app/components/sidebar.py
Normal 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",
|
||||||
|
)
|
||||||
@@ -39,6 +39,10 @@ def create_choropleth_figure(
|
|||||||
if center is None:
|
if center is None:
|
||||||
center = {"lat": 43.7, "lon": -79.4}
|
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 no geojson provided, create a placeholder map
|
||||||
if geojson is None or not data:
|
if geojson is None or not data:
|
||||||
fig = go.Figure(go.Scattermapbox())
|
fig = go.Figure(go.Scattermapbox())
|
||||||
@@ -51,6 +55,9 @@ def create_choropleth_figure(
|
|||||||
margin={"l": 0, "r": 0, "t": 40, "b": 0},
|
margin={"l": 0, "r": 0, "t": 40, "b": 0},
|
||||||
title=title or "Toronto Housing Map",
|
title=title or "Toronto Housing Map",
|
||||||
height=500,
|
height=500,
|
||||||
|
paper_bgcolor="rgba(0,0,0,0)",
|
||||||
|
plot_bgcolor="rgba(0,0,0,0)",
|
||||||
|
font_color="#c9c9c9",
|
||||||
)
|
)
|
||||||
fig.add_annotation(
|
fig.add_annotation(
|
||||||
text="No geometry data available. Complete QGIS digitization to enable map.",
|
text="No geometry data available. Complete QGIS digitization to enable map.",
|
||||||
@@ -59,7 +66,7 @@ def create_choropleth_figure(
|
|||||||
x=0.5,
|
x=0.5,
|
||||||
y=0.5,
|
y=0.5,
|
||||||
showarrow=False,
|
showarrow=False,
|
||||||
font={"size": 14, "color": "gray"},
|
font={"size": 14, "color": "#888888"},
|
||||||
)
|
)
|
||||||
return fig
|
return fig
|
||||||
|
|
||||||
@@ -68,6 +75,11 @@ def create_choropleth_figure(
|
|||||||
|
|
||||||
df = pd.DataFrame(data)
|
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(
|
fig = px.choropleth_mapbox(
|
||||||
df,
|
df,
|
||||||
geojson=geojson,
|
geojson=geojson,
|
||||||
@@ -76,7 +88,7 @@ def create_choropleth_figure(
|
|||||||
color=color_column,
|
color=color_column,
|
||||||
color_continuous_scale=color_scale,
|
color_continuous_scale=color_scale,
|
||||||
hover_data=hover_data,
|
hover_data=hover_data,
|
||||||
mapbox_style=map_style,
|
mapbox_style=effective_map_style,
|
||||||
center=center,
|
center=center,
|
||||||
zoom=zoom,
|
zoom=zoom,
|
||||||
opacity=0.7,
|
opacity=0.7,
|
||||||
@@ -86,10 +98,17 @@ def create_choropleth_figure(
|
|||||||
margin={"l": 0, "r": 0, "t": 40, "b": 0},
|
margin={"l": 0, "r": 0, "t": 40, "b": 0},
|
||||||
title=title,
|
title=title,
|
||||||
height=500,
|
height=500,
|
||||||
|
paper_bgcolor="rgba(0,0,0,0)",
|
||||||
|
plot_bgcolor="rgba(0,0,0,0)",
|
||||||
|
font_color="#c9c9c9",
|
||||||
coloraxis_colorbar={
|
coloraxis_colorbar={
|
||||||
"title": color_column.replace("_", " ").title(),
|
"title": {
|
||||||
|
"text": color_column.replace("_", " ").title(),
|
||||||
|
"font": {"color": "#c9c9c9"},
|
||||||
|
},
|
||||||
"thickness": 15,
|
"thickness": 15,
|
||||||
"len": 0.7,
|
"len": 0.7,
|
||||||
|
"tickfont": {"color": "#c9c9c9"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ def create_metric_card_figure(
|
|||||||
height=120,
|
height=120,
|
||||||
margin={"l": 20, "r": 20, "t": 40, "b": 20},
|
margin={"l": 20, "r": 20, "t": 40, "b": 20},
|
||||||
paper_bgcolor="rgba(0,0,0,0)",
|
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
|
return fig
|
||||||
|
|||||||
@@ -38,8 +38,15 @@ def create_price_time_series(
|
|||||||
x=0.5,
|
x=0.5,
|
||||||
y=0.5,
|
y=0.5,
|
||||||
showarrow=False,
|
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
|
return fig
|
||||||
|
|
||||||
df = pd.DataFrame(data)
|
df = pd.DataFrame(data)
|
||||||
@@ -69,6 +76,11 @@ def create_price_time_series(
|
|||||||
yaxis_tickprefix="$",
|
yaxis_tickprefix="$",
|
||||||
yaxis_tickformat=",",
|
yaxis_tickformat=",",
|
||||||
hovermode="x unified",
|
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
|
return fig
|
||||||
@@ -106,8 +118,15 @@ def create_volume_time_series(
|
|||||||
x=0.5,
|
x=0.5,
|
||||||
y=0.5,
|
y=0.5,
|
||||||
showarrow=False,
|
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
|
return fig
|
||||||
|
|
||||||
df = pd.DataFrame(data)
|
df = pd.DataFrame(data)
|
||||||
@@ -153,6 +172,11 @@ def create_volume_time_series(
|
|||||||
yaxis_title=volume_column.replace("_", " ").title(),
|
yaxis_title=volume_column.replace("_", " ").title(),
|
||||||
yaxis_tickformat=",",
|
yaxis_tickformat=",",
|
||||||
hovermode="x unified",
|
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
|
return fig
|
||||||
@@ -187,8 +211,15 @@ def create_market_comparison_chart(
|
|||||||
x=0.5,
|
x=0.5,
|
||||||
y=0.5,
|
y=0.5,
|
||||||
showarrow=False,
|
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
|
return fig
|
||||||
|
|
||||||
if metrics is None:
|
if metrics is None:
|
||||||
@@ -221,12 +252,18 @@ def create_market_comparison_chart(
|
|||||||
height=400,
|
height=400,
|
||||||
margin={"l": 40, "r": 40, "t": 50, "b": 40},
|
margin={"l": 40, "r": 40, "t": 50, "b": 40},
|
||||||
hovermode="x unified",
|
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={
|
legend={
|
||||||
"orientation": "h",
|
"orientation": "h",
|
||||||
"yanchor": "bottom",
|
"yanchor": "bottom",
|
||||||
"y": 1.02,
|
"y": 1.02,
|
||||||
"xanchor": "right",
|
"xanchor": "right",
|
||||||
"x": 1,
|
"x": 1,
|
||||||
|
"font": {"color": "#c9c9c9"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import dash
|
import dash
|
||||||
import dash_mantine_components as dmc
|
import dash_mantine_components as dmc
|
||||||
from dash_iconify import DashIconify
|
|
||||||
|
|
||||||
dash.register_page(__name__, path="/", name="Home")
|
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."
|
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:
|
def create_availability_section() -> dmc.Text:
|
||||||
"""Create the availability statement."""
|
"""Create the availability statement."""
|
||||||
return dmc.Text(AVAILABILITY, size="sm", c="dimmed", ta="center", fs="italic")
|
return dmc.Text(AVAILABILITY, size="sm", c="dimmed", ta="center", fs="italic")
|
||||||
@@ -193,7 +158,6 @@ layout = dmc.Container(
|
|||||||
create_summary_section(),
|
create_summary_section(),
|
||||||
create_tech_stack_section(),
|
create_tech_stack_section(),
|
||||||
create_projects_section(),
|
create_projects_section(),
|
||||||
create_social_links(),
|
|
||||||
dmc.Divider(my="lg"),
|
dmc.Divider(my="lg"),
|
||||||
create_availability_section(),
|
create_availability_section(),
|
||||||
dmc.Space(h=40),
|
dmc.Space(h=40),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
import dash
|
import dash
|
||||||
import dash_mantine_components as dmc
|
import dash_mantine_components as dmc
|
||||||
from dash import dcc, html
|
from dash import dcc, html
|
||||||
|
from dash_iconify import DashIconify
|
||||||
|
|
||||||
from portfolio_app.components import (
|
from portfolio_app.components import (
|
||||||
create_map_controls,
|
create_map_controls,
|
||||||
@@ -76,6 +77,17 @@ def create_header() -> dmc.Group:
|
|||||||
),
|
),
|
||||||
dmc.Group(
|
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(
|
create_year_selector(
|
||||||
id_prefix="toronto",
|
id_prefix="toronto",
|
||||||
min_year=2020,
|
min_year=2020,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import dash
|
import dash
|
||||||
import dash_mantine_components as dmc
|
import dash_mantine_components as dmc
|
||||||
from dash import html
|
from dash import dcc, html
|
||||||
|
from dash_iconify import DashIconify
|
||||||
|
|
||||||
dash.register_page(
|
dash.register_page(
|
||||||
__name__,
|
__name__,
|
||||||
@@ -18,8 +19,18 @@ def layout() -> dmc.Container:
|
|||||||
size="md",
|
size="md",
|
||||||
py="xl",
|
py="xl",
|
||||||
children=[
|
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
|
# Header
|
||||||
dmc.Title("Methodology", order=1, mb="lg"),
|
dmc.Title("Methodology", order=1, mb="lg", mt="md"),
|
||||||
dmc.Text(
|
dmc.Text(
|
||||||
"This page documents the data sources, processing methodology, "
|
"This page documents the data sources, processing methodology, "
|
||||||
"and known limitations of the Toronto Housing Dashboard.",
|
"and known limitations of the Toronto Housing Dashboard.",
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
"""Parsers for Toronto housing data sources."""
|
"""Parsers for Toronto housing data sources."""
|
||||||
|
|
||||||
from .cmhc import CMHCParser
|
from .cmhc import CMHCParser
|
||||||
|
from .geo import (
|
||||||
|
CMHCZoneParser,
|
||||||
|
NeighbourhoodParser,
|
||||||
|
TRREBDistrictParser,
|
||||||
|
load_geojson,
|
||||||
|
)
|
||||||
from .trreb import TRREBParser
|
from .trreb import TRREBParser
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"TRREBParser",
|
"TRREBParser",
|
||||||
"CMHCParser",
|
"CMHCParser",
|
||||||
|
# GeoJSON parsers
|
||||||
|
"CMHCZoneParser",
|
||||||
|
"TRREBDistrictParser",
|
||||||
|
"NeighbourhoodParser",
|
||||||
|
"load_geojson",
|
||||||
]
|
]
|
||||||
|
|||||||
463
portfolio_app/toronto/parsers/geo.py
Normal file
463
portfolio_app/toronto/parsers/geo.py
Normal 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}
|
||||||
6
tests/test_placeholder.py
Normal file
6
tests/test_placeholder.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Placeholder test to ensure pytest collection succeeds."""
|
||||||
|
|
||||||
|
|
||||||
|
def test_placeholder():
|
||||||
|
"""Remove this once real tests are added."""
|
||||||
|
assert True
|
||||||
Reference in New Issue
Block a user