---
title: "Chart anatomy"
description: "Axes, ticks, bands and gaps. The rule against a truncated health y-axis, why the scale is fixed rather than fitted, and what a missing reading must look like."
url: "https://opsinjs.pensievelabs.org/foundations/data-visualisation/chart-anatomy"
source: "https://opsinjs.pensievelabs.org/foundations/data-visualisation/chart-anatomy.md"
section: "Foundations"
kind: "foundation"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["y-axis", "axis truncation", "gridlines", "chart baseline"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="foundation" />

## Overview [#overview]

The most consequential decision in a health chart is not the colour of the line.
It is the range of the y-axis, and it is usually made automatically by whatever
plotting library is in use, whose default is to fit the axis to the data.

Fitting the axis to the data is how a two-point change in a blood-pressure
reading becomes a cliff. The library did nothing wrong; it filled the space it
was given. But a reader does not read an axis, they read a shape, and the shape
now says something the numbers do not.

So opsinjs states it as a rule with no soft edges: &#x2A;*a health chart's y-axis is
chosen once per metric and held.** It does not fit itself to the visible window,
it does not rescale when a reading changes, and it is not chosen to make anything
look like anything.

<NotBuiltYet what="A chart that renders every part named here">
  `trend-sparkline` ships. It draws a single series with an optional
  reference band, and it is deliberately minimal: no axis furniture, no second
  series, no interaction. It renders almost none of what this page names, and no
  chart in the registry renders all of it. Everything else on this page is
  specification, and the specification is what an implementation will be held to.
</NotBuiltYet>

## How it works [#how-it-works]

**The axis is fixed, not fitted.** The product sets the range for a metric once,
with clinical input, and uses it for every rendering of that metric. Two
consequences follow immediately, and both are the point: a change of a given size
looks the same size every week, and two charts of the same metric can be compared.

**Zero, or the clinical context, and nothing in between.** Zero is meaningful for
steps, for minutes of activity and for doses taken. Wherever it is meaningful,
the axis starts at zero, always, and a bar or area chart may start nowhere else.
Where zero is not meaningful, which covers most vital signs, the axis instead
spans the clinically relevant range for that metric with headroom on both sides.
What it may never do is span only the observed data. "Never truncate a health
y-axis" means precisely this: the axis is not permitted to crop to the data in
order to fill the plot area.

**If the axis does not start at zero, say so where the reader is looking.** An
axis label alone does not carry it; readers take the shape first and the numbers
second, if at all. The chart says the range it covers in words next to the shape.

**Aspect ratio is part of the claim.** The same data in a tall narrow frame looks
volatile and in a short wide frame looks flat. The aspect ratio for a metric is
fixed alongside its axis, for the same reason.

**Gaps are gaps.** A day with no reading is drawn as a discontinuity, never
bridged by a straight line. Connecting across a gap draws values that were never
measured, and it is the most common way a chart asserts data it does not have.
That is [data states](../data-states.mdx), rule 4, in a chart.

**Time on the x-axis is real time.** Readings are placed by when they were taken,
not evenly by index. Five readings taken over three weeks with a fortnight's gap
in the middle are not a smooth series of five, and drawing them evenly spaced
turns an irregular record into an apparent routine.

**Minimal furniture.** Gridlines are the lightest mark on the chart or absent;
ticks are few and land on values a person would say out loud; the axis carries its
unit. The last data point is labelled directly with its value, because that is the
number the reader came for and they should not have to read it off an axis.

**No dual y-axes, ever.** Two scales on one plot make the crossing point of two
lines look meaningful when it is an artefact of two arbitrary ranges. Use two
charts.

## Using it [#using-it]

**Decide the axis with the metric, not with the chart.** The range belongs to
"how this product displays blood pressure", alongside its units and its
precision, not to one screen. See
[Numbers, units and precision](../../health/numbers-units-precision.mdx).

<DoDont>
  <DoDont.Do>
    A fortnight of systolic readings on the metric's fixed axis, with the
    reference band behind them, the days with no reading left as gaps, and the
    latest value labelled directly.
  </DoDont.Do>

  <DoDont.Dont>
    The same fortnight auto-scaled to the observed minimum and maximum, with the
    two missing days interpolated. A four-point variation fills the frame, the
    reader sees a dramatic rise, and two of the points on the line were invented
    by the renderer.
  </DoDont.Dont>
</DoDont>

**Do not animate the axis.** A y-axis that rescales while the reader watches
turns a data update into a visual event and makes two renderings incomparable.

**Do not let the window change the scale.** Switching from seven days to ninety
changes what is on the x-axis. It must not change the y-axis, or the two views
tell different stories about the same readings.

**Give the reader the window, in words.** "Last 14 days" beside the chart, not
only inferable from tick labels. Somebody skimming takes the shape and the
caption and nothing else.

**Do not extrapolate, project or smooth.** A trend line, a moving average or a
projection drawn on the same axes as the readings is indistinguishable from the
readings. If a smoothed series is genuinely useful, it is visually subordinate,
labelled, and never the only line.

## Tokens [#tokens]

Axis, gridline and tick colours come from the neutral roles; the reference band
uses the neutral band tint from the chart ramp; spacing and label sizes come from
the space and type scales. Chart anatomy owns no tokens of its own, and that is
deliberate, because a chart with its own private geometry tokens is a chart
nobody else can match.

<NoDataYet script="scripts/build-tokens.mts" />

## Accessibility impact [#accessibility-impact]

* **A fixed axis is an accessibility feature as much as an honesty one.** Readers
  with low numeracy rely most heavily on the shape, which makes them the most
  exposed to a shape that changes meaning between renderings.
* **Axis labels scale with the reader's text size**, so the plot area shrinks
  rather than the labels. A chart that assumes a fixed label width overlaps its
  own axis at 1.3×, and the first thing lost is the unit.
* **Gridlines and ticks are non-text content** and are measured against their
  background under WCAG 2.2 SC 1.4.11. Making them "subtle" is the most common
  way a chart fails it.
* **A gap must be perceivable without colour**, which means a real discontinuity
  in the line rather than a lighter segment. It must also be stated in the table
  twin, where a missing value is an empty cell with a reason, not a blank.
* **The window and the axis range belong in the chart's text summary**, so that a
  reader using a screen reader gets the same framing a sighted reader takes from
  the shape. The contract is on [Accessible charts](./accessible-charts.mdx).

## Related [#related]

* [Accessible charts](./accessible-charts.mdx) has the table twin and the summary
  sentence that carry everything this page draws.
* [Trends and change](../../health/trends-and-change.mdx) says when a change is
  large enough to be worth drawing at all.
* [Data states](../data-states.mdx) has the partial-data rule that gaps are an
  instance of.
