D3js: Automatic labels placement to avoid overlaps? (force repulsion)

前端 未结 6 1589
有刺的猬
有刺的猬 2020-11-27 11:28

How to apply force repulsion on map\'s labels so they find their right places automatically ?


Bostock\' \"Let\'s Make a Map\"

Mike

相关标签:
6条回答
  • 2020-11-27 11:56

    In my opinion, the force layout is unsuitable for the purpose of placing labels on a map. The reason is simple -- labels should be as close as possible to the places they label, but the force layout has nothing to enforce this. Indeed, as far as the simulation is concerned, there is no harm in mixing up labels, which is clearly not desirable for a map.

    There could be something implemented on top of the force layout that has the places themselves as fixed nodes and attractive forces between the place and its label, while the forces between labels would be repulsive. This would likely require a modified force layout implementation (or several force layouts at the same time), so I'm not going to go down that route.

    My solution relies simply on collision detection: for each pair of labels, check if they overlap. If this is the case, move them out of the way, where the direction and magnitude of the movement is derived from the overlap. This way, only labels that actually overlap are moved at all, and labels only move a little bit. This process is iterated until no movement occurs.

    The code is somewhat convoluted because checking for overlap is quite messy. I won't post the entire code here, it can be found in this demo (note that I've made the labels much larger to exaggerate the effect). The key bits look like this:

    function arrangeLabels() {
      var move = 1;
      while(move > 0) {
        move = 0;
        svg.selectAll(".place-label")
           .each(function() {
             var that = this,
                 a = this.getBoundingClientRect();
             svg.selectAll(".place-label")
                .each(function() {
                  if(this != that) {
                    var b = this.getBoundingClientRect();
                    if(overlap) {
                      // determine amount of movement, move labels
                    }
                  }
                });
           });
      }
    }
    

    The whole thing is far from perfect -- note that some labels are quite far away from the place they label, but the method is universal and should at least avoid overlap of labels.

    enter image description here

    0 讨论(0)
  • 2020-11-27 11:58

    You might be interested in the d3fc-label-layout component (for D3v5) that is designed exactly for this purpose. The component provides a mechanism for arranging child components based on their rectangular bounding boxes. You can apply either a greedy or simulated annealing strategy in order to minimise overlaps.

    Here's a code snippet which demonstrates how to apply this layout component to Mike Bostock's map example:

    const labelPadding = 2;
    
    // the component used to render each label
    const textLabel = layoutTextLabel()
      .padding(labelPadding)
      .value(d => d.properties.name);
    
    // a strategy that combines simulated annealing with removal
    // of overlapping labels
    const strategy = layoutRemoveOverlaps(layoutGreedy());
    
    // create the layout that positions the labels
    const labels = layoutLabel(strategy)
        .size((d, i, g) => {
            // measure the label and add the required padding
            const textSize = g[i].getElementsByTagName('text')[0].getBBox();
            return [textSize.width + labelPadding * 2, textSize.height + labelPadding * 2];
        })
        .position(d => projection(d.geometry.coordinates))
        .component(textLabel);
    
    // render!
    svg.datum(places.features)
         .call(labels);
    

    And this is a small screenshot of the result:

    You can see a complete example here:

    http://bl.ocks.org/ColinEberhardt/389c76c6a544af9f0cab

    Disclosure: As discussed in the comment below, I am a core contributor of this project, so clearly I am somewhat biased. Full credit to the other answers to this question which gave us inspiration!

    0 讨论(0)
  • 2020-11-27 12:03

    One option is to use a Voronoi Layout to compute where there is space between points. There's a good example from Mike Bostock here.

    0 讨论(0)
  • 2020-11-27 12:04

    For 2D case here are some examples that do something very similar:

    one http://bl.ocks.org/1691430
    two http://bl.ocks.org/1377729

    thanks Alexander Skaburskis who brought this up here


    For 1D case For those who search a solution to a similar problem in 1-D i can share my sandbox JSfiddle where i try to solve it. It's far from perfect but it kind of doing the thing.

    Left: The sandbox model, Right: an example usage enter image description here

    Here is the code snippet which you can run by pressing the button in the end of the post, and also the code itself. When running, click on the field to position the fixed nodes.

    var width = 700,
        height = 500;
    
    var mouse = [0,0];
    
    var force = d3.layout.force()
        .size([width*2, height])
        .gravity(0.05)
        .chargeDistance(30)
        .friction(0.2)
        .charge(function(d){return d.fixed?0:-1000})
        .linkDistance(5)
        .on("tick", tick);
    
    var drag = force.drag()
        .on("dragstart", dragstart);
    
    var svg = d3.select("body").append("svg")
        .attr("width", width)
        .attr("height", height)
        .on("click", function(){
            mouse = d3.mouse(d3.select(this).node()).map(function(d) {
                return parseInt(d);
            });
            graph.links.forEach(function(d,i){
                var rn = Math.random()*200 - 100;
                d.source.fixed = true; 
                d.source.px = mouse[0];
                d.source.py = mouse[1] + rn;
                d.target.y = mouse[1] + rn;
            })
            force.resume();
            
            d3.selectAll("circle").classed("fixed", function(d){ return d.fixed});
        });
    
    var link = svg.selectAll(".link"),
        node = svg.selectAll(".node");
     
    var graph = {
      "nodes": [
        {"x": 469, "y": 410},
        {"x": 493, "y": 364},
        {"x": 442, "y": 365},
        {"x": 467, "y": 314},
        {"x": 477, "y": 248},
        {"x": 425, "y": 207},
        {"x": 402, "y": 155},
        {"x": 369, "y": 196},
        {"x": 350, "y": 148},
        {"x": 539, "y": 222},
        {"x": 594, "y": 235},
        {"x": 582, "y": 185}
      ],
      "links": [
        {"source":  0, "target":  1},
        {"source":  2, "target":  3},
        {"source":  4, "target":  5},
        {"source":  6, "target":  7},
        {"source":  8, "target":  9},
        {"source":  10, "target":  11}
      ]
    }
    
    function tick() {
      graph.nodes.forEach(function (d) {
         if(d.fixed) return;
         if(d.x<mouse[0]) d.x = mouse[0]
         if(d.x>mouse[0]+50) d.x--
        })
        
        
      link.attr("x1", function(d) { return d.source.x; })
          .attr("y1", function(d) { return d.source.y; })
          .attr("x2", function(d) { return d.target.x; })
          .attr("y2", function(d) { return d.target.y; });
    
      node.attr("cx", function(d) { return d.x; })
          .attr("cy", function(d) { return d.y; });
    }
    
    function dblclick(d) {
      d3.select(this).classed("fixed", d.fixed = false);
    }
    
    function dragstart(d) {
      d3.select(this).classed("fixed", d.fixed = true);
    }
    
    
    
      force
          .nodes(graph.nodes)
          .links(graph.links)
          .start();
    
      link = link.data(graph.links)
        .enter().append("line")
          .attr("class", "link");
    
      node = node.data(graph.nodes)
        .enter().append("circle")
          .attr("class", "node")
          .attr("r", 10)
          .on("dblclick", dblclick)
          .call(drag);
    .link {
      stroke: #ccc;
      stroke-width: 1.5px;
    }
    
    .node {
      cursor: move;
      fill: #ccc;
      stroke: #000;
      stroke-width: 1.5px;
      opacity: 0.5;
    }
    
    .node.fixed {
      fill: #f00;
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
    <body></body>

    0 讨论(0)
  • 2020-11-27 12:10

    One option is to use the force layout with multiple foci. Each foci must be located in the feature's centroid, set up the label to be attracted only by the corresponding foci. This way, each label will tend to be near of the feature's centroid, but the repulsion with other labels may avoid the overlapping issue.

    For comparison:

    • M. Bostock's "Lets Make a Map" tutorial (resulting map),
    • my gist for an Automatic Labels Placement version (resulting map) implementing the foci's strategy.

    The relevant code:

    // Place and label location
    var foci = [],
        labels = [];
    
    // Store the projected coordinates of the places for the foci and the labels
    places.features.forEach(function(d, i) {
        var c = projection(d.geometry.coordinates);
        foci.push({x: c[0], y: c[1]});
        labels.push({x: c[0], y: c[1], label: d.properties.name})
    });
    
    // Create the force layout with a slightly weak charge
    var force = d3.layout.force()
        .nodes(labels)
        .charge(-20)
        .gravity(0)
        .size([width, height]);
    
    // Append the place labels, setting their initial positions to
    // the feature's centroid
    var placeLabels = svg.selectAll('.place-label')
        .data(labels)
        .enter()
        .append('text')
        .attr('class', 'place-label')
        .attr('x', function(d) { return d.x; })
        .attr('y', function(d) { return d.y; })
        .attr('text-anchor', 'middle')
        .text(function(d) { return d.label; });
    
    force.on("tick", function(e) {
        var k = .1 * e.alpha;
        labels.forEach(function(o, j) {
            // The change in the position is proportional to the distance
            // between the label and the corresponding place (foci)
            o.y += (foci[j].y - o.y) * k;
            o.x += (foci[j].x - o.x) * k;
        });
    
        // Update the position of the text element
        svg.selectAll("text.place-label")
            .attr("x", function(d) { return d.x; })
            .attr("y", function(d) { return d.y; });
    });
    
    force.start();
    

    enter image description here

    0 讨论(0)
  • 2020-11-27 12:12

    While ShareMap-dymo.js may work, it does not appear to be very well documented. I have found a library that works for the more general case, is well documented and also uses simulated annealing: D3-Labeler

    I've put together a usage sample with this jsfiddle.The D3-Labeler sample page uses 1,000 iterations. I have found this is rather unnecessary and that 50 iterations seems to work quite well - this is very fast even for a few hundred data points. I believe there is room for improvement both in the way this library integrates with D3 and in terms of efficiency, but I wouldn't have been able to get this far on my own. I'll update this thread should I find the time to submit a PR.

    Here is the relevant code (see the D3-Labeler link for further documentation):

    var label_array = [];
    var anchor_array = [];
    
    //Create circles
    svg.selectAll("circle")
    .data(dataset)
    .enter()
    .append("circle")
    .attr("id", function(d){
        var text = getRandomStr();
        var id = "point-" + text;
        var point = { x: xScale(d[0]), y: yScale(d[1]) }
        var onFocus = function(){
            d3.select("#" + id)
                .attr("stroke", "blue")
                .attr("stroke-width", "2");
        };
        var onFocusLost = function(){
            d3.select("#" + id)
                .attr("stroke", "none")
                .attr("stroke-width", "0");
        };
        label_array.push({x: point.x, y: point.y, name: text, width: 0.0, height: 0.0, onFocus: onFocus, onFocusLost: onFocusLost});
        anchor_array.push({x: point.x, y: point.y, r: rScale(d[1])});
        return id;                                   
    })
    .attr("fill", "green")
    .attr("cx", function(d) {
        return xScale(d[0]);
    })
    .attr("cy", function(d) {
        return yScale(d[1]);
    })
    .attr("r", function(d) {
        return rScale(d[1]);
    });
    
    //Create labels
    var labels = svg.selectAll("text")
    .data(label_array)
    .enter()
    .append("text")
    .attr("class", "label")
    .text(function(d) {
        return d.name;
    })
    .attr("x", function(d) {
        return d.x;
    })
    .attr("y", function(d) {
        return d.y;
    })
    .attr("font-family", "sans-serif")
    .attr("font-size", "11px")
    .attr("fill", "black")
    .on("mouseover", function(d){
        d3.select(this).attr("fill","blue");
        d.onFocus();
    })
    .on("mouseout", function(d){
        d3.select(this).attr("fill","black");
        d.onFocusLost();
    });
    
    var links = svg.selectAll(".link")
    .data(label_array)
    .enter()
    .append("line")
    .attr("class", "link")
    .attr("x1", function(d) { return (d.x); })
    .attr("y1", function(d) { return (d.y); })
    .attr("x2", function(d) { return (d.x); })
    .attr("y2", function(d) { return (d.y); })
    .attr("stroke-width", 0.6)
    .attr("stroke", "gray");
    
    var index = 0;
    labels.each(function() {
        label_array[index].width = this.getBBox().width;
        label_array[index].height = this.getBBox().height;
        index += 1;
    });
    
    d3.labeler()
        .label(label_array)
        .anchor(anchor_array)
        .width(w)
        .height(h)
        .start(50);
    
    labels
        .transition()
        .duration(800)
        .attr("x", function(d) { return (d.x); })
        .attr("y", function(d) { return (d.y); });
    
    links
        .transition()
        .duration(800)
        .attr("x2",function(d) { return (d.x); })
        .attr("y2",function(d) { return (d.y); });
    

    For a more in depth look at how D3-Labeler works, see "A D3 plug-in for automatic label placement using simulated annealing"

    Jeff Heaton's "Artificial Intelligence for Humans, Volume 1" also does an excellent job at explaining the simulated annealing process.

    0 讨论(0)
提交回复
热议问题