Starting out with d3.chart

Writing reusable D3 code can be tough. The community shines at providing one-off examples of really cool charts, but it's hard to find a conventional approach to building out a small library of reusable components. d3.chart is a library from Bocoup that aims to simplify the development of reusable D3 charts.

If you're not familiar with d3.chart at all, this walkthrough on Github is a great place to start. You should be able to go through it without too much trouble, but as always, the devil is in the details. In this post I'll walk you through building a slightly more complex chart than what's in the walkthrough. Hopefully it will save you some time as you learn the library.

Now, it wouldn't make much sense to create an isolated chart with d3.chart; after all, the whole point is composability. So let's start out by making a base chart, which we can then extend to make other visualizations.

The base chart

Most charts have a lot in common, like dealing with width and height or re-rendering on resize events. To share this functionality across all our charts, we'll make a base chart superclass. @iros, one of the library's authors, regularly updates a base chart on Github, which I use here as a starting point. (There's a lot going on in that code - don't worry about it for now.)

We start by defining our base chart:

d3.chart("BaseChart", {
  // config options go here
});

Now let's create some properties. For this example, we'll add width, height, and margin properties to our base chart, since these are pretty common across all charts. In keeping with D3's margin conventions, we want all code in our extended charts to be able to ignore margins. This means the width and height properties will refer to the inner dimensions of our base chart. Our extended charts can then use these values for scales and other similar objects, without any knowledge of what the base chart's margins are. Note that this is also how the box model and CSS work in the browser: the width of an element corresponds to the inner width.

We'll create the properties in the initialize key of our chart's configuration:

d3.chart("BaseChart", {
  initialize: function () {
    // set up properties with reasonable defaults
    this._margin = { top: 20, right: 20, bottom: 20, left: 20 };
    this._width = this.base.attr("width")
      ? this.base.attr("width") - this._margin.left - this._margin.right
      : 200;
    this._height = this.base.attr("height")
      ? this.base.attr("height") - this._margin.top - this._margin.bottom
      : 200;
  },
});

As indicated in the docs, this.base refers to the D3 selection our chart will operate on - typically an <svg> element. If the width and height have already been set on this selection, we'll take those values for the dimensions of our charts; otherwise, we'll choose default values of 200.

Next, we'll append a g element to the base and translate it by our margins - again, by convention:

d3.chart('BaseChart', {

  initialize: function() {
    ...

    // add a marginalized container
    this.base.append('g')
      .attr('transform', 'translate(' + this._margin.left + ',' + this._margin.top + ')');
  }

});

We'll render our extended charts to this element. Since the charts will reference the inner dimensions, they will implicitly recognize our base chart's margins.

Getter/setters

To make the dimensions accessible, we can write getter/setters. Let's start with width:

d3.chart('BaseChart', {
  initialize: function() {...},

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

    // only if the width actually changed,
    if (this._width !== newWidth) {

      var oldWidth = this._width;

      this._width = newWidth;

      // set higher container's width
      this.base.attr('width', this._width + this._margin.left + this._margin.right);

      // trigger a change event
      this.trigger('change:width', newWidth, oldWidth);
    }

    // always return the chart, for chaining magic.
    return this;
  }
});

Notice that we trigger an event when the width changes, sending the new and old widths as event parameters. This allows our extended charts to, for example, render custom transitions during the change event.

The height method is nearly identical, and for now I've just made the margin method a barebones getter/setter. You can see them both in the links at the end of this post.

There's one more item on the agenda before we can leave our base chart. In the initialize method, we provided some defaults for width and height, in case they were not defined on the parent container element. If in fact we do use those defaults, our base chart should go ahead and set the container's actual dimensions in the DOM. The code is the same as what's already in our getter/setters: this.base.attr('width', this._width + this._margin.left + this._margin.right).

Since duplication is evil, let's first wrap this up in a method instead of copying it, and then replace the code in the width method with a call to this new method:

d3.chart('BaseChart', {
  initialize: function() {...},

  updateContainerWidth: function() { this.base.attr('width', this._width + this._margin.left + this._margin.right); },

  width: function(newWidth) {
    ...
    // set higher container's width
    this.updateContainerWidth();
    ...
  },

})

We do the same for height. Now, let's add code in the initialize method to make sure the parent container's dimensions are set:

d3.chart('BaseChart', {
  initialize: function() {

    // setup some reasonable defaults
    this._margin = {top: 20, right: 20, bottom: 20, left: 20};
    this._width  = this.base.attr('width') ? this.base.attr('width') - this._margin.left - this._margin.right : 200;
    this._height = this.base.attr('height') ? this.base.attr('height') - this._margin.top - this._margin.bottom : 200;

    // make sure container height and width are set
    this.updateContainerWidth();
    this.updateContainerHeight();

    // add a marginalized container
    this.base.append('g').attr('transform', 'translate(' + this._margin.left + ',' + this._margin.top + ')');

  },

  ...
})

We now have a base chart that handles dimensions and margins - we're ready to extend it!

A bar chart

We'll make a simple bar chart that extends our base chart:

d3.chart("BaseChart").extend("BarChart", {
  // config options
});

Just like in the base chart, we'll put variables we plan on using throughout the chart in the initialize method, as properties. In this chart we'll need x and y scales, and a color scale.

d3.chart("BaseChart").extend("BarChart", {
  initialize: function () {
    this.xScale = d3.scale.ordinal().rangeRoundBands([0, this.width()], 0.1);
    this.yScale = d3.scale.linear().range([this.height(), 0]);
    this.color = d3.scale.category10();
  },
});

Notice that our extended bar chart has access to the width and height methods in the base chart (e.g. this.height()), just as we'd expect.

Next, let's think about the events that our base chart triggers. How will a changing width or height affect our bar chart? Looking at the code we just added, we can see that the x and y scales depend on the chart's dimensions. This means that when the width (height) changes, we need to update the range of the x (y) scale.

There are some methods provided by d3.chart for data-driven operations, but since this code doesn't depend on the actual data our chart will be displaying, we can add it to the initialize method:

d3.chart("BaseChart").extend("BarChart", {
  initialize: function () {
    var chart = this;

    chart.xScale = d3.scale.ordinal().rangeRoundBands([0, chart.width()], 0.1);
    chart.yScale = d3.scale.linear().range([chart.height(), 0]);
    chart.color = d3.scale.category10();

    // update scales on dimension changes
    chart.on("change:width", function (newWidth) {
      chart.xScale.rangeRoundBands([0, newWidth], 0.1);
    });
    chart.on("change:height", function (newHeight) {
      chart.yScale.range([newHeight, 0]);
    });
  },
});

Transform

Layers in d3.chart are where we actually create our data-driven elements - in our case, the bars. But before we get there, we need to make sure our data is in the correct format. To find out if we need to first transform our data, lets ask ourselves the following:

  1. Does our data need to be massaged before being bound to our elements?
  2. Are there any one-time data-driven operations that need to be performed on a given input data set for the chart as a whole (i.e. data-driven operations that don't belong in any particular layer)?

If you have operations that fall into either of these categories, you should put them in the transform method. This method takes the raw data that the user passes in, transforms it, and then hands the transformed data off to the rest of your chart.

So what about us - do we need to perform any such operations? To answer the first question, let's look at a sample of our raw data:

var data = [
  {name: "A", value: 10},
  {name: "B", value: 3},
  ...
]:

In our case, the value data will be bound to our bars. There's no transformation we need to perform, since we'll just pass the data through our scales to get the appropriate heights. So, we don't really need to massage the data for our layers.

But what about the second question? Our scales do change based on the extent of our data. Because the scales are characteristics of the chart as a whole, rather than just the bars layer (consider sharing the scales with an axis layer), let's add code to update our scales in the transform method:

d3.chart('BaseChart').extend('BarChart', {

  initialize: function() {..},

  transform: function(data) {
    var chart = this;

    // update the scales
    chart.xScale.domain(data.map(function(d) { return d.name; }));
    chart.yScale.domain(d3.extent(data, function(d) {return d.value;}));

    return data;
  }

});

Note that we have to return data, even though we didn't actually do anything to it, because d3.chart hands off the return value of this method to the rest of our chart.

Layers

Now we're ready to add a layer, where we will actually render the bars. We add the layer in the initialize method. Layers take a name, a D3 selection, and an options object in their definition (docs):

d3.chart('BaseChart').extend('BarChart', {

  initialize: function() {
    ...

    chart.layer('bars', chart.base.select('g').append('g').classed('bars',true), {
      // layer options
    });
  },

  transform: function(data) {...}

});

Our bars layer gets its own g element. This element is a child of the base chart's g element, so that we can ignore margins while coding our bar chart.

Now, let's flesh out the layer. Typically in D3, we use the following workflow:

  1. Bind data to elements
  2. Insert those elements to the DOM
  3. Transform those elements when the data changes

d3.chart preserves this workflow, but forces us to keep these concerns separate. We'll start out by binding our data within the dataBind method:

chart.layer("bars", chart.base.select("g").append("g").classed("bars", true), {
  dataBind: function (data) {
    return this.selectAll(".bar").data(data);
  },
});

This gives us a data join to work with. Next, we use the insert method to insert the elements into the DOM:

chart.layer('bars', chart.base.select('g').append('g').classed('bars',true), {
  dataBind: function(data) {...},

  insert: function() {
    return this.append('rect')
      .attr('class', 'bar');
  }

});

In both of these methods we return the selections, so our subsequent handlers can use them.

Finally, now that we have the elements in the DOM, we can manipulate them based on our data joins. D3 groups joins into enter, exit, update and merge subselections. d3.chart gives us access to each of these selections in an events hash within our layer. Within d3.chart, these events are referred to as lifecycle events.

Let's code the enter lifecycle event. When new data enters our document, we'll size the bars according to their values:

chart.layer('bars', chart.base.select('g').append('g').classed('bars',true), {
  dataBind: function(data) {...},
  insert: function() {...},

  events: {
    'enter': function() {
      var chart = this.chart();

      this.attr('x', function(d) { return chart.xScale(d.name); })
        .attr('y', function(d) { return chart.yScale(d3.max([0, d.value])); })
        .attr('fill', function(d) {return chart.color(d.name);})
        .attr('width', chart.xScale.rangeBand())
        .attr('height', function(d) { return Math.abs(chart.yScale(d.value) - chart.yScale(0)); });
    }
  }

});

At this point you may be wondering why we appended the elements in the insert method, rather than in the enter selection, like we're used to seeing in standard D3 code:

d3.select("body")
  .selectAll("p")
  .data([4, 8, 15, 16, 23, 42])
  .enter()
  .append("p")
  .text(function (d) {
    return "I’m number " + d + "!";
  });

The reason we append elements in the insert method and not in the enter selection is for extensibility. d3.chart allows others to add code to our existing charts. One of they ways they can do this is by writing their own handlers to our chart's enter event. Because of this, we want to ensure that when they write their handler, they are referencing the same D3 selection; in essence, that this refers to the same thing in their code as it does in ours. In D3, when we append elements, we actually change the selection.

Thus, by keeping our append operations in insert, and doing all other data-driven transformations on the enter selection within the events hash, this will always refer to the enter selection, for any developer who works on our chart.

At this point, we have a working bar chart! Let's give it a spin.

var data = [
  { name: "A", value: 4 },
  { name: "B", value: -36 },
  { name: "C", value: 19 },
  { name: "D", value: -2 },
  { name: "E", value: 6 },
];

var barChart = d3.select("#bar-chart").append("svg").chart("BarChart");

barChart.draw(data);

Cool! Inspect the chart to see how the margins and layers work.

Let's try out our width accessor:

var barChart = d3
  .select("#wide-bar-chart")
  .append("svg")
  .chart("BarChart")
  .width(500);

Transitions

Working with transitions is quite easy, because transitions are lifecycle events just like the enter event. You can access a transition event by adding :transition to any of the four standard lifecycle events.

Let's make our bars grow when they enter. First, we'll change the enter lifecycle event, because when they are first drawn, the bars should start out on the x-axis with a height of 0:

  'enter': function() {
    var chart = this.chart();

    this.attr('x', function(d) { return chart.xScale(d.name); })
-     .attr('y', function(d) { return chart.yScale(d3.max([0, d.value])); })
+     .attr('y', function(d) { return chart.yScale(0); })
      .attr('fill', function(d) {return chart.color(d.name);})
      .attr('width', chart.xScale.rangeBand())
-     .attr('height', function(d) { return Math.abs(chart.yScale(d.value) - chart.yScale(0)); });
+     .attr('height', 0);
  },

Now, we'll make them grow in the enter:transition event. We need to animate both the y value and the bar's height:

  'enter:transition': function() {
    var chart = this.chart();

    this.duration(chart.duration)
      .attr('y', function(d) { return chart.yScale(d3.max([0, d.value])); })
      .attr('height', function(d) { return Math.abs(chart.yScale(d.value) - chart.yScale(0)); });
  }

All set! Let's try it out.

var barChart = d3
  .select("#animated-bar-chart")
  .append("svg")
  .chart("BarChart")
  .width(500);

Click to run

Conclusion

We've seen that d3.chart helps us build robust, reusable code. We were able to abstract common traits like widths and margins into our base chart, and extend it into a bar chart that's wide open for customization.

d3.chart also helps us separate our D3 code into logical chunks - but what's really cool is how this aspect carries over as we (or even other developers) extend our charts. For example, say we wanted to use the bar chart from this post, but we needed bars with negative values to be colored red. When using charting libraries, you'll often run into situations like this: you're able to work the configuration options 95% of the way, but you always seem to fall one option short of getting the exact chart you need.

Fortunately, with d3.chart, this isn't an issue - just add the code yourself:

d3.chart("AnimatedBarChart").extend("ProfitLossBarChart", {
  initialize: function () {
    this.layer("bars").on("enter", function () {
      this.style("fill", function (d, i) {
        return d.value < 0 ? "red" : "blue";
      });
    });
  },
});

Suffice it to say, d3.chart provides a very good solution to the perennial problem of chart configuration. I encourage you to try it out, and remember to show off your work on d3.chart's website!

December 18, 2013


Written by

Sam Selikoff