refactor: multi-dashboard structural migration
Some checks failed
CI / lint-and-test (pull_request) Has been cancelled

- Rename dbt project from toronto_housing to portfolio
- Restructure dbt models into domain subdirectories:
  - shared/ for cross-domain dimensions (dim_time)
  - staging/toronto/, intermediate/toronto/, marts/toronto/
- Update SQLAlchemy models for raw_toronto schema
- Add explicit cross-schema FK relationships for FactRentals
- Namespace figure factories under figures/toronto/
- Namespace notebooks under notebooks/toronto/
- Update Makefile with domain-specific targets and env loading
- Update all documentation for multi-dashboard structure

This enables adding new dashboard projects (e.g., /football, /energy)
without structural conflicts or naming collisions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 19:08:20 -05:00
parent a5d6866d63
commit 62d1a52eed
73 changed files with 1114 additions and 623 deletions

View File

@@ -0,0 +1,61 @@
"""Plotly figure factories for Toronto dashboard visualizations."""
from .bar_charts import (
create_horizontal_bar,
create_ranking_bar,
create_stacked_bar,
)
from .choropleth import (
create_choropleth_figure,
create_zone_map,
)
from .demographics import (
create_age_pyramid,
create_donut_chart,
create_income_distribution,
)
from .radar import (
create_comparison_radar,
create_radar_figure,
)
from .scatter import (
create_bubble_chart,
create_scatter_figure,
)
from .summary_cards import create_metric_card_figure, create_summary_metrics
from .time_series import (
add_policy_markers,
create_market_comparison_chart,
create_price_time_series,
create_time_series_with_events,
create_volume_time_series,
)
__all__ = [
# Choropleth
"create_choropleth_figure",
"create_zone_map",
# Time series
"create_price_time_series",
"create_volume_time_series",
"create_market_comparison_chart",
"create_time_series_with_events",
"add_policy_markers",
# Summary
"create_metric_card_figure",
"create_summary_metrics",
# Bar charts
"create_ranking_bar",
"create_stacked_bar",
"create_horizontal_bar",
# Scatter plots
"create_scatter_figure",
"create_bubble_chart",
# Radar charts
"create_radar_figure",
"create_comparison_radar",
# Demographics
"create_age_pyramid",
"create_donut_chart",
"create_income_distribution",
]

View File

@@ -0,0 +1,238 @@
"""Bar chart figure factories for dashboard visualizations."""
from typing import Any
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
def create_ranking_bar(
data: list[dict[str, Any]],
name_column: str,
value_column: str,
title: str | None = None,
top_n: int = 10,
bottom_n: int = 10,
color_top: str = "#4CAF50",
color_bottom: str = "#F44336",
value_format: str = ",.0f",
) -> go.Figure:
"""Create horizontal bar chart showing top and bottom rankings.
Args:
data: List of data records.
name_column: Column name for labels.
value_column: Column name for values.
title: Optional chart title.
top_n: Number of top items to show.
bottom_n: Number of bottom items to show.
color_top: Color for top performers.
color_bottom: Color for bottom performers.
value_format: Number format string for values.
Returns:
Plotly Figure object.
"""
if not data:
return _create_empty_figure(title or "Rankings")
df = pd.DataFrame(data).sort_values(value_column, ascending=False)
# Get top and bottom
top_df = df.head(top_n).copy()
bottom_df = df.tail(bottom_n).copy()
top_df["group"] = "Top"
bottom_df["group"] = "Bottom"
# Combine with gap in the middle
combined = pd.concat([top_df, bottom_df])
combined["color"] = combined["group"].map(
{"Top": color_top, "Bottom": color_bottom}
)
fig = go.Figure()
# Add top bars
fig.add_trace(
go.Bar(
y=top_df[name_column],
x=top_df[value_column],
orientation="h",
marker_color=color_top,
name="Top",
text=top_df[value_column].apply(lambda x: f"{x:{value_format}}"),
textposition="auto",
hovertemplate=f"%{{y}}<br>{value_column}: %{{x:{value_format}}}<extra></extra>",
)
)
# Add bottom bars
fig.add_trace(
go.Bar(
y=bottom_df[name_column],
x=bottom_df[value_column],
orientation="h",
marker_color=color_bottom,
name="Bottom",
text=bottom_df[value_column].apply(lambda x: f"{x:{value_format}}"),
textposition="auto",
hovertemplate=f"%{{y}}<br>{value_column}: %{{x:{value_format}}}<extra></extra>",
)
)
fig.update_layout(
title=title,
barmode="group",
showlegend=True,
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02},
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
xaxis={"gridcolor": "rgba(128,128,128,0.2)", "title": None},
yaxis={"autorange": "reversed", "title": None},
margin={"l": 10, "r": 10, "t": 40, "b": 10},
)
return fig
def create_stacked_bar(
data: list[dict[str, Any]],
x_column: str,
value_column: str,
category_column: str,
title: str | None = None,
color_map: dict[str, str] | None = None,
show_percentages: bool = False,
) -> go.Figure:
"""Create stacked bar chart for breakdown visualizations.
Args:
data: List of data records.
x_column: Column name for x-axis categories.
value_column: Column name for values.
category_column: Column name for stacking categories.
title: Optional chart title.
color_map: Mapping of category to color.
show_percentages: Whether to normalize to 100%.
Returns:
Plotly Figure object.
"""
if not data:
return _create_empty_figure(title or "Breakdown")
df = pd.DataFrame(data)
# Default color scheme
if color_map is None:
categories = df[category_column].unique()
colors = px.colors.qualitative.Set2[: len(categories)]
color_map = dict(zip(categories, colors, strict=False))
fig = px.bar(
df,
x=x_column,
y=value_column,
color=category_column,
color_discrete_map=color_map,
barmode="stack",
text=value_column if not show_percentages else None,
)
if show_percentages:
fig.update_traces(texttemplate="%{y:.1f}%", textposition="inside")
fig.update_layout(
title=title,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
xaxis={"gridcolor": "rgba(128,128,128,0.2)", "title": None},
yaxis={"gridcolor": "rgba(128,128,128,0.2)", "title": None},
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02},
margin={"l": 10, "r": 10, "t": 60, "b": 10},
)
return fig
def create_horizontal_bar(
data: list[dict[str, Any]],
name_column: str,
value_column: str,
title: str | None = None,
color: str = "#2196F3",
value_format: str = ",.0f",
sort: bool = True,
) -> go.Figure:
"""Create simple horizontal bar chart.
Args:
data: List of data records.
name_column: Column name for labels.
value_column: Column name for values.
title: Optional chart title.
color: Bar color.
value_format: Number format string.
sort: Whether to sort by value descending.
Returns:
Plotly Figure object.
"""
if not data:
return _create_empty_figure(title or "Bar Chart")
df = pd.DataFrame(data)
if sort:
df = df.sort_values(value_column, ascending=True)
fig = go.Figure(
go.Bar(
y=df[name_column],
x=df[value_column],
orientation="h",
marker_color=color,
text=df[value_column].apply(lambda x: f"{x:{value_format}}"),
textposition="outside",
hovertemplate=f"%{{y}}<br>Value: %{{x:{value_format}}}<extra></extra>",
)
)
fig.update_layout(
title=title,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
xaxis={"gridcolor": "rgba(128,128,128,0.2)", "title": None},
yaxis={"title": None},
margin={"l": 10, "r": 10, "t": 40, "b": 10},
)
return fig
def _create_empty_figure(title: str) -> go.Figure:
"""Create an empty figure with a message."""
fig = go.Figure()
fig.add_annotation(
text="No data available",
xref="paper",
yref="paper",
x=0.5,
y=0.5,
showarrow=False,
font={"size": 14, "color": "#888888"},
)
fig.update_layout(
title=title,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
xaxis={"visible": False},
yaxis={"visible": False},
)
return fig

View File

@@ -0,0 +1,143 @@
"""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}
# 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())
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,
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.",
xref="paper",
yref="paper",
x=0.5,
y=0.5,
showarrow=False,
font={"size": 14, "color": "#888888"},
)
return fig
# Create choropleth with data
import pandas as pd
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,
locations=location_key,
featureidkey=f"properties.{location_key}",
color=color_column,
color_continuous_scale=color_scale,
hover_data=hover_data,
mapbox_style=effective_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,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
coloraxis_colorbar={
"title": {
"text": color_column.replace("_", " ").title(),
"font": {"color": "#c9c9c9"},
},
"thickness": 15,
"len": 0.7,
"tickfont": {"color": "#c9c9c9"},
},
)
return fig
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,240 @@
"""Demographics-specific chart factories."""
from typing import Any
import pandas as pd
import plotly.graph_objects as go
def create_age_pyramid(
data: list[dict[str, Any]],
age_groups: list[str],
male_column: str = "male",
female_column: str = "female",
title: str | None = None,
) -> go.Figure:
"""Create population pyramid by age and gender.
Args:
data: List with one record per age group containing male/female counts.
age_groups: List of age group labels in order (youngest to oldest).
male_column: Column name for male population.
female_column: Column name for female population.
title: Optional chart title.
Returns:
Plotly Figure object.
"""
if not data or not age_groups:
return _create_empty_figure(title or "Age Distribution")
df = pd.DataFrame(data)
# Ensure data is ordered by age groups
if "age_group" in df.columns:
df["age_order"] = df["age_group"].apply(
lambda x: age_groups.index(x) if x in age_groups else -1
)
df = df.sort_values("age_order")
male_values = df[male_column].tolist() if male_column in df.columns else []
female_values = df[female_column].tolist() if female_column in df.columns else []
# Make male values negative for pyramid effect
male_values_neg = [-v for v in male_values]
fig = go.Figure()
# Male bars (left side, negative values)
fig.add_trace(
go.Bar(
y=age_groups,
x=male_values_neg,
orientation="h",
name="Male",
marker_color="#2196F3",
hovertemplate="%{y}<br>Male: %{customdata:,}<extra></extra>",
customdata=male_values,
)
)
# Female bars (right side, positive values)
fig.add_trace(
go.Bar(
y=age_groups,
x=female_values,
orientation="h",
name="Female",
marker_color="#E91E63",
hovertemplate="%{y}<br>Female: %{x:,}<extra></extra>",
)
)
# Calculate max for symmetric axis
max_val = max(max(male_values, default=0), max(female_values, default=0))
fig.update_layout(
title=title,
barmode="overlay",
bargap=0.1,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
xaxis={
"title": "Population",
"gridcolor": "rgba(128,128,128,0.2)",
"range": [-max_val * 1.1, max_val * 1.1],
"tickvals": [-max_val, -max_val / 2, 0, max_val / 2, max_val],
"ticktext": [
f"{max_val:,.0f}",
f"{max_val / 2:,.0f}",
"0",
f"{max_val / 2:,.0f}",
f"{max_val:,.0f}",
],
},
yaxis={"title": None, "gridcolor": "rgba(128,128,128,0.2)"},
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02},
margin={"l": 10, "r": 10, "t": 60, "b": 10},
)
return fig
def create_donut_chart(
data: list[dict[str, Any]],
name_column: str,
value_column: str,
title: str | None = None,
colors: list[str] | None = None,
hole_size: float = 0.4,
) -> go.Figure:
"""Create donut chart for percentage breakdowns.
Args:
data: List of data records with name and value.
name_column: Column name for labels.
value_column: Column name for values.
title: Optional chart title.
colors: List of colors for segments.
hole_size: Size of center hole (0-1).
Returns:
Plotly Figure object.
"""
if not data:
return _create_empty_figure(title or "Distribution")
df = pd.DataFrame(data)
if colors is None:
colors = [
"#2196F3",
"#4CAF50",
"#FF9800",
"#E91E63",
"#9C27B0",
"#00BCD4",
"#FFC107",
"#795548",
]
fig = go.Figure(
go.Pie(
labels=df[name_column],
values=df[value_column],
hole=hole_size,
marker_colors=colors[: len(df)],
textinfo="percent+label",
textposition="outside",
hovertemplate="%{label}<br>%{value:,} (%{percent})<extra></extra>",
)
)
fig.update_layout(
title=title,
paper_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
showlegend=False,
margin={"l": 10, "r": 10, "t": 60, "b": 10},
)
return fig
def create_income_distribution(
data: list[dict[str, Any]],
bracket_column: str,
count_column: str,
title: str | None = None,
color: str = "#4CAF50",
) -> go.Figure:
"""Create histogram-style bar chart for income distribution.
Args:
data: List of data records with income brackets and counts.
bracket_column: Column name for income brackets.
count_column: Column name for household counts.
title: Optional chart title.
color: Bar color.
Returns:
Plotly Figure object.
"""
if not data:
return _create_empty_figure(title or "Income Distribution")
df = pd.DataFrame(data)
fig = go.Figure(
go.Bar(
x=df[bracket_column],
y=df[count_column],
marker_color=color,
text=df[count_column].apply(lambda x: f"{x:,}"),
textposition="outside",
hovertemplate="%{x}<br>Households: %{y:,}<extra></extra>",
)
)
fig.update_layout(
title=title,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
xaxis={
"title": "Income Bracket",
"gridcolor": "rgba(128,128,128,0.2)",
"tickangle": -45,
},
yaxis={
"title": "Households",
"gridcolor": "rgba(128,128,128,0.2)",
},
margin={"l": 10, "r": 10, "t": 60, "b": 80},
)
return fig
def _create_empty_figure(title: str) -> go.Figure:
"""Create an empty figure with a message."""
fig = go.Figure()
fig.add_annotation(
text="No data available",
xref="paper",
yref="paper",
x=0.5,
y=0.5,
showarrow=False,
font={"size": 14, "color": "#888888"},
)
fig.update_layout(
title=title,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
xaxis={"visible": False},
yaxis={"visible": False},
)
return fig

View File

@@ -0,0 +1,166 @@
"""Radar/spider chart figure factory for multi-metric comparison."""
from typing import Any
import plotly.graph_objects as go
def create_radar_figure(
data: list[dict[str, Any]],
metrics: list[str],
name_column: str | None = None,
title: str | None = None,
fill: bool = True,
colors: list[str] | None = None,
) -> go.Figure:
"""Create radar/spider chart for multi-axis comparison.
Each record in data represents one entity (e.g., a neighbourhood)
with values for each metric that will be plotted on a separate axis.
Args:
data: List of data records, each with values for the metrics.
metrics: List of metric column names to display on radar axes.
name_column: Column name for entity labels.
title: Optional chart title.
fill: Whether to fill the radar polygons.
colors: List of colors for each data series.
Returns:
Plotly Figure object.
"""
if not data or not metrics:
return _create_empty_figure(title or "Radar Chart")
# Default colors
if colors is None:
colors = [
"#2196F3",
"#4CAF50",
"#FF9800",
"#E91E63",
"#9C27B0",
"#00BCD4",
]
fig = go.Figure()
# Format axis labels
axis_labels = [m.replace("_", " ").title() for m in metrics]
for i, record in enumerate(data):
values = [record.get(m, 0) or 0 for m in metrics]
# Close the radar polygon
values_closed = values + [values[0]]
labels_closed = axis_labels + [axis_labels[0]]
name = (
record.get(name_column, f"Series {i + 1}")
if name_column
else f"Series {i + 1}"
)
color = colors[i % len(colors)]
fig.add_trace(
go.Scatterpolar(
r=values_closed,
theta=labels_closed,
name=name,
line={"color": color, "width": 2},
fill="toself" if fill else None,
fillcolor=f"rgba{_hex_to_rgba(color, 0.2)}" if fill else None,
hovertemplate="%{theta}: %{r:.1f}<extra></extra>",
)
)
fig.update_layout(
title=title,
polar={
"radialaxis": {
"visible": True,
"gridcolor": "rgba(128,128,128,0.3)",
"linecolor": "rgba(128,128,128,0.3)",
"tickfont": {"color": "#c9c9c9"},
},
"angularaxis": {
"gridcolor": "rgba(128,128,128,0.3)",
"linecolor": "rgba(128,128,128,0.3)",
"tickfont": {"color": "#c9c9c9"},
},
"bgcolor": "rgba(0,0,0,0)",
},
paper_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
showlegend=len(data) > 1,
legend={"orientation": "h", "yanchor": "bottom", "y": -0.2},
margin={"l": 40, "r": 40, "t": 60, "b": 40},
)
return fig
def create_comparison_radar(
selected_data: dict[str, Any],
average_data: dict[str, Any],
metrics: list[str],
selected_name: str = "Selected",
average_name: str = "City Average",
title: str | None = None,
) -> go.Figure:
"""Create radar chart comparing a selection to city average.
Args:
selected_data: Data for the selected entity.
average_data: Data for the city average.
metrics: List of metric column names.
selected_name: Label for selected entity.
average_name: Label for average.
title: Optional chart title.
Returns:
Plotly Figure object.
"""
if not selected_data or not average_data:
return _create_empty_figure(title or "Comparison")
data = [
{**selected_data, "__name__": selected_name},
{**average_data, "__name__": average_name},
]
return create_radar_figure(
data=data,
metrics=metrics,
name_column="__name__",
title=title,
colors=["#4CAF50", "#9E9E9E"],
)
def _hex_to_rgba(hex_color: str, alpha: float) -> tuple[int, int, int, float]:
"""Convert hex color to RGBA tuple."""
hex_color = hex_color.lstrip("#")
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
return (r, g, b, alpha)
def _create_empty_figure(title: str) -> go.Figure:
"""Create an empty figure with a message."""
fig = go.Figure()
fig.add_annotation(
text="No data available",
xref="paper",
yref="paper",
x=0.5,
y=0.5,
showarrow=False,
font={"size": 14, "color": "#888888"},
)
fig.update_layout(
title=title,
paper_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
)
return fig

View File

@@ -0,0 +1,184 @@
"""Scatter plot figure factory for correlation views."""
from typing import Any
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
def create_scatter_figure(
data: list[dict[str, Any]],
x_column: str,
y_column: str,
name_column: str | None = None,
size_column: str | None = None,
color_column: str | None = None,
title: str | None = None,
x_title: str | None = None,
y_title: str | None = None,
trendline: bool = False,
color_scale: str = "Blues",
) -> go.Figure:
"""Create scatter plot for correlation visualization.
Args:
data: List of data records.
x_column: Column name for x-axis values.
y_column: Column name for y-axis values.
name_column: Column name for point labels (hover).
size_column: Column name for point sizes.
color_column: Column name for color encoding.
title: Optional chart title.
x_title: X-axis title.
y_title: Y-axis title.
trendline: Whether to add OLS trendline.
color_scale: Plotly color scale for continuous colors.
Returns:
Plotly Figure object.
"""
if not data:
return _create_empty_figure(title or "Scatter Plot")
df = pd.DataFrame(data)
# Build hover_data
hover_data = {}
if name_column and name_column in df.columns:
hover_data[name_column] = True
# Create scatter plot
fig = px.scatter(
df,
x=x_column,
y=y_column,
size=size_column if size_column and size_column in df.columns else None,
color=color_column if color_column and color_column in df.columns else None,
color_continuous_scale=color_scale,
hover_name=name_column,
trendline="ols" if trendline else None,
opacity=0.7,
)
# Style the markers
fig.update_traces(
marker={
"line": {"width": 1, "color": "rgba(255,255,255,0.3)"},
},
)
# Trendline styling
if trendline:
fig.update_traces(
selector={"mode": "lines"},
line={"color": "#FF9800", "dash": "dash", "width": 2},
)
fig.update_layout(
title=title,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
xaxis={
"gridcolor": "rgba(128,128,128,0.2)",
"title": x_title or x_column.replace("_", " ").title(),
"zeroline": False,
},
yaxis={
"gridcolor": "rgba(128,128,128,0.2)",
"title": y_title or y_column.replace("_", " ").title(),
"zeroline": False,
},
margin={"l": 10, "r": 10, "t": 40, "b": 10},
showlegend=color_column is not None,
)
return fig
def create_bubble_chart(
data: list[dict[str, Any]],
x_column: str,
y_column: str,
size_column: str,
name_column: str | None = None,
color_column: str | None = None,
title: str | None = None,
x_title: str | None = None,
y_title: str | None = None,
size_max: int = 50,
) -> go.Figure:
"""Create bubble chart with sized markers.
Args:
data: List of data records.
x_column: Column name for x-axis values.
y_column: Column name for y-axis values.
size_column: Column name for bubble sizes.
name_column: Column name for labels.
color_column: Column name for colors.
title: Optional chart title.
x_title: X-axis title.
y_title: Y-axis title.
size_max: Maximum marker size in pixels.
Returns:
Plotly Figure object.
"""
if not data:
return _create_empty_figure(title or "Bubble Chart")
df = pd.DataFrame(data)
fig = px.scatter(
df,
x=x_column,
y=y_column,
size=size_column,
color=color_column,
hover_name=name_column,
size_max=size_max,
opacity=0.7,
)
fig.update_layout(
title=title,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
xaxis={
"gridcolor": "rgba(128,128,128,0.2)",
"title": x_title or x_column.replace("_", " ").title(),
},
yaxis={
"gridcolor": "rgba(128,128,128,0.2)",
"title": y_title or y_column.replace("_", " ").title(),
},
margin={"l": 10, "r": 10, "t": 40, "b": 10},
)
return fig
def _create_empty_figure(title: str) -> go.Figure:
"""Create an empty figure with a message."""
fig = go.Figure()
fig.add_annotation(
text="No data available",
xref="paper",
yref="paper",
x=0.5,
y=0.5,
showarrow=False,
font={"size": 14, "color": "#888888"},
)
fig.update_layout(
title=title,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
xaxis={"visible": False},
yaxis={"visible": False},
)
return fig

View File

@@ -0,0 +1,107 @@
"""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)",
plot_bgcolor="rgba(0,0,0,0)",
font={"family": "Inter, sans-serif", "color": "#c9c9c9"},
)
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,386 @@
"""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,
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",
)
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",
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
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,
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",
)
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",
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
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,
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",
)
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",
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"},
},
)
return fig
def add_policy_markers(
fig: go.Figure,
policy_events: list[dict[str, Any]],
date_column: str = "event_date",
y_position: float | None = None,
) -> go.Figure:
"""Add policy event markers to an existing time series figure.
Args:
fig: Existing Plotly figure to add markers to.
policy_events: List of policy event dicts with date and metadata.
date_column: Column name for event dates.
y_position: Y position for markers. If None, uses top of chart.
Returns:
Updated Plotly Figure object with policy markers.
"""
if not policy_events:
return fig
# Color mapping for policy categories
category_colors = {
"monetary": "#1f77b4", # Blue
"tax": "#2ca02c", # Green
"regulatory": "#ff7f0e", # Orange
"supply": "#9467bd", # Purple
"economic": "#d62728", # Red
}
# Symbol mapping for expected direction
direction_symbols = {
"bullish": "triangle-up",
"bearish": "triangle-down",
"neutral": "circle",
}
for event in policy_events:
event_date = event.get(date_column)
category = event.get("category", "economic")
direction = event.get("expected_direction", "neutral")
title = event.get("title", "Policy Event")
level = event.get("level", "federal")
color = category_colors.get(category, "#666666")
symbol = direction_symbols.get(direction, "circle")
# Add vertical line for the event
fig.add_vline(
x=event_date,
line_dash="dot",
line_color=color,
opacity=0.5,
annotation_text="",
)
# Add marker with hover info
fig.add_trace(
go.Scatter(
x=[event_date],
y=[y_position] if y_position else [None], # type: ignore[list-item]
mode="markers",
marker={
"symbol": symbol,
"size": 12,
"color": color,
"line": {"width": 1, "color": "white"},
},
name=title,
hovertemplate=(
f"<b>{title}</b><br>"
f"Date: %{{x}}<br>"
f"Level: {level.title()}<br>"
f"Category: {category.title()}<br>"
f"<extra></extra>"
),
showlegend=False,
)
)
return fig
def create_time_series_with_events(
data: list[dict[str, Any]],
policy_events: list[dict[str, Any]],
date_column: str = "full_date",
value_column: str = "avg_price",
title: str = "Price Trend with Policy Events",
) -> go.Figure:
"""Create a time series chart with policy event markers.
Args:
data: Time series data.
policy_events: Policy events to overlay.
date_column: Column name for dates.
value_column: Column name for values.
title: Chart title.
Returns:
Plotly Figure with time series and policy markers.
"""
# Create base time series
fig = create_price_time_series(
data=data,
date_column=date_column,
price_column=value_column,
title=title,
)
# Add policy markers at the top of the chart
if policy_events:
fig = add_policy_markers(fig, policy_events)
return fig