# Data-viz rules

The house rules `charts.js`, `choropleth.js`, and every page in this stack are
built to. Mechanically checkable pieces are gated (`check-token-fidelity.mjs`,
`check-a11y-contrast.mjs`); the rest is editorial judgment a reviewer applies
by eye, the same way [`I18N-RULES.md`](../spine/kits/i18n/I18N-RULES.md) and
[`MOTION-RULES.md`](../spine/kits/motion/MOTION-RULES.md) work for their kits.

## 1. The categorical scale, in order

Use `--tp-cat-1` through `--tp-cat-8`, in this order, for series 1 through 8
in any chart, line, or map layer. Source of truth:
[`tokens/CONTEXTS.md`](../spine/kits/tokens/CONTEXTS.md) and
`design-systems/TresPies/colors.md` Context D.

| Series | Token | Reads as |
|---|---|---|
| 1 | `--tp-cat-1` | sun gold |
| 2 | `--tp-cat-2` | ocean |
| 3 | `--tp-cat-3` | ember |
| 4 | `--tp-cat-4` | emerald |
| 5 | `--tp-cat-5` | red |
| 6 | `--tp-cat-6` | cyan |
| 7 | `--tp-cat-7` | lime |
| 8 | `--tp-cat-8` | violet (bridge — not in the logo, closes the wheel) |

A 9th series wraps back to `--tp-cat-1`. Past 8 concurrent series, two series
will share a color — restructure the chart (small multiples, a filtered view)
rather than inventing a 9th hue outside this scale.

## 2. Color is never the only signal

Every mark in `charts.js`/`choropleth.js` pairs its color with a second,
color-independent encoder — a direct numeric label, a distinct marker shape,
or a dash pattern — so the chart still carries its information rendered in
grayscale or read by a color-blind visitor:

- **Bars** — the value is printed directly above the bar, in the page's
  normal text color, never inside the colored fill.
- **Lines** — each series gets a distinct marker shape (circle, square,
  triangle, diamond) and, past four series, a distinct dash pattern, plus its
  name printed at the end of its own line.
- **Choropleth cells** — every cell prints its own numeric value. The label's
  own foreground (ink or paper) is chosen per cell by contrast ratio against
  that cell's fill, so the number stays legible at both ends of the
  sequential scale, not just at one end.

A legend that shows only color swatches, with no accompanying shape or value,
does not satisfy this rule on its own.

## 3. Axis and label legibility

- Chart text reads from the page's Night Shift tokens (`--text` for values,
  `--muted` for axis/category labels, `--quiet` for gridline numbers and
  legend endpoints) — never a color sampled from the data series itself,
  which is what rule 2's "never inside the colored fill" is protecting.
- **`--subtle` is not available for chart text, and that is measured, not
  stylistic.** `--subtle` (`#6e6e80`) reaches 3.96:1 against the page ground
  at its very best, and on a raised chart card it measures 3.49:1 — under
  AA-normal, and the 11px gridline numbers it was used for are far too small
  to qualify for the relaxed 3:1 large-text threshold. `--quiet` is the
  stack-local third step that replaces it (declared in `styles.css`, base
  value `--muted`, enhanced to a mix of `--muted` and `--subtle` where
  `color-mix()` resolves); it measures 5.09:1 on `--plane-1`. `charts.js` and
  `choropleth.js` read it as `cssVar('--quiet', cssVar('--muted'))`, so a
  consumer that vendors the primitives without this stack's stylesheet still
  gets a legible axis rather than an invented hex.
- Gridlines are drawn at a fixed, small tick count (4-5 lines) landing on
  round numbers (`niceMax()`), not on the raw data maximum — a jagged axis
  like 0/23/46/69/92 is harder to read at a glance than 0/25/50/75/100.
- No rotated axis text. If labels are too long to fit horizontally, shorten
  them (abbreviate, wrap to small multiples) rather than rotating type.

## 4. The accessible-table requirement

Every chart ships a real `<table>` of the exact same data, visually hidden
(`.visually-hidden` — clipped, not `display:none`) but present in the DOM.
This is not a decorative extra: it is the chart's non-visual path for
screen-reader and text-browser visitors, and — because it is static HTML, not
a script-injected overlay — it is also what a JS-off visitor's page keeps
once the generated SVG's own static fallback frame is in place. A chart
without this table is not accessible no matter how good its own `role="img"`
labeling is.

## 5. Honest-scale rules

- **Bar charts always start at zero.** `buildBarChart()` has no "start"
  option — a truncated baseline turns a small real difference into a large
  visual one, which is the single most common way a bar chart misleads.
- **Small multiples share one scale.** `buildSmallMultiples()` computes its
  axis maximum across every panel's data, once, so a taller bar always means
  a bigger number, panel to panel — a per-panel independent scale would let
  two visually-identical bars represent very different numbers.
- **Line charts default to a zero baseline too** (`opts.zeroBaseline`
  defaults `true`); a non-zero start is available for a case that genuinely
  needs it, but the axis's start value must stay visibly labeled, never
  clipped off, so a reader can see where the baseline actually sits.
- **Never imply precision the data doesn't have.** This stack's synthetic
  values are whole numbers on a 0-100 index; `opts.decimals` defaults to `0`
  for exactly that reason — printing `61.847` from a fabricated demo number
  would claim a precision nothing behind it actually has.

## Choropleth geometry: synthetic only, here

`choropleth.js` ships and is demonstrated with an invented grid of unit
squares — no real jurisdiction, boundary, or place. Swapping in a real
GeoJSON dataset is documented in `choropleth.js`'s own closing comment
("Swapping in real GeoJSON"): the FeatureCollection shape and the
value-to-color-to-label pipeline stay the same; real longitude/latitude
coordinates need an actual map projection before `fitToViewBox()`, which is a
linear scale-and-translate, not a projection.
