No Time Dad

A blog about web development written by a busy dad

Bars and Hover Effects with D3

Is it just me, or are a lot of D3 examples wildly over complicated? I recently wanted to change the color of a bar in a bar chart when it was hovered. So, off to Google I went looking for examples. I did find what I was looking for, but I also found a lot of examples that were trying to re-write the entire library to achieve a hover effect.

Change color on hover? Easy. Checkout this 3,200 line example below.

Very frustrating to say the least. I guess part of that is D3 being inherentily complicated. But, I also think it might be a lack of familiarity with web (specifically svg) elements in the data visualization community. Maybe there are only two camps when it comes to D3. Quality code or advanced math. I don’t often see a lot of overlap.

Anyways, this wasn’t meant to be a “bash on D3 code” post. I actually just wanted to add some bars to the x and y axis example I wrote about previously. I’ll be using the final code from that post as my starting point. The goal (no pun intended) was to create the x and y axis for a dataset that contains the top 10 goal scorers in the English Premier League for the 2020/2021 season. Below is the example component and the final code from the previous post.

// bar-chart.jsx
import React, { useRef, useEffect } from "react"
import { InitBarChart } from "./bar-chart-utils.js"

export const BarChartComponent = () => {
  const containerRef = useRef()
  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-utils.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 InitBarChart = (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)
}

Adding bars

A bar chart isn’t useful without any bars. These bars are made up of svg shape rect elements. The basic idea here is that I’ll use the containerRef to add a new g element containing child rect elements. It’ll look something like the below, with the code that D3 generates beneath that (other elements omitted).

<svg>
  <g>
    <rect fill="lightcoral" height="364" width="39"></rect>
    <rect fill="lightcoral" height="364" width="39" x="50"></rect>
    <rect fill="lightcoral" height="364" width="39" x="100"></rect>
    <rect fill="lightcoral" height="364" width="39" x="150"></rect>
  </g>
</svg>

In my opinion, it’s easy to forgot that at a high level, D3 is really just creating html elements. It does much more than that, especially when it comes to math. But the main thing it’s doing is adding and modifying elements on the DOM. I sometimes have to remind myself to use chrome dev tools (F12) to inspect what D3 is doing, and often times things become much clearer for me when I can see the markup D3 is creating.

Going back to the bars, I need to tell D3 how to add them. I’ll use the svg variable I previously created to selectAll rect elements and perform a join on my data. This is essentially binding html elements to my dataset. Or saying “for each element in my array, create a rect element”.

I can then add a fill attribute and value via D3’s .attr method. I can then use the scaleBand and scaleLinear methods to help with the spacing and size of the bars. The height of the bars should correspond to the values on the y-axis.

The BarChartComponent code will remain the same, but I’ll add some new code to the InitBarChart helper function.

// bar-chart-utils.js
export const InitBarChart = (containerRef, data) => {
  // Existing code from X and Y axis example
  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)

  // *** New code added! ***

  const defaultBarColor = "lightcoral"
  // Add a wrapper g element for the bars
  svg
    .append("g")
    // Grab any existing rect elements and bind to the data
    .selectAll("rect")
    .data(data)
    .join("rect")
    // Set the bar color
    .attr("fill", defaultBarColor)
    // Use scaleBand to determine x-axis position for each bar
    .attr("x", (_, i) => x(i))
    // Use scaleLinear to determine y-axis position for each bar
    .attr("y", d => y(d.goals))
    // The height of the bars should correspond to the y-axis (goals scored)
    .attr("height", d => y(0) - y(d.goals))
    // Use D3's bandwidth to determine the width of the bars
    .attr("width", x.bandwidth())
}

Hover effect

The bar chart is actually a bar chart now. I was curious how hover effects work in D3, though. I thought it would be nice if the bars changed color on mouse hover so it’s obvious to the user which bar they’re currently on.

Mouse events in D3 are handled by the .on method that adds an event listener. The on method takes the event name as the first parameter. My first pass this looked something like this:

// bar-chart-utils.js
...
.on('hover', (element) => <change colors>)

But, it turns out that 'hover' is not a valid event in D3. I’m not actually sure why I thought it was. What I instead needed to do was add a listener for 'mouseover' and another listener for 'mouseout'. I could then change the color when the mouse is over the bar and change it back when the most leaves the element.

Which brings me to an interesting quirk of working with D3 and React. Typically D3’s .on method would have access to the instance this. But this will be undefined when working with React because this refers to the component instance. So, what I do is use the element from the D3 callback and access element.currentTarget instead. It looks something like the below code:

...
.on('mouseout', (element) => select(element.currentTarget) ...)

Below is the updated code for the InitBarChart helper method:

// bar-chart-utils.js
export const InitBarChart = (containerRef, data) => {
  ...

  const defaultBarColor = 'lightcoral';
  svg.append('g')
    .selectAll('rect')
    .data(data)
    .join('rect')
      .attr('fill', defaultBarColor)
      .attr('x', (_, i) => x(i))
      .attr('y', d => y(d.goals))
      .attr('height', d => y(0) - y(d.goals))
      .attr('width', x.bandwidth())
      // Change the bar color to red on mouseover events
      .on('mouseover', (element) => select(element.currentTarget).attr('fill', 'red'))
      // Change the bar color back to the original color on mouseout events
      .on('mouseout', (element) => select(element.currentTarget).attr('fill', defaultBarColor))
}

Conclusion

Trying to reverse engineer D3 using dev tools helps a lot. I don’t think I would’ve gotten the bars built at all if I didn’t take a look and see exactly what and where D3 was adding elements to the DOM. It makes D3 feel less intimidating when I can “peek under the hood” and see what’s happening.

The bar chart example here, so far, isn’t too complicated and neither is the code that produces it. I think I’ve managed to stay out of the spaghetti code trap that I often see with many D3 visualizations, but maybe I’ll fall into it as I add more to this one.

Final code & 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"
...
// bar-chart.jsx
import React, { useRef, useEffect } from "react"
import { InitBarChart } from "./bar-chart-utils.js"

const Index = () => {
  const containerRef = useRef()
  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-utils.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 InitBarChart = (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)

  const defaultBarColor = "lightcoral"
  svg
    .append("g")
    .selectAll("rect")
    .data(data)
    .join("rect")
    .attr("fill", defaultBarColor)
    .attr("x", (_, i) => x(i))
    .attr("y", d => y(d.goals))
    .attr("height", d => y(0) - y(d.goals))
    .attr("width", x.bandwidth())
    .on("mouseover", element =>
      select(element.currentTarget).attr("fill", "red")
    )
    .on("mouseout", element =>
      select(element.currentTarget).attr("fill", defaultBarColor)
    )
}