Add Sunburst Labels (Tutorial 3)

viz In this tutorial we’ll add properly-rotated labels to our Tutorial 2 sunburst. This will require us to update a few lines of existing code and add a new rotation function.

Just want the code? Or an overveiw of the tutorials?

I hope this tutorial helps you deepen your d3 visualization skills. If it does that, or if you have an idea about how to improve something below, let me know in the comments section. Do good!

—David Richards

Tutorial Contents

Summary

In this tutorial, we move from a plain sunburst, to one that has labels. (Tips: Keep the raw code open in a separate tab. Do a code compare between tutorial 1 and 2.)

Old: Data from a JSON file   New: Now we have labels  

Just the Code

The tl;dr version of this tutorial (in 4 easy steps):

  • Replace this old line of code with two new lines of code:
// Old code:
var vSlices = g.selectAll('path').data(vNodes).enter().append('path');

// New code:
var vSlices = g.selectAll('g').data(vNodes).enter().append('g');
  • Append a path element to our g elements:
// Old code:
vSlices.
    .attr('display', function (d) { return d.depth ? null : 'none'; })

// New code:
vSlices.append('path')
    .attr('display', function (d) { return d.depth ? null : 'none'; })
  • Add a new block at the end of drawSunburst() that appends our labels:
vSlices.append('text')
    .filter(function(d) { return d.parent; })
    .attr('transform', function(d) {
        return 'translate(' + vArc.centroid(d) + ')rotate(' + computeTextRotation(d) + ')'; })
    .attr('dx', '-20')
    .attr('dy', '.5em')
    .text(function(d) { return d.data.id });
  • Insert this function (outside of the drawSunburst() function) that calculates the proper rotation of the labels:
function computeTextRotation(d) {
    var angle = (d.x0 + d.x1) / Math.PI * 90;

    // Avoid upside-down labels; labels as rims
    return (angle < 120 || angle > 270) ? angle : angle + 180;
    //return (angle < 180) ? angle - 90 : angle + 90;  // labels as spokes
}

All of the juicy details about what and why are below.


Use g Instead of path

Beginning in our Layout code block, we append a “container” <g> element for each node in our data (previously we’d appended <path> elements). Then we’ll stash our path elements with the g elements.

// Old code: Append sunburst <path> elements
var vSlices = g.selectAll('path').data(vNodes).enter().append('path');

// New code: Append sunburst <g> elements
var vSlices = g.selectAll('g').data(vNodes).enter().append('g');

In the previous tutorial, we selected our non-existent <path> elements below and used the d3 Update Pattern to add a <path> for each node in our data. The problem is that we cannot attach a <text> element to path. So instead of beginning with the element, we'll add container `` elements (we talked about these a bit in Tutorial 1). Then we'll add both the `` and our new `` elements to the `` element.

Our updated vSlices variable now represents a list of all of our slices as elements. So when we act on vSlices, we need to append a to each, like so:

// Old code: vSlices are <path>s, so we work on them directly
vSlices.
    .attr('display', function (d) { return d.depth ? null : 'none'; })

// New code: vSlices are <g>s, so we append <path> first
vSlices.append('path')
    .attr('display', function (d) { return d.depth ? null : 'none'; })
    // everything else in this block remains the same

With these changes, the code now produces html that looks like this:

<g>
    <path d="..." style="..."></path>
</g>

The other lines of this block remains the same as Tutorial 1.


Add Labels

Now we add our labels to each of the g elements that we created above. Then we rotate and position them properly.

vSlices.append('text')  // <--1
    .filter(function(d) { return d.parent; })  // <--2
    .attr('transform', function(d) {  // <--3
        return 'translate(' + vArc.centroid(d) + ')rotate(' + computeTextRotation(d) + ')'; })
    .attr('dx', '-20')  // <--4
    .attr('dy', '.5em')  // <--5
    .text(function(d) { return d.data.id });  // <--6

Now we’ll add and populate the <text> elements with our data-driven titles.

  1. vSlices.append("text") like the last block, starts with the variables that we created above that points to all of our node-based elements. Then We append an empty `` element to each `` element.

  2. As with our statements, we filter out the root node (since, for our purposes, we don't want text there).

  3. .attr("transform", function(d) { return ...; }) adds a transform attribute to each our newly created <text> elements.
    • "translate(" + arc.centroid(d) + ")" moves the reference point for this <text> element to the center of each arc (the variable we defined above). The centroid command from d3 computes the midpoint [x, y] of each arc.
    • "rotate(" + computeTextRotation(d) + ")" then we’ll rotate our <text> element a specified number of degrees. We’ll do that calc in a separate function below.
  4. .attr("dx", "-20") // Moves the text element to the left, which makes our labels look centered.

  5. .attr("dy", ".5em") // Pulls our text element in closer to the center of the Sunburst, which makes our labels look centered.

  6. .text(function(d) { return d.parent ? d.data.name : "" }) returns the “name” attribute for each node, unless that particular node has no parents (the “root” node). In that case, it returns an empty string.
    1. This d.parent check is an alternative to the d.depth check that we did in Tutorial 1 for finding our root node.

Putting this block all together, each data node has an entry that looks like:

<g>
    <path d="..." style="..."></path>
    <text transform="translate(105.54,-66.97)rotate(-32.39)"
        dx="-20" dy=".35em">Topic A</text>
</g>

This is the end of changes to our drawSunburst() function.


computeTextRotation Function

The computeTextRotation function calculates the correct amount of rotation for each label based on its location in the sunburst. It also avoids upside down labels. It takes a single argument, “d”, which represents a single d3 node (this function is called one time for each text element).

function computeTextRotation(d) {
    var angle = (d.x0 + d.x1) / Math.PI * 90;  // <-- 1

    // Avoid upside-down labels; labels aligned with slices
    return (angle < 90 || angle > 270) ? angle : angle + 180;  // <--2

    // Alternate label formatting; labels as spokes
    //return (angle < 180) ? angle - 90 : angle + 90;  // <-- 3
}

Three math-rich lines in this function:

  1. var angle = (d.x0 + d.x1) / Math.PI * 90 calculate the angle (in degrees) of the label. However, it’ll draw 1/2 of the labels upside down.
    1. d.x0 = the beginning angle of this node / slice (in radians).
    2. d.x1 = the end angle of this node / slice (in radians).
  2. return (angle < 90 || angle > 270) ? angle : angle + 180 handles rotation to avoid any upside-down labels: If rotation angle is in the 1st or 2nd quadrants (top 1/2), leave the already calc’d angle. Otherwise, flip the text over so that the text appears right-side-up.

  3. return (angle < 180) ? angle - 90 : angle + 90 Alternatively, this line rotates the labels so that they appear in the traditional “spoke” formation. And it avoids any upside-down labels. It’s currently commented out.

Very good! You’ve made it through 3 tutorials. Stick with it.


jamestown-ships.png
In December 1606, 144 people set sail for a 4-month, cross-Atlantic trip from London to Virginia. One of my boys and I visited the Jamestown settlement and the recreations of the ships that carried the explorers: Susan Constant, Godspeed, & Discovery.

Written on July 15, 2017