Files
personal-portfolio/portfolio_app/figures/toronto/demographics.py
l3ocho 62d1a52eed
Some checks failed
CI / lint-and-test (pull_request) Has been cancelled
refactor: multi-dashboard structural migration
- 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>
2026-02-01 19:08:20 -05:00

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