Search code examples
d3.jshierarchypartition

d3.partition sunbursts: rotating text and other glitches


d3.hierarchy generally and d3.partition in particular are some of my favorite tools out of that great library. But I'm applying them to radial "sunburst" viz for the first time, and seem to be missing some important bits.

Attached below is a MCVE example generating this sunburst, to illustrate my main questions: sample sunburst

Rotating text

Rotating text labels beyond 180 degrees is a common issue; cf. this recent SO post

Following @mbostock's own example has this transform code:

    .attr("transform", function(d) { 
                    const x = (d.x0 + d.x1) / 2 * 180 / Math.PI;
                    const y = (d.y0 + d.y1) / 2;
                    return `rotate(${x - 90}) translate(${y},0) rotate(${x < 180 ? 0 : 180})`;
                    })           

but using this translate() in the transform throws the text far off the chart?

So the code below does a rotation based on the same average of inner/outer arc radii, and places the labels right-side (angles < 180) the same way, except that it uses a text-anchor alignment variation to align both depths' labels with respect to the same common circle.

  • 1 I've had to modify the radius by a hacked factor of 1.22 to nudge the (right-side) labels close to the line; why?

  • 2 This works great for all labels except the root's, which I want centered; how can I do that?

  • 3 But the new left-side (> 180 degrees) labels are not well-placed, and I've needed to add a depth-specific hackRatio in order to moderate the translation to even get them this close; why?

  • 4 A deeper problem is to figure out how to use the same text-anchor alignment trick used for the other labels? I want to do the rotation "in place" prior to alignment being applied; how can I do that?

How is d3.hierarchy.sum() supposed to work?

The labels also include the freq attribute in parentheses. The yearHier data provides this attribute only for data leaves. My impression from the d3.hierarchy.sum() and d3.partition doc was that the call to sum() on the root would calculate the sums for non-leaves ("... for this node and each descendant in post-order traversal"); why are these frequencies zero?

So as an alternative, I tried using the yearHierFreq data which incudes total frequencies for root and each year. But using it, d3.partition allocates only only two thirds of the years' arcs, and only half of the months' arcs within each year; see rendering below. It's as if the interior nodes' freq is being double counted; why?

using yearHierFreq data with all freq provided

<script src="https://d3js.org/d3.v5.min.js"></script>
<script>

var ColorNames = ["Blue", "Gray", "Purple", "Fuchsia", "Aqua", "Maroon", "Olive", "Yellow", "Teal", "Navy", "Green", "Silver", "Red", "Lime"];

// following after http://bl.ocks.org/kerryrodden/7090426

var width = 900;
var height = 900;
var radius = Math.min(width, height) / 2 * 0.7;

var vis = d3.select("#chart").append("svg:svg")
    .attr("width", width)
    .attr("height", height)
    .append("svg:g")
    .attr("id", "container")
    .attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");

var partition = d3.partition()
    .size([2 * Math.PI, radius * radius]);

var arc = d3.arc()
    .startAngle(function(d)  { return d.x0; })
    .endAngle(function(d)    { return d.x1; })
    .innerRadius(function(d) { return Math.sqrt(d.y0); })
    .outerRadius(function(d) { return Math.sqrt(d.y1); });

function createSunburst(json) {

 vis.append("svg:circle")
      .attr("r", radius)
      .style("opacity", 0);

  // Turn the data into a d3 hierarchy and calculate the sums.
  var root = d3.hierarchy(json)
      .sum(function(d) { return d.freq; })
      .sort(function(a, b) { return b.name - a.name; });

  var partition = d3.partition()
    .size([2 * Math.PI, radius * radius]);

  var nodes = partition(root).descendants();

   var path = vis.data([json]).selectAll("path")
      .data(nodes)
      .enter().append("svg:path")
      .attr("display", function(d) { return d.depth ? null : "none"; })
      .attr("d", arc)
      .attr("fill-rule", "evenodd")
      .style("fill", function(d,i) { return ColorNames[i % 14]; })
      .style("opacity", 1);

    var texts = vis.selectAll("text")
        .data(nodes)
.enter().append("text")

/*      .attr("transform", function(d) { 
            // https://beta.observablehq.com/@mbostock/d3-sunburst
                        const x = (d.x0 + d.x1) / 2 * 180 / Math.PI;
                        const y = (d.y0 + d.y1) / 2;
                        return `rotate(${x - 90}) translate(${y},0) rotate(${x < 180 ? 0 : 180})`;
                        })           
*/
        .attr("transform", function(d) { 
            var deg;
            if (d.depth==0) {
                deg = 90;
            } else {
                deg = 180 / Math.PI * (d.x0 +d.x1) / 2;
            }
            var trans = `rotate(${deg-90})`;
            if (deg > 180) { 
                var hackRatio = (d.depth == 0) ? 160 : 130;
                var yavg = (d.y0 + d.y1) / 2 / hackRatio;
                trans += ` translate(${yavg},0) rotate(180)`; 
            }
            return trans})

        .attr("x", radius / 1.22 )
        .text(function(d) {return  `${d.data.name} (${d.data.freq})`;})

        .attr("text-anchor", function(d) { 
            var alignVec = ["center","end","start"];
            return alignVec[d.depth];})
 };

var yearHier = {"freq": 0, "name": "AllYears", "children": [{"freq": 0, "name": "2017", "children": [{"freq": 5, "name": "January", "children": []}, {"freq": 17, "name": "February", "children": []}, {"freq": 16, "name": "March", "children": []}, {"freq": 2, "name": "April", "children": []}, {"freq": 18, "name": "May", "children": []}, {"freq": 14, "name": "June", "children": []}, {"freq": 17, "name": "July", "children": []}, {"freq": 2, "name": "August", "children": []}, {"freq": 10, "name": "September", "children": []}, {"freq": 6, "name": "October", "children": []}, {"freq": 10, "name": "November", "children": []}, {"freq": 17, "name": "December", "children": []}]}, {"freq": 0, "name": "2018", "children": [{"freq": 14, "name": "January", "children": []}, {"freq": 6, "name": "February", "children": []}, {"freq": 13, "name": "March", "children": []}, {"freq": 15, "name": "April", "children": []}, {"freq": 15, "name": "May", "children": []}, {"freq": 4, "name": "June", "children": []}, {"freq": 7, "name": "July", "children": []}, {"freq": 12, "name": "August", "children": []}, {"freq": 17, "name": "September", "children": []}, {"freq": 8, "name": "October", "children": []}, {"freq": 10, "name": "November", "children": []}, {"freq": 12, "name": "December", "children": []}]}, {"freq": 0, "name": "2019", "children": [{"freq": 10, "name": "January", "children": []}, {"freq": 12, "name": "February", "children": []}, {"freq": 15, "name": "March", "children": []}, {"freq": 6, "name": "April", "children": []}, {"freq": 14, "name": "May", "children": []}, {"freq": 3, "name": "June", "children": []}, {"freq": 6, "name": "July", "children": []}, {"freq": 9, "name": "August", "children": []}, {"freq": 18, "name": "September", "children": []}, {"freq": 4, "name": "October", "children": []}, {"freq": 8, "name": "November", "children": []}, {"freq": 16, "name": "December", "children": []}]}]}

var yearHierFreq = {"freq": 355, "name": "AllMonths", "children": [{"freq": 83, "name": "2017", "children": [{"freq": 4, "name": "January", "children": []}, {"freq": 7, "name": "February", "children": []}, {"freq": 4, "name": "March", "children": []}, {"freq": 11, "name": "April", "children": []}, {"freq": 16, "name": "May", "children": []}, {"freq": 8, "name": "June", "children": []}, {"freq": 5, "name": "July", "children": []}, {"freq": 3, "name": "August", "children": []}, {"freq": 10, "name": "September", "children": []}, {"freq": 3, "name": "October", "children": []}, {"freq": 2, "name": "November", "children": []}, {"freq": 10, "name": "December", "children": []}]}, {"freq": 156, "name": "2018", "children": [{"freq": 14, "name": "January", "children": []}, {"freq": 8, "name": "February", "children": []}, {"freq": 12, "name": "March", "children": []}, {"freq": 10, "name": "April", "children": []}, {"freq": 16, "name": "May", "children": []}, {"freq": 17, "name": "June", "children": []}, {"freq": 19, "name": "July", "children": []}, {"freq": 14, "name": "August", "children": []}, {"freq": 4, "name": "September", "children": []}, {"freq": 17, "name": "October", "children": []}, {"freq": 19, "name": "November", "children": []}, {"freq": 6, "name": "December", "children": []}]}, {"freq": 116, "name": "2019", "children": [{"freq": 4, "name": "January", "children": []}, {"freq": 15, "name": "February", "children": []}, {"freq": 12, "name": "March", "children": []}, {"freq": 8, "name": "April", "children": []}, {"freq": 3, "name": "May", "children": []}, {"freq": 5, "name": "June", "children": []}, {"freq": 13, "name": "July", "children": []}, {"freq": 19, "name": "August", "children": []}, {"freq": 12, "name": "September", "children": []}, {"freq": 11, "name": "October", "children": []}, {"freq": 5, "name": "November", "children": []}, {"freq": 9, "name": "December", "children": []}]}]}

createSunburst(yearHier);
d3.select(self.frameElement).style("height", "700px");
</script>

Solution

  • You can get the following result

    enter image description here

    with this code

    var radiusSeparation = 5;
    
    var texts = vis.selectAll("text")
      .data(nodes)
      .enter().append("text")
        .attr("transform", function(d) {
            if (d.depth == 0) return null;
            d.deg = 180 / Math.PI * (d.x0 + d.x1) * 0.5;
            var translate = d.depth == 1 ? Math.sqrt(d.y1)-radiusSeparation : Math.sqrt(d.y0)+radiusSeparation;
            var trans = `rotate(${(d.deg-90).toFixed(2)}) translate(${translate.toFixed(2)},0)`;
            if (d.deg > 180) {
                trans += ` rotate(180)`;
            }
            return trans;
        })
        .text( d => `${d.data.name} (${d.value})` )
        .attr("text-anchor", function(d) {
            if (d.depth == 0) return "middle";
            if (d.depth == 1) return d.deg < 180 ? "end" : "start";
            return d.deg < 180 ? "start" : "end";
        })
        .attr("dominant-baseline", "middle")
    
    • use the radius of your arcs to position the text. Use a small separation distance so the text does not touch the arcs

    • store the deg value in the datum so you can use it for the text anchor

    • switch text-anchor based on deg value

    • treat depth=0 as special in all cases

    • vertical align the text to the middle with dominant-baseline

    • d3.hierarchy.sum() stores the result in d.value, so use this in the text