Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 82 additions & 82 deletions __snapshots__/test/snapshots.test.ts.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,12 @@ exports['Physics snapshots Barnes Hut defaults binary tree 1'] = {

exports['Physics snapshots Force Atlas 2 defaults 2 connected nodes 1'] = {
"1": {
"x": -11,
"y": 58
"x": -14,
"y": 64
},
"2": {
"x": -8,
"y": -68
"x": -10,
"y": -74
}
}

Expand All @@ -215,172 +215,172 @@ exports['Physics snapshots Force Atlas 2 defaults 2 disconnected nodes 1'] = {

exports['Physics snapshots Force Atlas 2 defaults 2 doubly connected nodes 1'] = {
"1": {
"x": -18,
"y": 64
"x": -16,
"y": 71
},
"2": {
"x": 3,
"y": -65
"x": 0,
"y": -73
}
}

exports['Physics snapshots Force Atlas 2 defaults 6 node circle 1'] = {
"1": {
"x": -23,
"y": -151
"x": -31,
"y": -191
},
"2": {
"x": -133,
"y": -48
"x": -173,
"y": -61
},
"3": {
"x": -111,
"y": 102
"x": -141,
"y": 129
},
"4": {
"x": 34,
"y": 147
"x": 41,
"y": 187
},
"5": {
"x": 145,
"y": 45
"x": 184,
"y": 58
},
"6": {
"x": 120,
"y": -104
"x": 151,
"y": -131
}
}

exports['Physics snapshots Force Atlas 2 defaults 6 node complete graph 1'] = {
"1": {
"x": -160,
"y": -106
"x": -211,
"y": -144
},
"2": {
"x": -171,
"y": 94
"x": -234,
"y": 117
},
"3": {
"x": 175,
"y": 84
"x": 220,
"y": 137
},
"4": {
"x": 11,
"y": 197
"x": -8,
"y": 264
},
"5": {
"x": 39,
"y": -151
"x": 22,
"y": -247
},
"6": {
"x": 110,
"y": -109
"x": 220,
"y": -116
}
}

exports['Physics snapshots Force Atlas 2 defaults 6 node star 1'] = {
"1": {
"x": 12,
"y": -2
"x": 9,
"y": -1
},
"2": {
"x": -146,
"y": -12
"x": -196,
"y": -16
},
"3": {
"x": -40,
"y": -150
"x": -51,
"y": -197
},
"4": {
"x": 154,
"y": -73
"x": 193,
"y": -97
},
"5": {
"x": 121,
"y": 116
"x": 155,
"y": 145
},
"6": {
"x": -48,
"y": 146
"x": -68,
"y": 192
}
}

exports['Physics snapshots Force Atlas 2 defaults binary tree 1'] = {
"1": {
"x": 52,
"y": 48
"x": 73,
"y": 104
},
"2": {
"x": -97,
"y": 87
"x": -129,
"y": 123
},
"3": {
"x": 206,
"y": 34
"x": 269,
"y": 41
},
"4": {
"x": -113,
"y": -86
"x": -166,
"y": -124
},
"5": {
"x": -135,
"y": 245
"x": -202,
"y": 345
},
"6": {
"x": 211,
"y": -132
"x": 327,
"y": -187
},
"7": {
"x": 199,
"y": 204
"x": 312,
"y": 276
},
"8": {
"x": -278,
"y": -65
"x": -396,
"y": -106
},
"9": {
"x": -26,
"y": -228
"x": -61,
"y": -342
},
"10": {
"x": -279,
"y": 227
"x": -382,
"y": 329
},
"11": {
"x": -49,
"y": 358
"x": -86,
"y": 488
},
"12": {
"x": 356,
"y": -104
"x": 501,
"y": -161
},
"13": {
"x": 213,
"y": -277
"x": 296,
"y": -368
},
"14": {
"x": 340,
"y": 156
"x": 484,
"y": 221
},
"15": {
"x": 134,
"y": 333
"x": 215,
"y": 439
},
"16": {
"x": -304,
"y": -209
"x": -437,
"y": -282
},
"17": {
"x": -360,
"y": 55
"x": -499,
"y": 49
},
"18": {
"x": 63,
"y": -344
"x": 81,
"y": -461
},
"19": {
"x": -138,
"y": -327
"x": -182,
"y": -474
}
}

Expand Down
62 changes: 57 additions & 5 deletions lib/network/modules/components/physics/FA2BasedRepulsionSolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,57 @@ class ForceAtlas2BasedRepulsionSolver extends BarnesHutSolver {
this._rng = Alea("FORCE ATLAS 2 BASED REPULSION SOLVER");
}

/**
* The repulsion "charge" of a node: its mass weighted by its degree
* (`edges.length + 1`). ForceAtlas2 scales repulsion by node degree so that
* highly interconnected hubs are pushed to the periphery.
*
* The degree must enter the force symmetrically. ForceAtlas2's repulsion is
* `k * (deg(a)+1) * (deg(b)+1) / distance` (Jacomy et al. 2014), i.e. it
* depends on the product of both degrees, so the force on `a` from `b` equals
* the force on `b` from `a` (Newton's third law). Applying the degree to only
* the receiving node — as was done previously — makes the pairwise force
* non-reciprocal and injects a net torque every tick. Velocity damping caps
* but never removes it, so a converged layout spins about its center of mass
* forever (issue #2193). Folding the degree into the mass used both to build
* the Barnes-Hut tree and to evaluate the force keeps it reciprocal.
* @param {Node} node
* @returns {number} the degree-weighted mass of the node
* @private
*/
_getMass(node) {
return node.options.mass * (node.edges.length + 1);
}

/**
* Update the mass of a branch using the degree-weighted mass (see _getMass),
* so the accumulated branch mass and its center of mass are consistent with
* the degree-weighted repulsion applied in _calculateForces.
* @param {object} parentBranch
* @param {Node} node
* @private
*/
_updateBranchMass(parentBranch, node) {
const mass = this._getMass(node);
const centerOfMass = parentBranch.centerOfMass;
const totalMass = parentBranch.mass + mass;
const totalMassInv = 1 / totalMass;

centerOfMass.x = centerOfMass.x * parentBranch.mass + node.x * mass;
centerOfMass.x *= totalMassInv;

centerOfMass.y = centerOfMass.y * parentBranch.mass + node.y * mass;
centerOfMass.y *= totalMassInv;

parentBranch.mass = totalMass;
const biggestSize = Math.max(
Math.max(node.height, node.radius),
node.width,
);
parentBranch.maxWidth =
parentBranch.maxWidth < biggestSize ? biggestSize : parentBranch.maxWidth;
}

/**
* Calculate the forces based on the distance.
* @param {number} distance
Expand All @@ -38,14 +89,15 @@ class ForceAtlas2BasedRepulsionSolver extends BarnesHutSolver {
);
}

const degree = node.edges.length + 1;
// the dividing by the distance cubed instead of squared allows us to get the fx and fy components without sines and cosines
// it is shorthand for gravityforce with distance squared and fx = dx/distance * gravityForce
// dividing by the distance squared (rather than the cubed used by
// BarnesHutSolver) yields ForceAtlas2's linear 1/distance repulsion once the
// dx/dy components are factored in. Both parentBranch.mass and the node's
// mass are degree-weighted (see _getMass), so the repulsion is proportional
// to (degree+1) on both sides and stays reciprocal.
const gravityForce =
(this.options.gravitationalConstant *
parentBranch.mass *
node.options.mass *
degree) /
this._getMass(node)) /
Math.pow(distance, 2);
const fx = dx * gravityForce;
const fy = dy * gravityForce;
Expand Down
Loading