Implement full 5-tab Toronto Neighbourhood Dashboard with real data connectivity: Dashboard Structure: - Overview tab with livability scores and rankings - Housing tab with affordability metrics - Safety tab with crime statistics - Demographics tab with population/income data - Amenities tab with parks, schools, transit Figure Factories (portfolio_app/figures/): - bar_charts.py: ranking, stacked, horizontal bars - scatter.py: scatter plots, bubble charts - radar.py: spider/radar charts - demographics.py: donut, age pyramid, income distribution Service Layer (portfolio_app/toronto/services/): - neighbourhood_service.py: queries dbt marts for all tab data - geometry_service.py: generates GeoJSON from PostGIS - Graceful error handling when database unavailable Callbacks (portfolio_app/pages/toronto/callbacks/): - map_callbacks.py: choropleth updates, map click handling - chart_callbacks.py: supporting chart updates - selection_callbacks.py: dropdown handlers, KPI updates Data Pipeline (scripts/data/): - load_toronto_data.py: orchestration script with CLI flags Lessons Learned: - Graceful error handling in service layers - Modular callback structure for multi-tab dashboards - Figure factory pattern for reusable charts Closes: #64, #65, #66, #67, #68, #69, #70 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
167 lines
4.7 KiB
Python
167 lines
4.7 KiB
Python
"""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
|