Browse Source

[Network] Dynamically adjust clustering when data changes. (#3330)

* First working version of updating clustered edge

* Added fix for #1315 as well

* Enable unit testing for module Network

Adds mocks for certain components, in order to let module `Network` be run in unit tests.

Changes:

- Create a mock object for `hammer.js` when running browserless. The inspiration is taken from [here](https://github.com/uber/deck.gl/pull/658)
- Create a mock function for `window.requestAnimationFrame()` when running browserless in `network/modules/CanvasRenderer.js`
- Added unit test for `Network` to show that unit testing for it now works
- Fixed naming of container in `test/Graph3d.test.js`

Since `hammer.js` is also used in other modules, this fix is potentially an enabler for full-module unit tests for those as well.

* Cleanup unit test Network

* Added unit test for fix issue #1218

* Adding test for #1315 - Interim save

* Completed unit test and fixes for #1315

* Added fixes for #1291

* Added unit test for #1219

* Added header comment for Clustering.js, small fixes

* Fix for unit test

* Added example networks to unit test

* Fix error in loading disassemblerExample

* Network unit test final fixes

* Fixes for linting

* Fixed essential typo

* Fixed linting error

* Fixed unit test

* Fixed unit test again
revert-3409-performance
wimrijnders 7 years ago
committed by Yotam Berkowitz
parent
commit
810d088d34
11 changed files with 954 additions and 115 deletions
  1. +2
    -45
      examples/network/exampleApplications/disassemblerExample.html
  2. +53
    -0
      examples/network/exampleApplications/disassemblerExample.js
  3. +25
    -3
      lib/module/hammer.js
  4. +20
    -13
      lib/network/Network.js
  5. +80
    -17
      lib/network/modules/CanvasRenderer.js
  6. +364
    -29
      lib/network/modules/Clustering.js
  7. +36
    -6
      lib/network/modules/EdgesHandler.js
  8. +19
    -0
      lib/network/modules/components/Edge.js
  9. +2
    -2
      test/Graph3d.test.js
  10. +332
    -0
      test/Network.test.js
  11. +21
    -0
      test/network/basicUsage.js

+ 2
- 45
examples/network/exampleApplications/disassemblerExample.html View File

@ -9,59 +9,16 @@
</style>
<link href="../../../dist/vis-network.min.css" rel="stylesheet" type="text/css" />
<script src="../../../dist/vis.js"></script>
<script src="disassemblerExample.js"></script>
</head>
<body>
<p>Use VisJS to diagram the Control-Flow-Graph (CFG) of a function from
a program you wish to analyze.</p>
<p><div id="mynetwork"></div><br /></p>
<script type="text/javascript">
var opts = {
manipulation: false,
height: '90%',
layout: {
hierarchical: {
enabled: true,
levelSeparation: 300
}
},
physics: {
hierarchicalRepulsion: {
nodeDistance: 300
}
}
};
nodes = [
{'id': 'cfg_0x00405a2e', 'size': 150, 'label': "0x00405a2e:\nmov DWORD PTR ss:[esp + 0x000000b0], 0x00000002\nmov DWORD PTR ss:[ebp + 0x00], esi\ntest bl, 0x02\nje 0x00405a49<<Insn>>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}},
{'id': 'cfg_0x00405a49', 'size': 150, 'label': "0x00405a49:\ntest bl, 0x01\nje 0x00405a62<<Insn>>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}},
{'id': 'cfg_0x00405a55', 'size': 150, 'label': "0x00405a55:\nmov ecx, DWORD PTR ss:[esp + 0x1c]\npush ecx\ncall 0x004095c6<<Func>>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}},
{'id': 'cfg_0x00405a62', 'size': 150, 'label': "0x00405a62:\nmov eax, 0x00000002\nmov ecx, DWORD PTR ss:[esp + 0x000000a8]\nmov DWORD PTR fs:[0x00000000], ecx\npop ecx\npop esi\npop ebp\npop ebx\nadd esp, 0x000000a4\nret\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}},
{'id': 'cfg_0x004095c6', 'size': 150, 'label': "0x004095c6:\nmov edi, edi\npush ebp\nmov ebp, esp\npop ebp\njmp 0x00417563<<Func>>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}},
{'id': 'cfg_0x00405a39', 'size': 150, 'label': "0x00405a39:\nand ebx, 0xfd<-0x03>\nlea ecx, [esp + 0x34]\nmov DWORD PTR ss:[esp + 0x10], ebx\ncall 0x00403450<<Func>>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}},
{'id': 'cfg_0x00403450', 'size': 150, 'label': "0x00403450:\npush 0xff<-0x01>\npush 0x0042fa64\nmov eax, DWORD PTR fs:[0x00000000]\npush eax\npush ecx\npush ebx\npush ebp\npush esi\npush edi\nmov eax, DWORD PTR ds:[0x0043dff0<.data+0x0ff0>]\nxor eax, esp\npush eax\nlea eax, [esp + 0x18]\nmov DWORD PTR fs:[0x00000000], eax\nmov esi, ecx\nmov DWORD PTR ss:[esp + 0x14], esi\npush esi\nmov DWORD PTR ss:[esp + 0x24], 0x00000004\ncall 0x0042f03f<<Func>>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}},
{'id': 'cfg_0x00405a4e', 'size': 150, 'label': "0x00405a4e:\ncmp DWORD PTR ss:[esp + 0x30], 0x10\njb 0x00405a62<<Insn>>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}},
{'id': 'cfg_0x00405a5f', 'size': 150, 'label': "0x00405a5f:\nadd esp, 0x04\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}},
]
edges = [
{'from': "cfg_0x00405a2e", 'to': "cfg_0x00405a39", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a2e", 'to': "cfg_0x00405a49", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a49", 'to': "cfg_0x00405a4e", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a49", 'to': "cfg_0x00405a62", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a55", 'to': "cfg_0x00405a5f", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a55", 'to': "cfg_0x004095c6", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x004095c6", 'to': "cfg_0x00417563", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a39", 'to': "cfg_0x00403450", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a39", 'to': "cfg_0x00405a49", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00403450", 'to': "cfg_0x00403489", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00403450", 'to': "cfg_0x0042f03f", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a4e", 'to': "cfg_0x00405a55", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a4e", 'to': "cfg_0x00405a62", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a5f", 'to': "cfg_0x00405a62", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
]
var container = document.getElementById('mynetwork');
var data = {'nodes': nodes, 'edges': edges}
var gph = new vis.Network(container, data, opts);
var gph = new vis.Network(container, data, options);
</script>
</body>
</html>

+ 53
- 0
examples/network/exampleApplications/disassemblerExample.js View File

@ -0,0 +1,53 @@
var options = {
manipulation: false,
height: '90%',
layout: {
hierarchical: {
enabled: true,
levelSeparation: 300
}
},
physics: {
hierarchicalRepulsion: {
nodeDistance: 300
}
}
};
var nodes = [
{'id': 'cfg_0x00405a2e', 'size': 150, 'label': "0x00405a2e:\nmov DWORD PTR ss:[esp + 0x000000b0], 0x00000002\nmov DWORD PTR ss:[ebp + 0x00], esi\ntest bl, 0x02\nje 0x00405a49<<Insn>>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}},
{'id': 'cfg_0x00405a49', 'size': 150, 'label': "0x00405a49:\ntest bl, 0x01\nje 0x00405a62<<Insn>>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}},
{'id': 'cfg_0x00405a55', 'size': 150, 'label': "0x00405a55:\nmov ecx, DWORD PTR ss:[esp + 0x1c]\npush ecx\ncall 0x004095c6<<Func>>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}},
{'id': 'cfg_0x00405a62', 'size': 150, 'label': "0x00405a62:\nmov eax, 0x00000002\nmov ecx, DWORD PTR ss:[esp + 0x000000a8]\nmov DWORD PTR fs:[0x00000000], ecx\npop ecx\npop esi\npop ebp\npop ebx\nadd esp, 0x000000a4\nret\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}},
{'id': 'cfg_0x004095c6', 'size': 150, 'label': "0x004095c6:\nmov edi, edi\npush ebp\nmov ebp, esp\npop ebp\njmp 0x00417563<<Func>>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}},
{'id': 'cfg_0x00405a39', 'size': 150, 'label': "0x00405a39:\nand ebx, 0xfd<-0x03>\nlea ecx, [esp + 0x34]\nmov DWORD PTR ss:[esp + 0x10], ebx\ncall 0x00403450<<Func>>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}},
{'id': 'cfg_0x00403450', 'size': 150, 'label': "0x00403450:\npush 0xff<-0x01>\npush 0x0042fa64\nmov eax, DWORD PTR fs:[0x00000000]\npush eax\npush ecx\npush ebx\npush ebp\npush esi\npush edi\nmov eax, DWORD PTR ds:[0x0043dff0<.data+0x0ff0>]\nxor eax, esp\npush eax\nlea eax, [esp + 0x18]\nmov DWORD PTR fs:[0x00000000], eax\nmov esi, ecx\nmov DWORD PTR ss:[esp + 0x14], esi\npush esi\nmov DWORD PTR ss:[esp + 0x24], 0x00000004\ncall 0x0042f03f<<Func>>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}},
{'id': 'cfg_0x00405a4e', 'size': 150, 'label': "0x00405a4e:\ncmp DWORD PTR ss:[esp + 0x30], 0x10\njb 0x00405a62<<Insn>>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}},
{'id': 'cfg_0x00405a5f', 'size': 150, 'label': "0x00405a5f:\nadd esp, 0x04\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}},
];
//
// Note: there are a couple of node id's present here which do not exist
// - cfg_0x00417563
// - cfg_0x00403489
// - cfg_0x0042f03f
//
// The edges with these id's will not load into the Network instance.
//
var edges = [
{'from': "cfg_0x00405a2e", 'to': "cfg_0x00405a39", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a2e", 'to': "cfg_0x00405a49", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a49", 'to': "cfg_0x00405a4e", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a49", 'to': "cfg_0x00405a62", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a55", 'to': "cfg_0x00405a5f", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a55", 'to': "cfg_0x004095c6", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x004095c6", 'to': "cfg_0x00417563", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a39", 'to': "cfg_0x00403450", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a39", 'to': "cfg_0x00405a49", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00403450", 'to': "cfg_0x00403489", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00403450", 'to': "cfg_0x0042f03f", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a4e", 'to': "cfg_0x00405a55", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a4e", 'to': "cfg_0x00405a62", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
{'from': "cfg_0x00405a5f", 'to': "cfg_0x00405a62", 'arrows': 'to', 'physics': false, 'smooth': {'type': 'cubicBezier'}},
];

+ 25
- 3
lib/module/hammer.js View File

@ -1,5 +1,26 @@
// Only load hammer.js when in a browser environment
// (loading hammer.js in a node.js environment gives errors)
/**
* Setup a mock hammer.js object, for unit testing.
*
* Inspiration: https://github.com/uber/deck.gl/pull/658
*/
function hammerMock() {
const noop = () => {};
return {
on: noop,
off: noop,
destroy: noop,
emit: noop,
get: function(m) { //eslint-disable-line no-unused-vars
return {
set: noop
};
}
};
}
if (typeof window !== 'undefined') {
var propagating = require('propagating-hammerjs');
var Hammer = window['Hammer'] || require('hammerjs');
@ -9,6 +30,7 @@ if (typeof window !== 'undefined') {
}
else {
module.exports = function () {
throw Error('hammer.js is only available in a browser, not in node.js.');
// hammer.js is only available in a browser, not in node.js. Replacing it with a mock object.
return hammerMock();
}
}

+ 20
- 13
lib/network/Network.js View File

@ -271,14 +271,20 @@ Network.prototype._updateVisibleIndices = function () {
if (edges.hasOwnProperty(edgeId)) {
let edge = edges[edgeId];
if (!this.clustering._isClusteredEdge(edgeId)
// It can happen that this is executed *after* a node edge has been removed,
// but *before* the edge itself has been removed. Taking this into account.
let fromNode = nodes[edge.fromId];
let toNode = nodes[edge.toId];
let edgeNodesPresent = (fromNode !== undefined) && (toNode !== undefined);
let isVisible =
!this.clustering._isClusteredEdge(edgeId)
&& edge.options.hidden === false
// Not all nodes may be present due to interim refresh
&& nodes[edge.fromId] !== undefined
&& nodes[edge.toId ] !== undefined
// Also hidden if any of its connecting nodes are hidden
&& nodes[edge.fromId].options.hidden === false
&& nodes[edge.toId ].options.hidden === false) {
&& edgeNodesPresent
&& fromNode.options.hidden === false // Also hidden if any of its connecting nodes are hidden
&& toNode.options.hidden === false; // idem
if (isVisible) {
this.body.edgeIndices.push(edge.id);
}
}
@ -290,18 +296,19 @@ Network.prototype._updateVisibleIndices = function () {
* Bind all events
*/
Network.prototype.bindEventListeners = function () {
// this event will trigger a rebuilding of the cache everything. Used when nodes or edges have been added or removed.
// This event will trigger a rebuilding of the cache everything.
// Used when nodes or edges have been added or removed.
this.body.emitter.on("_dataChanged", () => {
// update shortcut lists
this._updateVisibleIndices();
this.body.emitter.emit("_requestRedraw");
// call the dataUpdated event because the only difference between the two is the updating of the indices
this.edgesHandler._updateState();
this.body.emitter.emit("_dataUpdated");
});
// this is called when options of EXISTING nodes or edges have changed.
this.body.emitter.on("_dataUpdated", () => {
// update values
// Order important in following block
this.clustering._updateState();
this._updateVisibleIndices();
this._updateValueRange(this.body.nodes);
this._updateValueRange(this.body.edges);
// start simulation (can be called safely, even if already running)

+ 80
- 17
lib/network/modules/CanvasRenderer.js View File

@ -1,6 +1,43 @@
if (typeof window !== 'undefined') {
window.requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame ||
window.webkitRequestAnimationFrame || window.msRequestAnimationFrame;
/**
* Initializes window.requestAnimationFrame() to a usable form.
*
* Specifically, set up this method for the case of running on node.js with jsdom enabled.
*
* NOTES:
*
* * On node.js, when calling this directly outside of this class, `window` is not defined.
* This happens even if jsdom is used.
* * For node.js + jsdom, `window` is available at the moment the constructor is called.
* For this reason, the called is placed within the constructor.
* * Even then, `window.requestAnimationFrame()` is not defined, so it still needs to be added.
* * During unit testing, it happens that the window object is reset during execution, causing
* a runtime error due to missing `requestAnimationFrame()`. This needs to be compensated for,
* see `_requestNextFrame()`.
* * Since this is a global object, it may affect other modules besides `Network`! This has not
* caused any problems yet. The method is only used within `Network`.
*
* @private
*/
function _initRequestAnimationFrame() {
var func;
if (window !== undefined) {
func = window.requestAnimationFrame
|| window.mozRequestAnimationFrame
|| window.webkitRequestAnimationFrame
|| window.msRequestAnimationFrame;
}
if (func === undefined) {
// window or method not present, setting mock requestAnimationFrame
window.requestAnimationFrame =
function(callback) {
//console.log("Called mock requestAnimationFrame");
callback();
}
} else {
window.requestAnimationFrame = func;
}
}
let util = require('../../util');
@ -8,6 +45,7 @@ let util = require('../../util');
class CanvasRenderer {
constructor(body, canvas) {
_initRequestAnimationFrame();
this.body = body;
this.canvas = canvas;
@ -74,15 +112,45 @@ class CanvasRenderer {
}
}
/**
* Prepare the drawing of the next frame.
*
* Calls the callback when the next frame can or will be drawn.
*
* @param delay {number} - timeout case only, wait this number of milliseconds
* @private
*/
_requestNextFrame(callback, delay) {
// During unit testing, it happens that the mock window object is reset while
// the next frame is still pending. Then, either 'window' is not present, or
// 'requestAnimationFrame()' is not present because it is not defined on the
// mock window object.
//
// This is not something that will happen in normal operation, but we still need
// to take it into account.
if (window === undefined) return;
let timer;
if (this.requiresTimeout === true) {
// wait given number of milliseconds and perform the animation step function
timer = window.setTimeout(callback, delay);
}
else {
if (window.requestAnimationFrame) {
timer = window.requestAnimationFrame(callback);
}
}
return timer;
}
_startRendering() {
if (this.renderingActive === true) {
if (this.renderTimer === undefined) {
if (this.requiresTimeout === true) {
this.renderTimer = window.setTimeout(this._renderStep.bind(this), this.simulationInterval); // wait this.renderTimeStep milliseconds and perform the animation step function
}
else {
this.renderTimer = window.requestAnimationFrame(this._renderStep.bind(this)); // wait this.renderTimeStep milliseconds and perform the animation step function
}
this.renderTimer = this._requestNextFrame(this._renderStep.bind(this), this.simulationInterval);
}
}
}
@ -117,18 +185,14 @@ class CanvasRenderer {
/**
* Redraw the network with the current data
* @param hidden | used to get the first estimate of the node sizes. only the nodes are drawn after which they are quickly drawn over.
* @param hidden | Used to get the first estimate of the node sizes.
* Only the nodes are drawn after which they are quickly drawn over.
* @private
*/
_requestRedraw() {
if (this.redrawRequested !== true && this.renderingActive === false && this.allowRedraw === true) {
this.redrawRequested = true;
if (this.requiresTimeout === true) {
window.setTimeout(() => {this._redraw(false);}, 0);
}
else {
window.requestAnimationFrame(() => {this._redraw(false);});
}
this._requestNextFrame(() => {this._redraw(false);}, 0);
}
}
@ -308,7 +372,6 @@ class CanvasRenderer {
this.requiresTimeout = true;
}
}
}
export default CanvasRenderer;

+ 364
- 29
lib/network/modules/Clustering.js View File

@ -1,12 +1,108 @@
/* ===========================================================================
# TODO
- `edgeReplacedById` not cleaned up yet on cluster edge removal
- check correct working for everything for clustered clusters (could use a unit test)
- Handle recursive unclustering on node removal
- `updateState()` not complete; scan TODO's there
----------------------------------------------
# State Model for Clustering
The total state for clustering is non-trivial. It is useful to have a model
available as to how it works. The following documents the relevant state items.
## Network State
The following `network`-members are relevant to clustering:
- `body.nodes` - all nodes actively participating in the network
- `body.edges` - same for edges
- `body.nodeIndices` - id's of nodes that are visible at a given moment
- `body.edgeIndices` - same for edges
This includes:
- helper nodes for dragging in `manipulation`
- helper nodes for edge type `dynamic`
- cluster nodes and edges
- there may be more than this.
A node/edge may be missing in the `Indices` member if:
- it is a helper node
- the node or edge state has option `hidden` set
- It is not visible due to clustering
## Clustering State
For the hashes, the id's of the nodes/edges are used as key.
Member `network.clustering` contains the following items:
- `clusteredNodes` - hash with values: { clusterId: <id of cluster>, node: <node instance>}
- `clusteredEdges` - hash with values: restore information for given edge
Due to nesting of clusters, these members can contain cluster nodes and edges as well.
The important thing to note here, is that the clustered nodes and edges also
appear in the members of the cluster nodes. For data update, it is therefore
important to scan these lists as well as the cluster nodes.
### Cluster Node
A cluster node has the following extra fields:
- `isCluster : true` - indication that this is a cluster node
- `containedNodes` - hash of nodes contained in this cluster
- `containedEdges` - same for edges
- `edges` - hash of cluster edges for this node
**NOTE:**
- `containedEdges` can also contain edges which are not clustered; e.g. an edge
connecting two nodes in the same cluster.
### Cluster Edge
These are the items in the `edges` member of a clustered node. They have the
following relevant members:
- 'clusteringEdgeReplacingIds` - array of id's of edges replaced by this edge
Note that it's possible to nest clusters, so that `clusteringEdgeReplacingIds`
can contain edge id's of other clusters.
### Clustered Edge
This is any edge contained by a cluster edge. It gets the following additional
member:
- `edgeReplacedById` - id of the cluster edge in which current edge is clustered
=========================================================================== */
let util = require("../../util");
var NetworkUtil = require('../NetworkUtil').default;
var Cluster = require('./components/nodes/Cluster').default;
var Edge = require('./components/Edge').default; // Only needed for check on type!
var Node = require('./components/Node').default; // Only needed for check on type!
class ClusterEngine {
constructor(body) {
this.body = body;
this.clusteredNodes = {}; // Set of all nodes which are in a cluster
this.clusteredEdges = {}; // Set of all edges replaced by a clustering edge
this.clusteredNodes = {}; // key: node id, value: { clusterId: <id of cluster>, node: <node instance>}
this.clusteredEdges = {}; // key: edge id, value: restore information for given edge
this.options = {};
this.defaultOptions = {};
@ -466,29 +562,7 @@ class ClusterEngine {
// finally put the cluster node into global
this.body.nodes[clusterNodeProperties.id] = clusterNode;
// create the new edges that will connect to the cluster, all self-referencing edges will be added to childEdgesObject here.
this._createClusterEdges(childNodesObj, childEdgesObj, clusterNodeProperties, options.clusterEdgeProperties);
// disable the childEdges
for (let edgeId in childEdgesObj) {
if (childEdgesObj.hasOwnProperty(edgeId)) {
if (this.body.edges[edgeId] !== undefined) {
let edge = this.body.edges[edgeId];
// cache the options before changing
this._backupEdgeOptions(edge);
// disable physics and hide the edge
edge.setOptions({physics:false});
}
}
}
// disable the childNodes
for (let nodeId in childNodesObj) {
if (childNodesObj.hasOwnProperty(nodeId)) {
this.clusteredNodes[nodeId] = {clusterId:clusterNodeProperties.id, node: this.body.nodes[nodeId]};
this.body.nodes[nodeId].setOptions({physics:false});
}
}
this._clusterEdges(childNodesObj, childEdgesObj, clusterNodeProperties, options.clusterEdgeProperties);
// set ID to undefined so no duplicates arise
clusterNodeProperties.id = undefined;
@ -672,10 +746,7 @@ class ClusterEngine {
}
}
edge.cleanup();
// this removes the edge from node.edges, which is why edgeIds is formed
edge.disconnect();
delete this.body.edges[edge.id];
edge.remove();
}
// handle the releasing of the edges
@ -938,6 +1009,270 @@ class ClusterEngine {
}
/**
* Add the passed child nodes and edges to the given cluster node.
*
* @param childNodes {Object|Node} hash of nodes or single node to add in cluster
* @param childEdges {Object|Edge} hash of edges or single edge to take into account when clustering
* @param clusterNode {Node} cluster node to add nodes and edges to
* @private
*/
_clusterEdges(childNodes, childEdges, clusterNode, clusterEdgeProperties) {
if (childEdges instanceof Edge) {
let edge = childEdges;
let obj = {};
obj[edge.id] = edge;
childEdges = obj;
}
if (childNodes instanceof Node) {
let node = childNodes;
let obj = {};
obj[node.id] = node;
childNodes = obj;
}
if (clusterNode === undefined || clusterNode === null) {
throw new Error("_clusterEdges: parameter clusterNode required");
}
if (clusterEdgeProperties === undefined) {
// Take the required properties from the cluster node
clusterEdgeProperties = clusterNode.clusterEdgeProperties;
}
// create the new edges that will connect to the cluster.
// All self-referencing edges will be added to childEdges here.
this._createClusterEdges(childNodes, childEdges, clusterNode, clusterEdgeProperties);
// disable the childEdges
for (let edgeId in childEdges) {
if (childEdges.hasOwnProperty(edgeId)) {
if (this.body.edges[edgeId] !== undefined) {
let edge = this.body.edges[edgeId];
// cache the options before changing
this._backupEdgeOptions(edge);
// disable physics and hide the edge
edge.setOptions({physics:false});
}
}
}
// disable the childNodes
for (let nodeId in childNodes) {
if (childNodes.hasOwnProperty(nodeId)) {
this.clusteredNodes[nodeId] = {clusterId:clusterNode.id, node: this.body.nodes[nodeId]};
this.body.nodes[nodeId].setOptions({physics:false});
}
}
}
/**
* Determine in which cluster given nodeId resides.
*
* If not in cluster, return undefined.
*
* NOTE: If you know a cleaner way to do this, please enlighten me (wimrijnders).
*
* @return {Node|undefined} Node instance for cluster, if present
* @private
*/
_getClusterNodeForNode(nodeId) {
if (nodeId === undefined) return undefined;
let clusteredNode = this.clusteredNodes[nodeId];
// NOTE: If no cluster info found, it should actually be an error
if (clusteredNode === undefined) return undefined;
let clusterId = clusteredNode.clusterId;
if (clusterId === undefined) return undefined;
return this.body.nodes[clusterId];
}
/**
* Internal helper function for conditionally removing items in array
*
* Done like this because Array.filter() is not fully supported by all IE's.
* @private
*/
_filter(arr, callback) {
let ret = [];
for (var n in arr) {
if (callback(arr[n])) {
ret.push(arr[n]);
}
}
return ret;
}
/**
* Scan all edges for changes in clustering and adjust this if necessary.
*
* Call this (internally) after there has been a change in node or edge data.
*/
_updateState() {
// Pre: States of this.body.nodes and this.body.edges consistent
// Pre: this.clusteredNodes and this.clusteredEdge consistent with containedNodes and containedEdges
// of cluster nodes.
let nodeId;
let edgeId;
let m, n;
let deletedNodeIds = [];
let deletedEdgeIds = [];
let self = this;
let eachClusterNode = (callback) => {
for (nodeId in this.body.nodes) {
let node = this.body.nodes[nodeId];
if (node.isCluster !== true) continue;
callback(node);
}
};
//
// Remove deleted regular nodes from clustering
//
// Determine the deleted nodes
for (nodeId in this.clusteredNodes) {
let node = this.body.nodes[nodeId];
if (node === undefined) {
deletedNodeIds.push(nodeId);
}
}
// Remove nodes from cluster nodes
eachClusterNode(function(clusterNode) {
for (n in deletedNodeIds) {
delete clusterNode.containedNodes[deletedNodeIds[n]];
}
});
// Remove nodes from cluster list
for (n in deletedNodeIds) {
delete this.clusteredNodes[deletedNodeIds[n]];
}
//
// Remove deleted edges from clustering
//
// Add the deleted clustered edges to the list
for (edgeId in this.clusteredEdges) {
let edge = this.body.edges[edgeId];
if (edge === undefined || !edge.endPointsValid()) {
deletedEdgeIds.push(edgeId);
}
}
// Cluster nodes can also contain edges which are not clustered,
// i.e. nodes 1-2 within cluster with an edge in between.
// So the cluster nodes also need to be scanned for invalid edges
eachClusterNode(function(clusterNode) {
for (edgeId in clusterNode.containedEdges) {
let edge = clusterNode.containedEdges[edgeId];
if (!edge.endPointsValid() && deletedEdgeIds.indexOf(edgeId) === -1) {
deletedEdgeIds.push(edgeId);
}
}
});
// Also scan for cluster edges which need to be removed in the active list.
// Regular edges have been removed beforehand, so this only picks up the cluster edges.
for (edgeId in this.body.edges) {
let edge = this.body.edges[edgeId];
if (!edge.endPointsValid()) {
deletedEdgeIds.push(edgeId);
}
}
// Remove edges from cluster nodes
eachClusterNode(function(clusterNode) {
for (n in deletedEdgeIds) {
let deletedEdgeId = deletedEdgeIds[n];
delete clusterNode.containedEdges[deletedEdgeId];
for (m in clusterNode.edges) {
let edge = clusterNode.edges[m];
if (edge.id === deletedEdgeId) {
clusterNode.edges[m] = null; // Don't want to directly delete here, because in the loop
continue;
}
edge.clusteringEdgeReplacingIds = self._filter(edge.clusteringEdgeReplacingIds, function(id) {
return deletedEdgeIds.indexOf(id) === -1;
});
}
// Clean up the nulls
clusterNode.edges = self._filter(clusterNode.edges, function(item) {return item !== null});
}
});
// Remove from cluster list
for (n in deletedEdgeIds) {
delete this.clusteredEdges[deletedEdgeIds[n]];
}
// Remove cluster edges from active list (this.body.edges).
// deletedEdgeIds still contains id of regular edges, but these should all
// be gone upon entering this method
for (n in deletedEdgeIds) {
delete this.body.edges[deletedEdgeIds[n]];
}
//
// Check changed cluster state of edges
//
// Iterating over keys here, because edges may be removed in the loop
let ids = Object.keys(this.body.edges);
for (n in ids) {
let edgeId = ids[n];
let edge = this.body.edges[edgeId];
let shouldBeClustered = this._isClusteredNode(edge.fromId) || this._isClusteredNode(edge.toId);
if (shouldBeClustered === this._isClusteredEdge(edge.id)) {
continue; // all is well
}
if (shouldBeClustered) {
// add edge to clustering
let clusterFrom = this._getClusterNodeForNode(edge.fromId);
if (clusterFrom !== undefined) {
this._clusterEdges(this.body.nodes[edge.fromId], edge, clusterFrom);
}
let clusterTo = this._getClusterNodeForNode(edge.toId);
if (clusterTo !== undefined) {
this._clusterEdges(this.body.nodes[edge.toId], edge, clusterTo);
}
// TODO: check that it works for both edges clustered
} else {
// undo clustering for this edge
throw new Error('remove edge from clustering not implemented!');
}
}
// TODO: Cluster nodes may now be empty or because of selected options may not be allowed to contain 1 node
// Remove these cluster nodes if necessary.
}
/**
* Determine if node with given id is part of a cluster.
*

+ 36
- 6
lib/network/modules/EdgesHandler.js View File

@ -327,27 +327,29 @@ class EdgesHandler {
}
/**
* Remove existing edges. Non existing ids will be ignored
* @param {Number[] | String[]} ids
* @private
*/
remove(ids) {
remove(ids, emit = true) {
if (ids.length === 0) return; // early out
var edges = this.body.edges;
for (var i = 0; i < ids.length; i++) {
var id = ids[i];
var edge = edges[id];
if (edge !== undefined) {
edge.cleanup();
edge.disconnect();
delete edges[id];
edge.remove();
}
}
this.body.emitter.emit("_dataChanged");
if (emit) {
this.body.emitter.emit("_dataChanged");
}
}
refresh() {
let edges = this.body.edges;
for (let edgeId in edges) {
@ -402,6 +404,34 @@ class EdgesHandler {
return nodeList;
}
/**
* Scan for missing nodes and remove corresponding edges, if any.
*
* There is no direct relation between the nodes and the edges DataSet,
* so the right place to do call this is in the handler for event `_dataUpdated`.
*/
_updateState() {
let edgesToDelete = [];
for(let id in this.body.edges) {
let edge = this.body.edges[id];
let toNode = this.body.nodes[edge.toId];
let fromNode = this.body.nodes[edge.fromId];
// Skip clustering edges here, let the Clustering module handle those
if ((toNode !== undefined && toNode.isCluster === true)
|| (fromNode !== undefined && fromNode.isCluster === true)) {
continue;
}
if (toNode === undefined || fromNode === undefined) {
edgesToDelete.push(id);
}
}
this.remove(edgesToDelete, false);
}
}
export default EdgesHandler;

+ 19
- 0
lib/network/modules/components/Edge.js View File

@ -648,6 +648,25 @@ class Edge {
cleanup() {
return this.edgeType.cleanup();
}
/**
* Remove edge from the list and perform necessary cleanup.
*/
remove() {
this.cleanup();
this.disconnect();
delete this.body.edges[this.id];
}
/**
* Check if both connecting nodes exist
*/
endPointsValid() {
return this.body.nodes[this.fromId] !== undefined
&& this.body.nodes[this.toId] !== undefined;
}
}
export default Edge;

+ 2
- 2
test/Graph3d.test.js View File

@ -13,10 +13,10 @@ describe('Graph3d', function () {
before(function() {
//console.log('before!');
this.jsdom_global = jsdom_global(
"<div id='mynetwork'></div>",
"<div id='mygraph'></div>",
{ skipWindowCheck: true}
);
this.container = document.getElementById('mynetwork');
this.container = document.getElementById('mygraph');
});

+ 332
- 0
test/Network.test.js View File

@ -0,0 +1,332 @@
var fs = require('fs');
var assert = require('assert');
var vis = require('../dist/vis');
var Network = vis.network;
var jsdom_global = require('jsdom-global');
var stdout = require('test-console').stdout;
var Validator = require("./../lib/shared/Validator").default;
//var {printStyle} = require('./../lib/shared/Validator');
// Useful during debugging:
// console.log(JSON.stringify(output, null, 2));
/**
* Load legacy-style (i.e. not module) javascript files into the given context.
*/
function include(list, context) {
if (!(list instanceof Array)) {
list = [list];
}
for (var n in list) {
var path = list[n];
var arr = [fs.readFileSync(path) + ''];
eval.apply(context, arr);
}
}
/**
* Defined network consists of two sub-networks:
*
* - 1-2-3-4
* - 11-12-13-14
*
* For reference, this is the sample network of issue #1218
*/
function createSampleNetwork() {
var NumInitialNodes = 8;
var NumInitialEdges = 6;
var nodes = new vis.DataSet([
{id: 1, label: '1'},
{id: 2, label: '2'},
{id: 3, label: '3'},
{id: 4, label: '4'},
{id: 11, label: '11'},
{id: 12, label: '12'},
{id: 13, label: '13'},
{id: 14, label: '14'},
]);
var edges = new vis.DataSet([
{from: 1, to: 2},
{from: 2, to: 3},
{from: 3, to: 4},
{from: 11, to: 12},
{from: 12, to: 13},
{from: 13, to: 14},
]);
// create a network
var container = document.getElementById('mynetwork');
var data = {
nodes: nodes,
edges: edges
};
var options = {
layout: {
randomSeed: 8
},
edges: {
smooth: {
type: 'continuous' // avoid dynamic here, it adds extra hidden nodes
}
}
};
var network = new vis.Network(container, data, options);
assertNumNodes(network, NumInitialNodes);
assertNumEdges(network, NumInitialEdges);
return [network, data, NumInitialNodes, NumInitialEdges];
};
/**
* Create a cluster for the dynamic data change cases.
*
* Works on the network created by createSampleNetwork().
*
* This is actually a pathological case; there are two separate sub-networks and
* a cluster is made of two nodes, each from one of the sub-networks.
*/
function createCluster(network) {
//console.log("clustering 1 and 11")
var clusterOptionsByData = {
joinCondition: function(node) {
if (node.id == 1 || node.id == 11) return true;
return false;
},
clusterNodeProperties: {id:"c1", label:'c1'}
}
network.cluster(clusterOptionsByData);
}
/**
* Display node/edge state, useful during debugging
*/
function log(network) {
console.log(Object.keys(network.body.nodes));
console.log(network.body.nodeIndices);
console.log(Object.keys(network.body.edges));
console.log(network.body.edgeIndices);
};
/**
* Note that only the node and edges counts are asserted.
* This might be done more thoroughly by explicitly checking the id's
*/
function assertNumNodes(network, expectedPresent, expectedVisible) {
if (expectedVisible === undefined) expectedVisible = expectedPresent;
assert.equal(Object.keys(network.body.nodes).length, expectedPresent);
assert.equal(network.body.nodeIndices.length, expectedVisible);
};
/**
* Comment at assertNumNodes() also applies.
*/
function assertNumEdges(network, expectedPresent, expectedVisible) {
if (expectedVisible === undefined) expectedVisible = expectedPresent;
assert.equal(Object.keys(network.body.edges).length, expectedPresent);
assert.equal(network.body.edgeIndices.length, expectedVisible);
};
describe('Network', function () {
before(function() {
this.jsdom_global = jsdom_global(
"<div id='mynetwork'></div>",
{ skipWindowCheck: true}
);
this.container = document.getElementById('mynetwork');
});
after(function() {
this.jsdom_global();
});
/**
* Check on fix for #1218
*/
it('connects a new edge to a clustering node instead of the clustered node', function () {
var [network, data, numNodes, numEdges] = createSampleNetwork();
createCluster(network);
numNodes += 1; // A clustering node is now hiding two nodes
assertNumNodes(network, numNodes, numNodes - 2);
numEdges += 2; // Two clustering edges now hide two edges
assertNumEdges(network, numEdges, numEdges - 2);
//console.log("Creating node 21")
data.nodes.update([{id: 21, label: '21'}]);
numNodes += 1; // New unconnected node added
assertNumNodes(network, numNodes, numNodes - 2);
assertNumEdges(network, numEdges, numEdges - 2); // edges unchanged
//console.log("Creating edge 21 pointing to 1");
// '1' is part of the cluster so should
// connect to cluster instead
data.edges.update([{from: 21, to: 1}]);
assertNumNodes(network, numNodes, numNodes - 2); // nodes unchanged
numEdges += 2; // A new clustering edge is hiding a new edge
assertNumEdges(network, numEdges, numEdges - 3);
});
/**
* Check on fix for #1315
*/
it('can uncluster a clustered node when a node is removed that has an edge to that cluster', function () {
// NOTE: this block is same as previous test
var [network, data, numNodes, numEdges] = createSampleNetwork();
createCluster(network);
numNodes += 1; // A clustering node is now hiding two nodes
assertNumNodes(network, numNodes, numNodes - 2);
numEdges += 2; // Two clustering edges now hide two edges
assertNumEdges(network, numEdges, numEdges - 2);
// End block same as previous test
//console.log("removing 12");
data.nodes.remove(12);
// NOTE:
// At this particular point, there are still the two edges for node 12 in the edges DataSet.
// If you want to do the delete correctly, these should also be deleted explictly from
// the edges DataSet. In the Network instance, however, this.body.nodes and this.body.edges
// should be correct, with the edges of 12 all cleared out.
// 12 was connected to 11, which is clustered
numNodes -= 1; // 12 removed, one less node
assertNumNodes(network, numNodes, numNodes - 2);
numEdges -= 3; // clustering edge c1-12 and 2 edges of 12 gone
assertNumEdges(network, numEdges, numEdges - 1);
//console.log("Unclustering c1");
network.openCluster("c1");
numNodes -= 1; // cluster node removed, one less node
assertNumNodes(network, numNodes, numNodes); // all are visible again
numEdges -= 1; // clustering edge gone, regular edge visible
assertNumEdges(network, numEdges, numEdges); // all are visible again
});
/**
* Check on fix for #1291
*/
it('can remove a node inside a cluster and then open that cluster', function () {
var [network, data, numNodes, numEdges] = createSampleNetwork();
var clusterOptionsByData = {
joinCondition: function(node) {
if (node.id == 1 || node.id == 2 || node.id == 3) return true;
return false;
},
clusterNodeProperties: {id:"c1", label:'c1'}
}
network.cluster(clusterOptionsByData);
numNodes += 1; // new cluster node
assertNumNodes(network, numNodes, numNodes - 3); // 3 clustered nodes
numEdges += 1; // 1 cluster edge expected
assertNumEdges(network, numEdges, numEdges - 3); // 3 edges hidden
//console.log("removing node 2, which is inside the cluster");
data.nodes.remove(2);
numNodes -= 1; // clustered node removed
assertNumNodes(network, numNodes, numNodes - 2); // view doesn't change
numEdges -= 2; // edges removed hidden in cluster
assertNumEdges(network, numEdges, numEdges - 1); // view doesn't change
//console.log("Unclustering c1");
network.openCluster("c1")
numNodes -= 1; // cluster node gone
assertNumNodes(network, numNodes, numNodes); // all visible
numEdges -= 1; // cluster edge gone
assertNumEdges(network, numEdges, numEdges); // all visible
//log(network);
});
describe('on node.js', function () {
it('should be running', function () {
assert(this.container !== null, 'Container div not found');
// The following should now just plain succeed
var [network, data] = createSampleNetwork();
assert.equal(Object.keys(network.body.nodes).length, 8);
assert.equal(Object.keys(network.body.edges).length, 6);
});
describe('runs example ', function () {
function loadExample(path, noPhysics) {
include(path, this);
var container = document.getElementById('mynetwork');
// create a network
var data = {
nodes: new vis.DataSet(nodes),
edges: new vis.DataSet(edges)
};
if (noPhysics) {
// Avoid excessive processor time due to load.
// We're just interested that the load itself is good
options.physics = false;
}
var network = new vis.Network(container, data, options);
return network;
};
it('basicUsage', function () {
var network = loadExample('./test/network/basicUsage.js');
//console.log(Object.keys(network.body.edges));
// Count in following also contains the helper nodes for dynamic edges
assert.equal(Object.keys(network.body.nodes).length, 10);
assert.equal(Object.keys(network.body.edges).length, 5);
});
it('WorlCup2014', function () {
// This is a huge example (which is why it's tested here!), so it takes a long time to load.
this.timeout(10000);
var network = loadExample('./examples/network/datasources/WorldCup2014.js', true);
// Count in following also contains the helper nodes for dynamic edges
assert.equal(Object.keys(network.body.nodes).length, 9964);
assert.equal(Object.keys(network.body.edges).length, 9228);
});
// This actually failed to load, added for this reason
it('disassemblerExample', function () {
var network = loadExample('./examples/network/exampleApplications/disassemblerExample.js');
// console.log(Object.keys(network.body.nodes));
// console.log(Object.keys(network.body.edges));
// Count in following also contains the helper nodes for dynamic edges
assert.equal(Object.keys(network.body.nodes).length, 9);
assert.equal(Object.keys(network.body.edges).length, 14 - 3); // NB 3 edges in data not displayed
});
}); // runs example
}); // on node.js
}); // Network

+ 21
- 0
test/network/basicUsage.js View File

@ -0,0 +1,21 @@
// Network from `basicUsage` example
// create an array with nodes
var nodes = [
{id: 1, label: 'Node 1'},
{id: 2, label: 'Node 2'},
{id: 3, label: 'Node 3'},
{id: 4, label: 'Node 4'},
{id: 5, label: 'Node 5'}
];
// create an array with edges
var edges = [
{from: 1, to: 3},
{from: 1, to: 2},
{from: 2, to: 4},
{from: 2, to: 5},
{from: 3, to: 3}
];
var options = {};

Loading…
Cancel
Save