/** * @constructor Cluster * Contains the cluster properties for the graph object */ function Cluster() { this.clusterSession = 0; } /** * This function can be called to increase the cluster level. This means that the nodes with only one edge connection will * be clustered with their connected node. This can be repeated as many times as needed. * This can be called externally (by a keybind for instance) to reduce the complexity of big datasets. */ Cluster.prototype.increaseClusterLevel = function() { var isMovingBeforeClustering = this.moving; this._formClusters(true); // if the simulation was settled, we restart the simulation if a cluster has been formed or expanded if (this.moving != isMovingBeforeClustering) { this.start(); } }; /** * This function can be called to decrease the cluster level. This means that the nodes with only one edge connection will * be unpacked if they are a cluster. This can be repeated as many times as needed. * This can be called externally (by a key-bind for instance) to look into clusters without zooming. */ Cluster.prototype.decreaseClusterLevel = function() { var isMovingBeforeClustering = this.moving; for (var i = 0; i < this.nodeIndices.length; i++) { var node = this.nodes[this.nodeIndices[i]]; if (node.clusterSize > 1) { this._expandClusterNode(node,false,true); } } this._updateNodeIndexList(); this.clusterSession = (this.clusterSession == 0) ? 0 : this.clusterSession - 1; // if the simulation was settled, we restart the simulation if a cluster has been formed or expanded if (this.moving != isMovingBeforeClustering) { this.start(); } this._updateLabels(); }; /** * This function can be called to open up a specific cluster. * It will unpack the cluster back one level. * * @param node | Node object: cluster to open. */ Cluster.prototype.openCluster = function(node) { var isMovingBeforeClustering = this.moving; this._expandClusterNode(node,false,true); this._updateNodeIndexList(); // if the simulation was settled, we restart the simulation if a cluster has been formed or expanded if (this.moving != isMovingBeforeClustering) { this.start(); } }; /** * 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 */ Cluster.prototype._updateClusters = function() { var isMovingBeforeClustering = this.moving; if (this.previousScale > this.scale) { // zoom out this._formClusters(false); } else if (this.previousScale < this.scale) { // zoom out this._openClusters(); } this._updateClusterLabels(); this._updateNodeLabels(); this._updateLabels(); this.previousScale = this.scale; // if the simulation was settled, we restart the simulation if a cluster has been formed or expanded if (this.moving != isMovingBeforeClustering) { this.start(); } }; /** * This updates the node labels for all nodes (for debugging purposes) * @private */ Cluster.prototype._updateLabels = function() { // update node labels //this._updateClusterLabels(); // this._updateNodeLabels(); // Debug : for (var nodeID in this.nodes) { if (this.nodes.hasOwnProperty(nodeID)) { var node = this.nodes[nodeID]; node.label = String(node.dynamicEdges.length).concat(":",node.dynamicEdgesLength,":",String(node.clusterSize),":::",String(node.id)); } } }; /** * This updates the node labels for all clusters * @private */ Cluster.prototype._updateClusterLabels = function() { // update node labels for (var nodeID in this.nodes) { if (this.nodes.hasOwnProperty(nodeID)) { var node = this.nodes[nodeID]; if (node.clusterSize > 1) { node.label = "[".concat(String(node.clusterSize),"]"); } } } }; /** * This updates the node labels for all nodes that are NOT clusters * @private */ Cluster.prototype._updateNodeLabels = function() { // update node labels for (var nodeID in this.nodes) { var node = this.nodes[nodeID]; if (node.clusterSize == 1) { node.label = String(node.id); } } }; /** * This function loops over all nodes in the nodeIndices 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 */ Cluster.prototype._openClusters = function() { var amountOfNodes = this.nodeIndices.length; for (var i = 0; i < this.nodeIndices.length; i++) { var node = this.nodes[this.nodeIndices[i]]; this._expandClusterNode(node,true,false); } this._updateNodeIndexList(); if (this.nodeIndices.length != amountOfNodes) { // this means a clustering operation has taken place this.clusterSession -= 1; } }; /** * 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 parentNode | Node object: to check for cluster and expand * @param recursive | Boolean: enable or disable recursive calling * @param forceExpand | Boolean: enable or disable forcing the last node to join the cluster to be expelled * @private */ Cluster.prototype._expandClusterNode = function(parentNode, recursive, forceExpand) { // first check if node is a cluster if (parentNode.clusterSize > 1) { // if the last child has been added on a smaller scale than current scale (@optimization) if (parentNode.formationScale < this.scale || forceExpand == true) { // we will check if any of the contained child nodes should be removed from the cluster for (var containedNodeID in parentNode.containedNodes) { if (parentNode.containedNodes.hasOwnProperty(containedNodeID)) { var childNode = parentNode.containedNodes[containedNodeID]; // force expand will expand the largest cluster size clusters. Since we cluster from outside in, we assume that // the largest cluster is the one that comes from outside if (forceExpand == true) { if (childNode.clusterSession == this.clusterSession - 1) { this._expelChildFromParent(parentNode,containedNodeID,recursive,forceExpand); } } else { if (this._parentNodeInActiveArea(parentNode)) { this._expelChildFromParent(parentNode,containedNodeID,recursive,forceExpand); } } } } } } }; Cluster.prototype._parentNodeInActiveArea = function(node) { if (Math.abs(node.x - this.zoomCenter.x) <= this.constants.clustering.activeAreaRadius/this.scale && Math.abs(node.y - this.zoomCenter.y) <= this.constants.clustering.activeAreaRadius/this.scale) { return true; } else { return false; } }; /** * This function will expel a child_node from a parent_node. This is to de-cluster the node. This function will remove * the child node from the parent contained_node object and put it back into the global nodes object. * The same holds for the edge that was connected to the child node. It is moved back into the global edges object. * * @param parentNode | Node object: the parent node * @param containedNodeID | String: child_node id as it is contained in the containedNodes object of the parent node * @param recursive | Boolean: This will also check if the child needs to be expanded. * With force and recursive both true, the entire cluster is unpacked * @param forceExpand | Boolean: This will disregard the zoom level and will expel this child from the parent * @private */ Cluster.prototype._expelChildFromParent = function(parentNode, containedNodeID, recursive, forceExpand) { var childNode = parentNode.containedNodes[containedNodeID]; // if child node has been added on smaller scale than current, kick out if (childNode.formationScale < this.scale || forceExpand == true) { // put the child node back in the global nodes object this.nodes[containedNodeID] = childNode; // release the contained edges from this childNode back into the global edges this._releaseContainedEdges(parentNode,childNode) // reconnect rerouted edges to the childNode this._connectEdgeBackToChild(parentNode,childNode); // undo the changes from the clustering operation on the parent node parentNode.mass -= this.constants.clustering.massTransferCoefficient * childNode.mass; parentNode.fontSize -= this.constants.clustering.fontSizeMultiplier * childNode.clusterSize; parentNode.clusterSize -= childNode.clusterSize; parentNode.dynamicEdgesLength = parentNode.dynamicEdges.length; // place the child node near the parent, not at the exact same location to avoid chaos in the system childNode.x = parentNode.x + this.constants.edges.length * 0.2 * (0.5 - Math.random()) * parentNode.clusterSize; childNode.y = parentNode.y + this.constants.edges.length * 0.2 * (0.5 - Math.random()) * parentNode.clusterSize; // remove the clusterSession from the child node childNode.clusterSession = 0; // remove node from the list delete parentNode.containedNodes[containedNodeID]; // restart the simulation to reorganise all nodes this.moving = true; // recalculate the size of the node on the next time the node is rendered parentNode.clearSizeCache(); } // check if a further expansion step is possible if recursivity is enabled if (recursive == true) { this._expandClusterNode(childNode,recursive,forceExpand); } }; /** * 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() * forceLevelCollapse ignores the length of the edge and collapses one level * This means that a node with only one edge will be clustered with its connected node * * @private * @param force_level_collapse | Boolean */ Cluster.prototype._formClusters = function(forceLevelCollapse) { var amountOfNodes = this.nodeIndices.length; if (forceLevelCollapse == false) { this._formClustersByZoom(); } else { this._forceClustersByZoom(); } if (this.nodeIndices.length != amountOfNodes) { // this means a clustering operation has taken place this.clusterSession += 1; } }; /** * This function handles the clustering by zooming out, this is based on minimum edge distance * * @private */ Cluster.prototype._formClustersByZoom = function() { var dx,dy,length, minLength = this.constants.clustering.clusterLength/this.scale; // create an array of edge ids var edgesIDarray = [] for (var id in this.edges) { if (this.edges.hasOwnProperty(id)) { edgesIDarray.push(id); } } // check if any edges are shorter than minLength and start the clustering // the clustering favours the node with the larger mass for (var i = 0; i < edgesIDarray.length; i++) { var edgeID = edgesIDarray[i]; var edge = this.edges[edgeID]; 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 < minLength) { // first check which node is larger var parentNode = edge.from var childNode = edge.to if (edge.to.mass > edge.from.mass) { parentNode = edge.to childNode = edge.from } // we allow clustering from outside in // 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 (childNode.dynamicEdgesLength == 1) { this._addToCluster(parentNode,childNode,edge,false); } else if (parentNode.dynamicEdgesLength == 1) { this._addToCluster(childNode,parentNode,edge,false); } } } } this._updateNodeIndexList(); }; /** * This function forces the graph to cluster all nodes with only one connecting edge to their * connected node. * * @private */ Cluster.prototype._forceClustersByZoom = function() { for (var nodeID = 0; nodeID < this.nodeIndices.length; nodeID++) { var childNode = this.nodes[this.nodeIndices[nodeID]]; if (childNode.dynamicEdgesLength == 1) { var edge = childNode.dynamicEdges[0]; var parentNode = (edge.toId == childNode.id) ? this.nodes[edge.fromId] : this.nodes[edge.toId]; this._addToCluster(parentNode,childNode,true); } } this._updateNodeIndexList(); this._updateDynamicEdges(); }; Cluster.prototype.aggregateHubs = function() { var isMovingBeforeClustering = this.moving; var hubThreshold = 4; this._forceClustersByHub(hubThreshold); // if the simulation was settled, we restart the simulation if a cluster has been formed or expanded if (this.moving != isMovingBeforeClustering) { this.start(); } }; /** * * @param hubThreshold * @private */ Cluster.prototype._forceClustersByHub = function(hubThreshold) { for (var nodeID = 0; nodeID < this.nodeIndices.length; nodeID++) { if (this.nodes.hasOwnProperty(this.nodeIndices[nodeID])) { var hubNode = this.nodes[this.nodeIndices[nodeID]]; if (hubNode.dynamicEdges.length >= hubThreshold) { var edgesIDarray = [] var amountOfInitialEdges = hubNode.dynamicEdges.length; for (var i = 0; i < amountOfInitialEdges; i++) { edgesIDarray.push(hubNode.dynamicEdges[i].id); } for (var i = 0; i < amountOfInitialEdges; i++) { var edge = this.edges[edgesIDarray[i]]; var childNode = this.nodes[(edge.fromId == hubNode.id) ? edge.toId : edge.fromId]; this._addToCluster(hubNode,childNode,true); } break; } } } this._updateNodeIndexList(); this._updateDynamicEdges(); this._updateLabels(); if (this.moving != isMovingBeforeClustering) { this.start(); } }; /** * This function adds the child node to the parent node, 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 force_level_collapse | Boolean: true will only update the remainingEdges at the very end of the clustering, ensuring single level collapse * @private */ Cluster.prototype._addToCluster = function(parentNode, childNode, forceLevelCollapse) { // join child node in the parent node parentNode.containedNodes[childNode.id] = childNode; if (forceLevelCollapse == false) { parentNode.dynamicEdgesLength += childNode.dynamicEdges.length - 2; } for (var i = 0; i < childNode.dynamicEdges.length; i++) { var edge = childNode.dynamicEdges[i]; if (edge.toId == parentNode.id || edge.fromId == parentNode.id) { // edge connected to parentNode this._addToContainedEdges(parentNode,childNode,edge); } else { console.log('connecting edge to cluster'); this._connectEdgeToCluster(parentNode,childNode,edge); } } childNode.dynamicEdges = []; // remove the childNode from the global nodes object delete this.nodes[childNode.id]; childNode.clusterSession = this.clusterSession; parentNode.mass += this.constants.clustering.massTransferCoefficient * childNode.mass; parentNode.clusterSize += childNode.clusterSize; parentNode.fontSize += this.constants.clustering.fontSizeMultiplier * childNode.clusterSize // giving the clusters a dynamic formationScale to ensure not all clusters open up when zoomed if (forceLevelCollapse == true) { parentNode.formationScale = this.scale * Math.pow(1.0/11.0,this.clusterSession); } else { parentNode.formationScale = this.scale; // The latest child has been added on this scale parentNode.dynamicEdgesLength = parentNode.dynamicEdges.length; } // recalculate the size of the node on the next time the node is rendered parentNode.clearSizeCache(); parentNode.containedNodes[childNode.id].formationScale = this.scale; // this child has been added at this scale. // restart the simulation to reorganise all nodes this.moving = true; }; /** * This function will apply the changes made to the remainingEdges during the formation of the clusters. * This is a seperate function to allow for level-wise collapsing of the node tree. * It has to be called if a level is collapsed. It is called by _formClusters(). * @private */ Cluster.prototype._updateDynamicEdges = function() { for (var i = 0; i < this.nodeIndices.length; i++) { var node = this.nodes[this.nodeIndices[i]]; node.dynamicEdgesLength = node.dynamicEdges.length; } }; Cluster.prototype._repositionNodes = function() { for (var i = 0; i < this.nodeIndices.length; i++) { var node = this.nodes[this.nodeIndices[i]]; if (!node.isFixed()) { // TODO: position new nodes in a smarter way! var radius = this.constants.edges.length * (1 + 0.5*node.clusterSize); var angle = 2 * Math.PI * Math.random(); node.x = radius * Math.cos(angle); node.y = radius * Math.sin(angle); } } }; /** * This adds an edge from the childNode to the contained edges of the parent node * * @param parentNode | Node object * @param childNode | Node object * @param edge | Edge object * @private */ Cluster.prototype._addToContainedEdges = function(parentNode, childNode, edge) { // create an array object if it does not yet exist for this childNode if (!(parentNode.containedEdges.hasOwnProperty(childNode.id))) { parentNode.containedEdges[childNode.id] = [] } // add this edge to the list parentNode.containedEdges[childNode.id].push(edge); // remove the edge from the global edges object delete this.edges[edge.id]; // remove the edge from the parent object for (var i = 0; i < parentNode.dynamicEdges.length; i++) { if (parentNode.dynamicEdges[i].id == edge.id) { parentNode.dynamicEdges.splice(i,1); break; } } }; /** * This function connects an edge that was connected to a child node to the parent node. * It keeps track of which nodes it has been connected to with the originalID array. * * @param parentNode | Node object * @param childNode | Node object * @param edge | Edge object * @private */ Cluster.prototype._connectEdgeToCluster = function(parentNode, childNode, edge) { if (edge.toId == childNode.id) { // edge connected to other node on the "to" side edge.originalToID.push(childNode.id); edge.to = parentNode; edge.toId = parentNode.id; } else { // edge connected to other node with the "from" side edge.originalFromID.push(childNode.id); edge.from = parentNode; edge.fromId = parentNode.id; } this._addToReroutedEdges(parentNode,childNode,edge); }; /** * This adds an edge from the childNode to the rerouted edges of the parent node * * @param parentNode | Node object * @param childNode | Node object * @param edge | Edge object * @private */ Cluster.prototype._addToReroutedEdges = function(parentNode, childNode, edge) { // create an array object if it does not yet exist for this childNode // we store the edge in the rerouted edges so we can restore it when the cluster pops open if (!(parentNode.reroutedEdges.hasOwnProperty(childNode.id))) { parentNode.reroutedEdges[childNode.id] = []; } parentNode.reroutedEdges[childNode.id].push(edge); // this edge becomes part of the dynamicEdges of the cluster node parentNode.dynamicEdges.push(edge); }; /** * This function connects an edge that was connected to a cluster node back to the child node. * * @param parentNode | Node object * @param childNode | Node object * @private */ Cluster.prototype._connectEdgeBackToChild = function(parentNode, childNode) { if (parentNode.reroutedEdges[childNode.id] != undefined) { for (var i = 0; i < parentNode.reroutedEdges[childNode.id].length; i++) { var edge = parentNode.reroutedEdges[childNode.id][i]; if (edge.originalFromID[edge.originalFromID.length-1] == childNode.id) { edge.originalFromID.pop(); edge.fromId = childNode.id; edge.from = childNode; } else { edge.originalToID.pop(); edge.toId = childNode.id; edge.to = childNode; } // append this edge to the list of edges connecting to the childnode childNode.dynamicEdges.push(edge); // remove the edge from the parent object for (var j = 0; j < parentNode.dynamicEdges.length; j++) { if (parentNode.dynamicEdges[j].id == edge.id) { parentNode.dynamicEdges.splice(j,1); break; } } } // remove the entry from the rerouted edges delete parentNode.reroutedEdges[childNode.id]; } }; /** * This function released the contained edges back into the global domain and puts them back into the * dynamic edges of both parent and child. * * @param parentNode | Node object * @param childNode | Node object * @private */ Cluster.prototype._releaseContainedEdges = function(parentNode, childNode) { for (var i = 0; i < parentNode.containedEdges[childNode.id].length; i++) { var edge = parentNode.containedEdges[childNode.id][i]; // put the edge back in the global edges object this.edges[edge.id] = edge; // put the edge back in the dynamic edges of the child and parent childNode.dynamicEdges.push(edge); parentNode.dynamicEdges.push(edge); } // remove the entry from the contained edges delete parentNode.containedEdges[childNode.id]; };