From 64c1995d81e913ddb23b06ebfa45dc3090e69d83 Mon Sep 17 00:00:00 2001 From: Alex de Mulder Date: Wed, 19 Aug 2015 21:13:01 +0200 Subject: [PATCH] added public ready versions of kamadakawai and adaptive layout, clustering bugfixes and reactive network. --- HISTORY.md | 4 + dist/vis.js | 162 ++++++++++++++++++--------- docs/network/layout.html | 2 + docs/network/physics.html | 4 +- lib/network/modules/Canvas.js | 36 +++++- lib/network/modules/LayoutEngine.js | 110 +++++++++--------- lib/network/modules/PhysicsEngine.js | 5 +- lib/network/options.js | 6 +- 8 files changed, 221 insertions(+), 108 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index b5d76dad..5894cb3a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -19,6 +19,10 @@ http://visjs.org - Added Adaptive timestep to the physics solvers for increased performance during stabilization. - Fixed bugs in clustering algorithm. - Greatly improved performance in clustering. +- Fixed find node return types. +- Made the network keep its 'view' during a change of the size of the container. +- Added improvedLayout as experimental option for greatly improved stabilization times. +- Added adaptiveTimestep as experimental option for greatly improved stabilization times. ## 2015-07-27, version 4.7.0 diff --git a/dist/vis.js b/dist/vis.js index 759a85b5..99f03193 100644 --- a/dist/vis.js +++ b/dist/vis.js @@ -5,7 +5,7 @@ * A dynamic, browser-based visualization library. * * @version 4.7.1-SNAPSHOT - * @date 2015-08-16 + * @date 2015-08-19 * * @license * Copyright (C) 2011-2014 Almende B.V, http://almende.com @@ -10642,7 +10642,7 @@ return /******/ (function(modules) { // webpackBootstrap if (options.valueMax !== undefined) this.defaultValueMax = options.valueMax; if (options.backgroundColor !== undefined) this._setBackgroundColor(options.backgroundColor); - if (options.cameraPosition !== undefined) cameraPosition = options.cameraPosition; + if (options.cameraState !== undefined) cameraPosition = options.cameraState; if (cameraPosition !== undefined) { this.camera.setArmRotation(cameraPosition.horizontal, cameraPosition.vertical); @@ -33079,7 +33079,8 @@ return /******/ (function(modules) { // webpackBootstrap onlyDynamicEdges: false, fit: true }, - timestep: 0.5 + timestep: 0.5, + adaptiveTimestep: true }; util.extend(this.options, this.defaultOptions); this.timestep = 0.5; @@ -33627,7 +33628,7 @@ return /******/ (function(modules) { // webpackBootstrap } // enable adaptive timesteps - this.adaptiveTimestep = true; + 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"); @@ -36147,6 +36148,7 @@ return /******/ (function(modules) { // webpackBootstrap this.pixelRatio = 1; this.resizeTimer = undefined; this.resizeFunction = this._onResize.bind(this); + this.cameraState = {}; this.options = {}; this.defaultOptions = { @@ -36219,6 +36221,42 @@ return /******/ (function(modules) { // webpackBootstrap this.setSize(); this.body.emitter.emit("_redraw"); } + + /** + * Get and store the cameraState + * @private + */ + }, { + key: '_getCameraState', + value: function _getCameraState() { + this.cameraState.previousWidth = this.frame.canvas.width; + this.cameraState.scale = this.body.view.scale; + this.cameraState.position = this.DOMtoCanvas({ x: 0.5 * this.frame.canvas.width, y: 0.5 * this.frame.canvas.height }); + } + + /** + * Set the cameraState + * @private + */ + }, { + key: '_setCameraState', + value: function _setCameraState() { + if (this.cameraState.scale !== undefined) { + this.body.view.scale = this.body.view.scale * (this.frame.canvas.clientWidth / this.cameraState.previousWidth); + + // this comes from the view module. + var viewCenter = this.DOMtoCanvas({ + x: 0.5 * this.frame.canvas.clientWidth, + y: 0.5 * this.frame.canvas.clientHeight + }); + var distanceFromCenter = { // offset from view, distance view has to change by these x and y to center the node + x: viewCenter.x - this.cameraState.position.x, + y: viewCenter.y - this.cameraState.position.y + }; + this.body.view.translation.x += distanceFromCenter.x * this.body.view.scale; + this.body.view.translation.y += distanceFromCenter.y * this.body.view.scale; + } + } }, { key: '_prepareValue', value: function _prepareValue(value) { @@ -36360,6 +36398,7 @@ return /******/ (function(modules) { // webpackBootstrap var width = arguments.length <= 0 || arguments[0] === undefined ? this.options.width : arguments[0]; var height = arguments.length <= 1 || arguments[1] === undefined ? this.options.height : arguments[1]; + this._getCameraState(); width = this._prepareValue(width); height = this._prepareValue(height); @@ -36403,7 +36442,7 @@ return /******/ (function(modules) { // webpackBootstrap oldHeight: Math.round(oldHeight / this.pixelRatio) }); } - + this._setCameraState(); return emitEvent; } }, { @@ -38887,6 +38926,7 @@ return /******/ (function(modules) { // webpackBootstrap this.defaultOptions = { randomSeed: undefined, + improvedLayout: true, hierarchical: { enabled: false, levelSeparation: 150, @@ -38921,7 +38961,7 @@ return /******/ (function(modules) { // webpackBootstrap value: function setOptions(options, allOptions) { if (options !== undefined) { var prevHierarchicalState = this.options.hierarchical.enabled; - + util.selectiveDeepExtend(["randomSeed", "improvedLayout"], this.options, options); util.mergeOptions(this.options, options, 'hierarchical'); if (options.randomSeed !== undefined) { this.initialRandomSeed = options.randomSeed; @@ -39051,58 +39091,70 @@ return /******/ (function(modules) { // webpackBootstrap }, { key: 'layoutNetwork', value: function layoutNetwork() { - // first check if we should KamadaKawai to layout. The threshold is if less than half of the visible - // nodes have predefined positions we use this. - var positionDefined = 0; - for (var i = 0; i < this.body.nodeIndices.length; i++) { - var node = this.body.nodes[this.body.nodeIndices[i]]; - if (node.predefinedPosition === true) { - positionDefined += 1; - } - } - - // if less than half of the nodes have a predefined position we continue - if (positionDefined < 0.5 * this.body.nodeIndices.length) { - var levels = 0; - var clusterThreshold = 100; - // if there are a lot of nodes, we cluster before we run the algorithm. - if (this.body.nodeIndices.length > clusterThreshold) { - var startLength = this.body.nodeIndices.length; - while (this.body.nodeIndices.length > clusterThreshold) { - levels += 1; - // if there are many nodes we do a hubsize cluster - if (levels % 3 === 0) { - this.body.modules.clustering.clusterBridges(); - } else { - this.body.modules.clustering.clusterOutliers(); - } + if (this.options.hierarchical.enabled !== true && this.options.improvedLayout === true) { + // first check if we should KamadaKawai to layout. The threshold is if less than half of the visible + // nodes have predefined positions we use this. + var positionDefined = 0; + for (var i = 0; i < this.body.nodeIndices.length; i++) { + var node = this.body.nodes[this.body.nodeIndices[i]]; + if (node.predefinedPosition === true) { + positionDefined += 1; } - // increase the size of the edges - this.body.modules.kamadaKawai.setOptions({ springLength: Math.max(150, 2 * startLength) }); - } - - // position the system for these nodes and edges - this.body.modules.kamadaKawai.solve(this.body.nodeIndices, this.body.edgeIndices, true); - - // uncluster all clusters - if (levels > 0) { - var clustersPresent = true; - while (clustersPresent === true) { - clustersPresent = false; - for (var i = 0; i < this.body.nodeIndices.length; i++) { - if (this.body.nodes[this.body.nodeIndices[i]].isCluster === true) { - clustersPresent = true; - this.body.modules.clustering.openCluster(this.body.nodeIndices[i], {}, false); + } + + // if less than half of the nodes have a predefined position we continue + if (positionDefined < 0.5 * this.body.nodeIndices.length) { + var levels = 0; + var clusterThreshold = 100; + // if there are a lot of nodes, we cluster before we run the algorithm. + if (this.body.nodeIndices.length > clusterThreshold) { + var startLength = this.body.nodeIndices.length; + while (this.body.nodeIndices.length > clusterThreshold) { + levels += 1; + var before = this.body.nodeIndices.length; + // if there are many nodes we do a hubsize cluster + if (levels % 3 === 0) { + this.body.modules.clustering.clusterBridges(); + } else { + this.body.modules.clustering.clusterOutliers(); + } + var after = this.body.nodeIndices.length; + if (before == after && levels % 3 !== 0) { + this._declusterAll(); + console.info("This network could not be positioned by this version of the improved layout algorithm."); + return; } } - if (clustersPresent === true) { - this.body.emitter.emit('_dataChanged'); - } + // increase the size of the edges + this.body.modules.kamadaKawai.setOptions({ springLength: Math.max(150, 2 * startLength) }); } - } - // reposition all bezier nodes. - this.body.emitter.emit("_repositionBezierNodes"); + // position the system for these nodes and edges + this.body.modules.kamadaKawai.solve(this.body.nodeIndices, this.body.edgeIndices, true); + + // uncluster all clusters + this._declusterAll(); + + // reposition all bezier nodes. + this.body.emitter.emit("_repositionBezierNodes"); + } + } + } + }, { + key: '_declusterAll', + value: function _declusterAll() { + var clustersPresent = true; + while (clustersPresent === true) { + clustersPresent = false; + for (var i = 0; i < this.body.nodeIndices.length; i++) { + if (this.body.nodes[this.body.nodeIndices[i]].isCluster === true) { + clustersPresent = true; + this.body.modules.clustering.openCluster(this.body.nodeIndices[i], {}, false); + } + } + if (clustersPresent === true) { + this.body.emitter.emit('_dataChanged'); + } } } }, { @@ -40779,6 +40831,7 @@ return /******/ (function(modules) { // webpackBootstrap }, layout: { randomSeed: { 'undefined': 'undefined', number: number }, + improvedLayout: { boolean: boolean }, hierarchical: { enabled: { boolean: boolean }, levelSeparation: { number: number }, @@ -40932,6 +40985,7 @@ return /******/ (function(modules) { // webpackBootstrap __type__: { object: object, boolean: boolean } }, timestep: { number: number }, + adaptiveTimestep: { boolean: boolean }, __type__: { object: object, boolean: boolean } }, @@ -41071,6 +41125,7 @@ return /******/ (function(modules) { // webpackBootstrap }, layout: { //randomSeed: [0, 0, 500, 1], + //improvedLayout: true, hierarchical: { enabled: false, levelSeparation: [150, 20, 500, 5], @@ -41140,6 +41195,7 @@ return /******/ (function(modules) { // webpackBootstrap solver: ['barnesHut', 'forceAtlas2Based', 'repulsion', 'hierarchicalRepulsion'], timestep: [0.5, 0.01, 1, 0.01] }, + //adaptiveTimestep: true global: { locale: ['en', 'nl'] } diff --git a/docs/network/layout.html b/docs/network/layout.html index ea33c7dd..c43a838c 100644 --- a/docs/network/layout.html +++ b/docs/network/layout.html @@ -101,6 +101,7 @@ var options = { layout: { randomSeed: undefined, + improvedLayout:true, hierarchical: { enabled:false, levelSeparation: 150, @@ -127,6 +128,7 @@ network.setOptions(options); + diff --git a/docs/network/physics.html b/docs/network/physics.html index cb9acb07..c8fe9920 100644 --- a/docs/network/physics.html +++ b/docs/network/physics.html @@ -138,7 +138,8 @@ var options = { onlyDynamicEdges: false, fit: true }, - timestep: 0.5 + timestep: 0.5, + adaptiveTimestep: true } } @@ -201,6 +202,7 @@ network.setOptions(options); +
NameTypeDefaultDescription
randomSeedNumberundefined When NOT using the hierarchical layout, the nodes are randomly positioned initially. This means that the settled result is different every time. If you provide a random seed manually, the layout will be the same every time. Ideally you try with an undefined seed, reload until you are happy with the layout and use the getSeed() method to ascertain the seed.
improvedLayoutBooleantrue When enabled, the network will use the Kamada Kawai algorithm for initial layout. For networks larger than 100 nodes, clustering will be performed automatically to reduce the amount of nodes. This can greatly improve the stabilization times. If the network is very interconnected (no or few leaf nodes), this may not work and it will revert back to the old method. Performance will be improved in the future.
hierarchicalObject or BooleanObject When true, the layout engine positions the nodes in a hierarchical fashion using default settings. For customization you can supply an object.
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.
diff --git a/lib/network/modules/Canvas.js b/lib/network/modules/Canvas.js index 5589712e..0b7fe972 100644 --- a/lib/network/modules/Canvas.js +++ b/lib/network/modules/Canvas.js @@ -16,6 +16,7 @@ class Canvas { this.pixelRatio = 1; this.resizeTimer = undefined; this.resizeFunction = this._onResize.bind(this); + this.cameraState = {}; this.options = {}; this.defaultOptions = { @@ -82,6 +83,38 @@ class Canvas { this.body.emitter.emit("_redraw"); } + /** + * Get and store the cameraState + * @private + */ + _getCameraState() { + this.cameraState.previousWidth = this.frame.canvas.width; + this.cameraState.scale = this.body.view.scale; + this.cameraState.position = this.DOMtoCanvas({x: 0.5 * this.frame.canvas.width, y: 0.5 * this.frame.canvas.height}); + } + + /** + * Set the cameraState + * @private + */ + _setCameraState() { + if (this.cameraState.scale !== undefined) { + this.body.view.scale = this.body.view.scale * (this.frame.canvas.clientWidth / this.cameraState.previousWidth); + + // this comes from the view module. + var viewCenter = this.DOMtoCanvas({ + x: 0.5 * this.frame.canvas.clientWidth, + y: 0.5 * this.frame.canvas.clientHeight + }); + var distanceFromCenter = { // offset from view, distance view has to change by these x and y to center the node + x: viewCenter.x - this.cameraState.position.x, + y: viewCenter.y - this.cameraState.position.y + }; + this.body.view.translation.x += distanceFromCenter.x * this.body.view.scale; + this.body.view.translation.y += distanceFromCenter.y * this.body.view.scale; + } + } + _prepareValue(value) { if (typeof value === 'number') { return value + 'px'; @@ -194,6 +227,7 @@ class Canvas { * or '30%') */ setSize(width = this.options.width, height = this.options.height) { + this._getCameraState(); width = this._prepareValue(width); height= this._prepareValue(height); @@ -238,7 +272,7 @@ class Canvas { oldHeight: Math.round(oldHeight / this.pixelRatio) }); } - + this._setCameraState(); return emitEvent; }; diff --git a/lib/network/modules/LayoutEngine.js b/lib/network/modules/LayoutEngine.js index e2de9308..16c90538 100644 --- a/lib/network/modules/LayoutEngine.js +++ b/lib/network/modules/LayoutEngine.js @@ -14,6 +14,7 @@ class LayoutEngine { this.defaultOptions = { randomSeed: undefined, + improvedLayout: true, hierarchical: { enabled:false, levelSeparation: 150, @@ -43,11 +44,9 @@ class LayoutEngine { setOptions(options, allOptions) { if (options !== undefined) { let prevHierarchicalState = this.options.hierarchical.enabled; - + util.selectiveDeepExtend(["randomSeed", "improvedLayout"],this.options, options); util.mergeOptions(this.options, options, 'hierarchical'); - if (options.randomSeed !== undefined) { - this.initialRandomSeed = options.randomSeed; - } + if (options.randomSeed !== undefined) {this.initialRandomSeed = options.randomSeed;} if (this.options.hierarchical.enabled === true) { if (prevHierarchicalState === true) { @@ -176,59 +175,70 @@ class LayoutEngine { * cluster them first to reduce the amount. */ layoutNetwork() { - // first check if we should KamadaKawai to layout. The threshold is if less than half of the visible - // nodes have predefined positions we use this. - let positionDefined = 0; - for (let i = 0; i < this.body.nodeIndices.length; i++) { - let node = this.body.nodes[this.body.nodeIndices[i]]; - if (node.predefinedPosition === true) { - positionDefined += 1; + if (this.options.hierarchical.enabled !== true && this.options.improvedLayout === true) { + // first check if we should KamadaKawai to layout. The threshold is if less than half of the visible + // nodes have predefined positions we use this. + let positionDefined = 0; + for (let i = 0; i < this.body.nodeIndices.length; i++) { + let node = this.body.nodes[this.body.nodeIndices[i]]; + if (node.predefinedPosition === true) { + positionDefined += 1; + } } - } - // if less than half of the nodes have a predefined position we continue - if (positionDefined < 0.5 * this.body.nodeIndices.length) { - let levels = 0; - let clusterThreshold = 100; - // if there are a lot of nodes, we cluster before we run the algorithm. - if (this.body.nodeIndices.length > clusterThreshold) { - let startLength = this.body.nodeIndices.length; - while(this.body.nodeIndices.length > clusterThreshold) { - levels += 1; - // if there are many nodes we do a hubsize cluster - if (levels % 3 === 0) { - this.body.modules.clustering.clusterBridges(); - } - else { - this.body.modules.clustering.clusterOutliers(); - } - } - // increase the size of the edges - this.body.modules.kamadaKawai.setOptions({springLength: Math.max(150,2*startLength)}) - } - - // position the system for these nodes and edges - this.body.modules.kamadaKawai.solve(this.body.nodeIndices, this.body.edgeIndices, true); - - // uncluster all clusters - if (levels > 0) { - let clustersPresent = true; - while (clustersPresent === true) { - clustersPresent = false; - for (let i = 0; i < this.body.nodeIndices.length; i++) { - if (this.body.nodes[this.body.nodeIndices[i]].isCluster === true) { - clustersPresent = true; - this.body.modules.clustering.openCluster(this.body.nodeIndices[i], {}, false); + // if less than half of the nodes have a predefined position we continue + if (positionDefined < 0.5 * this.body.nodeIndices.length) { + let levels = 0; + let clusterThreshold = 100; + // if there are a lot of nodes, we cluster before we run the algorithm. + if (this.body.nodeIndices.length > clusterThreshold) { + let startLength = this.body.nodeIndices.length; + while (this.body.nodeIndices.length > clusterThreshold) { + levels += 1; + let before = this.body.nodeIndices.length; + // if there are many nodes we do a hubsize cluster + if (levels % 3 === 0) { + this.body.modules.clustering.clusterBridges(); + } + else { + this.body.modules.clustering.clusterOutliers(); + } + let after = this.body.nodeIndices.length; + if (before == after && levels % 3 !== 0) { + this._declusterAll(); + console.info("This network could not be positioned by this version of the improved layout algorithm."); + return; } } - if (clustersPresent === true) { - this.body.emitter.emit('_dataChanged'); - } + // increase the size of the edges + this.body.modules.kamadaKawai.setOptions({springLength: Math.max(150, 2 * startLength)}) } + + // position the system for these nodes and edges + this.body.modules.kamadaKawai.solve(this.body.nodeIndices, this.body.edgeIndices, true); + + // uncluster all clusters + this._declusterAll(); + + // reposition all bezier nodes. + this.body.emitter.emit("_repositionBezierNodes"); } + } + } - // reposition all bezier nodes. - this.body.emitter.emit("_repositionBezierNodes"); + _declusterAll() { + let clustersPresent = true; + while (clustersPresent === true) { + clustersPresent = false; + for (let i = 0; i < this.body.nodeIndices.length; i++) { + if (this.body.nodes[this.body.nodeIndices[i]].isCluster === true) { + clustersPresent = true; + this.body.modules.clustering.openCluster(this.body.nodeIndices[i], {}, false); + } + } + if (clustersPresent === true) { + this.body.emitter.emit('_dataChanged'); + } } } diff --git a/lib/network/modules/PhysicsEngine.js b/lib/network/modules/PhysicsEngine.js index 08d86bb5..8b3e9184 100644 --- a/lib/network/modules/PhysicsEngine.js +++ b/lib/network/modules/PhysicsEngine.js @@ -81,7 +81,8 @@ class PhysicsEngine { onlyDynamicEdges: false, fit: true }, - timestep: 0.5 + timestep: 0.5, + adaptiveTimestep: true }; util.extend(this.options, this.defaultOptions); this.timestep = 0.5; @@ -597,7 +598,7 @@ class PhysicsEngine { } // enable adaptive timesteps - this.adaptiveTimestep = true; + 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"); diff --git a/lib/network/options.js b/lib/network/options.js index 7f4de30d..ffab67fc 100644 --- a/lib/network/options.js +++ b/lib/network/options.js @@ -117,6 +117,7 @@ let allOptions = { }, layout: { randomSeed: { 'undefined': 'undefined', number }, + improvedLayout: { boolean }, hierarchical: { enabled: { boolean }, levelSeparation: { number }, @@ -270,6 +271,7 @@ let allOptions = { __type__: { object, boolean } }, timestep: { number }, + adaptiveTimestep: { boolean }, __type__: { object, boolean } }, @@ -410,6 +412,7 @@ let configureOptions = { }, layout: { //randomSeed: [0, 0, 500, 1], + //improvedLayout: true, hierarchical: { enabled: false, levelSeparation: [150, 20, 500, 5], @@ -477,7 +480,8 @@ let configureOptions = { maxVelocity: [50, 0, 150, 1], minVelocity: [0.1, 0.01, 0.5, 0.01], solver: ['barnesHut', 'forceAtlas2Based', 'repulsion', 'hierarchicalRepulsion'], - timestep: [0.5, 0.01, 1, 0.01] + timestep: [0.5, 0.01, 1, 0.01], + //adaptiveTimestep: true }, global: { locale: ['en', 'nl']