visjs-network
Version:
A dynamic, browser-based network visualization library.
854 lines (762 loc) • 24.1 kB
JavaScript
var BarnesHutSolver = require('./components/physics/BarnesHutSolver').default
var Repulsion = require('./components/physics/RepulsionSolver').default
var HierarchicalRepulsion = require('./components/physics/HierarchicalRepulsionSolver')
.default
var SpringSolver = require('./components/physics/SpringSolver').default
var HierarchicalSpringSolver = require('./components/physics/HierarchicalSpringSolver')
.default
var CentralGravitySolver = require('./components/physics/CentralGravitySolver')
.default
var ForceAtlas2BasedRepulsionSolver = require('./components/physics/FA2BasedRepulsionSolver')
.default
var ForceAtlas2BasedCentralGravitySolver = require('./components/physics/FA2BasedCentralGravitySolver')
.default
var util = require('../../util')
var EndPoints = require('./components/edges/util/EndPoints').default // for debugging with _drawForces()
/**
* The physics engine
*/
class PhysicsEngine {
/**
* @param {Object} body
*/
constructor(body) {
this.body = body
this.physicsBody = {
physicsNodeIndices: [],
physicsEdgeIndices: [],
forces: {},
velocities: {}
}
this.physicsEnabled = true
this.simulationInterval = 1000 / 60
this.requiresTimeout = true
this.previousStates = {}
this.referenceState = {}
this.freezeCache = {}
this.renderTimer = undefined
// parameters for the adaptive timestep
this.adaptiveTimestep = false
this.adaptiveTimestepEnabled = false
this.adaptiveCounter = 0
this.adaptiveInterval = 3
this.stabilized = false
this.startedStabilization = false
this.stabilizationIterations = 0
this.ready = false // will be set to true if the stabilize
// default options
this.options = {}
this.defaultOptions = {
enabled: true,
barnesHut: {
theta: 0.5,
gravitationalConstant: -2000,
centralGravity: 0.3,
springLength: 95,
springConstant: 0.04,
damping: 0.09,
avoidOverlap: 0
},
forceAtlas2Based: {
theta: 0.5,
gravitationalConstant: -50,
centralGravity: 0.01,
springConstant: 0.08,
springLength: 100,
damping: 0.4,
avoidOverlap: 0
},
repulsion: {
centralGravity: 0.2,
springLength: 200,
springConstant: 0.05,
nodeDistance: 100,
damping: 0.09,
avoidOverlap: 0
},
hierarchicalRepulsion: {
centralGravity: 0.0,
springLength: 100,
springConstant: 0.01,
nodeDistance: 120,
damping: 0.09
},
maxVelocity: 50,
minVelocity: 0.75, // px/s
solver: 'barnesHut',
stabilization: {
enabled: true,
iterations: 1000, // maximum number of iteration to stabilize
updateInterval: 50,
onlyDynamicEdges: false,
fit: true
},
timestep: 0.5,
adaptiveTimestep: true
}
util.extend(this.options, this.defaultOptions)
this.timestep = 0.5
this.layoutFailed = false
this.bindEventListeners()
}
/**
* Binds event listeners
*/
bindEventListeners() {
this.body.emitter.on('initPhysics', () => {
this.initPhysics()
})
this.body.emitter.on('_layoutFailed', () => {
this.layoutFailed = true
})
this.body.emitter.on('resetPhysics', () => {
this.stopSimulation()
this.ready = false
})
this.body.emitter.on('disablePhysics', () => {
this.physicsEnabled = false
this.stopSimulation()
})
this.body.emitter.on('restorePhysics', () => {
this.setOptions(this.options)
if (this.ready === true) {
this.startSimulation()
}
})
this.body.emitter.on('startSimulation', () => {
if (this.ready === true) {
this.startSimulation()
}
})
this.body.emitter.on('stopSimulation', () => {
this.stopSimulation()
})
this.body.emitter.on('destroy', () => {
this.stopSimulation(false)
this.body.emitter.off()
})
this.body.emitter.on('_dataChanged', () => {
// Nodes and/or edges have been added or removed, update shortcut lists.
this.updatePhysicsData()
})
// debug: show forces
// this.body.emitter.on("afterDrawing", (ctx) => {this._drawForces(ctx);});
}
/**
* set the physics options
* @param {Object} options
*/
setOptions(options) {
if (options !== undefined) {
if (options === false) {
this.options.enabled = false
this.physicsEnabled = false
this.stopSimulation()
} else if (options === true) {
this.options.enabled = true
this.physicsEnabled = true
this.startSimulation()
} else {
this.physicsEnabled = true
util.selectiveNotDeepExtend(['stabilization'], this.options, options)
util.mergeOptions(this.options, options, 'stabilization')
if (options.enabled === undefined) {
this.options.enabled = true
}
if (this.options.enabled === false) {
this.physicsEnabled = false
this.stopSimulation()
}
// set the timestep
this.timestep = this.options.timestep
}
}
this.init()
}
/**
* configure the engine.
*/
init() {
var options
if (this.options.solver === 'forceAtlas2Based') {
options = this.options.forceAtlas2Based
this.nodesSolver = new ForceAtlas2BasedRepulsionSolver(
this.body,
this.physicsBody,
options
)
this.edgesSolver = new SpringSolver(this.body, this.physicsBody, options)
this.gravitySolver = new ForceAtlas2BasedCentralGravitySolver(
this.body,
this.physicsBody,
options
)
} else if (this.options.solver === 'repulsion') {
options = this.options.repulsion
this.nodesSolver = new Repulsion(this.body, this.physicsBody, options)
this.edgesSolver = new SpringSolver(this.body, this.physicsBody, options)
this.gravitySolver = new CentralGravitySolver(
this.body,
this.physicsBody,
options
)
} else if (this.options.solver === 'hierarchicalRepulsion') {
options = this.options.hierarchicalRepulsion
this.nodesSolver = new HierarchicalRepulsion(
this.body,
this.physicsBody,
options
)
this.edgesSolver = new HierarchicalSpringSolver(
this.body,
this.physicsBody,
options
)
this.gravitySolver = new CentralGravitySolver(
this.body,
this.physicsBody,
options
)
} else {
// barnesHut
options = this.options.barnesHut
this.nodesSolver = new BarnesHutSolver(
this.body,
this.physicsBody,
options
)
this.edgesSolver = new SpringSolver(this.body, this.physicsBody, options)
this.gravitySolver = new CentralGravitySolver(
this.body,
this.physicsBody,
options
)
}
this.modelOptions = options
}
/**
* initialize the engine
*/
initPhysics() {
if (this.physicsEnabled === true && this.options.enabled === true) {
if (this.options.stabilization.enabled === true) {
this.stabilize()
} else {
this.stabilized = false
this.ready = true
this.body.emitter.emit('fit', {}, this.layoutFailed) // if the layout failed, we use the approximation for the zoom
this.startSimulation()
}
} else {
this.ready = true
this.body.emitter.emit('fit')
}
}
/**
* Start the simulation
*/
startSimulation() {
if (this.physicsEnabled === true && this.options.enabled === true) {
this.stabilized = false
// when visible, adaptivity is disabled.
this.adaptiveTimestep = false
// this sets the width of all nodes initially which could be required for the avoidOverlap
this.body.emitter.emit('_resizeNodes')
if (this.viewFunction === undefined) {
this.viewFunction = this.simulationStep.bind(this)
this.body.emitter.on('initRedraw', this.viewFunction)
this.body.emitter.emit('_startRendering')
}
} else {
this.body.emitter.emit('_redraw')
}
}
/**
* Stop the simulation, force stabilization.
* @param {boolean} [emit=true]
*/
stopSimulation(emit = true) {
this.stabilized = true
if (emit === true) {
this._emitStabilized()
}
if (this.viewFunction !== undefined) {
this.body.emitter.off('initRedraw', this.viewFunction)
this.viewFunction = undefined
if (emit === true) {
this.body.emitter.emit('_stopRendering')
}
}
}
/**
* The viewFunction inserts this step into each render loop. It calls the physics tick and handles the cleanup at stabilized.
*
*/
simulationStep() {
// check if the physics have settled
var startTime = Date.now()
this.physicsTick()
var physicsTime = Date.now() - startTime
// run double speed if it is a little graph
if (
(physicsTime < 0.4 * this.simulationInterval ||
this.runDoubleSpeed === true) &&
this.stabilized === false
) {
this.physicsTick()
// this makes sure there is no jitter. The decision is taken once to run it at double speed.
this.runDoubleSpeed = true
}
if (this.stabilized === true) {
this.stopSimulation()
}
}
/**
* trigger the stabilized event.
*
* @param {number} [amountOfIterations=this.stabilizationIterations]
* @private
*/
_emitStabilized(amountOfIterations = this.stabilizationIterations) {
if (
this.stabilizationIterations > 1 ||
this.startedStabilization === true
) {
setTimeout(() => {
this.body.emitter.emit('stabilized', { iterations: amountOfIterations })
this.startedStabilization = false
this.stabilizationIterations = 0
}, 0)
}
}
/**
* Calculate the forces for one physics iteration and move the nodes.
* @private
*/
physicsStep() {
this.gravitySolver.solve()
this.nodesSolver.solve()
this.edgesSolver.solve()
this.moveNodes()
}
/**
* Make dynamic adjustments to the timestep, based on current state.
*
* Helper function for physicsTick().
* @private
*/
adjustTimeStep() {
const factor = 1.2 // Factor for increasing the timestep on success.
// we compare the two steps. if it is acceptable we double the step.
if (this._evaluateStepQuality() === true) {
this.timestep = factor * this.timestep
} else {
// if not, we decrease the step to a minimum of the options timestep.
// if the decreased timestep is smaller than the options step, we do not reset the counter
// we assume that the options timestep is stable enough.
if (this.timestep / factor < this.options.timestep) {
this.timestep = this.options.timestep
} else {
// if the timestep was larger than 2 times the option one we check the adaptivity again to ensure
// that large instabilities do not form.
this.adaptiveCounter = -1 // check again next iteration
this.timestep = Math.max(this.options.timestep, this.timestep / factor)
}
}
}
/**
* A single simulation step (or 'tick') in the physics simulation
*
* @private
*/
physicsTick() {
this._startStabilizing() // this ensures that there is no start event when the network is already stable.
if (this.stabilized === true) return
// adaptivity means the timestep adapts to the situation, only applicable for stabilization
if (
this.adaptiveTimestep === true &&
this.adaptiveTimestepEnabled === true
) {
// timestep remains stable for "interval" iterations.
let doAdaptive = this.adaptiveCounter % this.adaptiveInterval === 0
if (doAdaptive) {
// first the big step and revert.
this.timestep = 2 * this.timestep
this.physicsStep()
this.revert() // saves the reference state
// now the normal step. Since this is the last step, it is the more stable one and we will take this.
this.timestep = 0.5 * this.timestep
// since it's half the step, we do it twice.
this.physicsStep()
this.physicsStep()
this.adjustTimeStep()
} else {
this.physicsStep() // normal step, keeping timestep constant
}
this.adaptiveCounter += 1
} else {
// case for the static timestep, we reset it to the one in options and take a normal step.
this.timestep = this.options.timestep
this.physicsStep()
}
if (this.stabilized === true) this.revert()
this.stabilizationIterations++
}
/**
* Nodes and edges can have the physics toggles on or off. A collection of indices is created here so we can skip the check all the time.
*
* @private
*/
updatePhysicsData() {
this.physicsBody.forces = {}
this.physicsBody.physicsNodeIndices = []
this.physicsBody.physicsEdgeIndices = []
let nodes = this.body.nodes
let edges = this.body.edges
// get node indices for physics
for (let nodeId in nodes) {
if (nodes.hasOwnProperty(nodeId)) {
if (nodes[nodeId].options.physics === true) {
this.physicsBody.physicsNodeIndices.push(nodes[nodeId].id)
}
}
}
// get edge indices for physics
for (let edgeId in edges) {
if (edges.hasOwnProperty(edgeId)) {
if (edges[edgeId].options.physics === true) {
this.physicsBody.physicsEdgeIndices.push(edges[edgeId].id)
}
}
}
// get the velocity and the forces vector
for (let i = 0; i < this.physicsBody.physicsNodeIndices.length; i++) {
let nodeId = this.physicsBody.physicsNodeIndices[i]
this.physicsBody.forces[nodeId] = { x: 0, y: 0 }
// forces can be reset because they are recalculated. Velocities have to persist.
if (this.physicsBody.velocities[nodeId] === undefined) {
this.physicsBody.velocities[nodeId] = { x: 0, y: 0 }
}
}
// clean deleted nodes from the velocity vector
for (let nodeId in this.physicsBody.velocities) {
if (nodes[nodeId] === undefined) {
delete this.physicsBody.velocities[nodeId]
}
}
}
/**
* Revert the simulation one step. This is done so after stabilization, every new start of the simulation will also say stabilized.
*/
revert() {
var nodeIds = Object.keys(this.previousStates)
var nodes = this.body.nodes
var velocities = this.physicsBody.velocities
this.referenceState = {}
for (let i = 0; i < nodeIds.length; i++) {
let nodeId = nodeIds[i]
if (nodes[nodeId] !== undefined) {
if (nodes[nodeId].options.physics === true) {
this.referenceState[nodeId] = {
positions: { x: nodes[nodeId].x, y: nodes[nodeId].y }
}
velocities[nodeId].x = this.previousStates[nodeId].vx
velocities[nodeId].y = this.previousStates[nodeId].vy
nodes[nodeId].x = this.previousStates[nodeId].x
nodes[nodeId].y = this.previousStates[nodeId].y
}
} else {
delete this.previousStates[nodeId]
}
}
}
/**
* This compares the reference state to the current state
*
* @returns {boolean}
* @private
*/
_evaluateStepQuality() {
let dx, dy, dpos
let nodes = this.body.nodes
let reference = this.referenceState
let posThreshold = 0.3
for (let nodeId in this.referenceState) {
if (
this.referenceState.hasOwnProperty(nodeId) &&
nodes[nodeId] !== undefined
) {
dx = nodes[nodeId].x - reference[nodeId].positions.x
dy = nodes[nodeId].y - reference[nodeId].positions.y
dpos = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2))
if (dpos > posThreshold) {
return false
}
}
}
return true
}
/**
* move the nodes one timestep and check if they are stabilized
*/
moveNodes() {
var nodeIndices = this.physicsBody.physicsNodeIndices
var maxNodeVelocity = 0
var averageNodeVelocity = 0
// the velocity threshold (energy in the system) for the adaptivity toggle
var velocityAdaptiveThreshold = 5
for (let i = 0; i < nodeIndices.length; i++) {
let nodeId = nodeIndices[i]
let nodeVelocity = this._performStep(nodeId)
// stabilized is true if stabilized is true and velocity is smaller than vmin --> all nodes must be stabilized
maxNodeVelocity = Math.max(maxNodeVelocity, nodeVelocity)
averageNodeVelocity += nodeVelocity
}
// evaluating the stabilized and adaptiveTimestepEnabled conditions
this.adaptiveTimestepEnabled =
averageNodeVelocity / nodeIndices.length < velocityAdaptiveThreshold
this.stabilized = maxNodeVelocity < this.options.minVelocity
}
/**
* Calculate new velocity for a coordinate direction
*
* @param {number} v velocity for current coordinate
* @param {number} f regular force for current coordinate
* @param {number} m mass of current node
* @returns {number} new velocity for current coordinate
* @private
*/
calculateComponentVelocity(v, f, m) {
let df = this.modelOptions.damping * v // damping force
let a = (f - df) / m // acceleration
v += a * this.timestep
// Put a limit on the velocities if it is really high
let maxV = this.options.maxVelocity || 1e9
if (Math.abs(v) > maxV) {
v = v > 0 ? maxV : -maxV
}
return v
}
/**
* Perform the actual step
*
* @param {Node.id} nodeId
* @returns {number} the new velocity of given node
* @private
*/
_performStep(nodeId) {
let node = this.body.nodes[nodeId]
let force = this.physicsBody.forces[nodeId]
let velocity = this.physicsBody.velocities[nodeId]
// store the state so we can revert
this.previousStates[nodeId] = {
x: node.x,
y: node.y,
vx: velocity.x,
vy: velocity.y
}
if (node.options.fixed.x === false) {
velocity.x = this.calculateComponentVelocity(
velocity.x,
force.x,
node.options.mass
)
node.x += velocity.x * this.timestep
} else {
force.x = 0
velocity.x = 0
}
if (node.options.fixed.y === false) {
velocity.y = this.calculateComponentVelocity(
velocity.y,
force.y,
node.options.mass
)
node.y += velocity.y * this.timestep
} else {
force.y = 0
velocity.y = 0
}
let totalVelocity = Math.sqrt(
Math.pow(velocity.x, 2) + Math.pow(velocity.y, 2)
)
return totalVelocity
}
/**
* When initializing and stabilizing, we can freeze nodes with a predefined position.
* This greatly speeds up stabilization because only the supportnodes for the smoothCurves have to settle.
*
* @private
*/
_freezeNodes() {
var nodes = this.body.nodes
for (var id in nodes) {
if (nodes.hasOwnProperty(id)) {
if (nodes[id].x && nodes[id].y) {
let fixed = nodes[id].options.fixed
this.freezeCache[id] = { x: fixed.x, y: fixed.y }
fixed.x = true
fixed.y = true
}
}
}
}
/**
* Unfreezes the nodes that have been frozen by _freezeDefinedNodes.
*
* @private
*/
_restoreFrozenNodes() {
var nodes = this.body.nodes
for (var id in nodes) {
if (nodes.hasOwnProperty(id)) {
if (this.freezeCache[id] !== undefined) {
nodes[id].options.fixed.x = this.freezeCache[id].x
nodes[id].options.fixed.y = this.freezeCache[id].y
}
}
}
this.freezeCache = {}
}
/**
* Find a stable position for all nodes
*
* @param {number} [iterations=this.options.stabilization.iterations]
*/
stabilize(iterations = this.options.stabilization.iterations) {
if (typeof iterations !== 'number') {
iterations = this.options.stabilization.iterations
console.log(
'The stabilize method needs a numeric amount of iterations. Switching to default: ',
iterations
)
}
if (this.physicsBody.physicsNodeIndices.length === 0) {
this.ready = true
return
}
// enable adaptive timesteps
this.adaptiveTimestep = true && this.options.adaptiveTimestep
// this sets the width of all nodes initially which could be required for the avoidOverlap
this.body.emitter.emit('_resizeNodes')
this.stopSimulation() // stop the render loop
this.stabilized = false
// block redraw requests
this.body.emitter.emit('_blockRedraw')
this.targetIterations = iterations
// start the stabilization
if (this.options.stabilization.onlyDynamicEdges === true) {
this._freezeNodes()
}
this.stabilizationIterations = 0
setTimeout(() => this._stabilizationBatch(), 0)
}
/**
* If not already stabilizing, start it and emit a start event.
*
* @returns {boolean} true if stabilization started with this call
* @private
*/
_startStabilizing() {
if (this.startedStabilization === true) return false
this.body.emitter.emit('startStabilizing')
this.startedStabilization = true
return true
}
/**
* One batch of stabilization
* @private
*/
_stabilizationBatch() {
var running = () =>
this.stabilized === false &&
this.stabilizationIterations < this.targetIterations
var sendProgress = () => {
this.body.emitter.emit('stabilizationProgress', {
iterations: this.stabilizationIterations,
total: this.targetIterations
})
}
if (this._startStabilizing()) {
sendProgress() // Ensure that there is at least one start event.
}
var count = 0
while (running() && count < this.options.stabilization.updateInterval) {
this.physicsTick()
count++
}
sendProgress()
if (running()) {
setTimeout(this._stabilizationBatch.bind(this), 0)
} else {
this._finalizeStabilization()
}
}
/**
* Wrap up the stabilization, fit and emit the events.
* @private
*/
_finalizeStabilization() {
this.body.emitter.emit('_allowRedraw')
if (this.options.stabilization.fit === true) {
this.body.emitter.emit('fit')
}
if (this.options.stabilization.onlyDynamicEdges === true) {
this._restoreFrozenNodes()
}
this.body.emitter.emit('stabilizationIterationsDone')
this.body.emitter.emit('_requestRedraw')
if (this.stabilized === true) {
this._emitStabilized()
} else {
this.startSimulation()
}
this.ready = true
}
//--------------------------- DEBUGGING BELOW ---------------------------//
/**
* Debug function that display arrows for the forces currently active in the network.
*
* Use this when debugging only.
*
* @param {CanvasRenderingContext2D} ctx
* @private
*/
_drawForces(ctx) {
for (var i = 0; i < this.physicsBody.physicsNodeIndices.length; i++) {
let index = this.physicsBody.physicsNodeIndices[i]
let node = this.body.nodes[index]
let force = this.physicsBody.forces[index]
let factor = 20
let colorFactor = 0.03
let forceSize = Math.sqrt(Math.pow(force.x, 2) + Math.pow(force.x, 2))
let size = Math.min(Math.max(5, forceSize), 15)
let arrowSize = 3 * size
let color = util.HSVToHex(
(180 - Math.min(1, Math.max(0, colorFactor * forceSize)) * 180) / 360,
1,
1
)
let point = {
x: node.x + factor * force.x,
y: node.y + factor * force.y
}
ctx.lineWidth = size
ctx.strokeStyle = color
ctx.beginPath()
ctx.moveTo(node.x, node.y)
ctx.lineTo(point.x, point.y)
ctx.stroke()
let angle = Math.atan2(force.y, force.x)
ctx.fillStyle = color
EndPoints.draw(ctx, {
type: 'arrow',
point: point,
angle: angle,
length: arrowSize
})
ctx.fill()
}
}
}
export default PhysicsEngine