Exploring Reusability with D3.js

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”):

  1. Repeatable – A chart can be instantiated more than once in a given context, each visualizing a different data set.
  2. Modifiable – Chart definitions (source code) can be easily re-factored by other developers to suit their needs.
  3. Configurable – Chart behavior can be altered at run-time through a consistent API.
  4. 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 updatefunction 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 the this 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 theredraw 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 calld3.layer. It exposes an API based on the jQuery event APIfirst 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 withd3.Layer(commit) | Chord diagram withd3.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 withd3.layer(commit) | Colored-label chord diagram configured withd3.layer(commit)

Extensible This is easily the most highfalutin interpretation of “reusable” we’re considering. Unfortunately, there’s no magic makeExtensiblemixin 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 calledd3.chart.

The framework includes a verion of Backbone.js‘sextend 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 used3.chart(commit) | Chord diagram re-factored to used3.chart(commit)

Fading bar chart defined in terms of a bar chart(commit) | Colored-label chord diagram re-factored to used3.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#mixinmethod 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 onthe D3.js mailing list.

Regardless of how you feel aboutd3.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.

Comments

Contact Us

We'd love to hear from you. Get in touch!

Boston

201 South Street, Boston, MA 02111

New York

315 Church St, New York, NY 10013

Phone & Email

(617)379-2752 hello@bocoup.com