D3.js Step by Step: Adding a Legend
Providing context while making use of white space
UPDATE (July 18, 2016): The code and API links in these tutorials have been updated to target D3 v4, which was a complete rewrite. The D3 wiki contains a breakdown of the changes from v3.
TL;DR
This post is part of a series that explores some key concepts in D3.js by building up an example, step by step, from a bare-bones pie chart to an interactive, animated donut chart that loads external data. For the enough-with-the-jibber-jabber-show-me-the-code types out there, here's a breakdown of the steps we'll be covering:
- Step 0: Intro
- Step 1: A Basic Pie Chart (Code | Demo)
- Step 2: A Basic Donut Chart (Code | Demo)
- Step 3: Adding a Legend (Code | Demo) ← You Are Here
- Step 4: Loading External Data (Code | Demo)
- Step 5: Adding Tooltips (Code | Demo)
- Step 6: Animating Interactivity (Code | Demo)
NOTE: Because we're building things up step by step, the source code contains NEW
, UPDATED
and REMOVED
comments to annotate the lines that have been added, altered or deleted relative to the previous step.
So we have a swanky donut chart, but how are we supposed to know what each segment represents? It's time to add a legend , and we may as well make use of the white space we created when we switched to a donut:
If we were to inspect the markup, we would see something like this:
<g class="legend" transform="translate(-36,-44)">
<rect width="18" height="18" style="fill: rgb(57, 59, 121); stroke: rgb(57, 59, 121);"></rect>
<text x="22" y="14">Abulia</text>
</g>
<g class="legend" transform="translate(-36,-22)">
<rect width="18" height="18" style="fill: rgb(82, 84, 163); stroke: rgb(82, 84, 163);"></rect>
<text x="22" y="14">Betelgeuse</text>
</g>
<g class="legend" transform="translate(-36,0)">
<rect width="18" height="18" style="fill: rgb(107, 110, 207); stroke: rgb(107, 110, 207);"></rect>
<text x="22" y="14">Cantaloupe</text>
</g>
<g class="legend" transform="translate(-36,22)">
<rect width="18" height="18" style="fill: rgb(156, 158, 222); stroke: rgb(156, 158, 222);"></rect>
<text x="22" y="14">Dijkstra</text>
</g>
These g
elements come after the path
elements that we created in Step 1. The coloured squares and text labels are defined by rect
and text
elements, respectively. This seems straightforward enough. The only tricky bit is the amount by which each g
has been translated, but we'll get to that soon enough.
Before we proceed any further, an important thing to note is that we've gotten this far without employing any CSS at all . (You may have noticed the normalize.css
file in the full code, but that's just there to do things like strip the margin from the body; it hasn't directly affected our example.) We're going to start this step by defining our first styles:
<style>
.legend {
font-size: 12px;
}
rect {
stroke-width: 2;
}
</style>:
This isn't even strictly necessary but it will help make our legend a little prettier. If you've ever done any CSS before, the first declaration should be familiar. The second might look a little weird, although you will recognize rect
from the markup above. Setting stroke-width
to 2 means that it will have a 2px-wide border, which will come in handy in Step 6.
Next we're going to define a couple of size variables so we don't have to hard code any numbers:
var legendRectSize = 18;
var legendSpacing = 4;
legendRectSize
defines the size of the coloured squares you can see above. legendSpacing
, not surprisingly, will be used to provide spacing. Now for the meat of it:
var legend = svg.selectAll('.legend')
.data(color.domain())
.enter()
.append('g')
.attr('class', 'legend')
.attr('transform', function(d, i) {
var height = legendRectSize + legendSpacing;
var offset = height * color.domain().length / 2;
var horz = -2 * legendRectSize;
var vert = i * height - offset;
return 'translate(' + horz + ',' + vert + ')';
});
This should look rather familiar. Once again we see the .selectAll(el).data(data).enter().append(el)
pattern. And once again, let's take it line by line:
- We begin by selecting elements with class legend, which is arbitrarily—albeit appropriately—named.
- We call
data()
with the domain of ourcolor
variable. You may recall that in creating thepath
elements, we defined thefill
attribute usingcolor(d.data.label)
. Socolor.domain()
actually refers to an array of labels from our dataset. enter()
creates placeholders.- We replace our placeholders with
g
elements. - Each
g
is given a legend class. - This last bit isn't something we need to dwell on. I'll go over it in detail but it's just figuring out how to center the legend. We specify how to position each element as follows (keeping in mind that we're operating relative to the center of the SVG rather than the top left corner):
- The height of the element is the height of the coloured square plus the spacing.
- The vertical offset of the entire legend is calculated using the height of a single element and half the total number of elements.
- The horizontal position—that is, the left edge of the element—is shifted left of center by a (somewhat arbitrary) distance equal to the width of two coloured squares. This is meant to provide room for the text.
- The vertical position— that is, the top edge of the element—is shifted up or down from center using the offset defined earlier and the index,
i
, of the current element, which D3 passes in as the second argument. - Finally we return the translation.
As I mentioned, we needn't worry too much about that last part. The key takeaway is:
When iterating over a dataset, D3 provides the index of the current entry as the second parameter to the callback.
Now all that remains is to actually add the coloured square and the label. We'll start with the former:
legend.append('rect')
.attr('width', legendRectSize)
.attr('height', legendRectSize)
.style('fill', color)
.style('stroke', color);
This is straightforward enough that I don't think we need to go over it line by line. I will, however, comment that the fill
and stroke
are each passed color
, from which they can retrieve the appropriate colour for the background and border. Each legend element will pass its label into color()
; for instance, the first one will call color('Abulia')
and be given #393b79
in return.
Now for the last part of the legend, the text:
legend.append('text')
.attr('x', legendRectSize + legendSpacing)
.attr('y', legendRectSize - legendSpacing)
.text(function(d) { return d; });
Given the markup above, this shouldn't be too surprising. The x
and y
attributes use the spacing and the size of the coloured square to determine their position relative to the cotainer, the g
element with class legend. As such, these attributes are the same for all elements in the legend. Finally, for the text()
, we can just give it an identity function becuase we don't need to alter the labels in any way. If, for example, we wanted the to be all uppercase, we could do the following:
// Alternative
legend.append('text')
.attr('x', legendRectSize + legendSpacing)
.attr('y', legendRectSize - legendSpacing)
.text(function(d) { return d.toUpperCase(); });
So that brings us to the end of our legend. There was a fair bit to it given that we wanted to center it inside the donut. It's worth reiterating that we saw the .selectAll(el).data(data).enter().append(el)
pattern come up again. We won't run into it again in this series but I guarantee you'll see it if you start looking at other D3 examples. Now it's time to move on to Step 4, but first, here's the updated code in case you're interested: