"""Gauge (speedometer) chart visualizer.
Interactive gauge chart for single-metric dashboard display. Shows a
value on an arc with configurable zones (e.g., bad/ok/good), a needle,
and a digital readout.
Example
-------
>>> from endgame.visualization import GaugeChartVisualizer
>>> viz = GaugeChartVisualizer(value=0.92, label="Accuracy")
>>> viz.save("gauge.html")
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from endgame.visualization._base import BaseVisualizer
[docs]
class GaugeChartVisualizer(BaseVisualizer):
"""Interactive gauge chart visualizer.
Parameters
----------
value : float
Current metric value.
min_value : float, default=0
Minimum scale value.
max_value : float, default=1
Maximum scale value.
label : str, default=''
Metric label shown below the value.
zones : list of (float, float, str), optional
Colored zones as (start, end, color). If None, uses default
red/yellow/green zones dividing the range into thirds.
format_str : str, optional
Python format string for the displayed value (e.g., '.1%', '.3f').
title : str, optional
Chart title.
palette : str, default='tableau'
Color palette.
width : int, default=450
Chart width.
height : int, default=350
Chart height.
theme : str, default='dark'
'dark' or 'light'.
"""
def __init__(
self,
value: float,
*,
min_value: float = 0,
max_value: float = 1,
label: str = "",
zones: Sequence[tuple[float, float, str]] | None = None,
format_str: str | None = None,
title: str = "",
palette: str = "tableau",
width: int = 450,
height: int = 350,
theme: str = "dark",
):
super().__init__(title=title, palette=palette, width=width, height=height, theme=theme)
self.value = float(value)
self.min_value = float(min_value)
self.max_value = float(max_value)
self.label = label
self.format_str = format_str
if zones is None:
third = (max_value - min_value) / 3
self.zones = [
(min_value, min_value + third, "#d62728"),
(min_value + third, min_value + 2 * third, "#ff7f0e"),
(min_value + 2 * third, max_value, "#2ca02c"),
]
else:
self.zones = [(float(a), float(b), c) for a, b, c in zones]
[docs]
@classmethod
def from_score(
cls,
score: float,
metric_name: str = "Score",
*,
min_value: float = 0,
max_value: float = 1,
**kwargs,
) -> GaugeChartVisualizer:
"""Create from a single metric score.
Parameters
----------
score : float
Metric value.
metric_name : str, default='Score'
Name of the metric.
min_value : float, default=0
Min scale.
max_value : float, default=1
Max scale.
**kwargs
Additional keyword arguments.
"""
kwargs.setdefault("label", metric_name)
return cls(score, min_value=min_value, max_value=max_value, **kwargs)
[docs]
@classmethod
def from_accuracy(cls, accuracy: float, **kwargs) -> GaugeChartVisualizer:
"""Create for an accuracy metric (0-1 scale).
Parameters
----------
accuracy : float
Accuracy value (0 to 1).
**kwargs
Additional keyword arguments.
"""
kwargs.setdefault("label", "Accuracy")
kwargs.setdefault("format_str", ".1%")
return cls(accuracy, min_value=0, max_value=1, **kwargs)
def _build_data(self) -> dict[str, Any]:
# Format the display value
if self.format_str:
try:
display_val = format(self.value, self.format_str)
except (ValueError, TypeError):
display_val = str(round(self.value, 4))
else:
display_val = str(round(self.value, 4))
return {
"value": self.value,
"minValue": self.min_value,
"maxValue": self.max_value,
"label": self.label,
"displayValue": display_val,
"zones": [
{"start": z[0], "end": z[1], "color": z[2]}
for z in self.zones
],
}
def _chart_type(self) -> str:
return "gauge"
def _get_chart_js(self) -> str:
return _GAUGE_JS
_GAUGE_JS = r"""
function renderChart(data, config) {
const container = document.getElementById('chart-container');
const W = config.width;
const H = config.height;
const svg = EG.svg('svg', {width: W, height: H});
container.appendChild(svg);
const cx = W / 2;
const cy = H * 0.58;
const outerR = Math.min(W, H) * 0.38;
const innerR = outerR * 0.7;
const needleR = outerR * 0.88;
const vMin = data.minValue;
const vMax = data.maxValue;
const range = vMax - vMin || 1;
// Gauge arc from -135° to +135° (270° sweep)
const startAngle = -135 * Math.PI / 180;
const endAngle = 135 * Math.PI / 180;
const totalSweep = endAngle - startAngle;
function valToAngle(v) {
var t = (v - vMin) / range;
t = Math.max(0, Math.min(1, t));
return startAngle + t * totalSweep;
}
function polarX(angle, r) { return cx + r * Math.cos(angle); }
function polarY(angle, r) { return cy + r * Math.sin(angle); }
function arcPath(r, a1, a2) {
const x1 = polarX(a1, r), y1 = polarY(a1, r);
const x2 = polarX(a2, r), y2 = polarY(a2, r);
const largeArc = Math.abs(a2 - a1) > Math.PI ? 1 : 0;
return 'M' + x1 + ' ' + y1 + ' A' + r + ' ' + r + ' 0 ' + largeArc + ' 1 ' + x2 + ' ' + y2;
}
// Background arc (track)
svg.appendChild(EG.svg('path', {
d: arcPath(outerR, startAngle, endAngle),
fill: 'none', stroke: 'var(--grid-line)', 'stroke-width': outerR - innerR, opacity: 0.3,
'stroke-linecap': 'round'
}));
// Zone arcs
data.zones.forEach(function(z) {
const a1 = valToAngle(z.start);
const a2 = valToAngle(z.end);
const midR = (outerR + innerR) / 2;
svg.appendChild(EG.svg('path', {
d: arcPath(midR, a1, a2),
fill: 'none', stroke: z.color, 'stroke-width': outerR - innerR - 4,
'stroke-linecap': 'butt', opacity: 0.7
}));
});
// Tick marks
var nTicks = 10;
for (var t = 0; t <= nTicks; t++) {
var val = vMin + (t / nTicks) * range;
var angle = valToAngle(val);
var major = t % 2 === 0;
var r1 = outerR + 4;
var r2 = outerR + (major ? 14 : 9);
svg.appendChild(EG.svg('line', {
x1: polarX(angle, r1), y1: polarY(angle, r1),
x2: polarX(angle, r2), y2: polarY(angle, r2),
stroke: 'var(--text-muted)', 'stroke-width': major ? 2 : 1
}));
if (major) {
svg.appendChild(EG.svg('text', {
x: polarX(angle, outerR + 24), y: polarY(angle, outerR + 24) + 3,
'text-anchor': 'middle', fill: 'var(--text-secondary)', 'font-size': '10px'
})).textContent = EG.fmt(val, range >= 10 ? 0 : 2);
}
}
// Needle
var needleAngle = valToAngle(data.value);
var nx = polarX(needleAngle, needleR);
var ny = polarY(needleAngle, needleR);
// Needle body (tapered)
var perpAngle = needleAngle + Math.PI / 2;
var baseW = 4;
var bx1 = cx + baseW * Math.cos(perpAngle);
var by1 = cy + baseW * Math.sin(perpAngle);
var bx2 = cx - baseW * Math.cos(perpAngle);
var by2 = cy - baseW * Math.sin(perpAngle);
svg.appendChild(EG.svg('path', {
d: 'M' + bx1 + ' ' + by1 + ' L' + nx + ' ' + ny + ' L' + bx2 + ' ' + by2 + ' Z',
fill: 'var(--text-primary)', opacity: 0.9
}));
// Center dot
svg.appendChild(EG.svg('circle', {cx: cx, cy: cy, r: 8, fill: 'var(--text-primary)'}));
svg.appendChild(EG.svg('circle', {cx: cx, cy: cy, r: 4, fill: 'var(--bg-card)'}));
// Digital readout
svg.appendChild(EG.svg('text', {
x: cx, y: cy + outerR * 0.45,
'text-anchor': 'middle', fill: 'var(--text-primary)',
'font-size': '28px', 'font-weight': '700',
'font-family': '"SF Mono", "Fira Code", monospace'
})).textContent = data.displayValue;
// Label
if (data.label) {
svg.appendChild(EG.svg('text', {
x: cx, y: cy + outerR * 0.45 + 24,
'text-anchor': 'middle', fill: 'var(--text-muted)',
'font-size': '13px', 'font-weight': '500'
})).textContent = data.label;
}
}
"""