Charts and tablesEval radar
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises, from Astro sites to React apps and AI products.
Book a callEval radar
This run against the baseline on six eval scores, with the weak ones flagged. Free and MIT licensed, as HTML, a typed React component or an Astro component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add radar-chartReact
npx designme add radar-chart --reactAstro
npx designme add radar-chart --astroThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro islands. The Astro version lands in src/components/designme too, with the same markup, CSS and script as the HTML version. See Astro.
shadcn CLI, React
npx shadcn@latest add https://designme.dev/r/shadcn/radar-chart.json shadcn CLI, Astro
npx shadcn@latest add https://designme.dev/r/shadcn/astro/radar-chart.json In a project set up for shadcn/ui, its CLI adds the same files to your components folder, under designme/, with the shared core. To give the components your shadcn or Tailwind v4 theme, add the theme bridge.
About
What it is for and what it does.
A radar chart for eval scores: the baseline run as an outline and the current run filled over it, on six axes such as accuracy, instruction following, tool use, speed, cost efficiency and safety. Each axis is labelled with its score and how far it moved, a weak axis says Weak in words, and the average sits in the middle. Point at an axis, or tab to it, to read both scores and the change; the agent's note sits under the chart.
- To compare a new agent run, prompt or model with a baseline across several eval scores at once.
- When the overall average hides one score that got worse, and you want that score called out.
- Beside an eval report, as the picture that says where to look first.
- Grey scale rings at 25, 50, 75 and 100, with a spoke for each axis.
- The baseline is an outline in the second chart colour and this run is filled in the first (the accent), with a dot at each score.
- Each axis label shows the score and the change ("62 −16"); a score under
weakBelow(70) gets a Weak tag. - The average of this run sits in the middle of the chart.
- The legend shows and hides each series; one always stays on.
- Pointing at an axis, or moving the focus to it, lights its spoke and dot and writes both scores and the change under the chart; leaving it brings back the agent's
note. pulsemakes one axis's label pulse, to point at what the note is about.- On load the baseline draws itself and this run grows out of the middle. On a narrow card long names switch to their
shortform and the labels stack, so the rings keep their room.
- Each axis label is a button with a name that reads its scores and the change, like "Tool use: 62, baseline 78, down 16, weak".
- The labels form one tab stop: Left and Right (or Up and Down) move round the chart, Home and End go to the first and last.
- The line under the chart is a polite live region, so the scores of the axis in focus are read out.
- Weak axes are flagged in words, not only by colour; the two series differ by fill as well as colour.
- The drawn chart is hidden from screen readers, since every value is in the labels.
Usage
Import it in React and drive it from your own state, or place it in an Astro page.
React
import { RadarChart } from '@/components/designme/radar-chart/RadarChart';React example
The demo above as a React component, 23 lines you can copy.
'use client';
// The preview from designme.dev, rebuilt from the React component: the baseline draws, this run grows over it, and the agent
// notes it is cheaper but weaker at tool use, which pulses.
import { useEffect, useState } from 'react';
import { RadarChart, type RadarAxis } from './RadarChart';
const AXES: RadarAxis[] = [
{ label: 'Accuracy', value: 88, baseline: 84 }, { label: 'Instruction following', short: 'Instructions', value: 86, baseline: 80 },
{ label: 'Tool use', value: 62, baseline: 78 }, { label: 'Speed', value: 74, baseline: 66 },
{ label: 'Cost efficiency', short: 'Cost', value: 82, baseline: 58 }, { label: 'Safety', value: 91, baseline: 90 }
];
export default function RadarChartDemo() {
const [said, setSaid] = useState(false);
useEffect(() => { const t = setTimeout(() => setSaid(true), 3100); return () => clearTimeout(t); }, []);
return (
<div style={{ width: '100%' }}>
<RadarChart axes={AXES} subtitle="Run 48 against run 41" pulse={said ? 2 : undefined}
note={said ? <>Cheaper and faster, <b>weaker at tool use</b> (−16). It skipped the search tool on 7 of 40 tasks.</> : undefined} />
</div>
);
}
Astro
In a page in src/pages. It plays the demo above; change its radar-chart.js to show your own data. Attributes like class go on its root element.
---
import RadarChart from '../components/designme/radar-chart/RadarChart.astro';
---
<RadarChart />
<RadarChart data-style="soft" class="my-class" />Props
Everything RadarChart takes.
| Prop | Type | What it does |
|---|---|---|
axesrequired | RadarAxis[] | Five to seven axes, clockwise from the top |
title | string | |
subtitle | string | |
seriesLabels | [string, string] | The legend's names for the two series |
weakBelow | number | A score under this is flagged "Weak" (70) |
note | ReactNode | The agent's note under the chart, shown while no axis is pointed at. Defaults to the average and its change |
pulse | number | An axis to call out: its label pulses (by index) |
className | string |
Code
The full source, as the CLI installs it.
RadarChart.tsxReact · 118 lines
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useIsoLayoutEffect, useReducedMotion, useTween } from '../lib/hooks';
import { Icon } from '../lib/icons';
import '../designme.css';
import './radar-chart.css';
export interface RadarAxis {
/** Like "Instruction following" */
label: string;
/** Shown instead of the label on a narrow card, like "Instructions" */
short?: string;
/** This run's score, 0 to 100 */
value: number;
/** The baseline's score, 0 to 100 */
baseline: number;
}
export interface RadarChartProps {
/** Five to seven axes, clockwise from the top */
axes: RadarAxis[];
title?: string;
subtitle?: string;
/** The legend's names for the two series */
seriesLabels?: [string, string];
/** A score under this is flagged "Weak" (70) */
weakBelow?: number;
/** The agent's note under the chart, shown while no axis is pointed at. Defaults to the average and its change */
note?: ReactNode;
/** An axis to call out: its label pulses (by index) */
pulse?: number;
className?: string;
}
const tween = (ms: number, step: (t: number) => void, reduce: boolean) => new Promise<void>(res => {
const t0 = performance.now(), f = (t: number) => { const p = Math.min(1, (t - t0) / (reduce ? 1 : ms)); step(1 - Math.pow(1 - p, 3)); if (p < 1) requestAnimationFrame(f); else res(); };
requestAnimationFrame(f);
});
/** Compares a run with its baseline on a handful of eval scores: the baseline as an outline, this run filled, weak axes flagged in words. */
export function RadarChart({ axes, title = 'Eval scores', subtitle, seriesLabels = ['This run', 'Baseline'], weakBelow = 70, note, pulse, className }: RadarChartProps) {
const N = axes.length;
const ch = useRef<HTMLDivElement>(null), labels = useRef<(HTMLButtonElement | null)[]>([]);
const [size, setSize] = useState({ W: 0, H: 0, side: 92 });
const [on, setOn] = useState({ cur: true, base: true });
const [hot, setHot] = useState(-1);
const [focusAt, setFocusAt] = useState(0);
const [drawn, setDrawn] = useState(0), [grow, setGrow] = useState(0);
const reduce = useReducedMotion();
const mean = (k: 'value' | 'baseline') => Math.round(axes.reduce((t, a) => t + a[k], 0) / N);
const avg = useTween(grow > 0 ? mean('value') : 0, 1100, 0);
// the baseline draws itself, then this run grows out of the middle
useEffect(() => { let live = true; void (async () => {
await new Promise(r => setTimeout(r, 300)); if (!live) return;
await tween(900, t => live && setDrawn(t * 100), reduce); await new Promise(r => setTimeout(r, 250));
await tween(1100, t => live && setGrow(t), reduce);
})(); return () => { live = false; }; }, []); // eslint-disable-line react-hooks/exhaustive-deps
const narrow = size.W > 0 && size.W < 380;
useIsoLayoutEffect(() => {
const el = ch.current; if (!el) return;
const measure = () => { const side = Math.max(0, ...labels.current.filter((l, i) => l && Math.abs(Math.cos(ang(i))) >= .2).map(l => l!.offsetWidth)); setSize({ W: el.clientWidth, H: el.clientHeight, side }); };
measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect();
}, [narrow]); // eslint-disable-line react-hooks/exhaustive-deps
const ang = (i: number) => -Math.PI / 2 + i * 2 * Math.PI / N;
const { W, H } = size, cxp = W / 2, cyp = H / 2, R = Math.max(40, Math.min(H / 2 - 46, W / 2 - size.side - 14));
const pt = (i: number, v: number) => [cxp + Math.cos(ang(i)) * R * v / 100, cyp + Math.sin(ang(i)) * R * v / 100];
const poly = (f: (a: RadarAxis) => number) => axes.map((a, i) => pt(i, f(a)).map(n => n.toFixed(1)).join(',')).join(' ');
const ro = hot >= 0 ? axes[hot] : null;
const keys = (e: React.KeyboardEvent) => {
const i = focusAt, to = ({ ArrowRight: i + 1, ArrowDown: i + 1, ArrowLeft: i - 1, ArrowUp: i - 1, Home: 0, End: N - 1 } as Record<string, number>)[e.key];
if (to == null) return; e.preventDefault(); const j = (to + N) % N; setFocusAt(j); labels.current[j]?.focus();
};
const toggle = (s: 'cur' | 'base') => { const o = s === 'cur' ? 'base' : 'cur'; if (on[s] && !on[o]) return; setOn({ ...on, [s]: !on[s] }); };
return (
<div data-dm="" className={cx('dm-rdx dm-panel', className)}>
<div className="dm-rdx-h">
<div className="dm-rdx-t"><b>{title}</b>{subtitle && <span className="dm-muted">{subtitle}</span>}</div>
<div className="dm-rdx-lg">
<button type="button" className="dm-rdx-k" aria-pressed={on.cur} onClick={() => toggle('cur')}><i className="dm-c" />{seriesLabels[0]}</button>
<button type="button" className="dm-rdx-k" aria-pressed={on.base} onClick={() => toggle('base')}><i className="dm-b" />{seriesLabels[1]}</button>
</div>
</div>
<div ref={ch} className="dm-rdx-ch" role="group" aria-label="Scores by axis. Left and Right move between axes" onKeyDown={keys}
onPointerLeave={() => { if (!ch.current?.contains(document.activeElement)) setHot(-1); }} onBlur={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setHot(-1); }}>
{W > 0 && <svg className="dm-rdx-svg" viewBox={`0 0 ${W} ${H}`} aria-hidden="true">
{[25, 50, 75, 100].map(v => <polygon key={v} className={cx('dm-ring', v === 100 && 'dm-out')} points={axes.map((_, i) => pt(i, v).join(',')).join(' ')} />)}
{axes.map((_, i) => { const [x, y] = pt(i, 100); return <line key={i} className={cx('dm-sp', i === hot && 'dm-on')} x1={cxp} y1={cyp} x2={x} y2={y} />; })}
<polygon className={cx('dm-base', !on.base && 'dm-off')} pathLength={100} style={{ strokeDasharray: `${drawn} 100` }} points={poly(a => a.baseline)} />
<polygon className={cx('dm-cur', !on.cur && 'dm-off')} points={poly(a => a.value * grow)} />
{axes.map((a, i) => { const [bx, by] = pt(i, a.baseline), [x, y] = pt(i, a.value * grow); return <g key={i}>
<circle className={cx('dm-pt dm-b', !(on.base && drawn >= 100) && 'dm-off')} r={2.5} cx={bx} cy={by} />
<circle className={cx('dm-pt dm-c', !(on.cur && grow > .98) && 'dm-off', i === hot && 'dm-on')} r={3} cx={x} cy={y} /></g>; })}
</svg>}
<span className="dm-rdx-avg dm-num"><b>{Math.round(avg)}</b><span className="dm-muted">average</span></span>
{axes.map((a, i) => {
const an = ang(i), cs = Math.cos(an), sn = Math.sin(an), side = Math.abs(cs) < .2 ? 'm' : cs < 0 ? 'l' : 'r';
const x = cxp + cs * (R + 12), y = cyp + sn * (R + 12), d = a.value - a.baseline, weak = a.value < weakBelow;
const el = labels.current[i], w = el?.offsetWidth ?? 0, h = el?.offsetHeight ?? 0;
return (
<button type="button" key={a.label} ref={b => { labels.current[i] = b; }} className={cx('dm-rdx-ax', 'dm-' + side, i === hot && 'dm-on', i === pulse && grow > .98 && 'dm-hot')} tabIndex={i === focusAt ? 0 : -1}
aria-label={`${a.label}: ${a.value}, baseline ${a.baseline}, ${d >= 0 ? 'up' : 'down'} ${Math.abs(d)}${weak ? ', weak' : ''}`}
style={{ left: side === 'm' ? x - w / 2 : side === 'l' ? x - w : x, top: Math.abs(sn) > .9 ? (sn < 0 ? y - h : y) : y - h / 2, visibility: W ? undefined : 'hidden' }}
onPointerEnter={e => { if (e.pointerType !== 'touch') setHot(i); }} onFocus={() => { setHot(i); setFocusAt(i); }} onClick={() => setHot(i)}>
<span className="dm-n">{narrow && a.short ? a.short : a.label}</span>
<span className="dm-v dm-num"><b>{a.value}</b><span>{d >= 0 ? '+' : '−'}{Math.abs(d)}</span>{weak && <span className="dm-tag dm-warn">Weak</span>}</span>
</button>
);
})}
</div>
<div className="dm-rdx-ro" aria-live="polite">
{ro ? <span><b>{ro.label}</b> {ro.value} now, {ro.baseline} before, <span className={ro.value < ro.baseline ? 'dm-dn' : 'dm-up'}>{ro.value < ro.baseline ? '−' : '+'}{Math.abs(ro.value - ro.baseline)}</span>{ro.value < weakBelow ? `, below ${weakBelow}` : ''}</span>
: <span><Icon name="spark" size="xs" /><span>{note ?? <>This run averages <b>{mean('value')}</b>, {mean('value') >= mean('baseline') ? 'up' : 'down'} {Math.abs(mean('value') - mean('baseline'))} on the baseline.</>}</span></span>}
</div>
</div>
);
}
radar-chart.cssReact · 50 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Eval radar ---------- */
/* the baseline as an outline, this run filled over it, on grey scale rings; each axis is a label you can point at or tab to */
.dm-rdx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-2);container-type:inline-size}
.dm-rdx-h{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;flex-wrap:wrap}
.dm-rdx-t{display:flex;flex-direction:column;font-size:13.5px;line-height:1.4}
.dm-rdx-t b{font-weight:500}
.dm-rdx-t .dm-muted{font-size:12px}
.dm-rdx-lg{display:flex;gap:var(--dm-s-1)}
.dm-rdx-k{display:flex;align-items:center;gap:6px;height:26px;padding:0 9px;border-radius:var(--dm-r-pill);font-size:12px;color:var(--dm-muted);transition:color .2s,background .2s}
@media (hover:hover) and (pointer:fine){.dm-rdx-k:hover{background:var(--dm-row)}}
.dm-rdx-k[aria-pressed="true"]{color:var(--dm-fg)}
.dm-rdx-k i{width:10px;height:10px;border-radius:50%;transition:opacity .3s,transform .3s var(--dm-ease)}
.dm-rdx-k i.dm-c{background:color-mix(in srgb,var(--dm-c1) 22%,transparent);box-shadow:inset 0 0 0 1.5px var(--dm-c1)}
.dm-rdx-k i.dm-b{box-shadow:inset 0 0 0 1.5px var(--dm-c2)}
.dm-rdx-k[aria-pressed="false"] i{opacity:.3;transform:scale(.7)}
.dm-rdx-ch{position:relative;height:280px}
.dm-rdx-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
.dm-rdx-svg .dm-ring{fill:none;stroke:color-mix(in srgb,var(--dm-line) 50%,transparent);stroke-width:1}
.dm-rdx-svg .dm-ring.dm-out{stroke:var(--dm-line)}
.dm-rdx-svg .dm-sp{stroke:color-mix(in srgb,var(--dm-line) 50%,transparent);stroke-width:1;transition:stroke .2s}
.dm-rdx-svg .dm-sp.dm-on{stroke:var(--dm-faint)}
.dm-rdx-svg .dm-base{fill:none;stroke:var(--dm-c2);stroke-width:1.5;stroke-linejoin:round;transition:opacity .3s}
.dm-rdx-svg .dm-cur{fill:color-mix(in srgb,var(--dm-c1) 16%,transparent);stroke:var(--dm-c1);stroke-width:2;stroke-linejoin:round;transition:opacity .3s}
.dm-rdx-svg .dm-off{opacity:0}
.dm-rdx-svg .dm-pt{fill:var(--dm-surface);stroke-width:2;transition:opacity .3s,r .2s var(--dm-ease)}
.dm-rdx-svg .dm-pt.dm-c{stroke:var(--dm-c1)}.dm-rdx-svg .dm-pt.dm-b{stroke:var(--dm-c2)}
.dm-rdx-svg .dm-pt.dm-on{r:4.5}
.dm-rdx-avg{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:flex;flex-direction:column;align-items:center;padding:2px var(--dm-s-2);border-radius:calc(8px * var(--dm-rk));background:color-mix(in srgb,var(--dm-surface) 82%,transparent);line-height:1.25;pointer-events:none}
.dm-rdx-avg b{font-size:14px;font-weight:500}
.dm-rdx-avg .dm-muted{font-size:11px}
/* an axis: its name, this run's score and how far it moved; a weak one says so in words */
.dm-rdx-ax{position:absolute;display:flex;flex-direction:column;gap:1px;max-width:92px;padding:3px 6px;border-radius:calc(7px * var(--dm-rk));font-size:12px;line-height:1.3;color:var(--dm-fg2);transition:background var(--dm-t-fast),color var(--dm-t-fast)}
.dm-rdx-ax .dm-v{display:flex;align-items:center;gap:4px;font-size:11.5px;color:var(--dm-muted);white-space:nowrap}
.dm-rdx-ax .dm-v b{font-weight:500;color:var(--dm-fg)}
.dm-rdx-ax .dm-tag{height:16px;padding:0 5px;font-size:10.5px}
.dm-rdx-ax.dm-l{text-align:right;align-items:flex-end}.dm-rdx-ax.dm-m{text-align:center;align-items:center}
.dm-rdx-ax.dm-on{background:var(--dm-row);color:var(--dm-fg)}
@media (hover:hover) and (pointer:fine){.dm-rdx-ax:hover{background:var(--dm-row);color:var(--dm-fg)}}
.dm-rdx-ax.dm-hot{animation:dm-rdxpulse 1.1s var(--dm-ease-io) 3}
@keyframes dm-rdxpulse{50%{transform:scale(1.06);background:var(--dm-row)}}
.dm-rdx-ro{min-height:56px;display:flex;align-items:center;gap:var(--dm-s-2);padding:var(--dm-s-2) var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-row);font-size:12.5px;color:var(--dm-fg2)}
.dm-rdx-ro>span{display:flex;align-items:center;gap:var(--dm-s-2)}
.dm-rdx-ro .dm-i{flex:none;color:var(--dm-accent-fg,var(--dm-accent))}
.dm-rdx-ro b{font-weight:500;color:var(--dm-fg)}
.dm-rdx-ro .dm-dn{color:var(--dm-bad)}.dm-rdx-ro .dm-up{color:var(--dm-ok)}
:root:not([data-style="soft"]) .dm-rdx-svg .dm-ring.dm-out:not(:where([data-style="soft"],[data-style="soft"] *)){stroke:var(--dm-rail)}
/* on a phone the labels stack their parts, so the rings get more room, and the average gives way to them */
@container (max-width:300px){.dm-rdx-ax{max-width:68px}.dm-rdx-ax .dm-v{flex-wrap:wrap;justify-content:inherit}.dm-rdx-ax.dm-l .dm-v{justify-content:flex-end}.dm-rdx-ax.dm-m .dm-v{justify-content:center}.dm-rdx-avg{display:none}.dm-rdx-ch{height:260px}}
RadarChart.astroAstro · 34 lines
---
// Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync.
// Eval radar: This run against the baseline on six eval scores, with the weak ones flagged.
// The markup, CSS and script are designme.dev's own, run by the shared core in ../core, which mounts the component when it scrolls
// into view and plays its demo. To show your own data, change radar-chart.js. The markup is kept exactly as it is (is:raw).
import type { HTMLAttributes } from 'astro/types';
import '../core/designme.css';
import './radar-chart.css';
export interface Props extends HTMLAttributes<'div'> {
/** "soft" draws it in the Soft style. Leave it out for Blueprint, or for the style set on <html> (data-style="soft") */
'data-style'?: 'soft';
}
const { class: className, ...rest } = Astro.props;
---
<div class:list={['card', className]} data-c="radar-chart" {...rest}>
<div class="stage-in"><Fragment is:raw>
<div class="rdx panel">
<div class="rdx-h">
<div class="rdx-t"><b>Eval scores</b><span class="muted">Run 48 against run 41</span></div>
<div class="rdx-lg"><button class="rdx-k" aria-pressed="true" data-s="cur"><i class="c"></i>This run</button><button class="rdx-k" aria-pressed="true" data-s="base"><i class="b"></i>Baseline</button></div>
</div>
<div class="rdx-ch" role="group" aria-label="Scores by axis. Left and Right move between axes">
<svg class="rdx-svg" aria-hidden="true"></svg>
<span class="rdx-avg num"><b>0</b><span class="muted">average</span></span>
</div>
<div class="rdx-ro" aria-live="polite"><span></span></div>
</div>
</Fragment></div>
</div>
<script>
import './radar-chart.js';
</script>
radar-chart.htmlHTML · 16 lines
<div class="card" data-c="radar-chart">
<div class="stage-in">
<div class="rdx panel">
<div class="rdx-h">
<div class="rdx-t"><b>Eval scores</b><span class="muted">Run 48 against run 41</span></div>
<div class="rdx-lg"><button class="rdx-k" aria-pressed="true" data-s="cur"><i class="c"></i>This run</button><button class="rdx-k" aria-pressed="true" data-s="base"><i class="b"></i>Baseline</button></div>
</div>
<div class="rdx-ch" role="group" aria-label="Scores by axis. Left and Right move between axes">
<svg class="rdx-svg" aria-hidden="true"></svg>
<span class="rdx-avg num"><b>0</b><span class="muted">average</span></span>
</div>
<div class="rdx-ro" aria-live="polite"><span></span></div>
</div>
</div>
</div>
radar-chart.cssHTML · 49 lines
/* ---------- Eval radar ---------- */
/* the baseline as an outline, this run filled over it, on grey scale rings; each axis is a label you can point at or tab to */
.rdx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-2);container-type:inline-size}
.rdx-h{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;flex-wrap:wrap}
.rdx-t{display:flex;flex-direction:column;font-size:13.5px;line-height:1.4}
.rdx-t b{font-weight:500}
.rdx-t .muted{font-size:12px}
.rdx-lg{display:flex;gap:var(--dm-s-1)}
.rdx-k{display:flex;align-items:center;gap:6px;height:26px;padding:0 9px;border-radius:var(--dm-r-pill);font-size:12px;color:var(--dm-muted);transition:color .2s,background .2s}
@media (hover:hover) and (pointer:fine){.rdx-k:hover{background:var(--dm-row)}}
.rdx-k[aria-pressed="true"]{color:var(--dm-fg)}
.rdx-k i{width:10px;height:10px;border-radius:50%;transition:opacity .3s,transform .3s var(--dm-ease)}
.rdx-k i.c{background:color-mix(in srgb,var(--dm-c1) 22%,transparent);box-shadow:inset 0 0 0 1.5px var(--dm-c1)}
.rdx-k i.b{box-shadow:inset 0 0 0 1.5px var(--dm-c2)}
.rdx-k[aria-pressed="false"] i{opacity:.3;transform:scale(.7)}
.rdx-ch{position:relative;height:280px}
.rdx-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
.rdx-svg .ring{fill:none;stroke:color-mix(in srgb,var(--dm-line) 50%,transparent);stroke-width:1}
.rdx-svg .ring.out{stroke:var(--dm-line)}
.rdx-svg .sp{stroke:color-mix(in srgb,var(--dm-line) 50%,transparent);stroke-width:1;transition:stroke .2s}
.rdx-svg .sp.on{stroke:var(--dm-faint)}
.rdx-svg .base{fill:none;stroke:var(--dm-c2);stroke-width:1.5;stroke-linejoin:round;transition:opacity .3s}
.rdx-svg .cur{fill:color-mix(in srgb,var(--dm-c1) 16%,transparent);stroke:var(--dm-c1);stroke-width:2;stroke-linejoin:round;transition:opacity .3s}
.rdx-svg .off{opacity:0}
.rdx-svg .pt{fill:var(--dm-surface);stroke-width:2;transition:opacity .3s,r .2s var(--dm-ease)}
.rdx-svg .pt.c{stroke:var(--dm-c1)}.rdx-svg .pt.b{stroke:var(--dm-c2)}
.rdx-svg .pt.on{r:4.5}
.rdx-avg{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:flex;flex-direction:column;align-items:center;padding:2px var(--dm-s-2);border-radius:calc(8px * var(--dm-rk));background:color-mix(in srgb,var(--dm-surface) 82%,transparent);line-height:1.25;pointer-events:none}
.rdx-avg b{font-size:14px;font-weight:500}
.rdx-avg .muted{font-size:11px}
/* an axis: its name, this run's score and how far it moved; a weak one says so in words */
.rdx-ax{position:absolute;display:flex;flex-direction:column;gap:1px;max-width:92px;padding:3px 6px;border-radius:calc(7px * var(--dm-rk));font-size:12px;line-height:1.3;color:var(--dm-fg2);transition:background var(--dm-t-fast),color var(--dm-t-fast)}
.rdx-ax .v{display:flex;align-items:center;gap:4px;font-size:11.5px;color:var(--dm-muted);white-space:nowrap}
.rdx-ax .v b{font-weight:500;color:var(--dm-fg)}
.rdx-ax .tag{height:16px;padding:0 5px;font-size:10.5px}
.rdx-ax.l{text-align:right;align-items:flex-end}.rdx-ax.m{text-align:center;align-items:center}
.rdx-ax.on{background:var(--dm-row);color:var(--dm-fg)}
@media (hover:hover) and (pointer:fine){.rdx-ax:hover{background:var(--dm-row);color:var(--dm-fg)}}
.rdx-ax.hot{animation:rdxpulse 1.1s var(--dm-ease-io) 3}
@keyframes rdxpulse{50%{transform:scale(1.06);background:var(--dm-row)}}
.rdx-ro{min-height:56px;display:flex;align-items:center;gap:var(--dm-s-2);padding:var(--dm-s-2) var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-row);font-size:12.5px;color:var(--dm-fg2)}
.rdx-ro>span{display:flex;align-items:center;gap:var(--dm-s-2)}
.rdx-ro .i{flex:none;color:var(--dm-accent-fg,var(--dm-accent))}
.rdx-ro b{font-weight:500;color:var(--dm-fg)}
.rdx-ro .dn{color:var(--dm-bad)}.rdx-ro .up{color:var(--dm-ok)}
:root:not([data-style="soft"]) .rdx-svg .ring.out:not(:where([data-style="soft"],[data-style="soft"] *)){stroke:var(--dm-rail)}
/* on a phone the labels stack their parts, so the rings get more room, and the average gives way to them */
@container (max-width:300px){.rdx-ax{max-width:68px}.rdx-ax .v{flex-wrap:wrap;justify-content:inherit}.rdx-ax.l .v{justify-content:flex-end}.rdx-ax.m .v{justify-content:center}.rdx-avg{display:none}.rdx-ch{height:260px}}
radar-chart.jsHTML · 68 lines
DM.def('radar-chart', (r, c) => {
// six eval scores out of 100: this run against the baseline it is compared with
const A = [['Accuracy', 88, 84], ['Instruction following', 86, 80], ['Tool use', 62, 78], ['Speed', 74, 66], ['Cost efficiency', 82, 58], ['Safety', 91, 90]];
const WEAK = 70, N = A.length;
const ch = $('.rdx-ch', r), svg = $('.rdx-svg', r), avg = $('.rdx-avg b', r), ro = $('.rdx-ro span', r), keys = $$('.rdx-k', r);
const mean = k => Math.round(A.reduce((t, a) => t + a[k], 0) / N);
ch.insertAdjacentHTML('beforeend', A.map((a, i) => `<button class="rdx-ax" data-i="${i}" tabindex="${i ? -1 : 0}" aria-label="${a[0]}: ${a[1]}, baseline ${a[2]}, ${a[1] >= a[2] ? 'up' : 'down'} ${Math.abs(a[1] - a[2])}${a[1] < WEAK ? ', weak' : ''}"><span class="n">${a[0]}</span><span class="v num"><b>${a[1]}</b><span>${a[1] >= a[2] ? '+' : '−'}${Math.abs(a[1] - a[2])}</span>${a[1] < WEAK ? '<span class="tag warn">Weak</span>' : ''}</span></button>`).join(''));
const L = $$('.rdx-ax', ch);
let grow = 0, drawn = 0, on = { cur:true, base:true }, hot = -1, G = null;
// the geometry follows the chart's size, so the labels always sit outside the rings
// on a narrow card the long names shorten, and the rings take whatever room the side labels leave
const SHORT = ['Accuracy', 'Instructions', 'Tool use', 'Speed', 'Cost', 'Safety'];
function geo(){ const W = ch.clientWidth, H = ch.clientHeight, narrow = W < 380;
L.forEach((l, i) => { const t = narrow ? SHORT[i] : A[i][0], n = $('.n', l); if (n.textContent !== t) n.textContent = t; });
const side = Math.max(...L.filter((l, i) => Math.abs(Math.cos(ang(i))) >= .2).map(l => l.offsetWidth));
const R = Math.max(40, Math.min(H / 2 - 46, W / 2 - side - 14)); return { W, H, cx:W / 2, cy:H / 2, R }; }
const ang = i => -Math.PI / 2 + i * 2 * Math.PI / N;
const pt = (g, i, v) => [g.cx + Math.cos(ang(i)) * g.R * v / 100, g.cy + Math.sin(ang(i)) * g.R * v / 100];
const poly = (g, k, s = 1) => A.map((a, i) => pt(g, i, a[k] * s).map(n => n.toFixed(1)).join(',')).join(' ');
function draw(){
G = geo(); const g = G; svg.setAttribute('viewBox', `0 0 ${g.W} ${g.H}`);
svg.innerHTML = [25, 50, 75, 100].map(v => `<polygon class="ring${v === 100 ? ' out' : ''}" points="${A.map((a, i) => pt(g, i, v).join(',')).join(' ')}"/>`).join('')
+ A.map((a, i) => { const [x, y] = pt(g, i, 100); return `<line class="sp" x1="${g.cx}" y1="${g.cy}" x2="${x}" y2="${y}"/>`; }).join('')
+ `<polygon class="base${on.base ? '' : ' off'}" pathLength="100" style="stroke-dasharray:${drawn} 100" points="${poly(g, 2)}"/>`
+ `<polygon class="cur${on.cur ? '' : ' off'}" points="${poly(g, 1, grow)}"/>`
+ A.map((a, i) => { const [bx, by] = pt(g, i, a[2]), [x, y] = pt(g, i, a[1] * grow); return `<circle class="pt b${on.base && drawn >= 100 ? '' : ' off'}" r="2.5" cx="${bx}" cy="${by}"/><circle class="pt c${on.cur && grow > .98 ? '' : ' off'}${i === hot ? ' on' : ''}" r="3" cx="${x}" cy="${y}"/>`; }).join('');
$$('.sp', svg).forEach((s, i) => s.classList.toggle('on', i === hot));
L.forEach((l, i) => { const a = ang(i), x = g.cx + Math.cos(a) * (g.R + 12), y = g.cy + Math.sin(a) * (g.R + 12), cs = Math.cos(a), sn = Math.sin(a);
const side = Math.abs(cs) < .2 ? 'm' : cs < 0 ? 'l' : 'r'; l.classList.remove('l', 'm', 'r'); l.classList.add(side);
const w = l.offsetWidth, h = l.offsetHeight;
l.style.left = (side === 'm' ? x - w / 2 : side === 'l' ? x - w : x) + 'px'; l.style.top = (Math.abs(sn) > .9 ? (sn < 0 ? y - h : y) : y - h / 2) + 'px'; });
}
const ro2 = new ResizeObserver(draw); ro2.observe(ch); c.offs.push(() => ro2.disconnect());
// the line under the chart: the axis in hand with both scores and the change, or else the agent's note
let note = `${I('spark','xs')}<span>Run 48 averages <b>${mean(1)}</b>, up ${mean(1) - mean(2)} on the baseline.</span>`;
const show = i => { hot = i; L.forEach((l, j) => l.classList.toggle('on', j === i)); draw();
if (i < 0){ swap(ro, note, true); return; }
const [n, a, b] = A[i], d = a - b; swap(ro, `<span><b>${n}</b> ${a} now, ${b} before, <span class="${d < 0 ? 'dn' : 'up'}">${d < 0 ? '−' : '+'}${Math.abs(d)}</span>${a < WEAK ? ', below 70' : ''}</span>`, true); };
L.forEach((l, i) => { c.on(l, 'pointerenter', e => { if (e.pointerType !== 'touch') show(i); }); c.on(l, 'focus', () => show(i)); c.on(l, 'click', () => show(i)); });
c.on(ch, 'pointerleave', () => { if (!ch.contains(document.activeElement)) show(-1); });
c.on(ch, 'focusout', e => { if (!ch.contains(e.relatedTarget)) show(-1); });
// roving focus: one axis takes Tab, Left and Right (and Up and Down) go round the chart, Home and End to the first and last
c.on(ch, 'keydown', e => { const i = L.indexOf(document.activeElement); if (i < 0) return;
const to = { ArrowRight:i + 1, ArrowDown:i + 1, ArrowLeft:i - 1, ArrowUp:i - 1, Home:0, End:N - 1 }[e.key]; if (to == null) return;
e.preventDefault(); const j = (to + N) % N; L.forEach((l, k) => l.tabIndex = k === j ? 0 : -1); L[j].focus(); });
// the legend shows and hides a series; one always stays
keys.forEach(k => k.onclick = () => { const s = k.dataset.s, o = s === 'cur' ? 'base' : 'cur'; if (on[s] && !on[o]) return; on[s] = !on[s]; k.setAttribute('aria-pressed', on[s]); draw(); });
ro.innerHTML = '<span class="muted">Drawing the baseline</span>'; avg.textContent = '0';
// the baseline draws itself, this run grows out of the middle, then the agent says what changed and points at tool use
c.run(async () => {
await c.wait(300);
await tween(0, 100, 900, v => { drawn = v; draw(); });
await c.wait(250);
tween(0, mean(1), 1100, v => avg.textContent = Math.round(v));
await tween(0, 1, 1100, v => { grow = v; draw(); });
await c.wait(500);
note = `${I('spark','xs')}<span>Cheaper and faster, <b>weaker at tool use</b> (−16). It skipped the search tool on 7 of 40 tasks.</span>`;
if (hot < 0) swap(ro, note, true);
const t = L[2]; t.classList.remove('hot'); void t.offsetWidth; t.classList.add('hot');
});
if (r === c.st) c.story(async s => {
await s.beat(10);
await s.point(L[2]); show(2); await s.beat(4);
await s.point(L[4]); show(4); await s.beat(3);
s.away(); show(-1); await s.beat(3);
});
});
Questions
What people ask about it.
- How many axes can it take?
- It is laid out for five to seven. With more, the labels start to crowd on a phone.
- Can I compare more than two runs?
- No. It is built for one run against one baseline, which keeps the picture readable. For more runs, use a table or a line chart.
- What scale do the scores use?
- 0 to 100 on every axis. Convert other units first, for example cost efficiency as a score where 100 is your cheapest run.
More in Charts and tables
Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.