diff --git a/examples/graph/02_random_nodes.html b/examples/graph/02_random_nodes.html index 3d4b5a96..b4e5ee9d 100755 --- a/examples/graph/02_random_nodes.html +++ b/examples/graph/02_random_nodes.html @@ -74,6 +74,7 @@ nodes: nodes, edges: edges }; + /* var options = { nodes: { shape: 'circle' @@ -83,6 +84,13 @@ }, stabilize: false }; + */ + var options = { + edges: { + length: 50 + }, + stabilize: false + }; graph = new vis.Graph(container, data, options); // add event listeners diff --git a/src/graph/Graph.js b/src/graph/Graph.js index 4e508177..e6d3e3ab 100644 --- a/src/graph/Graph.js +++ b/src/graph/Graph.js @@ -39,6 +39,14 @@ function Graph (container, data, options) { highlight: { border: '#2B7CE9', background: '#D2E5FF' + }, + cluster: { + border: '#000000', + background: '#F0F0F0', + highlight: { + border: '#FF0FFF', + background: '#FF000F' + } } }, borderColor: '#2B7CE9', @@ -63,6 +71,13 @@ function Graph (container, data, options) { altLength: undefined } }, + clustering: { + clusterLength: 30, // threshold length for clustering + zoomOffset: 0.1, + widthGrowth: 15, // growth factor = ((parent_size + child_size) / parent_size) * widthGrowthFactor + heightGrowth: 15, // growth factor = ((parent_size + child_size) / parent_size) * heightGrowthFactor + massTransferCoefficient: 0.1 // parent.mass += massTransferCoefficient * child.mass + }, minForce: 0.05, minVelocity: 0.02, // px/s maxIterations: 1000 // maximum number of iteration to stabilize @@ -72,6 +87,8 @@ function Graph (container, data, options) { this.node_indices = []; // the node indices list is used to speed up the computation of the repulsion fields this.nodes = {}; // object with Node objects this.edges = {}; // object with Edge objects + this.scale = 1; // defining the global scale variable in the constructor + this.previous_scale = 1; // use this to check if the zoom operation is in or out // TODO: create a counter to keep track on the number of nodes having values // TODO: create a counter to keep track on the number of nodes currently moving // TODO: create a counter to keep track on the number of edges having values @@ -130,7 +147,184 @@ function Graph (container, data, options) { // draw data this.setData(data); -} +}; + +/** + * This function checks if the zoom action is in or out. + * If out, check if we can form clusters, if in, check if we can open clusters. + * This function is only called from _zoom() + * + * @private + */ +Graph.prototype._updateClusters = function() { + if (this.previous_scale > this.scale) { // zoom out + this._formClusters(); + } + else if (this.previous_scale < this.scale) { // zoom out + this._openClusters(); + } + this._updateNodeIndexList(); + + for (var nid in this.nodes) { + var node = this.nodes[nid]; + node.label = String(node.remaining_edges); + } + + this.previous_scale = this.scale; +}; + +/** + * This function loops over all nodes in the node_indices list. For each node it checks if it is a cluster and if it + * has to be opened based on the current zoom level. + * + * @private + */ +Graph.prototype._openClusters = function() { + for (var i = 0; i < this.node_indices.length; i++) { + var node = this.nodes[this.node_indices[i]]; + this._expandClusterNode(node,true); + } +}; + +/** + * This function checks if a node has to be opened. This is done by checking the zoom level. + * If the node contains child nodes, this function is recursively called on the child nodes as well. + * This recursive behaviour is optional and can be set by the recursive argument. + * + * @param node | Node object: to check for cluster and expand + * @param recursive | Boolean: enable or disable recursive calling + * @private + */ +Graph.prototype._expandClusterNode = function(node, recursive) { + if (node.formation_scale != undefined) { + if (this.scale > node.formation_scale) { + for (var contained_node_id in node.contained_nodes) { + if (node.contained_nodes.hasOwnProperty(contained_node_id)) { + // put the child node back in the global nodes object + this.nodes[contained_node_id] = node.contained_nodes[contained_node_id]; + + var child_node = this.nodes[contained_node_id]; + + // remove mass from child node from parent node + node.mass -= this.constants.clustering.massTransferCoefficient * this.nodes[contained_node_id].mass; + + // decrease the size again + node.cluster_size -= child_node.cluster_size; + var grow_coefficient = this.constants.clustering.zoomOffset + (node.cluster_size + child_node.cluster_size) / node.cluster_size; + node.width -= grow_coefficient * this.constants.clustering.widthGrowth; + node.height -= grow_coefficient * this.constants.clustering.heightGrowth; + node.fontSize -= 1 * child_node.cluster_size; + + // check if a further expansion step is possible if recursivity is enabled + if (recursive == true) { + this._expandClusterNode(child_node,true); + } + } + } + + // put the edges back in the global this.edges + for (var contained_edge_id in node.contained_edges) { + if (node.contained_edges.hasOwnProperty(contained_edge_id)) { + this.edges[contained_edge_id] = node.contained_edges[contained_edge_id]; + node.remaining_edges += 1; + } + } + + // reset the cluster settings of this node + node.resetCluster(); + } + } +}; + +/** + * This function checks if any nodes at the end of their trees have edges below a threshold length + * This function is called only from _updateClusters() + * + * @private + */ +Graph.prototype._formClusters = function() { + var min_length = this.constants.clustering.clusterLength/this.scale; + + var dx,dy,length, + edges = this.edges; + + // create an array of edge ids + var edges_id_array = [] + for (var id in edges) { + if (edges.hasOwnProperty(id)) { + edges_id_array.push(id); + } + } + + // check if any edges are shorter than min_length and start the clustering + // the clustering favours the node with the larger mass + for (var i = 0; i < edges_id_array.length; i++) { + var edge = edges[edges_id_array[i]]; + if (edge.connected) { + dx = (edge.to.x - edge.from.x); + dy = (edge.to.y - edge.from.y); + length = Math.sqrt(dx * dx + dy * dy); + + + if (length < min_length) { + // checking for clustering possiblities + + // first check which node is larger + if (edge.to.mass > edge.from.mass) { + var parent_node = edge.to + var child_node = edge.from + } + else { + var parent_node = edge.from + var child_node = edge.to + } + + // we allow clustering from outside in, ideally the child node in on the outside + // if we do not cluster from outside in, we would have to reconnect edges or keep a second set of edges for the + // clusters. This will also have to be altered in the force calculation and rendering. + // This method is non-destructive and does not require a second set of data. + if (child_node.remaining_edges == 1) { + this._addToCluster(parent_node,child_node,edge); + delete this.edges[edges_id_array[i]]; + } + else if (parent_node.remaining_edges == 1) { + this._addToCluster(child_node,parent_node,edge); + delete this.edges[edges_id_array[i]]; + } + } + } + } +}; + +/** + * This function adds the childnode to the parentnode, creating a cluster if it is not already. + * This function is called only from _updateClusters() + * + * @param parent_node | Node object: this is the node that will house the child node + * @param child_node | Node object: this node will be deleted from the global this.nodes and stored in the parent node + * @param edge | Edge object: this edge will be deleted from the global this.edges and stored in the parent node + * @private + */ +Graph.prototype._addToCluster = function(parent_node, child_node, edge) { + // join child node and edge in parent node + parent_node.contained_nodes[child_node.id] = child_node; + parent_node.contained_edges[child_node.id] = edge; + + if (this.nodes.hasOwnProperty(child_node.id)) { + delete this.nodes[child_node.id]; + } + + + var grow_coefficient = this.constants.clustering.zoomOffset + (parent_node.cluster_size + child_node.cluster_size) / parent_node.cluster_size; + parent_node.mass += this.constants.clustering.massTransferCoefficient * child_node.mass; + parent_node.width += grow_coefficient * this.constants.clustering.widthGrowth; + parent_node.height += grow_coefficient * this.constants.clustering.heightGrowth; + parent_node.remaining_edges -= 1; + parent_node.formation_scale = this.scale; + parent_node.cluster_size += child_node.cluster_size; + parent_node.fontSize += 1 * child_node.cluster_size; +}; + /** * Update the this.node_indices with the most recent node index list @@ -144,7 +338,7 @@ Graph.prototype._updateNodeIndexList = function() { this.node_indices.push(idx); } } -} +}; /** * Set nodes and edges, and optionally options as well. @@ -580,6 +774,7 @@ Graph.prototype._zoom = function(scale, pointer) { this._setScale(scale); this._setTranslation(tx, ty); + this._updateClusters(); this._redraw(); return scale; @@ -1578,19 +1773,25 @@ Graph.prototype._calculateForces = function() { dy = node2.y - node1.y; distance = Math.sqrt(dx * dx + dy * dy); - //if (distance < 10*minimumDistance) { + // + if (distance < 2*minimumDistance) { // at 2.0 * the minimum distance, the force is 0.000045 angle = Math.atan2(dy, dx); - // TODO: correct factor for repulsing force - //repulsingForce = 2 * Math.exp(-5 * (distance * distance) / (dmin * dmin) ); // TODO: customize the repulsing force - //repulsingForce = Math.exp(-1 * (distance * distance) / (dmin * dmin) ); // TODO: customize the repulsing force - repulsingForce = 1 / (1 + Math.exp((distance / minimumDistance - 1) * steepness)); // TODO: customize the repulsing force + if (distance < 0.5*minimumDistance) { // at 0.5 * the minimum distance, the force is 0.993307 + repulsingForce = 1.0; + } + else { + // TODO: correct factor for repulsing force + //repulsingForce = 2 * Math.exp(-5 * (distance * distance) / (dmin * dmin) ); // TODO: customize the repulsing force + //repulsingForce = Math.exp(-1 * (distance * distance) / (dmin * dmin) ); // TODO: customize the repulsing force + repulsingForce = 1 / (1 + Math.exp((distance / minimumDistance - 1) * steepness)); // TODO: customize the repulsing force + } fx = Math.cos(angle) * repulsingForce; fy = Math.sin(angle) * repulsingForce; node1._addForce(-fx, -fy); node2._addForce(fx, fy); - // } + } } } @@ -1645,7 +1846,8 @@ Graph.prototype._calculateForces = function() { } } - /* TODO: re-implement repulsion of edges + // TODO: re-implement repulsion of edges + // repulsing forces between edges var minimumDistance = this.constants.edges.distance, steepness = 10; // higher value gives steeper slope of the force around the given minimumDistance @@ -1680,7 +1882,7 @@ Graph.prototype._calculateForces = function() { edges[l2].to._addForce(fx, fy); } } - */ + }; @@ -1721,16 +1923,9 @@ Graph.prototype._discreteStepNodes = function() { */ Graph.prototype.start = function() { if (this.moving) { - var start = window.performance.now(); - this._calculateForces(); this._discreteStepNodes(); - var end = window.performance.now(); - var time = end - start; - console.log('Execution time: ' + time); - - var vmin = this.constants.minVelocity; this.moving = this._isMoving(vmin); } @@ -1741,8 +1936,23 @@ Graph.prototype.start = function() { var graph = this; this.timer = window.setTimeout(function () { graph.timer = undefined; + + // benchmark the calculation + var start = window.performance.now(); graph.start(); + // Optionally call this twice for faster convergence + // graph.start(); + var end = window.performance.now(); + var time = end - start; + // console.log('Simulation time: ' + time); + + + start = window.performance.now(); graph._redraw(); + end = window.performance.now(); + time = end - start; + // console.log('Drawing time: ' + time); + }, this.refreshRate); } } diff --git a/src/graph/Node.js b/src/graph/Node.js index 934c1399..e7ce4206 100644 --- a/src/graph/Node.js +++ b/src/graph/Node.js @@ -25,6 +25,7 @@ function Node(properties, imagelist, grouplist, constants) { this.selected = false; + this.edges = []; // all edges connected to this node this.group = constants.nodes.group; @@ -50,6 +51,10 @@ function Node(properties, imagelist, grouplist, constants) { this.imagelist = imagelist; this.grouplist = grouplist; + // creating the variables for clustering + this.resetCluster(); + this.remaining_edges = 0; + this.setProperties(properties, constants); // mass, force, velocity @@ -62,6 +67,17 @@ function Node(properties, imagelist, grouplist, constants) { this.damping = 0.9; // damping factor }; +/** + * (re)setting the clustering variables and objects + */ +Node.prototype.resetCluster = function() { + // clustering variables + this.formation_scale = undefined; // this is used to determine when to open the cluster + this.cluster_size = 1; // this signifies the total amount of nodes in this cluster + this.contained_nodes = {}; + this.contained_edges = {}; +}; + /** * Attach a edge to the node * @param {Edge} edge @@ -70,6 +86,8 @@ Node.prototype.attachEdge = function(edge) { if (this.edges.indexOf(edge) == -1) { this.edges.push(edge); } + this.remaining_edges = this.edges.length; + this.remaining_edges_tmp = this.edges.length; this._updateMass(); }; @@ -82,6 +100,8 @@ Node.prototype.detachEdge = function(edge) { if (index != -1) { this.edges.splice(index, 1); } + this.remaining_edges = this.edges.length; + this.remaining_edges_tmp = this.edges.length; this._updateMass(); }; @@ -192,6 +212,14 @@ Node.parseColor = function(color) { highlight: { border: color, background: color + }, + cluster: { + border: color, + background: color, + highlight: { + border: color, + background: color + } } }; // TODO: automatically generate a nice highlight color @@ -200,6 +228,7 @@ Node.parseColor = function(color) { c = {}; c.background = color.background || 'white'; c.border = color.border || c.background; + if (util.isString(color.highlight)) { c.highlight = { border: color.highlight, @@ -211,6 +240,32 @@ Node.parseColor = function(color) { c.highlight.background = color.highlight && color.highlight.background || c.background; c.highlight.border = color.highlight && color.highlight.border || c.border; } + + // check if cluster colorgroup has been defined + if (util.isString(color.cluster)) { + c.cluster = { + border: color.cluster, + background: color.cluster + } + } + else { + c.cluster = {}; + c.cluster.background = color.cluster && color.cluster.background || c.background; + c.cluster.border = color.cluster && color.cluster.border || c.border; + } + + // check if cluster highlight colorgroup has been defined + if (util.isString(color.cluster.highlight)) { + c.cluster.highlight = { + border: color.cluster.highlight, + background: color.cluster.highlight + } + } + else { + c.cluster.highlight = {}; + c.cluster.highlight.background = color.cluster.highlight && color.cluster.highlight.background || c.background; + c.cluster.highlight.border = color.cluster.highlight && color.cluster.highlight.border || c.border; + } } return c; }; @@ -220,7 +275,8 @@ Node.parseColor = function(color) { */ Node.prototype.select = function() { this.selected = true; - this._reset(); + // why do this? + // this._reset(); }; /** @@ -228,7 +284,8 @@ Node.prototype.select = function() { */ Node.prototype.unselect = function() { this.selected = false; - this._reset(); + // why do this? + // this._reset(); }; /** @@ -490,9 +547,15 @@ Node.prototype._drawBox = function (ctx) { this.left = this.x - this.width / 2; this.top = this.y - this.height / 2; - ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; - ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; - ctx.lineWidth = this.selected ? 2.0 : 1.0; + if (this.cluster_size > 1) { + ctx.strokeStyle = this.selected ? this.color.cluster.highlight.border : this.color.cluster.border; + ctx.fillStyle = this.selected ? this.color.cluster.highlight.background : this.color.cluster.background; + } + else { + ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; + ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; + } + ctx.lineWidth = (this.selected ? 2.0 : 1.0) + (this.cluster_size > 1) ? 2.0 : 0.0; ctx.roundRect(this.left, this.top, this.width, this.height, this.radius); ctx.fill(); ctx.stroke(); @@ -516,9 +579,15 @@ Node.prototype._drawDatabase = function (ctx) { this.left = this.x - this.width / 2; this.top = this.y - this.height / 2; - ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; - ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; - ctx.lineWidth = this.selected ? 2.0 : 1.0; + if (this.cluster_size > 1) { + ctx.strokeStyle = this.selected ? this.color.cluster.highlight.border : this.color.cluster.border; + ctx.fillStyle = this.selected ? this.color.cluster.highlight.background : this.color.cluster.background; + } + else { + ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; + ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; + } + ctx.lineWidth = (this.selected ? 2.0 : 1.0) + (this.cluster_size > 1) ? 2.0 : 0.0; ctx.database(this.x - this.width/2, this.y - this.height*0.5, this.width, this.height); ctx.fill(); ctx.stroke(); @@ -544,9 +613,15 @@ Node.prototype._drawCircle = function (ctx) { this.left = this.x - this.width / 2; this.top = this.y - this.height / 2; - ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; - ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; - ctx.lineWidth = this.selected ? 2.0 : 1.0; + if (this.cluster_size > 1) { + ctx.strokeStyle = this.selected ? this.color.cluster.highlight.border : this.color.cluster.border; + ctx.fillStyle = this.selected ? this.color.cluster.highlight.background : this.color.cluster.background; + } + else { + ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; + ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; + } + ctx.lineWidth = (this.selected ? 2.0 : 1.0) + (this.cluster_size > 1) ? 2.0 : 0.0; ctx.circle(this.x, this.y, this.radius); ctx.fill(); ctx.stroke(); @@ -571,9 +646,15 @@ Node.prototype._drawEllipse = function (ctx) { this.left = this.x - this.width / 2; this.top = this.y - this.height / 2; - ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; - ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; - ctx.lineWidth = this.selected ? 2.0 : 1.0; + if (this.cluster_size > 1) { + ctx.strokeStyle = this.selected ? this.color.cluster.highlight.border : this.color.cluster.border; + ctx.fillStyle = this.selected ? this.color.cluster.highlight.background : this.color.cluster.background; + } + else { + ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; + ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; + } + ctx.lineWidth = (this.selected ? 2.0 : 1.0) + (this.cluster_size > 1) ? 2.0 : 0.0; ctx.ellipse(this.left, this.top, this.width, this.height); ctx.fill(); ctx.stroke(); @@ -615,9 +696,15 @@ Node.prototype._drawShape = function (ctx, shape) { this.left = this.x - this.width / 2; this.top = this.y - this.height / 2; - ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; - ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; - ctx.lineWidth = this.selected ? 2.0 : 1.0; + if (this.cluster_size > 1) { + ctx.strokeStyle = this.selected ? this.color.cluster.highlight.border : this.color.cluster.border; + ctx.fillStyle = this.selected ? this.color.cluster.highlight.background : this.color.cluster.background; + } + else { + ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; + ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; + } + ctx.lineWidth = (this.selected ? 2.0 : 1.0) + (this.cluster_size > 1) ? 2.0 : 0.0; ctx[shape](this.x, this.y, this.radius); ctx.fill(); diff --git a/vis.js b/vis.js index 3aa33749..ff11893e 100644 --- a/vis.js +++ b/vis.js @@ -4,8 +4,8 @@ * * A dynamic, browser-based visualization library. * - * @version @@version - * @date @@date + * @version 0.3.0-SNAPSHOT + * @date 2014-01-08 * * @license * Copyright (C) 2011-2013 Almende B.V, http://almende.com @@ -12472,6 +12472,7 @@ if (typeof CanvasRenderingContext2D !== 'undefined') { function Node(properties, imagelist, grouplist, constants) { this.selected = false; + this.edges = []; // all edges connected to this node this.group = constants.nodes.group; @@ -12497,6 +12498,10 @@ function Node(properties, imagelist, grouplist, constants) { this.imagelist = imagelist; this.grouplist = grouplist; + // creating the variables for clustering + this.resetCluster(); + this.remaining_edges = 0; + this.setProperties(properties, constants); // mass, force, velocity @@ -12509,6 +12514,17 @@ function Node(properties, imagelist, grouplist, constants) { this.damping = 0.9; // damping factor }; +/** + * (re)setting the clustering variables and objects + */ +Node.prototype.resetCluster = function() { + // clustering variables + this.formation_scale = undefined; // this is used to determine when to open the cluster + this.cluster_size = 1; // this signifies the total amount of nodes in this cluster + this.contained_nodes = {}; + this.contained_edges = {}; +}; + /** * Attach a edge to the node * @param {Edge} edge @@ -12517,6 +12533,8 @@ Node.prototype.attachEdge = function(edge) { if (this.edges.indexOf(edge) == -1) { this.edges.push(edge); } + this.remaining_edges = this.edges.length; + this.remaining_edges_tmp = this.edges.length; this._updateMass(); }; @@ -12529,6 +12547,8 @@ Node.prototype.detachEdge = function(edge) { if (index != -1) { this.edges.splice(index, 1); } + this.remaining_edges = this.edges.length; + this.remaining_edges_tmp = this.edges.length; this._updateMass(); }; @@ -12639,6 +12659,14 @@ Node.parseColor = function(color) { highlight: { border: color, background: color + }, + cluster: { + border: color, + background: color, + highlight: { + border: color, + background: color + } } }; // TODO: automatically generate a nice highlight color @@ -12647,6 +12675,7 @@ Node.parseColor = function(color) { c = {}; c.background = color.background || 'white'; c.border = color.border || c.background; + if (util.isString(color.highlight)) { c.highlight = { border: color.highlight, @@ -12658,6 +12687,32 @@ Node.parseColor = function(color) { c.highlight.background = color.highlight && color.highlight.background || c.background; c.highlight.border = color.highlight && color.highlight.border || c.border; } + + // check if cluster colorgroup has been defined + if (util.isString(color.cluster)) { + c.cluster = { + border: color.cluster, + background: color.cluster + } + } + else { + c.cluster = {}; + c.cluster.background = color.cluster && color.cluster.background || c.background; + c.cluster.border = color.cluster && color.cluster.border || c.border; + } + + // check if cluster highlight colorgroup has been defined + if (util.isString(color.cluster.highlight)) { + c.cluster.highlight = { + border: color.cluster.highlight, + background: color.cluster.highlight + } + } + else { + c.cluster.highlight = {}; + c.cluster.highlight.background = color.cluster.highlight && color.cluster.highlight.background || c.background; + c.cluster.highlight.border = color.cluster.highlight && color.cluster.highlight.border || c.border; + } } return c; }; @@ -12667,7 +12722,8 @@ Node.parseColor = function(color) { */ Node.prototype.select = function() { this.selected = true; - this._reset(); + // why do this? + // this._reset(); }; /** @@ -12675,7 +12731,8 @@ Node.prototype.select = function() { */ Node.prototype.unselect = function() { this.selected = false; - this._reset(); + // why do this? + // this._reset(); }; /** @@ -12937,9 +12994,15 @@ Node.prototype._drawBox = function (ctx) { this.left = this.x - this.width / 2; this.top = this.y - this.height / 2; - ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; - ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; - ctx.lineWidth = this.selected ? 2.0 : 1.0; + if (this.cluster_size > 1) { + ctx.strokeStyle = this.selected ? this.color.cluster.highlight.border : this.color.cluster.border; + ctx.fillStyle = this.selected ? this.color.cluster.highlight.background : this.color.cluster.background; + } + else { + ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; + ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; + } + ctx.lineWidth = (this.selected ? 2.0 : 1.0) + (this.cluster_size > 1) ? 2.0 : 0.0; ctx.roundRect(this.left, this.top, this.width, this.height, this.radius); ctx.fill(); ctx.stroke(); @@ -12963,9 +13026,15 @@ Node.prototype._drawDatabase = function (ctx) { this.left = this.x - this.width / 2; this.top = this.y - this.height / 2; - ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; - ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; - ctx.lineWidth = this.selected ? 2.0 : 1.0; + if (this.cluster_size > 1) { + ctx.strokeStyle = this.selected ? this.color.cluster.highlight.border : this.color.cluster.border; + ctx.fillStyle = this.selected ? this.color.cluster.highlight.background : this.color.cluster.background; + } + else { + ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; + ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; + } + ctx.lineWidth = (this.selected ? 2.0 : 1.0) + (this.cluster_size > 1) ? 2.0 : 0.0; ctx.database(this.x - this.width/2, this.y - this.height*0.5, this.width, this.height); ctx.fill(); ctx.stroke(); @@ -12991,9 +13060,15 @@ Node.prototype._drawCircle = function (ctx) { this.left = this.x - this.width / 2; this.top = this.y - this.height / 2; - ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; - ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; - ctx.lineWidth = this.selected ? 2.0 : 1.0; + if (this.cluster_size > 1) { + ctx.strokeStyle = this.selected ? this.color.cluster.highlight.border : this.color.cluster.border; + ctx.fillStyle = this.selected ? this.color.cluster.highlight.background : this.color.cluster.background; + } + else { + ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; + ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; + } + ctx.lineWidth = (this.selected ? 2.0 : 1.0) + (this.cluster_size > 1) ? 2.0 : 0.0; ctx.circle(this.x, this.y, this.radius); ctx.fill(); ctx.stroke(); @@ -13018,9 +13093,15 @@ Node.prototype._drawEllipse = function (ctx) { this.left = this.x - this.width / 2; this.top = this.y - this.height / 2; - ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; - ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; - ctx.lineWidth = this.selected ? 2.0 : 1.0; + if (this.cluster_size > 1) { + ctx.strokeStyle = this.selected ? this.color.cluster.highlight.border : this.color.cluster.border; + ctx.fillStyle = this.selected ? this.color.cluster.highlight.background : this.color.cluster.background; + } + else { + ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; + ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; + } + ctx.lineWidth = (this.selected ? 2.0 : 1.0) + (this.cluster_size > 1) ? 2.0 : 0.0; ctx.ellipse(this.left, this.top, this.width, this.height); ctx.fill(); ctx.stroke(); @@ -13062,9 +13143,15 @@ Node.prototype._drawShape = function (ctx, shape) { this.left = this.x - this.width / 2; this.top = this.y - this.height / 2; - ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; - ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; - ctx.lineWidth = this.selected ? 2.0 : 1.0; + if (this.cluster_size > 1) { + ctx.strokeStyle = this.selected ? this.color.cluster.highlight.border : this.color.cluster.border; + ctx.fillStyle = this.selected ? this.color.cluster.highlight.background : this.color.cluster.background; + } + else { + ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border; + ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background; + } + ctx.lineWidth = (this.selected ? 2.0 : 1.0) + (this.cluster_size > 1) ? 2.0 : 0.0; ctx[shape](this.x, this.y, this.radius); ctx.fill(); @@ -14005,6 +14092,14 @@ function Graph (container, data, options) { highlight: { border: '#2B7CE9', background: '#D2E5FF' + }, + cluster: { + border: '#000000', + background: '#F0F0F0', + highlight: { + border: '#FF0FFF', + background: '#FF000F' + } } }, borderColor: '#2B7CE9', @@ -14029,6 +14124,13 @@ function Graph (container, data, options) { altLength: undefined } }, + clustering: { + clusterLength: 30, // threshold length for clustering + zoomOffset: 0.1, + widthGrowth: 15, // growth factor = ((parent_size + child_size) / parent_size) * widthGrowthFactor + heightGrowth: 15, // growth factor = ((parent_size + child_size) / parent_size) * heightGrowthFactor + massTransferCoefficient: 0.1 // parent.mass += massTransferCoefficient * child.mass + }, minForce: 0.05, minVelocity: 0.02, // px/s maxIterations: 1000 // maximum number of iteration to stabilize @@ -14038,6 +14140,8 @@ function Graph (container, data, options) { this.node_indices = []; // the node indices list is used to speed up the computation of the repulsion fields this.nodes = {}; // object with Node objects this.edges = {}; // object with Edge objects + this.scale = 1; // defining the global scale variable in the constructor + this.previous_scale = 1; // use this to check if the zoom operation is in or out // TODO: create a counter to keep track on the number of nodes having values // TODO: create a counter to keep track on the number of nodes currently moving // TODO: create a counter to keep track on the number of edges having values @@ -14096,7 +14200,184 @@ function Graph (container, data, options) { // draw data this.setData(data); -} +}; + +/** + * This function checks if the zoom action is in or out. + * If out, check if we can form clusters, if in, check if we can open clusters. + * This function is only called from _zoom() + * + * @private + */ +Graph.prototype._updateClusters = function() { + if (this.previous_scale > this.scale) { // zoom out + this._formClusters(); + } + else if (this.previous_scale < this.scale) { // zoom out + this._openClusters(); + } + this._updateNodeIndexList(); + + for (var nid in this.nodes) { + var node = this.nodes[nid]; + node.label = String(node.remaining_edges); + } + + this.previous_scale = this.scale; +}; + +/** + * This function loops over all nodes in the node_indices list. For each node it checks if it is a cluster and if it + * has to be opened based on the current zoom level. + * + * @private + */ +Graph.prototype._openClusters = function() { + for (var i = 0; i < this.node_indices.length; i++) { + var node = this.nodes[this.node_indices[i]]; + this._expandClusterNode(node,true); + } +}; + +/** + * This function checks if a node has to be opened. This is done by checking the zoom level. + * If the node contains child nodes, this function is recursively called on the child nodes as well. + * This recursive behaviour is optional and can be set by the recursive argument. + * + * @param node | Node object: to check for cluster and expand + * @param recursive | Boolean: enable or disable recursive calling + * @private + */ +Graph.prototype._expandClusterNode = function(node, recursive) { + if (node.formation_scale != undefined) { + if (this.scale > node.formation_scale) { + for (var contained_node_id in node.contained_nodes) { + if (node.contained_nodes.hasOwnProperty(contained_node_id)) { + // put the child node back in the global nodes object + this.nodes[contained_node_id] = node.contained_nodes[contained_node_id]; + + var child_node = this.nodes[contained_node_id]; + + // remove mass from child node from parent node + node.mass -= this.constants.clustering.massTransferCoefficient * this.nodes[contained_node_id].mass; + + // decrease the size again + node.cluster_size -= child_node.cluster_size; + var grow_coefficient = this.constants.clustering.zoomOffset + (node.cluster_size + child_node.cluster_size) / node.cluster_size; + node.width -= grow_coefficient * this.constants.clustering.widthGrowth; + node.height -= grow_coefficient * this.constants.clustering.heightGrowth; + node.fontSize -= 1 * child_node.cluster_size; + + // check if a further expansion step is possible if recursivity is enabled + if (recursive == true) { + this._expandClusterNode(child_node,true); + } + } + } + + // put the edges back in the global this.edges + for (var contained_edge_id in node.contained_edges) { + if (node.contained_edges.hasOwnProperty(contained_edge_id)) { + this.edges[contained_edge_id] = node.contained_edges[contained_edge_id]; + node.remaining_edges += 1; + } + } + + // reset the cluster settings of this node + node.resetCluster(); + } + } +}; + +/** + * This function checks if any nodes at the end of their trees have edges below a threshold length + * This function is called only from _updateClusters() + * + * @private + */ +Graph.prototype._formClusters = function() { + var min_length = this.constants.clustering.clusterLength/this.scale; + + var dx,dy,length, + edges = this.edges; + + // create an array of edge ids + var edges_id_array = [] + for (var id in edges) { + if (edges.hasOwnProperty(id)) { + edges_id_array.push(id); + } + } + + // check if any edges are shorter than min_length and start the clustering + // the clustering favours the node with the larger mass + for (var i = 0; i < edges_id_array.length; i++) { + var edge = edges[edges_id_array[i]]; + if (edge.connected) { + dx = (edge.to.x - edge.from.x); + dy = (edge.to.y - edge.from.y); + length = Math.sqrt(dx * dx + dy * dy); + + + if (length < min_length) { + // checking for clustering possiblities + + // first check which node is larger + if (edge.to.mass > edge.from.mass) { + var parent_node = edge.to + var child_node = edge.from + } + else { + var parent_node = edge.from + var child_node = edge.to + } + + // we allow clustering from outside in, ideally the child node in on the outside + // if we do not cluster from outside in, we would have to reconnect edges or keep a second set of edges for the + // clusters. This will also have to be altered in the force calculation and rendering. + // This method is non-destructive and does not require a second set of data. + if (child_node.remaining_edges == 1) { + this._addToCluster(parent_node,child_node,edge); + delete this.edges[edges_id_array[i]]; + } + else if (parent_node.remaining_edges == 1) { + this._addToCluster(child_node,parent_node,edge); + delete this.edges[edges_id_array[i]]; + } + } + } + } +}; + +/** + * This function adds the childnode to the parentnode, creating a cluster if it is not already. + * This function is called only from _updateClusters() + * + * @param parent_node | Node object: this is the node that will house the child node + * @param child_node | Node object: this node will be deleted from the global this.nodes and stored in the parent node + * @param edge | Edge object: this edge will be deleted from the global this.edges and stored in the parent node + * @private + */ +Graph.prototype._addToCluster = function(parent_node, child_node, edge) { + // join child node and edge in parent node + parent_node.contained_nodes[child_node.id] = child_node; + parent_node.contained_edges[child_node.id] = edge; + + if (this.nodes.hasOwnProperty(child_node.id)) { + delete this.nodes[child_node.id]; + } + + + var grow_coefficient = this.constants.clustering.zoomOffset + (parent_node.cluster_size + child_node.cluster_size) / parent_node.cluster_size; + parent_node.mass += this.constants.clustering.massTransferCoefficient * child_node.mass; + parent_node.width += grow_coefficient * this.constants.clustering.widthGrowth; + parent_node.height += grow_coefficient * this.constants.clustering.heightGrowth; + parent_node.remaining_edges -= 1; + parent_node.formation_scale = this.scale; + parent_node.cluster_size += child_node.cluster_size; + parent_node.fontSize += 1 * child_node.cluster_size; +}; + /** * Update the this.node_indices with the most recent node index list @@ -14110,7 +14391,7 @@ Graph.prototype._updateNodeIndexList = function() { this.node_indices.push(idx); } } -} +}; /** * Set nodes and edges, and optionally options as well. @@ -14546,6 +14827,7 @@ Graph.prototype._zoom = function(scale, pointer) { this._setScale(scale); this._setTranslation(tx, ty); + this._updateClusters(); this._redraw(); return scale; @@ -15544,19 +15826,25 @@ Graph.prototype._calculateForces = function() { dy = node2.y - node1.y; distance = Math.sqrt(dx * dx + dy * dy); - //if (distance < 10*minimumDistance) { + // + if (distance < 2*minimumDistance) { // at 2.0 * the minimum distance, the force is 0.000045 angle = Math.atan2(dy, dx); - // TODO: correct factor for repulsing force - //repulsingForce = 2 * Math.exp(-5 * (distance * distance) / (dmin * dmin) ); // TODO: customize the repulsing force - //repulsingForce = Math.exp(-1 * (distance * distance) / (dmin * dmin) ); // TODO: customize the repulsing force - repulsingForce = 1 / (1 + Math.exp((distance / minimumDistance - 1) * steepness)); // TODO: customize the repulsing force + if (distance < 0.5*minimumDistance) { // at 0.5 * the minimum distance, the force is 0.993307 + repulsingForce = 1.0; + } + else { + // TODO: correct factor for repulsing force + //repulsingForce = 2 * Math.exp(-5 * (distance * distance) / (dmin * dmin) ); // TODO: customize the repulsing force + //repulsingForce = Math.exp(-1 * (distance * distance) / (dmin * dmin) ); // TODO: customize the repulsing force + repulsingForce = 1 / (1 + Math.exp((distance / minimumDistance - 1) * steepness)); // TODO: customize the repulsing force + } fx = Math.cos(angle) * repulsingForce; fy = Math.sin(angle) * repulsingForce; node1._addForce(-fx, -fy); node2._addForce(fx, fy); - // } + } } } @@ -15611,7 +15899,8 @@ Graph.prototype._calculateForces = function() { } } - /* TODO: re-implement repulsion of edges + // TODO: re-implement repulsion of edges + // repulsing forces between edges var minimumDistance = this.constants.edges.distance, steepness = 10; // higher value gives steeper slope of the force around the given minimumDistance @@ -15646,7 +15935,7 @@ Graph.prototype._calculateForces = function() { edges[l2].to._addForce(fx, fy); } } - */ + }; @@ -15687,16 +15976,9 @@ Graph.prototype._discreteStepNodes = function() { */ Graph.prototype.start = function() { if (this.moving) { - var start = window.performance.now(); - this._calculateForces(); this._discreteStepNodes(); - var end = window.performance.now(); - var time = end - start; - console.log('Execution time: ' + time); - - var vmin = this.constants.minVelocity; this.moving = this._isMoving(vmin); } @@ -15707,8 +15989,23 @@ Graph.prototype.start = function() { var graph = this; this.timer = window.setTimeout(function () { graph.timer = undefined; + + // benchmark the calculation + var start = window.performance.now(); graph.start(); + // Optionally call this twice for faster convergence + // graph.start(); + var end = window.performance.now(); + var time = end - start; + // console.log('Simulation time: ' + time); + + + start = window.performance.now(); graph._redraw(); + end = window.performance.now(); + time = end - start; + // console.log('Drawing time: ' + time); + }, this.refreshRate); } }