Skip to content
Star on GitHub
Charts and tablesEval radar
Style
Accent
Sound

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 call

Eval 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.

$npx designme add radar-chart
Eval scoresRun 48 against run 41
0average
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add radar-chart

React

$npx designme add radar-chart --react

Astro

$npx designme add radar-chart --astro

The 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.

02

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.
03

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" />
04

Props

Everything RadarChart takes.

PropTypeWhat it does
axesrequiredRadarAxis[]Five to seven axes, clockwise from the top
titlestring
subtitlestring
seriesLabels[string, string]The legend's names for the two series
weakBelownumberA score under this is flagged "Weak" (70)
noteReactNodeThe agent's note under the chart, shown while no axis is pointed at. Defaults to the average and its change
pulsenumberAn axis to call out: its label pulses (by index)
classNamestring
05

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);
  });
});
06

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.
07

More in Charts and tables

Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.

Line chartTwo series over time with a hover readout and toggles.
Bar chartWeekly agent runs, split by outcome, with a range switch.
Donut chartWhere the agent's time went, by kind of task.
Radial goalsEach agent's share of its own target as a ring, with the overall score in the middle.
Funnel chartSignup to paid, with the biggest drop called out by the agent.
Top pagesMost visited pages with share of traffic and a quick sort.
Health scoreA design system score made of a few checks you can open.
Score breakdownA score out of 100 whose gauge is split by what goes into it, with the weakest part flagged and a fix to try.
All 13 in Charts and tables →