d3.forceCollide() 和 d3.forceX/Y() 之间的冲突具有高 strength() 值

Conflict between d3.forceCollide() and d3.forceX/Y() with high strength() value

我相信我在 D3 v4.x (v4.5.0) 中发现了一个错误,当在力导向图表中结合 d3.forceCollide to prevent nodes from overlapping and d3.forceX/Y 设置节点的位置时 strength 值,我将在 D3 GitHub 存储库中创建一个问题。

然而,在创建问题之前,我想在 Whosebug 上与 D3 用户核实一下:它可能不是一个错误,它可能是使用这种组合的预期行为。

密码

在S.O。下面的代码片段,我正在绘制 900 (30 x 30) 个圆圈。它们使用d3.forceXd3.forceY定位,高强度值(默认值为0.1):

.force("xPos", d3.forceX(d => scale(d.x)).strength(1))
.force("yPos", d3.forceY(d => scale(d.y)).strength(1))

为了防止圆重叠,我使用 d3.forceCollide,基于它们的半径:

.force("collide", d3.forceCollide(d => d.radius * 1.2).strength(1))

半径只有 2px,除了中心节点,我特意把它做得更大。这是您要四处拖动以重现问题的节点。

问题描述

单击“运行 代码片段”并拖动中间的圆圈。它将排斥小圆圈,如 d3.forceCollide 中配置的那样。

然而,这个大圆圈和其他任何节点一样,有自己的 xy 位置,这些位置由 d3.forceX/Y 设置,并且这些力正在移动碰撞力的中心朝向圆圈的原始位置。你可以看到,它离中心越远,排斥力就越不准确:几乎就像有一个幽灵圈排斥小圆圈,幽灵总是在实际的 SVG 元素和它的位置集之间通过 d3.forceX/Y.

var n = 30,
 width = 300,
 padding = 5,
 nodes = [];

for (var y = 0; y < n; ++y) {
 for (var x = 0; x < n; ++x) {
  nodes.push({
   x: x,
   y: y
  })
 }
}

var svg = d3.select("body")
 .append("svg")
 .attr("width", width)
 .attr("height", width);

var scale = d3.scaleLinear()
 .domain([0, 29])
 .range([padding, width - padding]);

var simulation = d3.forceSimulation()
 .force("charge", d3.forceManyBody().strength(-1))
 .force("xPos", d3.forceX(d => scale(d.x)).strength(1))
 .force("yPos", d3.forceY(d => scale(d.y)).strength(1))
 .force("collide", d3.forceCollide(d => d.radius * 1.2).strength(1));

var circles = svg.selectAll("foo")
 .data(nodes)
 .enter()
 .append("circle")
 .attr("fill", "darkslateblue")
 .attr("r", d => {
  d.x == 14 && d.y == 14 ? d.radius = 25 : d.radius = 2;
  return d.radius
 })
 .call(d3.drag()
  .on("start", dragstarted)
  .on("drag", dragged)
  .on("end", dragended));

simulation.nodes(nodes)
 .on("tick", ticked);

function ticked() {
 circles.attr("cx", d => d.x).attr("cy", d => d.y)
}

function dragstarted(d) {
 if (!d3.event.active) simulation.alphaTarget(0.3).restart();
 d.fx = d.x;
 d.fy = d.y;
}

function dragged(d) {
 d.fx = d3.event.x;
 d.fy = d3.event.y;
}

function dragended(d) {
 if (!d3.event.active) simulation.alphaTarget(0);
 d.fx = null;
 d.fy = null;
}
<script src="https://d3js.org/d3.v4.min.js"></script>

在第二个代码段中,使用默认值 strength(即 0.1),奇怪的行为消失了:

var n = 30,
 width = 300,
 padding = 5,
 nodes = [];

for (var y = 0; y < n; ++y) {
 for (var x = 0; x < n; ++x) {
  nodes.push({
   x: x,
   y: y
  })
 }
}

var svg = d3.select("body")
 .append("svg")
 .attr("width", width)
 .attr("height", width);

var scale = d3.scaleLinear()
 .domain([0, 29])
 .range([padding, width - padding]);

var simulation = d3.forceSimulation()
 .force("charge", d3.forceManyBody().strength(-1))
 .force("xPos", d3.forceX(d => scale(d.x)).strength(0.1))
 .force("yPos", d3.forceY(d => scale(d.y)).strength(0.1))
 .force("collide", d3.forceCollide(d => d.radius * 1.2).strength(1));

var circles = svg.selectAll("foo")
 .data(nodes)
 .enter()
 .append("circle")
 .attr("fill", "darkslateblue")
 .attr("r", d => {
  d.x == 14 && d.y == 14 ? d.radius = 25 : d.radius = 2;
  return d.radius
 })
 .call(d3.drag()
  .on("start", dragstarted)
  .on("drag", dragged)
  .on("end", dragended));

simulation.nodes(nodes)
 .on("tick", ticked);

function ticked() {
 circles.attr("cx", d => d.x).attr("cy", d => d.y)
}

function dragstarted(d) {
 if (!d3.event.active) simulation.alphaTarget(0.3).restart();
 d.fx = d.x;
 d.fy = d.y;
}

function dragged(d) {
 d.fx = d3.event.x;
 d.fy = d3.event.y;
}

function dragended(d) {
 if (!d3.event.active) simulation.alphaTarget(0);
 d.fx = null;
 d.fy = null;
}
<script src="https://d3js.org/d3.v4.min.js"></script>

我的问题:

这是将 d3.forceCollided3.forceX/Y 与如此高的 strength 混合使用时的预期行为吗? d3.forceCollide 不应该基于 SVG 圆形元素的实际 cx/cy (d.x/d.y) 位置吗?据我了解,正如文档所明确的那样,

Higher values moves nodes more quickly to the target position

但是,d3.forceX/Y 并没有移动实际的 SVG 元素,只是移动了碰撞力的计算中心。

那么,这是错误还是预期的行为?

从 v4 开始,力布局的工作方式是 switched from position Verlet integration to velocity Verlet integration. The forces, which are consecutively applied, will calculate their changes to the velocities of the nodes. These effects are calculated by each force and are added up to the nodes' vx and vy velocity values. After all forces have been calculated for the actual tick, the new positions of the nodes are calculated 通过将结果(即所有力的积分)速度添加到 当前 位置。

forces.each(function(force) {
  force(alpha);
});

for (i = 0; i < n; ++i) {
  node = nodes[i];
  if (node.fx == null) node.x += node.vx *= velocityDecay;
  else node.x = node.fx, node.vx = 0;
  if (node.fy == null) node.y += node.vy *= velocityDecay;
  else node.y = node.fy, node.vy = 0;
}

这就是碰撞似乎以偏移为中心的原因。这在第一个片段中更加明显,因为您选择了力及其各自的参数。不过,值得注意的是,在您的第二个示例中也可以看到这种效果,只是不那么明显。

重要的是要注意,定位力 d3.forceXd3.forceY 有点强大并且 reckless(强调我的):

The strength determines how much to increment the node’s x-velocity: (x - node.x) × strength. For example, a value of 0.1 indicates that the node should move a tenth of the way from its current x-position to the target x-position with each application. Higher values moves nodes more quickly to the target position, often at the expense of other forces or constraints.

将强度设置为 1,这是推荐范围的上限,几乎会立即迫使节点到达终点位置,从而减少其他力的较小影响。将这种主导效应与其他力量的弱势效应结合起来,就会产生第一个片段中所示的行为。由于强大的 forceXforceY 节点被迫在刚性网格中的位置强调先前位置周围的孔。

另一个问题是你没有正确地 re-heating 模拟。在您的 dragstarteddragended 处理函数中,您使用 simulation.alphaTarget 来加热系统,这不是正确的做法。要控制系统的熵,您应该始终使用 simulation.alpha 而不是 simulation.alphaTarget。虽然后者由于在公式中的使用方式,在某些情况下会 有点 的作用,但它是一种 hack,其行为很可能不是您想要的。总结一下,使用 alpha 控制热量并使用 alphaTargetalphaMinalphaDecay 调整衰减曲线。以下片段是从您的问题中复制的,并已将 re-heating 调整为使用 simulation.alpha.

var n = 30,
 width = 300,
 padding = 5,
 nodes = [];

for (var y = 0; y < n; ++y) {
 for (var x = 0; x < n; ++x) {
  nodes.push({
   x: x,
   y: y
  })
 }
}

var svg = d3.select("body")
 .append("svg")
 .attr("width", width)
 .attr("height", width);

var scale = d3.scaleLinear()
 .domain([0, 29])
 .range([padding, width - padding]);

var simulation = d3.forceSimulation()
 .force("charge", d3.forceManyBody().strength(-1))
 .force("xPos", d3.forceX(d => scale(d.x)).strength(1))
 .force("yPos", d3.forceY(d => scale(d.y)).strength(1))
 .force("collide", d3.forceCollide(d => d.radius * 1.2).strength(1));

var circles = svg.selectAll("foo")
 .data(nodes)
 .enter()
 .append("circle")
 .attr("fill", "darkslateblue")
 .attr("r", d => {
  d.x == 14 && d.y == 14 ? d.radius = 25 : d.radius = 2;
  return d.radius
 })
 .call(d3.drag()
  .on("start", dragstarted)
  .on("drag", dragged)
  .on("end", dragended));

simulation.nodes(nodes)
 .on("tick", ticked);

function ticked() {
 circles.attr("cx", d => d.x).attr("cy", d => d.y)
}

function dragstarted(d) {
 if (!d3.event.active) simulation.alpha(0.3).restart();
 d.fx = d.x;
 d.fy = d.y;
}

function dragged(d) {
 d.fx = d3.event.x;
 d.fy = d3.event.y;
}

function dragended(d) {
 if (!d3.event.active) simulation.alpha(0);
 d.fx = null;
 d.fy = null;
}
<script src="https://d3js.org/d3.v4.min.js"></script>

如你所见,当拖动并按住大圆圈时,偏移量会慢慢减小并最终消失,因为模拟re-heated足以保持它运行。

这是一个错误吗?不,是这样,强制布局在 v4 中有效。一些极端的参数设置增强了这一点。