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,
} 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">
<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">
<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>, an onRegionSelect 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, 40 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 container cursorFormat still overrides it, and a cursorFormat 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 (04,000) instead of distance recorded (2,0005,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">
{/* 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 spanning a collapsed session gap sit unevenly across the seam. Elapsed trading time (five hours of market time) isn't implemented.
  • 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.