staging #96

Merged
lmiranda merged 90 commits from staging into main 2026-02-01 21:33:13 +00:00
10 changed files with 1298 additions and 0 deletions
Showing only changes of commit ad6ee3d37f - Show all commits

View File

@@ -0,0 +1,14 @@
"""Shared Dash components for the portfolio application."""
from .map_controls import create_map_controls, create_metric_selector
from .metric_card import MetricCard, create_metric_cards_row
from .time_slider import create_time_slider, create_year_selector
__all__ = [
"create_map_controls",
"create_metric_selector",
"create_time_slider",
"create_year_selector",
"MetricCard",
"create_metric_cards_row",
]

View File

@@ -0,0 +1,79 @@
"""Map control components for choropleth visualizations."""
from typing import Any
import dash_mantine_components as dmc
from dash import html
def create_metric_selector(
id_prefix: str,
options: list[dict[str, str]],
default_value: str | None = None,
label: str = "Select Metric",
) -> dmc.Select:
"""Create a metric selector dropdown.
Args:
id_prefix: Prefix for component IDs.
options: List of options with 'label' and 'value' keys.
default_value: Initial selected value.
label: Label text for the selector.
Returns:
Mantine Select component.
"""
return dmc.Select(
id=f"{id_prefix}-metric-selector",
label=label,
data=options,
value=default_value or (options[0]["value"] if options else None),
style={"width": "200px"},
)
def create_map_controls(
id_prefix: str,
metric_options: list[dict[str, str]],
default_metric: str | None = None,
show_layer_toggle: bool = True,
) -> dmc.Paper:
"""Create a control panel for map visualizations.
Args:
id_prefix: Prefix for component IDs.
metric_options: Options for metric selector.
default_metric: Default selected metric.
show_layer_toggle: Whether to show layer visibility toggle.
Returns:
Mantine Paper component containing controls.
"""
controls: list[Any] = [
create_metric_selector(
id_prefix=id_prefix,
options=metric_options,
default_value=default_metric,
label="Display Metric",
),
]
if show_layer_toggle:
controls.append(
dmc.Switch(
id=f"{id_prefix}-layer-toggle",
label="Show Boundaries",
checked=True,
style={"marginTop": "10px"},
)
)
return dmc.Paper(
children=[
dmc.Text("Map Controls", fw=500, size="sm", mb="xs"),
html.Div(controls),
],
p="md",
radius="sm",
withBorder=True,
)

View File

@@ -0,0 +1,115 @@
"""Metric card components for KPI display."""
from typing import Any
import dash_mantine_components as dmc
from dash import dcc
from portfolio_app.figures.summary_cards import create_metric_card_figure
class MetricCard:
"""A reusable metric card component."""
def __init__(
self,
id_prefix: str,
title: str,
value: float | int | str = 0,
delta: float | None = None,
prefix: str = "",
suffix: str = "",
format_spec: str = ",.0f",
positive_is_good: bool = True,
):
"""Initialize a metric card.
Args:
id_prefix: Prefix for component IDs.
title: Card title.
value: Main metric value.
delta: Change value for delta indicator.
prefix: Value prefix (e.g., '$').
suffix: Value suffix.
format_spec: Python format specification.
positive_is_good: Whether positive delta is good.
"""
self.id_prefix = id_prefix
self.title = title
self.value = value
self.delta = delta
self.prefix = prefix
self.suffix = suffix
self.format_spec = format_spec
self.positive_is_good = positive_is_good
def render(self) -> dmc.Paper:
"""Render the metric card component.
Returns:
Mantine Paper component with embedded graph.
"""
fig = create_metric_card_figure(
value=self.value,
title=self.title,
delta=self.delta,
prefix=self.prefix,
suffix=self.suffix,
format_spec=self.format_spec,
positive_is_good=self.positive_is_good,
)
return dmc.Paper(
children=[
dcc.Graph(
id=f"{self.id_prefix}-graph",
figure=fig,
config={"displayModeBar": False},
style={"height": "120px"},
)
],
p="xs",
radius="sm",
withBorder=True,
)
def create_metric_cards_row(
metrics: list[dict[str, Any]],
id_prefix: str = "metric",
) -> dmc.SimpleGrid:
"""Create a row of metric cards.
Args:
metrics: List of metric configurations with keys:
- title: Card title
- value: Metric value
- delta: Optional change value
- prefix: Optional value prefix
- suffix: Optional value suffix
- format_spec: Optional format specification
- positive_is_good: Optional delta color logic
id_prefix: Prefix for component IDs.
Returns:
Mantine SimpleGrid component with metric cards.
"""
cards = []
for i, metric in enumerate(metrics):
card = MetricCard(
id_prefix=f"{id_prefix}-{i}",
title=metric.get("title", ""),
value=metric.get("value", 0),
delta=metric.get("delta"),
prefix=metric.get("prefix", ""),
suffix=metric.get("suffix", ""),
format_spec=metric.get("format_spec", ",.0f"),
positive_is_good=metric.get("positive_is_good", True),
)
cards.append(card.render())
return dmc.SimpleGrid(
cols={"base": 1, "sm": 2, "md": len(cards)},
spacing="md",
children=cards,
)

View File

@@ -0,0 +1,135 @@
"""Time selection components for temporal data filtering."""
from datetime import date
import dash_mantine_components as dmc
def create_year_selector(
id_prefix: str,
min_year: int = 2020,
max_year: int | None = None,
default_year: int | None = None,
label: str = "Select Year",
) -> dmc.Select:
"""Create a year selector dropdown.
Args:
id_prefix: Prefix for component IDs.
min_year: Minimum year option.
max_year: Maximum year option (defaults to current year).
default_year: Initial selected year.
label: Label text for the selector.
Returns:
Mantine Select component.
"""
if max_year is None:
max_year = date.today().year
if default_year is None:
default_year = max_year
years = list(range(max_year, min_year - 1, -1))
options = [{"label": str(year), "value": str(year)} for year in years]
return dmc.Select(
id=f"{id_prefix}-year-selector",
label=label,
data=options,
value=str(default_year),
style={"width": "120px"},
)
def create_time_slider(
id_prefix: str,
min_year: int = 2020,
max_year: int | None = None,
default_range: tuple[int, int] | None = None,
label: str = "Time Range",
) -> dmc.Paper:
"""Create a time range slider component.
Args:
id_prefix: Prefix for component IDs.
min_year: Minimum year for the slider.
max_year: Maximum year for the slider.
default_range: Default (start, end) year range.
label: Label text for the slider.
Returns:
Mantine Paper component containing the slider.
"""
if max_year is None:
max_year = date.today().year
if default_range is None:
default_range = (min_year, max_year)
# Create marks for every year
marks = [
{"value": year, "label": str(year)} for year in range(min_year, max_year + 1)
]
return dmc.Paper(
children=[
dmc.Text(label, fw=500, size="sm", mb="xs"),
dmc.RangeSlider(
id=f"{id_prefix}-time-slider",
min=min_year,
max=max_year,
value=list(default_range),
marks=marks,
step=1,
minRange=1,
style={"marginTop": "20px", "marginBottom": "10px"},
),
],
p="md",
radius="sm",
withBorder=True,
)
def create_month_selector(
id_prefix: str,
default_month: int | None = None,
label: str = "Select Month",
) -> dmc.Select:
"""Create a month selector dropdown.
Args:
id_prefix: Prefix for component IDs.
default_month: Initial selected month (1-12).
label: Label text for the selector.
Returns:
Mantine Select component.
"""
months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
]
options = [{"label": month, "value": str(i + 1)} for i, month in enumerate(months)]
if default_month is None:
default_month = date.today().month
return dmc.Select(
id=f"{id_prefix}-month-selector",
label=label,
data=options,
value=str(default_month),
style={"width": "140px"},
)

View File

@@ -0,0 +1,12 @@
"""Plotly figure factories for data visualization."""
from .choropleth import create_choropleth_figure
from .summary_cards import create_metric_card_figure
from .time_series import create_price_time_series, create_volume_time_series
__all__ = [
"create_choropleth_figure",
"create_price_time_series",
"create_volume_time_series",
"create_metric_card_figure",
]

View File

@@ -0,0 +1,152 @@
"""Choropleth map figure factory for Toronto housing data."""
from typing import Any
import plotly.express as px
import plotly.graph_objects as go
def create_choropleth_figure(
geojson: dict[str, Any] | None,
data: list[dict[str, Any]],
location_key: str,
color_column: str,
hover_data: list[str] | None = None,
color_scale: str = "Blues",
title: str | None = None,
map_style: str = "carto-positron",
center: dict[str, float] | None = None,
zoom: float = 9.5,
) -> go.Figure:
"""Create a choropleth map figure.
Args:
geojson: GeoJSON FeatureCollection for boundaries.
data: List of data records with location keys and values.
location_key: Column name for location identifier.
color_column: Column name for color values.
hover_data: Additional columns to show on hover.
color_scale: Plotly color scale name.
title: Optional chart title.
map_style: Mapbox style (carto-positron, open-street-map, etc.).
center: Map center coordinates {"lat": float, "lon": float}.
zoom: Initial zoom level.
Returns:
Plotly Figure object.
"""
# Default center to Toronto
if center is None:
center = {"lat": 43.7, "lon": -79.4}
# If no geojson provided, create a placeholder map
if geojson is None or not data:
fig = go.Figure(go.Scattermapbox())
fig.update_layout(
mapbox={
"style": map_style,
"center": center,
"zoom": zoom,
},
margin={"l": 0, "r": 0, "t": 40, "b": 0},
title=title or "Toronto Housing Map",
height=500,
)
fig.add_annotation(
text="No geometry data available. Complete QGIS digitization to enable map.",
xref="paper",
yref="paper",
x=0.5,
y=0.5,
showarrow=False,
font={"size": 14, "color": "gray"},
)
return fig
# Create choropleth with data
import pandas as pd
df = pd.DataFrame(data)
fig = px.choropleth_mapbox(
df,
geojson=geojson,
locations=location_key,
featureidkey=f"properties.{location_key}",
color=color_column,
color_continuous_scale=color_scale,
hover_data=hover_data,
mapbox_style=map_style,
center=center,
zoom=zoom,
opacity=0.7,
)
fig.update_layout(
margin={"l": 0, "r": 0, "t": 40, "b": 0},
title=title,
height=500,
coloraxis_colorbar={
"title": color_column.replace("_", " ").title(),
"thickness": 15,
"len": 0.7,
},
)
return fig
def create_district_map(
districts_geojson: dict[str, Any] | None,
purchase_data: list[dict[str, Any]],
metric: str = "avg_price",
) -> go.Figure:
"""Create choropleth map for TRREB districts.
Args:
districts_geojson: GeoJSON for TRREB district boundaries.
purchase_data: Purchase statistics by district.
metric: Metric to display (avg_price, sales_count, etc.).
Returns:
Plotly Figure object.
"""
hover_columns = ["district_name", "sales_count", "avg_price", "median_price"]
return create_choropleth_figure(
geojson=districts_geojson,
data=purchase_data,
location_key="district_code",
color_column=metric,
hover_data=[c for c in hover_columns if c != metric],
color_scale="Blues" if "price" in metric else "Greens",
title="Toronto Purchase Market by District",
)
def create_zone_map(
zones_geojson: dict[str, Any] | None,
rental_data: list[dict[str, Any]],
metric: str = "avg_rent",
) -> go.Figure:
"""Create choropleth map for CMHC zones.
Args:
zones_geojson: GeoJSON for CMHC zone boundaries.
rental_data: Rental statistics by zone.
metric: Metric to display (avg_rent, vacancy_rate, etc.).
Returns:
Plotly Figure object.
"""
hover_columns = ["zone_name", "avg_rent", "vacancy_rate", "rental_universe"]
return create_choropleth_figure(
geojson=zones_geojson,
data=rental_data,
location_key="zone_code",
color_column=metric,
hover_data=[c for c in hover_columns if c != metric],
color_scale="Oranges" if "rent" in metric else "Purples",
title="Toronto Rental Market by Zone",
)

View File

@@ -0,0 +1,106 @@
"""Summary card figure factories for KPI display."""
from typing import Any
import plotly.graph_objects as go
def create_metric_card_figure(
value: float | int | str,
title: str,
delta: float | None = None,
delta_suffix: str = "%",
prefix: str = "",
suffix: str = "",
format_spec: str = ",.0f",
positive_is_good: bool = True,
) -> go.Figure:
"""Create a KPI indicator figure.
Args:
value: The main metric value.
title: Card title.
delta: Optional change value (for delta indicator).
delta_suffix: Suffix for delta value (e.g., '%').
prefix: Prefix for main value (e.g., '$').
suffix: Suffix for main value.
format_spec: Python format specification for the value.
positive_is_good: Whether positive delta is good (green) or bad (red).
Returns:
Plotly Figure object.
"""
# Determine numeric value for indicator
if isinstance(value, int | float):
number_value: float | None = float(value)
else:
number_value = None
fig = go.Figure()
# Add indicator trace
indicator_config: dict[str, Any] = {
"mode": "number",
"value": number_value if number_value is not None else 0,
"title": {"text": title, "font": {"size": 14}},
"number": {
"font": {"size": 32},
"prefix": prefix,
"suffix": suffix,
"valueformat": format_spec,
},
}
# Add delta if provided
if delta is not None:
indicator_config["mode"] = "number+delta"
indicator_config["delta"] = {
"reference": number_value - delta if number_value else 0,
"relative": False,
"valueformat": ".1f",
"suffix": delta_suffix,
"increasing": {"color": "green" if positive_is_good else "red"},
"decreasing": {"color": "red" if positive_is_good else "green"},
}
fig.add_trace(go.Indicator(**indicator_config))
fig.update_layout(
height=120,
margin={"l": 20, "r": 20, "t": 40, "b": 20},
paper_bgcolor="rgba(0,0,0,0)",
font={"family": "Inter, sans-serif"},
)
return fig
def create_summary_metrics(
metrics: dict[str, dict[str, Any]],
) -> list[go.Figure]:
"""Create multiple metric card figures.
Args:
metrics: Dictionary of metric configurations.
Key: metric name
Value: dict with 'value', 'title', 'delta' (optional), etc.
Returns:
List of Plotly Figure objects.
"""
figures = []
for metric_config in metrics.values():
fig = create_metric_card_figure(
value=metric_config.get("value", 0),
title=metric_config.get("title", ""),
delta=metric_config.get("delta"),
delta_suffix=metric_config.get("delta_suffix", "%"),
prefix=metric_config.get("prefix", ""),
suffix=metric_config.get("suffix", ""),
format_spec=metric_config.get("format_spec", ",.0f"),
positive_is_good=metric_config.get("positive_is_good", True),
)
figures.append(fig)
return figures

View File

@@ -0,0 +1,233 @@
"""Time series figure factories for Toronto housing data."""
from typing import Any
import plotly.express as px
import plotly.graph_objects as go
def create_price_time_series(
data: list[dict[str, Any]],
date_column: str = "full_date",
price_column: str = "avg_price",
group_column: str | None = None,
title: str = "Average Price Over Time",
show_yoy: bool = True,
) -> go.Figure:
"""Create a time series chart for price data.
Args:
data: List of records with date and price columns.
date_column: Column name for dates.
price_column: Column name for price values.
group_column: Optional column for grouping (e.g., district_code).
title: Chart title.
show_yoy: Whether to show year-over-year change annotations.
Returns:
Plotly Figure object.
"""
import pandas as pd
if not data:
fig = go.Figure()
fig.add_annotation(
text="No data available",
xref="paper",
yref="paper",
x=0.5,
y=0.5,
showarrow=False,
)
fig.update_layout(title=title, height=350)
return fig
df = pd.DataFrame(data)
df[date_column] = pd.to_datetime(df[date_column])
if group_column and group_column in df.columns:
fig = px.line(
df,
x=date_column,
y=price_column,
color=group_column,
title=title,
)
else:
fig = px.line(
df,
x=date_column,
y=price_column,
title=title,
)
fig.update_layout(
height=350,
margin={"l": 40, "r": 20, "t": 50, "b": 40},
xaxis_title="Date",
yaxis_title=price_column.replace("_", " ").title(),
yaxis_tickprefix="$",
yaxis_tickformat=",",
hovermode="x unified",
)
return fig
def create_volume_time_series(
data: list[dict[str, Any]],
date_column: str = "full_date",
volume_column: str = "sales_count",
group_column: str | None = None,
title: str = "Sales Volume Over Time",
chart_type: str = "bar",
) -> go.Figure:
"""Create a time series chart for volume/count data.
Args:
data: List of records with date and volume columns.
date_column: Column name for dates.
volume_column: Column name for volume values.
group_column: Optional column for grouping.
title: Chart title.
chart_type: 'bar' or 'line'.
Returns:
Plotly Figure object.
"""
import pandas as pd
if not data:
fig = go.Figure()
fig.add_annotation(
text="No data available",
xref="paper",
yref="paper",
x=0.5,
y=0.5,
showarrow=False,
)
fig.update_layout(title=title, height=350)
return fig
df = pd.DataFrame(data)
df[date_column] = pd.to_datetime(df[date_column])
if chart_type == "bar":
if group_column and group_column in df.columns:
fig = px.bar(
df,
x=date_column,
y=volume_column,
color=group_column,
title=title,
)
else:
fig = px.bar(
df,
x=date_column,
y=volume_column,
title=title,
)
else:
if group_column and group_column in df.columns:
fig = px.line(
df,
x=date_column,
y=volume_column,
color=group_column,
title=title,
)
else:
fig = px.line(
df,
x=date_column,
y=volume_column,
title=title,
)
fig.update_layout(
height=350,
margin={"l": 40, "r": 20, "t": 50, "b": 40},
xaxis_title="Date",
yaxis_title=volume_column.replace("_", " ").title(),
yaxis_tickformat=",",
hovermode="x unified",
)
return fig
def create_market_comparison_chart(
data: list[dict[str, Any]],
date_column: str = "full_date",
metrics: list[str] | None = None,
title: str = "Market Indicators",
) -> go.Figure:
"""Create a multi-metric comparison chart.
Args:
data: List of records with date and metric columns.
date_column: Column name for dates.
metrics: List of metric columns to display.
title: Chart title.
Returns:
Plotly Figure object with secondary y-axis.
"""
import pandas as pd
from plotly.subplots import make_subplots
if not data:
fig = go.Figure()
fig.add_annotation(
text="No data available",
xref="paper",
yref="paper",
x=0.5,
y=0.5,
showarrow=False,
)
fig.update_layout(title=title, height=400)
return fig
if metrics is None:
metrics = ["avg_price", "sales_count"]
df = pd.DataFrame(data)
df[date_column] = pd.to_datetime(df[date_column])
fig = make_subplots(specs=[[{"secondary_y": True}]])
colors = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728"]
for i, metric in enumerate(metrics[:4]):
if metric not in df.columns:
continue
secondary = i > 0
fig.add_trace(
go.Scatter(
x=df[date_column],
y=df[metric],
name=metric.replace("_", " ").title(),
line={"color": colors[i % len(colors)]},
),
secondary_y=secondary,
)
fig.update_layout(
title=title,
height=400,
margin={"l": 40, "r": 40, "t": 50, "b": 40},
hovermode="x unified",
legend={
"orientation": "h",
"yanchor": "bottom",
"y": 1.02,
"xanchor": "right",
"x": 1,
},
)
return fig

View File

@@ -1 +1,282 @@
"""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",
)

View File

@@ -1 +1,172 @@
"""Toronto dashboard callbacks."""
import plotly.graph_objects as go
from dash import Input, Output, callback
from portfolio_app.figures.choropleth import create_choropleth_figure
from portfolio_app.figures.time_series import (
create_market_comparison_chart,
create_price_time_series,
create_volume_time_series,
)
# Sample data for demonstration (will be replaced with database queries)
SAMPLE_PURCHASE_DATA = [
{
"district_code": "C01",
"district_name": "Downtown Toronto",
"avg_price": 1250000,
"median_price": 1100000,
"sales_count": 450,
"avg_dom": 15,
},
{
"district_code": "C02",
"district_name": "Annex/Yorkville",
"avg_price": 1850000,
"median_price": 1650000,
"sales_count": 280,
"avg_dom": 12,
},
{
"district_code": "E01",
"district_name": "East York",
"avg_price": 980000,
"median_price": 920000,
"sales_count": 320,
"avg_dom": 18,
},
{
"district_code": "W01",
"district_name": "Etobicoke South",
"avg_price": 875000,
"median_price": 825000,
"sales_count": 410,
"avg_dom": 20,
},
]
SAMPLE_RENTAL_DATA = [
{
"zone_code": "Z01",
"zone_name": "Toronto Core",
"avg_rent": 2650,
"vacancy_rate": 2.1,
"rental_universe": 125000,
},
{
"zone_code": "Z02",
"zone_name": "North York",
"avg_rent": 2200,
"vacancy_rate": 2.8,
"rental_universe": 85000,
},
{
"zone_code": "Z03",
"zone_name": "Scarborough",
"avg_rent": 1950,
"vacancy_rate": 3.2,
"rental_universe": 72000,
},
]
SAMPLE_TIME_SERIES_DATA = [
{"full_date": "2023-01-01", "avg_price": 1050000, "sales_count": 3200},
{"full_date": "2023-02-01", "avg_price": 1080000, "sales_count": 3400},
{"full_date": "2023-03-01", "avg_price": 1120000, "sales_count": 4100},
{"full_date": "2023-04-01", "avg_price": 1150000, "sales_count": 4500},
{"full_date": "2023-05-01", "avg_price": 1180000, "sales_count": 4800},
{"full_date": "2023-06-01", "avg_price": 1160000, "sales_count": 4600},
{"full_date": "2023-07-01", "avg_price": 1140000, "sales_count": 4200},
{"full_date": "2023-08-01", "avg_price": 1130000, "sales_count": 4000},
{"full_date": "2023-09-01", "avg_price": 1125000, "sales_count": 3800},
{"full_date": "2023-10-01", "avg_price": 1110000, "sales_count": 3600},
{"full_date": "2023-11-01", "avg_price": 1100000, "sales_count": 3400},
{"full_date": "2023-12-01", "avg_price": 1090000, "sales_count": 3000},
{"full_date": "2024-01-01", "avg_price": 1095000, "sales_count": 3100},
{"full_date": "2024-02-01", "avg_price": 1105000, "sales_count": 3300},
{"full_date": "2024-03-01", "avg_price": 1125000, "sales_count": 4000},
{"full_date": "2024-04-01", "avg_price": 1140000, "sales_count": 4400},
{"full_date": "2024-05-01", "avg_price": 1155000, "sales_count": 4700},
{"full_date": "2024-06-01", "avg_price": 1145000, "sales_count": 4500},
]
@callback( # type: ignore[misc]
Output("purchase-choropleth", "figure"),
Input("purchase-map-metric-selector", "value"),
Input("toronto-year-selector", "value"),
)
def update_purchase_choropleth(metric: str, year: str) -> go.Figure:
"""Update the purchase market choropleth map."""
# In production, this would query the database
# For now, return a placeholder map without geojson
return create_choropleth_figure(
geojson=None, # Will be populated when QGIS digitization is complete
data=SAMPLE_PURCHASE_DATA,
location_key="district_code",
color_column=metric or "avg_price",
hover_data=["district_name", "sales_count"],
title=f"Purchase Market - {metric.replace('_', ' ').title() if metric else 'Average Price'}",
)
@callback( # type: ignore[misc]
Output("rental-choropleth", "figure"),
Input("rental-map-metric-selector", "value"),
Input("toronto-year-selector", "value"),
)
def update_rental_choropleth(metric: str, year: str) -> go.Figure:
"""Update the rental market choropleth map."""
return create_choropleth_figure(
geojson=None, # Will be populated when QGIS digitization is complete
data=SAMPLE_RENTAL_DATA,
location_key="zone_code",
color_column=metric or "avg_rent",
hover_data=["zone_name", "vacancy_rate"],
color_scale="Oranges",
title=f"Rental Market - {metric.replace('_', ' ').title() if metric else 'Average Rent'}",
)
@callback( # type: ignore[misc]
Output("price-time-series", "figure"),
Input("toronto-year-selector", "value"),
)
def update_price_time_series(year: str) -> go.Figure:
"""Update the price time series chart."""
return create_price_time_series(
data=SAMPLE_TIME_SERIES_DATA,
date_column="full_date",
price_column="avg_price",
title="Average Price Trend",
)
@callback( # type: ignore[misc]
Output("volume-time-series", "figure"),
Input("toronto-year-selector", "value"),
)
def update_volume_time_series(year: str) -> go.Figure:
"""Update the volume time series chart."""
return create_volume_time_series(
data=SAMPLE_TIME_SERIES_DATA,
date_column="full_date",
volume_column="sales_count",
title="Sales Volume Trend",
chart_type="bar",
)
@callback( # type: ignore[misc]
Output("market-comparison-chart", "figure"),
Input("market-comparison-time-slider", "value"),
)
def update_market_comparison(time_range: list[int]) -> go.Figure:
"""Update the market comparison chart."""
return create_market_comparison_chart(
data=SAMPLE_TIME_SERIES_DATA,
date_column="full_date",
metrics=["avg_price", "sales_count"],
title="Market Indicators Comparison",
)