From 9a8468a6afd8d7a013a64caba2260a929d2f94ae Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 12 Aug 2020 14:35:44 -0700 Subject: [PATCH 1/6] Add simulation.randomSource. --- src/collide.js | 10 ++++++---- src/jiggle.js | 4 ++-- src/link.js | 10 ++++++---- src/manyBody.js | 14 ++++++++------ src/simulation.js | 9 +++++++-- 5 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/collide.js b/src/collide.js index c87878e..8ebd8d9 100644 --- a/src/collide.js +++ b/src/collide.js @@ -13,6 +13,7 @@ function y(d) { export default function(radius) { var nodes, radii, + random, strength = 1, iterations = 1; @@ -46,8 +47,8 @@ export default function(radius) { y = yi - data.y - data.vy, l = x * x + y * y; if (l < r * r) { - if (x === 0) x = jiggle(), l += x * x; - if (y === 0) y = jiggle(), l += y * y; + if (x === 0) x = jiggle(random), l += x * x; + if (y === 0) y = jiggle(random), l += y * y; l = (r - (l = Math.sqrt(l))) / l * strength; node.vx += (x *= l) * (r = (rj *= rj) / (ri2 + rj)); node.vy += (y *= l) * r; @@ -77,8 +78,9 @@ export default function(radius) { for (i = 0; i < n; ++i) node = nodes[i], radii[node.index] = +radius(node, i, nodes); } - force.initialize = function(_) { - nodes = _; + force.initialize = function(_nodes, _random) { + nodes = _nodes; + random = _random; initialize(); }; diff --git a/src/jiggle.js b/src/jiggle.js index de46cff..00752b9 100644 --- a/src/jiggle.js +++ b/src/jiggle.js @@ -1,3 +1,3 @@ -export default function() { - return (Math.random() - 0.5) * 1e-6; +export default function(random) { + return (random() - 0.5) * 1e-6; } diff --git a/src/link.js b/src/link.js index 3dc3c7d..df6afa6 100644 --- a/src/link.js +++ b/src/link.js @@ -20,6 +20,7 @@ export default function(links) { nodes, count, bias, + random, iterations = 1; if (links == null) links = []; @@ -32,8 +33,8 @@ export default function(links) { for (var k = 0, n = links.length; k < iterations; ++k) { for (var i = 0, link, source, target, x, y, l, b; i < n; ++i) { link = links[i], source = link.source, target = link.target; - x = target.x + target.vx - source.x - source.vx || jiggle(); - y = target.y + target.vy - source.y - source.vy || jiggle(); + x = target.x + target.vx - source.x - source.vx || jiggle(random); + y = target.y + target.vy - source.y - source.vy || jiggle(random); l = Math.sqrt(x * x + y * y); l = (l - distances[i]) / l * alpha * strengths[i]; x *= l, y *= l; @@ -86,8 +87,9 @@ export default function(links) { } } - force.initialize = function(_) { - nodes = _; + force.initialize = function(_nodes, _random) { + nodes = _nodes; + random = _random; initialize(); }; diff --git a/src/manyBody.js b/src/manyBody.js index 045a401..746a0d0 100644 --- a/src/manyBody.js +++ b/src/manyBody.js @@ -6,6 +6,7 @@ import {x, y} from "./simulation.js"; export default function() { var nodes, node, + random, alpha, strength = constant(-30), strengths, @@ -63,8 +64,8 @@ export default function() { // Limit forces for very close nodes; randomize direction if coincident. if (w * w / theta2 < l) { if (l < distanceMax2) { - if (x === 0) x = jiggle(), l += x * x; - if (y === 0) y = jiggle(), l += y * y; + if (x === 0) x = jiggle(random), l += x * x; + if (y === 0) y = jiggle(random), l += y * y; if (l < distanceMin2) l = Math.sqrt(distanceMin2 * l); node.vx += x * quad.value * alpha / l; node.vy += y * quad.value * alpha / l; @@ -77,8 +78,8 @@ export default function() { // Limit forces for very close nodes; randomize direction if coincident. if (quad.data !== node || quad.next) { - if (x === 0) x = jiggle(), l += x * x; - if (y === 0) y = jiggle(), l += y * y; + if (x === 0) x = jiggle(random), l += x * x; + if (y === 0) y = jiggle(random), l += y * y; if (l < distanceMin2) l = Math.sqrt(distanceMin2 * l); } @@ -89,8 +90,9 @@ export default function() { } while (quad = quad.next); } - force.initialize = function(_) { - nodes = _; + force.initialize = function(_nodes, _random) { + nodes = _nodes; + random = _random; initialize(); }; diff --git a/src/simulation.js b/src/simulation.js index f1e751d..dc03df8 100644 --- a/src/simulation.js +++ b/src/simulation.js @@ -21,7 +21,8 @@ export default function(nodes) { velocityDecay = 0.6, forces = new Map(), stepper = timer(step), - event = dispatch("tick", "end"); + event = dispatch("tick", "end"), + random = Math.random; if (nodes == null) nodes = []; @@ -75,7 +76,7 @@ export default function(nodes) { } function initializeForce(force) { - if (force.initialize) force.initialize(nodes); + if (force.initialize) force.initialize(nodes, random); return force; } @@ -116,6 +117,10 @@ export default function(nodes) { return arguments.length ? (velocityDecay = 1 - _, simulation) : 1 - velocityDecay; }, + randomSource: function(_) { + return arguments.length ? (random = _, simulation) : random; + }, + force: function(name, _) { return arguments.length > 1 ? ((_ == null ? forces.delete(name) : forces.set(name, initializeForce(_))), simulation) : forces.get(name); }, From c4986ff2b003149fa8c1ff1570e6ed7492ccf96c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 12 Aug 2020 14:41:45 -0700 Subject: [PATCH 2/6] Re-initialize forces when random source changes. --- src/simulation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/simulation.js b/src/simulation.js index dc03df8..b47b4bd 100644 --- a/src/simulation.js +++ b/src/simulation.js @@ -118,7 +118,7 @@ export default function(nodes) { }, randomSource: function(_) { - return arguments.length ? (random = _, simulation) : random; + return arguments.length ? (random = _, forces.forEach(initializeForce), simulation) : random; }, force: function(name, _) { From 599b9d49dc0162ea0bba58cf7d968854e531831c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 12 Aug 2020 14:42:03 -0700 Subject: [PATCH 3/6] README --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ffcc00a..eed3b26 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,10 @@ simulation.force("charge", null); Returns the node closest to the position ⟨*x*,*y*⟩ with the given search *radius*. If *radius* is not specified, it defaults to infinity. If there is no node within the search area, returns undefined. +# simulation.randomSource([source]) [<>](https://github.com/d3/d3-force/blob/master/src/simulation.js "Source")) + +If *source* is specified, sets the function used to generate random numbers; this should be a function that returns a number between 0 (inclusive) and 1 (exclusive). If *source* is not specified, returns this simulation’s current random source which defaults to Math.random. A custom random source can be used to guarantee deterministic behavior. See also [*random*.source](https://github.com/d3/d3-random/blob/master/README.md#random_source). + # simulation.on(typenames, [listener]) [<>](https://github.com/d3/d3-force/blob/master/src/simulation.js#L145 "Source") If *listener* is specified, sets the event *listener* for the specified *typenames* and returns this simulation. If an event listener was already registered for the same type and name, the existing listener is removed before the new listener is added. If *listener* is null, removes the current event listeners for the specified *typenames*, if any. If *listener* is not specified, returns the first currently-assigned listener matching the specified *typenames*, if any. When a specified event is dispatched, each *listener* will be invoked with the `this` context as the simulation. @@ -171,9 +175,9 @@ Forces may optionally implement [*force*.initialize](#force_initialize) to recei Applies this force, optionally observing the specified *alpha*. Typically, the force is applied to the array of nodes previously passed to [*force*.initialize](#force_initialize), however, some forces may apply to a subset of nodes, or behave differently. For example, [d3.forceLink](#links) applies to the source and target of each link. -# force.initialize(nodes) [<>](https://github.com/d3/d3-force/blob/master/src/simulation.js#L77 "Source") +# force.initialize(nodes, random) [<>](https://github.com/d3/d3-force/blob/master/src/simulation.js#L77 "Source") -Assigns the array of *nodes* to this force. This method is called when a force is bound to a simulation via [*simulation*.force](#simulation_force) and when the simulation’s nodes change via [*simulation*.nodes](#simulation_nodes). A force may perform necessary work during initialization, such as evaluating per-node parameters, to avoid repeatedly performing work during each application of the force. +Supplies the array of *nodes* and *random* source to this force. This method is called when a force is bound to a simulation via [*simulation*.force](#simulation_force) and when the simulation’s nodes change via [*simulation*.nodes](#simulation_nodes). A force may perform necessary work during initialization, such as evaluating per-node parameters, to avoid repeatedly performing work during each application of the force. #### Centering From 86aed7e1c8a78d52eaf42b923d3cc314a30639f1 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 12 Aug 2020 14:57:16 -0700 Subject: [PATCH 4/6] Add deterministic LCG. --- README.md | 2 +- src/lcg.js | 9 +++++++++ src/simulation.js | 3 ++- 3 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 src/lcg.js diff --git a/README.md b/README.md index eed3b26..0832339 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ Returns the node closest to the position ⟨*x*,*y*⟩ with the given search *ra # simulation.randomSource([source]) [<>](https://github.com/d3/d3-force/blob/master/src/simulation.js "Source")) -If *source* is specified, sets the function used to generate random numbers; this should be a function that returns a number between 0 (inclusive) and 1 (exclusive). If *source* is not specified, returns this simulation’s current random source which defaults to Math.random. A custom random source can be used to guarantee deterministic behavior. See also [*random*.source](https://github.com/d3/d3-random/blob/master/README.md#random_source). +If *source* is specified, sets the function used to generate random numbers; this should be a function that returns a number between 0 (inclusive) and 1 (exclusive). If *source* is not specified, returns this simulation’s current random source which defaults to a fixed-seed [linear congruential generator](https://en.wikipedia.org/wiki/Linear_congruential_generator). See also [*random*.source](https://github.com/d3/d3-random/blob/master/README.md#random_source). # simulation.on(typenames, [listener]) [<>](https://github.com/d3/d3-force/blob/master/src/simulation.js#L145 "Source") diff --git a/src/lcg.js b/src/lcg.js new file mode 100644 index 0000000..a13cf79 --- /dev/null +++ b/src/lcg.js @@ -0,0 +1,9 @@ +// https://en.wikipedia.org/wiki/Linear_congruential_generator#Parameters_in_common_use +const a = 1664525; +const c = 1013904223; +const m = 4294967296; // 2^32 + +export default function() { + let s = 1; + return () => (s = (a * s + c) % m) / m; +} diff --git a/src/simulation.js b/src/simulation.js index b47b4bd..7a1ff82 100644 --- a/src/simulation.js +++ b/src/simulation.js @@ -1,5 +1,6 @@ import {dispatch} from "d3-dispatch"; import {timer} from "d3-timer"; +import lcg from "./lcg.js"; export function x(d) { return d.x; @@ -22,7 +23,7 @@ export default function(nodes) { forces = new Map(), stepper = timer(step), event = dispatch("tick", "end"), - random = Math.random; + random = lcg(); if (nodes == null) nodes = []; From 7d3eadb5d68e954fca5028843ad95d509b27f4bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sat, 15 Aug 2020 13:29:55 +0200 Subject: [PATCH 5/6] test randomSource in simulation --- test/simulation-test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/simulation-test.js b/test/simulation-test.js index e4d09bd..b8d244e 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', 'randomSource', 'restart', 'stop', 'tick', 'velocityDecay' ]); test.end(); }); From 41bb91d70c5e724b019d1079658a02b8221af2c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sat, 15 Aug 2020 13:30:10 +0200 Subject: [PATCH 6/6] test that forceCollide jiggles in a reproducible way --- test/collide-test.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/collide-test.js b/test/collide-test.js index f9ed20f..e9529d6 100644 --- a/test/collide-test.js +++ b/test/collide-test.js @@ -46,3 +46,12 @@ tape("forceCollide jiggles equal positions", function(test) { test.equal(a.vy, -b.vy); test.end(); }); + +tape("forceCollide jiggles in a reproducible way", function(test) { + const nodes = Array.from({length:10}, () => ({x:0,y:0})); + force.forceSimulation() + .nodes(nodes) + .force("collide", force.forceCollide()).stop().tick(50); + test.nodeEqual(nodes[0], {x: -5.371433857229194, y: -2.6644608278592576, index: 0, vy: 0, vx: 0}); + test.end(); +});