---
name: data-and-charts
description: Charts that are true — picking the chart from the question, honest axes, colour-blind-safe series, direct labels, and the data shapes that break a chart
when: When building a dashboard, report, stats, totals, a leaderboard, a graph, or anything showing numbers
---

# Data and charts

A technically-working chart that misleads is the normal output, not the rare
one. Everything below exists to stop a specific way that happens.

## What can actually load a chart library, and what cannot

⚠️⚠️ **A CDN tag is refused by the browser, silently.** The page renders, the
library is `undefined`, and the only evidence is one console line — which reads
as "the chart is broken" when it was a network refusal. `{host}/vendor/` is the
**only** external script source that works, always shaped
`/vendor/<package>@<exact-version>/<file>.min.js`.

⭐ **It is nearly free.** An app has a hard `400_000`-byte ceiling for its own
files and Chart.js minified is ~200KB, so inlining spends half the project on one
dependency. The `<script src>` costs ~**50 bytes**, cached across every app.

⚠️ **Never guess a package is on the shelf.** The exact URLs — including Chart.js
and d3 — are in `vendor-shelf`, with the version traps in `vendor-traps`. A
library not named there is not there, and a wrong path 404s **silently**: a blank
rectangle that looks exactly like a chart with a data bug. Classic scripts only —
`type="module"` is fetched in CORS mode and a published app runs in an opaque
origin, so it fails before your code runs.

## ⭐ Default to SVG you draw yourself anyway

For what a dashboard actually needs (under ~200 points, ~6 series) hand-drawn
SVG wins: no version trap, themable with `currentColor`, directly labellable,
and it inherits your type:

```js
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));

function barChart(rows, { w = 560, h = 220, pad = 28 } = {}) {  // rows: [{ label, value }]
  const max = Math.max(1, ...rows.map(r => r.value));
  const bw = (w - pad * 2) / rows.length;
  const bars = rows.map((r, i) => {
    const bh = Math.round((h - pad * 2) * (r.value / max));
    const x = pad + i * bw, y = h - pad - bh, mid = x + bw / 2;
    return `<rect x="${x + 4}" y="${y}" width="${bw - 8}" height="${bh}" rx="3" fill="currentColor"/>`
      + `<text x="${mid}" y="${h - 8}" text-anchor="middle" font-size="11">${esc(r.label)}</text>`
      + `<text x="${mid}" y="${y - 6}" text-anchor="middle" font-size="11">${r.value}</text>`;
  }).join('');
  return `<svg viewBox="0 0 ${w} ${h}" width="100%" role="img" aria-label="chart">${bars}</svg>`;
}
```

⚠️ `esc` is not optional — labels are whatever a visitor typed into a public
store; un-escaped, a label is script injection.

A sparkline is `<polyline points="…">`; a donut is one `<circle>` with
`stroke-dasharray`. Canvas is fine too.

**Reach for a library** for interactive tooltips over hundreds of points,
zoom/pan, automatic time ticks, or many-series stacked/combo charts. Below that
it is a dependency for two rectangles.

## The numbers come from the store, not from an array you typed

A dashboard whose data is a hard-coded literal is a picture of one.

```js
const { items, total } = await AcuvoData.list('orders', { limit: 200 });
const revenue = items.reduce((n, r) => n + (r.value.amount || 0), 0);
```

⚠️ `list` returns **`{ items, total }`, not an array**, **100 records by default
and at most 200**, and it is **async**. So:

- Compute totals from `items` and label them honestly: if `total > items.length`
  it is "last 100", not "all time".
- Never `.map()` the result of `list` directly — that is `undefined.map`.
- Never render before the `await` resolves (`state-management`).

## Pick the chart from the QUESTION, not from the data

| the question | the chart |
|---|---|
| how has this changed over time? | line |
| which of these is biggest? | horizontal bar, sorted |
| what is this made of? | stacked bar — **not** a pie |
| are these two related? | scatter |
| how is this spread out? | histogram — an average alone hides it |
| what is the single number right now? | just print the number, large |

⭐ The last row is the one most over-built: one number should be one number,
not a gauge, a donut and a sparkline that make it harder to read.

⚠️ **An average is a claim about a distribution.** "Average response 4 minutes"
where half are 30 s and half 8 min describes nobody. Show the spread: median
plus p90 in text beats a mean in a big font.

## ⚠️ Never a pie for more than five slices, and never for non-parts

People compare angles badly. Past ~5 slices a pie is a legend with a picture
attached; a sorted horizontal bar answers "which is biggest" instantly in the
same space. And a pie of values that do not sum to a meaningful whole — "visits
by page" where one visit hits several — is simply wrong arithmetic.

## The axis rules that change the conclusion

- ⚠️⚠️ **Bar charts start at zero. Always.** Length is the encoding; a truncated
  axis makes 102 look twice 101 — the commonest chart lie, usually a library
  auto-fitting the domain. Set the minimum.
- **Line charts need not start at zero** — the shape of the change is the point —
  but say so: a y-axis starting at 40 must be labelled `40`, not blank.
- ⚠️ **Never two y-axes.** The correlation you see is the one you chose the
  scales to make. Stack two charts on one x-axis instead.
- ⚠️ **A time axis must be time, not a list of rows.** A missing Tuesday plotted
  Mon→Wed draws a line through a day that never happened. Space by date; break
  the line at a real gap.
- **Log scales need saying so.** Label the axis `(log)` — equal distances are
  equal *ratios*, and nobody reads that unless told.
- **Label the units.** "Revenue" is not a unit; "Revenue (A$, ex GST)" is.
- ⚠️ **Percent vs percentage point.** 20% → 25% is "up 5 percentage points" or
  "up 25%". Mixing them is how a chart and its caption disagree.
- **Cumulative charts always go up.** That is not growth; for "are we
  growing" plot the per-period value.

## Colour: two rules, and one palette that survives

**One accent for the series that matters, grey for the rest.** Every series in
its own bright colour says everything matters equally, which is never true.

⚠️⚠️ **Never encode meaning in hue alone.** ~1 in 12 men cannot separate red from
green. Double-encode: a label, a dash pattern, or a marker shape.

Categorical colours: the Okabe–Ito set, distinguishable under the common
colour-vision deficiencies:

```
#0072B2 blue      #E69F00 orange    #009E73 bluish green   #CC79A7 reddish purple
#56B4E9 sky blue  #D55E00 vermillion #F0E442 yellow        #000000 black
```

⚠️⚠️ **Fill colours, not text colours.** Against white only `#0072B2` (5.19:1)
clears WCAG 4.5:1 — `#D55E00` 3.87, `#009E73` 3.42, `#CC79A7` 3.06, `#56B4E9`
2.31, `#E69F00` 2.25, `#F0E442` **1.32**. Labels stay in your ink colour; the
swatch carries the hue.

⭐ Use them in that order: two series = blue + orange, the safest pair.

**Sequential** scales vary lightness, not hue (one hue pale→dark reads in
greyscale). **Diverging** scales: blue↔orange, never red↔green.

## ⭐ Label the series directly; a legend is a lookup task

A legend makes the reader match a swatch to a name and hold it in memory while
they read. On a line chart put the name at the end of its own line:

```js
// after the polyline for a series ending at (x2, y2) — reserve right padding
`<text x="${x2 + 6}" y="${y2 + 4}" font-size="11" fill="${colour}">${esc(name)}</text>`
```

Use a legend only when direct labels would collide — many short series, or a
stacked bar. Same for bars: the value belongs at the end of the bar, not on a
y-axis the eye has to travel back to.

## ⚠️ The data shapes that break a chart, and what to draw instead

Every one of these ships as a blank rectangle by default.

| shape | what a chart does | what to draw instead |
|---|---|---|
| **0 rows** | empty axes, or `-Infinity` from `Math.max()` of an empty array | the empty state: "No orders yet. This fills in after your first sale." |
| **1 point** | a line with nothing to connect | the number, large, plus "1 day of data — a trend needs a week" |
| **2–4 points** | a "trend" that is noise | the numbers, and no trendline |
| **~1,000+ points** | 1,000 DOM nodes; frame rate into the floor | aggregate to a coarser period (daily→weekly), or draw to `<canvas>` |
| **one huge outlier** | every other bar is 1px tall | keep the scale honest and annotate it; never clip it silently |
| **a negative value** | a bar drawn upward, or off-canvas | a zero line with bars both sides |

```js
const max = Math.max(1, ...rows.map(r => r.value));   // the `1` is the empty-array guard
```

## Show loading, empty AND failed — they are three different screens

Not a blank rectangle. **Loading** → the chart frame with a shimmer. **Empty** →
"No orders yet. This fills in after your first sale." **Failed** → "Couldn't load
your orders — <the error message>" plus a Retry button.

A chart that renders zero because the read failed is a lie told in pictures.
`error-handling` covers why `.catch(() => [])` turns the third into the second;
`web-app-quality` has the markup for all four states.

## Numbers people can read

- Round to the precision that matters: `A$1,284`, not `A$1284.3891`
- Thousands separators, always: `n.toLocaleString()`
- Axis ticks compact, the tooltip exact:
  `new Intl.NumberFormat('en-AU', { notation: 'compact' }).format(1284000)` → `1.3M`
- Percentages need a base: "12% (of 340 calls)"
- Dates with no ambiguity: `18 Aug 2026`, never `08/09/26`
- **Right-align numerals and use `font-variant-numeric: tabular-nums`**, so digits
  line up in a column and the eye can compare magnitudes without reading.

## A chart is an image to a screen reader unless you help

```html
<svg role="img" aria-labelledby="c1t c1d"> <title id="c1t">Revenue by month</title>
  <desc id="c1d">Rose from A$4,200 in June to A$9,800 in August.</desc> … </svg>
```

⭐ `aria-label="chart"` says nothing. The description should be the *sentence you
would say out loud* about the shape. Where a reader might need exact values, put
a real `<table>` next to it — visually hidden is fine.

## Tables are underrated

If the reader's real question is "what exactly was this number", a sorted table
with right-aligned numerals beats every chart. **Charts are for shape; tables are
for values.** A dashboard that shows both, and lets the chart be small, is
usually the right answer.

## Before calling a chart done

- Does the axis start at zero if it is a bar chart?
- Could you tell the series apart in greyscale?
- Does the title state the *finding*, not the field name? "Revenue up 34% since
  June" beats "Revenue".
- Draw it with zero rows and with one row — does it still make sense?
- Does the number in the caption use the same rounding as the number on the axis?

## Search over the store

`AcuvoData.search(collection, text)` returns records whose text contains
`text` anywhere (case-insensitive, newest first, up to 100). Put it behind a
search box; do not list everything and filter in the page.

```js
const box = document.querySelector('#search');
let timer;
box.addEventListener('input', () => {
  clearTimeout(timer);
  timer = setTimeout(async () => {
    const { items } = box.value.trim()
      ? await AcuvoData.search('contacts', box.value.trim(), { limit: 50 })
      : await AcuvoData.list('contacts');
    render(items);
  }, 250);
});
```

`AcuvoData.private.search` is the same over the signed-in person's own
records. Matching is substring, not ranking; say "contains" in the UI.
