The D3.js JavaScript library frequently comes up across our adventures in data visualization. Those who read the tutorials or spend time on the mailing list will likely notice frequent use of the term “reusable” when it comes to creating visualizations with the library. There’s a lot of meaning packed into that word, so we wanted to take some time to investigate how it applies (and could apply) to D3.js specifically.
What is a “Reusable Chart”?
The term “reusable” may be interpreted in a number of ways. Here, we let our work with various JavaScript libraries inform our understanding of what it could mean for D3.js specifically. We’ve ordered these interpretations by degree of abstraction (or, to use the technical jargon, “meta-ness”):
- Repeatable – A chart can be instantiated more than once in a given context, each visualizing a different data set.
- Modifiable – Chart definitions (source code) can be easily re-factored by other developers to suit their needs.
- Configurable – Chart behavior can be altered at run-time through a consistent API.
- Extensible – New charts can be defined in terms of existing charts.
Generally speaking, it is difficult to paint the notion of “reusability” in a negative light. “Dynamic programming” comes to mind:
“What title, what name, could I choose? […] Let’s take a word that has an absolutely precise meaning, namely dynamic, in the classical physical sense. It also has a very interesting property as an adjective, and that is it’s impossible to use the word, dynamic, in a pejorative sense. Try thinking of some combination that will possibly give it a pejorative meaning. It’s impossible. Thus, I thought dynamic programming was a good name. It was something not even a Congressman could object to. So I used it as an umbrella for my activities.”
– Dreyfus, Stuart, “Richard Bellman on the birth of Dynamic Programming“
Similarly, none of the above interpretations sound like a bad idea. But it’s important to note that each degree of reusability brings with it a new level of complexity–a library author must make trade-offs between usability, maintainability, and expressiveness.
A Look at Current Best Practices
Common usage of D3.js generally falls short of the above interpretation of “reusable”. To demonstrate, we’ll be referring to the bar chart created in A Bar Chart, Part 2. This simple chart (and accompanying tutorial) has been instructive to many D3.js initiates.
Repeatable In Towards Reusable Charts, Mike Bostock, the maintainer of D3.js, recommends best practices for structuring charts made with the library. Charts that adhere to this structure can be arbitrarily repeated with different data sets.
Modifiable We find approaching new charts to be quite difficult for a
number of reasons. Charts typically define a monolithic draw
or update
function that contain a majority of their rendering logic. These functions
usually operate on nested selections which
(besides being difficult to read) have a non-trivial impact on how and where
the chart may be modified. Take this bar
chart‘s implementation for
example:
function redraw() {
var rect = chart.selectAll("rect")
.data(data, function(d) { return d.time; });
rect.enter().insert("rect", "line")
.attr("x", function(d, i) { return x(i + 1) - .5; })
.attr("y", function(d) { return h - y(d.value) - .5; })
.attr("width", w)
.attr("height", function(d) { return y(d.value); })
.transition()
.duration(1000)
.attr("x", function(d, i) { return x(i) - .5; });
rect.transition()
.duration(1000)
.attr("x", function(d, i) { return x(i) - .5; });
rect.exit().transition()
.duration(1000)
.attr("x", function(d, i) { return x(i - 1) - .5; })
.remove();
}
This function includes logic for:
- Binding data to SVG elements
- Creating new SVG elements
- The behavior of entering elements
- The behavior of updating elements
- The behavior of exiting elements
…and since there is no guidance on how to structure all this logic, familiarity with any chart’s structure is largely non-transferrable.
Charts have one or more sets of elements that correspond to data. For instance, this chord diagram has a group of “ticks” that label the axis, a set of “chords” that visualize the relations in the data, and “handles” which can be hovered over. We call these sets “layers”. Although they have clear conceptual boundaries, layer definitions generally sprawl across charts.
Configurable Towards Reusable Charts recommends encapsulating chart properies in private scope. This means the chart author must explicitly expose an API for every aspect of the chart that a consumer might want to change. Here’s an abridged example from that article:
function chart() {
var width = 720: // default width
function my() {
// generate chart here, using `width` and `height`
}
my.width = function(value) {
if (!arguments.length) return width;
width = value;
return my;
};
return my;
}
This pattern means chart consumers must choose between learning the chart’s API (and accepting its limitations) and modifying the chart code directly (raising again the problems with modifiability).
Extensible To date, we have not encountered a chart written in “pure” D3.js that assisted developers in using it as a base upon which to define new charts. It doesn’t really make sense for individual charts to implement the inheritance structure this requires, but such logic can be found in frameworks. For example, the xCharts framework supports this behavior through its “Custom Vis Types” feature.
Solutions
Lest you think we’re a bunch of Negative Nancies, we do have some ideas for improving D3.js along these lines. In some cases, the improvements take the form of suggested patterns for D3.js users. In other cases, we suggest changes to the D3.js API itself. For these latter suggestions, we’ve implemented prototypes to help communicate our intentions. We demonstrate each suggestion by incrementally changing the implementation of the bar chart and the chord diagram. We documented these incremental steps with a repository on GitHub.com; you’ll find links to the relevant files (and commits) as we proceed.
Repeatable As we covered above, the recommended best-practice for chart structure is sufficient to acheive this interpretation of “reusable”. While we generally prefer to embrace JavaScript’s prototypical inheritance facilities, Bostock points out:
A conventional object-oriented approach as
Chart.prototype.render
would also work, but then you must manage thethis
context when calling the function.
So we’ll let this one slide.
Repeatable bar chart (commit) | Repeatable chord diagram (commit)
Modifiable Of course, all charts written in JavaScript are intrinsically open to modification. This doesn’t mean we can’t do better, though. We would like to see more structure to D3.js charts so that the path to modification is clear and consistent. Recall that in this context, we’re using “modifiable” to describe the ease with which source code can be changed. As such, the recommendations here amount to simple transformations that concern code style.
A bit of technical background will be useful to understand the context of our
suggestion here. Selections returned by .enter
, .exit
, and .transition
(which we call “lifecycle selections”) are somewhat brittle in D3.js: making
them more than once can have undesirable side effects. So to modify the
behavior at these points, logic must be inserted within the call chains.
A low-tech solution would entail adopting more explicit code style conventions
in chart logic. Developers could avoid excessive chaining by assigning each
lifecycle selection to a variable and performing operations on those
references. The redraw
method from earlier in this post could be implemented
like so:
function redraw() {
var rect = chart.selectAll("rect")
.data(data, function(d) { return d.time; });
// Store references to "lifecycle" selections
var entering = rect.enter().insert("rect", line);
var exiting = rect.exit();
var enteringTrans = entering.transition();
var trans = rect.transition();
var exitingTrans = exiting.transition();
// Operate on "lifecycle" selections
entering
.attr("x", function(d, i) { return x(i + 1) - .5; })
.attr("y", function(d) { return h - y(d.value) - .5; })
.attr("width", w)
.attr("height", function(d) { return y(d.value); });
enteringTrans.duration(1000)
.attr("x", function(d, i) { return x(i) - .5; });
trans.duration(1000)
.attr("x", function(d, i) { return x(i) - .5; });
exitingTrans.duration(1000)
.attr("x", function(d, i) { return x(i - 1) - .5; })
.remove();
}
Modifiable bar chart (commit) | Modifiable chord diagram (commit)
…or even better, making liberal use of functions and D3.js’s call
method:
function onEnter() {
this.attr("x", function(d, i) { return x(i + 1) - .5; })
.attr("y", function(d) { return h - y(d.value) - .5; })
.attr("width", w)
.attr("height", function(d) { return y(d.value); });
}
function onEnterTrans() {
this
.duration(1000)
.attr("x", function(d, i) { return x(i) - .5; });
}
function onTrans() {
this.attr("x", function(d, i) { return x(i) - .5; });
}
function onExit() {
// nothing to do
}
function onExitTrans() {
this.duration(1000)
.attr("x", function(d, i) { return x(i - 1) - .5; })
.remove();
}
function redraw() {
var rect = chart.selectAll("rect")
.data(data, function(d) { return d.time; });
rect.enter()
.insert("rect", "line")
.call(onEnter);
.transition().call(onEnterTrans);
rect.transition()
.call(onTrans)
rect.exit()
.call(onExit)
.transistion().call(onExitTrans);
}
This structure is far more explicit, and developers seeking to modify the
chart’s bahavior have a much clearer path to doing so. Note how abstract the
redraw
function has become. Unless they are seeking to fundamentally change
the way this chart behaves (which is probably better-acheived by starting from
scratch), developers will not need to modify the redraw
function at all.
More modifiable bar chart (commit) | More modifiable chord diagram (commit)
Lastly, chart authors should take time to recognize the “layers” within their charts and organize them into distinct methods. Since the bar chart only has one conceptual layer, we’ll instead demonstrate this approach in terms of the chord diagram:
function redraw() {
layers.ticks();
layers.handles();
layers.chords();
}
Bar chart unchanged | Even more modifiable chord diagram (commit)
Configurable We love the power of the D3.js API (we wouldn’t be writing this if we didn’t). We want to use it not just when we write our charts, but when we use charts written by others. To facilitate this, chart authors (or D3.js itself!) could implement a general-purpose method for attaching code to lifecycle events.
It doesn’t make sense for every chart to define this API, but a framework could
address this need consistently for all charts. In order to toy around with the
idea, we’ve implemented a tiny (100 LOC) framework we call
d3.layer
.
It exposes an API based on the jQuery event API
first introduced in version
1.7 which is the bee’s
knees.
A framework like this allows you to configure an existing bar chart to render bars according to your own use case. Imagine that you want one specific instance of the chart to render each bar as progressively more transparent. You can do this without changing the core rendering logic of the bar chart:
myChart.on("update:transition", function() {
// Here, `this` refers to a D3.js selection, so it exposes all the
// functionality you have come to expect from the library
this.attr("opacity", function(d, i) { return i / 32; });
});
As it turns out, such a framework can have benefit beyond configurability. Remember the common chart logic we described earlier in this post?
- Binding data to SVG elements
- Creating new SVG elements
- The behavior of entering elements
- The behavior of updating elements
- The behavior of exiting elements
In order to support the configurable event API, the framework codifies that structure. The recommendations of the previous section (code organization that improve chart readability and maintainability) are promoted to technical requirements.
Bar chart with
d3.Layer
(commit)
| Chord diagram with
d3.Layer
(commit)
Note that in order to truly reap the benefits of configurability, it’s up to the chart author to expose the layer(s) that comprise the graph. We’ve demonstrated usage of the API by applying it to both charts:
Fading bar chart configured with
d3.layer
(commit)
| Colored-label chord diagram configured with
d3.layer
(commit)
Extensible This is easily the most highfalutin interpretation of
“reusable” we’re considering. Unfortunately, there’s no magic makeExtensible
mixin in JavaScript.
As before, we start with a “low-tech” approach that relies on developer
convention. In this case, developers can simply “wrap” existing charts,
configure/add/remove layers, and return a new draw
function that manages the
new chart.
Informal extension of the bar chart (commit) | Informal extension of the chord diagram (commit)
A more structured and repeatable solution will once again require some sort of
framework. This time, we’ve abstracted the definition of a chart comprised of
layers; it’s called
d3.chart
.
The framework includes a verion of Backbone.js‘s
extend
method to support extensibility. Here, we’ll use it to define a
factory for the “fading” bar chart in terms of the factory for the original
bar chart:
// Define `BarChart` base chart
d3.chart("BarChart", {
initialize: function(svg) {
this.layer("bars", this.base.append("g"));
this.layers.bars.on("enter", function() {
// etc...
});
}
});
// Define `FadingBarChart` to extend the functionality of `BarChart`
d3.chart("BarChart").extend("FadingBarChart", {
initialize: function(svg) {
this.layer("bars").on("update:transition", function() {
this.attr("opacity", function(d, i) { return i/32; });
});
}
});
// The usage of both charts is identical
var myBarChart = d3.select("#chart1").chart("BarChart");
var myWackyBarChart = d3.select("#chart2").chart("FadingBarChart");
In an attempt to more seamlessly mesh with the D3.js API, we have foregone
traditional constructors in favor of a d3.selection
-based approach.
Bar chart re-factored to use
d3.chart
(commit)
| Chord diagram re-factored to use
d3.chart
(commit)
Fading bar chart defined in terms of a bar
chart
(commit)
| Colored-label chord diagram re-factored to use
d3.chart
(commit)
You might be thinking, “These concepts are nice and all, but that sure is a lot
of overhead just to call my charts ‘reusable’.” There is one more benefit to
rigorously defining the structure of your charts: you are able to express more
nuanced relationships. Specifically, you can define charts that combine
completely independent charts. Here, we’re using d3.Chart’s Chart#mixin
method to create a hybrid chart from the “Fading Bar Chart” and “Colored-label
Chord Diagram”:
Hybrid chart defined in terms of a bar chart (commit)
Pragmatism in the Quest for Reuse
We covered a lot of ground in this short blog post. As we stepped through higher-order interpretations of the term “reusable”, we developed best practices for structuring code around D3.js. We also identified opportunities to abstract common usage patterns into a software framework. This process necessarily involved a lot of assumptions about how D3.js can be used. What’s been missing from the discussion is whether these assumptions are valid, and whether codifying them such as we have is too limiting. This evaluation is best made by developers using the library, either in the comments of this post or on the D3.js mailing list.
Regardless of how you feel about d3.chart, we hope this post has got you thinking about the various interpretations of the term “reusable” and how you can improve the structure of your data visualizations.