Source code for endgame.visualization.radar_chart

"""Radar chart visualizer.

Interactive radar (spider) charts for multi-metric model comparison.

Example
-------
>>> from endgame.visualization import RadarChartVisualizer
>>> viz = RadarChartVisualizer(
...     dimensions=["Accuracy", "Precision", "Recall", "F1", "AUC"],
...     series={"ModelA": [0.92, 0.88, 0.95, 0.91, 0.96],
...             "ModelB": [0.89, 0.91, 0.87, 0.89, 0.93]},
... )
>>> viz.save("radar.html")
"""

from __future__ import annotations

from collections.abc import Sequence
from typing import Any

from endgame.visualization._base import BaseVisualizer


[docs] class RadarChartVisualizer(BaseVisualizer): """Interactive radar chart visualizer. Parameters ---------- dimensions : list of str Axis labels. series : dict of str → list of float Mapping of series name to values (one per dimension). ranges : list of tuple of float, optional (min, max) for each dimension. If None, auto-computed. fill : bool, default=True Fill polygon area. title : str, optional Chart title. palette : str, default='tableau' Color palette. width : int, default=600 Chart width. height : int, default=600 Chart height. theme : str, default='dark' 'dark' or 'light'. """ def __init__( self, dimensions: Sequence[str], series: dict[str, Sequence[float]], *, ranges: Sequence[tuple] | None = None, fill: bool = True, title: str = "", palette: str = "tableau", width: int = 600, height: int = 600, theme: str = "dark", ): super().__init__(title=title, palette=palette, width=width, height=height, theme=theme) self.dimensions = list(dimensions) self.series = {k: list(v) for k, v in series.items()} self.ranges = list(ranges) if ranges else None self.fill = fill def _build_data(self) -> dict[str, Any]: dims = self.dimensions n_dims = len(dims) all_vals = [] for v in self.series.values(): all_vals.extend(v[:n_dims]) if self.ranges: axis_ranges = [(float(lo), float(hi)) for lo, hi in self.ranges] else: lo = min(all_vals) if all_vals else 0 hi = max(all_vals) if all_vals else 1 margin = (hi - lo) * 0.05 axis_ranges = [(lo - margin, hi + margin)] * n_dims series_list = [] for name, values in self.series.items(): # Normalize to [0, 1] per axis normalized = [] for i, v in enumerate(values[:n_dims]): rng = axis_ranges[i] span = rng[1] - rng[0] normalized.append(round((v - rng[0]) / span if span > 0 else 0.5, 6)) series_list.append({ "name": name, "values": values[:n_dims], "normalized": normalized, }) return { "dimensions": dims, "series": series_list, "ranges": axis_ranges, "fill": self.fill, } def _chart_type(self) -> str: return "radar" def _get_chart_js(self) -> str: return _RADAR_JS
_RADAR_JS = r""" function renderChart(data, config) { const container = document.getElementById('chart-container'); const size = Math.min(config.width, config.height); const margin = 60; const R = (size - 2 * margin) / 2; const cx = size / 2, cy = size / 2; const svg = EG.svg('svg', {width: size, height: size}); container.appendChild(svg); container.style.width = size + 'px'; container.style.height = size + 'px'; const g = EG.svg('g'); svg.appendChild(g); const dims = data.dimensions; const n = dims.length; const palette = config.palette; const angleStep = (2 * Math.PI) / n; function polarToXY(angle, radius) { return { x: cx + radius * Math.sin(angle), y: cy - radius * Math.cos(angle) }; } // Grid rings const nRings = 5; for (let r = 1; r <= nRings; r++) { const radius = R * r / nRings; let d = ''; for (let i = 0; i <= n; i++) { const p = polarToXY(i * angleStep, radius); d += (i === 0 ? 'M' : ' L') + p.x + ' ' + p.y; } g.appendChild(EG.svg('path', {d: d, fill: 'none', stroke: 'var(--grid-line)', 'stroke-width': 1})); } // Axis lines and labels for (let i = 0; i < n; i++) { const angle = i * angleStep; const p = polarToXY(angle, R); g.appendChild(EG.svg('line', {x1: cx, y1: cy, x2: p.x, y2: p.y, stroke: 'var(--border)', 'stroke-width': 1})); // Label const lp = polarToXY(angle, R + 18); const anchor = Math.abs(lp.x - cx) < 5 ? 'middle' : (lp.x > cx ? 'start' : 'end'); const label = EG.svg('text', { x: lp.x, y: lp.y + 4, 'text-anchor': anchor, fill: 'var(--text-secondary)', 'font-size': '11px', 'font-weight': '500' }); label.textContent = dims[i]; g.appendChild(label); } // Draw series data.series.forEach(function(s, si) { const color = palette[si % palette.length]; let d = ''; const points = []; for (let i = 0; i < n; i++) { const angle = i * angleStep; const radius = R * s.normalized[i]; const p = polarToXY(angle, radius); points.push(p); d += (i === 0 ? 'M' : ' L') + p.x + ' ' + p.y; } d += ' Z'; // Fill if (data.fill) { g.appendChild(EG.svg('path', {d: d, fill: color, opacity: 0.15, stroke: 'none'})); } // Outline g.appendChild(EG.svg('path', {d: d, fill: 'none', stroke: color, 'stroke-width': 2.5, 'stroke-linejoin': 'round'})); // Points points.forEach(function(p, i) { const circle = EG.svg('circle', {cx: p.x, cy: p.y, r: 4, fill: color, stroke: 'var(--bg-card)', 'stroke-width': 2}); circle.addEventListener('mouseenter', function(e) { circle.setAttribute('r', '6'); EG.tooltip.show(e, '<b>' + EG.esc(s.name) + '</b><br>' + EG.esc(dims[i]) + ': ' + EG.fmt(s.values[i], 4)); }); circle.addEventListener('mouseleave', function() { circle.setAttribute('r', '4'); EG.tooltip.hide(); }); g.appendChild(circle); }); }); // Legend if (data.series.length > 1) { const items = data.series.map(function(s, i) { return {label: s.name, color: palette[i % palette.length]}; }); EG.drawLegend(container, items); } } """