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>
241 lines
6.5 KiB
Python
241 lines
6.5 KiB
Python
"""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
|