From b47fd693f76ac0518a025a558918b75d5b9f4abe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 25 Jun 2020 18:32:13 +0200 Subject: [PATCH 01/11] make forceCenter much less aggressive (with a small strength multiplied by alpha) fixes #81 --- src/center.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/center.js b/src/center.js index b8ce38e..8c9c6fc 100644 --- a/src/center.js +++ b/src/center.js @@ -1,10 +1,10 @@ export default function(x, y) { - var nodes; + var nodes, strength = 0.05; if (x == null) x = 0; if (y == null) y = 0; - function force() { + function force(alpha) { var i, n = nodes.length, node, @@ -15,8 +15,10 @@ export default function(x, y) { node = nodes[i], sx += node.x, sy += node.y; } - for (sx = sx / n - x, sy = sy / n - y, i = 0; i < n; ++i) { - node = nodes[i], node.x -= sx, node.y -= sy; + sx = (sx / n - x) * alpha * strength; + sy = (sy / n - y) * alpha * strength; + for (i = 0; i < n; ++i) { + node = nodes[i], node.vx -= sx, node.vy -= sy; } } From ac23e0b25a0afdd668da430c2774fc7def1a0e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 25 Jun 2020 19:19:23 +0200 Subject: [PATCH 02/11] only start the simulation when someone is listening fixes #45 --- src/simulation.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/simulation.js b/src/simulation.js index f1e751d..2350e68 100644 --- a/src/simulation.js +++ b/src/simulation.js @@ -21,6 +21,7 @@ export default function(nodes) { velocityDecay = 0.6, forces = new Map(), stepper = timer(step), + started = stepper.stop() || 0, event = dispatch("tick", "end"); if (nodes == null) nodes = []; @@ -144,7 +145,9 @@ export default function(nodes) { }, on: function(name, _) { - return arguments.length > 1 ? (event.on(name, _), simulation) : event.on(name); + return arguments.length > 1 + ? (event.on(name, _), started++ || stepper.restart(step), simulation) + : event.on(name); } }; } From 676836fd9593ec874ea3fbbc5a7617112dc44ad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 25 Jun 2020 18:32:13 +0200 Subject: [PATCH 03/11] make forceCenter much less aggressive (with a small strength multiplied by alpha) fixes #81 --- src/center.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/center.js b/src/center.js index b8ce38e..8c9c6fc 100644 --- a/src/center.js +++ b/src/center.js @@ -1,10 +1,10 @@ export default function(x, y) { - var nodes; + var nodes, strength = 0.05; if (x == null) x = 0; if (y == null) y = 0; - function force() { + function force(alpha) { var i, n = nodes.length, node, @@ -15,8 +15,10 @@ export default function(x, y) { node = nodes[i], sx += node.x, sy += node.y; } - for (sx = sx / n - x, sy = sy / n - y, i = 0; i < n; ++i) { - node = nodes[i], node.x -= sx, node.y -= sy; + sx = (sx / n - x) * alpha * strength; + sy = (sy / n - y) * alpha * strength; + for (i = 0; i < n; ++i) { + node = nodes[i], node.vx -= sx, node.vy -= sy; } } From c1ee87874cd3cda0fdcf91b9a74ded91fcd337ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 8 Jul 2020 14:05:05 +0200 Subject: [PATCH 04/11] a few tests for #17 --- test/center-test.js | 27 +++++++++++++ test/collide-test.js | 48 +++++++++++++++++++++++ test/find-test.js | 24 ++++++++++++ test/nodeEqual.js | 23 +++++++++++ test/simulation-test.js | 21 +++++++++++ test/x-test.js | 84 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 227 insertions(+) create mode 100644 test/center-test.js create mode 100644 test/collide-test.js create mode 100644 test/find-test.js create mode 100644 test/nodeEqual.js create mode 100644 test/simulation-test.js create mode 100644 test/x-test.js diff --git a/test/center-test.js b/test/center-test.js new file mode 100644 index 0000000..ef0c14a --- /dev/null +++ b/test/center-test.js @@ -0,0 +1,27 @@ +var tape = require("tape"), + force = require("../"); + +require("./nodeEqual.js"); + +tape("forceCenter repositions nodes", function(test) { + const center = force.forceCenter(0, 0); + const f = force.forceSimulation().force("center", center).stop(); + const a = { x: 100, y: 0 }, b = { x: 200, y: 0 }, c = { x: 300, y: 0 }; + f.nodes([a, b, c]); + f.tick(); + test.nodeEqual(a, { index: 0, x: -100, y: 0, vy: 0, vx: 0 }); + test.nodeEqual(b, { index: 1, x: 0, y: 0, vy: 0, vx: 0 }); + test.nodeEqual(c, { index: 2, x: 100, y: 0, vy: 0, vx: 0 }); + test.end(); +}); + + +tape("forceCenter respects fixed positions", function(test) { + const center = force.forceCenter(); + const f = force.forceSimulation().force("center", center).stop(); + const a = { fx: 0, fy:0 }, b = {}, c = {}; + f.nodes([a, b, c]); + f.tick(); + test.nodeEqual(a, { fx: 0, fy: 0, index: 0, x: 0, y: 0, vy: 0, vx: 0 }); + test.end(); +}); diff --git a/test/collide-test.js b/test/collide-test.js new file mode 100644 index 0000000..f9ed20f --- /dev/null +++ b/test/collide-test.js @@ -0,0 +1,48 @@ +var tape = require("tape"), + force = require("../"); + +require("./nodeEqual.js"); + +tape("forceCollide collides nodes", function(test) { + const collide = force.forceCollide(1); + const f = force.forceSimulation().force("collide", collide).stop(); + const a = {}, b = {}, c = {}; + f.nodes([a, b, c]); + f.tick(10); + test.nodeEqual(a, { index: 0, x: 7.0710678118654755, y: 0, vy: 0, vx: 0 }); + test.nodeEqual(b, { index: 1, x: -9.03088751750192, y: 8.27303273571596, vy: 0, vx: 0 }); + test.nodeEqual(c, { index: 2, x: 1.3823220809823638, y: -15.750847141167634, vy: 0, vx: 0 }); + collide.radius(100); + f.tick(10); + test.nodeEqual(a, { index: 0, x: 174.08616723117228, y: 66.51743051995625, vy: 0.26976816231064354, vx: 0.677346615710878 }); + test.nodeEqual(b, { index: 1, x: -139.73606544743998, y: 95.69860503079263, vy: 0.3545632444404687, vx: -0.5300880593105067 }); + test.nodeEqual(c, { index: 2, x: -34.9275994083864, y: -169.69384995620052, vy: -0.6243314067511122, vx: -0.1472585564003713 }); + test.end(); +}); + + +tape("forceCollide respects fixed positions", function(test) { + const collide = force.forceCollide(1); + const f = force.forceSimulation().force("collide", collide).stop(); + const a = { fx: 0, fy:0 }, b = {}, c = {}; + f.nodes([a, b, c]); + f.tick(10); + test.nodeEqual(a, { fx: 0, fy: 0, index: 0, x: 0, y: 0, vy: 0, vx: 0 }); + collide.radius(100); + f.tick(10); + test.nodeEqual(a, { fx: 0, fy: 0, index: 0, x: 0, y: 0, vy: 0, vx: 0 }); + test.end(); +}); + +tape("forceCollide jiggles equal positions", function(test) { + const collide = force.forceCollide(1); + const f = force.forceSimulation().force("collide", collide).stop(); + const a = { x: 0, y:0 }, b = { x:0, y: 0 }; + f.nodes([a, b]); + f.tick(); + test.assert(a.x !== b.x); + test.assert(a.y !== b.y); + test.equal(a.vx, -b.vx); + test.equal(a.vy, -b.vy); + test.end(); +}); diff --git a/test/find-test.js b/test/find-test.js new file mode 100644 index 0000000..e7c0dd2 --- /dev/null +++ b/test/find-test.js @@ -0,0 +1,24 @@ +var tape = require("tape"), + force = require("../"); + +require("./nodeEqual.js"); + +tape("simulation.find finds a node", function(test) { + const f = force.forceSimulation().stop(); + const a = { x: 5, y: 0 }, b = { x: 10, y: 16 }, c = { x: -10, y: -4}; + f.nodes([a, b, c]); + test.equal(f.find(0, 0), a); + test.equal(f.find(0, 20), b); + test.end(); +}); + +tape("simulation.find(x, y, radius) finds a node within radius", function(test) { + const f = force.forceSimulation().stop(); + const a = { x: 5, y: 0 }, b = { x: 10, y: 16 }, c = { x: -10, y: -4}; + f.nodes([a, b, c]); + test.equal(f.find(0, 0), a); + test.equal(f.find(0, 0, 1), undefined); + test.equal(f.find(0, 20), b); + test.end(); +}); + diff --git a/test/nodeEqual.js b/test/nodeEqual.js new file mode 100644 index 0000000..788f02e --- /dev/null +++ b/test/nodeEqual.js @@ -0,0 +1,23 @@ +var tape = require("tape"); + +tape.Test.prototype.nodeEqual = nodeEqual; + +function nodeEqual(actual, expected, delta) { + delta = delta || 1e-6; + this._assert(nodeEqual(actual, expected, delta), { + message: "should be similar", + operator: "nodeEqual", + actual: actual, + expected: expected + }); + + function nodeEqual(actual, expected, delta) { + return actual.index == expected.index + && Math.abs(actual.x - expected.x) < delta + && Math.abs(actual.vx - expected.vx) < delta + && Math.abs(actual.y - expected.y) < delta + && Math.abs(actual.vy - expected.vy) < delta + && !(Math.abs(actual.fx - expected.fx) > delta) + && !(Math.abs(actual.fy - expected.fy) > delta); + } +} \ No newline at end of file diff --git a/test/simulation-test.js b/test/simulation-test.js new file mode 100644 index 0000000..e4d09bd --- /dev/null +++ b/test/simulation-test.js @@ -0,0 +1,21 @@ +var tape = require("tape"), + force = require("../"); + +require("./nodeEqual.js"); + +tape("forceSimulation() returns a simulation", function(test) { + const f = force.forceSimulation().stop(); + test.deepEqual(Object.keys(f).sort(), [ 'alpha', 'alphaDecay', 'alphaMin', 'alphaTarget', 'find', 'force', 'nodes', 'on', 'restart', 'stop', 'tick', 'velocityDecay' ]); + test.end(); +}); + +tape("simulation.nodes(nodes) initializes a simulation with indices & phyllotaxis positions, 0 speed", function(test) { + const f = force.forceSimulation().stop(); + const a = {}, b = {}, c = {}; + f.nodes([a, b, c]); + test.nodeEqual(a, { index: 0, x: 7.0710678118654755, y: 0, vy: 0, vx: 0 }); + test.nodeEqual(b, { index: 1, x: -9.03088751750192, y: 8.27303273571596, vy: 0, vx: 0 }); + test.nodeEqual(c, { index: 2, x: 1.3823220809823638, y: -15.750847141167634, vy: 0, vx: 0 }); + test.end(); +}); + diff --git a/test/x-test.js b/test/x-test.js new file mode 100644 index 0000000..51a989f --- /dev/null +++ b/test/x-test.js @@ -0,0 +1,84 @@ +var tape = require("tape"), + force = require("../"); + +require("./nodeEqual.js"); + +tape("forceX centers nodes", function(test) { + const x = force.forceX(200); + const f = force.forceSimulation().force("x", x).stop(); + const a = { x: 100, y: 0 }, b = { x: 200, y: 0 }, c = { x: 300, y: 0 }; + f.nodes([a, b, c]); + f.tick(30); + test.assert(a.x > 190); + test.assert(a.vx > 0); + test.equal(b.x, 200); + test.equal(b.vx, 0); + test.assert(c.x < 210); + test.assert(c.vx < 0); + test.end(); +}); + +tape("forceY centers nodes", function(test) { + const y = force.forceY(200); + const f = force.forceSimulation().force("y", y).stop(); + const a = { y: 100, x: 0 }, b = { y: 200, x: 0 }, c = { y: 300, x: 0 }; + f.nodes([a, b, c]); + f.tick(30); + test.assert(a.y > 190); + test.assert(a.vy > 0); + test.equal(b.y, 200); + test.equal(b.vy, 0); + test.assert(c.y < 210); + test.assert(c.vy < 0); + test.end(); +}); + +tape("forceX respects fixed positions", function(test) { + const x = force.forceX(200); + const f = force.forceSimulation().force("x", x).stop(); + const a = { fx: 0, fy:0 }, b = {}, c = {}; + f.nodes([a, b, c]); + f.tick(); + test.nodeEqual(a, { fx: 0, fy: 0, index: 0, x: 0, y: 0, vy: 0, vx: 0 }); + test.end(); +}); + +tape("forceY respects fixed positions", function(test) { + const y = force.forceX(200); + const f = force.forceSimulation().force("y", y).stop(); + const a = { fx: 0, fy:0 }, b = {}, c = {}; + f.nodes([a, b, c]); + f.tick(); + test.nodeEqual(a, { fx: 0, fy: 0, index: 0, x: 0, y: 0, vy: 0, vx: 0 }); + test.end(); +}); + +tape("forceX.x() accessor", function(test) { + const x = force.forceX().x(d => d.x0); + const f = force.forceSimulation().force("x", x).stop(); + const a = { x: 100, y: 0, x0: 300 }, b = { x: 200, y: 0, x0: 200 }, c = { x: 300, y: 0, x0: 100 }; + f.nodes([a, b, c]); + f.tick(30); + test.assert(a.x > 290); + test.assert(a.vx > 0); + test.equal(b.x, 200); + test.equal(b.vx, 0); + test.assert(c.x < 110); + test.assert(c.vx < 0); + test.end(); +}); + +tape("forceY.y() accessor", function(test) { + const y = force.forceY().y(d => d.y0); + const f = force.forceSimulation().force("y", y).stop(); + const a = { y: 100, x: 0, y0: 300 }, b = { y: 200, x: 0, y0: 200 }, c = { y: 300, x: 0, y0: 100 }; + f.nodes([a, b, c]); + f.tick(30); + test.assert(a.y > 290); + test.assert(a.vy > 0); + test.equal(b.y, 200); + test.equal(b.vy, 0); + test.assert(c.y < 110); + test.assert(c.vy < 0); + test.end(); +}); From 006ea1bc2f3fb7c41d09c2d845049104a86fd007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 27 Jul 2020 12:33:34 +0200 Subject: [PATCH 05/11] forceCenter is now more gentle --- test/center-test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/center-test.js b/test/center-test.js index ef0c14a..6b468c8 100644 --- a/test/center-test.js +++ b/test/center-test.js @@ -8,7 +8,7 @@ tape("forceCenter repositions nodes", function(test) { const f = force.forceSimulation().force("center", center).stop(); const a = { x: 100, y: 0 }, b = { x: 200, y: 0 }, c = { x: 300, y: 0 }; f.nodes([a, b, c]); - f.tick(); + f.alphaDecay(0).tick(250); test.nodeEqual(a, { index: 0, x: -100, y: 0, vy: 0, vx: 0 }); test.nodeEqual(b, { index: 1, x: 0, y: 0, vy: 0, vx: 0 }); test.nodeEqual(c, { index: 2, x: 100, y: 0, vy: 0, vx: 0 }); From e1a9ecee87b693549a6a16d350429c720b119ad3 Mon Sep 17 00:00:00 2001 From: GerardoFurtado Date: Fri, 2 Feb 2018 12:05:26 +1100 Subject: [PATCH 06/11] Passing functions to radial.x() and radial.y() Currently, the radial force... d3.forceRadial(radius[, x][, y]) ... allows only numbers to the `x` and `y` positions: > If x is specified, sets the x-coordinate of the circle center to the specified number and returns this force. This proposed change allows passing functions to `radial.x()` and `radial.y()`, using the same pattern of other forces. Here is the working demo: https://bl.ocks.org/anonymous/0685343afe4675a022403be8728bc7c4/7dda06727201c128b9ff820b24c2c955e655436d --- src/radial.js | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/radial.js b/src/radial.js index 609516b..dbf2661 100644 --- a/src/radial.js +++ b/src/radial.js @@ -4,17 +4,19 @@ export default function(radius, x, y) { var nodes, strength = constant(0.1), strengths, - radiuses; + radiuses, + xs, + ys; if (typeof radius !== "function") radius = constant(+radius); - if (x == null) x = 0; - if (y == null) y = 0; + if (typeof x !== "function") x = constant(x == null ? 0 : +x); + if (typeof y !== "function") y = constant(y == null ? 0 : +y); function force(alpha) { for (var i = 0, n = nodes.length; i < n; ++i) { var node = nodes[i], - dx = node.x - x || 1e-6, - dy = node.y - y || 1e-6, + dx = node.x - xs[i] || 1e-6, + dy = node.y - ys[i] || 1e-6, r = Math.sqrt(dx * dx + dy * dy), k = (radiuses[i] - r) * strengths[i] * alpha / r; node.vx += dx * k; @@ -27,8 +29,12 @@ export default function(radius, x, y) { var i, n = nodes.length; strengths = new Array(n); radiuses = new Array(n); + xs = new Array(n); + ys = new Array(n); for (i = 0; i < n; ++i) { radiuses[i] = +radius(nodes[i], i, nodes); + xs[i] = +x(nodes[i], i, nodes); + ys[i] = +y(nodes[i], i, nodes); strengths[i] = isNaN(radiuses[i]) ? 0 : +strength(nodes[i], i, nodes); } } @@ -46,11 +52,11 @@ export default function(radius, x, y) { }; force.x = function(_) { - return arguments.length ? (x = +_, force) : x; + return arguments.length ? (x = typeof _ === "function" ? _ : constant(+_), initialize(), force) : x; }; force.y = function(_) { - return arguments.length ? (y = +_, force) : y; + return arguments.length ? (y = typeof _ === "function" ? _ : constant(+_), initialize(), force) : y; }; return force; From 46ce7a9c9420ed42abf17b32de1b56e565bd7b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 6 Jul 2020 18:51:53 +0200 Subject: [PATCH 07/11] document forceRadial.x() and .y() as setting accessor functions instead of numbers --- README.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bf24aef..ad69b1b 100644 --- a/README.md +++ b/README.md @@ -453,8 +453,25 @@ The *radius* accessor is invoked for each [node](#simulation_nodes) in the simul # radial.x([x]) [<>](https://github.com/d3/d3-force/blob/master/src/radial.js "Source") -If *x* is specified, sets the *x*-coordinate of the circle center to the specified number and returns this force. If *x* is not specified, returns the current *x*-coordinate of the center, which defaults to zero. +If *x* is specified, sets the *x*-coordinate accessor to the specified number or function, re-evaluates the *x*-accessor for each node, and returns this force. If *x* is not specified, returns the current *x*-accessor, which defaults to: + +```js +function x() { + return 0; +} +``` + +The *x*-accessor is invoked for each [node](#simulation_nodes) in the simulation, being passed the *node* and its zero-based *index*. The resulting number is then stored internally, such that the target *x*-coordinate of each node is only recomputed when the force is initialized or when this method is called with a new *x*, and not on every application of the force. # radial.y([y]) [<>](https://github.com/d3/d3-force/blob/master/src/radial.js "Source") -If *y* is specified, sets the *y*-coordinate of the circle center to the specified number and returns this force. If *y* is not specified, returns the current *y*-coordinate of the center, which defaults to zero. +If *y* is specified, sets the *y*-coordinate accessor to the specified number or function, re-evaluates the *y*-accessor for each node, and returns this force. If *y* is not specified, returns the current *y*-accessor, which defaults to: + +```js +function y() { + return 0; +} +``` + +The *y*-accessor is invoked for each [node](#simulation_nodes) in the simulation, being passed the *node* and its zero-based *index*. The resulting number is then stored internally, such that the target *y*-coordinate of each node is only recomputed when the force is initialized or when this method is called with a new *y*, and not on every application of the force. + From 32e857130a2e858b2c8880c12e070b0fcf9eac03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 6 Jul 2020 18:58:05 +0200 Subject: [PATCH 08/11] forceRadial().angle() sets the preferred angle as in https://observablehq.com/@fil/forcepolar closes #152 --- README.md | 12 +++++++--- src/math.js | 2 ++ src/radial.js | 66 ++++++++++++++++++++++++++++++++++++++------------- 3 files changed, 60 insertions(+), 20 deletions(-) create mode 100644 src/math.js diff --git a/README.md b/README.md index ad69b1b..273d019 100644 --- a/README.md +++ b/README.md @@ -425,11 +425,11 @@ function y() { The *y*-accessor is invoked for each [node](#simulation_nodes) in the simulation, being passed the *node* and its zero-based *index*. The resulting number is then stored internally, such that the target *y*-coordinate of each node is only recomputed when the force is initialized or when this method is called with a new *y*, and not on every application of the force. -# d3.forceRadial(radius[, x][, y]) [<>](https://github.com/d3/d3-force/blob/master/src/radial.js "Source") +# d3.forceRadial(radius[, x][, y][, angle]) [<>](https://github.com/d3/d3-force/blob/master/src/radial.js "Source") [Radial Force](https://bl.ocks.org/mbostock/cd98bf52e9067e26945edd95e8cf6ef9) -Creates a new positioning force towards a circle of the specified [*radius*](#radial_radius) centered at ⟨[*x*](#radial_x),[*y*](#radial_y)⟩. If *x* and *y* are not specified, they default to ⟨0,0⟩. +Creates a new positioning force towards a circle of the specified [*radius*](#radial_radius) centered at ⟨[*x*](#radial_x),[*y*](#radial_y)⟩, and with a preferred [*angle*](#radial_angle). If *x* and *y* are not specified, they default to ⟨0,0⟩. If *radius* or *angle* are not specified (or null), they are ignored. # radial.strength([strength]) [<>](https://github.com/d3/d3-force/blob/master/src/radial.js "Source") @@ -447,7 +447,7 @@ The strength accessor is invoked for each [node](#simulation_nodes) in the simul # radial.radius([radius]) [<>](https://github.com/d3/d3-force/blob/master/src/radial.js "Source") -If *radius* is specified, sets the circle *radius* to the specified number or function, re-evaluates the *radius* accessor for each node, and returns this force. If *radius* is not specified, returns the current *radius* accessor. +If *radius* is specified, sets the circle *radius* to the specified number or function, re-evaluates the *radius* accessor for each node, and returns this force. If *radius* is not specified, returns the current *radius* accessor. If *angle* is null, the force ignores the radius (see [*radial*.angle](#radial_angle)). The *radius* accessor is invoked for each [node](#simulation_nodes) in the simulation, being passed the *node* and its zero-based *index*. The resulting number is then stored internally, such that the target radius of each node is only recomputed when the force is initialized or when this method is called with a new *radius*, and not on every application of the force. @@ -475,3 +475,9 @@ function y() { The *y*-accessor is invoked for each [node](#simulation_nodes) in the simulation, being passed the *node* and its zero-based *index*. The resulting number is then stored internally, such that the target *y*-coordinate of each node is only recomputed when the force is initialized or when this method is called with a new *y*, and not on every application of the force. +# radial.angle([angle]) [<>](https://github.com/d3/d3-force/blob/master/src/radial.js "Source") + +If *angle* is specified, sets the preferred *angle* to the specified number or function, re-evaluates the *angle* accessor for each node, and returns this force. If *angle* is not specified, returns the current *angle* accessor. If *angle* is null, the force ignores the preferred angle. + +The *angle* accessor is invoked for each [node](#simulation_nodes) in the simulation, being passed the *node* and its zero-based *index*. The resulting number is then stored internally, such that the target angle of each node is only recomputed when the force is initialized or when this method is called with a new *angle*, and not on every application of the force. + diff --git a/src/math.js b/src/math.js new file mode 100644 index 0000000..d38afca --- /dev/null +++ b/src/math.js @@ -0,0 +1,2 @@ +export var pi = Math.PI; +export var radians = pi / 180; diff --git a/src/radial.js b/src/radial.js index dbf2661..3fe3ff1 100644 --- a/src/radial.js +++ b/src/radial.js @@ -1,26 +1,52 @@ import constant from "./constant.js"; +import {radians} from "./math.js"; -export default function(radius, x, y) { +function value(x) { + if (typeof x === "function") return x; + if (x === null || x === undefined || isNaN(x = +x)) return; + return constant(x); +} + +export default function(radius, x, y, angle) { var nodes, strength = constant(0.1), strengths, - radiuses, + radii, xs, - ys; + ys, + angles; - if (typeof radius !== "function") radius = constant(+radius); - if (typeof x !== "function") x = constant(x == null ? 0 : +x); - if (typeof y !== "function") y = constant(y == null ? 0 : +y); + radius = value(radius); + x = value(x) || constant(0); + y = value(y) || constant(0); + angle = value(angle); function force(alpha) { for (var i = 0, n = nodes.length; i < n; ++i) { var node = nodes[i], dx = node.x - xs[i] || 1e-6, dy = node.y - ys[i] || 1e-6, - r = Math.sqrt(dx * dx + dy * dy), - k = (radiuses[i] - r) * strengths[i] * alpha / r; - node.vx += dx * k; - node.vy += dy * k; + r = Math.sqrt(dx * dx + dy * dy); + + if (radius) { + var k = ((radii[i] - r) * strengths[i] * alpha) / r; + node.vx += dx * k; + node.vy += dy * k; + } + + if (angle) { + var a = Math.atan2(dy, dx), + diff = angles[i] - a, + q = r * Math.sin(diff) * (strengths[i] * alpha); + + // the factor below augments the "unease" for points that are opposite + // the correct direction: in that case, though sin(diff) is small, + // tan(diff/2) is very high + q *= Math.hypot(1, Math.tan(diff / 2)); + + node.vx += -q * Math.sin(a); + node.vy += q * Math.cos(a); + } } } @@ -28,14 +54,16 @@ export default function(radius, x, y) { if (!nodes) return; var i, n = nodes.length; strengths = new Array(n); - radiuses = new Array(n); + radii = new Array(n); xs = new Array(n); ys = new Array(n); + angles = new Array(n); for (i = 0; i < n; ++i) { - radiuses[i] = +radius(nodes[i], i, nodes); + if (radius) radii[i] = +radius(nodes[i], i, nodes); xs[i] = +x(nodes[i], i, nodes); ys[i] = +y(nodes[i], i, nodes); - strengths[i] = isNaN(radiuses[i]) ? 0 : +strength(nodes[i], i, nodes); + if (angle) angles[i] = +angle(nodes[i], i, nodes) * radians; + strengths[i] = isNaN(radii[i]) ? 0 : +strength(nodes[i], i, nodes); } } @@ -44,19 +72,23 @@ export default function(radius, x, y) { }; force.strength = function(_) { - return arguments.length ? (strength = typeof _ === "function" ? _ : constant(+_), initialize(), force) : strength; + return arguments.length ? (strength = value(_) || constant(1), initialize(), force) : strength; }; force.radius = function(_) { - return arguments.length ? (radius = typeof _ === "function" ? _ : constant(+_), initialize(), force) : radius; + return arguments.length ? (radius = value(_), initialize(), force) : radius; }; force.x = function(_) { - return arguments.length ? (x = typeof _ === "function" ? _ : constant(+_), initialize(), force) : x; + return arguments.length ? (x = value(_) || constant(0), initialize(), force) : x; }; force.y = function(_) { - return arguments.length ? (y = typeof _ === "function" ? _ : constant(+_), initialize(), force) : y; + return arguments.length ? (y = value(_) || constant(0), initialize(), force) : y; + }; + +force.angle = function(_) { + return arguments.length ? (angle = value(_), initialize(), force) : y; }; return force; From d4089da7df71f274b0eebc73752733523ca80c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 28 Jul 2020 22:22:11 +0200 Subject: [PATCH 09/11] indent Co-authored-by: Mike Bostock --- src/radial.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/radial.js b/src/radial.js index 3fe3ff1..0f59d9e 100644 --- a/src/radial.js +++ b/src/radial.js @@ -87,7 +87,7 @@ export default function(radius, x, y, angle) { return arguments.length ? (y = value(_) || constant(0), initialize(), force) : y; }; -force.angle = function(_) { + force.angle = function(_) { return arguments.length ? (angle = value(_), initialize(), force) : y; }; From 2ef3321b43e36dc71d42bc657f330ea7825c3462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 28 Jul 2020 22:32:12 +0200 Subject: [PATCH 10/11] start the simulation explicitly --- README.md | 6 ++++-- src/simulation.js | 12 ++++++++---- test/simulation-test.js | 17 ++++++++++++++++- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 273d019..6a80f90 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,13 @@ var simulation = d3.forceSimulation(nodes); # d3.forceSimulation([nodes]) [<>](https://github.com/d3/d3-force/blob/master/src/simulation.js "Source") -Creates a new simulation with the specified array of [*nodes*](#simulation_nodes) and no [forces](#simulation_force). If *nodes* is not specified, it defaults to the empty array. The simulator [starts](#simulation_restart) automatically; use [*simulation*.on](#simulation_on) to listen for tick events as the simulation runs. If you wish to run the simulation manually instead, call [*simulation*.stop](#simulation_stop), and then call [*simulation*.tick](#simulation_tick) as desired. +Creates a new simulation with the specified array of [*nodes*](#simulation_nodes) and no [forces](#simulation_force). If *nodes* is not specified, it defaults to the empty array. Use [*simulation*.on](#simulation_on) to listen for tick events as the simulation runs, and [*simulation*.start](#simulation_start) to start the simulator. If you wish to run the simulation manually instead, call [*simulation*.tick](#simulation_tick) as desired. + +# simulation.start() [<>](https://github.com/d3/d3-force/blob/master/src/simulation.js "Source") # simulation.restart() [<>](https://github.com/d3/d3-force/blob/master/src/simulation.js#L86 "Source") -Restarts the simulation’s internal timer and returns the simulation. In conjunction with [*simulation*.alphaTarget](#simulation_alphaTarget) or [*simulation*.alpha](#simulation_alpha), this method can be used to “reheat” the simulation during interaction, such as when dragging a node, or to resume the simulation after temporarily pausing it with [*simulation*.stop](#simulation_stop). +Starts (or restarts) the simulation’s internal timer and returns the simulation. In conjunction with [*simulation*.alphaTarget](#simulation_alphaTarget) or [*simulation*.alpha](#simulation_alpha), this method can be used to “reheat” the simulation during interaction, such as when dragging a node, or to resume the simulation after temporarily pausing it with [*simulation*.stop](#simulation_stop). # simulation.stop() [<>](https://github.com/d3/d3-force/blob/master/src/simulation.js#L90 "Source") diff --git a/src/simulation.js b/src/simulation.js index 2350e68..bb2f9b5 100644 --- a/src/simulation.js +++ b/src/simulation.js @@ -20,9 +20,9 @@ export default function(nodes) { alphaTarget = 0, velocityDecay = 0.6, forces = new Map(), - stepper = timer(step), - started = stepper.stop() || 0, - event = dispatch("tick", "end"); + event = dispatch("tick", "end"), + stepper = timer(step); + stepper.stop(); if (nodes == null) nodes = []; @@ -85,6 +85,10 @@ export default function(nodes) { return simulation = { tick: tick, + start: function() { + return this.restart(); + }, + restart: function() { return stepper.restart(step), simulation; }, @@ -146,7 +150,7 @@ export default function(nodes) { on: function(name, _) { return arguments.length > 1 - ? (event.on(name, _), started++ || stepper.restart(step), simulation) + ? (event.on(name, _), simulation) : event.on(name); } }; diff --git a/test/simulation-test.js b/test/simulation-test.js index e4d09bd..ecb0dc0 100644 --- a/test/simulation-test.js +++ b/test/simulation-test.js @@ -5,7 +5,7 @@ require("./nodeEqual.js"); tape("forceSimulation() returns a simulation", function(test) { const f = force.forceSimulation().stop(); - test.deepEqual(Object.keys(f).sort(), [ 'alpha', 'alphaDecay', 'alphaMin', 'alphaTarget', 'find', 'force', 'nodes', 'on', 'restart', 'stop', 'tick', 'velocityDecay' ]); + test.deepEqual(Object.keys(f).sort(), [ 'alpha', 'alphaDecay', 'alphaMin', 'alphaTarget', 'find', 'force', 'nodes', 'on', 'restart', 'start', 'stop', 'tick', 'velocityDecay' ]); test.end(); }); @@ -19,3 +19,18 @@ tape("simulation.nodes(nodes) initializes a simulation with indices & phyllotaxi test.end(); }); +tape("simulation.nodes(nodes).start() starts the simulator", function(test) { + const f = force.forceSimulation(); + const a = {}, b = {}, c = {}; + f.nodes([a, b, c]); + setTimeout(() => { + test.equal(f.alpha(), 1); + f.start(); + setTimeout(() => { + test.assert(f.alpha() < 1); + f.stop(); + test.end(); + }, 100); + }, 100); +}); + From 04775aed424a7b75990309e98a3f335fe995e3d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 30 Jul 2020 16:21:18 +0200 Subject: [PATCH 11/11] Add forceCenter().strength(), which defaults to 1 and change x,y not vx, vy. - this makes #81 non-breaking - strength=1 recenters "at once" for non-interactive graphs (or, for example, the initial phyllotaxis layout) - strength=0.05 recenters gently for interactive situations such as https://observablehq.com/d/674136693ce67301 --- README.md | 4 ++++ src/center.js | 12 ++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3e1db85..046328d 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,10 @@ If *x* is specified, sets the *x*-coordinate of the centering position to the sp If *y* is specified, sets the *y*-coordinate of the centering position to the specified number and returns this force. If *y* is not specified, returns the current *y*-coordinate, which defaults to zero. +# center.strength([strength]) · [Source](https://github.com/d3/d3-force/blob/master/src/center.js) + +If *strength* is specified, sets the centering force’s strength. A reduced strength of e.g. 0.05 softens the movements on interactive graphs in which new nodes enter or exit the graph. If *strength* is not specified, returns the force’s current strength, which defaults to 1. + #### Collision The collision force treats nodes as circles with a given [radius](#collide_radius), rather than points, and prevents nodes from overlapping. More formally, two nodes *a* and *b* are separated so that the distance between *a* and *b* is at least *radius*(*a*) + *radius*(*b*). To reduce jitter, this is by default a “soft” constraint with a configurable [strength](#collide_strength) and [iteration count](#collide_iterations). diff --git a/src/center.js b/src/center.js index 8c9c6fc..48bc9dd 100644 --- a/src/center.js +++ b/src/center.js @@ -1,10 +1,10 @@ export default function(x, y) { - var nodes, strength = 0.05; + var nodes, strength = 1; if (x == null) x = 0; if (y == null) y = 0; - function force(alpha) { + function force() { var i, n = nodes.length, node, @@ -15,8 +15,8 @@ export default function(x, y) { node = nodes[i], sx += node.x, sy += node.y; } - sx = (sx / n - x) * alpha * strength; - sy = (sy / n - y) * alpha * strength; + sx = (sx / n - x) * strength; + sy = (sy / n - y) * strength; for (i = 0; i < n; ++i) { node = nodes[i], node.vx -= sx, node.vy -= sy; } @@ -34,5 +34,9 @@ export default function(x, y) { return arguments.length ? (y = +_, force) : y; }; + force.strength = function(_) { + return arguments.length ? (strength = +_, force) : strength; + }; + return force; }