A y-axis for a ChartRow, rendered as DOM chrome (not canvas) so the
text is crisp, themeable, and accessible. Registers its id / side / width /
domain with the row, which reserves the gutter (shrinking plotWidth) and
computes this axis's scale from the charts linked to it; the gutter then draws
tick marks + labels from that scale. Charts attach via <LineChart axis="id">
(default: the first axis).
Gestures. With <ChartContainer axisPanZoom="y"> (or "xy") the gutter is
grabbable: drag or wheel it to scale this axis only
— a sibling axis on the other side, and every other row, hold still — and
double-click to release it back to its fit. That per-axis scaling is what the
plot's vertical gesture deliberately cannot do; see
RowFrame.axisTransforms. Report it to a scale UI with
YAxisProps.onBoundsChange.
Props
Identifier a chart links to via its axis prop (and the first declared is
the row's default).
boundaryLabelsbooleanRender the tick labels at the domain extremes (the top & bottom ticks)?
Default true. false drops just those two numbers — the gridlines
stay — for when the min/max labels crowd a stacked row's edges and you'd
rather omit them than keep them. (Extreme labels are otherwise clamped to
stay inside the row, never overflowing the edge.)
colorstringThis axis instance's colour — tick labels and the axis title take it,
overriding the theme's axis.label / axis.title.color. The multi-axis
convention of colouring each y axis to match its series (color
matching the layer's) — busy, but standard. Omit for the theme's axis
colours.
Also worn by the axis-edge chrome that lands on this axis — a
<CrosshairCursor>'s value pill takes it when the reticle reads a series
scaled here, so with several axes the pill says which scale the number is
on (the ChartIQ price-tag convention). That is why it rides on the
registered spec: the pill is drawn by the row's cursor overlay, not by this
component, so a colour it never registered could not reach it.
formatAxisFormatAxisFormattype@pond-ts/chartsstring | (value: number) => stringHow to format an axis's values — a d3 [format specifier]
(https://github.com/d3/d3-format#locale_format) string, or a custom
(value) => string function. Omit for the scale's d3 default.
Value formatting for the tick labels (and the cursor readout, which matches):
a d3 format specifier string (e.g. '.0%', ',.2f') or a (value) => string
function. Omit for the scale's d3 default — which is calibrated to the tick
step, so a between-ticks readout rounds to tick precision; pass a specifier
(e.g. ',.2f') when you want finer readout precision. See AxisFormat.
Live charts: a string specifier is value-compared, so an inline
format='.0%' is safe every render. An inline format={(v) => …} function
is a fresh reference each render — the one axis prop a structural guard can't
value-compare — so on a frequently re-rendering (e.g. scrub-driven) chart,
hoist it or wrap it in useCallback, or it re-registers the axis each frame.
hidebooleanKeep the scale, draw no gutter. The axis still registers its domain
(min/max/scale/pad) and layers still bind to it by id, but it
renders nothing and reserves no width — the plot gets the space.
A <YAxis> does two jobs: it holds the scale and it renders a gutter.
Without this there was no way to ask for the first without the second, so a
chart with a fixed domain whose scale is already explained by its
chrome (threshold band lines, a legend, a panel header) had two reachable
options and needed a third:
| | auto domain | explicit domain |
|---|---|---|
| gutter | <YAxis /> | <YAxis min max /> |
| no gutter | omit the axis | ← this prop |
Omitting the axis is not the same thing: the row then supplies an implicit
auto-domain axis, and the fixed domain is exactly what must not be given
up. width={0} is not it either — the labels still draw, now over the
plot.
Gridlines are unaffected. They belong to the plot, not the gutter, and
<ChartContainer grid> already controls them — so a hidden axis can still rule
its own gridlines, which is usually what a "the shape matters, the numbers
don't" chart wants. Turn them off there if you want neither.
labelstringDisplay label / unit (e.g. bpm); defaults to id.
labelPlacement'rotated' | 'top'How the axis title (label) is drawn:
'rotated'(default) — a thin vertical strip down the outer edge (the standard y-axis convention; fits long labels in a narrow gutter).'top'— horizontal, at the top of the axis, aligned to its side. Reads better for short unit labels; keep it terse and pair it with a domain that has headroom (auto-fit / padded) so it doesn't crowd the top tick.
linearWindownumberscale="symlog"'s linear window, as a fraction of the domain's largest
magnitude. Default 0.02 — the knee sits at 2% of maxAbs, so a ±1M
domain is linear through ±20k and logarithmic beyond. Ignored on any other
scale.
Domain-relative, not absolute (d3's own constant is absolute). A chart
that re-keys to the largest magnitude on every update would otherwise need
the constant recomputed each tick, and would drift silently the moment
someone forgot — the fraction survives a domain change with no call-site
arithmetic at all.
Precisely: a fraction of the resolved domain before any pan/zoom — the
one the axis's min/max/pad/auto-fit produce. A 2-D gesture is carried
as a pixel transform and the knee is deliberately not recomputed from
the zoomed window, so zooming moves the plot without moving the boundary
between the two régimes underneath it. (Recomputing would make the same
datum linear at one zoom level and logarithmic at the next.)
A value outside (0, 1] cannot be a knee; the axis draws with the default
instead and dev-warns which window is in force.
maxnumberminnumberExplicit domain bounds; omit to auto-fit the charts linked to this axis.
onBoundsChange(bounds: readonly [number, number] | null) => voidA gutter gesture scaled this axis — the "auto vs manual" hand-off.
Fires with the [min, max] bounds the gesture arrived at, and with
null when the axis is released back to auto-fit (double-click).
Named for bounds rather than the domain because that is what it reports: with
a pad set, the visible domain is these bounds plus the padding, and it is
the bounds you hand back as min/max.
The common shape this exists for: an auto-fitting y axis on a chart whose x is panned and zoomed. The moment the user scrolls or drags the y gutter they have overridden the fit, and a UI usually wants to say so — show the resulting min/max, mark the scale "manual", and offer a toggle back to auto (which is the same thing double-clicking the gutter does).
const [scale, setScale] = useState<[number, number] | null>(null); // null = auto
<YAxis
id="price"
{...(scale ? { min: scale[0], max: scale[1] } : {})}
onBoundsChange={setScale}
/>
Providing it makes the axis controlled, exactly as onTimeRangeChange
does for the x view: the gesture then only reports, and what the axis draws
is whatever min/max you feed back. Omit it and the axis holds the zoom
itself (an internal per-axis transform) — which is the standalone behaviour,
and why a chart with no scale UI needs no wiring at all.
The reported pair is in data units, ready to hand straight back as
min/max.
scale="symlog" is approximate on this path, by construction.
linearWindow is a fraction of the domain, so bounds fed back
re-derive the knee and reshape the curve — the grabbed pixel cannot be held
on a curve that moves with the bounds. (It is the same fact that makes
linearWindow deliberately not recompute under a 2-D gesture.) The zoom is
still monotone and well-behaved; if you need the pixel held exactly on a
symlog axis, leave this callback off and let the axis hold the zoom itself,
where the knee stays anchored to the resolved domain. With an active plot-level y zoom (panZoom="panZoomY"/"panZoomXY")
the two compose: the bounds are the axis's own, and the plot transform
still narrows what is drawn on top of them. On a log axis it stays positive (the zoom is done in log
space), so it is always a domain the axis can actually draw.
onMouseEventAxisMouseHandlerAxisMouseHandlertype@pond-ts/charts(info: AxisMouseEvent) => voidA single handler for every mouse event on an axis strip — see
AxisMouseEvent. Passed as onMouseEvent to <XAxis> / <YAxis>.
Mouse events on this axis's gutter, with the axis value under the
pointer (AxisMouseHandler, whose AxisMouseEvent payload carries it) — a click reports the value it landed
on, and this axis's id, so one handler can serve several axes. The lever
for axis-driven UI: set a threshold by clicking the gutter, open a scale
menu (event.type === 'contextmenu'), drill into a categorical row.
One handler takes every mouse event — click, double-click, context
menu, down/up, move, enter, leave — so switch on event.type. Nothing is
attached when the prop is omitted, so the move events cost nothing unless
you ask for them. A hideden axis draws no gutter and so fires nothing.
A gutter that also zooms (see the component docs) still reports every event
here, minus the trailing click a zoom drag would otherwise synthesize.
padnumberFractional headroom added to each side of the resolved domain — 0 (the
default) means none. Lifts a tight domain off the plot edges without
hand-computing bounds (e.g. pad={0.05} adds 5% of the span top & bottom).
Applies to an explicit [min, max] or an auto-fit domain.
scale'linear' | 'log' | 'symlog'Which scale the axis maps its domain through. Default 'linear'.
'log' gives a base-10 logarithmic axis — for data spanning orders of
magnitude, where a linear axis flattens everything below the top decade
onto the baseline. Ticks land on the decades, and format still formats
the value, so a readout says 1.2 PB, not its logarithm.
A log domain cannot contain zero or negative numbers — d3 maps them to
NaN, which has no position on the plot. So:
- Auto-fit ignores non-positive extents when picking the low end (a
BarChart, whose extent always reaches zero so its bars can meet their baseline, can therefore share the axis), and rounds the domain out to whole powers of ten. - An explicit
min/maxthat is not positive is refused, and that side auto-fits instead. A positive bound is always honoured exactly; when only one side is given and the domain would invert, the auto side moves — the same policy a linear axis follows. - Layers that fill to a baseline (
AreaChart,BarChart, a stacked histogram) rest it on the bottom of the domain rather than on zero. - A value with no position gaps the line, rather than its neighbours being joined straight across it.
A dev-mode warning fires for the cases that are unambiguously a mistake: a refused bound, negative data, or an axis with no positive data at all.
'symlog' is linear through zero, logarithmic beyond — for data that
spans orders of magnitude on both sides of zero, which 'log' cannot
express at all (it admits no zero and no negatives). The linear window is
linearWindow. Because it admits zero, it resolves its domain on the
ordinary linear path: no positive-only bound refusal, no rounding out to
decades, no gapping of non-positive samples.
The axis owns tick placement, and that is the substance of the feature.
d3's symlog supplies the transform but ticks it linearly, which on a ±1M
domain with a 20k knee labels nothing below the knee — the exact region the
scale was chosen to reveal. pond grids it on zero, the knee (±linearWindow × maxAbs) and mirrored decades beyond, thinned by the same rule the log axis
uses. See yticks.ts.
The curve is log1p, not piecewise — read this before replacing a
hand-rolled one. "Linear through zero, logarithmic beyond" describes how the
axis reads, not two joined segments: scaleSymlog is the single smooth
sign(x) · log1p(|x / knee|), so there is no exact boundary at which one law
stops and the other starts. A common hand-rolled curve is piecewise —
exactly linear below the knee, log10 above — and the two are the same family
with materially different shape. Swapping one for the other, a reporting
consumer measured small values landing at roughly half their former height
(a ±9M domain: 283k went from 0.44 to 0.24 of the half-plot above the zero
line), while order, the dominance of the tail, and a several-fold lift over a
linear axis all held — the chart still says the same thing, but it does not
say it identically.
No linearWindow recovers a piecewise shape. The same consumer tried: a
smaller window fits the large values while overshooting the small ones about
2×, because the difference is the curve, not the knee. If you need the
piecewise curve exactly, you need your own transform — which is the thing this
scale exists to let you delete, so weigh that before reaching for it.
side'left' | 'right'Which side of the plot the gutter sits on. Author left axes before
<Layers> in JSX and right axes after — the row lays children out in
order. Default left.
tickCountnumberTarget number of auto ticks — the count passed to scale.ticks()
(d3 returns nice 1-2-5 values near it, not exactly this many). Omitted ⇒
derived from the row height so a short strip isn't crushed with a tall
row's density (mirrors the width-derived x axis). Ignored when explicit
ticks are given (those set both labels and gridlines directly).
ticksreadonly { at: number; label: string }[]Explicit ticks — { at, label } in axis-value units — instead of the
scale's automatic ticks, driving BOTH the labels and the row's gridlines so
the two align. The y-axis counterpart of <XAxis ticks> (same shape): the
lever for a non-uniform axis like pace, where the caller chooses round-pace
positions and their own m:ss labels ({ at: -300, label: '5:00' }). at
values outside [min, max] extrapolate off-plot (the scale does not clamp).
Pass [] to draw none. The array is value-compared on registration, so an
inline ticks={[…]} (or ticks={[]}) with unchanged contents no longer
re-registers the axis — only genuinely changed tick positions do. (An inline
format function still needs hoisting; see format.)
widthnumberGutter width in CSS pixels (default 50).
zeroAnchoredbooleanPin the zoom to the value-0 gridline instead of the pointer, and drop
drag-to-pan entirely — for a bar chart, whose baseline must never move.
A bar rests on 0 (or, straddling positive and negative, has 0
somewhere inside the visible range) — panning or zooming about an
arbitrary pointer position would slide that baseline around the plot,
which reads as the data moving under gestures that never touched it (see
resolveBarBaseline, and [PND-XBASE] for the still-open x-axis
counterpart of this exact failure mode). zeroAnchored sidesteps it:
every wheel notch scales the axis around wherever 0 currently renders,
so the baseline holds however far in or out you scroll — whether it sits
at the plot floor (all-positive bars) or in the middle (bars that
straddle zero).
One qualification: the pivot is clamped into the axis's own pixel range,
so if 0 has been scrolled off the plot entirely the zoom pivots about
the nearer edge instead and the baseline does creep. For a bar chart that
is benign — the clamp lands on the same floor resolveBarBaseline
already resolves against — but it is a creep, not a guarantee.
Still gated by the container's own axisPanZoom opt-in ('y' / 'xy')
— this only changes what the gesture does once enabled, not whether
it's enabled. Double-click still resets to the declared/auto-fit view.
Default false.