d3.js spreading labels for pie charts

2019-01-09 16:39发布

问题:

I'm using d3.js - I have a pie chart here. The problem though is when the slices are small - the labels overlap. What is the best way of spreading out the labels.

http://jsfiddle.net/BxLHd/16/

Here is the code for the labels. I am curious - is it possible to mock a 3d pie chart with d3?

                        //draw labels                       
                        valueLabels = label_group.selectAll("text.value").data(filteredData)
                        valueLabels.enter().append("svg:text")
                                .attr("class", "value")
                                .attr("transform", function(d) {
                                    return "translate(" + Math.cos(((d.startAngle+d.endAngle - Math.PI)/2)) * (that.r + that.textOffset) + "," + Math.sin((d.startAngle+d.endAngle - Math.PI)/2) * (that.r + that.textOffset) + ")";
                                })
                                .attr("dy", function(d){
                                        if ((d.startAngle+d.endAngle)/2 > Math.PI/2 && (d.startAngle+d.endAngle)/2 < Math.PI*1.5 ) {
                                                return 5;
                                        } else {
                                                return -7;
                                        }
                                })
                                .attr("text-anchor", function(d){
                                        if ( (d.startAngle+d.endAngle)/2 < Math.PI ){
                                                return "beginning";
                                        } else {
                                                return "end";
                                        }
                                }).text(function(d){
                                        //if value is greater than threshold show percentage
                                        if(d.value > threshold){
                                            var percentage = (d.value/that.totalOctets)*100;
                                            return percentage.toFixed(2)+"%";
                                        }
                                });

                        valueLabels.transition().duration(this.tweenDuration).attrTween("transform", this.textTween);
                        valueLabels.exit().remove();

回答1:

As @The Old County discovered, the previous answer I posted fails in firefox because it relies on the SVG method .getIntersectionList() to find conflicts, and that method hasn't been implemented yet in Firefox.

That just means we have to keep track of label positions and test for conflicts ourselves. With d3, the most efficient way to check for layout conflicts involves using a quadtree data structure to store positions, that way you don't have to check every label for overlap, just those in a similar area of the visualization.

The second part of the code from the previous answer gets replaced with:

        /* check whether the default position 
           overlaps any other labels*/
        var conflicts = [];
        labelLayout.visit(function(node, x1, y1, x2, y2){
            //recurse down the tree, adding any overlapping labels
            //to the conflicts array

            //node is the node in the quadtree, 
            //node.point is the value that we added to the tree
            //x1,y1,x2,y2 are the bounds of the rectangle that
            //this node covers

            if (  (x1 > d.r + maxLabelWidth/2) 
                    //left edge of node is to the right of right edge of label
                ||(x2 < d.l - maxLabelWidth/2) 
                    //right edge of node is to the left of left edge of label
                ||(y1 > d.b + maxLabelHeight/2)
                    //top (minY) edge of node is greater than the bottom of label
                ||(y2 < d.t - maxLabelHeight/2 ) )
                    //bottom (maxY) edge of node is less than the top of label

                  return true; //don't bother visiting children or checking this node

            var p = node.point;
            var v = false, h = false;
            if ( p ) { //p is defined, i.e., there is a value stored in this node
                h =  ( ((p.l > d.l) && (p.l <= d.r))
                   || ((p.r > d.l) && (p.r <= d.r)) 
                   || ((p.l < d.l)&&(p.r >=d.r) ) ); //horizontal conflict

                v =  ( ((p.t > d.t) && (p.t <= d.b))
                   || ((p.b > d.t) && (p.b <= d.b))  
                   || ((p.t < d.t)&&(p.b >=d.b) ) ); //vertical conflict

                if (h&&v)
                    conflicts.push(p); //add to conflict list
            }

        });

        if (conflicts.length) {
            console.log(d, " conflicts with ", conflicts);  
            var rightEdge = d3.max(conflicts, function(d2) {
                return d2.r;
            });

            d.l = rightEdge;
            d.x = d.l + bbox.width / 2 + 5;
            d.r = d.l + bbox.width + 10;
        }
        else console.log("no conflicts for ", d);

        /* add this label to the quadtree, so it will show up as a conflict
           for future labels.  */
        labelLayout.add( d );
        var maxLabelWidth = Math.max(maxLabelWidth, bbox.width+10);
        var maxLabelHeight = Math.max(maxLabelHeight, bbox.height+10);

Note that I've changed the parameter names for the edges of the label to l/r/b/t (left/right/bottom/top) to keep everything logical in my mind.

Live fiddle here: http://jsfiddle.net/Qh9X5/1249/

An added benefit of doing it this way is that you can check for conflicts based on the final position of the labels, before actually setting the position. Which means that you can use transitions for moving the labels into position after figuring out the positions for all the labels.



回答2:

Should be possible to do. How exactly you want to do it will depend on what you want to do with spacing out the labels. There is not, however, a built in way of doing this.

The main problem with the labels is that, in your example, they rely on the same data for positioning that you are using for the slices of your pie chart. If you want them to space out more like excel does (i.e. give them room), you'll have to get creative. The information you have is their starting position, their height, and their width.

A really fun (my definition of fun) way to go about solving this would be to create a stochastic solver for an optimal arrangement of labels. You could do this with an energy-based method. Define an energy function where energy increases based on two criteria: distance from start point and overlap with nearby labels. You can do simple gradient descent based on that energy criteria to find a locally optimal solution with regards to your total energy, which would result in your labels being as close as possible to their original points without a significant amount of overlap, and without pushing more points away from their original points.

How much overlap is tolerable would depend on the energy function you specify, which should be tunable to give a good looking distribution of points. Similarly, how much you're willing to budge on point closeness would depend on the shape of your energy increase function for distance from the original point. (A linear energy increase will result in closer points, but greater outliers. A quadratic or a cubic will have greater average distance, but smaller outliers.)

There might also be an analytical way of solving for the minima, but that would be harder. You could probably develop a heuristic for positioning things, which is probably what excel does, but that would be less fun.



回答3:

One way to check for conflicts is to use the <svg> element's getIntersectionList() method. That method requires you to pass in an SVGRect object (which is different from a <rect> element!), such as the object returned by a graphical element's .getBBox() method.

With those two methods, you can figure out where a label is within the screen and if it overlaps anything. However, one complication is that the rectangle coordinates passed to getIntersectionList are interpretted within the root SVG's coordinates, while the coordinates returned by getBBox are in the local coordinate system. So you also need the method getCTM() (get cumulative transformation matrix) to convert between the two.

I started with the example from Lars Khottof that @TheOldCounty had posted in a comment, as it already included lines between the arc segments and the labels. I did a little re-organization to put the labels, lines and arc segments in separate <g> elements. That avoids strange overlaps (arcs drawn on top of pointer lines) on update, and it also makes it easy to define which elements we're worried about overlapping -- other labels only, not the pointer lines or arcs -- by passing the parent <g> element as the second parameter to getIntersectionList.

The labels are positioned one at a time using an each function, and they have to be actually positioned (i.e., the attribute set to its final value, no transitions) at the time the position is calculated, so that they are in place when getIntersectionList is called for the next label's default position.

The decision of where to move a label if it overlaps a previous label is a complex one, as @ckersch's answer outlines. I keep it simple and just move it to the right of all the overlapped elements. This could cause a problem at the top of the pie, where labels from the last segments could be moved so that they overlap labels from the first segments, but that's unlikely if the pie chart is sorted by segment size.

Here's the key code:

    labels.text(function (d) {
        // Set the text *first*, so we can query the size
        // of the label with .getBBox()
        return d.value;
    })
    .each(function (d, i) {
        // Move all calculations into the each function.
        // Position values are stored in the data object 
        // so can be accessed later when drawing the line

        /* calculate the position of the center marker */
        var a = (d.startAngle + d.endAngle) / 2 ;

        //trig functions adjusted to use the angle relative
        //to the "12 o'clock" vector:
        d.cx = Math.sin(a) * (that.radius - 75);
        d.cy = -Math.cos(a) * (that.radius - 75);

        /* calculate the default position for the label,
           so that the middle of the label is centered in the arc*/
        var bbox = this.getBBox();
        //bbox.width and bbox.height will 
        //describe the size of the label text
        var labelRadius = that.radius - 20;
        d.x =  Math.sin(a) * (labelRadius);
        d.sx = d.x - bbox.width / 2 - 2;
        d.ox = d.x + bbox.width / 2 + 2;
        d.y = -Math.cos(a) * (that.radius - 20);
        d.sy = d.oy = d.y + 5;

        /* check whether the default position 
           overlaps any other labels*/

        //adjust the bbox according to the default position
        //AND the transform in effect
        var matrix = this.getCTM();
        bbox.x = d.x + matrix.e;
        bbox.y = d.y + matrix.f;

        var conflicts = this.ownerSVGElement
                            .getIntersectionList(bbox, this.parentNode);

        /* clear conflicts */
        if (conflicts.length) {
            console.log("Conflict for ", d.data, conflicts);   
            var maxX = d3.max(conflicts, function(node) {
                var bb = node.getBBox();
                return bb.x + bb.width;
            })

            d.x = maxX + 13;
            d.sx = d.x - bbox.width / 2 - 2;
            d.ox = d.x + bbox.width / 2 + 2;

        }

        /* position this label, so it will show up as a conflict
           for future labels. (Unfortunately, you can't use transitions.) */
        d3.select(this)
            .attr("x", function (d) {
                return d.x;
            })
            .attr("y", function (d) {
                return d.y;
            });
    });

And here's the working fiddle: http://jsfiddle.net/Qh9X5/1237/