Skip to main content

Duration axis

Some charts aren't about when — they're about how far in. A workout, a lab run, a load test, a race: nobody reads "the heart rate at 10:38", they read "the heart rate five minutes in". <ChartContainer origin> relabels the shared x axis as offsets from a zero point, so the strip reads

00:00 00:05 00:10

where it used to read

10:35 10:40 10:45
src/examples/charts-duration-axis.tsx
import {
ChartContainer,
ChartRow,
Layers,
LineChart,
XAxis,
YAxis,
LineCursor,
} from '@pond-ts/charts';
import { TimeSeries } from 'pond-ts';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';

/** A ride that started at 10:33:17 — a deliberately un-round instant, so the
* duration labels and the wall-clock labels can't be confused. */
const START = Date.UTC(2026, 0, 15, 10, 33, 17);

function ride() {
const rows: Array<[number, number]> = [];
for (let i = 0; i < 73; i += 1) {
rows.push([START + i * 10_000, 138 + 22 * Math.sin(i / 8) + (i % 3)]);
}
return new TimeSeries({
name: 'ride',
schema: [
{ name: 'time', kind: 'time' },
{ name: 'hr', kind: 'number' },
] as const,
rows,
});
}

export default function ChartsDurationAxis() {
const theme = useSiteChartTheme();

return (
<ChartContainer showAxis={false} width={560} theme={theme} origin="data">
<LineCursor />
<ChartRow height={200}>
<YAxis id="hr" label="bpm" width={44} />
<Layers>
<LineChart series={ride()} column="hr" axis="hr" />
</Layers>
</ChartRow>
{/* The primary strip reads durations from the ride's first sample. */}
<XAxis label="Elapsed" />
{/* A second strip on the SAME origin-anchored ticks: a d3 time specifier
can only describe an instant, so it labels the wall clock underneath. */}
<XAxis format="%H:%M" />
</ChartContainer>
);
}

That example stacks both strips on purpose — durations on top, the wall clock underneath, one shared tick set. See Two label systems, one scale below.

origin​

ValueZero pointUse it for
'data'The start of the data (the union of the layers' x extents)"Since the beginning of the series" — the common case. Stays put as you pan.
a numberAn explicit instant / axis valueA gun, a trigger, a lap marker. Ticks before it read negative (-00:05).
<ChartContainer width={620} origin="data">
<LineCursor />
<ChartRow height={180}>
<YAxis id="hr" label="bpm" />
<Layers>
<LineChart series={ride} column="hr" axis="hr" />
</Layers>
</ChartRow>
<XAxis label="Elapsed" />
</ChartContainer>

origin lives on the container, not on <XAxis>, because the container owns the shared x geometry: the ticks it resolves are the ones the axis labels and the ones the gridlines draw at. Put the origin on one axis and you'd get labels at 00:05 over a gridline at 10:35.

Labelling, not transforming​

origin changes two things and only two: where the ticks sit and what they say. Everything else stays in absolute axis units — a range, a <Marker at>, a <RangeCursor onDragRelease> span, trackerPosition, the data itself. There is no second coordinate system to keep straight; you never convert.

Ticks sit at round durations, measured from the origin​

This is the part that a formatter alone can't do. A ride starting at 10:33:17 gets ticks at 10:33:17, 10:38:17, 10:43:17 — so its labels read 00:00, 00:05, 00:10. Placing the ticks on wall-clock boundaries instead and just relabelling them would give 00:01:43, 00:06:43, 00:11:43: technically correct, unreadable.

The step comes off a duration ladder — the intervals a clock actually subdivides by — rather than the 1-2-5 ladder a linear axis uses:

1 / 2 / 5 / 10 / 20 / 50 / 100 / 200 / 500 ms · 1 / 2 / 5 / 10 / 15 / 30 s · 1 / 2 / 5 / 10 / 15 / 30 min · 1 / 2 / 3 / 6 / 12 h · then 1-2-5 whole days

Calendar months are deliberately not a rung: an elapsed axis measures duration, and "one month later" isn't one.

Label shapes​

The step picks the finest component shown, the axis's magnitude picks the coarsest. A minute-or-coarser step heads the clock with hours even when they're zero (00:05 — five minutes in), because that's the shape of the wall-clock axis it replaces; only an axis fine enough for seconds drops to MM:SS.

AxisReads
2 s, 500 ms steps00:00.000 00:00.500
90 s, 15 s steps00:15 00:30 00:45
2 h, 30 s steps01:01:30
10 min, 5 min steps00:00 00:05 00:10
6 h, 1 h steps01:00 02:00
3 days, 12 h steps12:00 1d 00:00 1d 12:00
30 days, 5 day steps0d 5d 10d

The day part appears only once there is one — the same promotion-at-the-turn the flat date style makes on a calendar axis.

The cursor reads one grain finer​

Ticks stay terse; the crosshair pill and <Marker indicator> pills add seconds — 00:05:12 under a 00:05 axis. Same shape, more precision: the elapsed twin of the grain-aware readout a calendar axis gives you. A <CrosshairCursor format> still overrides it, and a format function receives the elapsed default as its defaultText.

Two label systems, one scale​

An explicit format wins over the duration default — and on a time axis, a d3 time specifier can only describe an instant, so it labels the wall clock underneath. That's the lever, not a limitation: declare two <XAxis> strips and you get durations and wall clock on one shared, origin-anchored tick set.

<XAxis label="Elapsed" />
<XAxis format="%H:%M" color="#8b8fa3" />

For a label the library doesn't offer, pass a function — it receives the raw axis value and owns the whole label:

<ChartContainer origin="data" timeFormat={(t) => `${Math.round((t - start) / 60_000)}m`}>

On a value axis​

The same prop, the same walk: ticks anchored at the origin on the 1-2-5 ladder, labels reading the offset. A ride logged from 1,200 m reads distance travelled (0 … 4,000) instead of distance recorded (2,000 … 5,000). Here a number specifier describes the offset perfectly well, so <XAxis format> formats the offset rather than the absolute value.

<ChartContainer width={560} origin="data">
<LineCursor />
{/* series is a ValueSeries — see the value-axis page */}
<XAxis label="Metres ridden" />
</ChartContainer>

Sharp edges​

  • Trading calendars. On a trading-time axis the durations are wall-clock, so ticks sit unevenly across a collapse seam: the ladder strides through closed time the axis doesn't draw, and everything inside a gap lands on the seam itself. Ticks that would coincide there are dropped rather than stacked, so a session boundary shows one label instead of four — expect thinning around the seams, not a uniform stride. Elapsed trading time (five hours of market time) isn't implemented.
  • An origin far outside the view makes every label carry the distance to it. origin={0} on a 2026 time axis is legitimately ~20,468 days ago, so the ticks read 20468d 10:00, 20468d 10:01 — correct, and useless. Put the origin near the data ('data', or an instant inside it); the labels shorten as soon as you do.
  • Category axes have no numeric origin to offset from; origin is ignored.
  • The origin is the data's, not the view's. Pan past the first sample and the labels go negative rather than re-zeroing at the left edge. A view-anchored mode isn't implemented.

See also​

  • Storybook: Axes/DurationAxis — one story per grain, both origin kinds, the value axis, and the format overrides.
  • Value axis — keying by a quantity instead of time.
  • Axes — declaring and binding axes, <XAxis> props.