D3js:自动放置标签以避免重叠? (强制排斥)

Posted

技术标签:

【中文标题】D3js:自动放置标签以避免重叠? (强制排斥)【英文标题】:D3js: Automatic labels placement to avoid overlaps? (force repulsion) 【发布时间】:2013-06-29 19:14:35 【问题描述】:

如何对地图的标签施加力排斥,以便它们自动找到正确的位置?


博斯托克的“让我们做一张地图”

Mike Bostock 的 Let's Make a Map(截图如下)。默认情况下,标签放置在点的坐标和多边形/多边形的path.centroid(d) + 简单的左对齐或右对齐,因此它们经常发生冲突。

手工标签放置

I met 的一项改进需要添加人工修复的IF,并根据需要添加任意数量的修复程序,例如:

.attr("dy", function(d) if(d.properties.name==="Berlin") return ".9em" )

随着要重新调整的标签数量的增加,整体变得越来越脏:​​

//places's labels: point objects
svg.selectAll(".place-label")
    .data(topojson.object(de, de.objects.places).geometries)
  .enter().append("text")
    .attr("class", "place-label")
    .attr("transform", function(d)  return "translate(" + projection(d.coordinates) + ")"; )
    .attr("dy", ".35em")
    .text(function(d)  if (d.properties.name!=="Berlin"&&d.properties.name!=="Bremen")return d.properties.name; )
    .attr("x", function(d)  return d.coordinates[0] > -1 ? 6 : -6; )
    .style("text-anchor", function(d)  return d.coordinates[0] > -1 ? "start" : "end"; );

//districts's labels: polygons objects.
svg.selectAll(".subunit-label")
    .data(topojson.object(de, de.objects.subunits).geometries)
  .enter().append("text")
    .attr("class", function(d)  return "subunit-label " + d.properties.name; )
    .attr("transform", function(d)  return "translate(" + path.centroid(d) + ")"; )
    .attr("dy", function(d)
    //handmade IF
        if( d.properties.name==="Sachsen"||d.properties.name==="Thüringen"|| d.properties.name==="Sachsen-Anhalt"||d.properties.name==="Rheinland-Pfalz")
            return ".9em"
        else if(d.properties.name==="Brandenburg"||d.properties.name==="Hamburg")
            return "1.5em"
        else if(d.properties.name==="Berlin"||d.properties.name==="Bremen")
            return "-1em"elsereturn ".35em"
    )
    .text(function(d)  return d.properties.name; );

需要更好的解决方案

这对于较大的地图和标签集是无法管理的。 如何为这两个类添加力排斥:.place-label.subunit-label

这个问题是一场头脑风暴,因为我没有截止日期,但我很好奇。我正在考虑将这个问题作为 Migurski/Dymo.py 的基本 D3js 实现。 Dymo.py 的 README.md 文档设置了大量的目标,从中选择核心需求和功能(20% 的工作,80% 的结果)。

    初始放置: Bostock 以相对于地理点的左/右定位提供了一个良好的开端。 标签间排斥:可以采用不同的方法,Lars 和 Navarrc 分别提出了一种, 标签湮灭:标签湮灭函数,当一个标签的整体排斥太强烈时,因为挤压在其他标签之间,湮灭的优先级是随机的或基于population数据值,我们可以通过 NaturalEarth 的 .shp 文件获取。 [Luxury] 标签到点排斥:带有固定点和移动标签。但这相当奢侈。

我忽略了标签排斥是否会跨标签层和标签类别起作用。但是让国家标签和城市标签不重叠也可能是一种奢侈。

【问题讨论】:

我认为向地点标签添加强制排斥力可以使一些标签脱离其各自的区域。另一件需要考虑的事情是,不同类型的标签可以在某些地图中重叠,城市名称可以覆盖国家名称,但字体非常不同。我认为最终的解决方案可能会更复杂,只是增加了排斥力。 我在这里使用了强制布局来定位标签:larsko.org/v/igdp/index-alt.html 你的情况比较复杂,因为它涉及到两个维度,但你也许可以重用一些代码。 @PabloNavarro:首先,如何对我的物品施加排斥力。后来,力量可能是微妙的。它需要一个随距离迅速减小的斥力,R = 1/x。这种调整将是另一个问题。 我实现了上述策略的演示。它并不完美,但它可以提供帮助。 bl.ocks.org/pnavarrc/5913636 我知道这与强制排斥无关,但正如 Mike Bostock 在教程中指出的那样,有这个脚本 github.com/migurski/Dymo 应该可以解决问题(我无法让它工作tho,我什至在这里发布了一个问题以获得一些建议,但希望你可以!) 【参考方案1】:

在我看来,力布局不适合在地图上放置标签。原因很简单——标签应该尽可能靠近他们标注的地方,但是强制布局没有强制执行这一点。事实上,就模拟而言,混淆标签并没有什么坏处,这对于地图来说显然是不可取的。

可以在力布局之上实现一些东西,将地点本身作为固定节点,并在地点与其标签之间施加吸引力,而标签之间的力将是排斥的。这可能需要修改强制布局实现(或同时进行多个强制布局),所以我不打算走这条路。

我的解决方案仅依赖于碰撞检测:对于每对标签,检查它们是否重叠。如果是这种情况,请将它们移开,其中移动的方向和幅度来自重叠。这样,只有实际重叠的标签才会被移动,而标签只会移动一点点。重复此过程,直到没有运动发生。

代码有些复杂,因为检查重叠非常混乱。我不会在这里发布整个代码,它可以在this demo 中找到(请注意,我已经将标签做得更大以夸大效果)。关键位如下所示:

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
                
              
            );
       );
  

整个事情远非完美——请注意,有些标签离他们标注的地方很远,但方法是通用的,至少应该避免标签重叠。

【讨论】:

哦,整个事情当然可以更有效地实施! 在我的回答中,我建议将标签吸引到这些地方,但在它们之间被排斥。但是,强制布局并不能保证标签不会重叠。此处提出的方法可行,但不能保证标签会靠近原始位置。我认为在大多数情况下,强制布局、文本对齐和小调整的组合可以工作,但同样不能保证。 是的,这不是一个简单的解决方案的问题。 Dymo 看起来是最好的解决方案,但显然将其移植到 javascript 需要相当多的工作。 我正在考虑一个基本的 D3js 版本的 Dymo.py,它具有核心功能,完成了 80% 的工作。我澄清了有关需求的问题,因为这个问题更像是针对具有挑战性的 D3js / 网络制图问题的长期头脑风暴。 好吧,我的解决方案当然不能满足您的要求(我认为这个项目对于 SO 问题来说太大了)。我会把决定权留给你——不管怎样都很好。【参考方案2】:

一种选择是使用force layout with multiple foci。每个焦点必须位于特征的质心,将标签设置为仅被相应的焦点吸引。这样,每个标签将倾向于靠近特征的质心,但与其他标签的排斥可能会避免重叠问题。

比较:

M. Bostock 的"Lets Make a Map" tutorial (resulting map), 我的gist Automatic Labels Placement version (resulting map) 实施焦点战略。

相关代码:

// 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();

【讨论】:

您的代码有效。另一方面,我没有时间用不同的力量进一步测试或开发其他方法。我现在验证了您的答案,但如果出现更好的答案,我最终可能会验证。这个问题是地图制作的核心,我希望未来几个月会出现一个很棒的解决方案。 我认为随着距离的增加消失得更快的力量可以带来更好的布局。 更新:之前的一些版本,力布局允许设置最大作用距离,这将稍微改进这种方法(你可以看到最北区域的标签被所有标签排斥) .更新要点bl.ocks.org/pnavarrc/5913636 @PabloNavarro 的建议是最佳的整体选择。只要标签不重叠,Lars 的选择就会随机放置标签。这提供了排序。 我认为这是最好的解决方案。我在我当前的项目中使用它,它很快而且很容易。我改变了一件事:为了防止标签移动,我运行强制布局固定次数,然后显示标签,如 Mike Bostock 示例 bl.ocks.org/mbostock/1667139 。【参考方案3】:

虽然 ShareMap-dymo.js 可能有效,但它似乎没有很好的文档记录。我找到了一个适用于更一般情况的库,有据可查并且还使用模拟退火:D3-Labeler

我已将使用示例与此 jsfiddle 放在一起。D3-Labeler 示例页面使用 1,000 次迭代。我发现这是相当不必要的,而且 50 次迭代似乎工作得很好——即使对于几百个数据点,这也非常快。我相信这个库与 D3 集成的方式和效率方面都有改进的余地,但我自己无法做到这一点。如果我有时间提交 PR,我会更新这个帖子。

以下是相关代码(有关更多文档,请参阅 D3-Labeler 链接):

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); );

如需更深入地了解 D3-Labeler 的工作原理,请参阅 "A D3 plug-in for automatic label placement using simulated annealing"

Jeff Heaton 的“人类人工智能,第 1 卷”在解释模拟退火过程方面也做得非常出色。

【讨论】:

对于强制导向的方法,我能够将这个小提琴放在一起 jsfiddle.net/s3logic/j789j3xt 我源自:bl.ocks.org/ilyabo/2585241 ...但我无法让它在Dorling 制图应用程序 这确实是最好的解决方案。标签放置必须通过约束优化来完成,没有“强制布局”可以得到类似的结果。我们已经在我们的项目中使用了它,它工作正常 这很好用。唯一的问题是它在不需要移动的点上运行算法。【参考方案4】:

您可能对专门为此目的设计的d3fc-label-layout 组件(用于D3v5)感兴趣。该组件提供了一种机制,用于根据子组件的矩形边界框排列子组件。您可以应用贪心或模拟退火策略以尽量减少重叠。

这是一个代码 sn-p,它演示了如何将此布局组件应用于 Mike Bostock 的地图示例:

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);

这是结果的小截图:

你可以在这里看到一个完整的例子:

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

披露:正如下面评论中所讨论的,我是这个项目的核心贡献者,所以显然我有些偏见。完全归功于这个问题的其他答案,这给了我们灵感!

【讨论】:

感谢您提及该组件。我一直错过解决这个问题的彻底实施。但是,您应该添加一个disclosure,它是您自己的项目。 @altocumulus - 好电话,我添加了一个披露,表明我对这个解决方案有偏见。谢谢! 你好@ColinE,谢谢。披露很酷:看到一些人受到 SO 的启发,而不是通过答案和代码做出贡献,这很酷。太棒了:) @ColinE 您好,感谢您的库。但是如何将它与 mapbox 或 leaflet.js 等其他库一起使用?有没有办法将一些带有 x,y,width,height 的数组传递给 fc.layoutLabel(strategy) 并获得一个新排列的坐标数组?谢谢 @ColinE 有没有办法用不仅仅是文本来构建textLabels?我想要一些 <tspan> 元素来设置样式。【参考方案5】:

对于 2D 案例 以下是一些非常相似的示例:

一个http://bl.ocks.org/1691430 两个http://bl.ocks.org/1377729

感谢 Alexander Skaburskis 提出这个问题here


对于一维情况 对于那些在 1-D 中搜索类似问题的解决方案的人,我可以分享我的沙箱 JSfiddle,我试图在其中解决它。它远非完美,但它确实做到了。

左:沙盒模型,右:示例用法

这里是代码 sn-p,您可以通过按帖子末尾的按钮运行它,以及代码本身。运行时点击字段定位固定节点。

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>

【讨论】:

以上是关于D3js:自动放置标签以避免重叠? (强制排斥)的主要内容,如果未能解决你的问题,请参考以下文章

无法将标签添加到 d3js 强制布局网络可视化

D3Js 圆环图,避免标签文本覆盖

使用 foreignObject 在 d3js 强制布局中制作 contenteditable 标签并在 Chrome 上拖动

Highcharts - 如何强制dataLabels在重叠内容时显示标签

如何在 QTabWidget Stretch 中制作标签以避免滚动

如何避免在散点图中重叠文本?