Nice numbers for axis ticks
Why axes stop at 250 and not 247, and the tiny algorithm that has been quietly running under every chart since 1990.
In the code: vocabulary/scales.py
Your data maxes out at 247. Nobody wants an axis labelled 0, 61.75, 123.5, 185.25, 247. They want 0, 50, 100, 150, 200, 250 — even though not one of those numbers appears in the data.
This is such a universal expectation that it has a name and a canonical solution: Paul Heckbert's Nice Numbers for Graph Labels, from Graphics Gems (1990). It is about fifteen lines long and it is in every plotting library you have ever used.
The algorithm
A "nice" number is one a person would choose: 1, 2, 5, or 10, times a power of ten. To pick the spacing between ticks:
- Take the data range and the number of ticks you want, and compute the raw
spacing:
range / ticks. - Find its magnitude:
10^floor(log10(spacing)). - Divide out the magnitude to get a value between 1 and 10, and round it to the nearest nice fraction — 1, 2, 5 or 10.
- Multiply the magnitude back.
That much is Heckbert's nicenum, unchanged. What ticks() does with the
spacing is where it departs: it emits the multiples that fall inside the
range and never touches a bound. The low end is rounded up — inward — and
the loop stops at the high end, so ticks(3, 97) gives 20, 40, 60, 80: the
data sits outside the outermost tick at both ends. Heckbert rounds the bounds
outward instead, which is what makes his ticks span the data.
The choice of {1, 2, 5, 10} is not arbitrary: they are the divisors that keep subdivision mental. A spacing of 2.5 is legal in some variants and immediately feels worse to read — counting 0, 2.5, 5, 7.5 is work in a way that 0, 2, 4, 6 is not.
Where the axis stops is a separate question
Sauvignon splits the problem in two, and this is worth being explicit about because the two halves disagree about 2.5.
The tick spacing comes from the routine above. The axis bound — the top of the scale — comes from a simpler rule of its own: take the smallest value from {1, 2, 2.5, 5, 10} × 10ᵏ that covers the data. So 247 gets an axis to 250, which is 2.5 × 100: the fraction the tick spacing rejects earns its place here, because as a bound 250 is plainly better than the 300 you would get from {1, 2, 5, 10} alone. Nobody counts in units of the bound, so its awkwardness as a step size costs nothing.
The honest cost of keeping the two rules independent is that the bound is not rounded to a multiple of the tick spacing, and a value just past a threshold gets a loose axis: 260 rounds up to 500, leaving the data filling barely half the plot. Heckbert's own formulation avoids this by deriving the bound from the chosen spacing. Doing the same here would be a small change and a real improvement; it is on the list.
<diagram padding="16">
<data name="d">
<record x="Jan" y="182"/>
<record x="Feb" y="247"/>
<record x="Mar" y="119"/>
<record x="Apr" y="203"/>
</data>
<plot data="d" width="330" height="190">
<bars x="x" y="y"/>
</plot>
</diagram>
The tallest bar is 247. The axis goes to 250, and the labels are all round, because nobody asked it to and it happened anyway.
Band scales are the other half
Numeric axes are only one case. Categorical data — six months, four regions — needs a band scale instead: divide the range into one band per category, each with a slot for padding, and put each mark in the centre of its band. There is no rounding involved; the interesting parameter is how much of each band is gap rather than mark, which is what separates a bar chart that breathes from one that looks like a solid wall.
The distinction matters more than it sounds, because it decides the shape of the
axis. Sauvignon picks automatically for points, line and area: if every
value in the field parses as a finite number the axis is linear, otherwise it is
a band. That is why a scatter plot of numbers gets a continuous x-axis while the
same layer over month names gets evenly spaced categories, without you selecting
anything.
Bars are the exception, and always get a band. A bar needs a width, and a width means a slot — on a continuous axis there is nothing to say how wide "1" should be, and unevenly spaced numeric categories would give bars of different widths for no reason the reader could see.
There is a trap in that auto-detection. float("inf") and float("nan") parse
perfectly happily, so a stray inf in a column used to make a field "numeric"
and then produce a scale with an infinite domain — every coordinate becoming
nan and the chart rendering as nothing. The fix is to require values to be
finite, not merely parseable, which reclassifies the column as categorical and
draws something sensible instead of a blank.
Extensions worth knowing about
Heckbert's version optimises one thing: round numbers. Later work asks for more at once — Talbot, Lin and Hanrahan's An Extension of Wilkinson's Algorithm for Positioning Tick Labels on Axes (2010) scores candidate tick sequences on simplicity, coverage, density and legibility together, and searches for the best compromise. It is what you want if you care about axes that stay good under animation or resizing, where Heckbert's can jump between choices.
Further reading. Heckbert, Nice Numbers for Graph Labels, Graphics Gems (1990); Talbot, Lin & Hanrahan (InfoVis 2010) for the modern treatment; Wilkinson's The Grammar of Graphics for why scales are a first-class part of the model rather than a chart-drawing detail.