An Introduction to Making Dynamic and Interactive Plots with D3
By: Naveen Venkatesan
Tags: Data Visualization, Programming, JavaScript, Data Science, D3js

An Introduction to Making Dynamic and Interactive Plots with D3

Photo by chuttersnap on Unsplash

I’ve always been hesitant to learn how to use D3.js. It has always seemed much less intuitive than other libraries such as matplotlib , which I have written extensively about. However, here I find myself writing a tutorial on making a plot in D3 — if you are developing a website or web application and want dynamic and interactive data visualizations, it may be useful to be familiar with the library. In this article, I have compiled the results of many trial-and-errors in that hope that it can both serve me well as a reference and help you avoid some simple beginner mistakes in the process. The goal of this article will be to reproduce the plot of sample absorbances found in one of my earlier pieces:

Before we get started, I’d like to first take a step back. A lot of my previous work on Medium has been geared towards experimental scientists, as this is my own personal background. Speaking to that demographic, I believe that adding this new tool (D3) to your toolbox will help you make your data come alive, and be able to reach a wider audience than you normally would just by sharing your published journal articles. So, without further ado, let’s get started. As a disclaimer, a basic understanding of JavaScript, HTML, and CSS will certainly be helpful to you as a reader.

Creating our files

To get started, we are going to need to create 3 files (put them all in the same folder on your computer):

index.html — this is the main HTML file that we will be able to open in our web browser to see our visualization style.css — we will use CSS to style a few of the elements in our visualization plot.js — this will be the workhorse of our project, and contain all of the D3 code we need to make our plot

Setting up our HTML file

Our index.html will not contain too much in it, as we will manipulate the document using JavaScript. We start with the general skeleton code for any HTML file.

<!DOCTYPE html> <html>



</html>

Within our html tag, we are going to create two sections: head and body . In the head section, we will add references to our CSS stylesheet, as well as a link to D3.js so we can use all the modules later.

<!--Import D3 and our CSS in the head section--> <head>

<link rel='stylesheet' href='./style.css'>

<script src="https://d3js.org/d3.v5.min.js"></script>

</head>

Now, in the body of our HTML file, we will create a div called plot_area , which we will manipulate with our plot.js file, which we will also import into the current file.

<!--Create div for our plot and import plot.js--> <body>

<div id='plot_area'></div>

<script src='./plot.js'></script>

</body>

We are done with all the edits to index.html — now let’s move on to the main workhorse of our visualization, JavaScript.

Using D3.js

D3 is a JavaScript library that is short for Data-Driven Documents, and provides a series of functions to use and manipulate SVGs (scalable vector graphics). The first thing we are going to want to do is define a height and width for our SVG (in pixels) — in addition to this, we will define a padding value, to ensure that our plot axes are not right up against the boundaries of our SVG frame.

// Set SVG width, height, and padding

const w = 500;

const h = 500;

const padding = 60;

Loading our data

Next, we will need to load our data — to do this, we can make use of a built-in function, where format_fnc() is our JavaScript function to format our data, and manipulate_fnc() is our function that we will use to manipulate the data:

d3.csv('path_to_file', format_fnc()).then(manipulate_fnc());

The filepath that we will use is the CSV file from my previous article, and we will pass this to a callback function that reformats our data into a multidimensional array. We can reference fields in each of our CSV rows by their column names (‘Wavelength’, ‘Sample_1_Absorbance’, and ‘Sample_2_Absorbance’) — we must add a + because d3.csv() loads data as a string by default, so this ensures that we are storing the numerical values. Our final data array, will have subarrays for each data point, where the first element will be the wavelength, the second element is sample 1 absorbance, and the third element is sample 2 absorbance. Finally, we pass our formatted data to a plot_data() function that we will write next.

// Load our CSV data

d3.csv('https://raw.githubusercontent.com/naveenv92/python-science-tutorial/master/intro/Absorbance_Data.csv', function (d) {

return [

+d['Wavelength'],

+d['Sample_1_Absorbance'],

+d['Sample_2_Absorbance']

]

}).then(plot_data);

Now, we can start building the plot_data() function — the first thing we will do is define values for the axis limits:

// Data plotting function

function plot_data(data) { // Set axis limits

const xMin = 400;

const xMax = 950;

const yMin = 0;

const yMax = 2;

}

Creating axis scales

We will continue to add to the body of this function — we now need to set the axis scaling, which we will do using d3.scaleLinear() , since both our axis will have linear scaling. Onto this, we will chain two more methods: domain() and range() . The domain corresponds to the input values that we will pass to our scale, and the range corresponds to the output that will be drawn on the SVG (in pixels). You will notice that a main theme in D3 is that we can keep chaining an arbitrary number of methods. In both ranges, we will offset the endpoints by the value of our padding variable, so that we have some whitespace around our axes. Another interesting thing you will notice is that the y-axis range appears to be backwards! This is because the origin of the SVG is at the top left corner, so we need to flip the y-values in order to move this origin to the bottom left.

// Set x and y-axis scales

const xScale = d3.scaleLinear()

.domain([xMin, xMax])

.range([padding, w - padding]); const yScale = d3.scaleLinear()

.domain([yMin, yMax])

.range([h - padding, padding]);

Although we have specified a domain for our input values, our scaling functions will still plot data outside these ranges. To ensure that all our data is in our x-axis domain, we will create a new array that contains only the points in this region:

// Trim data points to only be in range of x-axis

let data_in_range = [];

data.forEach(function (e) {

if (e[0] >= xMin && e[0] <= xMax) {

data_in_range.push(e);

}

});

Creating initial SVG

We can now create the SVG object — in D3, this is often driven by the functions d3.select() and d3.selectAll() . We will select the div we created earlier with id=plot_area and append an SVG to it with append('svg') . We can then edit attributes of the SVG with attr() and pass the height and width values we already defined.

// Append an svg to the plot_area div

const svg = d3.select('#plot_area')

.append('svg')

.attr('width', w)

.attr('height', h);

Plotting our first lines

To plot our first line (the absorbance of sample 1), we will append a path object to our SVG. Then, we can bind the relevant data to this line using the datum() function. We can then edit attributes as we did above, but to actually create the line, we need to edit the d attribute and use d3.line() . We will pass callback functions for the x and y-data, where we use the data points in conjunction with the xScale and yScale functions we created earlier.

// Append path object for sample 1

svg.append('path')

.datum(data_in_range)

.attr('stroke', 'black')

.attr('stroke-width', 2)

.attr('fill', 'none')

.attr('d', d3.line()

.x((d) => xScale(d[0]))

.y((d) => yScale(d[1])));

If all goes well, we can now open the index.html file in our browser and should be greeted with the following:

Baby steps! Now let’s add the second line, much in the way we did the first:

// Append path object for sample 2

svg.append('path')

.datum(data_in_range)

.attr('stroke', 'steelblue')

.attr('stroke-width', 2)

.attr('fill', 'none')

.attr('d', d3.line()

.x((d) => xScale(d[0]))

.y((d) => yScale(d[2])));

We have successfully plotted both lines, but without any axes, there is no context for the data, so let’s add these.

Added axes to our plot

To create the x-axis, we use d3.axisBottom() and to create the y-axis we use d3.axisLeft() . All these two methods do, however, is make the x and y ticks point in the right direction (x-axis at the top of the SVG and y-axis at the left of the SVG) — we still need to translate them to their respective parts of the SVG, which we can do through the transform attribute:

// Add x-axis

svg.append('g')

.style('font-size', '12px')

.attr('transform', 'translate(0,' + (h - padding) + ')')

.call(d3.axisBottom(xScale)); // Add y-axis

svg.append('g')

.style('font-size', '12px')

.attr('transform', 'translate(' + padding + ',0)')

.call(d3.axisLeft(yScale));

Add axis labels

D3 doesn’t directly have a method to create axis labels, so we do this by appending text objects. We can easily position the labels using the height and width variables we have stored already. In the following code block, I am offsetting the labels by 15 pixels from the edge of the SVG. Also, but setting the text-anchor property to middle , we can center the text object on the axis.

For the y-axis label, we have to do one extra step. When rotating a text object, it gets rotated around the origin by default, even if we manually set the x and y attributes. To fix this, we will use a translation along with our rotation to reset the point of rotation, and rotate by -90 degrees.

// Add x-axis label

svg.append('text')

.attr('x', w/2)

.attr('y', h - 15)

.attr('text-anchor', 'middle')

.style('font-family', 'sans-serif')

.text('Wavelength (nm)'); // Add y-axis label

svg.append('text')

.attr('text-anchor', 'middle')

.attr('transform', 'translate(15,' + h/2 + ')rotate(-90)')

.style('font-family', 'sans-serif')

.text('Absorbance (O.D.)');

Adding a legend

Again, we’re pretty much on our own here as far as creating a legend. Our course of action will be as follows:

Create lines for the legend by creating a 2-point dataset Appending text objects for each of the legend labels

Our legend lines with go from [750, 800] in our plot, and will be at y-values of 1.9 for Sample 1, and 1.7 for Sample 2.

// Add legend

svg.append('path')

.datum([[750, 1.9], [800, 1.9]])

.attr('stroke', 'black')

.attr('stroke-width', 2)

.attr('d', d3.line()

.x((d) => xScale(d[0]))

.y((d) => yScale(d[1]))); svg.append('path')

.datum([[750, 1.7], [800, 1.7]])

.attr('stroke', 'steelblue')

.attr('stroke-width', 2)

.attr('d', d3.line()

.x((d) => xScale(d[0]))

.y((d) => yScale(d[1])));

Now we can append text labels next to each of the newly-drawn lines:

svg.append('text')

.attr('x', xScale(805))

.attr('y', yScale(1.9))

.attr('alignment-baseline', 'central')

.style('font-family', 'sans-serif')

.style('font-size', '16px')

.text('Sample 1'); svg.append('text')

.attr('x', xScale(805))

.attr('y', yScale(1.7))

.attr('alignment-baseline', 'central')

.style('font-family', 'sans-serif')

.style('font-size', '16px')

.text('Sample 2');

Making our plot dynamic

Now, if you thought that we went through a lot of trouble to make the simple plot you see above — you’re probably right. Making static figures with other programming software such as R or Python is very straightforward. But making our plot dynamic is where the power of D3 is really shown. First, let’s create a little animation, where both of the lines start flat at 0 and then rise up to their values over the course of one second.

To do this, we will go back and edit our initial plotting of the paths. We use the transition() method and duration() to change our attributes, in this case the d attribute. We set all the y-values to 0 initially, then change them to their respective values.

// Append path object for sample 1

svg.append('path')

.datum(data_in_range)

.attr('stroke', 'black')

.attr('stroke-width', 2)

.attr('fill', 'none')

.attr('d', d3.line()

.x((d) => xScale(d[0]))

.y(yScale(0)))

.transition()

.duration(1000)

.attr('d', d3.line()

.x((d) => xScale(d[0]))

.y((d) => yScale(d[1]))); // Append path object for sample 2

svg.append('path')

.datum(data_in_range)

.attr('stroke', 'steelblue')

.attr('stroke-width', 2)

.attr('fill', 'none')

.attr('d', d3.line()

.x((d) => xScale(d[0]))

.y(yScale(0)))

.transition()

.duration(1000)

.attr('d', d3.line()

.x((d) => xScale(d[0]))

.y((d) => yScale(d[2])));

Now, let’s make it so that we can use our mouse to hover over the actual data points and get their exact value. To do this, I am going to append circles for every data point. In this case, we will be use d3.selectAll() to select the circle objects we are going to create. We bind the data using data() instead of datum() because we are creating a new circle for each data point instead of using all the data points to create one path, as we did above.

For each of the circles, we edit the attribute cx and cy for the circle’s x and y coordinates, respectively, and r for the circles’ radii. We will set the fill color to the same as the lines. Each of the circles then gets assigned a class value of points , which we will use CSS to modify. Finally, we set pointer-events to all so that if any part of the circle comes into the mouse pointer, we can trigger an event.

To create the tooltips, we append title objects to each of the circle, and set their text attributes to be the data corresponding to that point. So when we hover over one of the points, we will see the data.

// Append circles for hovering points for sample 1

svg.selectAll('circle_samp_1')

.data(data_in_range)

.enter()

.append('circle')

.attr('cx', (d) => xScale(d[0]))

.attr('cy', (d) => yScale(d[1]))

.attr('r', 4)

.attr('fill', 'black')

.attr('class', 'points')

.style('pointer-events', 'all')

.append('title')

.text(function (d) {

return (

'Wavelength: ' + d[0] + ' nm' + '

' + 'Absorbance: ' + d[1]

);

}); // Append circles for hovering for sample 2

svg.selectAll('circle_samp_2')

.data(data_in_range)

.enter()

.append('circle')

.attr('cx', (d) => xScale(d[0]))

.attr('cy', (d) => yScale(d[2]))

.attr('r', 4)

.attr('fill', 'steelblue')

.attr('class', 'points')

.style('pointer-events', 'all')

.append('title')

.text(function (d) {

return (

'Wavelength: ' + d[0] + ' nm' + '

' + 'Absorbance: ' + d[2]

);

});

Editing CSS to bring it all together

I want the circles that we drew above to be invisible right until we hover over them. We can do this very simply in our CSS by editing the visibility attribute. We start with them being hidden and then make them visible when we trigger the points:hover event.

/* Make points visible on hover */

.points {

visibility: hidden;

} .points:hover {

visibility: visible;

}

And there you have it! An interactive version of our absorbance plot made with D3!

Final Remarks

Thank you for reading! All the analyses from this article can be found at this Github repository. I appreciate any feedback, and you can find me on Twitter and connect with me on LinkedIn for more updates and articles.