Source code for endgame.visualization.radial_bar

"""Radial bar chart visualizer.

Interactive radial bar charts where bars extend outward from a center
point. Visually striking alternative to horizontal bar charts when
comparing many categories.

Example
-------
>>> from endgame.visualization import RadialBarVisualizer
>>> viz = RadialBarVisualizer(
...     labels=["LGBM", "XGB", "CatBoost", "RF", "MLP", "SVM"],
...     values=[0.923, 0.918, 0.915, 0.901, 0.890, 0.875],
...     title="Model Scores",
... )
>>> viz.save("radial_bar.html")
"""

from __future__ import annotations

from collections.abc import Sequence
from typing import Any

from endgame.visualization._base import BaseVisualizer


[docs] class RadialBarVisualizer(BaseVisualizer): """Interactive radial bar chart visualizer. Parameters ---------- labels : list of str Category labels. values : list of float Bar values. inner_radius_ratio : float, default=0.3 Ratio of inner radius to outer radius. sort : bool, default=False If True, sort bars by value descending. title : str, optional Chart title. palette : str, default='tableau' Color palette. width : int, default=650 Chart width. height : int, default=650 Chart height. theme : str, default='dark' 'dark' or 'light'. """ def __init__( self, labels: Sequence[str], values: Sequence[float], *, inner_radius_ratio: float = 0.3, sort: bool = False, title: str = "", palette: str = "tableau", width: int = 650, height: int = 650, theme: str = "dark", ): super().__init__(title=title, palette=palette, width=width, height=height, theme=theme) self.labels = list(labels) self.values = [float(v) for v in values] self.inner_radius_ratio = inner_radius_ratio self.sort = sort def _build_data(self) -> dict[str, Any]: labels = list(self.labels) values = list(self.values) if self.sort: pairs = sorted(zip(labels, values), key=lambda p: p[1], reverse=True) labels = [p[0] for p in pairs] values = [p[1] for p in pairs] v_max = max(values) if values else 1 return { "labels": labels, "values": values, "vMax": v_max, "innerRatio": self.inner_radius_ratio, } def _chart_type(self) -> str: return "radial_bar" def _get_chart_js(self) -> str: return _RADIAL_BAR_JS
_RADIAL_BAR_JS = r""" function renderChart(data, config) { const container = document.getElementById('chart-container'); const size = Math.min(config.width, config.height); const svg = EG.svg('svg', {width: size, height: size}); container.appendChild(svg); container.style.width = size + 'px'; container.style.height = size + 'px'; const palette = config.palette; const cx = size / 2, cy = size / 2; const outerR = size / 2 - 50; const innerR = outerR * data.innerRatio; const labels = data.labels; const values = data.values; const n = labels.length; const vMax = data.vMax || 1; if (n === 0) return; const angleStep = (2 * Math.PI) / n; const barGap = 0.02; // gap between bars in radians // Background ring svg.appendChild(EG.svg('circle', { cx: cx, cy: cy, r: outerR, fill: 'none', stroke: 'var(--grid-line)', 'stroke-width': 1 })); svg.appendChild(EG.svg('circle', { cx: cx, cy: cy, r: innerR, fill: 'var(--bg-secondary)', stroke: 'var(--border)', 'stroke-width': 1 })); // Grid rings for (let ring = 1; ring <= 3; ring++) { const r = innerR + (outerR - innerR) * ring / 4; svg.appendChild(EG.svg('circle', { cx: cx, cy: cy, r: r, fill: 'none', stroke: 'var(--grid-line)', 'stroke-width': 0.5 })); } // Draw bars for (let i = 0; i < n; i++) { const v = values[i]; const barR = innerR + (v / vMax) * (outerR - innerR); const startAngle = i * angleStep - Math.PI / 2 + barGap; const endAngle = (i + 1) * angleStep - Math.PI / 2 - barGap; const color = palette[i % palette.length]; const large = (endAngle - startAngle > Math.PI) ? 1 : 0; // Arc from innerR to barR const ix1 = cx + innerR * Math.cos(startAngle); const iy1 = cy + innerR * Math.sin(startAngle); const ix2 = cx + innerR * Math.cos(endAngle); const iy2 = cy + innerR * Math.sin(endAngle); const ox1 = cx + barR * Math.cos(startAngle); const oy1 = cy + barR * Math.sin(startAngle); const ox2 = cx + barR * Math.cos(endAngle); const oy2 = cy + barR * Math.sin(endAngle); const d = [ 'M', ix1, iy1, 'L', ox1, oy1, 'A', barR, barR, 0, large, 1, ox2, oy2, 'L', ix2, iy2, 'A', innerR, innerR, 0, large, 0, ix1, iy1, 'Z' ].join(' '); const bar = EG.svg('path', {d: d, fill: color, opacity: 0.8, stroke: 'var(--bg-card)', 'stroke-width': 1}); bar.addEventListener('mouseenter', function(e) { bar.setAttribute('opacity', '1'); EG.tooltip.show(e, '<b>' + EG.esc(labels[i]) + '</b><br>Value: ' + EG.fmt(v, 4)); }); bar.addEventListener('mouseleave', function() { bar.setAttribute('opacity', '0.8'); EG.tooltip.hide(); }); svg.appendChild(bar); // Label const midAngle = (startAngle + endAngle) / 2; const lR = outerR + 15; const lx = cx + lR * Math.cos(midAngle); const ly = cy + lR * Math.sin(midAngle); const rotate = (midAngle * 180 / Math.PI); const flip = midAngle > Math.PI / 2 && midAngle < 3 * Math.PI / 2; const anchor = flip ? 'end' : 'start'; const actualRotate = flip ? rotate + 180 : rotate; const label = EG.svg('text', { x: lx, y: ly + 3, 'text-anchor': anchor, fill: 'var(--text-secondary)', 'font-size': n > 10 ? '9px' : '11px', transform: 'rotate(' + actualRotate + ',' + lx + ',' + ly + ')' }); label.textContent = labels[i]; svg.appendChild(label); } } """