Back-end11-minute read

Towards Updatable D3.js Charts

When Mike Bostock created D3.js, he introduced a tried and true reusable charts pattern for implementing the same chart in any number of selections. However, the limitations of this pattern are realized once the chart is initialized. In this article, Toptal engineer Rob Moore presents a revised reusable charts pattern that leverages the full power of D3.js.


Toptalauthors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.

When Mike Bostock created D3.js, he introduced a tried and true reusable charts pattern for implementing the same chart in any number of selections. However, the limitations of this pattern are realized once the chart is initialized. In this article, Toptal engineer Rob Moore presents a revised reusable charts pattern that leverages the full power of D3.js.


Toptalauthors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.
Rob Moore
Verified Expert in Engineering

where he tries to bring to the web development the experiences got in the field of HPC and (Big) Data Processing.

Previously At

Johns Hopkins
Share

Introduction

D3.js is an open source library for data visualizations developed by Mike Bostock. D3 stands for data driven documents, and as its name suggests, the library allows developers to easily generate and manipulate DOM elements based on data. Although not limited by the capabilities of the library, D3.js is typically used with SVG elements and offers powerful tools for developing vector data visualizations from scratch.

Updatable chart pattern allows making D3.js charts easy

Let’s start with a simple example. Suppose you’re training for a 5k race, and you want to make a horizontal bar chart of the number of miles you’ve run each day of the last week:

    var milesRun = [2, 5, 4, 1, 2, 6, 5];
 
    d3.select('body').append('svg')
        .attr('height', 300)
        .attr('width', 800)
        .selectAll('rect')
            .data(milesRun)
        	.enter()
            .append('rect')
        	.attr('y', function (d, i) { return i * 40 })
            .attr('height', 35)
        	.attr('x', 0)
       	 .attr('width', function (d) { return d*100})
            .style('fill', 'steelblue');

To see it in action, check it out on bl.ocks.org.

If this code looks familiar, that’s great. If not, I found Scott Murray’s tutorials to be an excellent resource for getting started with D3.js.

As a freelancer who has worked hundreds of hours developing with D3.js, my development pattern has gone through an evolution, always with an end goal of creating the most comprehensive client and user experiences. As I will discuss in more detail later, Mike Bostock’s pattern for reusable charts offered a tried and true method for implementing the same chart in any number of selections. However, its limitations are realized once the chart is initialized. If I wanted to use D3’s transitions and update patterns with this method, changes to the data had to be handled entirely within the same scope that the chart was generated. In practice, this meant implementing filters, dropdown selects, sliders, and resize options all within the same function scope.

After repeatedly experiencing these limitations firsthand, I wanted to create a way to leverage the full power of D3.js. For example, listening for changes on a dropdown of a completely separate component and seamlessly triggering chart updates from old data to new. I wanted to be able to hand over the chart controls with full functionality, and do so in a way that was logical and modular. The result is an updatable chart pattern, and I’m going to walk through my complete progression to creating this pattern.

D3.js Charts Pattern Progression

Step 1: Configuration Variables

As I began using D3.js to develop visualizations, it became very convenient to use configuration variables to quickly define and change the specs of a chart. This allowed my charts to handle all different lengths and values of data. The same piece of code that displayed miles run could now display a longer list of temperatures without any hiccups:

    var highTemperatures = [77, 71, 82, 87, 84, 78, 80, 84, 86, 72, 71, 68];
    var height = 300;
    var width = 800;
    var barPadding = 1;
    var barSpacing = height / highTemperatures.length;
    var barHeight = barSpacing - barPadding;
    var maxValue = d3.max(highTemperatures);
    var widthScale = width / maxValue;
 
    d3.select('body').append('svg')
            .attr('height', height)
            .attr('width', width)
            .selectAll('rect')
            .data(highTemperatures)
        	.enter()
            .append('rect')
        	.attr('y', function (d, i) { return i * barSpacing })
            .attr('height', barHeight)
        	.attr('x', 0)
            .attr('width', function (d) { return d*widthScale})
            .style('fill', 'steelblue');

To see it in action, check it out on bl.ocks.org.

Notice how the heights and widths of the bars are scaled based on both the size and values of the data. One variable is changed, and the rest is taken care of.

Step 2: Easy Repetition Through Functions

Coding should never be an exercise in copy-paste

Coding should never be an exercise in copy-paste

By abstracting out some of the business logic, we’re able to create more versatile code that’s ready to handle a generalized template of data. The next step is to wrap this code into a generation function, which reduces initialization to just one line. The function takes in three arguments: the data, a DOM target, and an options object which can be used to overwrite default configuration variables. Take a look at how this can be done:

	var milesRun = [2, 5, 4, 1, 2, 6, 5];
	var highTemperatures = [77, 71, 82, 87, 84, 78, 80, 84, 86, 72, 71, 68, 75, 73, 80, 85, 86, 80];
 
	function drawChart(dom, data, options) {
    	var width = options.width || 800;
    	var height = options.height || 200;
    	var barPadding = options.barPadding || 1;
    	var fillColor = options.fillColor || 'steelblue';
 
    	var barSpacing = height / data.length;
    	var barHeight = barSpacing - barPadding;
    	var maxValue = d3.max(data);
    	var widthScale = width / maxValue;
 
    	d3.select(dom).append('svg')
                .attr('height', height)
                .attr('width', width)
                .selectAll('rect')
                .data(data)
                .enter()
                .append('rect')
                .attr('y', function (d, i) { return i * barSpacing })
                .attr('height', barHeight)
                .attr('x', 0)
                .attr('width', function (d) { return d*widthScale})
                .style('fill', fillColor);
    	}
 
    	var weatherOptions = {fillColor: 'coral'};
    	drawChart('#weatherHistory', highTemperatures, weatherOptions);
 
    	var runningOptions = {barPadding: 2};
    	drawChart('#runningHistory', milesRun, runningOptions);

To see it in action, check it out on bl.ocks.org.

It’s also important to make a note about D3.js selections in this context. General selections like d3.selectAll(‘rect’) should always be avoided. If SVGs are present somewhere else on the page, all rect’s on the page become a part of the selection. Instead, using the DOM reference passed in, create one svg object that you can refer to when appending and updating elements. This technique can also improve the runtime of chart generation, as using a reference like bars also prevents having to make the D3.js selection again.

Step 3: Method Chaining and Selections

While the previous skeleton using configuration objects is very common across JavaScript libraries, Mike Bostock, the creator of D3.js, recommends another pattern for creating reusable charts. In short, Mike Bostock recommends implementing charts as closures with getter-setter methods. While adding some complexity to the chart implementation, setting configuration options becomes very straightforward for the caller by simply using method chaining:

	// Using Mike Bostock's Towards Reusable Charts Pattern
	function barChart() {
 
    	// All options that should be accessible to caller
    	var width = 900;
    	var height = 200;
    	var barPadding = 1;
    	var fillColor = 'steelblue';
 
    	function chart(selection){
            selection.each(function (data) {
            	var barSpacing = height / data.length;
            	var barHeight = barSpacing - barPadding;
            	var maxValue = d3.max(data);
            	var widthScale = width / maxValue;
 
                d3.select(this).append('svg')
                    .attr('height', height)
                    .attr('width', width)
                    .selectAll('rect')
                    .data(data)
                	.enter()
                    .append('rect')
                    .attr('y', function (d, i) { return i * barSpacing })
                    .attr('height', barHeight)
                    .attr('x', 0)
                    .attr('width', function (d) { return d*widthScale})
                    .style('fill', fillColor);
        	});
    	}
 
    	chart.width = function(value) {
        	if (!arguments.length) return margin;
        	width = value;
        	return chart;
    	};
 
    	chart.height = function(value) {
        	if (!arguments.length) return height;
        	height = value;
        	return chart;
    	};
 
        chart.barPadding = function(value) {
        	if (!arguments.length) return barPadding;
        	barPadding = value;
        	return chart;
    	};
 
        chart.fillColor = function(value) {
        	if (!arguments.length) return fillColor;
        	fillColor = value;
        	return chart;
    	};
 
    	return chart;
	}	
	var milesRun = [2, 5, 4, 1, 2, 6, 5];
	var highTemperatures = [77, 71, 82, 87, 84, 78, 80, 84, 86, 72, 71, 68, 75, 73, 80, 85, 86, 80];
 
	var runningChart = barChart().barPadding(2);
    d3.select('#runningHistory')
            .datum(milesRun)
            .call(runningChart);
 
	var weatherChart = barChart().fillColor('coral');
    d3.select('#weatherHistory')
            .datum(highTemperatures)
            .call(weatherChart);

To see it in action, check it out on bl.ocks.org.

The chart initialization uses the D3.js selection, binding the relevant data and passing the DOM selection as the this context to the generator function. The generator function wraps default variables in a closure, and allows the caller to change these through method chaining with configuration functions that return the chart object. By doing this, the caller can render the same chart to multiple selections at a time, or use one chart to render the same graph to different selections with different data, all while avoiding passing around a bulky options object.

Step 4: A New Pattern for Updatable Charts

The previous pattern suggested by Mike Bostock gives us, as the chart developers, a lot of power within the generator function. Given one set of data and any chained configurations passed in, we control everything from there. If data needs to be changed from within, we can use appropriate transitions instead of just redrawing from scratch. Even things like window resizes can be handled elegantly, creating responsive features like using abbreviated text or changing axis labels.

But what if the data is modified from outside the scope of the generator function? Or what if the chart needs to be programmatically resized? We could just call the chart function again, with the new data and new size configuration. Everything would be redrawn, and voilà. Problem solved.

Unfortunately, there are a number of problems with this solution.

First off, we are almost inevitably performing unnecessary initialization calculation. Why do complex data manipulation when all we have to do is scale the width? These calculations may be necessary the first time a chart is initialized, but certainly not on every update we need to make. Each programmatic request requires some modification, and as the developers we know exactly what these changes are. No more, no less. Moreover, within the chart scope, we already have access to a lot of things we need (SVG objects, current data states, and more) making changes straightforward to implement.

Take for example the bar chart example above. If we wanted to update the width, and did so by redrawing the entire chart, we would trigger a lot of unnecessary computations: finding the maximum data value, calculating the bar height, and the rendering all of these SVG elements. Really, once width is assigned to its new value, the only changes we need to make are:

width = newWidth;
widthScale = width / maxValue;
bars.attr('width', function(d) { return d*widthScale});
svg.attr('width', width);

But it gets even better. Since we now have some history of the chart, we can use D3’s built in transitions to update our charts and animate them easily. Continuing with the example above, adding a transition on width is as simple as changing

bars.attr('width', function(d) { return d*widthScale});

to

bars.transition().duration(1000).attr('width', function(d) { return d*widthScale});

Even better yet, if we allow a user to pass in a new data set, we can use D3’s update selections (enter, update, and exit) to also apply transitions to new data. But how do we allow for new data? If you recall, our previous implementation created a new chart like this:

d3.select('#weatherHistory')
    .datum(highTemperatures)
	.call(weatherChart);

We bound data to a D3.js selection, and called our reusable chart. Any changes to the data would have to be done by binding new data to the same selection. Theoretically, we could use the old pattern and probe the selection for existing data, and then update our findings with the new data. Not only is this messy and complicated to implement, but it would require the assumption that the existing chart was of the same type and form.

Instead, with some changes to the structure of the JavaScript generator function, we can create a chart that will allow the caller to easily prompt changes externally through method chaining. Whereas before configuration and data was set and then left untouched, the caller can now do something like this, even after the chart is initialized:

weatherChart.width(420);

The result is a smooth transition to a new width from the existing chart. With no unnecessary calculations and with sleek transitions, the result is a happy client.

No unnecessary calculations + sleek transitions = happy client

No unnecessary calculations + sleek transitions = happy client

This extra functionality comes with a slight increase in developer effort. An effort, however, that I have found to be well worth the time historically. Here’s a skeleton of the updatable chart:

function barChart() {
 
	// All options that should be accessible to caller
	var data = [];
	var width = 800;
	//... the rest
 
	var updateData;
	var updateWidth;
	//... the rest
 
	function chart(selection){
        selection.each(function () {
 
        	//
        	//draw the chart here using data, width
        	//
 
            updateWidth = function() {
            	// use width to make any changes
        	};
 
        	updateData = function() {
            	// use D3 update pattern with data
        	}
 
    	});
	}
 
	chart.data = function(value) {
    	if (!arguments.length) return data;
    	data = value;
    	if (typeof updateData === 'function') updateData();
    	return chart;
	};
 
	chart.width = function(value) {
    	if (!arguments.length) return width;
    	width = value;
    	if (typeof updateWidth === 'function') updateWidth();
    	return chart;
	};
	//... the rest
 
	return chart;
}

To see fully implemented, check it out on bl.ocks.org.

Let’s review new structure. The biggest change from the previous closure implementation is the addition of update functions. As previously discussed, these functions leverage D3.js transitions and update patterns to smoothly make any necessary changes based on new data or chart configurations. To make these accessible to the caller, functions are added as properties to the chart. And to make it even easier yet, both initial configuration and updates are handled through the same function:

chart.width = function(value) {
    if (!arguments.length) return width;
    width = value;
    if (typeof updateWidth === 'function') updateWidth();
    return chart;
};

Note that updateWidth will not be defined until the chart has been initialized. If it is undefined, then the configuration variable will be globally set and used in the chart closure. If the chart function has been called, then all transitions are handed off to the updateWidth function, which uses the changed width variable to make any needed changes. Something like this:

updateWidth = function() {
    widthScale = width / maxValue;
    bars.transition().duration(1000).attr('width', function(d) { return d*widthScale});
    svg.transition().duration(1000).attr('width', width);
};

With this new structure, the data for the chart is passed in through method chaining just like any other configuration variable, instead of binding it to a D3.js selection. The difference:

var weatherChart = barChart();
 
d3.select('#weatherHistory')
       .datum(highTemperatures)
       .call(weatherChart);

which becomes:

var weatherChart = barChart().data(highTemperatures);
 
d3.select('#weatherHistory')
         .call(weatherChart);

So we’ve made some changes and added a bit of developer effort, let’s see the benefits.

Let’s say you’ve got a new feature request: “Add a dropdown so that the user can change between high temperatures and low temperatures. And make the color change too while you’re at it.” Instead of clearing the current chart, binding the new data, and redrawing from scratch, now you can make a simple call when low temperature is selected:

weatherChart.data(lowTemperatures).fillColor(‘blue’);

and enjoy the magic. Not only are we saving calculations, but we add a new level of comprehension to the visualization as it updates, which wasn’t possible before.

An important word of caution about transitions is needed here. Be careful when scheduling multiple transitions on the same element. Starting a new transition will implicitly cancel any previously running transitions. Of course, multiple attributes or styles can be changed on an element in one D3.js initiated transition, but I have come across some instances where multiple transitions are triggered simultaneously. In these instances, consider using concurrent transitions on parent and child elements when creating your update functions.

A Change in Philosophy

Mike Bostock introduces closures as a way to encapsulate chart generation. His pattern is optimized for creating the same chart with different data in many places. In my years working with D3.js, however, I’ve found a slight difference in priorities. Instead of using one instance of a chart to create the same visualization with different data, the new pattern I’ve introduced allows the caller to easily create multiple instances of a chart, each of which can be fully modified even after initialization. Furthermore, each of these updates is handled with full access to the current state of the chart, allowing the developer to eliminate unnecessary computations and harness the power of D3.js to create more seamless user and client experiences.

Hire a Toptal expert on this topic.
Hire Now
Rob Moore

Rob Moore

Verified Expert in Engineering

London, United Kingdom

Member since July 23, 2015

About the author

where he tries to bring to the web development the experiences got in the field of HPC and (Big) Data Processing.

authors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.

PREVIOUSLY AT

Johns Hopkins

World-class articles, delivered weekly.

By entering your email, you are agreeing to our privacy policy.

World-class articles, delivered weekly.

By entering your email, you are agreeing to our privacy policy.

Join the Toptal® community.