Charts and tablesCost vs quality
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 callCost vs quality
Every run as a dot by cost and eval score, sized by tokens, with quadrants and a drag to select. 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 scatter-chartReact
npx designme add scatter-chart --reactAstro
npx designme add scatter-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/scatter-chart.json shadcn CLI, Astro
npx shadcn@latest add https://designme.dev/r/shadcn/astro/scatter-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 scatter chart of agent runs: each dot is a run, placed by its cost and its eval score and sized by the tokens it used, with a colour and a shape for each model. Dashed median lines cut it into four named quadrants. Point at the chart and the tooltip snaps to the nearest run while the other models step back; drag across it to select runs, see how many and their averages, and ask the agent why they stand out.
- To weigh cost against quality across many runs, prompts or models at once.
- To find runs that cost a lot for a middling score, or cheap runs that score well, and ask the agent what they share.
- On an evals or spend dashboard, beside a table of the same runs.
- Axis titles with units set clear of the ticks, a few light gridlines with quiet tick labels, and a dot size that grows with tokens.
- A colour from the chart palette and a shape (circle, square, triangle) for each series, so the models tell apart without colour too.
- Median lines for cost and score, worked out from the runs on show, with a name in each quadrant's corner (
quadrants). - The legend shows and hides each model; one always stays, and the medians follow what is on show.
- Pointing snaps the tooltip to the nearest run within reach (cost, score, run, model and tokens) and dims the other models.
- Dragging draws a dashed box; the runs inside it are selected, with their count and averages under the chart, and an Ask the agent why button (with
onAsk). A click on an empty spot, or Escape, clears it. - On a narrow chart (340px or less, as on a phone) the quadrant names give way to the median lines alone, each axis keeps three ticks, the dots get a little smaller, and a selection's words sit above a full-width Ask the agent why.
ringeddraws dashed rings round runs worth a look, such as outliers the agent found, andnotesays why.- The selection can be controlled with
selectedandonSelectedChange.
- The chart takes the focus as one group with a label saying the arrow keys step through the runs.
- The arrow keys move from run to run, cheapest first; Home and End go to the first and last; Escape clears a selection. Each run in focus is written under the chart, which is a polite live region, with its place in the list ("7 of 48").
- Series differ by shape as well as colour.
- The drawn chart is hidden from screen readers; the line under it carries the values.
- The keyboard focus ring is the quiet grey ring; a mouse or touch never shows one.
Usage
Import it in React and drive it from your own state, or place it in an Astro page.
React
import { ScatterChart } from '@/components/designme/scatter-chart/ScatterChart';React example
The demo above as a React component, 35 lines you can copy.
'use client';
// The preview from designme.dev, rebuilt from the React component: the agent rings three outliers, then the person selects
// the cheap, high scoring runs and asks why.
import { useEffect, useRef, useState } from 'react';
import { ScatterChart, type ScatterRun } from './ScatterChart';
import { useStory } from '../lib/useScript';
const rnd = (i: number, k: number) => { const x = Math.sin(i * 12.9898 + k * 78.233) * 43758.5453; return x - Math.floor(x); };
const RUNS: ScatterRun[] = [];
const add = (series: string, n: number, x: [number, number], y: [number, number], t: [number, number], k: number) => {
for (let i = 0; i < n; i++) RUNS.push({ id: 12 + RUNS.length, series, x: Math.round((x[0] + rnd(i, k) * x[1]) * 100) / 100, y: Math.round(y[0] + rnd(i, k + 1) * y[1]), size: Math.round(t[0] + rnd(i, k + 2) * t[1]) });
};
add('opus', 15, [.3, .22], [85, 12], [28, 30], 1); add('sonnet', 18, [.06, .22], [79, 16], [12, 18], 4); add('haiku', 12, [.02, .08], [64, 20], [5, 8], 7);
RUNS.push({ id: 57, series: 'opus', x: .57, y: 73, size: 61 }, { id: 58, series: 'opus', x: .54, y: 70, size: 58 }, { id: 59, series: 'sonnet', x: .47, y: 71, size: 44 });
export default function ScatterChartDemo() {
const ref = useRef<HTMLDivElement>(null);
const [ringed, setRinged] = useState<number[]>([]);
const [selected, setSelected] = useState<(string | number)[]>([]);
useEffect(() => { const t = setTimeout(() => setRinged([57, 58, 59]), 1500); return () => clearTimeout(t); }, []);
useStory(ref, async ({ wait, press }) => {
await wait(4500); setRinged([]);
setSelected(RUNS.filter(r => r.x >= .03 && r.x <= .2 && r.y >= 88).map(r => r.id));
await wait(1500); press('.dm-cvq-ft .dm-pill');
});
return (
<div ref={ref} style={{ width: '100%' }}>
<ScatterChart runs={RUNS} subtitle="48 runs this week, sized by tokens" ringed={ringed} selected={selected} onSelectedChange={setSelected}
series={[{ key: 'opus', label: 'Opus' }, { key: 'sonnet', label: 'Sonnet' }, { key: 'haiku', label: 'Haiku' }]}
note={ringed.length ? <><b>3 outliers</b> cost over $0.45 for a score under 75. Each one looped on the search tool.</> : undefined}
onAsk={runs => new Promise(res => setTimeout(() => res(`Most of these ${runs.length} are Sonnet runs on short tickets, with under 6 tool calls each. Sending tickets like these to Sonnet would cut their cost by about 40%.`), 1000))} />
</div>
);
}
Astro
In a page in src/pages. It plays the demo above; change its scatter-chart.js to show your own data. Attributes like class go on its root element.
---
import ScatterChart from '../components/designme/scatter-chart/ScatterChart.astro';
---
<ScatterChart />
<ScatterChart data-style="soft" class="my-class" />Props
Everything ScatterChart takes.
| Prop | Type | What it does |
|---|---|---|
runsrequired | ScatterRun[] | |
seriesrequired | ScatterSeries[] | |
title | string | |
subtitle | string | |
xDomain | [number, number] | [min, max] of each axis |
yDomain | [number, number] | |
xTicks | number[] | Where the labels go along the bottom. A narrow chart (340px or less, on a phone) shows the two ends and the middle |
yTicks | number[] | Where the gridlines and their labels go up the side. A narrow chart keeps the two ends and the middle |
xTitle | string | |
yTitle | string | |
formatX | (x: number) => string | |
formatY | (y: number) => string | |
formatSize | (s: number) => string | |
quadrants | [string, string, string, string] | The four quadrants the medians make, from the top left, clockwise: top left, top right, bottom right, bottom left. A narrow chart keeps the median lines and leaves the names out |
ringed | (string | number)[] | Runs to ring, like outliers the agent found |
note | ReactNode | The line under the chart while nothing is selected or in focus |
selected | (string | number)[] | The selected runs, by id |
onSelectedChange | (ids: (string | number)[]) => void | |
onAsk | (runs: ScatterRun[]) => string | Promise<string> | Adds "Ask the agent why" to a selection. Return the answer (or a promise of it) and it streams in. A click on an empty spot, or Escape, clears the selection |
className | string |
Code
The full source, as the CLI installs it.
ScatterChart.tsxReact · 171 lines
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useIsoLayoutEffect } from '../lib/hooks';
import { Icon } from '../lib/icons';
import '../designme.css';
import './scatter-chart.css';
export interface ScatterRun {
id: string | number;
/** Which series it belongs to, by key */
series: string;
/** Along the bottom, like cost per run in dollars */
x: number;
/** Up the side, like an eval score in % */
y: number;
/** Its dot's size, like thousands of tokens */
size: number;
}
export interface ScatterSeries {
key: string;
label: string;
/** Defaults to the chart palette in order: --dm-c1, --dm-c2, --dm-c3 */
color?: string;
/** Each series has a shape as well as a colour, so it reads without colour too */
shape?: 'circle' | 'square' | 'triangle';
}
export interface ScatterChartProps {
runs: ScatterRun[];
series: ScatterSeries[];
title?: string;
subtitle?: string;
/** [min, max] of each axis */
xDomain?: [number, number];
yDomain?: [number, number];
/** Where the labels go along the bottom. A narrow chart (340px or less, on a phone) shows the two ends and the middle */
xTicks?: number[];
/** Where the gridlines and their labels go up the side. A narrow chart keeps the two ends and the middle */
yTicks?: number[];
xTitle?: string;
yTitle?: string;
formatX?: (x: number) => string;
formatY?: (y: number) => string;
formatSize?: (s: number) => string;
/** The four quadrants the medians make, from the top left, clockwise: top left, top right, bottom right, bottom left. A narrow chart keeps the median lines and leaves the names out */
quadrants?: [string, string, string, string];
/** Runs to ring, like outliers the agent found */
ringed?: (string | number)[];
/** The line under the chart while nothing is selected or in focus */
note?: ReactNode;
/** The selected runs, by id */
selected?: (string | number)[];
onSelectedChange?: (ids: (string | number)[]) => void;
/** Adds "Ask the agent why" to a selection. Return the answer (or a promise of it) and it streams in. A click on an empty spot, or Escape, clears the selection */
onAsk?: (runs: ScatterRun[]) => string | Promise<string>;
className?: string;
}
const PAL = ['var(--dm-c1)', 'var(--dm-c2)', 'var(--dm-c3)', 'var(--dm-c4)', 'var(--dm-c5)', 'var(--dm-c6)'];
const SHAPES = ['circle', 'square', 'triangle'] as const;
const median = (a: number[]) => { if (!a.length) return 0; const s = [...a].sort((p, q) => p - q), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
function Mark({ shape, x, y, s }: { shape: 'circle' | 'square' | 'triangle'; x: number; y: number; s: number }) {
return shape === 'circle' ? <circle cx={x} cy={y} r={s} /> : shape === 'square' ? <rect x={x - s * .9} y={y - s * .9} width={s * 1.8} height={s * 1.8} rx={s * .25} />
: <path d={`M${x} ${y - s * 1.15}L${x + s * 1.05} ${y + s * .75}L${x - s * 1.05} ${y + s * .75}Z`} />;
}
/** Runs as dots by cost and quality, sized by tokens, a colour and a shape per model, with median quadrants, a tooltip that snaps to the nearest run, and a drag to select. */
export function ScatterChart({ runs, series, title = 'Cost vs quality', subtitle, xDomain = [0, .6], yDomain = [60, 100], xTicks = [0, .2, .4, .6], yTicks = [60, 80, 100],
xTitle = 'Cost per run (USD)', yTitle = 'Eval score (%)', formatX = x => '$' + x.toFixed(2), formatY = y => Math.round(y) + '%', formatSize = s => Math.round(s) + 'k tokens',
quadrants = ['Cheap, good', 'Costly, good', 'Costly, weaker', 'Cheap, weaker'], ringed = [], note, selected, onSelectedChange, onAsk, className }: ScatterChartProps) {
const pl = useRef<HTMLDivElement>(null), tt = useRef<HTMLDivElement>(null);
const [dim, setDim] = useState({ W: 0, H: 0 });
const [off, setOff] = useState<string[]>([]);
const [hot, setHot] = useState(-1);
const [box, setBox] = useState<number[] | null>(null);
const [inner, setInner] = useState<(string | number)[]>([]);
const sel = selected ?? inner;
const setSel = (ids: (string | number)[]) => { if (selected === undefined) setInner(ids); onSelectedChange?.(ids); };
const [answer, setAnswer] = useState<{ text: string; busy: boolean } | null>(null);
const asked = useRef(0);
const drag = useRef<{ x: number; y: number; id: number; moved: boolean } | null>(null);
useIsoLayoutEffect(() => { const el = pl.current!; const m = () => setDim({ W: el.clientWidth, H: el.clientHeight }); m(); const ro = new ResizeObserver(m); ro.observe(el); return () => ro.disconnect(); }, []);
useEffect(() => { asked.current++; setAnswer(null); }, [sel.join()]);
// narrow charts (phones) leave out the quadrant names and keep three ticks a side, so the runs have the room
const { W, H } = dim, narrow = W > 0 && W <= 340, l = narrow ? 38 : 44, b = 40, t = 6, rr = 8;
const ends = (a: number[], d: [number, number]) => narrow && a.length > 3 ? [a[0], (d[0] + d[1]) / 2, a[a.length - 1]] : a;
const xt = ends(xTicks, xDomain), yt = ends(yTicks, yDomain);
const X = (v: number) => l + (v - xDomain[0]) / (xDomain[1] - xDomain[0]) * (W - l - rr), Y = (v: number) => t + (1 - (v - yDomain[0]) / (yDomain[1] - yDomain[0])) * (H - t - b);
const sMin = Math.min(...runs.map(r => r.size)), sMax = Math.max(...runs.map(r => r.size));
const R = (r: ScatterRun) => (3 + (r.size - sMin) / Math.max(1, sMax - sMin) * 4.5) * (narrow ? .85 : 1);
const si = (k: string) => Math.max(0, series.findIndex(s => s.key === k));
const colorOf = (k: string) => series[si(k)]?.color ?? PAL[si(k) % PAL.length];
const shapeOf = (k: string) => series[si(k)]?.shape ?? SHAPES[si(k) % SHAPES.length];
const shown = runs.map((_, i) => i).filter(i => !off.includes(runs[i].series));
const mx = median(shown.map(i => runs[i].x)), my = median(shown.map(i => runs[i].y));
const near = (px: number, py: number) => { let best = -1, bd = 26 * 26; shown.forEach(i => { const dx = X(runs[i].x) - px, dy = Y(runs[i].y) - py, d = dx * dx + dy * dy; if (d < bd) { bd = d; best = i; } }); return best; };
const local = (e: React.PointerEvent) => { const r = pl.current!.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
const order = [...shown].sort((a, c) => runs[a].x - runs[c].x || runs[c].y - runs[a].y);
const ask = async () => {
if (!onAsk) return; const id = ++asked.current, ps = runs.filter(r => sel.includes(r.id));
setAnswer({ text: '', busy: true }); const text = await onAsk(ps); if (id !== asked.current) return;
const ws = text.split(' ');
for (let i = 1; i <= ws.length; i++) { if (id !== asked.current) return; setAnswer({ text: ws.slice(0, i).join(' '), busy: false }); await new Promise(r => setTimeout(r, 45)); }
};
const hr = hot >= 0 ? runs[hot] : null;
const ps = runs.filter(r => sel.includes(r.id));
const ttX = hr && tt.current ? Math.max(tt.current.offsetWidth / 2, Math.min(W - tt.current.offsetWidth / 2, X(hr.x))) : 0;
return (
<div data-dm="" className={cx('dm-cvq dm-panel', className)}>
<div className="dm-cvq-h">
<div className="dm-cvq-t"><b>{title}</b>{subtitle && <span className="dm-muted">{subtitle}</span>}</div>
<div className="dm-cvq-lg">{series.map(s => (
<button type="button" key={s.key} className="dm-cvq-k" aria-pressed={!off.includes(s.key)}
onClick={() => { if (!off.includes(s.key) && off.length === series.length - 1) return; setHot(-1); setOff(o => o.includes(s.key) ? o.filter(k => k !== s.key) : [...o, s.key]); }}>
<svg viewBox="-6 -6 12 12" style={{ fill: colorOf(s.key) }} aria-hidden="true"><Mark shape={shapeOf(s.key)} x={0} y={0} s={4.5} /></svg>{s.label}
</button>))}</div>
</div>
<div ref={pl} className="dm-cvq-pl" tabIndex={0} role="group" aria-roledescription="scatter chart" aria-label={`${title}. Arrow keys step through the runs`}
onPointerDown={e => { if (e.button) return; const [x, y] = local(e); drag.current = { x, y, id: e.pointerId, moved: false }; }}
onPointerMove={e => {
const [x, y] = local(e), d = drag.current;
if (d && (d.moved || Math.abs(x - d.x) + Math.abs(y - d.y) > 5)) { if (!d.moved) { d.moved = true; pl.current!.setPointerCapture(d.id); setHot(-1); } setBox([d.x, d.y, Math.max(l, Math.min(W - rr, x)), Math.max(t, Math.min(H - b, y))]); return; }
if (e.pointerType !== 'touch') setHot(near(x, y));
}}
onPointerUp={() => { const d = drag.current; drag.current = null; if (!d) return;
if (d.moved && box) { const [a, b2, x2, y2] = box, lo = Math.min(a, x2), hi = Math.max(a, x2), top = Math.min(b2, y2), bot = Math.max(b2, y2);
setSel(shown.filter(i => X(runs[i].x) >= lo && X(runs[i].x) <= hi && Y(runs[i].y) >= top && Y(runs[i].y) <= bot).map(i => runs[i].id)); }
else if (hot < 0 && sel.length) { setSel([]); setBox(null); } }}
onPointerCancel={() => { drag.current = null; setBox(null); }}
onPointerLeave={() => { if (!drag.current && document.activeElement !== pl.current) setHot(-1); }}
onBlur={() => setHot(-1)}
onKeyDown={e => {
let k = order.indexOf(hot);
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') k = Math.min(order.length - 1, k + 1); else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') k = Math.max(0, k - 1);
else if (e.key === 'Home') k = 0; else if (e.key === 'End') k = order.length - 1;
else if (e.key === 'Escape' && sel.length) { setSel([]); setBox(null); return; } else return;
e.preventDefault(); setHot(order[k]);
}}>
{W > 0 && <svg className="dm-cvq-svg" viewBox={`0 0 ${W} ${H}`} aria-hidden="true">
{/* a few light gridlines and quiet tick labels; the axis titles sit well clear of the ticks */}
{yt.map(v => <g key={v}><line className="dm-gl" x1={l} x2={W - rr} y1={Y(v)} y2={Y(v)} /><text x={l - 6} y={Y(v) + 3.5} textAnchor="end">{v}</text></g>)}
{xt.map((v, i) => <text key={v} x={X(v)} y={H - b + 15} textAnchor={i === 0 ? 'start' : i === xt.length - 1 ? 'end' : 'middle'}>{formatX(v)}</text>)}
<line className="dm-ax" x1={l} x2={W - rr} y1={H - b} y2={H - b} />
<text className="dm-at" x={W - rr} y={H - 1} textAnchor="end">{xTitle}</text>
<text className="dm-at" x={-t} y={9} transform="rotate(-90)" textAnchor="end">{yTitle}</text>
<line className="dm-md" x1={X(mx)} x2={X(mx)} y1={t} y2={H - b} /><line className="dm-md" x1={l} x2={W - rr} y1={Y(my)} y2={Y(my)} />
{!narrow && <><text className="dm-q" x={l + 6} y={t + 11}>{quadrants[0]}</text><text className="dm-q" x={W - rr - 4} y={t + 11} textAnchor="end">{quadrants[1]}</text>
<text className="dm-q" x={W - rr - 4} y={H - b - 6} textAnchor="end">{quadrants[2]}</text><text className="dm-q" x={l + 6} y={H - b - 6}>{quadrants[3]}</text></>}
{runs.map((r, i) => <g key={r.id} className={cx('dm-d', off.includes(r.series) && 'dm-off', hr && hr.series !== r.series && !off.includes(r.series) && 'dm-dim', i === hot && 'dm-on', sel.includes(r.id) && 'dm-sel')} style={{ fill: colorOf(r.series) }}>
<Mark shape={shapeOf(r.series)} x={+X(r.x).toFixed(1)} y={+Y(r.y).toFixed(1)} s={+R(r).toFixed(1)} /></g>)}
{runs.filter(r => ringed.includes(r.id) && !off.includes(r.series)).map(r => <circle key={r.id} className="dm-ring" cx={X(r.x)} cy={Y(r.y)} r={R(r) + 6} />)}
{box && <rect className="dm-box" x={Math.min(box[0], box[2])} y={Math.min(box[1], box[3])} width={Math.abs(box[2] - box[0])} height={Math.abs(box[3] - box[1])} rx={3} />}
</svg>}
<div ref={tt} className={cx('dm-cvq-tt', hr && 'dm-on')} style={hr ? { left: ttX, top: Y(hr.y) - R(hr) } : undefined}>
{hr && <><b className="dm-num">{formatX(hr.x)} · {formatY(hr.y)}</b><span>Run {hr.id}, {series[si(hr.series)]?.label}, {formatSize(hr.size)}</span></>}
</div>
</div>
<div className="dm-cvq-ft" aria-live="polite">
{hr && document.activeElement === pl.current
? <span className="dm-cvq-msg"><span><b>Run {hr.id}</b>, {series[si(hr.series)]?.label}: {formatX(hr.x)}, {formatY(hr.y)}, {formatSize(hr.size)}. {order.indexOf(hot) + 1} of {order.length}</span></span>
: answer ? <span className="dm-cvq-msg"><Icon name="spark" size="xs" /><span className={answer.busy ? 'dm-shim' : undefined}>{answer.busy ? `Looking at ${ps.length} runs` : answer.text}</span></span>
: ps.length ? <><span className="dm-cvq-msg"><span><b>{ps.length} runs selected</b>, {formatX(ps.reduce((a, r) => a + r.x, 0) / ps.length)} and {formatY(ps.reduce((a, r) => a + r.y, 0) / ps.length)} on average</span></span>
{onAsk && <button type="button" className="dm-pill dm-soft dm-sm" onClick={ask}><Icon name="spark" size="xs" />Ask the agent why</button>}</>
: <span className="dm-cvq-msg"><Icon name="spark" size="xs" /><span>{note ?? <>Medians split the runs: <b>{formatX(mx)}</b> and <b>{formatY(my)}</b>. Drag across the chart to select runs.</>}</span></span>}
</div>
</div>
);
}
scatter-chart.cssReact · 51 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Cost vs quality ---------- */
/* runs as dots: cost along, eval score up, size for tokens, a colour and a shape for each model. Dashed median lines cut the
chart into four named quadrants; a drag draws a box that selects the runs in it */
.dm-cvq{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3)}
.dm-cvq-h{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;flex-wrap:wrap}
.dm-cvq-t{display:flex;flex-direction:column;font-size:13.5px;line-height:1.4}
.dm-cvq-t b{font-weight:500}
.dm-cvq-t .dm-muted{font-size:12px}
.dm-cvq-lg{display:flex;gap:2px}
.dm-cvq-k{display:flex;align-items:center;gap:6px;height:26px;padding:0 8px;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-cvq-k:hover{background:var(--dm-row)}}
.dm-cvq-k[aria-pressed="true"]{color:var(--dm-fg)}
.dm-cvq-k svg{width:10px;height:10px;overflow:visible;transition:opacity .3s,transform .3s var(--dm-ease)}
.dm-cvq-k[aria-pressed="false"] svg{opacity:.3;transform:scale(.7)}
.dm-cvq-pl{position:relative;height:244px;touch-action:pan-y;cursor:crosshair;user-select:none;-webkit-user-select:none;border-radius:var(--dm-r-sm)}
.dm-cvq-pl:focus-visible{outline-offset:4px}
.dm-cvq-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
.dm-cvq-svg .dm-gl{stroke:color-mix(in srgb,var(--dm-line) 40%,transparent);stroke-width:1}
.dm-cvq-svg .dm-ax{stroke:color-mix(in srgb,var(--dm-line) 80%,transparent);stroke-width:1}
.dm-cvq-svg .dm-md{stroke:color-mix(in srgb,var(--dm-faint) 75%,transparent);stroke-width:1;stroke-dasharray:3 3}
.dm-cvq-svg text{font-size:10.5px;fill:var(--dm-muted);font-family:var(--dm-sans)}
.dm-cvq-svg text.dm-q{font-size:10px}
.dm-cvq-svg text.dm-at{font-size:10.5px}
.dm-cvq-svg .dm-d{stroke:var(--dm-surface);stroke-width:1;fill-opacity:.85;transition:opacity .25s,fill-opacity .25s}
.dm-cvq-svg .dm-d.dm-dim{opacity:.18}
.dm-cvq-svg .dm-d.dm-off{opacity:0}
.dm-cvq-svg .dm-d.dm-sel{fill-opacity:1;stroke:var(--dm-fg);stroke-width:1.2}
.dm-cvq-svg .dm-d.dm-on{fill-opacity:1;stroke:var(--dm-fg);stroke-width:1.5}
.dm-cvq-svg .dm-ring{fill:none;stroke:var(--dm-fg2);stroke-width:1.2;stroke-dasharray:3 2.5}
.dm-cvq-svg .dm-box{fill:color-mix(in srgb,var(--dm-fg) 5%,transparent);stroke:var(--dm-fg2);stroke-width:1;stroke-dasharray:4 3}
.dm-cvq-tt{position:absolute;left:0;top:0;padding:6px 9px;border-radius:calc(10px * var(--dm-rk));background:var(--dm-ink);color:var(--dm-on-ink);font-size:11.5px;display:flex;flex-direction:column;opacity:0;transform:translate(-50%,-100%) translateY(-6px);transition:opacity .15s;pointer-events:none;white-space:nowrap;z-index:2}
.dm-cvq-tt span{opacity:.75}
.dm-cvq-tt.dm-on{opacity:1}
.dm-cvq-ft{min-height:72px;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-cvq-msg{flex:1;min-width:0;display:flex;align-items:center;gap:var(--dm-s-2)}
.dm-cvq-msg .dm-i{flex:none;color:var(--dm-accent-fg,var(--dm-accent))}
.dm-cvq-msg b{font-weight:500;color:var(--dm-fg)}
.dm-cvq-ft .dm-pill{flex:none}
.dm-cvq-ft .dm-ib{flex:none;width:26px;height:26px}
.dm-cvq-ft{flex-wrap:wrap}.dm-cvq-msg{flex:1 1 180px}
/* phones and narrow cards: the plot is a little shorter, and a selection's words sit above its action, which takes the full width */
.dm-cvq{container-type:inline-size}
@container (max-width:340px){
.dm-cvq-pl{height:228px}
.dm-cvq-ft{flex-direction:column;align-items:stretch;justify-content:center;gap:10px}
.dm-cvq-msg{flex:none}
.dm-cvq-msg:has(>.dm-pill){flex-direction:column;align-items:stretch;gap:10px}
.dm-cvq-ft .dm-pill{justify-content:center}
}
ScatterChart.astroAstro · 34 lines
---
// Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync.
// Cost vs quality: Every run as a dot by cost and eval score, sized by tokens, with quadrants and a drag to select.
// 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 scatter-chart.js. The markup is kept exactly as it is (is:raw).
import type { HTMLAttributes } from 'astro/types';
import '../core/designme.css';
import './scatter-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="scatter-chart" {...rest}>
<div class="stage-in"><Fragment is:raw>
<div class="cvq panel">
<div class="cvq-h">
<div class="cvq-t"><b>Cost vs quality</b><span class="muted">48 runs this week, sized by tokens</span></div>
<div class="cvq-lg"></div>
</div>
<div class="cvq-pl" tabindex="0" role="group" aria-roledescription="scatter chart" aria-label="Runs by cost and eval score. Arrow keys step through the runs">
<svg class="cvq-svg" aria-hidden="true"></svg>
<div class="cvq-tt"><b class="num"></b><span></span></div>
</div>
<div class="cvq-ft" aria-live="polite"><span class="cvq-msg"></span></div>
</div>
</Fragment></div>
</div>
<script>
import './scatter-chart.js';
</script>
scatter-chart.htmlHTML · 16 lines
<div class="card" data-c="scatter-chart">
<div class="stage-in">
<div class="cvq panel">
<div class="cvq-h">
<div class="cvq-t"><b>Cost vs quality</b><span class="muted">48 runs this week, sized by tokens</span></div>
<div class="cvq-lg"></div>
</div>
<div class="cvq-pl" tabindex="0" role="group" aria-roledescription="scatter chart" aria-label="Runs by cost and eval score. Arrow keys step through the runs">
<svg class="cvq-svg" aria-hidden="true"></svg>
<div class="cvq-tt"><b class="num"></b><span></span></div>
</div>
<div class="cvq-ft" aria-live="polite"><span class="cvq-msg"></span></div>
</div>
</div>
</div>
scatter-chart.cssHTML · 50 lines
/* ---------- Cost vs quality ---------- */
/* runs as dots: cost along, eval score up, size for tokens, a colour and a shape for each model. Dashed median lines cut the
chart into four named quadrants; a drag draws a box that selects the runs in it */
.cvq{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3)}
.cvq-h{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;flex-wrap:wrap}
.cvq-t{display:flex;flex-direction:column;font-size:13.5px;line-height:1.4}
.cvq-t b{font-weight:500}
.cvq-t .muted{font-size:12px}
.cvq-lg{display:flex;gap:2px}
.cvq-k{display:flex;align-items:center;gap:6px;height:26px;padding:0 8px;border-radius:var(--dm-r-pill);font-size:12px;color:var(--dm-muted);transition:color .2s,background .2s}
@media (hover:hover) and (pointer:fine){.cvq-k:hover{background:var(--dm-row)}}
.cvq-k[aria-pressed="true"]{color:var(--dm-fg)}
.cvq-k svg{width:10px;height:10px;overflow:visible;transition:opacity .3s,transform .3s var(--dm-ease)}
.cvq-k[aria-pressed="false"] svg{opacity:.3;transform:scale(.7)}
.cvq-pl{position:relative;height:244px;touch-action:pan-y;cursor:crosshair;user-select:none;-webkit-user-select:none;border-radius:var(--dm-r-sm)}
.cvq-pl:focus-visible{outline-offset:4px}
.cvq-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
.cvq-svg .gl{stroke:color-mix(in srgb,var(--dm-line) 40%,transparent);stroke-width:1}
.cvq-svg .ax{stroke:color-mix(in srgb,var(--dm-line) 80%,transparent);stroke-width:1}
.cvq-svg .md{stroke:color-mix(in srgb,var(--dm-faint) 75%,transparent);stroke-width:1;stroke-dasharray:3 3}
.cvq-svg text{font-size:10.5px;fill:var(--dm-muted);font-family:var(--dm-sans)}
.cvq-svg text.q{font-size:10px}
.cvq-svg text.at{font-size:10.5px}
.cvq-svg .d{stroke:var(--dm-surface);stroke-width:1;fill-opacity:.85;transition:opacity .25s,fill-opacity .25s}
.cvq-svg .d.dim{opacity:.18}
.cvq-svg .d.off{opacity:0}
.cvq-svg .d.sel{fill-opacity:1;stroke:var(--dm-fg);stroke-width:1.2}
.cvq-svg .d.on{fill-opacity:1;stroke:var(--dm-fg);stroke-width:1.5}
.cvq-svg .ring{fill:none;stroke:var(--dm-fg2);stroke-width:1.2;stroke-dasharray:3 2.5}
.cvq-svg .box{fill:color-mix(in srgb,var(--dm-fg) 5%,transparent);stroke:var(--dm-fg2);stroke-width:1;stroke-dasharray:4 3}
.cvq-tt{position:absolute;left:0;top:0;padding:6px 9px;border-radius:calc(10px * var(--dm-rk));background:var(--dm-ink);color:var(--dm-on-ink);font-size:11.5px;display:flex;flex-direction:column;opacity:0;transform:translate(-50%,-100%) translateY(-6px);transition:opacity .15s;pointer-events:none;white-space:nowrap;z-index:2}
.cvq-tt span{opacity:.75}
.cvq-tt.on{opacity:1}
.cvq-ft{min-height:72px;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)}
.cvq-msg{flex:1;min-width:0;display:flex;align-items:center;gap:var(--dm-s-2)}
.cvq-msg .i{flex:none;color:var(--dm-accent-fg,var(--dm-accent))}
.cvq-msg b{font-weight:500;color:var(--dm-fg)}
.cvq-ft .pill{flex:none}
.cvq-ft .ib{flex:none;width:26px;height:26px}
.cvq-ft{flex-wrap:wrap}.cvq-msg{flex:1 1 180px}
/* phones and narrow cards: the plot is a little shorter, and a selection's words sit above its action, which takes the full width */
.cvq{container-type:inline-size}
@container (max-width:340px){
.cvq-pl{height:228px}
.cvq-ft{flex-direction:column;align-items:stretch;justify-content:center;gap:10px}
.cvq-msg{flex:none}
.cvq-msg:has(>.pill){flex-direction:column;align-items:stretch;gap:10px}
.cvq-ft .pill{justify-content:center}
}
scatter-chart.jsHTML · 104 lines
DM.def('scatter-chart', (r, c) => {
// the week's runs: cost per run in dollars, eval score in %, thousands of tokens, and the model. A few long tool loops cost a lot
// for a middling score
const M = [['Opus', 'var(--dm-c1)', 'c'], ['Sonnet', 'var(--dm-c2)', 's'], ['Haiku', 'var(--dm-c3)', 't']];
const rnd = (i, k) => { const x = Math.sin(i * 12.9898 + k * 78.233) * 43758.5453; return x - Math.floor(x); };
const R = [];
for (let i = 0; i < 15; i++) R.push({ m:0, x:.3 + rnd(i, 1) * .22, y:85 + rnd(i, 2) * 12, t:28 + rnd(i, 3) * 30 });
for (let i = 0; i < 18; i++) R.push({ m:1, x:.06 + rnd(i, 4) * .22, y:79 + rnd(i, 5) * 16, t:12 + rnd(i, 6) * 18 });
for (let i = 0; i < 12; i++) R.push({ m:2, x:.02 + rnd(i, 7) * .08, y:64 + rnd(i, 8) * 20, t:5 + rnd(i, 9) * 8 });
R.push({ m:0, x:.57, y:73, t:61, out:1 }, { m:0, x:.54, y:70, t:58, out:1 }, { m:1, x:.47, y:71, t:44, out:1 });
R.forEach((p, i) => { p.id = 12 + i; p.x = Math.round(p.x * 100) / 100; p.y = Math.round(p.y); p.t = Math.round(p.t); });
const pl = $('.cvq-pl', r), svg = $('.cvq-svg', r), tt = $('.cvq-tt', r), msg = $('.cvq-msg', r), ft = $('.cvq-ft', r), lg = $('.cvq-lg', r);
const shape = (k, x, y, s) => k === 'c' ? `<circle cx="${x}" cy="${y}" r="${s}"/>` : k === 's' ? `<rect x="${x - s * .9}" y="${y - s * .9}" width="${s * 1.8}" height="${s * 1.8}" rx="${s * .25}"/>` : `<path d="M${x} ${y - s * 1.15}L${x + s * 1.05} ${y + s * .75}L${x - s * 1.05} ${y + s * .75}Z"/>`;
lg.innerHTML = M.map((m, i) => `<button class="cvq-k" aria-pressed="true" data-m="${i}"><svg viewBox="-6 -6 12 12" style="fill:${m[1]}">${shape(m[2], 0, 0, 4.5)}</svg>${m[0]}</button>`).join('');
const show = [true, true, true];
let G = null, hot = -1, sel = null, box = null, rings = false, seen = 0, focusI = -1;
const X0 = 0, X1 = .6, Y0 = 60, Y1 = 100;
const med = a => { const s = [...a].sort((p, q) => p - q), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
// narrow charts (phones) drop the quadrant names and keep three ticks a side, so the runs have the room
function geo(){ const W = pl.clientWidth, H = pl.clientHeight, n = W <= 340, l = n ? 38 : 44, b = 40, t = 6, rr = 8; return { W, H, n, l, b, t, rr, x:v => l + (v - X0) / (X1 - X0) * (W - l - rr), y:v => t + (1 - (v - Y0) / (Y1 - Y0)) * (H - t - b), vx:px => X0 + (px - l) / (W - l - rr) * (X1 - X0), vy:py => Y0 + (1 - (py - t) / (H - t - b)) * (Y1 - Y0) }; }
const size = p => (3 + (p.t - 5) / 56 * 4.5) * (G && G.n ? .85 : 1);
const vis = () => R.filter(p => show[p.m]);
function draw(){
const g = G = geo(), v = vis(), mx = med(v.map(p => p.x)), my = med(v.map(p => p.y));
let s = '';
// a few light gridlines and quiet tick labels; the axis titles sit well clear of the ticks
[60, 80, 100].forEach(y => { s += `<line class="gl" x1="${g.l}" x2="${g.W - g.rr}" y1="${g.y(y)}" y2="${g.y(y)}"/><text x="${g.l - 6}" y="${g.y(y) + 3.5}" text-anchor="end">${y}</text>`; });
const xt = g.n ? [0, .3, .6] : [0, .2, .4, .6];
xt.forEach(x => { s += `<text x="${g.x(x)}" y="${g.H - g.b + 15}" text-anchor="${x === 0 ? 'start' : x === .6 ? 'end' : 'middle'}">$${x.toFixed(2)}</text>`; });
s += `<line class="ax" x1="${g.l}" x2="${g.W - g.rr}" y1="${g.H - g.b}" y2="${g.H - g.b}"/>`;
s += `<text class="at" x="${g.W - g.rr}" y="${g.H - 1}" text-anchor="end">Cost per run (USD)</text><text class="at" x="${-g.t}" y="9" transform="rotate(-90)" text-anchor="end">Eval score (%)</text>`;
// the medians split it into four quadrants, each named in its corner (on a phone the lines stay and the names go)
s += `<line class="md" x1="${g.x(mx)}" x2="${g.x(mx)}" y1="${g.t}" y2="${g.H - g.b}"/><line class="md" x1="${g.l}" x2="${g.W - g.rr}" y1="${g.y(my)}" y2="${g.y(my)}"/>`;
if (!g.n) s += `<text class="q" x="${g.l + 6}" y="${g.t + 11}">Cheap, good</text><text class="q" x="${g.W - g.rr - 4}" y="${g.t + 11}" text-anchor="end">Costly, good</text><text class="q" x="${g.l + 6}" y="${g.H - g.b - 6}">Cheap, weaker</text><text class="q" x="${g.W - g.rr - 4}" y="${g.H - g.b - 6}" text-anchor="end">Costly, weaker</text>`;
R.forEach((p, i) => { const m = M[p.m], cl = ['d', !show[p.m] || i >= seen ? 'off' : '', hot >= 0 && R[hot].m !== p.m && show[p.m] ? 'dim' : '', i === hot ? 'on' : '', sel && sel.includes(i) ? 'sel' : ''].filter(Boolean).join(' ');
s += `<g class="${cl}" style="fill:${m[1]}">${shape(m[2], +g.x(p.x).toFixed(1), +g.y(p.y).toFixed(1), +size(p).toFixed(1))}</g>`; });
if (rings) R.forEach(p => { if (p.out && show[p.m]) s += `<circle class="ring" cx="${g.x(p.x)}" cy="${g.y(p.y)}" r="${size(p) + 6}"/>`; });
if (box) s += `<rect class="box" x="${Math.min(box[0], box[2])}" y="${Math.min(box[1], box[3])}" width="${Math.abs(box[2] - box[0])}" height="${Math.abs(box[3] - box[1])}" rx="3"/>`;
svg.setAttribute('viewBox', `0 0 ${g.W} ${g.H}`); svg.innerHTML = s;
// the tooltip sits over the dot in hand
if (hot >= 0){ const p = R[hot], x = g.x(p.x), y = g.y(p.y) - size(p); $('b', tt).textContent = `$${p.x.toFixed(2)} · ${p.y}%`; $('span', tt).textContent = `Run ${p.id}, ${M[p.m][0]}, ${p.t}k tokens`;
tt.classList.add('on'); const w = tt.offsetWidth; tt.style.left = Math.max(w / 2, Math.min(g.W - w / 2, x)) + 'px'; tt.style.top = y + 'px'; }
else tt.classList.remove('on');
}
const ro = new ResizeObserver(draw); ro.observe(pl); c.offs.push(() => ro.disconnect());
const say = (h, keep) => { if (!keep) asked++; swap(msg, h, true); };
let asked = 0;
const idle = () => `${I('spark','xs')}<span>Medians split the runs: <b>$${med(vis().map(p => p.x)).toFixed(2)}</b> and <b>${med(vis().map(p => p.y))}%</b>. Drag across the chart to select runs.</span>`;
// a selection: how many, their averages, and a way to ask about them
function selected(){
if (!sel || !sel.length){ sel = null; say(idle()); draw(); return; }
const ps = sel.map(i => R[i]), ax = ps.reduce((t, p) => t + p.x, 0) / ps.length, ay = ps.reduce((t, p) => t + p.y, 0) / ps.length;
say(`<span><b>${ps.length} runs selected</b>, $${ax.toFixed(2)} and ${Math.round(ay)}% on average</span><button class="pill soft sm cvq-ask">${I('spark','xs')}Ask the agent why</button>`);
// (a click on an empty spot, or Escape, clears the selection)
setTimeout(() => { const a = $('.cvq-ask', ft); if (a) a.onclick = () => ask(ps); }, REDUCE ? 0 : 200);
draw();
}
const ask = ps => c.run(async () => {
const id = ++asked; swap(msg, `${I('spark','xs')}<span class="shim">Looking at ${ps.length} runs</span>`, true); await c.wait(1000); if (id !== asked) return;
const by = M.map((m, i) => ps.filter(p => p.m === i).length), top = by.indexOf(Math.max(...by));
const text = `Most are ${M[top][0]} runs on short tickets, with under 6 tool calls each. Sending tickets like these to ${M[top][0]} would cut their cost by about 40%.`;
msg.innerHTML = `${I('spark','xs')}<span class="b"></span>`; const b = $('.b', msg), ws = text.split(' ');
for (let i = 1; i <= ws.length; i++){ if (id !== asked) return; b.textContent = ws.slice(0, i).join(' '); await c.wait(45); }
});
// pointing snaps to the nearest dot within reach and dims the other models
const near = (px, py) => { let best = -1, bd = 26 * 26; R.forEach((p, i) => { if (!show[p.m] || i >= seen) return; const dx = G.x(p.x) - px, dy = G.y(p.y) - py, d = dx * dx + dy * dy; if (d < bd){ bd = d; best = i; } }); return best; };
const local = e => { const b = pl.getBoundingClientRect(); return [e.clientX - b.left, e.clientY - b.top]; };
let drag = null;
c.on(pl, 'pointerdown', e => { if (e.button) return; const [x, y] = local(e); drag = { x, y, id:e.pointerId, moved:false }; });
c.on(pl, 'pointermove', e => { const [x, y] = local(e);
if (drag && (drag.moved || Math.abs(x - drag.x) + Math.abs(y - drag.y) > 5)){ if (!drag.moved){ drag.moved = true; pl.setPointerCapture(drag.id); hot = -1; } box = [drag.x, drag.y, Math.max(G.l, Math.min(G.W - G.rr, x)), Math.max(G.t, Math.min(G.H - G.b, y))]; draw(); return; }
if (e.pointerType === 'touch') return; const i = near(x, y); if (i !== hot){ hot = i; draw(); } });
c.on(pl, 'pointerleave', () => { if (!drag && hot >= 0 && document.activeElement !== pl){ hot = -1; draw(); } });
const finish = () => { if (!drag) return; const d = drag; drag = null;
if (d.moved){ const [a, b2, x2, y2] = box, lo = Math.min(a, x2), hi = Math.max(a, x2), top = Math.min(b2, y2), bot = Math.max(b2, y2);
sel = R.map((p, i) => i).filter(i => show[R[i].m] && G.x(R[i].x) >= lo && G.x(R[i].x) <= hi && G.y(R[i].y) >= top && G.y(R[i].y) <= bot); selected(); }
else { const i = hot; if (i < 0 && sel){ sel = null; box = null; selected(); } } };
c.on(pl, 'pointerup', finish); c.on(pl, 'pointercancel', () => { drag = null; box = null; draw(); });
// the keys step through the runs from cheapest to dearest; Escape clears a selection
const order = () => R.map((p, i) => i).filter(i => show[R[i].m]).sort((a, b) => R[a].x - R[b].x || R[b].y - R[a].y);
c.on(pl, 'keydown', e => { const o = order(); let k = o.indexOf(hot);
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') k = Math.min(o.length - 1, k + 1); else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') k = Math.max(0, k < 0 ? 0 : k - 1);
else if (e.key === 'Home') k = 0; else if (e.key === 'End') k = o.length - 1;
else if (e.key === 'Escape' && sel){ sel = null; box = null; selected(); return; } else return;
e.preventDefault(); hot = o[k]; draw(); const p = R[hot]; say(`<span><b>Run ${p.id}</b>, ${M[p.m][0]}: $${p.x.toFixed(2)}, ${p.y}%, ${p.t}k tokens. ${k + 1} of ${o.length}</span>`); });
c.on(pl, 'blur', () => { if (hot >= 0){ hot = -1; draw(); } if (!sel) say(idle(), true); });
$$('.cvq-k', lg).forEach(k => k.onclick = () => { const m = +k.dataset.m; if (show[m] && show.filter(Boolean).length === 1) return; show[m] = !show[m]; k.setAttribute('aria-pressed', show[m]); hot = -1; draw(); if (!sel) say(idle(), true); });
msg.innerHTML = idle();
// the dots fade in, the agent rings three runs that cost a lot for a middling score, then the person selects the cheap,
// high scoring corner and asks why
c.run(async () => { await c.wait(250); for (let i = 0; i <= R.length; i += 3){ seen = i; draw(); await c.wait(40); } seen = R.length; draw();
await c.wait(700); rings = true; draw(); say(`${I('spark','xs')}<span><b>3 outliers</b> cost over $0.45 for a score under 75. Each one looped on the search tool.</span>`, true); });
if (r === c.st) c.story(async s => {
await s.beat(7);
const a = [G.x(.03), G.y(97)], b = [G.x(.2), G.y(88)], pb = pl.getBoundingClientRect();
await s.point(pl, false); const hand = $('.hand', c.st), hb = hand.parentElement.getBoundingClientRect();
const put = (x, y) => { hand.style.transform = `translate(${pb.left - hb.left + x}px,${pb.top - hb.top + y}px)`; };
put(...a); await s.beat(1); rings = false;
await tween(0, 1, 900, t => { box = [a[0], a[1], a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; put(box[2], box[3]); draw(); });
sel = R.map((p, i) => i).filter(i => R[i].x >= .03 && R[i].x <= .2 && R[i].y >= 88 && R[i].y <= 97); selected(); await s.beat(3);
await s.tap($('.cvq-ask', ft)); s.away(); await s.beat(10);
});
});
Questions
What people ask about it.
- Can I plot something other than cost and score?
- Yes. Set
xDomain,yDomain, the ticks, the titles, the formatters and the quadrant names for your two measures. The demo's labels are only defaults. - How does the agent answer about a selection?
- Pass
onAsk. It gets the selected runs and returns the answer, or a promise of it; the answer streams in under the chart a word at a time. - Does a drag on a phone scroll the page or select?
- A vertical swipe still scrolls the page. A drag that moves sideways draws the selection box.
More in Charts and tables
Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.