D3.js Axis Tips and Tricks

example code illustration
Published
8 min read

Correct use of axes is at the core for creating user-friendly data visualisations. Axes are important for all sorts of graphs, be it bar charts, histograms, scatter plots, time series, bullet charts and many more. They provide a reference for the graphics so that the user can extract meaningful information about the data. A good axis is subtle and non-intrusive, yet stand by to provide valuable meaning whenever the user requires it.

Let’s dig into a few tips and tricks that I’ve learned over the years.

1. Display ticks as percentages

Whenever you’re visualizing data that are percentages, the axis should obviously reflect this. The raw data, however, might be (or perhaps should be) a decimal number (i.e. 0.25 instead of 25%).

We want to turn this:

0.00.10.20.30.40.50.60.70.80.91.0

into this:

0%10%20%30%40%50%60%70%80%90%100%

Here’s the code to achieve this:

const scale = scaleLinear().domain([0, width])
const axis = axisBottom(scale).tickFormat(format('~%'))

svg.append('g').call(axis)

As you can see, it is d3.format() that does the number formatting here. axis.tickFormat() lets you format your axis’s ticks in whatever way you’d like, and in this case we want to convert whatever the initial numeric value is into a percentage.

d3.format() has a variety of formatting options, and the value “~%” means the following:

  • the % (percentage sign) multiplies each value with 100 and adds the percentage sign, and
  • the ~ (tilde) removes all insignificant trailing zeros

Without the ~ we’d end up with numbers like “20.0000%”, but luckily d3 has a way to handle that.

2. Dynamic number of ticks

There are several challenges when creating responsive visualisations. It can be difficult to make a graph look good on both small and large screens, and sometimes the x-axis can cause some headache. Out of the box our d3-axes will just guess the appropriate number of ticks based on its scale.

Here’s what I’m talking about.

AprilFri 03Apr 05Tue 07Thu 09Sat 11Mon 13Wed 15Fri 17Apr 19Tue 21Thu 23Sat 25Mon 27Wed 29May

D3 does some calculations under the hood, trying to optimise the number of ticks, but it doesn’t always get it right. And especially when you want the number of ticks to react to a changing width, you need the extra bit of control.

To do this, we can use .ticks().

const scale = scaleTime()
  .range([0, width])
  .domain([new Date('2020-04-01'), new Date('2020-05-01')])
const axis = axisBottom(scale).ticks(3)

this.canvas.append('g').call(axis)

This code renders a cleaner axis:

Apr 05Apr 12Apr 19Apr 26

On line 4 above we call .ticks(3) on the axis. Note that the actual number of ticks rendered on the axis may differ from the number passed into the function though. D3 will still try and optimize the number of ticks, but most of the time the tick count it will be a maximum of one off.

By dynamically set the number of ticks, we can easily get an axis that responds to the available width.

050100150200250300350400450

We take the width of the graph and divide by the width we want in between each tick.

import { scaleLinear, axisBottom } from 'd3'

// ...

const tickWidth = 60
const scale = scaleLinear().range([0, width]).domain([0, 480])
const axis = axisBottom(scale).ticks(width / tickWidth)

gAxis.call(axis)

All what’s left is to update/call the axis each time the width changes.

3. Create grid lines with .tickSize

Sometimes (but maybe not as often as you might think) grid lines can enhance a graph.

As a rule of thumb, grid lines should be an extension of a labelled value on the axis guiding the readers’ eye to the a quantitative value.

The easiest way to add grid lines using d3 is to tweak an axis.

We start by setting up our axes and scales:

import { scaleLinear, axisBottom, axisLeft } from 'd3'

// ...

const xScale = scaleLinear().domain([0, 100]).range([0, innerWidth])
const yScale = scaleLinear().domain([0, 100]).range([innerHeight, 0])

const xAxis = axisBottom(xScale)
const yAxis = axisLeft(yScale)

const gXAxis = this.canvas.append('g').attr('transform', `translate(0, ${innerHeight})`)
const gYAxis = this.canvas.append('g')

gXAxis.call(xAxis)
gYAxis.call(yAxis)

// ...

Now we have something like this:

01020304050607080901000102030405060708090100

All d3-axes have a .tickSize() method that controls the length of each tick mark. By setting this to a negative value we can actually get it to cross over the perpendicular domain line and turn into grid lines.

// ...

const yAxis = axisLeft(yScale).tickSize(-innerWidth)

// ...
01020304050607080901000102030405060708090100

And then we can reduce the opacity of the grid lines after we’ve called the yAxis …

// ...

gYAxis.call(yAxis)

gYAxis.selectAll('.tick line').attr('opacity', 0.1)

// ...

… and add some random data:

01020304050607080901000102030405060708090100

4. Animate axis on update

Clever use of animation can sometimes elevate the visualisation and its message. Transitioning the axis from one state to another is as easy to do in d3 as transitioning any other element (i.e. bars, lines), and is equally important. A good use for transitioning or animating a graph is to help the user quickly understand the direction and distance the data is changing.

Bar chart races like this are good examples of animated axis. Here they give the audience clues for the speed in which the data change.

Now imagine we have a simple time series chart displaying sales of Product A of a fictional company. And that this visualisation takes part of a bigger page with details about this particular product.

2019AprilJulyOctober2020$120k$130k$130k$140k$140k$150k$150k$160k$160k

If a user then navigates to a different product, Product B, then the graph should not just refresh instantaniously, but instead transition in accordance to the other product’s data.

2019AprilJulyOctober2020$180k$190k$200k$210k$220k$230k$240k$250k$260k$270k$130k$130k$130k$140k$140k$140k$140k$140k$150k$150k$150k$150k$150k$160k$160k$160kProduct A

Transitioning an axis is as simple as setting group.transition() before calling the axis.

// ...

const gYAxis = this.canvas.append('g')

// Whenever you need to update the axis ...
gYAxis.transition().duration(500).call(this.yAxis)

//...

5. Custom axis styling

Out of the box, d3-axes have okay styling, but if you want to adjust their appearence, there are a few things that are neat to be aware of:

Unless you explicitly set styles like font-size and font-family to the svg’s <text> elements, they will render at 10px size and your device’s default ‘sans-serif’ font.

Depending on your preference, this can be solved with d3 or CSS.

// ...

const scale = d3.scaleLinear()
const axis = d3.scaleBottom(scale)
const gAxis = svg.append('g').call(axis)

gAxis
  .style('font-size', '1rem')
  .style('font-family', 'inherit')
  .style('color', 'currentColor')

// ...

For CSS, we can utilise that D3 adds class ‘tick’ to each of the ticks on the axis.

svg .tick > text {
  font-size: 1rem;
  font-family: /* my custom font */ ;
  fill: currentColor;
}

When changing the font of the axis, it might be an idea to consider using a font that either has tabular figures build-in, or supports the font-variant-numeric: tabular-nums; CSS property to ensure that numbers are aligned correctly.

If you want a slightly cleaner look for you axis, you might consider hiding or reducing the visual prominence of the axis’s domain. The domain is the <path> element that D3 injects that runs along the axis, typically separating the ticks from the graph. Styling the domain can also be done either directly in the JavaScript or with CSS.

For JavaScript, you need to select the axis group’s path.domain child and add styles or attributes to it. So expanding on our example above we can do the following:

const scale = d3.scaleLinear()
const axis = d3.scaleBottom(scale)
const gAxis = svg.append('g').call(axis)

gAxis.select('.domain').attr('opacity', 0.25)

This reduces the opacity of the domain path to 0.25. Here, you could instead set its style to hidden to hide it or call the .remove() method on it to remove it from the DOM completely.

6. Include unit of measurement on the Y‑axis

Labelling all axes is important. Not only must the reader easily understand what the values on the axis refer to, but also when they export or otherwise use the graph outside its intended context, the unit of measurement should be easily accessible.

One trick for achieving this is to include the y-axis’s unit of measurement directly on the axis itself.

By extending the first tick’s label to include what’s measured can be useful if the chart’s design allows it.

OctoberNovemberDecember20203004005006007008009001,0001,1001,200units sold per week (Q4 2019)

In the d3 script we can create and call a function addUnit() for the y-axis, and pass the unit of measurement as a parameter. On line 2 we import our helper function which we call on line 9.

import { scaleLinear, axisLeft } from 'd3'
import addUnit from './helpers'

// ...
const scaleY = scaleLinear().range([innerHeight, 0]).domain(extent)
const yAxis = axisLeft(scaleY)
const gYAxis = this.canvas.append('g')

gYAxis.call(yAxis).call(addUnit, 'units sold per week (Q4 2019)')

// ...

In our helpers file we export the following function.

export function addUnit(selection, unit) {
  const lastTick = selection.selectAll('.tick').filter(last)

  const offsetX = lastTick.select('line').attr('x2')
  const offsetY = lastTick.select('text').attr('dy')

  const rect = lastTick.append('rect')
  const text = lastTick.append('text')

  text
    .text(unit)
    .attr('fill', 'black')
    .attr('text-anchor', 'start')
    .attr('dx', offsetX)
    .attr('dy', offsetY)

  rect
    .attr('height', 15)
    .attr('fill', 'white')
    .attr('width', text.node().textLength.baseVal.value)
    .attr('x', offsetX)
    .attr('y', -5)
}

function last(d, i, j) {
  const length = j.length - 1
  return i === length
}

What happens here is that we find the last (in DOM) tick for the axis (line 2) for which we add a <rect> (a white background masking the tick mark and domain line below the text) and a <text> element. We use the length of the text element to set the width of the rectangle.

Please only use this snippet as a guide for how to achieve this effect.

You may also be interested in ...