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.
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
| Prop | Type | Default | Purpose |
|---|---|---|---|
id | string | — (required) | The scale id a layer binds to via axis. First-declared = row default. |
side | 'left' | 'right' | 'left' | Which gutter the axis sits in. |
label | string | id | Axis title / unit. |
labelPlacement | 'rotated' | 'top' | 'rotated' | rotated = vertical strip on the outer edge; top = horizontal atop. |
min / max | number | auto-fit | Explicit domain bounds; omit to fit the bound layers. |
pad | number | 0 | Fractional headroom added each side of the resolved domain. |
format | AxisFormat (d3 specifier or (v) => string) | scale default | Tick-label and cursor-readout formatting. Hoist an inline function. |
ticks | ReadonlyArray<{ at: number; label: string }> | auto | Explicit ticks — drives both labels and gridlines. [] draws none. |
tickCount | number | height-derived | Target auto-tick count (a ticks(count) hint). Omit ⇒ derived from row height, so a short strip isn't crushed. ticks overrides it. |
boundaryLabels | boolean | true | false drops just the top & bottom extreme labels (gridlines stay). |
width | number | 50 | Gutter width in CSS px. |
color | string | theme | This axis's tick + title colour (presentation-only). |
onMouseEvent | (info: AxisMouseEvent) => void | — | Mouse events on the gutter, with the axis value under the pointer. See Clicking an axis. |
onBoundsChange | (bounds: [number, number] | null) => void | — | A 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
| Prop | Type | Default | Purpose |
|---|---|---|---|
side | 'top' | 'bottom' | 'bottom' | Which edge. Declaration order stacks multiple strips. |
label | string | — | Centred axis title. |
format | AxisFormat | container's | Tick/cursor formatting, resolved against the axis kind. |
ticks | ReadonlyArray<{ at: number; label: string }> | auto | Explicit 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. |
timeZone | string | container's | Time axis only: render this strip in another IANA zone — the second axis of a two-zone pair. See Time zones. |
color | string | theme | Tick / label / rule / title colour — the lever for a stacked dual axis. |
height | number | fit-to-content | Strip height in px. |
onMouseEvent | (info: AxisMouseEvent) => void | — | Mouse 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:
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:
| Field | Type | What it is |
|---|---|---|
event | React.MouseEvent | The raw event; event.type says which one fired. |
axis | 'x' | 'y' | Which axis fired, so one handler can serve both. |
id | string | undefined | The axis's id. A <YAxis> always has one; an <XAxis> has none. |
value | number | The pointer's pixel inverted through that axis's scale — epoch ms on a time axis, the number on a value axis. |
label | string | value on that axis's readout channel — the category name on a category axis. |
Four details worth knowing:
valueis 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), solabelnames 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,valueis the underlying value, not the derived unit — apply the sametransform.toto read it in the strip's own language. - Two stacked x-axes look identical (
idisundefinedfor both), so close over the distinction:onMouseEvent={(e) => onAxis('delta', e)}. labelis the readout channel, not the tick text. It always agrees with what the cursor pill says at that pixel, which means a containercursorFormatshapes it (the documentedcursorFormat→ axisformat→ container precedence). Set one andlabelreads 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
- Value axis · Category axis · Trading-time axis — the three x-axis kinds.
- Storybook:
Axes(every Y/X prop and state) andAxes/DualX. - The API reference for the
exhaustive
YAxisProps/XAxisPropstypes.