diff --git a/docs/network/physics.html b/docs/network/physics.html index c8fe9920..0e609ca3 100644 --- a/docs/network/physics.html +++ b/docs/network/physics.html @@ -139,7 +139,8 @@ var options = { fit: true }, timestep: 0.5, - adaptiveTimestep: true + adaptiveTimestep: true, + useWorker: false } } @@ -203,6 +204,15 @@ network.setOptions(options); stabilization.fit Boolean true Toggle whether or not you want the view to zoom to fit all nodes when the stabilization is finished. timestep Number 0.5 The physics simulation is discrete. This means we take a step in time, calculate the forces, move the nodes and take another step. If you increase this number the steps will be too large and the network can get unstable. If you see a lot of jittery movement in the network, you may want to reduce this value a little. adaptiveTimestep Boolean true If this is enabled, the timestep will intelligently be adapted (only during the stabilization stage if stabilization is enabled!) to greatly decrease stabilization times. The timestep configured above is taken as the minimum timestep. This can be further improved by using the improvedLayout algorithm. + useWorker Boolean false + If this is enabled, the physics calculation will be performed in a separate thread. The file vis.physics.worker.js must be available from the same webserver hosting vis.js at the same path. If + you are embedding vis into a javascript bundle, you can set the script id to "visjs" to enable + path resolution. If the worker fails for any reason, the system will fall back to standard + physics calculations. + WorkInProgress: This has only been tested with default physics selections and does not attempt to optimize + stabilization. Has not been optimized for small changes to underlying dataset, so each change will + cause all data to be recopied to the worker thread. + diff --git a/gulpfile.js b/gulpfile.js index 360b3bd3..e5259afa 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -52,9 +52,15 @@ var webpackConfig = { wrappedContextRegExp: /$^/ }, plugins: [ bannerPlugin ], - cache: true + cache: true, //debug: true, //bail: true + worker: { + path: DIST, + output: { + filename: "vis.physics.worker.js" + } + } }; var uglifyConfig = { diff --git a/lib/network/Network.js b/lib/network/Network.js index 7b3ae0e1..536876b3 100644 --- a/lib/network/Network.js +++ b/lib/network/Network.js @@ -270,7 +270,7 @@ Network.prototype.bindEventListeners = function () { this.body.emitter.on("_dataChanged", () => { // update shortcut lists this._updateVisibleIndices(); - this.physics.updatePhysicsData(); + this.physics.initPhysicsData(); this.body.emitter.emit("_requestRedraw"); // call the dataUpdated event because the only difference between the two is the updating of the indices this.body.emitter.emit("_dataUpdated"); diff --git a/lib/network/modules/InteractionHandler.js b/lib/network/modules/InteractionHandler.js index 76cc15cc..884912cd 100644 --- a/lib/network/modules/InteractionHandler.js +++ b/lib/network/modules/InteractionHandler.js @@ -313,8 +313,7 @@ class InteractionHandler { yFixed: object.options.fixed.y }; - object.options.fixed.x = true; - object.options.fixed.y = true; + object.setFixed(true); this.drag.selection.push(s); } @@ -395,8 +394,7 @@ class InteractionHandler { if (selection && selection.length) { selection.forEach(function (s) { // restore original xFixed and yFixed - s.node.options.fixed.x = s.xFixed; - s.node.options.fixed.y = s.yFixed; + s.node.setFixed({x: s.xFixed, y: s.yFixed}); }); this.selectionHandler._generateClickEvent('dragEnd', event, this.getPointer(event.center)); this.body.emitter.emit('startSimulation'); diff --git a/lib/network/modules/LayoutEngine.js b/lib/network/modules/LayoutEngine.js index b38bc393..d3d5e443 100644 --- a/lib/network/modules/LayoutEngine.js +++ b/lib/network/modules/LayoutEngine.js @@ -384,11 +384,11 @@ class LayoutEngine { let level = this.hierarchicalLevels[nodeId] === undefined ? 0 : this.hierarchicalLevels[nodeId]; if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') { node.y = this.options.hierarchical.levelSeparation * level; - node.options.fixed.y = true; + node.setFixed({y: true}); } else { node.x = this.options.hierarchical.levelSeparation * level; - node.options.fixed.x = true; + node.setFixed({x: true}); } if (distribution[level] === undefined) { distribution[level] = {amount: 0, nodes: {}, distance: 0}; diff --git a/lib/network/modules/NodesHandler.js b/lib/network/modules/NodesHandler.js index a35cfce1..6d639a18 100644 --- a/lib/network/modules/NodesHandler.js +++ b/lib/network/modules/NodesHandler.js @@ -353,10 +353,10 @@ class NodesHandler { var dataset = this.body.data.nodes.getDataSet(); for (let nodeId in dataset._data) { - if (dataset._data.hasOwnProperty(nodeId)) { + if (dataset._data.hasOwnProperty(nodeId) && this.body.nodes.hasOwnProperty(nodeId)) { let node = this.body.nodes[nodeId]; if (dataset._data[nodeId].x != Math.round(node.x) || dataset._data[nodeId].y != Math.round(node.y)) { - dataArray.push({ id: nodeId, x: Math.round(node.x), y: Math.round(node.y) }); + dataArray.push({id: nodeId, x: Math.round(node.x), y: Math.round(node.y)}); } } } diff --git a/lib/network/modules/PhysicsBase.js b/lib/network/modules/PhysicsBase.js new file mode 100644 index 00000000..37db6e07 --- /dev/null +++ b/lib/network/modules/PhysicsBase.js @@ -0,0 +1,261 @@ +import BarnesHutSolver from './components/physics/BarnesHutSolver'; +import Repulsion from './components/physics/RepulsionSolver'; +import HierarchicalRepulsion from './components/physics/HierarchicalRepulsionSolver'; +import SpringSolver from './components/physics/SpringSolver'; +import HierarchicalSpringSolver from './components/physics/HierarchicalSpringSolver'; +import CentralGravitySolver from './components/physics/CentralGravitySolver'; +import ForceAtlas2BasedRepulsionSolver from './components/physics/FA2BasedRepulsionSolver'; +import ForceAtlas2BasedCentralGravitySolver from './components/physics/FA2BasedCentralGravitySolver'; + +class PhysicsBase { + constructor() { + this.physicsBody = {physicsNodeIndices:[], physicsEdgeIndices:[], forces: {}, velocities: {}}; + this.options = {}; + + this.referenceState = {}; + this.previousStates = {}; + + this.startedStabilization = false; + this.stabilized = false; + this.stabilizationIterations = 0; + this.timestep = 0.5; + + // parameters for the adaptive timestep + this.adaptiveTimestep = false; + this.adaptiveTimestepEnabled = false; + this.adaptiveCounter = 0; + this.adaptiveInterval = 3; + } + + /** + * configure the engine. + */ + initPhysicsSolvers() { + 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; + } + + /** + * A single simulation step (or 'tick') in the physics simulation + * + * @private + */ + physicsTick() { + // this is here to ensure that there is no start event when the network is already stable. + if (this.startedStabilization === false) { + this.emit('startStabilizing'); + this.startedStabilization = true; + } + + if (this.stabilized === false) { + // adaptivity means the timestep adapts to the situation, only applicable for stabilization + if (this.adaptiveTimestep === true && this.adaptiveTimestepEnabled === true) { + // this is the factor for increasing the timestep on success. + let factor = 1.2; + + // we assume the adaptive interval is + if (this.adaptiveCounter % this.adaptiveInterval === 0) { // we leave the timestep stable for "interval" iterations. + // first the big step and revert. Revert saves the reference state. + this.timestep = 2 * this.timestep; + this.calculateForces(); + this.moveNodes(); + this.revert(); + + // 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.calculateForces(); + this.moveNodes(); + this.calculateForces(); + this.moveNodes(); + + // 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); + } + } + } + else { + // normal step, keeping timestep constant + this.calculateForces(); + this.moveNodes(); + } + + // increment the counter + 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.calculateForces(); + this.moveNodes(); + } + + // determine if the network has stabilzied + if (this.stabilized === true) { + this.revert(); + } + + this.stabilizationIterations++; + } + } + + /** + * 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 (this.isWorker || 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 + */ + _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 timestap and check if they are stabilized + * @returns {boolean} + */ + moveNodes() { + var nodeIndices = this.physicsBody.physicsNodeIndices; + var maxVelocity = this.options.maxVelocity ? this.options.maxVelocity : 1e9; + 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, maxVelocity); + // 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; + } + + // TODO consider moving _performStep in here + // right now Physics nodes don't have setX setY functions + // - maybe switch logic of setX and set x? + // - add functions to physics nodes - seems not desirable + + /** + * calculate the forces for one physics iteration. + */ + calculateForces() { + this.gravitySolver.solve(); + this.nodesSolver.solve(); + this.edgesSolver.solve(); + } + + /** + * One batch of stabilization + * @private + */ + _stabilizationBatch() { + // this is here to ensure that there is at least one start event. + if (this.startedStabilization === false) { + this.emit('startStabilizing'); + this.startedStabilization = true; + } + + var count = 0; + while (this.stabilized === false && count < this.options.stabilization.updateInterval && this.stabilizationIterations < this.targetIterations) { + this.physicsTick(); + count++; + } + + if (this.stabilized === false && this.stabilizationIterations < this.targetIterations) { + this.emit('stabilizationProgress', {iterations: this.stabilizationIterations, total: this.targetIterations}); + setTimeout(this._stabilizationBatch.bind(this),0); + } + else { + this._finalizeStabilization(); + } + } +} + +export default PhysicsBase; \ No newline at end of file diff --git a/lib/network/modules/PhysicsEngine.js b/lib/network/modules/PhysicsEngine.js index 5c78b9fb..319403b4 100644 --- a/lib/network/modules/PhysicsEngine.js +++ b/lib/network/modules/PhysicsEngine.js @@ -1,43 +1,26 @@ -import BarnesHutSolver from './components/physics/BarnesHutSolver'; -import Repulsion from './components/physics/RepulsionSolver'; -import HierarchicalRepulsion from './components/physics/HierarchicalRepulsionSolver'; -import SpringSolver from './components/physics/SpringSolver'; -import HierarchicalSpringSolver from './components/physics/HierarchicalSpringSolver'; -import CentralGravitySolver from './components/physics/CentralGravitySolver'; -import ForceAtlas2BasedRepulsionSolver from './components/physics/FA2BasedRepulsionSolver'; -import ForceAtlas2BasedCentralGravitySolver from './components/physics/FA2BasedCentralGravitySolver'; +import PhysicsBase from './PhysicsBase'; +import PhysicsWorker from 'worker!./PhysicsWorkerWrapper'; var util = require('../../util'); -class PhysicsEngine { +class PhysicsEngine extends PhysicsBase { constructor(body) { + super(); 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, + useWorker: false, barnesHut: { theta: 0.5, gravitationalConstant: -2000, @@ -85,8 +68,11 @@ class PhysicsEngine { adaptiveTimestep: true }; util.extend(this.options, this.defaultOptions); - this.timestep = 0.5; this.layoutFailed = false; + this.draggingNodes = []; + this.positionUpdateHandler = () => {}; + this.physicsUpdateHandler = () => {}; + this.emit = this.body.emitter.emit; this.bindEventListeners(); } @@ -112,6 +98,20 @@ class PhysicsEngine { this.stopSimulation(false); this.body.emitter.off(); }); + this.body.emitter.on('_positionUpdate', (properties) => this.positionUpdateHandler(properties)); + this.body.emitter.on('_physicsUpdate', (properties) => this.physicsUpdateHandler(properties)); + this.body.emitter.on('dragStart', (properties) => { + this.draggingNodes = properties.nodes; + }); + this.body.emitter.on('dragEnd', () => { + this.draggingNodes = []; + }); + this.body.emitter.on('destroy', () => { + if (this.physicsWorker) { + this.physicsWorker.terminate(); + this.physicsWorker = undefined; + } + }); } @@ -144,43 +144,153 @@ class PhysicsEngine { this.timestep = this.options.timestep; } } - this.init(); + if (this.options.useWorker) { + this.initPhysicsWorker(); + this.physicsWorker.postMessage({type: 'options', data: this.options}); + } else { + this.initEmbeddedPhysics(); + } } /** * 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; + initEmbeddedPhysics() { + this.positionUpdateHandler = () => {}; + this.physicsUpdateHandler = (properties) => { + if (properties.options.physics !== undefined) { + // we've received a node that has changed physics state + // so rebuild physicsBody + this.initPhysicsData(); + } + // else we're accessing the information directly out of the node + // so no need to do anything. + }; + if (this.physicsWorker) { + this.options.useWorker = false; + this.physicsWorker.terminate(); + this.physicsWorker = undefined; + this.initPhysicsData(); + } + this.initPhysicsSolvers(); + } + + initPhysicsWorker() { + if (!this.physicsWorker) { + // setup path to webworker javascript file + if (!__webpack_public_path__) { + // search for element with id of 'visjs' + let parentScript = document.getElementById('visjs'); + if (parentScript) { + let src = parentScript.getAttribute('src') + __webpack_public_path__ = src.substr(0, src.lastIndexOf('/') + 1); + } else { + // search all scripts for 'vis.js' + let scripts = document.getElementsByTagName('script'); + for (let i = 0; i < scripts.length; i++) { + let src = scripts[i].getAttribute('src'); + if (src && src.length >= 6) { + let position = src.length - 6; + let index = src.indexOf('vis.js', position); + if (index === position) { + __webpack_public_path__ = src.substr(0, src.lastIndexOf('/') + 1); + break; + } + } + } + } + } + // launch webworker + this.physicsWorker = new PhysicsWorker(); + this.physicsWorker.addEventListener('message', (event) => { + this.physicsWorkerMessageHandler(event); + }); + this.physicsWorker.onerror = (event) => { + console.error('Falling back to embedded physics engine', event); + this.initEmbeddedPhysics(); + // throw new Error(event.message + " (" + event.filename + ":" + event.lineno + ")"); + }; + this.positionUpdateHandler = (positions) => { + this.physicsWorker.postMessage({type: 'updatePositions', data: positions}); + }; + this.physicsUpdateHandler = (properties) => { + this._physicsUpdateHandler(properties); + }; + } } + _physicsUpdateHandler(properties) { + if (properties.options.physics !== undefined) { + if (properties.options.physics) { + let data = { + nodes: {}, + edges: {} + }; + if (properties.type === 'node') { + data.nodes[properties.id] = this.createPhysicsNode(properties.id); + } else if (properties.type === 'edge') { + data.edges[properties.id] = this.createPhysicsEdge(properties.id); + } else { + console.warn('invalid element type'); + } + this.physicsWorker.postMessage({ + type: 'addElements', + data: data + }); + } else { + let data = { + nodeIds: [], + edgeIds: [] + }; + if (properties.type === 'node') { + data.nodeIds = [properties.id.toString()]; + } else if (properties.type === 'edge') { + data.edgeIds = [properties.id.toString()]; + } else { + console.warn('invalid element type'); + } + this.physicsWorker.postMessage({type: 'removeElements', data: data}); + } + } else { + this.physicsWorker.postMessage({type: 'updateProperties', data: properties}); + } + } + + physicsWorkerMessageHandler(event) { + var msg = event.data; + switch (msg.type) { + case 'tickResults': + this.stabilized = msg.data.stabilized; + this.stabilizationIterations = msg.data.stabilizationIterations; + this._receivedPositions(msg.data.positions); + break; + case 'finalizeStabilization': + this._finalizeStabilization(); + break; + case 'emit': + this.emit(msg.data.event, msg.data.data); + break; + default: + console.warn('unhandled physics worker message:', msg); + } + } + + _receivedPositions(positions) { + for (let i = 0; i < this.draggingNodes.length; i++) { + delete positions[this.draggingNodes[i]]; + } + let nodeIds = Object.keys(positions); + for (let i = 0; i < nodeIds.length; i++) { + let nodeId = nodeIds[i]; + let node = this.body.nodes[nodeId]; + // handle case where we get a positions from an old physicsObject + if (node) { + node.setX(positions[nodeId].x); + node.setY(positions[nodeId].y); + } + } + } /** * initialize the engine @@ -209,6 +319,7 @@ class PhysicsEngine { startSimulation() { if (this.physicsEnabled === true && this.options.enabled === true) { this.stabilized = false; + this._updateWorkerStabilized(); // when visible, adaptivity is disabled. this.adaptiveTimestep = false; @@ -232,6 +343,8 @@ class PhysicsEngine { */ stopSimulation(emit = true) { this.stabilized = true; + this._updateWorkerStabilized(); + if (emit === true) { this._emitStabilized(); } @@ -244,23 +357,35 @@ class PhysicsEngine { } } + _updateWorkerStabilized() { + if (this.physicsWorker) { + this.physicsWorker.postMessage({ + type: 'setStabilized', + data: this.stabilized + }); + } + } /** * The viewFunction inserts this step into each renderloop. 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) { + if (this.physicsWorker) { + this.physicsWorker.postMessage({type: 'physicsTick'}); + } else { + // 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; + // 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) { @@ -268,6 +393,13 @@ class PhysicsEngine { } } + _sendWorkerStabilized() { + if (this.physicsWorker) { + this.physicsWorker.postMessage({ + type: 'stabilized' + }); + } + } /** * trigger the stabilized event. @@ -279,86 +411,55 @@ class PhysicsEngine { this.body.emitter.emit('stabilized', {iterations: amountOfIterations}); this.startedStabilization = false; this.stabilizationIterations = 0; + this._sendWorkerStabilized(); }, 0); } } - /** - * A single simulation step (or 'tick') in the physics simulation - * - * @private - */ - physicsTick() { - // this is here to ensure that there is no start event when the network is already stable. - if (this.startedStabilization === false) { - this.body.emitter.emit('startStabilizing'); - this.startedStabilization = true; - } - - if (this.stabilized === false) { - // adaptivity means the timestep adapts to the situation, only applicable for stabilization - if (this.adaptiveTimestep === true && this.adaptiveTimestepEnabled === true) { - // this is the factor for increasing the timestep on success. - let factor = 1.2; - - // we assume the adaptive interval is - if (this.adaptiveCounter % this.adaptiveInterval === 0) { // we leave the timestep stable for "interval" iterations. - // first the big step and revert. Revert saves the reference state. - this.timestep = 2 * this.timestep; - this.calculateForces(); - this.moveNodes(); - this.revert(); - - // 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.calculateForces(); - this.moveNodes(); - this.calculateForces(); - this.moveNodes(); - - // 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); - } - } - } - else { - // normal step, keeping timestep constant - this.calculateForces(); - this.moveNodes(); + createPhysicsNode(nodeId) { + let node = this.body.nodes[nodeId]; + if (node) { + return { + id: node.id.toString(), + x: node.x, + y: node.y, + // TODO update on change + edges: { + length: node.edges.length + }, + options: { + fixed: { + x: node.options.fixed.x, + y: node.options.fixed.y + }, + mass: node.options.mass } - - // increment the counter - 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.calculateForces(); - this.moveNodes(); } + } + } - // determine if the network has stabilzied - if (this.stabilized === true) { - this.revert(); + createPhysicsEdge(edgeId) { + let edge = this.body.edges[edgeId]; + if (edge && edge.options.physics === true) { + let physicsEdge = { + id: edge.id, + connected: edge.connected, + edgeType: {}, + toId: edge.toId, + fromId: edge.fromId, + options: { + length: edge.length + } + }; + // TODO test/implment dynamic + if (edge.edgeType.via) { + physicsEdge.edgeType = { + via: { + id: edge.edgeType.via.id + } + } } - - this.stabilizationIterations++; + return physicsEdge; } } @@ -367,18 +468,25 @@ class PhysicsEngine { * * @private */ - updatePhysicsData() { + initPhysicsData() { + let nodes = this.body.nodes; + let edges = this.body.edges; + this.physicsBody.forces = {}; this.physicsBody.physicsNodeIndices = []; this.physicsBody.physicsEdgeIndices = []; - let nodes = this.body.nodes; - let edges = this.body.edges; + let physicsWorkerNodes = {}; + let physicsWorkerEdges = {}; + // get node indices for physics for (let nodeId in nodes) { if (nodes.hasOwnProperty(nodeId)) { if (nodes[nodeId].options.physics === true) { this.physicsBody.physicsNodeIndices.push(nodeId); + if (this.physicsWorker) { + physicsWorkerNodes[nodeId] = this.createPhysicsNode(nodeId); + } } } } @@ -388,6 +496,9 @@ class PhysicsEngine { if (edges.hasOwnProperty(edgeId)) { if (edges[edgeId].options.physics === true) { this.physicsBody.physicsEdgeIndices.push(edgeId); + if (this.physicsWorker) { + physicsWorkerEdges[edgeId] = this.createPhysicsEdge(edgeId); + } } } } @@ -395,11 +506,11 @@ class PhysicsEngine { // 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}; + 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}; + this.physicsBody.velocities[nodeId] = {x: 0, y: 0}; } } @@ -409,88 +520,18 @@ class PhysicsEngine { 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; + if (this.physicsWorker) { + this.physicsWorker.postMessage({ + type: 'initPhysicsData', + data: { + nodes: physicsWorkerNodes, + edges: physicsWorkerEdges } - } - else { - delete this.previousStates[nodeId]; - } + }); } } - /** - * This compares the reference state to the current state - */ - _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 timestap and check if they are stabilized - * @returns {boolean} - */ - moveNodes() { - var nodeIndices = this.physicsBody.physicsNodeIndices; - var maxVelocity = this.options.maxVelocity ? this.options.maxVelocity : 1e9; - 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, maxVelocity); - // 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; - } - - /** * Perform the actual step * @@ -513,7 +554,7 @@ class PhysicsEngine { let ax = (forces[nodeId].x - dx) / node.options.mass; // acceleration velocities[nodeId].x += ax * timestep; // velocity velocities[nodeId].x = (Math.abs(velocities[nodeId].x) > maxVelocity) ? ((velocities[nodeId].x > 0) ? maxVelocity : -maxVelocity) : velocities[nodeId].x; - node.x += velocities[nodeId].x * timestep; // position + node.setX(node.x + velocities[nodeId].x * timestep); // position } else { forces[nodeId].x = 0; @@ -525,7 +566,7 @@ class PhysicsEngine { let ay = (forces[nodeId].y - dy) / node.options.mass; // acceleration velocities[nodeId].y += ay * timestep; // velocity velocities[nodeId].y = (Math.abs(velocities[nodeId].y) > maxVelocity) ? ((velocities[nodeId].y > 0) ? maxVelocity : -maxVelocity) : velocities[nodeId].y; - node.y += velocities[nodeId].y * timestep; // position + node.setY(node.y + velocities[nodeId].y * timestep); // position } else { forces[nodeId].y = 0; @@ -536,18 +577,7 @@ class PhysicsEngine { return totalVelocity; } - - /** - * calculate the forces for one physics iteration. - */ - calculateForces() { - this.gravitySolver.solve(); - this.nodesSolver.solve(); - this.edgesSolver.solve(); - } - - - + // TODO probably want to move freeze/restore to PhysicsBase and do in worker if running /** * 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. @@ -560,8 +590,7 @@ class PhysicsEngine { if (nodes.hasOwnProperty(id)) { if (nodes[id].x && nodes[id].y) { this.freezeCache[id] = {x:nodes[id].options.fixed.x,y:nodes[id].options.fixed.y}; - nodes[id].options.fixed.x = true; - nodes[id].options.fixed.y = true; + nodes[id].setFixed(true); } } } @@ -577,8 +606,7 @@ class PhysicsEngine { 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; + nodes[id].setFixed({x: this.freezeCache[id].x, y: this.freezeCache[id].y}); } } } @@ -609,7 +637,7 @@ class PhysicsEngine { // stop the render loop this.stopSimulation(); - // set stabilze to false + // set stabilize to false this.stabilized = false; // block redraw requests @@ -621,38 +649,19 @@ class PhysicsEngine { this._freezeNodes(); } this.stabilizationIterations = 0; - - setTimeout(() => this._stabilizationBatch(),0); - } - - - /** - * One batch of stabilization - * @private - */ - _stabilizationBatch() { - // this is here to ensure that there is at least one start event. - if (this.startedStabilization === false) { - this.body.emitter.emit('startStabilizing'); - this.startedStabilization = true; - } - var count = 0; - while (this.stabilized === false && count < this.options.stabilization.updateInterval && this.stabilizationIterations < this.targetIterations) { - this.physicsTick(); - count++; - } - - if (this.stabilized === false && this.stabilizationIterations < this.targetIterations) { - this.body.emitter.emit('stabilizationProgress', {iterations: this.stabilizationIterations, total: this.targetIterations}); - setTimeout(this._stabilizationBatch.bind(this),0); - } - else { - this._finalizeStabilization(); + if (this.physicsWorker) { + this.physicsWorker.postMessage({ + type: 'stabilize', + data: { + targetIterations: iterations + } + }); + } else { + setTimeout(() => this._stabilizationBatch(), 0); } } - /** * Wrap up the stabilization, fit and emit the events. * @private @@ -679,7 +688,7 @@ class PhysicsEngine { this.ready = true; } - + } export default PhysicsEngine; \ No newline at end of file diff --git a/lib/network/modules/PhysicsWorker.js b/lib/network/modules/PhysicsWorker.js new file mode 100644 index 00000000..abaf26ee --- /dev/null +++ b/lib/network/modules/PhysicsWorker.js @@ -0,0 +1,268 @@ +import PhysicsBase from './PhysicsBase'; + +class PhysicsWorker extends PhysicsBase { + constructor(postMessage) { + super(); + this.body = { + nodes: {}, + edges: {} + }; + this.postMessage = postMessage; + this.previousStates = {}; + this.toRemove = { + nodeIds: [], + edgeIds: [] + }; + this.isWorker = true; + this.emit = (event, data) => {this.postMessage({type: 'emit', data: {event: event, data: data}})}; + } + + handleMessage(event) { + var msg = event.data; + switch (msg.type) { + case 'physicsTick': + this.processRemovals(); + this.physicsTick(); + this.sendTickResults(); + break; + case 'updatePositions': + this.receivePositions(msg.data); + break; + case 'updateProperties': + this.updateProperties(msg.data); + break; + case 'addElements': + this.addElements(msg.data); + break; + case 'removeElements': + this.removeElements(msg.data); + break; + case 'stabilize': + this.stabilize(msg.data); + break; + case 'setStabilized': + this.stabilized = msg.data; + break; + case 'stabilized': + this.startedStabilization = false; + this.stabilizationIterations = 0; + break; + case 'initPhysicsData': + console.debug('init physics data'); + this.initPhysicsData(msg.data); + break; + case 'options': + this.options = msg.data; + this.timestep = this.options.timestep; + this.initPhysicsSolvers(); + break; + default: + console.warn('unknown message from PhysicsEngine', msg); + } + } + + sendTickResults() { + let nodeIndices = this.physicsBody.physicsNodeIndices; + let positions = {}; + for (let i = 0; i < nodeIndices.length; i++) { + let nodeId = nodeIndices[i]; + let node = this.body.nodes[nodeId]; + positions[nodeId] = {x:node.x, y:node.y}; + } + + this.postMessage({ + type: 'tickResults', + data: { + positions: positions, + stabilized: this.stabilized, + stabilizationIterations: this.stabilizationIterations + } + }); + } + + receivePositions(data) { + let updatedNode = this.body.nodes[data.id]; + if (updatedNode) { + updatedNode.x = data.x; + updatedNode.y = data.y; + this.physicsBody.forces[updatedNode.id] = {x: 0, y: 0}; + this.physicsBody.velocities[updatedNode.id] = {x: 0, y: 0}; + } + } + + stabilize(data) { + this.stabilized = false; + this.targetIterations = data.targetIterations; + this.stabilizationIterations = 0; + setTimeout(() => this._stabilizationBatch(), 0); + } + + updateProperties(data) { + if (data.type === 'node') { + let optionsNode = this.body.nodes[data.id]; + if (optionsNode) { + let opts = data.options; + if (opts.fixed) { + if (opts.fixed.x !== undefined) { + optionsNode.options.fixed.x = opts.fixed.x; + } + if (opts.fixed.y !== undefined) { + optionsNode.options.fixed.y = opts.fixed.y; + } + } + if (opts.mass !== undefined) { + optionsNode.options.mass = opts.mass; + } + if (opts.edges && opts.edges.length) { + optionsNode.edges.length = opts.edges.length; + } + } else { + console.warn('sending properties to unknown node', data.id, data.options); + } + } else if (data.type === 'edge') { + let edge = this.body.edges[data.id]; + if (edge) { + let opts = data.options; + if (opts.connected) { + edge.connected = opts.connected; + } + } else { + console.warn('sending properties to unknown edge', data.id, data.options); + } + } else { + console.warn('sending properties to unknown element', data.id, data.options); + } + } + + addElements(data, replaceElements = true) { + let nodeIds = Object.keys(data.nodes); + for (let i = 0; i < nodeIds.length; i++) { + let nodeId = nodeIds[i]; + let newNode = data.nodes[nodeId]; + if (replaceElements) { + this.body.nodes[nodeId] = newNode; + } + 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}; + } + if (this.physicsBody.physicsNodeIndices.indexOf(nodeId) === -1) { + this.physicsBody.physicsNodeIndices.push(nodeId); + } + } + let edgeIds = Object.keys(data.edges); + for (let i = 0; i < edgeIds.length; i++) { + let edgeId = edgeIds[i]; + if (replaceElements) { + this.body.edges[edgeId] = data.edges[edgeId]; + } + if (this.physicsBody.physicsEdgeIndices.indexOf(edgeId) === -1) { + this.physicsBody.physicsEdgeIndices.push(edgeId); + } + } + } + + removeElements(data) { + // schedule removal of elements on the next physicsTick + // avoids having to defensively check every node read in each physics implementation + this.toRemove.nodeIds.push.apply(this.toRemove.nodeIds, data.nodeIds); + this.toRemove.edgeIds.push.apply(this.toRemove.edgeIds, data.edgeIds); + // Handle case where physics is disabled. + if (!this.options.enabled) { + this.processRemovals(); + } + } + + processRemovals() { + while (this.toRemove.nodeIds.length > 0) { + let nodeId = this.toRemove.nodeIds.pop(); + let index = this.physicsBody.physicsNodeIndices.indexOf(nodeId); + if (index > -1) { + this.physicsBody.physicsNodeIndices.splice(index,1); + } + delete this.physicsBody.forces[nodeId]; + delete this.physicsBody.velocities[nodeId]; + delete this.body.nodes[nodeId]; + } + while (this.toRemove.edgeIds.length > 0) { + let edgeId = this.toRemove.edgeIds.pop(); + let index = this.physicsBody.physicsEdgeIndices.indexOf(edgeId); + if (index > -1) { + this.physicsBody.physicsEdgeIndices.splice(index,1); + } + delete this.body.edges[edgeId]; + } + } + + initPhysicsData(data) { + this.physicsBody.forces = {}; + this.physicsBody.physicsNodeIndices = []; + this.physicsBody.physicsEdgeIndices = []; + + this.body.nodes = data.nodes; + this.body.edges = data.edges; + this.addElements(data, false); + + // clean deleted nodes from the velocity vector + for (let nodeId in this.physicsBody.velocities) { + if (this.body.nodes[nodeId] === undefined) { + delete this.physicsBody.velocities[nodeId]; + } + } + } + + /** + * Perform the actual step + * + * @param nodeId + * @param maxVelocity + * @returns {number} + * @private + */ + _performStep(nodeId,maxVelocity) { + let node = this.body.nodes[nodeId]; + let timestep = this.timestep; + let forces = this.physicsBody.forces; + let velocities = this.physicsBody.velocities; + + // store the state so we can revert + this.previousStates[nodeId] = {x:node.x, y:node.y, vx:velocities[nodeId].x, vy:velocities[nodeId].y}; + + if (node.options.fixed.x === false) { + let dx = this.modelOptions.damping * velocities[nodeId].x; // damping force + let ax = (forces[nodeId].x - dx) / node.options.mass; // acceleration + velocities[nodeId].x += ax * timestep; // velocity + velocities[nodeId].x = (Math.abs(velocities[nodeId].x) > maxVelocity) ? ((velocities[nodeId].x > 0) ? maxVelocity : -maxVelocity) : velocities[nodeId].x; + node.x += velocities[nodeId].x * timestep; // position + } + else { + forces[nodeId].x = 0; + velocities[nodeId].x = 0; + } + + if (node.options.fixed.y === false) { + let dy = this.modelOptions.damping * velocities[nodeId].y; // damping force + let ay = (forces[nodeId].y - dy) / node.options.mass; // acceleration + velocities[nodeId].y += ay * timestep; // velocity + velocities[nodeId].y = (Math.abs(velocities[nodeId].y) > maxVelocity) ? ((velocities[nodeId].y > 0) ? maxVelocity : -maxVelocity) : velocities[nodeId].y; + node.y += velocities[nodeId].y * timestep; // position + } + else { + forces[nodeId].y = 0; + velocities[nodeId].y = 0; + } + + let totalVelocity = Math.sqrt(Math.pow(velocities[nodeId].x,2) + Math.pow(velocities[nodeId].y,2)); + return totalVelocity; + } + + _finalizeStabilization() { + this.sendTickResults(); + this.postMessage({ + type: 'finalizeStabilization' + }); + } +} + +export default PhysicsWorker; diff --git a/lib/network/modules/PhysicsWorkerWrapper.js b/lib/network/modules/PhysicsWorkerWrapper.js new file mode 100644 index 00000000..12222d3f --- /dev/null +++ b/lib/network/modules/PhysicsWorkerWrapper.js @@ -0,0 +1,4 @@ +import PhysicsWorker from './PhysicsWorker.js'; + +var physicsWorker = new PhysicsWorker((data) => postMessage(data)); +self.addEventListener('message', (event) => physicsWorker.handleMessage(event), false); diff --git a/lib/network/modules/components/Edge.js b/lib/network/modules/components/Edge.js index 89ac1c76..b6149dfc 100644 --- a/lib/network/modules/components/Edge.js +++ b/lib/network/modules/components/Edge.js @@ -51,7 +51,10 @@ class Edge { this.labelModule = new Label(this.body, this.options); + // prevents sending connected messages on initial creation as it should be handled by added element + this.sendPhysicsUpdates = false; this.setOptions(options); + this.sendPhysicsUpdates = true; } @@ -86,10 +89,14 @@ class Edge { // A node is connected when it has a from and to node that both exist in the network.body.nodes. this.connect(); + // TODO make changing physics of 1 edge not trigger a complete rebuild of physics processing. if (options.hidden !== undefined || options.physics !== undefined) { dataChanged = true; } + // TODO if edgeType.via.id, toId, fromId, to.id, or from.id changed + // emit _physicsUpdate + return dataChanged; } @@ -252,6 +259,8 @@ class Edge { * Connect an edge to its nodes */ connect() { + let previousConnected = this.connected; + this.disconnect(); this.from = this.body.nodes[this.fromId] || undefined; @@ -272,6 +281,10 @@ class Edge { } this.edgeType.connect(); + + if (this.sendPhysicsUpdates && this.connected !== previousConnected) { + this.body.emitter.emit('_physicsUpdate', {type: 'edge', id: this.id, options: {connected: this.connected}}); + } } diff --git a/lib/network/modules/components/Node.js b/lib/network/modules/components/Node.js index 44c38993..4672e48f 100644 --- a/lib/network/modules/components/Node.js +++ b/lib/network/modules/components/Node.js @@ -59,8 +59,8 @@ class Node { this.grouplist = grouplist; // state options - this.x = undefined; - this.y = undefined; + this._x = undefined; + this._y = undefined; this.baseSize = this.options.size; this.baseFontSize = this.options.font.size; this.predefinedPosition = false; // used to check if initial fit should just take the range or approximate @@ -68,9 +68,58 @@ class Node { this.hover = false; this.labelModule = new Label(this.body, this.options); + // prevents sending connected messages on initial creation as it should be handled by added element + this.sendPhysicsUpdates = false; this.setOptions(options); + this.sendPhysicsUpdates = true; } + get x() { + return this._x; + } + + set x(newX) { + this._x = newX; + this.body.emitter.emit('_positionUpdate', {id: this.id, x: this._x, y: this._y}); + } + + /** + * Non emitting version for use by physics engine so we don't create infinite loops. + * @param newX + */ + setX(newX) { + this._x = newX; + } + + get y() { + return this._y; + } + + set y(newY) { + this._y = newY; + this.body.emitter.emit('_positionUpdate', {id: this.id, x: this._x, y: this._y}); + } + + /** + * Emitting version + * + * @param newFixed + */ + setFixed(newFixed) { + // TODO split out fixed portion? + let physOpts = Node.parseOptions(this.options, {fixed: newFixed}); + if (Object.keys(physOpts).length > 0) { + this.body.emitter.emit('_physicsUpdate', {type: 'node', id: this.id, options: physOpts}); + } + } + + /** + * Non emitting version for use by physics engine so we don't create infinite loops. + * @param newY + */ + setY(newY) { + this._y = newY; + } /** * Attach a edge to the node @@ -136,7 +185,7 @@ class Node { } // this transforms all shorthands into fully defined options - Node.parseOptions(this.options, options, true, this.globalOptions); + let physOpts = Node.parseOptions(this.options, options, true, this.globalOptions); // load the images if (this.options.image !== undefined) { @@ -151,8 +200,21 @@ class Node { this.updateLabelModule(); this.updateShape(currentShape); + if (options.mass !== undefined) { + this.options.mass = options.mass; + physOpts.mass = options.mass; + } + if (options.physics !== undefined) { + this.options.physics = options.physics; + physOpts.physics = options.physics; + } + + if (this.sendPhysicsUpdates && Object.keys(physOpts).length > 0) { + this.body.emitter.emit('_physicsUpdate', {type: 'node', id: this.id, options: physOpts}); + } - if (options.hidden !== undefined || options.physics !== undefined) { + // TODO make embedded physics trigger this or handle _physicsUpdate messages + if (options.hidden !== undefined) { return true; } return false; @@ -172,6 +234,7 @@ class Node { 'fixed', 'shadow' ]; + var changedPhysicsOptions = {}; util.selectiveNotDeepExtend(fields, parentOptions, newOptions, allowDeletion); // merge the shadow options into the parent. @@ -189,15 +252,26 @@ class Node { // handle the fixed options if (newOptions.fixed !== undefined && newOptions.fixed !== null) { if (typeof newOptions.fixed === 'boolean') { - parentOptions.fixed.x = newOptions.fixed; - parentOptions.fixed.y = newOptions.fixed; + if (parentOptions.fixed.x !== newOptions.fixed || parentOptions.fixed.y !== newOptions.fixed) { + parentOptions.fixed.x = newOptions.fixed; + parentOptions.fixed.y = newOptions.fixed; + changedPhysicsOptions.fixed = {x: newOptions.fixed, y: newOptions.fixed}; + } } else { - if (newOptions.fixed.x !== undefined && typeof newOptions.fixed.x === 'boolean') { + if (newOptions.fixed.x !== undefined && + typeof newOptions.fixed.x === 'boolean' && + parentOptions.fixed.x !== newOptions.fixed.x) + { parentOptions.fixed.x = newOptions.fixed.x; + util.deepExtend(changedPhysicsOptions, {fixed: {x: newOptions.fixed.x}}); } - if (newOptions.fixed.y !== undefined && typeof newOptions.fixed.y === 'boolean') { + if (newOptions.fixed.y !== undefined && + typeof newOptions.fixed.y === 'boolean' && + parentOptions.fixed.y !== newOptions.fixed.y) + { parentOptions.fixed.y = newOptions.fixed.y; + util.deepExtend(changedPhysicsOptions, {fixed: {y: newOptions.fixed.y}}); } } } @@ -214,6 +288,7 @@ class Node { if (newOptions.scaling !== undefined) { util.mergeOptions(parentOptions.scaling, newOptions.scaling, 'label', allowDeletion, globalOptions.scaling); } + return changedPhysicsOptions; } updateLabelModule() { diff --git a/lib/network/modules/components/physics/SpringSolver.js b/lib/network/modules/components/physics/SpringSolver.js index 194631f3..b16069bb 100644 --- a/lib/network/modules/components/physics/SpringSolver.js +++ b/lib/network/modules/components/physics/SpringSolver.js @@ -18,6 +18,7 @@ class SpringSolver { let edgeLength, edge; let edgeIndices = this.physicsBody.physicsEdgeIndices; let edges = this.body.edges; + let nodes = this.body.nodes; let node1, node2, node3; // forces caused by the edges, modelled as springs @@ -28,9 +29,9 @@ class SpringSolver { if (this.body.nodes[edge.toId] !== undefined && this.body.nodes[edge.fromId] !== undefined) { if (edge.edgeType.via !== undefined) { edgeLength = edge.options.length === undefined ? this.options.springLength : edge.options.length; - node1 = edge.to; - node2 = edge.edgeType.via; - node3 = edge.from; + node1 = nodes[edge.toId]; + node2 = nodes[edge.edgeType.via.id]; + node3 = nodes[edge.fromId]; this._calculateSpringForce(node1, node2, 0.5 * edgeLength); this._calculateSpringForce(node2, node3, 0.5 * edgeLength); @@ -39,7 +40,7 @@ class SpringSolver { // the * 1.5 is here so the edge looks as large as a smooth edge. It does not initially because the smooth edges use // the support nodes which exert a repulsive force on the to and from nodes, making the edge appear larger. edgeLength = edge.options.length === undefined ? this.options.springLength * 1.5: edge.options.length; - this._calculateSpringForce(edge.from, edge.to, edgeLength); + this._calculateSpringForce(nodes[edge.fromId], nodes[edge.toId], edgeLength); } } } diff --git a/lib/network/options.js b/lib/network/options.js index ba435208..54071e19 100644 --- a/lib/network/options.js +++ b/lib/network/options.js @@ -226,6 +226,7 @@ let allOptions = { }, physics: { enabled: { boolean }, + useWorker: { boolean }, barnesHut: { gravitationalConstant: { number }, centralGravity: { number }, diff --git a/package.json b/package.json index 3366c2d7..889cab57 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,9 @@ "moment": "^2.10.2", "propagating-hammerjs": "^1.4.3", "uuid": "^2.0.1", + "worker-loader": "^0.6.0" + }, + "devDependencies": { "babel": "^5.1.11", "babel-loader": "^5.0.0", "babelify": "^6.0.2",