9

I'm drawing a pie chart using D3.js with a quite simple script. The problem is that when slices are small, their labels overlap.

What options do I have to prevent them from overlapping? Does D3.js have built-in mechanisms I could exploit?

Demo: http://jsfiddle.net/roxeteer/JTuej/

var container = d3.select("#piechart");
var data = [
        { name: "Group 1", value: 1500 },
        { name: "Group 2", value: 500 },
        { name: "Group 3", value: 100 },
        { name: "Group 4", value: 50 },
        { name: "Group 5", value: 20 }
    ];
var width = 500;
var height = 500;
var radius = 150;
var textOffset = 14;

var color = d3.scale.category20();

var svg = container.append("svg:svg")
    .attr("width", width)
    .attr("height", height);

var pie = d3.layout.pie().value(function(d) {
    return d.value;
});

var arc = d3.svg.arc()
    .outerRadius(function(d) { return radius; });

var arc_group = svg.append("svg:g")
    .attr("class", "arc")
    .attr("transform", "translate(" + (width/2) + "," + (height/2) + ")");

var label_group = svg.append("svg:g")
    .attr("class", "arc")
    .attr("transform", "translate(" + (width/2) + "," + (height/2) + ")");

var pieData = pie(data);

var paths = arc_group.selectAll("path")
    .data(pieData)
    .enter()
    .append("svg:path")
    .attr("stroke", "white")
    .attr("stroke-width", 0.5)
    .attr("fill", function(d, i) { return color(i); })
    .attr("d", function(d) {
        return arc({startAngle: d.startAngle, endAngle: d.endAngle});
    });

var labels = label_group.selectAll("path")
    .data(pieData)
    .enter()
    .append("svg:text")
    .attr("transform", function(d) {
        return "translate(" + Math.cos(((d.startAngle + d.endAngle - Math.PI) / 2)) * (radius + textOffset) + "," + Math.sin((d.startAngle + d.endAngle - Math.PI) / 2) * (radius + textOffset) + ")";
    })
    .attr("text-anchor", function(d){
        if ((d.startAngle  +d.endAngle) / 2 < Math.PI) {
            return "beginning";
        } else {
            return "end";
        }
    })
    .text(function(d) {
        return d.data.name;
    });
Visa Kopu
  • 695
  • 3
  • 7
  • 20
  • possible duplicate of [Preventing Overlap of Text in D3](http://stackoverflow.com/questions/14534024/preventing-overlap-of-text-in-d3) – Alex Filipovici Oct 30 '13 at 12:07
  • The problem is also addressed here: [http://stackoverflow.com/a/14803104/674700](http://stackoverflow.com/a/14803104/674700). – Alex Filipovici Oct 30 '13 at 12:09
  • Thanks for your reply. Unfortunately, the sunbeam-style positioning of labels is not possible in this case (the labels are too long and will probably contain multiple lines of text). – Visa Kopu Oct 30 '13 at 14:20

4 Answers4

6

D3 doesn't offer anything built-in that does this, but you can do it by, after having added the labels, iterating over them and checking if they overlap. If they do, move one of them.

var prev;
labels.each(function(d, i) {
  if(i > 0) {
    var thisbb = this.getBoundingClientRect(),
        prevbb = prev.getBoundingClientRect();
    // move if they overlap
    if(!(thisbb.right < prevbb.left || 
            thisbb.left > prevbb.right || 
            thisbb.bottom < prevbb.top || 
            thisbb.top > prevbb.bottom)) {
        var ctx = thisbb.left + (thisbb.right - thisbb.left)/2,
            cty = thisbb.top + (thisbb.bottom - thisbb.top)/2,
            cpx = prevbb.left + (prevbb.right - prevbb.left)/2,
            cpy = prevbb.top + (prevbb.bottom - prevbb.top)/2,
            off = Math.sqrt(Math.pow(ctx - cpx, 2) + Math.pow(cty - cpy, 2))/2;
        d3.select(this).attr("transform",
            "translate(" + Math.cos(((d.startAngle + d.endAngle - Math.PI) / 2)) *
                                    (radius + textOffset + off) + "," +
                           Math.sin((d.startAngle + d.endAngle - Math.PI) / 2) *
                                    (radius + textOffset + off) + ")");
    }
  }
  prev = this;
});

This checks, for each label, if it overlaps with the previous label. If this is the case, a radius offset is computed (off). This offset is determined by half the distance between the centers of the text boxes (this is just a heuristic, there's no specific reason for it to be this) and added to the radius + text offset when recomputing the position of the label as originally.

The maths is a bit involved because everything needs to be checked in two dimensions, but it's farily straightforward. The net result is that if a label overlaps a previous label, it is pushed further out. Complete example here.

Lars Kotthoff
  • 107,425
  • 16
  • 204
  • 204
  • This approach works but it has some quirks. The most notable is that it wont work if you are using transitions unless you have to wait till they have moved. With some minor changes it isn't to hard to pre-compute the locations by using d3.svg.arc() to position the labels. I've done this in my AngularD3 library here: https://github.com/WealthBar/angular-d3/blob/2b14ca11051bbe50c874f0e8dad42de5f69cbd65/angularD3.js#L597 – Chris Nicola Mar 22 '14 at 16:48
  • Or you could simply do the calculations as above and store the positions, reset to the original ones and then start the transition to the previously computed positions. – Lars Kotthoff Mar 22 '14 at 17:06
  • That's what I did. However you can't use bounding box unless you've already positioned them. With transitions this results in a "jerking" effect as the overlapping items move twice. – Chris Nicola Mar 22 '14 at 17:16
  • Awesome - this works for me (not using transitions), but since only adjusts position of slices 2 through N it fails to detect overlap between the labels for Nth slice and 1st slice. Fortunately this was easy to fix by using same logic to compare label-N to label-1 and by using a different `textOffset` (to avoid going around and around). – nothingisnecessary Dec 04 '14 at 19:48
  • @nothingisnecessary [This question](http://stackoverflow.com/questions/17425268/d3js-automatic-labels-placement-to-avoid-overlaps-force-repulsion) also has a few answers that may be helpful in this context. – Lars Kotthoff Dec 04 '14 at 20:12
  • @codesnooker Yes, it won't work in extreme cases like this. Two things would be necessary to make it work: 1) take into account overlap between any labels and not just subsequent ones and 2) iterate the moving label process until convergence. Both would make it much more computationally expensive. – Lars Kotthoff Mar 19 '15 at 12:15
  • @LarsKotthoff Thanks! I am trying now different approach. I will post the link if I succeed. Your solution gave me a good start. – codesnooker Mar 19 '15 at 14:20
  • the solution is not working for other example since it based only on this example – Liad Livnat Jul 24 '16 at 12:41
1

@LarsKotthoff

Finally I have solved the problem. I have used stack approach to display the labels. I made a virtual stack on both left and right side. Based the angle of the slice, I allocated the stack-row. If stack row is already filled then I find the nearest empty row on both top and bottom of desired row. If no row found then the value (on the current side) with least share angle is removed from the stack and labels are adjust accordingly.

See the working example here: http://manicharts.com/#/demosheet/3d-donut-chart-smart-labels

codesnooker
  • 1,191
  • 10
  • 19
1

The actual problem here is one of label clutter. So, you could try not displaying labels for very narrow arcs:

.text(function(d) {
    if(d.endAngle - d.startAngle<4*Math.PI/180){return ""}
    return d.data.key; });

This is not as elegant as the alternate solution, or codesnooker's resolution to that issue, but might help reduce the number of labels for those who have too many. If you need labels to be able to be shown, a mouseover might do the trick.

Roland Heath
  • 334
  • 2
  • 10
1

For small angles(less than 5% of the Pie Chart), I have changed the centroid value for the respective labels. I have used this code:

    arcs.append("text") 
        .attr("transform", function(d,i) {
            var centroid_value = arc.centroid(d);

            var pieValue = ((d.endAngle - d.startAngle)*100)/(2*Math.PI);                
            var accuratePieValue = pieValue.toFixed(0);
            if(accuratePieValue <= 5){
                var pieLableArc = d3.svg.arc().innerRadius(i*20).outerRadius(outer_radius + i*20);
                centroid_value = pieLableArc.centroid(d);
            }

            return "translate(" + centroid_value + ")";
        })
        .text(function(d, i) { ..... });