No Time Dad

A blog about web development written by a busy dad

Example X and Y Axis with D3 and React

D3 can be intimidating. I started writing this post a few times, only to give up out of frustration with D3. My brain is just having a hard time “thinking in D3”. I don’t know what it is, but D3 just requires me to think about the frontend in a way I haven’t done before. And it’s not really a bad thing, it’s just different. Different is good though because it pushes me.

My ultimate goal is to create a fancy bar chart with D3. But, I thought a good place to start with that would be to create the x and y axis for the bar chart. Basically, learning D3 in small chunks. Which is sort of my mantra anyways (see blog title).

I’ll be using React for creating the axis with D3. I’ve mentioned it previously, but I’m trying very hard to keep the D3 code out of the React code. I like having my D3 code as a small library that React can use in it’s lifecycle hook.

Getting started

The React component itself isn’t complicated. I’ll use React’s useRef to pass the element reference to D3, as well as React’s useEffect hook to call my custom D3 functions.

The data I’ll be using for this bar chart isn’t complicated either. I made an array of objects that contains data on the top ten goal scorers in the English Premier League last season. In another post I’ll add the bars to the bar chart, but for now I’m focusing on just getting the axis to show up.

The final code and demo can be viewed here.

import React, { useRef, useEffect } from 'react';
import { InitBarChart } from './bar-chart.js';

export const GoalScorerBarChart = () => {
  // Init the element reference to pass to D3
  const containerRef = useRef(null);
  const data = [
    { player: 'Kane', goals: 23 },
    { player: 'Salah', goals: 22 },
    { player: 'Fernandes', goals: 18 },
    { player: 'Heung-Min', goals: 17 },
    { player: 'Bamform', goals: 17 },
    { player: 'Calvert-Lewin', goals: 16 },
    { player: 'Vardy', goals: 15 },
    { player: 'Watkins', goals: 14 },
    { player: 'Lacazette', goals: 13 },
    { player: 'Gundogan', goals: 13 },
  ]

  useEffect(() => {
    // Initialize the D3 bar chart
    InitBarChart(containerRef, data);
  });

  return (
    // Pass the reference to the target element
    <div ref={containerRef}></div>
  )
}
Building the x-axis

The x-axis is where I want to display the name of each player from my data. Something I want to be careful of here is that the names will be of varying length and sometimes pretty long. I went through a few different iterations of this, and ultimately decided that rotating the names slightly on the axis made them fix nicely and easy to read. It also saves space.

Before I can change the angle of the text I need to define the domain and range of my data. These are what help determine the width and length of the x-axis. I’m essentially “fitting” the data to the axis. D3 has some helper methods that make this easier, but it still took me a lot of reading with some trial and error to really understand them.

Once my data is fit to the axis, I can call D3’s axisBottom to create the axis. This function has a helper method called tickFormat which is where I can tell D3 to use the string value of the player’s name for each tick mark on the x-axis. I also tell D3 that I don’t want an extra tickmark on the end of the line by calling tickSizeOuter with a 0.

Now that I have my axis mostly built, I need a parent svg element to put it in. I can use D3’s select function for this, passing in the React element ref I created earlier. I’ll append the svg element to the div from my React component, and set the width and height attributes for the svg.

Using the svg instance I can call the xAxis function to add the axis to the page. At this point I can also apply the text rotation I mentioned at the start. It took some fiddling to get the text to align on the tickmarks how I wanted it to, but I eventually got it there.

// bar-chart.js
import { scaleBand } from 'd3-scale';
import { axisBottom } from 'd3-axis';
import { select } from 'd3-selection';
import { range } from 'd3-array';

export const InitBarChar = () => (
  InitAxis();
)

const InitAxis = (containerRef, data) => {
  const height = 500;
  const width = 500;
  const margin = { top: 20, right: 30, bottom: 100, left: 40 };

  // Fit the data to the axis
  const x = scaleBand()
    .domain(range(data.length))
    .range([margin.left, width - margin.top])
    .padding(0.1);

  const xAxis = g => g
    .attr('transform', `translate(0,${height - margin.bottom})`)
    .call(
      axisBottom(x)
        // Use the player names from the data
        .tickFormat(i => data[i].player)
        // Remove outer tick mark
        .tickSizeOuter(0)
    );

  // Create the svg in the parent div
  const svg = select(containerRef.current)
    .append('svg')
    .attr('width', width)
    .attr('height', height);

  svg.append('g').call(xAxis)
    // Rotate the text slightly on the tick mark
    .selectAll("text")
    .style("text-anchor", "end")
    .attr("dx", "-1em")
    .attr("dy", "0")
    .attr("transform", "rotate(-65)");
}

Below is how the x-axis should look so far. Each name should be evenly spaced, and there shouldn’t be any trailing tickmarks. The height of the axis could probably be more dynamic since a longer name could show up, but for now it works fine. Just something I need to keep in mind for later if I change the data.

Building the y-axis

The y-axis will be similar to the x-axis but it will be built using the range of goals in the data instead of the player names. The y-axis should start at zero and go all the way up to the most amount of goals scored by a single player, 23 in this case. I don’t want to hardcode 23 on the axis, though so I’ll let D3 do some math for me.

I’ll use D3’s scaleLinear to build the continuous scale that is the y-axis. I can fix the y-axis data the same way I did with the x-axis data using domain and range. The main difference this time is that I’m dealing with numbers instead of strings. I’ll start the data at 0 and use D3’s max method to get the max. The domain method has it’s own helper method called nice which will round the start and end numbers up or down slightly to help make the data more presentable.

After setting the transform and translate values, which are used to help draw the axis, I can call D3’s axisLeft function to intialize the y-axis on the page. Then, the last thing to do is to use the svg variable I create previously for the x-axis to call yAxis and create the y-axis.

The updated InitAxis function from the previous section is shown below.

// bar-chart.js
import { scaleBand, scaleLinear } from 'd3-scale';
import { axisBottom, axisLeft, } from 'd3-axis';
import { select } from 'd3-selection';
import { max, range } from 'd3-array';

export const InitBarChar = () => (
  InitAxis();
);

const InitAxis = (containerRef, data) => {
  const height = 500;
  const width = 500;
  const margin = { top: 20, right: 30, bottom: 100, left: 40 };

  const x = scaleBand()
    .domain(range(data.length))
    .range([margin.left, width - margin.top])
    .padding(0.1);

  const xAxis = g => g
    .attr('transform', `translate(0,${height - margin.bottom})`)
    .call(
      axisBottom(x)
        .tickFormat(i => data[i].player)
        .tickSizeOuter(0)
    );

  // Fix the data to the y-axis
  const y = scaleLinear()
    .domain([0, max(data, d => d.goals)]).nice()
    .range([height - margin.bottom, margin.top]);

  // Function to draw the x-axis
  const yAxis = g => g
    .attr('transform', `translate(${margin.left},0)`)
    .call(axisLeft(y));

  const svg = select(containerRef.current)
    .append('svg')
    .attr('width', width)
    .attr('height', height);

  svg.append('g').call(xAxis)
    .selectAll("text")
    .style("text-anchor", "end")
    .attr("dx", "-1em")
    .attr("dy", "0")
    .attr("transform", "rotate(-65)");
  
  // Call the function to draw the x-axis
  svg.append('g').call(yAxis);
}

Conclusion

The hardest part of this was building the x-axis. I spent most of my time trying to get the spacing right and figuring out the best angle to fit the name and still have it be easy to read. There are probably a few different ways to get a label to show up on the x-axis in D3, but I found that tickFormat was the easiest and most consistent for me.

So, building the x and y axis are done and I can eventually move on to adding bars, labels, or whatever else. It’s a piece by piece process for me with D3. Trying to do too much at once with this library always leads to confusion or me just adding random code I’ve found online without really understanding it.

Final code and demo

// package.json
...
"d3-array": "^2.12.1",
"d3-axis": "^2.1.0",
"d3-scale": "^3.3.0",
"d3-selection": "^2.0.0",
"react": "^17.0.2",
...
// BarChart.jsx
import React, { useRef, useEffect } from 'react';
import { InitBarChart } from './bar-chart.js';

export const GoalScorerBarChart = () => {
  const containerRef = useRef(null);
  const data = [
    { player: 'Kane', goals: 23 },
    { player: 'Salah', goals: 22 },
    { player: 'Fernandes', goals: 18 },
    { player: 'Heung-Min', goals: 17 },
    { player: 'Bamform', goals: 17 },
    { player: 'Calvert-Lewin', goals: 16 },
    { player: 'Vardy', goals: 15 },
    { player: 'Watkins', goals: 14 },
    { player: 'Lacazette', goals: 13 },
    { player: 'Gundogan', goals: 13 },
  ]

  useEffect(() => {
    InitBarChart(containerRef, data);
  });

  return (
    <div ref={containerRef}></div>
  )
}
// bar-chart.js
import { scaleBand, scaleLinear } from 'd3-scale';
import { axisBottom, axisLeft, } from 'd3-axis';
import { select } from 'd3-selection';
import { max, range } from 'd3-array';

export const InitBarChar = () => (
  InitAxis();
);

const InitAxis = (containerRef, data) => {
  const height = 500;
  const width = 500;
  const margin = { top: 20, right: 30, bottom: 100, left: 40 };

  const x = scaleBand()
    .domain(range(data.length))
    .range([margin.left, width - margin.top])
    .padding(0.1);

  const xAxis = g => g
    .attr('transform', `translate(0,${height - margin.bottom})`)
    .call(
      axisBottom(x)
        .tickFormat(i => data[i].player)
        .tickSizeOuter(0)
    );

  const y = scaleLinear()
    .domain([0, max(data, d => d.goals)]).nice()
    .range([height - margin.bottom, margin.top]);

  const yAxis = g => g
    .attr('transform', `translate(${margin.left},0)`)
    .call(axisLeft(y));

  const svg = select(containerRef.current)
    .append('svg')
    .attr('width', width)
    .attr('height', height);

  svg.append('g').call(xAxis)
    .selectAll("text")
    .style("text-anchor", "end")
    .attr("dx", "-1em")
    .attr("dy", "0")
    .attr("transform", "rotate(-65)");
  
  svg.append('g').call(yAxis);
}