A Framework for Creating Reusable Charts with d3.js

We’ve been working more and more with d3.js here at Bocoup with clients and as part of the Miso Project. As much as we love the library and are grateful that it exists, we’ve also run head-on into architectural issues with some of the patterns commonly used when creating charts. We love to write code that we can reuse, extend and release, so the need arose to think at a higher level about how we work with d3. You may want to stop here and read the fantastic article by Mike Pennisi about the thinking behind d3.chart, but I will try to summarize in one paragraph:

Think about jQuery for a moment. It’s incredibly powerful – it offers direct access to the DOM with unprecedented granularity. It is the Swiss army knife of the web and has enabled us to do crazy and amazing things in our browsers. With all of this power comes great responsibility – specifically your responsibility not to write all your JavaScript code in one 20,000-line file. Who hasn’t done that though?! That’s where the need for structure comes into play. We are all grateful for the Backbone.js and Ember.js and others for adding some structure to our otherwise soon-to-be-out-of-control code. This is what d3.chart is here to do.

With d3.chart, we’re trying to provide a way to structure d3 code to promote reusability, configurability, modularity, extensibility, and composability. d3.chart is not a charting library – there are no charts out of the box. It is a more formal structure for authoring charts. This structure enables more seamless chart re-use, and we hope to foster an open collection comprised of your amazing work.

And so, without further delay, let’s dive into d3.chart. At any point, feel free to access the code on Github. It is exhaustively documented on the wiki, as well as on the miso project website.

Principles

While writing d3.chart, we knew that we were entering an ecosystem with an existing set of patterns and rules. We could have written an object oriented framework like Backbone.js, but we wanted to follow the set of common practices:

  • Nothing is new under the sun – Nothing in d3 is instantiated using the new keyword, so your d3.chart won’t be either.
  • Selections, selections, selections – Everything in d3 is driven off of selections! We wanted to make sure that d3.chart and its API fit right into that workflow.
  • Un-chain my heart – Chaining is probably the most common pattern in d3. Most examples we see out there involve long declarative chains. It’s a blessing and a curse – while d3.chart aims to break apart some of that chaining, it recognizes that method chaining is familiar.
  • It’s the circle of life – If you’ve worked with d3 long enough, you are familiar with update, enter, exit and their transitions. We didn’t want to abstract that away and offer our own language for the same set of behaviors. Instead, you access those selections by binding to ‘lifecycle events’. This improves code structure while still working within the familiar d3 functionality.

We embraced these principles in order to leverage the knowledge and practice of the d3 ecosystem. We wanted to minimize the amount of framework-specific knowledge, so that users can easily participate in d3.chart’s growth by building on their familiarity with d3.

What Does It Give You

Let’s start by seeing what it’s like to work with a fully-defined d3.chart. Throughout the post, we will build up what this chart will look like.

Let’s say your chart is rendering some circles along the x-axis in accordance with your data. Your data is just an array of numbers and the only thing the data drives is the x position of the circles. Otherwise, the circles are red and of a fixed radius.

Ideally, you want to instantiate your chart like so:

01.instantiate.js

var data = [1,4,6,9,12,13,30];
var circleChart = d3.select("div#vis")
  .append("svg")
  .chart("CircleChart")
  .width(200)
  .height(100)
  .radius(3);

// render it with some data
circleChart.draw(data);

Short and sweet! It took about 4 lines to define our chart. Note that it is anchored to a selection, and it offers a basic configuration API (that we are going to work through together below). Last but not least, we tell it to draw our data and pass our array in. Conveniently, if we need to draw new data in the same chart we can just call draw again with a new/updated set of data, without needing to redefine any of our chart’s other properties.

So how do we make the Circles chart type? Well, we start by defining it.

Defining a chart

Let’s say that you have a chart you wish to define. The basic assumption of d3.chart is that you may want to instantiate many of them, reuse them across projects, or (even better!) release them into the open source wilderness. Even if that is not the case, d3.chart offers you a way to separate your chart definition code from the actual use of the chart, making your code cleaner and more modular.

Creating a new chart type in d3.chart happens as follows:

02.define.js

d3.chart("ChartTypeName", {

});

That’s it. Defining the chart type above will allow us to call .chart("ChartTypeName") on any d3 selection (besides lifecycle selections) to initialize the chart. Granted, the above chart doesn’t do very much, so we probably want to define some custom behavior to go along with it.

Adding Initialization

The initialize method is a good place to define things like scales or other elements that will be used throughout the chart. It’s also a good place to establish some default values for various properties of the chart.

03.initialize.js

d3.chart("CircleChart", {
  initialize : function() {
    // create a scale that we will use to position our circles
    // along the xAxis.
    this.xScale = d3.scale.linear();

    // setup some defaults for the height/width/radius variables
    this._width = this._width || this.base.attr("width") || 200;
    this._height = this._height || this.base.attr("height") || 100;
    this._radius = this._radius || 5;
  }
});

Adding Configuration API

Any of the methods defined in the second object argument will be added to the chart prototype, which is how we can define the configuration API our chart requires. Note that we are building our configuration functions to behave in accordance with d3’s common pattern of reusing the same function for getters/setters.

04.configuration.js

d3.chart("CircleChart", {
  initialize : function() {
    this.xScale = d3.scale.linear();

    // setup some defaults
    this._width = this._width || this.base.attr("width") || 200;
    this._height = this._height || this.base.attr("height") || 100;
    this._radius = this._radius || 5;
  },

  width: function(newWidth) {
    if (arguments.length === 0) {
        return this._width;
    }
    this._width = newWidth;

    // if the width changed, update the width of the
    // base container.
    this.base.attr("width", this._width);

    // if the width changed, we need to update the range of our
    // x-scale
    this.xScale.range([this.radius(), this._width - this.radius()]);
    return this;
  },

  height: function(newHeight) {
    if (arguments.length === 0) {
        return this._height;
    }
    this._height = newHeight;
    // If the height changed, we need to update the height
    // of our base container
    this.base.attr("height", this._height);
    return this;
  },

  radius: function(newRadius) {
    if (arguments.length === 0) {
        return this._radius;
    }
    this._radius = newRadius;
    return this;
  }
});

Layers

Most of the rendered elements in a d3 chart are inevitably tied to the data. More often than not, your charts will have multiple elements that are driven by the same data source, but with different results. To support this notion, a core part of d3.chart is the ability to create a layer.

A layer is a set of visual elements that are drawn to your data. A layer is meant to manage the data binding, drawing and lifecycle events for its elements.

In our example, our one and only layer is that of the circles, so we probably want to add in our initialize method:

05.layer.js

d3.chart("CircleChart", {
  initialize : function() {
    this.xScale = d3.scale.linear();

    // setup some defaults
    this._width = this._width || this.base.attr("width") || 200;
    this._height = this._height || this.base.attr("height") || 100;
    this._radius = this._radius || 5;

    // create a container in which the circles will live.
    var circleLayerBase = this.base.append("g")
      .classed("circles", true);

    // add our circle layer
    this.layer("circles", circleLayerBase, {

    });
  }
  // configuration functions omitted for brevity
});

The layer api takes three important arguments:

  • name – The name of the layer. This will allow it to be retrieved from outside the chart definition or inside of other methods of the chart. That can be done by calling myChart.layer(“circles”) on your chart instance or this.layer(“circles”) inside a chart method.
  • selection – The selection used as the base for the layer. Most of the time this will be a group element that is positioned and sized accordingly with its place on the screen.
  • options – The options object. The options object deserves its own entire heading:

Layer Options

If you think about the common d3 data binding pattern, you’ll recognize the following structure:

06.commonpattern.js

circles.selectAll("circle")
  .data(data)
  .enter()
    .append("circle")
    .classed("circle", true)
    .style("fill", "red")
    .attr("r", r)
    .attr("cy", height/2)
    .attr("cx", function(d) {
      return xScale(d);
    });
    // other lifecycle events here

More specifically:

  1. You prepare to bind your data by selecting the elements you will bind data to
  2. You bind your data
  3. You append/insert the actual elements that are expected
  4. You define what happens on lifecycle events such as enter, exit etc.

The layer options split up this behavior into the following pattern:

  1. dataBind method – prepares the elements that will be bound, and binds the data to them. This is also the only layer method that receives the data, allowing you to interact with it, update any chart scales that may be data dependent and so on.

  2. insert method – the method that creates the actual data-bound elements and sets any attributes that don’t have to do with the data. The reason we don’t do that here is because we want to account for our data potentially changing and thus want to delegate setting any data-driven attributes to the lifecycle events.

  3. lifecycle events object – a collection of any of the lifecycle methods that usually set the attributes relating to the data. You will usually define at least an enter event, even if your chart is static. These methods can be: update, enter, merge (the combination of entered and existing nodes), exit, update:transition, enter:transition, merge:transition and exit:transition.

Note that the context of each of these methods is the appropriate selection for what you’d normally chain off of in d3!

07.fulllayer.js

d3.chart("CircleChart", {
  initialize : function() {
    this.xScale = d3.scale.linear();

    // setup some defaults
    this._width = this._width || this.base.attr("width") || 200;
    this._height = this._height || this.base.attr("height") || 100;
    this._radius = this._radius || 5;

    // create a container in which the circles will live.
    var circleLayerBase = this.base.append("g")
      .classed("circles", true);

    // add our circle layer
    this.layer("circles", circleLayerBase, {

      // prepare your data for data binding, and return
      // the selection+data call
      dataBind: function(data) {
        var chart = this.chart();

        // assuming our data is sorted, set the domain of the
        // scale we're working with.
        chart.xScale.domain([data[0], data[data.length-1]]);

        return this.selectAll("circle")
          .data(data);
      },

      // append the actual expected elements and set the
      // appropriate attributes that don't have to do with
      // data!
      insert: function() {
        var chart = this.chart();

        // append circles, set their radius to our fixed
        // chart radius, and set the height to the middle
        // of the chart.
        return this.append("circle")
          .attr("r", chart.radius())
          .attr("cy", chart.height()/2);
      },
      events: {
        // define what happens on these lifecycle events.
        // in our case, set the cx property of each circle
        // to the correct position based on our scale.
        enter: function() {
          var chart = this.chart();
            return this.attr("cx", function(d) {
                return chart.xScale(d)
            });
        }
      }
    });
  }
  // configuration functions omitted for brevity
});

Chart Time

Your chart is now ready to be used. We’ve skipped a few parts, but you can see the final version in the jsFiddle above.

To Infinity and Beyond – Extension

Obviously our circle chart is a bit lacking. It would be nice to add various functionality to it, or perhaps combine it with another chart that is dependent on the same data. To do that, there are two methods available on a d3.chart:

  • extend – allows you to create a new chart type based on your definition of a previous chart type.
  • mixin – allows you to mix in a chart into another chart, assuming the two live in the same high-level container.

For example, if we wanted to create hover-able circles, we could do that like so:

08.extended.js

d3.chart("CircleChart").extend("HoverableCircleChart", {
  initialize: function() {

    this._highlightColor = 'blue';

    // add a new behavior on the `enter` lifecycle event.
    // note that this doesn't overwrite the previous `enter`
    // behavior! It just adds additional behavior to it.
    this.layer("circles").on("enter", function() {

      var chart = this.chart();

      // add a mouseover listener to our circles
      // and change their color and broadcast a chart
      // brush event to any listeners.
      this.on("mouseover", function() {
        var el = d3.select(this);
        el.style("fill", chart.highlightColor());
        chart.trigger("brush", this);

      }).on("mouseout", function() {
        var el = d3.select(this);
        el.style("fill", chart.color());
      });
    });
  },
  highlightColor: function(color) {
      if (arguments.length === 0) {
          return this._highlightColor;
      }
      this._highlightColor = color;
      return this;
  }
});

Note that we defined a new chart type: HoverableCircleChart that just amended the functionality of the existing circles layer by adding mouse listeners to it. We’ve also added another method to let us customize the highlight color. We can now instantiate the chart like so:

09.extended-chart-instantiation.js

// create an instance of the chart on a d3 selection
var circleChart = d3.select("div#vis")
  .append("svg")
  .chart("HoverableCircleChart")
  .width(400)
  .height(50)
  .radius(5)
  .color('orange')
  .highlightColor('red');

You can read more about d3.chart extension and mixins on the wiki.

Chart Coordination

One last thing we want to point out: as you may have noticed, we triggered a brush event on the chart when a circle was being moused over.

d3.chart offers a really simple event API that allows you to bind events to a chart instance and trigger events on the chart instance. Note that you can’t trigger events or bind to events on random selections – just the chart. This lets you coordinate charts that should otherwise be left decoupled and drive various other UI elements as needed.

Next Steps

We are really excited to release d3.chart. We’ve been working on it for over half a year, and it is the best iteration of this library yet. We’ve been using it internally for several months and hope it will be as useful to you as it has been to us.

We would love:

  • Your feedback in the form of issues, pull requests and other forms of communication.
  • Your reusable charts! – Considering how easy it is to make charts we can reuse now, we’d love to see more and more charts released into the wild. You can refer to our contribution guide for instructions and see some of the existing charts that have been made.

Happy Visualizing!