A Bar Chart, Part 2

The previous part of this tutorial covered the construction of a no-frills, static bar chart. This part will showcase some of the dynamic capabilities of D3, including transitions and data joins.

Say that, rather than a simple array of numbers, you want to visualize a time series—a sequence of values sampled at regular time intervals. For example, say you run a website, and want to track how many visitors find your ideas intriguing? A bar chart could show the number of visitors that subscribe to your newsletter in realtime!

Dynamic Data

Now typically, the subscription data would be downloaded to the client via an HTTP request. You can poll the server to refresh the latest data every minute, or use web sockets to stream data incrementally, minimizing latency. To simplify this tutorial and focus on the task of visualization, we’ll construct a synthetic (i.e., fake) dataset by random walk:

 1 var t = 1297110663, // start time (seconds since epoch)
 2     v = 70, // start value (subscribers)
 3     data = d3.range(33).map(next); // starting dataset
 4 
 5 function next() {
 6   return {
 7     time: ++t,
 8     value: v = ~~Math.max(10, Math.min(90, v + 10 * (Math.random() - .5)))
 9   };
10 }

The exact mechanism of the random walk is unimportant, but you should understand the structure of the resulting data. Rather than a number, each data point is an object with time and value attributes:

1 {"time": 1297110663, "value": 56},
2 {"time": 1297110664, "value": 53},
3 {"time": 1297110665, "value": 58},
4 {"time": 1297110666, "value": 58},

Note that the values in the dataset are constrained to the domain [10, 90], which is convenient because it allows a fixed y-scale. This simplifies the implementation, as the old bars will not resize as new data arrives. You can use a dynamic scale, but keep in mind that rescaling old values while introducing new ones makes it harder for the user to perceive changes accurately. Also, you’ll need reference lines! Cushioning your scales to avoid sudden changes, or applying hysteresis to delay changes, is recommended.

If you stream data from the server, you can redraw the bar chart whenever new data becomes available. In this case, we’ll cycle the data every 1.5 seconds:

1 setInterval(function() {
2   data.shift();
3   data.push(next());
4   redraw();
5 }, 1500);

The shift operation removes the first (oldest) element in the array, while the push appends after the last (newest) element. If you have a lot of data, a circular buffer will improve performance; with smaller data, the inefficiency of the shift operation is negligible and can be ignored. The redraw method is a function that you will define; we’ll get to that shortly.

Dynamic Bars

For now, the next step is to construct two scales, based on our knowledge of the dataset and the desired chart size. To fix the maximum bar size to 80×20, construct two linear scales:

 1 var w = 20,
 2     h = 80;
 3 
 4 var x = d3.scale.linear()
 5     .domain([0, 1])
 6     .range([0, w]);
 7 
 8 var y = d3.scale.linear()
 9     .domain([0, 100])
10     .rangeRound([0, h]);

The x-scale is a bit cheeky in that we’ve defined the domain as [0, 1], rather than the full time-domain of the dataset. That’s because we’ll assume (again, for simplicity) that the data is in chronological order and there are no missing data points. As such, we can use the index of the data to derive the x-position; x(i) is identical to w * i. A more robust implementation would update the domain from the time attributes of the dataset whenever the data changes.

The y-scale uses rangeRound rather than range; the only difference is that the output values of the scale are rounded to the nearest integer to avoid antialiasing artifacts. If you prefer, you can instead use SVG’s shape-rendering property. However, antialiasing is nice for smooth intermediate values during transition.

With the scales ready, construct the SVG container for the chart:

1 var chart = d3.select("body")
2   .append("svg:svg")
3     .attr("class", "chart")
4     .attr("width", w * data.length - 1)
5     .attr("height", h);

Add the initial bars:

1 chart.selectAll("rect")
2     .data(data)
3   .enter().append("svg:rect")
4     .attr("x", function(d, i) { return x(i) - .5; })
5     .attr("y", function(d) { return h - y(d.value) - .5; })
6     .attr("width", w)
7     .attr("height", function(d) { return y(d.value); });

In SVG, rects are positioned relative to their top-left corner. For a vertical bar chart (also known as a column chart), the bars should be anchored by their bottom-left corner, so the “y” attribute flips the y-scale. Alternatively, you can use a transform to change the coordinate system. The .5 offset is to avoid antialiasing; the 1-pixel white stroke is centered on the given location, so a half-pixel offset will fill the pixel exactly. If you are not the Martha Stewart type, and don’t care for crisp edges, you may omit this step.

Add the y-axis last, so that it appears on top of the bars:

1 chart.append("svg:line")
2     .attr("x1", 0)
3     .attr("x2", w * data.length)
4     .attr("y1", h - .5)
5     .attr("y2", h - .5)
6     .attr("stroke", "#000");

SVG draws shapes in the order they are specified, so to have the axis appear on top of the bars, the line must exist after the rects in the DOM. It is sometimes convenient to use svg:g elements to group shapes into the desired z-order.

A little bit of CSS will set the bar colors:

1 .chart rect {
2   fill: steelblue;
3   stroke: white;
4 }

The code so far produces a static bar chart:

Now, what about that redraw function?

 1 function redraw() {
 2 
 3   // Update…
 4   chart.selectAll("rect")
 5       .data(data)
 6     .transition()
 7       .duration(1000)
 8       .attr("y", function(d) { return h - y(d.value) - .5; })
 9       .attr("height", function(d) { return y(d.value); });
10 
11 }

Observe how the bars dance happily in response to changing data:

The redraw function is fairly trivial—reselect the rect elements, bind them to the new data, and then start a transition that updates the “y” and “height” attributes. No enter and exit selection is needed! Without a data join, the data are joined to nodes by index. As the length of the data array is fixed, the number of nodes never changes, and thus the enter and exit selections are always empty.

Object Constancy

Yet, the above animation is poor because it lacks object constancy through the transition: it does not convey the changing data accurately. Rather than updating values in-place, the bars should slide to the left, so that each bar corresponds to the same point in time across the transition. Do this using a data join, to bind nodes to data by timestamp rather than index:

 1 function redraw() {
 2 
 3   var rect = chart.selectAll("rect")
 4       .data(data, function(d) { return d.time; });
 5 
 6   // Enter…
 7   rect.enter().insert("svg:rect", "line")
 8       .attr("x", function(d, i) { return x(i) - .5; })
 9       .attr("y", function(d) { return h - y(d.value) - .5; })
10       .attr("width", w)
11       .attr("height", function(d) { return y(d.value); });
12 
13   // Update…
14   rect.transition()
15       .duration(1000)
16       .attr("x", function(d, i) { return x(i) - .5; });
17 
18   // Exit…
19   rect.exit()
20       .remove();
21 
22 }

With the new data join, we can no longer assume that the enter and exit selections are empty; instead, each contains exactly one bar upon redraw, as a new data point arrives and an old data point leaves. (If using real data, don’t assume regularity; multiple bars could enter and exit with each redraw.) So, the update is split to handle enter and exit separately. However, the update transition is actually simplified: we only transition the “x” attribute, as the “y” and “height” attributes do not change!

Note that operations on the entering or exiting selection do not affect the updating selection. Thus, the transition defined on rect on L14 above includes only the updating bars, not any of the entering bars that are appended on L7.

The bar chart now slides as desired, but the enter and exit are a bit clunky:

The above implementation enters new bars immediately, while old bars are removed immediately. A common alternative is to fade, but in this case the most intuitive transition is for new bars to enter from the right, and old bars to exit to the left. Enter and exit can have transitions, too, which you can use to offset the index i to the x-scale:

 1 function redraw() {
 2 
 3   var rect = chart.selectAll("rect")
 4       .data(data, function(d) { return d.time; });
 5 
 6   rect.enter().insert("svg:rect", "line")
 7       .attr("x", function(d, i) { return x(i + 1) - .5; })
 8       .attr("y", function(d) { return h - y(d.value) - .5; })
 9       .attr("width", w)
10       .attr("height", function(d) { return y(d.value); })
11     .transition()
12       .duration(1000)
13       .attr("x", function(d, i) { return x(i) - .5; });
14 
15   rect.transition()
16       .duration(1000)
17       .attr("x", function(d, i) { return x(i) - .5; });
18 
19   rect.exit().transition()
20       .duration(1000)
21       .attr("x", function(d, i) { return x(i - 1) - .5; })
22       .remove();
23 
24 }

Note that the enter transition is staged; we initialize the values, and then start the transition. This is not needed with the exit transition because we’ll transition from the current state of the bar, regardless of value.

Et voilà!

This tutorial covered several core concepts in D3, including transitions, enter and exit, and data joins. However, this only scratches the surface! Explore the examples gallery to see more advanced techniques with D3.

Copyright © 2011 Mike Bostock
Fork me on GitHub