Skip to content

Charts

Protoyard's own chart kit, with no third-party chart library (ADR-021). Two packages:

math (scales, shapes, downsampling, layouts, palettes), plain TypeScript tested in Node.

components, one lazy subpath per family.

Every chart has:

  • a title;
  • a spoken summary;
  • one keyboard tab stop with arrow-key traversal and a polite live region;
  • a tooltip that follows the pointer, focus and taps;
  • a real table view;
  • loading, empty and error states;
  • colors from the theme.

Live reference: the Charts group of the ui-kit prototype (pnpm protoyard dev ui-kit, then #/components/charts).

Install

Add "@protoyard/charts-ui": "workspace:*" to the prototype's dependencies. Import the styles after the renderer's app.css (or after Tailwind and the theme/UI styles):

@import "@protoyard/renderer-svelte/app.css";
@import "@protoyard/charts-ui/styles.css";

Import charts from a lazy route or chunk (load: () => import('./Screen.svelte')), so chart code stays out of initial JS. The bundle guard fails when it leaks.

Choosing a chart

NeedComponentSubpath
A value over time, many seriesLineChart, AreaChart (stacked)cartesian
Compare categories, stack or 100 %BarChart (mode, orientation)cartesian
Two units on two axes (bars + lines)ComboChartcartesian
Correlation, bubblesScatterChart (size on points)cartesian
Share of a wholeDonutChart, PieChartpart-whole
Step-by-step conversionFunnelChart (takes computeFunnel().steps as they are)part-whole
Nested shares, drill-inTreemappart-whole
Flows between stagesSankeyChart (a DAG)part-whole
Two categorical axesHeatmap (sequential or diverging)matrix
Daily activity over monthsCalendarHeatmapmatrix
A distributionHistogram (rules: ['median', 'mean'])matrix
A number in a tile or cellSparkline, Gauge, RadialProgresscompact
Values per regionTileMap (WORLD_TILES, REGION_TILES or your own tiles)geo

Props and data contracts

Every framed chart takes these props, besides those listed below:

  • title (required);
  • description? and summary?. The summary replaces the generated sentence.
  • height?;
  • the states: loading?, error? + onretry? (its promise keeps Retry pending), emptyMessage?;
  • onselect?, fired on click, tap or Enter.

unit is one of number, compact, cents (integer cents, formatted with currency) or percent (ratios, 0.25 = 25 %).

ComponentDataOther props
LineChart`series: { id, label, points: { x, y \null, datum? }[], color?, comparison? }[] (seriesFromRows(rows, …)` builds it)
AreaChartas LineChartLineChart with area
BarChartas LineChartxKind, orientation (vertical, horizontal), mode (grouped, stacked, expanded), unit, currency, showValues, margin, xHeader, showLegend, showTable, hidden
ComboChartas LineChart; each series may set `type: 'bar' \'line' and axis: 'left' \
ScatterChartas LineChart, numeric x, optional size per pointxKind (time, linear), unit, currency, radius: [min, max], renderer, margin, xHeader, showLegend, showTable, hidden
DonutChartdata: { id, label, value, color? }[]unit, currency, innerRatio, otherThreshold (default 3 %), otherLabel, centerLabel
PieChartas DonutChartDonutChart with no hole
FunnelChart`steps: { id, label, value? \count? }[]`
Treemaproot: { id, label?, value?, children? } (a parent's value, when given, must equal its children's sum)unit
SankeyChartnodes: { id, label? }[], links: { source, target, value }[] (no cycles)unit
Heatmaprows: string[], cols: string[], `values: (number \null)[][]`
CalendarHeatmapstart, end (YYYY-MM-DD), values: Record<'YYYY-MM-DD', number>unit, weekStartsOn
Histogram`values: (number \null)[]`
TileMap`data: Record<tileId, number \null>`
Sparkline`values: (number \null)[]`
Gaugelabel, valuemin, max, thresholds, bandLabels, bandColors, format, size, thickness, class
RadialProgresslabel, valuemax, size, thickness, color, class
ChartFrame (to build your own)children(size) receives { width, height, plot }title, description, summary, hideTitle, height, margin, loading, empty, error, onretry, footer, class

Behavior per family (grouping into "Other", drill-in, cycles as errors, Canvas thresholds) is in the charts-ui README.

Accessibility

  • One tab stop per chart. The plot is a focusable role="group" with

aria-roledescription="chart" and aria-activedescendant (the WAI-ARIA composite pattern; no role="application").

  • Arrows move between points, slices, cells, tiles or nodes; Home and End jump to the ends; Enter selects. On cartesian charts, Page Up and Page Down switch series.
  • A polite live region reads each item ("Pro, 280, 28 %. 2 of 4.").
  • Words before marks. Every chart has a visible title and a generated

summary sentence, which the plot's description links to.

  • "View as table" swaps the plot for a real <table> with the same

formatted values:

  • cartesian charts: one row per x;
  • donut and pie: slice, value and share;
  • funnel: count, conversion and drop-off per step;
  • Sankey: one row per link;
  • histogram: one row per bin;
  • heatmaps, calendar and tile maps: the same values by row and column.

Treemap uses its breadcrumb and drill-in instead.

  • Pointer, focus and touch show the same tooltip. A tap selects the

point under the finger, because a touch fires pointerleave before its click.

  • Color is never the only cue. Every chart has a legend and labels, and

comparison series are dashed. Text on heatmap cells uses light or dark only where it reaches 4.5:1 against the cell.

  • Reduced motion (OS setting or data-py-motion="reduce") sets

--py-chart-duration: 0ms and data-motion="reduce" on every chart.

The E2E suite runs axe (WCAG 2.1 A/AA, serious and critical) on every family demo in light and dark mode.

Palette

Charts read --py-chart1…8 (categorical), --py-seq-1…9 (sequential) and --py-div-1…9 (diverging).

  • Themes with a `charts` block (library and generated themes) publish a

validated palette on <html>, and charts use it as is.

  • Other themes: chartPalette() derives the palette from

chart1…chart5 and repairs it:

  • contrast ≥ 3:1 against the surfaces;
  • categorical colors ≥ 0.08 apart in OKLab;
  • ≥ 0.04 apart under simulated protanopia and deuteranopia;
  • monotonic ramps.

That builder loads on demand (@protoyard/charts/palette/derive).

  • One shared reader watches theme, mode and motion changes for every chart

on the page.

Details and the repair rules: charts README → Chart palettes and THEMES.

Renderers: SVG and Canvas

LineChart and ScatterChart take renderer:

  • `svg`: every mark is an SVG element. Dense lines are reduced with

LTTB to 2 points per pixel (200–4,000 points), keeping the shape, gaps and extremes.

  • `canvas`: pixels are drawn on a decorative <canvas>, with min/max

per pixel column for lines and circles for scatter. The focusable SVG layer on top keeps keyboard, tooltip and table on all points.

  • `auto` (the default) switches to Canvas above a per-kind point count.

The Canvas code is a lazy chunk, so SVG-only screens never download it.

The ui-kit stress demo (#/c/chart-stress: 10,000 points from a fixed seed, an SVG/Canvas toggle) is the bench target.

Measured numbers

All on the Apple M1 Max (10 cores, 64 GB), macOS 27.0, Node 26.11.1, headless Chromium 156.0.8078.4, on 2026-10-19, with the machine shared by other builds (load average 60–140): read them as upper bounds. Method and history: PERFORMANCE → Charts.

WhatMeasuredGate
LTTB, 10,000 points → 1,600 (Node, median)0.19–0.25 ms< 2 ms
Scale + LTTB + path, 100,000 points (Node, median)7.0 ms—
10k-point line render, SVG (Chromium, to DOM + layout)4.2–6.9 ms< 50 ms
10k-point line render, Canvas5.7–10.5 ms< 50 ms
Hover latency p95, both renderers1.1–1.5 ms< 16 ms
Long tasks during a 200-step hover sweep0 (one run of seven, at load average ~50, had two, longest 66 ms)0
Sparkline added to a first screen (Vite build, gzip)2,549 B≤ 2,750 B
LineChart + BarChart view (lazy, gzip)16,006 B (15,959 B before the tap-selection fix)≤ 17,000 B

Whole family subpaths (every component, gzip, beyond the app entry): cartesian 20.7 KB, part-whole 19.0 KB, matrix 21.1 KB, compact 4.6 KB, canvas 2.5 KB, geo 14.1 KB. A real view is smaller: unused components are tree-shaken.

Limits (honest)

  • The line + bar view is 16,006 B, not the planned 10 KB.
  • About 3.7 KB is the interaction layer (keyboard model, tooltip, axes).
  • About 5.2 KB is the two charts themselves.
  • Removing the whole interaction layer would still leave about 12 KB.
  • The breakdown is in PERFORMANCE → Charts.
  • No annotation layer. Release notes or incidents cannot be drawn on

the plot yet. The Pebblestat flagship shows them in a dated strip under the charts.

  • No zoom, brush or pan. Use the date range and drill-downs instead.
  • Canvas covers lines and scatter only. Bars, matrices and part-whole

charts are SVG, which is fine for their sizes.

  • Not measured: real phones and GPUs, screen readers beyond axe and the

live-region checks, and data larger than 100,000 points.

Tests

pnpm vitest run packages/charts packages/charts-ui                # unit (happy-dom)
pnpm vitest run packages/cli/tests/charts-bundle.test.ts          # real Vite build, size ceilings
pnpm protoyard build ui-kit && pnpm exec tsx scripts/bench-charts.ts --runs 3 --check
PROTOYARD_E2E_PROTOTYPES=ui-kit pnpm test:e2e tests/e2e/charts.spec.ts

For agents

The distributable Protoyard skill routes kit choices through its kits sub-skill (`skills/protoyard/kits/SKILL.md`) and its catalog (`skills/protoyard/references/kits.md`), which link back to this guide. Keep the catalog's entry for this kit in step when its entry points or limits change.