"""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}", ) ) 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