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);
}
}