Skip to main content

Axes

A chart has two kinds of axis, owned at two different levels. The x axis is shared by the whole <ChartContainer> — one scale every row draws against, so cursors and ranges line up across stacked plots. Each <ChartRow> owns its own y axis (or axes), so rows with different units stack cleanly.

This page is the reference for declaring and binding axes. The three axis kinds that change what the x axis means each get their own page:

  • Value axis — key by a monotonic quantity (distance, strike) instead of time.
  • Category axis — an ordinal axis, one slot per category.
  • Trading-time axis — a session-aware time axis with closed-market gaps collapsed.

And one that changes how the x axis reads rather than what it means:

  • Duration axis — label a time or value axis as offsets from a zero point (00:00 00:05 00:10) instead of absolute values.

Y axes

A y axis is a <YAxis> inside a <ChartRow>. A draw layer binds to it by id through its axis prop — id picks the scale, while as picks the style (they're separate channels). Declare several with distinct ids and sides for a dual-axis row; placement follows side, not JSX order, and the first-declared axis is the row's default for any layer that names none.

src/examples/learn-02-dual-axis.tsx
import {
ChartContainer,
ChartRow,
Layers,
LineChart,
YAxis,
} from '@pond-ts/charts';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
import { singleHostSeries } from './lib/server-metrics';

export default function DualAxis() {
const theme = useSiteChartTheme();
const series = singleHostSeries();

return (
<ChartContainer range={series.timeRange()} width={560} theme={theme}>
<ChartRow height={220}>
<YAxis id="pct" side="left" label="cpu" format=".0%" />
<YAxis id="ms" side="right" label="latency (ms)" format=",.0f" />
<Layers>
<LineChart series={series} column="cpu" axis="pct" as="primary" />
<LineChart
series={series}
column="latency"
axis="ms"
as="secondary"
/>
</Layers>
</ChartRow>
</ChartContainer>
);
}
<ChartRow height={200}>
<YAxis id="pct" side="left" format=".0%" />
<YAxis id="ms" side="right" format=",.0f" />
<Layers>
<LineChart series={s} column="cpu" axis="pct" />
<LineChart series={s} column="latency" axis="ms" />
</Layers>
</ChartRow>

If a row has no <YAxis> at all, the layers still draw against an implicit auto-fitting axis — you just get no gutter or ticks.

<YAxis> props

PropTypeDefaultPurpose
idstring— (required)The scale id a layer binds to via axis. First-declared = row default.
side'left' | 'right''left'Which gutter the axis sits in.
labelstringidAxis title / unit.
labelPlacement'rotated' | 'top''rotated'rotated = vertical strip on the outer edge; top = horizontal atop.
min / maxnumberauto-fitExplicit domain bounds; omit to fit the bound layers.
padnumber0Fractional headroom added each side of the resolved domain.
formatAxisFormat (d3 specifier or (v) => string)scale defaultTick-label and cursor-readout formatting. Hoist an inline function.
ticksReadonlyArray<{ at: number; label: string }>autoExplicit ticks — drives both labels and gridlines. [] draws none.
tickCountnumberheight-derivedTarget auto-tick count (a ticks(count) hint). Omit ⇒ derived from row height, so a short strip isn't crushed. ticks overrides it.
boundaryLabelsbooleantruefalse drops just the top & bottom extreme labels (gridlines stay).
widthnumber50Gutter width in CSS px.
colorstringthemeThis axis's tick + title colour (presentation-only).
onMouseEvent(info: AxisMouseEvent) => voidMouse events on the gutter, with the axis value under the pointer. See Clicking an axis.
onBoundsChange(bounds: [number, number] | null) => voidA gutter gesture scaled this axis: the [min, max] reached, or null for back-to-auto. Providing it makes the axis controlled.

format is the one prop whose inline function form must be hoisted or useCallback'd — it's the only prop the layout's structural change-detection can't value-compare, so a fresh function each render re-registers the axis.

The x axis

You don't declare the x axis to get one: <ChartContainer> renders a <TimeAxis> at the bottom automatically (showAxis defaults to true). Set showAxis={false} for a bare plot, or to place your own <XAxis> — for a top axis, a label, custom ticks, or a second (transformed) strip.

Its kind is inferred from the data, never set by a prop: a TimeSeries gives a time axis, a ValueSeries a value axis, and BarChart categories a category axis. Every layer in a container must agree on the kind — a mix throws. <TimeAxis> and <CategoryAxis> are both just <XAxis> presets; the kind follows the data regardless of which you render.

<XAxis> props

PropTypeDefaultPurpose
side'top' | 'bottom''bottom'Which edge. Declaration order stacks multiple strips.
labelstringCentred axis title.
formatAxisFormatcontainer'sTick/cursor formatting, resolved against the axis kind.
ticksReadonlyArray<{ at: number; label: string }>autoExplicit ticks in axis-value units.
transform{ to(v): number; from(u): number }Relabel the same scale into a derived unit (a second tick layout).
align'auto' | 'center' | 'right''center'Horizontal tick-label placement.
dateStyle'flat' | 'stacked''flat'Time axis only: flat promotes date context inline on one row (the TradingView look); stacked uses a second boundary row. See Trading-time axis.
timeZonestringcontainer'sTime axis only: render this strip in another IANA zone — the second axis of a two-zone pair. See Time zones.
colorstringthemeTick / label / rule / title colour — the lever for a stacked dual axis.
heightnumberfit-to-contentStrip height in px.
onMouseEvent(info: AxisMouseEvent) => voidMouse events on the strip, with the axis value under the pointer. See Clicking an axis.

Dual x-axes — transform

A second <XAxis> with a transform relabels the same pixel scale into a derived unit — one scale, two tick layouts, never two scales. The to / from pair are monotonic inverses and may be nonlinear:

src/examples/charts-value-axis-dual.tsx
import {
ChartContainer,
ChartRow,
Layers,
LineChart,
ScatterChart,
XAxis,
YAxis,
} from '@pond-ts/charts';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
import { SPOT, smileChain } from './lib/value-axis-fixtures';

export default function ChartsValueAxisDual() {
const theme = useSiteChartTheme();
const chain = smileChain();

return (
<ChartContainer showAxis={false} width={560} theme={theme}>
{/* A second <XAxis> relabels the SAME shared scale into a derived
unit via `transform` — one pixel mapping, two tick layouts. Here
strike (below) and moneyness = strike / spot (above) are linearly
related, so the top strip's ticks land evenly too. */}
<XAxis
side="top"
transform={{ to: (k) => k / SPOT, from: (m) => m * SPOT }}
format=".2f"
label="Moneyness"
/>
<ChartRow height={220}>
<YAxis id="iv" label="implied vol" format=".1%" width={60} />
<Layers>
<LineChart series={chain} column="fair" curve="natural" />
<ScatterChart series={chain} column="fair" id="fair" />
</Layers>
</ChartRow>
<XAxis label="Strike" format=",.0f" />
</ChartContainer>
);
}

Declaration order stacks the strips (before <ChartRow> → above the plot, after → below); gridlines always follow the container's primary ticks. This is relabeling, not the axis-kind mixing that throws — the scale is unchanged.

Time formatting

Two independent channels. Labels: ChartContainer's timeFormat (or a per-instance <XAxis format>) shapes the tick labels; a custom label format owns the labels, so it opts the axis out of the dateStyle date styles (flat / stacked). Readout: ChartContainer's cursorFormat shapes the crosshair pill, marker indicators, and annotation auto-labels independently, and does not disqualify a date style. Omit both and the readout defaults to a grain-aware format — a day-or-coarser axis reads a date, a sub-day axis date + clock — so a daily bar never reads a foreign-timezone time-of-day.

Time zones

By default the time axis renders in the viewer's zone — the browser's, whatever that is — which is what every chart did before there was a knob. To render in a named zone, give the container an IANA id:

<ChartContainer timeZone="Europe/Berlin">

Everything that reads time follows: day ticks land on Berlin midnights (23 or 25 hours apart across a DST change), week and month ticks on Berlin Mondays and month starts, labels and the stacked date bands read Berlin dates, and the crosshair pill, marker indicators and annotation auto-labels read Berlin time. The timeFormat / cursorFormat specifier strings are unchanged; a %Z now reads the zone's abbreviation (CEST) and %z its offset. Sub-day ticks align to the zone's wall clock, so a 6 h grain reads 00 / 06 / 12 / 18 on both sides of a DST jump. Function formatters still receive epoch ms; the resolved zone is on the chart context as timeZone (undefined = local).

The one idiom. A zoned axis and a zoned aggregate should be given the same zone, once — the same core primitive (TimeZone) cuts the buckets and places the ticks, so a bucket edge and the tick that labels it are one instant:

const timeZone = 'Europe/Berlin';
const daily = series.aggregate(Sequence.calendar('day', { timeZone }), {
kwh: 'sum',
});
<ChartContainer timeZone={timeZone}>

The two defaults differ on purpose: Sequence.calendar defaults to UTC (a deterministic answer wherever the code runs), the axis to the viewer's zone (what a reader expects a clock to say). Passing the zone to both is what removes the asymmetry.

Two strips, two zones. A second <XAxis timeZone> renders in its own zone over the same pixel mapping — the container's zone below the plot, say, and the exchange's or a colleague's above it:

<ChartContainer timeZone="UTC">
<XAxis side="top" timeZone="America/New_York" label="New York" />
<ChartRow></ChartRow>
<XAxis label="UTC" />
</ChartContainer>

Same instants, different calendars: each strip's day turns sit on its own midnight, and each strip's cursor pill reads its own zone. A container cursorFormat still wins for the pill; a container timeFormat string is re-resolved in the strip's zone.

Trading calendars carry their zone. A @pond-ts/financial TradingCalendar.fromRules({ timeZone: 'America/New_York', … }) exposes that zone, and <ChartContainer calendar={cal}> renders in it with no further wiring — a NYSE chart reads New York time in Sydney. An explicit timeZone prop still wins (a display zone over the exchange zone).

Clicking an axis

Both axes take an onMouseEvent handler, and it carries the thing you can't work out from the DOM event alone: the axis value under the pointer.

<YAxis
id="price"
onMouseEvent={({ event, id, value, label }) => {
if (event.type !== 'click') return;
setThreshold({ axis: id, at: value }); // `label` is "184.20", as the axis prints it
}}
/>

One handler receives every mouse event on the strip — click, dblclick, contextmenu, mousedown/mouseup, mousemove, mouseenter/mouseleave — so switch on event.type and ignore the rest. The event itself is the ordinary React one: modifier keys, button, preventDefault(). Nothing is attached when you omit the prop, so an axis that doesn't opt in pays nothing for the move events.

What arrives:

FieldTypeWhat it is
eventReact.MouseEventThe raw event; event.type says which one fired.
axis'x' | 'y'Which axis fired, so one handler can serve both.
idstring | undefinedThe axis's id. A <YAxis> always has one; an <XAxis> has none.
valuenumberThe pointer's pixel inverted through that axis's scale — epoch ms on a time axis, the number on a value axis.
labelstringvalue on that axis's readout channel — the category name on a category axis.

Four details worth knowing:

  • value is continuous, not snapped to a tick — it lands between them. The exception is a category x-axis, whose scale inverts to the nearest band centre (i + 0.5), so label names the category you clicked. A categorical row (horizontal bars, categories on y) is a plain slot scale and doesn't snap: Math.floor(value) is its slot index.
  • On a transformed x-axis, value is the underlying value, not the derived unit — apply the same transform.to to read it in the strip's own language.
  • Two stacked x-axes look identical (id is undefined for both), so close over the distinction: onMouseEvent={(e) => onAxis('delta', e)}.
  • label is the readout channel, not the tick text. It always agrees with what the cursor pill says at that pixel, which means a container cursorFormat shapes it (the documented cursorFormat → axis format → container precedence). Set one and label reads in the precise form the pill does, which may be finer than the terse tick beneath it.

Axes take no className, so use the data-axis hooks to style one — e.g. [data-axis='x'] { cursor: pointer }, or [data-axis-id='price'] for a single y gutter.

See also