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

核能气质少年 提交于 2019-11-30 18:45:53

问题


I believe I found a bug in D3 v4.x (v4.5.0), when in a force-directed chart one combines d3.forceCollide to prevent nodes from overlapping and d3.forceX/Y to set the position of the nodes with a high strength value, and I’m about to create an issue in D3 GitHub repository.

However, before creating the issue, I’d like to check it with the D3 users here on StackOverflow: it may not be a bug , it may be the very expected behaviour of using such combination.

The code

In the S.O. snippet below, I’m plotting 900 (30 x 30) circles. They are positioned using d3.forceX and d3.forceY, with a high strength value (the default value is 0.1):

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

To prevent circles from overlapping, I’m using d3.forceCollide, based on their radiuses:

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

The radiuses are just 2px, except the central node, which I made purposely larger. This is the node that you’ll drag around to reproduce the problem.

Description of the problem

Click “run code snippet” and drag the central circle around. It will repel the small circles, as configured in d3.forceCollide.

However, this big circle, as any other node, has its own x and y positions set by d3.forceX/Y, and those forces are moving the center of the collision force towards the circle’s original position. You can see that, the farther you move it from the center, the more inaccurate the repelling force becomes: it is almost as if there was a ghost circle repelling the small circles, the ghost being always between the actual SVG element and its position set by 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>

In this second snippet, with the default strength (which is 0.1), the strange behaviour disappears:

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>

My question:

Is this the expected behaviour when one mixes d3.forceCollide and d3.forceX/Y with such high strength? Shouldn’t d3.forceCollide be based on the actual cx/cy (d.x/d.y) position of the SVG circle element? I understand that, as the documentation makes clear,

Higher values moves nodes more quickly to the target position

However, d3.forceX/Y is not moving the actual SVG element, only the calculated center of the collision force.

So, is this a bug or the expected behaviour?


回答1:


As of v4 the workings of the force layout were 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 by adding the resulting (i.e. integrated over all forces) velocities to the current positions.

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

This is the reason, why the collision seems to be centered with an offset. This is much more obvious in the first snippet because of your choice of forces and their respective parameters. It is worth noting, though, that the effect is also visible in your second example, if much less prominent.

It is important to notice, that the positioning forces d3.forceX and d3.forceY are somewhat powerful and reckless (emphasis mine):

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.

Setting the strength to 1, which is the upper limit of the recommended range, will almost immediately force the nodes to the end position whereby diminishing the smaller effects of other forces. Integrating this dominant effect together with the less powerful effects of the other forces gives the behaviour as shown in your first snippet. Because of the strong forceX and forceY the nodes are forced to their positions in a rigid grid emphasizing the hole around the previous position.

Another issues adding to the problem is that you are not re-heating the simulation properly. In your dragstarted and dragended handler functions you are using simulation.alphaTarget to heat up the system, which is not the correct way of doing it. To control the entropy of the system, you should always use simulation.alpha instead of simulation.alphaTarget. While the latter will kind of work in some cases because of the way it is used in the formula, it is a hack and the behaviour is most likely not what you want. To wrap this up, use alpha to control the heat and alphaTarget, alphaMin or alphaDecay to tweak the curve of the decay. The following snippet is copied from your question and has the re-heating adjusted to use 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>

As you can see, when dragging and holding the large circle, the offset will slowly decrease and eventually vanish, because the simulation is re-heated enough to keep it running.

Is it a bug? No, it is the way, the force layout works in v4. This is enhanced by some extreme parameter settings.



来源:https://stackoverflow.com/questions/42185092/conflict-between-d3-forcecollide-and-d3-forcex-y-with-high-strength-value

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!