Axes

What Pluma infers about scales, ticks and margins, and the three things worth saying yourself — a second value axis, a domain, a time granularity.

Several value axes

Quantities in units that have no scale in common get an axis each. Pass yAxis an array, give each entry beyond the first an id, and point a mark at one with yAxis: '<id>' — a mark that names none is measured against the first.

Guides are drawn down the left, and axes sharing a side stack outward in the order they were declared. position: 'right' moves one opposite, which on a chart with two is usually what you want: it puts each guide beside the marks it belongs to instead of leaving the reader to work out which of two left-hand guides a line is measured against. An axis keeps the side it named however few it ends up sharing the plot with, so a legend hiding the last series off another axis leaves this one where it was — wherever the vertical scale is Pluma's to resolve. A chart that brought its own scale, or whose vertical scale isn't linear, gets one guide down the left whatever position says.

Here Apple's monthly traded volume and its closing price: shares and dollars, one chart.

Loading editor

Give each mark a seriesName, as above. Without one they take the same palette slot, and nothing on the plot would say which line belongs to which side.

What the axes share

Every vertical axis is drawn at the same tick heights, over one grid — several grids on one plot leaves the reader working out which lines belong to which axis. So a few things are decided for the set rather than per axis:

  • The tick count. Pluma tries a few and keeps whichever lets every axis land on round numbers without leaving the plot half empty. tickCount on any axis pins it for all of them.
  • Where zero falls. A bar or an area is measured from zero, so as soon as one sits on an axis past the first, every axis is anchored to put zero at the same height. Two bottoms in one plot would be a chart drawing its fills from the wrong one.
  • The scale. scale belongs to the first axis, and the rest cover the vertical range it lays out. Use domain on any of them for a range that has to be exact — or with one end 'auto' to hold an axis at zero and still let the set choose where it stops.

Everything else is per axis: position, label, format, notation, isHidden. An axis nothing is drawn against isn't drawn at all.

More than two

Adding an axis is adding an entry and a mark that names it. Below, Apple's monthly return joins the volume and the price — shares, dollars and percent, each read against its own guide.

Loading editor

A bar group can span the set: groupWith divides one category's band between several bar marks, so a column of counts can stand beside one of rates with each measured against its own guide. See Grouping across value axes.

Each extra axis costs the plot some width, and the reader some work: nothing about a line says which guide it belongs to. Two is usually the most a chart earns. Past that, or where the quantities are separate subjects rather than one qualifying another, stacked charts sharing an x axis read better.

Keeping zero off the axis

An area fills down to zero, and normally says so — it hands its baseline to the axis, which then has to show zero. That is right when the fill is the quantity: a filled area whose bottom the reader can't see isn't one they can measure.

It is wrong when the fill is a wash under a line. There the line carries the reading and the fill is only density, so withDomain: false takes the area out of the range entirely — not just its baseline. Every value the fill covers is one the line over the same rows has already declared, zero included, so the range is unchanged by the area's silence. The axis becomes whatever the other marks imply, the same one the chart would have without the area on it, while the fill still runs down to zero, off the bottom of the plot, where its gradient finishes:

<Chart
	ariaLabel="Open rate per day"
	marks={[
		area({ data: rows, x: 'date', y: 'rate', withGradient: true, fillOpacity: 0.2, withDomain: false }),
		line({ data: rows, x: 'date', y: 'rate' }),
	]}
/>

Pair it with withGradient too. An area clips itself to the plot, so the overhang stays out of sight either way, but a flat fill running off the bottom edge is rarely what's wanted.

domain is the blunter instrument for the same end: it sets the range an axis covers instead of taking the one the data implies, whatever the marks say. Reach for it when you want an exact window — a fixed scale across several charts, or headroom the data doesn't ask for — rather than to work around an area. On a time axis the same option takes dates; Holding a time axis to a window covers that.

<Chart ariaLabel="Open rate per day" marks={marks} yAxis={{ domain: [0, 100] }} />

Either end can be 'auto', which holds the other and leaves that one to the data. [0, 'auto'] is the one to reach for when a line should start at zero: without it the axis starts wherever the values do, and pinning both ends means inventing a maximum the data may outgrow.

<Chart ariaLabel="Open rate per day" marks={marks} yAxis={{ domain: [0, 'auto'] }} />

A held end is where the axis stops, not just a value it has to reach — values past it fall outside the plot — and it is rounded outward to a height the axis' own step lands on, so the ticks stay round whatever number you write. Zero is the exception nobody has to think about: every step divides it, so a floor of zero is always exactly the foot of the axis.

On a chart whose axes share a zero — which they do as soon as an axis past the first carries bars or an area — the ends are whatever puts zero at one height, so a held end is only guaranteed to be covered. A floor of zero still lands exactly, but a floor anywhere else is passed rather than landed on. Name both ends when an axis has to stop somewhere specific on a chart like that.

Ticks on whole numbers

An axis counting things can end up written in fractions. Nothing about a count says its values are whole, so a series reaching 1 on a plot with room for seven ticks is labelled 0, 0.2, 0.4 and up — and a reader who sees 0.5 messages takes it as a fact about the data rather than about the axis.

tickStep: 'integer' holds the ticks to whole numbers. It constrains the step rather than fixing it, so an axis that already steps in whole numbers is unchanged, and one reaching into the thousands still steps by 200 rather than by 1:

<Chart ariaLabel="Messages delivered per day" marks={marks} yAxis={{ tickStep: 'integer' }} />

A number is a step exactly — the ticks land on its multiples, and how many there are follows from the range:

<Chart ariaLabel="Open rate per day" marks={marks} yAxis={{ domain: [0, 100], tickStep: 25 }} />

tickCount still asks for roughly how many ticks to aim for. Where the two disagree the step wins, since a count is an approximation and a step is not.

On a time axis a step is a duration in milliseconds, counted from the start of the axis' range. A time axis otherwise ticks on round calendar units, and its weeks start on Sunday — so a series bucketed on Mondays is labelled between its buckets rather than on them. A week-long step lands the ticks on the buckets:

const WEEK = 7 * 24 * 60 * 60 * 1000;

<Chart ariaLabel="Messages delivered per week" marks={marks} xAxis={{ tickStep: WEEK }} />;

Uneven units can't be written as a duration. A series sitting one row per calendar month is already ticked a month at a time without a step, and anything more particular wants a d3-time interval through scale. 'integer' is a numeric-axis idea and a time axis ignores it.

How ticks are written

Numbers get thousands separators, notation: 'compact' shortens them to 50k, and a date is written short — Mar 1. Rows sitting one per calendar month are labelled by month instead, with the year abbreviated onto every tick: Jan '26.

That is also the one case where a time axis is given a fixed format rather than labelling its own ticks, and it comes with the ticks pinned to the months themselves. A time scale picks its interval from how wide its domain is and how many ticks it is asked for, never from how far apart the rows sit — over three monthly points on a wide plot it ticks weekly, and a month label on a weekly tick would write Jan '26 four times. tickCount still asks for fewer labels; it thins those months rather than choosing a finer interval.

All of it is US English wherever the chart is opened, rather than the reader's own locale. A chart writes the same quantity in several places at once — a tick, the tooltip pointing at it, a label on the line — and those only agree if none of them moves; a 1.600 tick above a 1,519 tooltip is worse than either convention on its own. format is the way out when you want something else, and it is called exactly as you wrote it, including whatever locale you format in.

Not every category gets a label

More categories than the plot has room for means only some of them are labelled — evenly spaced, starting at the first. tickCount asks for fewer than that, and tickLabelRotate turns the labels so more of them fit, which is what to reach for on long category names.

Time axes

A Date field gets a time axis: real elapsed-time spacing, and ticks that step by hour, day, week or month depending on the range. tickCount nudges that granularity.

Five years of Apple's daily closing price — 1,258 points on one line, with the axis choosing its own tick interval as the range changes:

Loading editor

Several lines share one time axis the same way — one series field, one mark:

Loading editor

Holding a time axis to a window

A time axis covers the range its rows reach. That is wrong whenever the reader picked the range themselves: a report over the last quarter whose series only starts halfway through spans half a quarter, and the chart quietly answers a different question than the one the date picker asked.

domain takes Dates on a time axis, and holds it to the window instead. The stretch with nothing in it is drawn as an empty margin, which is the reading the reader asked for — a young segment is young, and a chart that hides that by starting at its first row says the opposite.

Here Apple's 2017 closing price on an axis held to the whole calendar year, so the two months the series stops short of are visible as the gap they are:

Loading editor

Either end can be 'auto', which holds the other and leaves that one to the rows — [startOfRange, 'auto'] pins where a report begins and lets it end wherever the data does.

Dates are held exactly, with no rounding at either end. A time axis' step is a calendar interval, so rounding a bound outward to one would move a window that was picked deliberately, and by as much as a month. Numeric bounds are still rounded out to a round tick, since a round number is not a date anybody chose.

The two kinds don't mix. A number written on a time axis, or a Date written on a numeric one, is ignored along with the end it was written at — read literally it would put the axis at the epoch, which is nothing anyone meant.