feat: Complete Phase 5 dashboard implementation
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>
This commit is contained in:
@@ -1,9 +1,27 @@
|
||||
"""Plotly figure factories for data visualization."""
|
||||
|
||||
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,
|
||||
@@ -26,4 +44,18 @@ __all__ = [
|
||||
# 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",
|
||||
]
|
||||
|
||||
238
portfolio_app/figures/bar_charts.py
Normal file
238
portfolio_app/figures/bar_charts.py
Normal 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
|
||||
240
portfolio_app/figures/demographics.py
Normal file
240
portfolio_app/figures/demographics.py
Normal 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
|
||||
166
portfolio_app/figures/radar.py
Normal file
166
portfolio_app/figures/radar.py
Normal 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
|
||||
184
portfolio_app/figures/scatter.py
Normal file
184
portfolio_app/figures/scatter.py
Normal 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
|
||||
Reference in New Issue
Block a user