Interactive Charts with D3.js

by Amelia Wattenberger
on June 4th, 2019

Learn how to visualize data with Fullstack D3 and Data Visualization

book

You did it! You grabbed a data set and visualized it, right here in the browser. Congratulations, that is no easy feat!

This is 2019 and the web browser opens up a whole new realm of possibilities when visualizing data.

  • If a user wonders what the exact value of a data point is, they can hover over it and find out
  • We can even show whole charts within a chart tooltip
  • We can tell a story with a chart, progressively revealing parts of it as the user scrolls

Let’s take advantage of these new possibilities and talk about how to take your chart to the next level.

Our journey today will go through the following steps, click one to jump ahead:

#
Native trigger events

We can interact with the content of a web page in a variety of ways: we can hover elements, click on buttons, and select text, to start.

What are the possibilities with a since DOM element? See how many events you can trigger on these circles:

Interact with the circles - try clicking, dragging, right-clicking, scrolling over
Log

There are a ton of native events, aren’t there?! Did you find the drag events? Or the double-click event?

Any of these events can be used to trigger an interaction with a data visualization. The most common one is to show a tooltip with more information or detail ⁠— we’ll do a deep dive on tooltips further down.

This is just the tip of the iceberg ⁠— imagine letting users zoom into part of a chart with a wheel event. Or adding a custom context menu on right click.

#
Our chart

Let’s start out with a simple bar chart.

This histogram shows the difference between hours estimated and actual hours for specific tasks. Hovering over a bar triggers a tooltip that explains what the bar is, a few example tasks, and how many tasks fit into the bin.

The tooltip even has a bar that shows what percent of the work was done by developers. Showing a chart within a tooltip can help users dive even further into a dataset, letting them explore additional metrics.

Loading...

#
Getting set up

If you’d like to follow along, download the code on Github.

To get the code running:

  • 1.
    in your Terminal, install live-server
    In your terminal
    npm install -g live-server
  • 2.
    in your Terminal, start a server in the examples/ folder
    In your terminal
    live-server ⁠—open
  • 3.
    your browser should open the example automatically, but if it doesn’t, navigate to localhost:8080
  • 4.
    this page should show a directory of folders. Click until you reach /interactions/-bars-start, or navigate to the url localhost:8080/interactions/-bars-start/
Activate this step

On the rightBelow, you’ll see the full code to create the basic histogram.

At various points in this article, we’ll update the code in the right panel. As you scroll, keep an eye on the icons in the page’s left margin. When the scrolls into the rectangle on the right, the code on the right will update to match the next step.

You can also click on the to trigger the code to update.

This code goes through the 7 basic steps of creating a chart (as outlined in the Fullstack D3 and Data Visualization book).

  • 1.
    Access data
    Look at the data structure and declare how to access the values we’ll need
  • 2.
    Create chart dimensions
    Declare the physical (i.e. pixels) chart parameters
  • 3.
    Draw canvas
    Render the chart area and bounds element
  • 4.
    Create scales
    Create scales for every data-to-physical attribute in our chart
  • 5.
    Draw data
    Render your data elements
  • 6.
    Draw peripherals
    Render your axes, labels, and legends
  • 7.
    Set up interactions
    Initialize event listeners and create interaction behavior

The steps are grouped and collapsable in the code ⁠— if you want to learn about these in detail, download the first chapter of the book for free.

Activate this step

We’ll be fleshing out the last step: Set up interactions. Our code draws the complete chart, but doesn’t yet trigger any tooltips.

Loading...

#
How are we drawing this chart?

Activate this step

First, let’s look at how we’re drawing our bars.

In our Draw data step, we’re creating one group (<g> element) for each item in a bins array. Notice that we’re storing a d3 selection object containing these groups in the variable binGroups.

interactions/-bars-start/chart.js
102.
let binGroups = bounds.select(".bins")
103.
.selectAll(".bin")
104.
.data(bins)
105.
106.
binGroups.exit()
107.
.remove()
108.
109.
const newBinGroups = binGroups.enter().append("g")
110.
.attr("class", "bin")

But what is a d3 selection object? Whenever we pass a CSS-selector-like string to d3.select() or to d3.selectAll(), we create a new d3 selection object.

Matching DOM elements are stored in a list (represented under the _groups key). There will be other keys in this object, like the selection’s parents (in _parents).

d3 selection object
Activate this step
Next, we then create a <rect> element for each group and set its position and size, calculated with our already created xScale and yScale.
interactions/-bars-start/chart.js
112.
newBinGroups.append("rect")
113.
114.
// update binGroups to include new points
115.
binGroups = newBinGroups.merge(binGroups)
116.
117.
const barRects = binGroups.select("rect")
118.
.attr("x", d => xScale(d.x0) + barPadding)
119.
.attr("y", d => yScale(yAccessor(d)))
120.
.attr("height", d => (
121.
dimensions.boundedHeight - yScale(yAccessor(d))
122.
))
123.
.attr("width", d => d3.max([
124.
0,
125.
xScale(d.x1) - xScale(d.x0) - barPadding
126.
]))

#
Listening to mouse events

Now that we have an idea of how we’re drawing each of our bars, we can start adding our tooltip.

Activate this step

Let’s move back down to Step 7.

To show a tooltip when a user hovers a bar, we’ll need to trigger changes under two circumstances:

  • 1.
    the mouse enters a bar
  • 2.
    the mouse leaves a bar

Thankfully, d3 selection objects have an .on() method that will execute a function when an event is triggered. .on() takes three parameters:

  • 1.
    typename: the name of a supported DOM event type
  • 2.
    listener (optional): the function to execute when triggered
  • 3.
    options (optional): an object containing native event options, for example capture

For example, to log "hi" to the console when the body of a page is clicked, we could use the following code:

d3.select("body").on("click", () => console.log("hi"))
Activate this step

Let’s create some interactions! Using our binGroups selection, we’ll create one function to run on mouse enter and one function to run on mouse leave.

interactions/-bars-start/chart.js
182.
binGroups.select("rect")
183.
.on("mouseenter", onMouseEnter)
184.
.on("mouseleave", onMouseLeave)
185.
186.
function onMouseEnter(datum) {
187.
}
188.
189.
function onMouseLeave() {
190.
}
191.

#
Populating our tooltip

If we look at our index.html file, we’ll see that we’ve created a <div> with an id of "tooltip".

interactions/-bars-start/index.html
12.
<div id="wrapper" class="wrapper">
13.
14.
<div id="tooltip" class="tooltip">
15.
<div class="tooltip-range" id="range"></div>
16.
<div class="tooltip-examples" id="examples"></div>
17.
<div class="tooltip-value">
18.
...of <span id="count"></span> tasks
19.
</div>
20.
<div class="tooltip-bar-value">
21.
<b><span id="tooltip-bar-value"></span>%</b>
22.
of the work was done by developers
23.
</div>
24.
<div class="tooltip-bar">
25.
<div class="tooltip-bar-fill" id="tooltip-bar-fill"></div>
26.
</div>
27.
</div>
28.
29.
</div>
Activate this step

Let’s create a d3 selection object that contains our tooltip element.

We’ll create this element outside of our onMouseEnter and onMouseLeave functions, so we don’t have to find it every time we move our mouse.

interactions/-bars-start/chart.js
182.
binGroups.select("rect")
183.
.on("mouseenter", onMouseEnter)
184.
.on("mouseleave", onMouseLeave)
185.
186.
const tooltip = d3.select("#tooltip")
187.
function onMouseEnter(datum) {
188.
}
189.
190.
function onMouseLeave() {
191.
}
Activate this step

Next, let’s flesh out our onMouseEnter function. First, we’ll want to make our tooltip visible.

interactions/-bars-start/chart.js
189.
tooltip.style("opacity", 1)

Progress! Now our tooltip shows up when we hover a bar.

It’ll stay in its default position (top, left) since we’re not yet setting its position.

Loading...

Now we can start populating the different parts of our tooltip, starting with the title on top.

In index.html, we can see that the title of our tooltip has an id of "range".

interactions/-bars-start/index.html
12.
<div id="wrapper" class="wrapper">
13.
14.
<div id="tooltip" class="tooltip">
15.
<div class="tooltip-range" id="range"></div>
16.
<div class="tooltip-examples" id="examples"></div>
17.
<div class="tooltip-value">
18.
...of <span id="count"></span> tasks
19.
</div>
20.
<div class="tooltip-bar-value">
21.
<b><span id="tooltip-bar-value"></span>%</b>
22.
of the work was done by developers
23.
</div>
24.
<div class="tooltip-bar">
25.
<div class="tooltip-bar-fill" id="tooltip-bar-fill"></div>
26.
</div>
27.
</div>
28.
29.
</div>
Activate this step

We can target this <div> by creating a d3 selection object using the string "#range" (# specifies that we’re looking for elements with a specific id).

interactions/-bars-start/chart.js
191.
.text([

When we hover over a bar, we want the title of our tooltip to tell us the range of hours that are included. For example: Over-estimated by 5 to 10 hours. But how do we know what that range is for the bar we're hovering over?

Activate this step

The first parameter of our onMouseEnter() function is the specific data bound to the hovered element. We've named it datum.

datum will correspond to the item in the bins array that we used to create the hovered over bar.

interactions/-bars-start/chart.js
191.
tooltip.select("#range")
192.
.text([
193.
datum.x0 < 0
194.
? `Under-estimated by`
195.
: `Over-estimated by`,
196.
Math.abs(datum.x0),
197.
"to",
198.
Math.abs(datum.x1),
199.
"hours",
200.
].join(" "))
201.

Each item in our bins array contains:

  • the list of tasks that fit inside of the bucket
  • x0: the smallest number of hours included in the bucket
  • x1: the largest number of hours included in the bucket (exclusive)
bins

Knowing this, we can find the range of hours for our hovered bar at datum.x0 and datum.x1.

Activate this step

We can change the text inside of this <div> by using the d3 selection object's method .text(). Let's piece together our statement by creating an array of strings and numbers, then glueing them into one string using .join().

Loading...

Awesome! Now our tooltip's title updates as we move our mouse around.

Activate this step

The phrase Over-estimated by -25 to -20 hours is a little mind-bendy. Let's add some extra logic to handle under- and over-estimating differently. We can use Math.abs() to prevent from showing negative numbers.

Loading...

That's much better!

Activate this step

Let’s populate the rest of the tooltip.

This example is a little more in-depth, so feel free to breeze through these added lines. If you’re curious, feel free to implement them one-by-one to see how they populate the tooltip.

interactions/-bars-start/chart.js
203.
tooltip.select("#examples")
204.
.html(
205.
datum
206.
.slice(0, 3)
207.
.map(summaryAccessor)
208.
.join("<br />")
209.
)
210.
211.
tooltip.select("#count")
212.
.text(Math.max(0, yAccessor(datum) - 2))
213.
214.
const percentDeveloperHoursValues = datum.map(d => (
215.
(developerHoursAccessor(d) / actualHoursAccessor(d)) || 0
216.
))
217.
const percentDeveloperHours = d3.mean(percentDeveloperHoursValues)
218.
const formatHours = d => d3.format(",.2f")(Math.abs(d))
219.
tooltip.select("#tooltip-bar-value")
220.
.text(formatHours(percentDeveloperHours))
221.
tooltip.select("#tooltip-bar-fill")
222.
.style("width", `${percentDeveloperHours * 100}%`)
223.

Great! Now we can see our tooltip updating as we hover over different bars:

Loading...

#
Positioning our tooltip

Let’s update the position of our tooltip to sit on top of the bar that we’re hovering over. This will help to reinforce the relationship between the bar and the extra information, as well as decreasing the amount that users have to move their eyes back and forth.

Activate this step

If we look in Step 4, we created an xScale that converts hours over-estimated into an x-position.

We can use our xScale to convert the lower and upper bounds of our hovered bin into x-positions.

For example, xScale(datum.x0) will give us the x-position of the left side of our bin.

Activate this step

To find the middle of our bar, we’ll want to add three numbers together:

  • 1.
    the x-position of the left side of our bar
  • 2.
    half the width of our bar (the x-position of the rightof our bar, minus the x-position of the left side of our bar)
  • 3.
    the size of our left margin
interactions/-bars-start/chart.js
224.
const x = xScale(datum.x0)
225.
+ (xScale(datum.x1) - xScale(datum.x0)) / 2
226.
+ dimensions.margin.left

We also need to find the y-position of our tooltip.

Activate this step

To find the top of our bar, we’ll want to add two numbers together:

  • 1.
    the y-position of the top of our bar
  • 2.
    the size of our top margin
interactions/-bars-start/chart.js
227.
const y = yScale(yAccessor(datum))
228.
+ dimensions.margin.top

Great! Now we just need to use our x and y to position our tooltip.

Activate this step

We can move our tooltip element by setting its CSS transform property.

interactions/-bars-start/chart.js
230.
tooltip.style("transform", `translate(${x}px,${y}px)`)

Our tooltip is moving now! But it isn’t aligning with the correct bars!

Loading...

There are actually two things wrong here, let’s focus on the first:

If we look at the CSS, our tooltip is absolutely positioned:

interactions/-bars-start/styles.css
77.
.tooltip {
78.
position: absolute;

Absolutely positioned elements are positioned relative to their containing block. How are containing blocks created for an absolutely positioned element?

Our tooltip will be positioned based on the edge of the padding box of the nearest ancestor element that has:

  • a position value other than static (fixed, absolute, relative, or sticky).
  • a transform or perspective value other than none
  • a will-change value of transform or perspective
  • A filter value other than none or a will-change value of filter (only on Firefox).
  • A contain value of paint

Because none of these apply to any of our chart’s ancestor elements, our tooltip will be positioned relative to the initial containing block (basically what the <html> element covers).

Instead, we want to position our tooltip based on the top, left corner of our chart’s wrapper element. Let’s give this element one of the properties in the list above: the easiest is to set the position to relative. This won’t have an effect on the element, since relative acts very similar to the default static.

interactions/-bars-start/styles.css
12.
.wrapper {
13.
position: relative;
14.
}
Loading...

Something is else wrong here ⁠— our tooltip is moving to the top of our hovered bar, but we’re aligning the top, left corner.

Instead, we want to align the bottom, middle edge to the top of our hovered bar.

We want our tooltip to shift left by 50% of its own width and up by 100% of its own height. There are several ways to use CSS to position an element using percent, and each way uses the percent of a different value:

  • top and bottom
    percentage of the parent element’s width
  • left and right
    percentage of the parent element’s height
  • margin
    percentage of the parent element’s width (even the top and bottom margins)
  • transform: translate()
    percentage of the specified element’s height and width

Since we want to shift our tooltip by its own height and width, we’ll need to use the transform: translate() CSS property. But we’re already using it to set the overall position.

Thankfully, we can use the CSS calc() function! CSS calc() lets you specify a value using multiple units. For example, an element with the rule width: calc(100% + 20px) will be 20 pixels wider than its context.

Activate this step

Let’s use calc() to also shift our tooltip left by -50% of its width and up by 100% of its height.

interactions/-bars-start/chart.js
230.
tooltip.style("transform", `translate(`
231.
+ `calc( -50% + ${x}px),`
232.
+ `calc(-100% + ${y}px)`
233.
+ `)`)
234.
}
Loading...

Perfect! Now our tooltip is correctly positioned above any bar that we hover.

#
Finishing tweaks

Activate this step

Our tooltip is sticking around, obscuring part of the chart. When our mouse leaves a bar, let’s remove the tooltip to clear the view.

interactions/-bars-start/chart.js
237.
tooltip.style("opacity", 0)
Loading...

Lastly, let’s highlight the bar that we’re hovering over, making it easier to focus on it.

We could update its fill in our onMouseEnter and onMouseLeave functions, but there’s a simpler way.

We can insert a CSS selector in our styles.css file, targeting any .bin that is being :hovered.

interactions/-bars-start/styles.css
29.
.bin:hover {
30.
fill: #22a6b3;
31.
}

There we go!

Loading...

It’s always good to be aware of multiple ways of doing things, and the benefits of each. My general rule of thumb is to use a CSS style when possible, especially when the change is more of a decorative style or uses SASS/LESS variables.

#
Make the interaction as easy as possible

While the tooltip we just created is wonderful and helpful, it could be easier to trigger. The easier a chart is to interact with, the more likely a user is to interact with it.

Until now, we’ve been using existing elements to trigger mouse events. What if we created new elements to trigger those events? This opens up tons of new possibilities.

What would the ideal hover event be? Let’s make it so that hovering anywhere on our chart will trigger a tooltip.

We’ll want to create new bars on top of our existing bins, but these will cover the full height of our bounds.

Loading...
Activate this step

At the top of our Set up interactions step, we’ll create these new bars and attach our listener events to them.

interactions/-bars-start/chart.js
170.
const barRectsListeners = bounds.selectAll(".listeners")
171.
.data(bins)
172.
.enter().append("rect")
173.
.attr("class", "listeners")
174.
.attr("x", d => xScale(d.x0))
175.
.attr("y", -dimensions.margin.top)
176.
.attr("height", dimensions.boundedHeight + dimensions.margin.top)
177.
.attr("width", d => d3.max([
178.
0,
179.
xScale(d.x1) - xScale(d.x0)
180.
]))
181.
.on("mouseenter", onMouseEnter)
182.
.on("mouseleave", onMouseLeave)

We’re also ignoring the padding between our bars, making them flush against each other. This will prevent our tooltip from flickering when our mouse is in-between two bars.

Since our new bars will have a default fill of black, let’s update that in our styles.css file so that they’re not obstructing our existing chart.

interactions/-bars-start/styles.css
163.
.listeners {
164.
fill: transparent;
165.
}

Note that we want to keep the bars there to capture the pointer events, we just want them to have a transparent fill.

Notice how much easier it is to interact with our chart now?

Loading...
We’ve lost our CSS :hover styles, though, since our new bars are capturing the hover events.
Activate this step

When we draw our .bins in our Draw data step, let’s give them a key attribute that we can target.

We’ll use the index of the bin as an identifier, since it will be consistent between both sets of bars.

interactions/-bars-start/chart.js
108.
.attr("key", (d, i) => i)
Activate this step

The second parameter .on() sends to its callback is the index of the d3 selection object.

interactions/-bars-start/chart.js
186.
function onMouseEnter(datum, index) {

Let’s assign this index to the variable index.

Activate this step

At the bottom of our onMouseEnter() function, we can find the corresponding .bin and add a class to it: hovered.

interactions/-bars-start/chart.js
229.
const hoveredBar = binGroups.select(`rect[key='${index}']`)
230.
hoveredBar.classed("hovered", true)
235.
barRects.classed("hovered", false)

Lastly, we’ll need to add one more rule to our styles.css file.

Let’s want to add a fill to elements with a class of hovered, since we can no longer use the :hover pseudo-class.

interactions/-bars-start/styles.css
29.
.hovered {
30.
fill: #22a6b3;
31.
}

And voila! Now our bar changes color on hover again.

Loading...

Take a minute to compare our new version (above) with the first version we made (below). Which one is more enjoyable to interact with?

Loading...

#
Taking it further

Activate this step

In this exercise, we learned a few fundamental concepts:

  • how to trigger changes to our chart based on native event listeners
  • how to update the contents of a tooltip
  • how to position a tooltip
  • to create new elements to get more fluid interactions

Stay tuned!

In the next post, we’ll explore how to add tooltips to a scatter plot. To do this well, we’ll create extra elements similar to our last series of bars, but this is tricker to do with irregularly-spaced elements.

After that, we’ll explore how to add a tooltip to a line chart. We’ll learn how to find the exact mouse position and search for the closest data point.

I'll be posting updates on Twitter, feel free to follow me if you want to be notified of updates. Or share any thoughts about this article. Thanks for reading!