Mike Bostock

Thinking with Joins

Say you’re making a basic scatterplot using D3, and you need to create some SVG circle elements to visualize your data. You may be surprised to discover that D3 has no primitive for creating multiple DOM elements. WAT?

Sure, there’s the append method, which you can use to create a single element:

svg.append("circle")
    .attr("cx", d.x)
    .attr("cy", d.y)
    .attr("r", 2.5);

But that’s just a single circle, and you want many circles: one for each data point. Before you bust out a for loop and brute-force it, consider this mystifying sequence from one of D3’s examples:

svg.selectAll("circle")
    .data(data)
  .enter().append("circle")
    .attr("cx", function(d) { return d.x; })
    .attr("cy", function(d) { return d.y; })
    .attr("r", 2.5);

This code does exactly what you need—it creates a circle element for each data point, using the x and y data properties for positioning. But what’s with the selectAll("circle")? Why do you have to select elements that don’t exist in order to create new ones? WAT.

Here’s the deal: instead of telling D3 how to do something, tell D3 what you want. In this case, you want the circle elements to correspond to data: you want one circle per datum. Instead of instructing D3 to create circles, then, tell D3 that the selection "circle" should correspond to data—and describe how to get there. This concept is called the data-join:

Thinking with joins reveals the mystery behind the sequence:

  1. The selectAll("circle") returns the empty selection, since the SVG container element (svg) is empty. No magic here.

  2. The empty selection is joined to data: data(data). The data method binds data to elements, producing three virtual selections: enter, update and exit. The enter selection contains placeholders for any missing elements. The update selection contains existing elements, bound to data. Any remaining elements end up in the exit selection for removal.

  3. Since the selection was empty, all data ends up as placeholder nodes in enter().

  4. The missing elements are added to the SVG container by append("circle").

So that’s it. You wanted the selection "circle" to correspond to data, and you described how to create the missing elements.

But why all the trouble? Why not have a primitive to create multiple elements? The beauty of the data-join is that it generalizes. The above code only handles the enter selection. That’s sufficient for static visualizations, but you can extend it to support dynamic visualizations with only minor modifications for update and exit. And that means you can visualize realtime data, allow interactive exploration, and transition smoothly between datasets!

Here’s an example of handling all three states:

var circle = svg.selectAll("circle")
    .data(data);

circle.enter().append("circle")
    .attr("r", 2.5);

circle
    .attr("cx", function(d) { return d.x; })
    .attr("cy", function(d) { return d.y; });

circle.exit().remove();

If we run this code repeatedly, it recomputes the data-join each time. If the new dataset is smaller than the old one, the surplus elements end up in the exit selection and get removed. If the new dataset is larger, the surplus data ends up in the enter selection and new nodes are added. If the new dataset is exactly the same size, then all the elements are simply updated with new positions, and no elements are added or removed.

Thinking with joins also means your code is more declarative: you handle these three states with no branching (if) and no iteration (for), simply by describing how elements should correspond to data. If a given enter, update or exit selection happens to be empty, the corresponding chunk of code is a no-op with minimal overhead.

Joins also let you target operations to specific states, if needed. For example, you can set constant attributes (such as the circle’s radius, defined by the "r" attribute) on enter rather than update. By reselecting elements and minimizing DOM changes, you vastly improve rendering performance! Similarly, you can target animated transitions to specific states. For example, for entering circles to expand-in:

circle.enter().append("circle")
    .attr("r", 0)
  .transition()
    .attr("r", 2.5);

Likewise, to shrink-out:

circle.exit().transition()
    .attr("r", 0)
    .remove();

Now you’re thinking with joins!

Comments or questions? Discuss on HN.

gipoco.com is neither affiliated with the authors of this page nor responsible for its contents. This is a safe-cache copy of the original web site.