diff --git a/examples/network/exampleApplications/disassemblerExample.html b/examples/network/exampleApplications/disassemblerExample.html index 8ecd8e39..a7ab0eb1 100644 --- a/examples/network/exampleApplications/disassemblerExample.html +++ b/examples/network/exampleApplications/disassemblerExample.html @@ -9,59 +9,16 @@ +

Use VisJS to diagram the Control-Flow-Graph (CFG) of a function from a program you wish to analyze.


diff --git a/examples/network/exampleApplications/disassemblerExample.js b/examples/network/exampleApplications/disassemblerExample.js new file mode 100644 index 00000000..c22fb289 --- /dev/null +++ b/examples/network/exampleApplications/disassemblerExample.js @@ -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<>\n", 'color': "#FFCFCF", 'shape': 'box', 'font': {'face': 'monospace', 'align': 'left'}}, + {'id': 'cfg_0x00405a49', 'size': 150, 'label': "0x00405a49:\ntest bl, 0x01\nje 0x00405a62<>\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<>\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<>\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<>\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<>\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<>\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'}}, +]; diff --git a/lib/module/hammer.js b/lib/module/hammer.js index 76c272a6..3ca066dc 100644 --- a/lib/module/hammer.js +++ b/lib/module/hammer.js @@ -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(); } } diff --git a/lib/network/Network.js b/lib/network/Network.js index 54a0242c..85f68d9c 100644 --- a/lib/network/Network.js +++ b/lib/network/Network.js @@ -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) diff --git a/lib/network/modules/CanvasRenderer.js b/lib/network/modules/CanvasRenderer.js index 27ad7194..16d39a0b 100644 --- a/lib/network/modules/CanvasRenderer.js +++ b/lib/network/modules/CanvasRenderer.js @@ -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; diff --git a/lib/network/modules/Clustering.js b/lib/network/modules/Clustering.js index 49f5a632..02486ca9 100644 --- a/lib/network/modules/Clustering.js +++ b/lib/network/modules/Clustering.js @@ -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: , node: } +- `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: , node: } + 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. * diff --git a/lib/network/modules/EdgesHandler.js b/lib/network/modules/EdgesHandler.js index 8aaa665e..540d3c13 100644 --- a/lib/network/modules/EdgesHandler.js +++ b/lib/network/modules/EdgesHandler.js @@ -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; diff --git a/lib/network/modules/components/Edge.js b/lib/network/modules/components/Edge.js index 99b5ad42..45bee61c 100644 --- a/lib/network/modules/components/Edge.js +++ b/lib/network/modules/components/Edge.js @@ -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; diff --git a/test/Graph3d.test.js b/test/Graph3d.test.js index c6fa5bf0..5c5e76ea 100644 --- a/test/Graph3d.test.js +++ b/test/Graph3d.test.js @@ -13,10 +13,10 @@ describe('Graph3d', function () { before(function() { //console.log('before!'); this.jsdom_global = jsdom_global( - "
", + "
", { skipWindowCheck: true} ); - this.container = document.getElementById('mynetwork'); + this.container = document.getElementById('mygraph'); }); diff --git a/test/Network.test.js b/test/Network.test.js new file mode 100644 index 00000000..fc476b63 --- /dev/null +++ b/test/Network.test.js @@ -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( + "
", + { 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 diff --git a/test/network/basicUsage.js b/test/network/basicUsage.js new file mode 100644 index 00000000..85808210 --- /dev/null +++ b/test/network/basicUsage.js @@ -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 = {};