/** * @author Andrei Kashcha (aka anvaka) / http://anvaka.blogspot.com */ // TODO: rename all links to edges. Otherwise it's incositent var Viva = Viva || {}; Viva.Graph = Viva.Graph || {}; Viva.Graph.version = '1.0.0.42';/*global Viva*/ Viva.BrowserInfo = (function(){ var ua = navigator.userAgent; // Useragent RegExp var rwebkit = /(webkit)[ \/]([\w.]+)/; var ropera = /(opera)(?:.*version)?[ \/]([\w.]+)/; var rmsie = /(msie) ([\w.]+)/; var rmozilla = /(mozilla)(?:.*? rv:([\w.]+))?/; ua = ua.toLowerCase(); var match = rwebkit.exec( ua ) || ropera.exec( ua ) || rmsie.exec( ua ) || ua.indexOf("compatible") < 0 && rmozilla.exec( ua ) || []; return { browser: match[1] || "", version: match[2] || "0" }; })(); /** * @author Andrei Kashcha (aka anvaka) / http://anvaka.blogspot.com */ /*global Viva*/ Viva.Graph.Utils = Viva.Graph.Utils || {}; Viva.Graph.Utils.indexOfElementInArray = function(element, array) { if (array.indexOf) { return array.indexOf(element); } var len = array.length; var i = 0; for ( ; i < len; i++ ) { if ( i in array && array[i] === element ) { return i; } } return -1; }; /*global Viva*/ Viva.Graph.Utils = Viva.Graph.Utils || {}; Viva.Graph.Utils.getDimension = function(container) { if (!container){ throw { message : 'Cannot get dimensions of undefined container' }; } // TODO: Potential cross browser bug. var width = container.clientWidth; var height = container.clientHeight; return { left : 0, top : 0, width : width, height : height }; }; /** * Finds the absolute position of an element on a page */ Viva.Graph.Utils.findElementPosition = function(obj) { var curleft = 0, curtop = 0; if (obj.offsetParent) { do { curleft += obj.offsetLeft; curtop += obj.offsetTop; } while ( (obj = obj.offsetParent) ); // This is not a mistake. Should be assignment. } return [curleft,curtop]; };/** * @author Andrei Kashcha (aka anvaka) / http://anvaka.blogspot.com */ /*global Viva, window*/ Viva.Graph.Utils = Viva.Graph.Utils || {}; // TODO: I don't really like the way I implemented events. It looks clumsy and // hard to understand. Refactor it. /** * Allows to start/stop listen to element's events. An element can be arbitrary * DOM element, or object with eventuality behavior. * * To add eventuality behavior to arbitrary object 'obj' call * Viva.Graph.Utils.event(obj).extend() method. * After this call is made the object can use obj.fire(eventName, params) method, and listeners * can listen to event by Viva.Graph.Utils.events(obj).on(eventName, callback) method. */ Viva.Graph.Utils.events = function(element){ /** * Extends arbitrary object with fire method and allows it to be used with on/stop methods. * * This behavior is based on Crockford's eventuality example, but with a minor changes: * - fire() method accepts parameters to pass to callbacks (instead of setting them in 'on' method) * - on() method is replaced with addEventListener(), to let objects be used as a DOM objects. * - behavoir contract is simplified to "string as event name"/"function as callback" convention. * - removeEventListener() method added to let unsubscribe from events. */ var eventuality = function(that){ var registry = {}; /** * Fire an event on an object. The event is a string containing the name of the event * Handlers registered by the 'addEventListener' method that match the event name * will be invoked. */ that.fire = function (eventName, parameters) { var registeredHandlers, callback, handler; if (typeof eventName !== 'string') { throw 'Only strings can be used as even type'; } // If an array of handlers exist for this event, then // loop through it and execute the handlers in order. if (registry.hasOwnProperty(eventName)) { registeredHandlers = registry[eventName]; for (var i = 0; i < registeredHandlers.length; ++i) { handler = registeredHandlers[i]; callback = handler.method; callback(parameters); } } return this; }; that.addEventListener = function (eventName, callback) { if (typeof callback !== 'function'){ throw 'Only functions allowed to be callbacks'; } var handler = { method: callback }; if (registry.hasOwnProperty(eventName)) { registry[eventName].push(handler); } else { registry[eventName] = [handler]; } return this; }; that.removeEventListener = function(eventName, callback){ if (typeof callback !== 'function'){ throw 'Only functions allowed to be callbacks'; } if (registry.hasOwnProperty(eventName)) { var handlers = registry[eventName]; for (var i = 0; i < handlers.length; ++i) { if (handlers[i].callback === callback) { handlers.splice(i); break; } } } return this; }; return that; }; return { /** * Registes callback to be called when element fires event with given event name. */ on : function(eventName, callback) { if (element.addEventListener) {// W3C DOM and eventuality objecets. element.addEventListener(eventName, callback, false); } else if (element.attachEvent) { // IE DOM element.attachEvent("on" + eventName, callback); } return this; }, /** * Unsubcribes from object's events. */ stop : function(eventName, callback) { if (element.removeEventListener) { element.removeEventListener(eventName, callback, false); } else if (element.detachEvent) { element.detachEvent('on' + eventName, callback); } }, /** * Adds eventuality to arbitrary JavaScript object. Eventuality adds * fire(), addEventListner() and removeEventListners() to the target object. * * This is required if you want to use object with on(), stop() methods. */ extend : function(){ return eventuality(element); } }; };/** * @author Andrei Kashcha (aka anvaka) / http://anvaka.blogspot.com */ /*global Viva, window*/ Viva.Graph.Utils = Viva.Graph.Utils || {}; // TODO: Add support for touch events: http://www.sitepen.com/blog/2008/07/10/touching-and-gesturing-on-the-iphone/ Viva.Graph.Utils.dragndrop = function(element) { var start, drag, end, scroll, prevSelectStart, prevDragStart, documentEvents = Viva.Graph.Utils.events(window.document), elementEvents = Viva.Graph.Utils.events(element), findElementPosition = Viva.Graph.Utils.findElementPosition, startX = 0, startY = 0, dragObject, getMousePos = function(e) { var posx = 0, posy = 0; e = e || window.event; if (e.pageX || e.pageY) { posx = e.pageX; posy = e.pageY; } else if (e.clientX || e.clientY) { posx = e.clientX + document.body.scrollLeft + document.documentElement.scrollLeft; posy = e.clientY + document.body.scrollTop + document.documentElement.scrollTop; } return [posx, posy]; }, stopPropagation = function (e) { if (e.stopPropagation) { e.stopPropagation(); } else { e.cancelBubble = true; } }, handleDisabledEvent = function(e) { stopPropagation(e); return false; }, handleMouseMove = function(e) { e = e || window.event; if (drag){ drag(e, {x : e.clientX - startX, y : e.clientY - startY }); } startX = e.clientX; startY = e.clientY; }, handleMouseDown = function(e) { e = e || window.event; // for IE, left click == 1 // for Firefox, left click == 0 var isLeftButton = (e.button === 1 && window.event !== null || e.button === 0); if (isLeftButton) { startX = e.clientX; startY = e.clientY; // TODO: bump zIndex? dragObject = e.target || e.srcElement; if (start) { start(e, {x: startX, y : startY}); } documentEvents.on('mousemove', handleMouseMove); documentEvents.on('mouseup', handleMouseUp); stopPropagation(e); // TODO: This is suggested here: http://luke.breuer.com/tutorial/javascript-drag-and-drop-tutorial.aspx // do we need it? What if event already there? // Not bullet proof: prevSelectStart = document.onselectstart; prevDragStart = document.ondragstart; document.onselectstart = handleDisabledEvent; dragObject.ondragstart = handleDisabledEvent; // prevent text selection (except IE) return false; } }, handleMouseUp = function(e) { e = e || window.event; documentEvents.stop('mousemove', handleMouseMove); documentEvents.stop('mouseup', handleMouseUp); document.onselectstart = prevSelectStart; dragObject.ondragstart = prevDragStart; dragObject = null; if (end) { end(); } }, handleMouseWheel = function(e){ if (typeof scroll !== 'function') { return; } e = e || window.event; if(e.preventDefault) { e.preventDefault(); } e.returnValue = false; var delta, mousePos = getMousePos(e), elementOffset = findElementPosition(element), relMousePos = { x: mousePos[0] - elementOffset[0], y: mousePos[1] - elementOffset[1] }; if(e.wheelDelta) { delta = e.wheelDelta / 360; // Chrome/Safari } else { delta = e.detail / -9; // Mozilla } scroll(e, delta, relMousePos); }, updateScrollEvents = function(scrollCallback) { if (!scroll && scrollCallback) { // client is interested in scrolling. Start listening to events: if (Viva.BrowserInfo.browser === 'webkit') { element.addEventListener('mousewheel', handleMouseWheel, false); // Chrome/Safari } else { element.addEventListener('DOMMouseScroll', handleMouseWheel, false); // Others } } else if (scroll && !scrollCallback) { if (Viva.BrowserInfo.browser === 'webkit') { element.removeEventListener('mousewheel', handleMouseWheel, false); // Chrome/Safari } else { element.removeEventListener('DOMMouseScroll', handleMouseWheel, false); // Others } } scroll = scrollCallback; }; elementEvents.on('mousedown', handleMouseDown); return { onStart : function(callback) { start = callback; return this; }, onDrag : function(callback) { drag = callback; return this; }, onStop : function(callback) { end = callback; return this; }, /** * Occurs when mouse wheel event happens. callback = function(e, scrollDelta, scrollPoint); */ onScroll : function(callback) { updateScrollEvents(callback); return this; }, release : function() { // TODO: could be unsafe. We might wanna release dragObject, etc. documentEvents.stop('mousemove', handleMouseMove); documentEvents.stop('mousedown', handleMouseDown); documentEvents.stop('mouseup', handleMouseUp); updateScrollEvents(null); } }; }; /** * @author Andrei Kashcha (aka anvaka) / http://anvaka.blogspot.com */ /*global Viva, window*/ Viva.Graph.Utils = Viva.Graph.Utils || {}; /** * Timer that fires callback with given interval (in ms) until * callback returns true; */ Viva.Graph.Utils.timer = function(callback, interval){ // I wanted to extract this to make further transition to // requestAnimationFrame easier: http://paulirish.com/2011/requestanimationframe-for-smart-animating/ var intervalId, stopTimer = function(){ clearInterval(intervalId); intervalId = 0; }, startTimer = function(){ intervalId = setInterval( function() { if (!callback()) { stopTimer(); } }, interval); }; startTimer(); // start it right away. return { /** * Stops execution of the callback */ stop: stopTimer, restart : function(){ if (!intervalId) { startTimer(); } } }; }; /*global Viva*/ Viva.Graph.geom = function() { return { // function from Graphics GEM to determine lines intersection: // http://www.opensource.apple.com/source/graphviz/graphviz-498/graphviz/dynagraph/common/xlines.c intersect : function(x1, y1, x2, y2, // first line segment x3, y3, x4, y4) { // second line segment var a1, a2, b1, b2, c1, c2, /* Coefficients of line eqns. */ r1, r2, r3, r4, /* 'Sign' values */ denom, offset, num, /* Intermediate values */ result = { x: 0, y : 0}; /* Compute a1, b1, c1, where line joining points 1 and 2 * is "a1 x + b1 y + c1 = 0". */ a1 = y2 - y1; b1 = x1 - x2; c1 = x2 * y1 - x1 * y2; /* Compute r3 and r4. */ r3 = a1 * x3 + b1 * y3 + c1; r4 = a1 * x4 + b1 * y4 + c1; /* Check signs of r3 and r4. If both point 3 and point 4 lie on * same side of line 1, the line segments do not intersect. */ if (r3 !== 0 && r4 !== 0 && ((r3 >= 0) === (r4 >= 4))) { return null; //no itersection. } /* Compute a2, b2, c2 */ a2 = y4 - y3; b2 = x3 - x4; c2 = x4 * y3 - x3 * y4; /* Compute r1 and r2 */ r1 = a2 * x1 + b2 * y1 + c2; r2 = a2 * x2 + b2 * y2 + c2; /* Check signs of r1 and r2. If both point 1 and point 2 lie * on same side of second line segment, the line segments do * not intersect. */ if (r1 !== 0 && r2 !== 0 && ((r1 >= 0) === (r2 >= 0 ))) { return null; // no intersection; } /* Line segments intersect: compute intersection point. */ denom = a1 * b2 - a2 * b1; if ( denom === 0 ) { return null; // Actually collinear.. } offset = denom < 0 ? - denom / 2 : denom / 2; offset = 0.0; /* The denom/2 is to get rounding instead of truncating. It * is added or subtracted to the numerator, depending upon the * sign of the numerator. */ num = b1 * c2 - b2 * c1; result.x = ( num < 0 ? num - offset : num + offset ) / denom; num = a2 * c1 - a1 * c2; result.y = ( num < 0 ? num - offset : num + offset ) / denom; return result; }, /** * Returns intersection point of the rectangle defined by * left, top, right, bottom and a line starting in x1, y1 * and ending in x2, y2; */ intersectRect : function(left, top, right, bottom, x1, y1, x2, y2) { return this.intersect(left, top, left, bottom, x1, y1, x2, y2) || this.intersect(left, bottom, right, bottom, x1, y1, x2, y2) || this.intersect(right, bottom, right, top, x1, y1, x2, y2) || this.intersect(right, top, left, top, x1, y1, x2, y2); } }; };/** * @fileOverview Contains definition of the core graph object. * * @author Andrei Kashcha (aka anvaka) / http://anvaka.blogspot.com */ /*global Viva*/ /** * @namespace Represents a graph data structure. * * @example * var g = Viva.Graph.graph(); * g.addNode(1); // g has one node. * g.addLink(2, 3); // now g contains three nodes and one link. * */ Viva.Graph.graph = function() { // Graph structure is maintained as dictionary of nodes // and array of links. Each node has 'links' property which // hold all links related to that node. And general links // array is used to speed up all links enumeration. This is inefficient // in terms of memory, but simplifies coding. Furthermore, the graph structure // is isolated from outter world, and can be changed to adjacency matrix later. var nodes = {}, links = [], nodesCount = 0, suspendEvents = 0, // Accumlates all changes made during graph updates. // Each change element contains: // changeType - one of the strings: 'add', 'remove' or 'update'; // node - if change is related to node this property is set to changed graph's node; // link - if change is related to link this property is set to changed graph's link; changes = [], fireGraphChanged = function(graph){ // TODO: maybe we shall copy changes? graph.fire('changed', changes); }, // Enter, Exit Mofidication allows bulk graph updates without firing events. enterModification = function(graph){ suspendEvents += 1; }, exitModification = function(graph){ suspendEvents -= 1; if (suspendEvents === 0 && changes.length > 0){ fireGraphChanged(graph); changes.length = 0; } }, recordNodeChange = function(node, changeType){ // TODO: Could add changeType verification. changes.push( {node : node, changeType : changeType} ); }, recordLinkChange = function(link, changeType){ // TODO: Could add change type verification; changes.push( {link : link, changeType : changeType} ); }, isArray = function (value) { return value && typeof value === 'object' && typeof value.length === 'number' && typeof value.splice === 'function' && !(value.propertyIsEnumerable('length')); }; /** @scope Viva.Graph.graph */ var graphPart = { /** * Adds node to the graph. If node with given id already exists in the graph * its data is extended with whatever comes in 'data' argument. * * @param nodeId the node's identifier. A string is preferred. * @param [data] additional data for the node being added. If node already * exists its data object is augmented with the new one. * * @return {node} The newly added node or node with given id if it already exists. */ addNode : function(nodeId, data) { if( typeof nodeId === 'undefined') { throw { message: 'Invalid node identifier' }; } enterModification(); var node = this.getNode(nodeId); if(!node) { node = {}; node.links = []; node.id = nodeId; nodesCount++; recordNodeChange(node, 'add'); } else { recordNodeChange(node, 'update'); } if(data) { var augmentedData = node.data || {}; if (typeof data === 'string' || isArray(data)) { augmentedData = data; } else { for(var name in data) { // TODO: Do we want to copy everything, including prototype's properties? if (data.hasOwnProperty(name)){ augmentedData[name] = data[name]; } } } node.data = augmentedData; } nodes[nodeId] = node; exitModification(this); return node; }, /** * Adds a link to the graph. The function always create a new * link between two nodes. If one of the nodes does not exists * a new node is created. * * @param fromId link start node id; * @param toId link end node id; * @param [data] additional data to be set on the new link; * * @return {link} The newly created link */ addLink : function(fromId, toId, data) { enterModification(); // It is not oriented graph. Probably have to rethink this. var fromNode = this.getNode(fromId) || this.addNode(fromId); var toNode = this.getNode(toId) || this.addNode(toId); var link = { fromId : fromId, toId : toId, data : data }; links.push(link); // TODO: this is not cool. On large graphs potentially would consume more memory. fromNode.links.push(link); toNode.links.push(link); recordLinkChange(link, 'add'); exitModification(this); return link; }, /** * Removes link from the graph. If link does not exist does nothing. * * @param link - object returned by addLink() or getLinks() methods. * * @returns true if link was removed; false otherwise. */ removeLink : function(link) { if (!link) { return false; } var idx = Viva.Graph.Utils.indexOfElementInArray(link, links); if (idx < 0) { return false; } enterModification(); links.splice(idx, 1); var fromNode = this.getNode(link.fromId); var toNode = this.getNode(link.toId); if (fromNode) { idx = Viva.Graph.Utils.indexOfElementInArray(link, fromNode.links); if (idx >= 0) { fromNode.links.splice(idx, 1); } } if (toNode) { idx = Viva.Graph.Utils.indexOfElementInArray(link, toNode.links); if (idx >= 0) { toNode.links.splice(idx, 1); } } recordLinkChange(link, 'remove'); exitModification(this); return true; }, /** * Removes node with given id from the graph. If node does not exist in the graph * does nothing. * * @param nodeId node's identifier passed to addNode() function. * * @returns true if node was removed; false otherwise. */ removeNode: function(nodeId) { var node = this.getNode(nodeId); if (!node) { return false; } enterModification(); while(node.links.length){ var link = node.links[0]; this.removeLink(link); } nodes[nodeId] = null; delete nodes[nodeId]; nodesCount--; recordNodeChange(node, 'remove'); exitModification(this); }, /** * Gets node with given identifier. If node does not exist undefined value is returned. * * @param nodeId requested node identifier; * * @return {node} in with requested identifier or undefined if no such node exists. */ getNode : function(nodeId) { return nodes[nodeId]; }, /** * Gets number of nodes in this graph. * * @return number of nodes in the graph. */ getNodesCount : function() { return nodesCount; }, /** * Gets total number of links in the graph. */ getLinksCount : function() { return links.length; }, /** * Gets all links (inbound and outbound) from the node with given id. * If node with given id is not found null is returned. * * @param nodeId requested node identifier. * * @return Array of links from and to requested node if such node exists; * otherwise null is returned. */ getLinks : function(nodeId) { var node = this.getNode(nodeId); return node ? node.links : null; }, /** * Invokes callback on each node of the graph. * * @param {Function(node)} callback Function to be invoked. The function * is passed one argument: visited node. */ forEachNode : function(callback) { if( typeof callback !== 'function') { return; } // TODO: could it be faster for nodes iteration if we had indexed access? // I.e. use array + 'for' iterator instead of dictionary + 'for .. in'? for(var node in nodes) { // For performance reasons you might want to sacrifice this sanity check: if(nodes.hasOwnProperty(node)) { callback(nodes[node]); } } }, /** * Invokes callback on every linked (adjacent) node to the given one. * * @param nodeId Identifier of the requested node. * @param {Function(node, link)} callback Function to be called on all linked nodes. * The function is passed two parameters: adjacent node and link object itself. */ forEachLinkedNode : function(nodeId, callback) { var node = this.getNode(nodeId); if(node && node.links && typeof callback === 'function') { for(var i = 0; i < node.links.length; ++i) { var link = node.links[i]; var linkedNodeId = link.fromId === nodeId ? link.toId : link.fromId; callback(nodes[linkedNodeId], link); } } }, /** * Enumerates all links in the graph * * @param {Function(link)} callback Function to be called on all links in the graph. * The function is passed one parameter: graph's link object. * * Link object contains at least the following fields: * fromId - node id where link starts; * toId - node id where link ends, * data - additional data passed to graph.addLink() method. */ forEachLink : function(callback) { if( typeof callback === 'function') { for(var i = 0; i < links.length; ++i) { callback(links[i]); } } }, /** * Suspend all notifications about graph changes until * endUpdate is called. */ beginUpdate : function() { enterModification(); }, /** * Resumes all notifications about graph changes and fires * graph 'changed' event in case there are any pending changes. */ endUpdate : function() { exitModification(this); }, /** * Removes all nodes and links from the graph. */ clear : function(){ var that = this; that.beginUpdate(); that.forEachNode(function(node){ that.removeNode(node.id); }); that.endUpdate(); }, /** * Detects whether there is a link between two nodes. * Operation complexity is O(n) where n - number of links of a node. * * @returns link if there is one. null otherwise. */ hasLink : function(fromNodeId, toNodeId) { // TODO: Use adjacency matrix to speed up this operation. var node = this.getNode(fromNodeId); if (!node) { return null; } for (var i = 0; i < node.links.length; ++i) { var link = node.links[i]; if (link.fromId === fromNodeId && link.toId === toNodeId) { return link; } } return null; // no link. } }; // Let graph fire events before we return it to the caller. Viva.Graph.Utils.events(graphPart).extend(); return graphPart; }; /** * @fileOverview Contains collection of graph generators. * * @author Andrei Kashcha (aka anvaka) / http://anvaka.blogspot.com */ /*global Viva*/ Viva.Graph.generator = function() { return { /** * Generates complete graph Kn. * * @param n represents number of nodes in the complete graph. */ complete : function(n) { if(!n || n < 1) { throw { message: 'At least two nodes expected for complete graph' }; } var g = Viva.Graph.graph(); g.Name = "Complete K" + n; for(var i = 0; i < n; ++i) { for(var j = i + 1; j < n; ++j) { if(i !== j) { g.addLink(i, j); } } } return g; }, /** * Generates complete bipartite graph K n,m. Each node in the * first partition is connected to all nodes in the second partition. * * @param n represents number of nodes in the first graph partition * @param m represents number of nodes in the second graph partition */ completeBipartite : function(n, m){ if(!n || !m || n < 0 || m < 0) { throw { message: 'Graph dimensions are invalid. Number of nodes in each partition should be greate than 0' }; } var g = Viva.Graph.graph(); g.Name = "Complete K " + n + "," + m; for(var i = 0; i < n; ++i){ for(var j = n; j < n + m; ++j){ g.addLink(i, j); } } return g; }, /** * Generates a graph in a form of a ladder with n steps. * * @param n number of steps in the ladder. */ ladder : function(n) { if(!n || n < 0) { throw { message: 'Invalid number of nodes' }; } var g = Viva.Graph.graph(); g.Name = "Ladder graph " + n; for(var i = 0; i < n - 1; ++i) { g.addLink(i, i + 1); // first row g.addLink(n + i, n + i + 1); // second row g.addLink(i, n + i); // ladder's step } g.addLink(n - 1, 2 * n - 1); // last step in the ladder; return g; }, /** * Generates a graph in a form of a circular ladder with n steps. * * @param n number of steps in the ladder. */ circularLadder : function(n){ if(!n || n < 0) { throw { message: 'Invalid number of nodes' }; } var g = this.ladder(n); g.Name = "Circular ladder graph " + n; g.addLink(0, n - 1); g.addLink(n, 2 * n - 1); return g; }, /** * Generates a graph in a form of a grid with n rows and m columns. * * @param n number of rows in the graph. * @param m number of columns in the graph. */ grid: function(n, m){ var g = Viva.Graph.graph(); g.Name = "Grid graph " + n + "x" + m; for(var i = 0; i < n; ++i){ for (var j = 0; j < m; ++j){ var node = i + j * n; if (i > 0) { g.addLink(node, i - 1 + j * n); } if (j > 0) { g.addLink(node, i + (j - 1) * n); } } } return g; }, path: function(n){ if(!n || n < 0) { throw { message: 'Invalid number of nodes' }; } var g = Viva.Graph.graph(); g.Name = "Path graph " + n; g.addNode(0); for(var i = 1; i < n; ++i){ g.addLink(i - 1, i); } return g; }, lollipop: function(m, n){ if(!n || n < 0 || !m || m < 0) { throw { message: 'Invalid number of nodes' }; } var g = this.complete(m); g.Name = "Lollipop graph. Head x Path " + m + "x" + n; for(var i = 0; i < n; ++i){ g.addLink(m + i - 1, m + i); } return g; }, /** * Creates balanced binary tree with n levels. */ balancedBinTree: function (n){ var g = Viva.Graph.graph(); g.Name = "Balanced bin tree graph " + n; var count = Math.pow(2, n); for(var level = 1; level < count; ++level){ var root = level; var left = root * 2; var right = root * 2 + 1; g.addLink(root, left); g.addLink(root, right); } return g; }, /** * Generates a graph with n nodes and 0 links. * * @param n number of nodes in the graph. */ randomNoLinks : function(n){ if(!n || n < 0) { throw { message: 'Invalid number of nodes' }; } var g = Viva.Graph.graph(); g.Name = "Random graph, no Links: " + n; for(var i = 0; i < n; ++i){ g.addNode(i); } return g; } }; }; /*global Viva*/ Viva.Graph.Physics = Viva.Graph.Physics || {}; Viva.Graph.Physics.Vector = function(x, y){ this.x = x || 0; this.y = y || 0; }; Viva.Graph.Physics.Vector.prototype = { multiply : function(scalar){ return new Viva.Graph.Physics.Vector(this.x * scalar, this.y * scalar); } }; Viva.Graph.Physics.Point = function(x, y) { this.x = x || 0; this.y = y || 0; }; Viva.Graph.Physics.Point.prototype = { add : function(point){ return new Viva.Graph.Physics.Point(this.x + point.x, this.y + point.y); } }; Viva.Graph.Physics.Body = function(){ this.mass = 1; this.force = new Viva.Graph.Physics.Vector(); this.velocity = new Viva.Graph.Physics.Vector(); // For chained call use vel() method. this.location = new Viva.Graph.Physics.Point(); // For chained calls use loc() method instead. this.prevLocation = new Viva.Graph.Physics.Point(); // TODO: might be not always needed }; Viva.Graph.Physics.Body.prototype = { loc : function(location){ if (location){ this.location.x = location.x; this.location.y = location.y; return this; } else { return this.location; } }, vel : function(velocity) { if (velocity){ this.velocity.x = velocity.x; this.velocity.y = velocity.y; return this; } else { return this.velocity; } } }; Viva.Graph.Physics.Spring = function(body1, body2, length, coeff){ this.body1 = body1; this.body2 = body2; this.length = length; this.coeff = coeff; }; Viva.Graph.Physics.QuadTreeNode = function(){ this.centerOfMass = new Viva.Graph.Physics.Point(); this.children = []; this.body = null; this.hasChildren = false; this.x1 = 0; this.y1 = 0; this.x2 = 0; this.y2 = 0; }; /*global Viva*/ Viva.Graph.Physics = Viva.Graph.Physics || {}; /** * Updates velocity and position data using the 4th order * Runge-Kutta method (RK4). It is slower but more accurate * than other techniques (such as Euler's method). * The method requires reevaluating forces 4 times for a given step. * * http://en.wikipedia.org/wiki/Runge%E2%80%93Kutta_methods */ Viva.Graph.Physics.rungeKuttaIntegrator = function() { var ensureRk4Initialized = function(forceSimulator) { // Sanity check if(!forceSimulator || !forceSimulator.bodies) { throw { message : 'Simulator does not have defined bodies array' }; } if(!forceSimulator.rk4) { // Init storage for interm steps of RK4. for(var i = 0; i < forceSimulator.bodies.length; i++) { var body = forceSimulator.bodies[i]; body.rgkDataV = []; body.rgkDataF = []; } forceSimulator.rk4 = true; } }; return { setSimulator : function(forceSimulator) { }, integrate : function(forceSimulator, timeStep) { ensureRk4Initialized(forceSimulator); // TODO: if number of bodies changed we might get into troubles here var speedLimit = forceSimulator.speedLimit, ar, vx, vy, v, coeff, body, location, i, max = forceSimulator.bodies.length; for(i = 0; i < max; ++i) { body = forceSimulator.bodies[i]; coeff = timeStep / body.mass; body.prevLocation.x = body.location.x; body.prevLocation.y = body.location.y; // I would love to have more expressive syntax here // rather than all these operations inlined, but // from performance perspective this should be better. body.rgkDataV[0] = { x : timeStep * body.velocity.x, y : timeStep * body.velocity.y }; body.rgkDataF[0] = { x : body.force.x * coeff, y : body.force.y * coeff }; location = body.prevLocation; body.loc({ x : location.x + 0.5 * body.rgkDataV[0].x, y : location.y + 0.5 * body.rgkDataV[0].y }); } forceSimulator.accumulate(); for( i = 0; i < max; i++) { body = forceSimulator.bodies[i]; coeff = timeStep / body.mass; // Had to expand these operations. I'm really scared about performance here. vx = body.velocity.x + 0.5 * body.rgkDataF[0].x; vy = body.velocity.y + 0.5 * body.rgkDataF[0].y; v = Math.sqrt(vx * vx + vy * vy); if(v > speedLimit) { vx = speedLimit * vx / v; vy = speedLimit * vy / v; } body.rgkDataV[1] = { x : timeStep * vx, y : timeStep * vy }; body.rgkDataF[1] = { x : body.force.x * coeff, y : body.force.y * coeff }; location = body.prevLocation; body.loc({ x : location.x + 0.5 * body.rgkDataV[1].x, y : location.y + 0.5 * body.rgkDataV[1].y }); } forceSimulator.accumulate(); for( i = 0; i < max; i++) { body = forceSimulator.bodies[i]; coeff = timeStep / body.mass; vx = body.velocity.x + 0.5 * body.rgkDataF[1].x; vy = body.velocity.y + 0.5 * body.rgkDataF[1].y; v = Math.sqrt(vx * vx + vy * vy); if(v > speedLimit) { vx = speedLimit * vx / v; vy = speedLimit * vy / v; } body.rgkDataV[2] = { x : timeStep * vx, y : timeStep * vy }; body.rgkDataF[2] = { x : body.force.x * coeff, y : body.force.y * coeff }; location = body.prevLocation; body.loc({ x : location.x + 0.5 * body.rgkDataV[2].x, y : location.y + 0.5 * body.rgkDataV[2].y }); } forceSimulator.accumulate(); var tx = 0, ty = 0; for( i = 0; i < max; i++) { body = forceSimulator.bodies[i]; coeff = timeStep / body.mass; vx = body.velocity.x + body.rgkDataF[2].x; vy = body.velocity.y + body.rgkDataF[2].y; v = Math.sqrt(vx * vx + vy * vy); if(v > speedLimit) { vx = speedLimit * vx / v; vy = speedLimit * vy / v; } body.rgkDataV[3] = { x : timeStep * vx, y : timeStep * vy }; body.rgkDataF[3] = { x : body.force.x * coeff, y : body.force.y * coeff }; location = body.prevLocation; var rgkDataV = body.rgkDataV; body.loc({ x : location.x + (rgkDataV[0].x + rgkDataV[3].x) / 6 + (rgkDataV[1].x + rgkDataV[2].x) / 3, y : location.y + (rgkDataV[0].y + rgkDataV[3].y) / 6 + (rgkDataV[1].y + rgkDataV[2].y) / 3 }); var rgkDataF = body.rgkDataF; vx = (rgkDataF[0].x + rgkDataF[3].x) / 6 + (rgkDataF[1].x + rgkDataF[2].x) / 3; vy = (rgkDataF[0].y + rgkDataF[3].y) / 6 + (rgkDataF[1].y + rgkDataF[2].y) / 3; v = Math.sqrt(vx * vx + vy * vy); if(v > speedLimit) { vx = speedLimit * vx / v; vy = speedLimit * vy / v; } tx += vy; ty += vy; // not quite right; should be distances, to comply with eulerIntegrator, but not sure whether I need this anyway body.velocity.x += vx; body.velocity.y += vy; } return tx * tx + ty * ty; } }; }; /*global Viva*/ Viva.Graph.Physics = Viva.Graph.Physics || {}; /** * Updates velocity and position data using the Euler's method. * It is faster than RK4 but may produce less accurate results. * * http://en.wikipedia.org/wiki/Euler_method */ Viva.Graph.Physics.eulerIntegrator = function() { return { /** * Performs forces integration, using given timestep and force simulator. * * @returns squared distance of total position updates. */ integrate : function(simulator, timeStep){ var speedLimit = simulator.speedLimit, tx = 0, ty = 0; for(var i = 0, max = simulator.bodies.length; i < max; ++i){ var body = simulator.bodies[i]; var coeff = timeStep / body.mass; body.velocity.x += coeff * body.force.x; body.velocity.y += coeff * body.force.y; var vx = body.velocity.x; var vy = body.velocity.y; var v = Math.sqrt(vx * vx + vy * vy); if (v > speedLimit){ body.velocity.x = speedLimit * vx / v; body.velocity.y = speedLimit * vy / v; } tx = timeStep * body.velocity.x; ty = timeStep * body.velocity.y; body.location.x += tx; body.location.y += ty; } return tx * tx + ty * ty; } }; }; /*global Viva*/ /** * This is Barnes Hut simulation algorithm. Implementation * is adopted to non-recursive solution, since certain browsers * handle recursion extremly bad. * * http://www.cs.princeton.edu/courses/archive/fall03/cs126/assignments/barnes-hut.html */ Viva.Graph.Physics.nbodyForce = function(options) { options = options || {}; var gravity = typeof options.gravity === 'number' ? options.gravity : -1, theta = options.theta || 0.8, Node = function() { this.body = null; this.quads = []; this.mass = 0; this.massX = 0; this.massY = 0; this.left = 0; this.top = 0; this.bottom = 0; this.right = 0; this.isInternal = false; }; var nodesCache = [], currentInCache = 0, newNode = function() { // To avoid pressure on GC we reuse nodes. var node; if(nodesCache[currentInCache]) { node = nodesCache[currentInCache]; node.quads[0] = null; node.quads[1] = null; node.quads[2] = null; node.quads[3] = null; node.body = null; node.mass = node.massX = node.massY = 0; node.left = node.right = node.top = node.bottom = 0; node.isInternal = false; } else { node = new Node(); nodesCache[currentInCache] = node; } ++currentInCache; return node; }, root = newNode(); var isSamePosition = function(point1, point2) { // TODO: Consider inlining. var dx = Math.abs(point1.x - point2.x); var dy = Math.abs(point1.y - point2.y); return (dx < 0.01 && dy < 0.01); }, // Inserts body to the tree insert = function(newBody) { // TODO: Consider reusing queue's elements if GC hit shows up. var queue = [{ node : root, body : newBody }]; while(queue.length) { var queueItem = queue.shift(), node = queueItem.node, body = queueItem.body; if(node.isInternal) { // This is internal node. Update the total mass of the node and center-of-mass. var x = body.location.x; var y = body.location.y; node.mass = node.mass + body.mass; node.massX = node.massX + body.mass * x; node.massY = node.massY + body.mass * y; // Recursively insert the body in the appropriate quadrant. // But first find the appropriate quadrant. var quadIdx = 0, // Assume we are in the 0's quad. left = node.left, right = (node.right + left) / 2, top = node.top, bottom = (node.bottom + top) / 2; if(x > right) {// somewhere in the eastern part. quadIdx = quadIdx + 1; var oldLeft = left; left = right; right = right + (right - oldLeft); } if(y > bottom) {// and in south. quadIdx = quadIdx + 2; var oldTop = top; top = bottom; bottom = bottom + (bottom - oldTop); } var child = node.quads[quadIdx]; if(!child) { // The node is internal but this quadrant is not taken. Add // subnode to it. child = newNode(); child.left = left; child.top = top; child.right = right; child.bottom = bottom; node.quads[quadIdx] = child; } // proceed search in this quadrant. queue.unshift({ node : child, body : body }); } else if(node.body) { // We are trying to add to the leaf node. // To do this we have to convert current leaf into internal node // and continue adding two nodes. var oldBody = node.body; node.body = null; // internal nodes does not cary bodies node.isInternal = true; if(isSamePosition(oldBody.location, body.location)) { // Prevent infinite subdivision by bumping one node // slightly. I assume this operation should be quite // rare, that's why usage of cos()/sin() shouldn't hit performance. var newX, newY; do { var angle = 2 * Math.random() * Math.PI; var dx = (node.right - node.left) * 0.006 * Math.cos(angle); var dy = (node.bottom - node.top) * 0.006 * Math.sin(angle); newX = oldBody.location.x + dx; newY = oldBody.location.y + dy; // Make sure we don't bump it out of the box. If we do, next iteration should fix it } while(newX < node.left || newX > node.right || newY < node.top || newY > node.bottom); oldBody.location.x = newX; oldBody.location.y = newY; } // Next iteration should subdivide node further. queue.unshift({ node : node, body : oldBody }); queue.unshift({ node : node, body : body }); } else { // Node has no body. Put it in here. node.body = body; } } }, update = function(sourceBody){ var queue = [root], v, dx, dy, r; // TODO: looks like in rare cases this guy has infinite loop bug. To reproduce // render K1000 (complete(1000)) with the settings: {springLength : 3, springCoeff : 0.0005, // dragCoeff : 0.02, gravity : -1.2 } while(queue.length){ var node = queue.shift(), body = node.body; if (body && body !== sourceBody){ // If the current node is an external node (and it is not source body), // calculate the force exerted by the current node on body, and add this // amount to body's net force. dx = body.location.x - sourceBody.location.x; dy = body.location.y - sourceBody.location.y; r = Math.sqrt(dx * dx + dy * dy); if (r === 0){ // Poor man's protection agains zero distance. dx = (Math.random() - 0.5) / 50; dy = (Math.random() - 0.5) / 50; r = Math.sqrt(dx * dx + dy * dy); } // This is standard gravition force calculation but we divide // by r^3 to save two operations when normalizing force vector. v = gravity * body.mass * sourceBody.mass / (r * r * r); sourceBody.force.x = sourceBody.force.x + v * dx; sourceBody.force.y = sourceBody.force.y + v * dy; } else { // Otherwise, calculate the ratio s / r, where s is the width of the region // represented by the internal node, and r is the distance between the body // and the node's center-of-mass dx = node.massX / node.mass - sourceBody.location.x; dy = node.massY / node.mass - sourceBody.location.y; r = Math.sqrt(dx * dx + dy * dy); if (r === 0){ // Sorry about code duplucation. I don't want to create many functions // right away. Just want to see performance first. dx = (Math.random() - 0.5) / 50; dy = (Math.random() - 0.5) / 50; r = Math.sqrt(dx * dx + dy * dy); } // If s / r < θ, treat this internal node as a single body, and calculate the // force it exerts on body b, and add this amount to b's net force. if ( (node.right - node.left) / r < theta){ // in the if statement above we consider node's width only // because the region was sqaurified during tree creation. // Thus there is no difference between using width or height. v = gravity * node.mass * sourceBody.mass / (r * r * r); sourceBody.force.x = sourceBody.force.x + v * dx; sourceBody.force.y = sourceBody.force.y + v * dy; } else { // Otherwise, run the procedure recursively on each of the current node's children. // I intentionally unfolded this loop, to save several CPU cycles. if (node.quads[0]) { queue.push(node.quads[0]); } if (node.quads[1]) { queue.push(node.quads[1]); } if (node.quads[2]) { queue.push(node.quads[2]); } if (node.quads[3]) { queue.push(node.quads[3]); } } } } }, init = function(forceSimulator){ var x1 = Number.MAX_VALUE, y1 = Number.MAX_VALUE, x2 = Number.MIN_VALUE, y2 = Number.MIN_VALUE, i, bodies = forceSimulator.bodies, max = bodies.length; // To reduce quad tree depth we are looking for exact bounding box of all particles. i = max; while(i--) { var x = bodies[i].location.x; var y = bodies[i].location.y; if(x < x1) { x1 = x; } if(x > x2) { x2 = x; } if(y < y1) { y1 = y; } if(y > y2) { y2 = y; } } // Squarify the bounds. var dx = x2 - x1, dy = y2 - y1; if (dx > dy) { y2 = y1 + dx; } else { x2 = x1 + dy; } currentInCache = 0; root = newNode(); root.left = x1; root.right = x2; root.top = y1; root.bottom = y2; i = max; while(i--) { insert(bodies[i], root); } }; return { insert : insert, init : init, update : update, options : function(newOptions){ if (newOptions){ if (typeof newOptions.gravity === 'number') { gravity = newOptions.gravity; } if (typeof newOptions.theta === 'number') { theta = newOptions.theta; } return this; } else { return {gravity : gravity, theta : theta}; } } }; }; /** * Brute force approach to nbody force calculation with O(n^2) performance. * I implemented it only to assist in finding bugs in Barnes Hut implementation. * This force is not intended to be used anywhere and probably weill be removed * in future. */ Viva.Graph.Physics.nbodyForceBrute = function(options) { options = options || {}; var gravity = typeof options.gravity === 'number' ? options.gravity : -1; var bodies = []; var update = function(sourceBody){ sourceBody.force.x = 0; sourceBody.force.y = 0; for(var i = 0; i < bodies.length; ++i){ var body = bodies[i]; if (body !== sourceBody){ var dx = body.location.x - sourceBody.location.x; var dy = body.location.y - sourceBody.location.y; var r = Math.sqrt(dx * dx + dy * dy); if (r === 0){ // Poor man's protection agains zero distance. dx = (Math.random() - 0.5) / 50; dy = (Math.random() - 0.5) / 50; r = Math.sqrt(dx * dx + dy * dy); } // This is standard gravition force calculation but we divide // by r^3 to save two operations when normalizing force vector. var v = gravity * body.mass * sourceBody.mass / (r * r * r); sourceBody.force.x = sourceBody.force.x + v * dx; sourceBody.force.y = sourceBody.force.y + v * dy; } } }; return { insert : function(){}, init : function(forceSimulator){ bodies = forceSimulator.bodies; }, update : update, options : function(newOptions){ if (newOptions){ if (typeof newOptions.gravity === 'number') { gravity = newOptions.gravity; } return this; } else { return {gravity : gravity}; } } }; };/*global Viva*/ Viva.Graph.Physics.dragForce = function(options){ options = options || {}; var currentOptions = { coeff : options.coeff || 0.01 }; return { init : function(forceSimulator) {}, update : function(body){ body.force.x -= currentOptions.coeff * body.velocity.x; body.force.y -= currentOptions.coeff * body.velocity.y; }, options : function(newOptions){ if (newOptions){ if (typeof newOptions.coeff === 'number') { currentOptions.coeff = newOptions.coeff; } return this; } else { return currentOptions; } } }; }; /*global Viva*/ Viva.Graph.Physics.springForce = function(options){ options = options || {}; var currentOptions = { length : options.length || 50, coeff : typeof options.coeff === 'number' ? options.coeff : 0.00022 }; return { init : function(forceSimulator) {}, update : function(spring){ var body1 = spring.body1; var body2 = spring.body2; var length = spring.length < 0 ? currentOptions.length : spring.length; var dx = body2.location.x - body1.location.x; var dy = body2.location.y - body1.location.y; var r = Math.sqrt(dx * dx + dy * dy); if (r === 0){ dx = (Math.random() - 0.5) / 50; dy = (Math.random() - 0.5) / 50; r = Math.sqrt(dx * dx + dy * dy); } var d = r - length; var coeff = ( (!spring.coeff || spring.coeff < 0) ? currentOptions.coeff : spring.coeff) * d / r; body1.force.x += coeff * dx; body1.force.y += coeff * dy; body2.force.x += -coeff * dx; body2.force.y += -coeff * dy; }, options : function(newOptions){ if (newOptions){ if (typeof newOptions.length === 'number') { currentOptions.length = newOptions.length; } if (typeof newOptions.coeff === 'number') { currentOptions.coeff = newOptions.coeff; } return this; } else { return currentOptions; } } }; }; /*global Viva*/ Viva.Graph.Physics = Viva.Graph.Physics || {}; /** * Manages a simulation of physical forces acting on bodies. * To create a custom force simulator register forces of the system * via addForce() method, choos appropriate integrator and register * bodies. * * // TODO: Show example. */ Viva.Graph.Physics.forceSimulator = function(forceIntegrator){ var integrator = forceIntegrator || Viva.Graph.Physics.rungeKuttaIntegrator(); var bodies = []; // Bodies in this simulation. var springs = []; // Springs in this simulation. var bodyForces = []; // Forces acting on bodies. var springForces = []; // Forces acting on springs. return { /** * The speed limit allowed by this simulator. */ speedLimit : 1.0, /** * Bodies in this simulation */ bodies : bodies, /** * Accumulates all forces acting on the bodies and springs. */ accumulate : function(){ var i, j, body; // Reinitialize all forces i = bodyForces.length; while(i--) { bodyForces[i].init(this); } i = springForces.length; while(i--){ springForces[i].init(this); } // Accumulate forces acting on bodies. i = bodies.length; while(i--){ body = bodies[i]; body.force.x = 0; body.force.y = 0; for (j=0; j < bodyForces.length; j++) { bodyForces[j].update(body); } } // Accumulate forces acting on springs. for(i = 0; i < springs.length; ++i){ for(j = 0; j < springForces.length; j++){ springForces[j].update(springs[i]); } } }, /** * Runs simulation for one time step. */ run : function(timeStep){ this.accumulate(); return integrator.integrate(this, timeStep); }, /** * Adds body to this simulation * * @param body - a new body. Bodies expected to have * mass, force, velocity, location and prevLocation properties. * the method does not check all this properties, for the sake of performance. * // TODO: maybe it should check it? */ addBody : function(body){ if (!body){ throw { message : 'Cannot add null body to force simulator' }; } bodies.push(body); // TODO: could mark simulator as dirty... return body; }, removeBody : function(body) { if (!body) { return false; } var idx = Viva.Graph.Utils.indexOfElementInArray(body, bodies); if (idx < 0) { return false; } return bodies.splice(idx, 1); }, /** * Adds a spring to this simulation. */ addSpring: function(body1, body2, springLength, springCoefficient){ if (!body1 || !body2){ throw { message : 'Cannot add null spring to force simulator' }; } if (typeof springLength !== 'number'){ throw { message : 'Spring length should be a number' }; } var spring = new Viva.Graph.Physics.Spring(body1, body2, springLength, springCoefficient >= 0 ? springCoefficient : -1); springs.push(spring); // TODO: could mark simulator as dirty. return spring; }, removeSpring : function(spring) { if (!spring) { return false; } var idx = Viva.Graph.Utils.indexOfElementInArray(spring, springs); if (idx < 0) { return false; } return springs.splice(idx, 1); }, /** * Adds a force acting on all bodies in this simulation */ addBodyForce: function(force){ if (!force){ throw { message : 'Cannot add mighty (unknown) force to the simulator' }; } bodyForces.push(force); }, /** * Adds a spring force acting on all springs in this simulation. */ addSpringForce : function(force){ if (!force){ throw { message : 'Cannot add unknown force to the simulator' }; } springForces.push(force); } }; };/*global Viva*/ Viva.Graph.Layout = Viva.Graph.Layout || {}; Viva.Graph.Layout.forceDirected = function(graph, userSettings) { var STABLE_THRESHOLD = 0.001; // Maximum movement of the system which can be considered as stabilized if(!graph) { throw { message : "Graph structure cannot be undefined" }; } userSettings = userSettings || {}; var settings = { /** * Ideal length for links (springs in physical model). */ springLength : typeof userSettings.springLength === 'number' ? userSettings.springLength : 80, /** * Hook's law coefficient. 1 - solid spring. */ springCoeff : typeof userSettings.springCoeff === 'number' ? userSettings.springCoeff : 0.0002, /** * Coulomb's law coefficient. It's used to repel nodes thus should be negative * if you make it positive nodes start attract each other :). */ gravity: typeof userSettings.gravity === 'number' ? userSettings.gravity : -1.2, /** * Theta coeffiecient from Barnes Hut simulation. Ranged between (0, 1). * The closer it's to 1 the more nodes algorithm will have to go through. * Setting it to one makes Barnes Hut simulation no different from * brute-force forces calculation (each node is considered). */ theta : typeof userSettings.theta === 'number' ? userSettings.theta : 0.8, /** * Drag force coefficient. Used to slow down system, thus should be less than 1. * The closer it is to 0 the less tight system will be. */ dragCoeff : typeof userSettings.dragCoeff === 'number' ? userSettings.dragCoeff : 0.02 }, forceSimulator = Viva.Graph.Physics.forceSimulator(Viva.Graph.Physics.eulerIntegrator()), nbodyForce = Viva.Graph.Physics.nbodyForce({gravity : settings.gravity, theta: settings.theta}), springForce = Viva.Graph.Physics.springForce({length : settings.springLength, coeff: settings.springCoeff }), dragForce = Viva.Graph.Physics.dragForce({coeff: settings.dragCoeff}), initializationRequired = true, graphRect = {x1: 0, y1 : 0, x2 : 0, y2 : 0}, rndNext = function rndNext(maxValue) { return Math.floor(Math.random() * (maxValue || 0xffffffff)); }, getBestNodePosition = function(node) { // TODO: Initial position could be picked better, e.g. take into // account all neighbouring nodes/links, not only one. // TODO: this is the same as in gem layout. consider refactoring. var baseX = (graphRect.x1 + graphRect.x2) / 2, baseY = (graphRect.y1 + graphRect.y2) / 2, springLength = settings.springLength; if (node.links && node.links.length > 0){ var firstLink= node.links[0], otherNode = firstLink.fromId != node.id ? graph.getNode(firstLink.fromId) : graph.getNode(firstLink.toId); if (otherNode.position){ baseX = otherNode.position.x; baseY = otherNode.position.y; } } return { x : baseX + rndNext(springLength) - springLength/2, y : baseY + rndNext(springLength) - springLength/2 }; }, updateNodeMass = function(node){ var body = node.force_directed_body; body.mass = 1 + graph.getLinks(node.id).length / 3.0; }, initNode = function(node) { var body = node.force_directed_body; if (!body){ // TODO: rename position to location or location to position to be consistent with // other places. node.position = node.position || getBestNodePosition(node); body = new Viva.Graph.Physics.Body(); node.force_directed_body = body; updateNodeMass(node); body.loc(node.position); forceSimulator.addBody(body); } }, releaseNode = function(node) { var body = node.force_directed_body; if (body) { node.force_directed_body = null; delete node.force_directed_body; forceSimulator.removeBody(body); } }, initLink = function(link) { // TODO: what if bodies are not initialized? var from = graph.getNode(link.fromId), to = graph.getNode(link.toId); updateNodeMass(from); updateNodeMass(to); link.force_directed_spring = forceSimulator.addSpring(from.force_directed_body, to.force_directed_body, -1.0); }, releaseLink = function(link) { var spring = link.force_directed_spring; if (spring) { var from = graph.getNode(link.fromId), to = graph.getNode(link.toId); if (from) { updateNodeMass(from); } if (to) { updateNodeMass(to); } link.force_directed_spring = null; delete link.force_directed_spring ; forceSimulator.removeSpring(spring); } }, initSimulator = function() { graph.forEachNode(initNode); graph.forEachLink(initLink); }, isNodePinned = function(node) { if(!node) { return true; } return node.isPinned || (node.data && node.data.isPinned); }, updateNodePositions = function(){ var x1 = Number.MAX_VALUE, y1 = Number.MAX_VALUE, x2 = Number.MIN_VALUE, y2 = Number.MIN_VALUE; if (graph.getNodesCount() === 0) { return ;} graph.forEachNode(function(node) { var body = node.force_directed_body; if (!body){ return; // TODO: maybe we shall initialize it? } if (isNodePinned(node)){ body.loc(node.position); } // TODO: once again: use one name to be consistent (position vs location) node.position.x = body.location.x; node.position.y = body.location.y; if (node.position.x < x1) { x1 = node.position.x; } if (node.position.x > x2) { x2 = node.position.x; } if (node.position.y < y1) { y1 = node.position.y; } if (node.position.y > y2) { y2 = node.position.y; } }); graphRect.x1 = x1; graphRect.x2 = x2; graphRect.y1 = y1; graphRect.y2 = y2; }; forceSimulator.addSpringForce(springForce); forceSimulator.addBodyForce(nbodyForce); forceSimulator.addBodyForce(dragForce); return { /** * Attempts to layout graph within given number of iterations. * * @param {integer} [iterationsCount] number of algorithm's iterations. */ run : function(iterationsCount) { iterationsCount = iterationsCount || 50; for(var i = 0; i < iterationsCount; ++i) { this.step(); } }, step : function() { // we assume graph was not modified between calls. If it was // we will have to reinitialize force simulator. if (initializationRequired) { initSimulator(); initializationRequired = false; } var energy = forceSimulator.run(20); updateNodePositions(); return energy < STABLE_THRESHOLD; }, /** * Returns rectangle structure {x1, y1, x2, y2}, which represents * current space occupied by graph. */ getGraphRect : function() { return graphRect; }, addNode : function(node) { initNode(node); }, removeNode : function(node) { releaseNode(node); }, addLink : function(link) { initLink(link); }, removeLink : function(link) { releaseLink(link); }, // Layout specific methods /** * Gets or sets current desired length of the edge. * * @param length new desired length of the springs (aka edge, aka link). * if this parameter is empty then old spring length is returned. */ springLength : function(length) { if (arguments.length === 1) { springForce.options({ length : length }); return this; } else { return springForce.options().length; } }, /** * Gets or sets current spring coeffiсient. * * @param coeff new spring coeffiсient. * if this parameter is empty then its old value returned. */ springCoeff : function(coeff) { if (arguments.length === 1) { springForce.options({ coeff : coeff }); return this; } else { return springForce.options().coeff; } }, /** * Gets or sets current gravity in the nbody simulation. * * @param g new gravity constant. * if this parameter is empty then its old value returned. */ gravity : function(g) { if (arguments.length === 1) { nbodyForce.options({ gravity : g }); return this; } else { return nbodyForce.options().gravity; } }, /** * Gets or sets current theta value in the nbody simulation. * * @param t new theta coeffiсient. * if this parameter is empty then its old value returned. */ theta : function(t) { if (arguments.length === 1) { nbodyForce.options({ theta : t }); return this; } else { return nbodyForce.options().theta; } }, /** * Gets or sets current theta value in the nbody simulation. * * @param dragCoeff new drag coeffiсient. * if this parameter is empty then its old value returned. */ drag : function(dragCoeff) { if (arguments.length === 1) { dragForce.options({ coeff : dragCoeff }); return this; } else { return dragForce.options().coeff; } } }; };/** * @fileOverview Implements GEM graph drawing algorithm. * * @see The * A fast adaptive layout algorithm for undirected graphs (abstract) and * * [PDF] from psu.edu * * @author Andrei Kashcha (aka anvaka) / http://anvaka.blogspot.com * * TODO: According to GEM hypothesis random node processing order during one iteration * leads to faster GEM convergence. Current implementation does not employ this techinque. * */ /*global Viva*/ Viva.Graph.Layout = Viva.Graph.Layout || {}; /** * Implements GEM graph drawing algorithm. * * @example * * var graphGenerator = Viva.Graph.generator(); * var graph = graphGenerator.complete(5); // Create complete graph K5 * * var gemLayout = Viva.Graph.Layout.gem(graph); * gemLayout.run(); * // Now every node of the graph has position.x and position.y * // pointing to 'nice' locations. */ Viva.Graph.Layout.gem = function(graph, customSettings) { if(!graph) { throw { message : "Graph structure cannot be undefined" }; } var settings = (function buildAlgorithmSettings(userSettings) { var finalSettings = { /** * Start temperature of every node. */ initialTemperature : userSettings.initialTemperature || 0.3, /** * Temperature of the system that we are trying to reach. */ stopTemperature : userSettings.stopTemperature || 0.02, maxIterations : userSettings.MaxIterations || 5, /** * Desired edge length. */ edgeLength : userSettings.springLength || 80, /** * Gravitational constant that is used to calculate attraction to center of gravity. * Used during node impulse calculation. */ gravitationalConstant : userSettings.gravitationalConstant || 0.05, /** * Maximum percentage of @EdgeLength that is allowed for random nodes movement. * Used during node impulse calculation. */ shakeDisturbance : userSettings.shakeDisturbance || 0.2, /** * Sensitivity towards oscillation. */ oscilationSensitivity : userSettings.oscilationSensitivity || 0.4, /** * Sensitivity towards rotation. */ rotationSensitivity : userSettings.rotationSensitivity || 0.9, minimalRotationTemperature : userSettings.minimalRotationTemperature || 2 }; // Max local temperature. finalSettings.maximalTemperature = userSettings.maximalTemperature || 1.5 * finalSettings.edgeLength; finalSettings.edgeLengthSquared = finalSettings.edgeLength * finalSettings.edgeLength; finalSettings.maxShakeOffset = finalSettings.shakeDisturbance * finalSettings.edgeLength; return finalSettings; })(customSettings || {}), // Sum of all nodes coordinates. Used to simplify barycenter computation. systemCenterX, systemCenterY, // Current temperature of the system. systemTemperature, graphRect = {x1: 0, y1 : 0, x2 : 0, y2 : 0}, initializationRequired = true, /** * Helper function to get random positive integer numbers. * * @param maxValue the biggest integer number */ rndNext = function (maxValue) { return Math.floor(Math.random() * (maxValue || 0xffffffff)); }, getBestNodePosition = function(node) { // TODO: Initial position could be picked better, like take into // account all neighbouring nodes/links, not only one. // TODO: this is the same as in force based layout. consider refactoring. var baseX = (graphRect.x1 + graphRect.x2) / 2, baseY = (graphRect.y1 + graphRect.y2) / 2, springLength = settings.edgeLength; if (node.links && node.links.length > 0){ var firstLink= node.links[0], otherNode = firstLink.fromId != node.id ? graph.getNode(firstLink.fromId) : graph.getNode(firstLink.toId); if (otherNode.position){ baseX = otherNode.position.x; baseY = otherNode.position.y; } } return { x : baseX + rndNext(springLength) - springLength/2, y : baseY + rndNext(springLength) - springLength/2 }; }, initGemNode = function(node) { node.position = node.position || getBestNodePosition(node); node.gemData = { // Current temperature of this node heat : settings.initialTemperature * settings.edgeLength, // Impulse X iX : 0, // Impulse Y iY : 0, skewGauge : 0, mass : 1 + graph.getLinks(node.id).length / 3.0 }; systemTemperature += node.gemData.heat * node.gemData.heat; systemCenterX += node.position.x; systemCenterY += node.position.y; }, releaseGemNode = function(node) { if (node.gemData) { delete node.gemData; } }, initSimulator = function () { systemTemperature = 0; systemCenterX = 0; systemCenterY = 0; graph.forEachNode(initGemNode); }, /** * Computes impulse of the given node. Runtime: O(N + m), N - number of graph nodes, m - number of linked nodes. * * @returns object {iX, iY}, with corresponding impulse values. */ computeImpulse = function(node) { var position = node.position; if(!position) { return ; // This is a new node. Wait untill renderer requests us to initialize it. } var edgeLengthSquared = settings.edgeLengthSquared; var nodeX = position.x; var nodeY = position.y; var gemNode = node.gemData; var nodesCount = graph.getNodesCount(); // Attraction to center of gravity: var impulseX = (systemCenterX / nodesCount - nodeX) * gemNode.mass * settings.gravitationalConstant; var impulseY = (systemCenterY / nodesCount - nodeY) * gemNode.mass * settings.gravitationalConstant; // Random disturbance: impulseX += rndNext() % (2 * settings.maxShakeOffset + 1) - settings.maxShakeOffset; impulseY += rndNext() % (2 * settings.maxShakeOffset + 1) - settings.maxShakeOffset; // +1 to exclude zero. // Repulsive forces graph.forEachNode(function(otherNode) { if(otherNode.id !== node.id) { var dx = nodeX - otherNode.position.x; var dy = nodeY - otherNode.position.y; var lenSqr = dx * dx + dy * dy; if(lenSqr > 0) { impulseX += dx * edgeLengthSquared / lenSqr; impulseY += dy * edgeLengthSquared / lenSqr; } } }); // Attractive forces graph.forEachLinkedNode(node.id, function(adjacentNode) { var dx = nodeX - adjacentNode.position.x; var dy = nodeY - adjacentNode.position.y; var lenSqr = dx * dx + dy * dy; impulseX -= dx * lenSqr / (edgeLengthSquared * node.gemData.mass); impulseY -= dy * lenSqr / (edgeLengthSquared * node.gemData.mass); }); return { iX : impulseX, iY : impulseY }; }, /** * Updates node's position, temperature and impulse. * Also detects possible rotations oscillations. Runtime is O(1). */ updatePositionAndTemperature = function(node, impulse) { var impulseX = impulse.iX, impulseY = impulse.iY, nodesCount = graph.getNodesCount(); if(impulseX === 0 && impulseY === 0) { return; // Impulse is negligible. } var scale = Math.max(Math.abs(impulseX), Math.abs(impulseY)) / settings.edgeLengthSquared; // Don't let impulse vector be longer than edge: if(scale > 1) { impulseX /= scale; impulseY /= scale; } var gemNode = node.gemData; // scale with current temperature: var impulseLength = Math.sqrt(impulseX * impulseX + impulseY * impulseY); var currentTemperature = gemNode.heat; impulseX = currentTemperature * impulseX / impulseLength; impulseY = currentTemperature * impulseY / impulseLength; node.position.x += impulseX; node.position.y += impulseY; // save the division at this point systemCenterX += impulseX; systemCenterY += impulseY; var nodeImpulse = currentTemperature * Math.sqrt(gemNode.iX * gemNode.iX + gemNode.iY * gemNode.iY); if(nodeImpulse > 0) { systemTemperature -= currentTemperature * currentTemperature; // Oscillation: currentTemperature += currentTemperature * settings.oscilationSensitivity * (impulseX * gemNode.iX + impulseY * gemNode.iY) / nodeImpulse; currentTemperature = Math.min(currentTemperature, settings.maximalTemperature); // Rotation: gemNode.skewGauge += settings.rotationSensitivity * (impulseX * gemNode.iY - impulseY * gemNode.iX) / nodeImpulse; currentTemperature -= currentTemperature * Math.abs(gemNode.skewGauge) / nodesCount; currentTemperature = Math.max(currentTemperature, settings.minimalRotationTemperature); systemTemperature += currentTemperature * currentTemperature; gemNode.heat = currentTemperature; } gemNode.iX = impulseX; gemNode.iY = impulseY; }; return { /** * Attempts to layout graph within given number of iterations. * * @param {integer} [iterationsCount] number of algorithm's iterations. */ run : function(iterationsCount) { iterationsCount = iterationsCount || 50; for(var i = 0; i < iterationsCount; ++i) { this.step(); } }, /** * Performs one iteration of the layout algorithm. Could be used to visualize the algorithm. * * Note: Gem is not well-suited for animation. It's fast but visualization of the process * is not very appealing. */ step : function() { if (initializationRequired){ initSimulator(); initializationRequired = false; } var nodesCount = graph.getNodesCount(), stopTemperature = settings.stopTemperature * settings.stopTemperature * settings.edgeLengthSquared * nodesCount, maxIteration = settings.maxIterations * nodesCount * nodesCount, x1 = Number.MAX_VALUE, y1 = Number.MAX_VALUE, x2 = Number.MIN_VALUE, y2 = Number.MIN_VALUE, that = this; if(systemTemperature < stopTemperature || nodesCount === 0) { return; } graph.forEachNode(function (node) { if(that.isNodePinned(node) || !node.gemData) { return; } var impulse = computeImpulse(node); updatePositionAndTemperature(node, impulse); if (node.position.x < x1) { x1 = node.position.x; } if (node.position.x > x2) { x2 = node.position.x; } if (node.position.y < y1) { y1 = node.position.y; } if (node.position.y > y2) { y2 = node.position.y; } }); graphRect.x1 = x1; graphRect.x2 = x2; graphRect.y1 = y1; graphRect.y2 = y2; }, /** * Determines whether or not given node should be considered by layout algorithm. * If node is "pinned" layout algorithm does not move it. * * @param node under question. Note: It is NOT an identifier of the node, but actual * object returned from graph.addNode() or graph.getNode() methods. */ isNodePinned : function(node) { if(!node) { return true; } return node.isPinned || (node.data && node.data.isPinned); }, /** * Returns rectangle structure {x1, y1, x2, y2}, which represents * current space occupied by graph. */ getGraphRect : function() { return graphRect; }, addNode : function(node) { initGemNode(node); }, removeNode : function(node) { releaseGemNode(node); }, addLink : function(link) { // NOP. Just for compliance with renderer; }, removeLink : function(link) { // NOP. Just for compliance with renderer; } }; }; /** * @fileOverview Defines a graph renderer that uses CSS based drawings. * * @author Andrei Kashcha (aka anvaka) / http://anvaka.blogspot.com */ /*global Viva*/ Viva.Graph.View = Viva.Graph.View || {}; /** * Performs css-based graph rendering. This module does not perform * layout, but only visualizes nodes and edeges of the graph. * * NOTE: Most likely I will remove this graphics engine due to superior svg support. * In certain cases it doesn't work and require further imporvments: * * does not properly work for dragging. * * does not support scaling. * * does not support IE versions prior to IE9. * */ Viva.Graph.View.cssGraphics = function() { var container, // Where graph will be rendered OLD_IE = 'OLD_IE', offsetX, offsetY, scaleX = 1, scaleY = 1, transformName = (function(){ var browserName = Viva.BrowserInfo.browser, prefix; switch (browserName) { case 'mozilla' : prefix = 'Moz'; break; case 'webkit' : prefix = 'webkit'; break; case 'opera' : prefix = 'O'; break; case 'msie' : var version = Viva.BrowserInfo.version.split(".")[0]; if(version > 8) { prefix = 'ms'; } else { return OLD_IE; } } if (prefix) { // CSS3 return prefix + 'Transform'; } else { // Unknown browser return null; } })(), /** * Returns a function (ui, x, y, angleRad). * * The function attempts to rotate 'ui' dom element on 'angleRad' radians * and position it to 'x' 'y' coordinates. * * Operation works in most modern browsers that support transform css style * and IE. * */ positionLink = (function() { if (transformName === OLD_IE) { // This is old IE, use filters return function(ui, x, y, angleRad) { var cos = Math.cos(angleRad); var sin = Math.sin(angleRad); // IE 6, 7 and 8 are screwed up when it comes to transforms; // I could not find justification for their choice of "floating" // matrix transform origin. The following ugly code was written // out of complete dispair. if(angleRad < 0) { angleRad = 2 * Math.PI + angleRad; } if(angleRad < Math.PI / 2) { ui.style.left = x + 'px'; ui.style.top = y + 'px'; } else if(angleRad < Math.PI) { ui.style.left = x - (ui.clientWidth) * Math.cos(Math.PI - angleRad); ui.style.top = y; } else if(angleRad < (Math.PI + Math.PI / 2)) { ui.style.left = x - (ui.clientWidth) * Math.cos(Math.PI - angleRad); ui.style.top = y + (ui.clientWidth) * Math.sin(Math.PI - angleRad); } else { ui.style.left = x; ui.style.top = y + ui.clientWidth * Math.sin(Math.PI - angleRad); } ui.style.filter = "progid:DXImageTransform.Microsoft.Matrix(sizingMethod='auto expand'," + "M11=" + cos + ", M12=" + (-sin) + "," + "M21=" + sin + ", M22=" + cos + ");"; }; } else if (transformName) { // Modern CSS3 browser return function(ui, x, y, angleRad) { ui.style.left = x + 'px'; ui.style.top = y + 'px'; ui.style[transformName] = 'rotate(' + angleRad + 'rad)'; ui.style[transformName + 'Origin'] = 'left'; }; } else { return function(ui, x, y, angleRad) { // Don't know how to rotate links in other browsers. }; } })(), nodeBuilder = function(node){ var nodeUI = document.createElement('div'); nodeUI.setAttribute('class', 'node'); return nodeUI; }, nodePositionCallback = function(nodeUI, pos) { // TODO: Remove magic 5. It should be half of the width or height of the node. nodeUI.style.left = pos.x - 5 + 'px'; nodeUI.style.top = pos.y - 5 + 'px'; }, linkPositionCallback = function(linkUI, fromPos, toPos) { var dx = fromPos.x - toPos.x; var dy = fromPos.y - toPos.y; var length = Math.sqrt(dx * dx + dy * dy); linkUI.style.height = '1px'; linkUI.style.width = length + 'px'; positionLink(linkUI, toPos.x, toPos.y, Math.atan2(dy, dx)); }, linkBuilder = function(link) { var linkUI = document.createElement('div'); linkUI.setAttribute('class', 'link'); return linkUI; }, updateTransform = function() { if (container) { if (transformName && transformName !== OLD_IE) { var transform = 'matrix(' + scaleX + ", 0, 0," + scaleY + "," + offsetX + "," + offsetY + ")"; container.style[transformName] = transform; } else { // TODO Implement OLD_IE Filter based transform } } }; return { /** * Sets the collback that creates node representation or creates a new node * presentation if builderCallbackOrNode is not a function. * * @param builderCallbackOrNode a callback function that accepts graph node * as a parameter and must return an element representing this node. OR * if it's not a function it's treated as a node to which DOM element should be created. * * @returns If builderCallbackOrNode is a valid callback function, instance of this is returned; * Otherwise a node representation is returned for the passed parameter. */ node : function(builderCallbackOrNode) { if (builderCallbackOrNode && typeof builderCallbackOrNode !== 'function'){ return nodeBuilder(builderCallbackOrNode); } nodeBuilder = builderCallbackOrNode; return this; }, /** * Sets the collback that creates link representation or creates a new link * presentation if builderCallbackOrLink is not a function. * * @param builderCallbackOrLink a callback function that accepts graph link * as a parameter and must return an element representing this link. OR * if it's not a function it's treated as a link to which DOM element should be created. * * @returns If builderCallbackOrLink is a valid callback function, instance of this is returned; * Otherwise a link representation is returned for the passed parameter. */ link : function(builderCallbackOrLink){ if (builderCallbackOrLink && typeof builderCallbackOrLink !== 'function'){ return linkBuilder(builderCallbackOrLink); } linkBuilder = builderCallbackOrLink; return this; }, /** * Sets translate operation that should be applied to all nodes and links. */ setInitialOffset : function(x, y) { offsetX = x; offsetY = y; updateTransform(); }, translateRel : function(dx, dy) { offsetX += dx; offsetY += dy; updateTransform(); }, scale : function(x, y) { // TODO: implement me return 1; }, resetScale : function(){ // TODO: implement me return this; }, /** * Allows to override default position setter for the node with a new * function. newPlaceCallback(node, position) is function which * is used by updateNode(). */ placeNode : function(newPlaceCallback){ nodePositionCallback = newPlaceCallback; return this; }, placeLink : function(newPlaceLinkCallback){ linkPositionCallback = newPlaceLinkCallback; return this; }, /** * Called by Viva.Graph.View.renderer to let concrete graphic output * providers prepare to render. */ init : function (parentContainer) { container = parentContainer; updateTransform(); }, /** * Called by Viva.Graph.View.renderer to let concrete graphic output * provider prepare to render given link of the graph * * @param linkUI visual representation of the link created by link() execution. */ initLink : function(linkUI) { if(container.childElementCount > 0) { container.insertBefore(linkUI, container.firstChild); } else { container.appendChild(linkUI); } }, /** * Called by Viva.Graph.View.renderer to let concrete graphic output * provider remove link from rendering surface. * * @param linkUI visual representation of the link created by link() execution. **/ releaseLink : function(linkUI) { container.removeChild(linkUI); }, /** * Called by Viva.Graph.View.renderer to let concrete graphic output * provider prepare to render given node of the graph. * * @param nodeUI visual representation of the node created by node() execution. **/ initNode : function(nodeUI) { container.appendChild(nodeUI); }, /** * Called by Viva.Graph.View.renderer to let concrete graphic output * provider remove node from rendering surface. * * @param nodeUI visual representation of the node created by node() execution. **/ releaseNode : function(nodeUI) { container.removeChild(nodeUI); }, /** * Called by Viva.Graph.View.renderer to let concrete graphic output * provider place given node to recommended position pos {x, y}; */ updateNodePosition : function(node, pos) { nodePositionCallback(node, pos); }, /** * Called by Viva.Graph.View.renderer to let concrete graphic output * provider place given link of the graph */ updateLinkPosition : function(link, fromPos, toPos) { linkPositionCallback(link, fromPos, toPos); } }; }; /** * @author Andrei Kashcha (aka anvaka) / http://anvaka.blogspot.com */ /*global Viva, window*/ /** * Simple wrapper over svg object model API, to shorten the usage syntax. */ Viva.Graph.svg = function(element) { var svgns = "http://www.w3.org/2000/svg", xlinkns = 'http://www.w3.org/1999/xlink', svgElement = element; if (typeof element === 'string') { svgElement = document.createElementNS(svgns, element); } if (svgElement.vivagraph_augmented) { return svgElement; } svgElement.vivagraph_augmented = true; // Augment svg element (TODO: it's not safe - what if some function already exists on the prototype?): /** * Gets an svg attribute from an element if value is not specified. * OR sets a new value to the given attribute. * * @param name - svg attribute name; * @param value - value to be set; * * @returns svg element if both name and value are specified; or value of the given attribute * if value parameter is missing. */ svgElement.attr = function(name, value) { if (arguments.length === 2) { svgElement.setAttributeNS(null, name, value); return svgElement; } else { return svgElement.getAttributeNS(null, name); } }; svgElement.append = function(element){ var child = Viva.Graph.svg(element); svgElement.appendChild(child); return child; }; svgElement.text = function(textContent){ if (typeof textContent == 'undefined') { return svgElement.textContent; } else { svgElement.textContent = textContent; } return svgElement; }; svgElement.link = function(target) { if (arguments.length === 0){ return svgElement.getAttributeNS(xlinkns, 'xlink:href'); } svgElement.setAttributeNS(xlinkns, 'xlink:href', target); return svgElement; }; return svgElement; }; /** * @fileOverview Defines a graph renderer that uses SVG based drawings. * * @author Andrei Kashcha (aka anvaka) / http://anvaka.blogspot.com */ /*global Viva*/ Viva.Graph.View = Viva.Graph.View || {}; /** * Performs svg-based graph rendering. This module does not perform * layout, but only visualizes nodes and edeges of the graph. */ Viva.Graph.View.svgGraphics = function() { var svgContainer, svgRoot, offsetX, offsetY, actualScale = 1, nodeBuilder = function(node){ return Viva.Graph.svg('rect') .attr('width', 10) .attr('height', 10) .attr('fill', '#00a2e8'); }, nodePositionCallback = function(nodeUI, pos){ // TODO: Remove magic 5. It should be halfo of the width or height of the node. nodeUI.attr("x", pos.x - 5) .attr("y", pos.y - 5); }, linkBuilder = function(link){ return Viva.Graph.svg('line') .attr('stroke', '#999'); }, linkPositionCallback = function(linkUI, fromPos, toPos){ linkUI.attr("x1", fromPos.x) .attr("y1", fromPos.y) .attr("x2", toPos.x) .attr("y2", toPos.y); }, updateTransform = function() { if (svgContainer) { var transform = 'matrix(' + actualScale + ", 0, 0," + actualScale + "," + offsetX + "," + offsetY + ")"; svgContainer.attr('transform', transform); } }; return { /** * Sets the collback that creates node representation or creates a new node * presentation if builderCallbackOrNode is not a function. * * @param builderCallbackOrNode a callback function that accepts graph node * as a parameter and must return an element representing this node. OR * if it's not a function it's treated as a node to which DOM element should be created. * * @returns If builderCallbackOrNode is a valid callback function, instance of this is returned; * Otherwise a node representation is returned for the passed parameter. */ node : function(builderCallbackOrNode) { if (builderCallbackOrNode && typeof builderCallbackOrNode !== 'function'){ return nodeBuilder(builderCallbackOrNode); } nodeBuilder = builderCallbackOrNode; return this; }, /** * Sets the collback that creates link representation or creates a new link * presentation if builderCallbackOrLink is not a function. * * @param builderCallbackOrLink a callback function that accepts graph link * as a parameter and must return an element representing this link. OR * if it's not a function it's treated as a link to which DOM element should be created. * * @returns If builderCallbackOrLink is a valid callback function, instance of this is returned; * Otherwise a link representation is returned for the passed parameter. */ link : function(builderCallbackOrLink) { if (builderCallbackOrLink && typeof builderCallbackOrLink !== 'function'){ return linkBuilder(builderCallbackOrLink); } linkBuilder = builderCallbackOrLink; return this; }, /** * Allows to override default position setter for the node with a new * function. newPlaceCallback(nodeUI, position) is function which * is used by updateNodePosition(). */ placeNode : function(newPlaceCallback) { nodePositionCallback = newPlaceCallback; return this; }, placeLink : function(newPlaceLinkCallback) { linkPositionCallback = newPlaceLinkCallback; return this; }, /** * Sets translate operation that should be applied to all nodes and links. */ setInitialOffset : function(x, y) { offsetX = x; offsetY = y; updateTransform(); }, translateRel : function(dx, dy) { var p = svgRoot.createSVGPoint(), t = svgContainer.getCTM(), origin = svgRoot.createSVGPoint().matrixTransform(t.inverse()); p.x = dx; p.y = dy; p = p.matrixTransform(t.inverse()); p.x = (p.x - origin.x) * t.a; p.y = (p.y - origin.y) * t.d; t.e += p.x; t.f += p.y; var transform = 'matrix(' + t.a + ", 0, 0," + t.d + "," + t.e + "," + t.f + ")"; svgContainer.attr('transform', transform); }, scale : function(scaleFactor, scrollPoint) { // scaleX = x; // scaleY = y; var p = svgRoot.createSVGPoint(); p.x = scrollPoint.x; p.y = scrollPoint.y; p = p.matrixTransform(svgContainer.getCTM().inverse()); // translate to svg coordinates // Compute new scale matrix in current mouse position var k = svgRoot.createSVGMatrix().translate(p.x, p.y).scale(scaleFactor).translate(-p.x, -p.y), t = svgContainer.getCTM().multiply(k); actualScale = t.a; offsetX = t.e; offsetY = t.f; var transform = 'matrix(' + t.a + ", 0, 0," + t.d + "," + t.e + "," + t.f + ")"; svgContainer.attr('transform', transform); return actualScale; }, resetScale : function(){ actualScale = 1; var transform = 'matrix(1, 0, 0, 1, 0, 0)'; svgContainer.attr('transform', transform); return this; }, /** * Called by Viva.Graph.View.renderer to let concrete graphic output * provider prepare to render. */ init : function(container) { svgRoot = Viva.Graph.svg("svg"); svgContainer = Viva.Graph.svg("g") .attr('shape-rendering', 'optimizeSpeed') .attr('buffered-rendering', 'dynamic'); svgRoot.appendChild(svgContainer); container.appendChild(svgRoot); updateTransform(); }, /** * Called by Viva.Graph.View.renderer to let concrete graphic output * provider prepare to render given link of the graph * * @param linkUI visual representation of the link created by link() execution. */ initLink : function(linkUI) { if(svgContainer.childElementCount > 0) { svgContainer.insertBefore(linkUI, svgContainer.firstChild); } else { svgContainer.appendChild(linkUI); } }, /** * Called by Viva.Graph.View.renderer to let concrete graphic output * provider remove link from rendering surface. * * @param linkUI visual representation of the link created by link() execution. **/ releaseLink : function(linkUI) { svgContainer.removeChild(linkUI); }, /** * Called by Viva.Graph.View.renderer to let concrete graphic output * provider prepare to render given node of the graph. * * @param nodeUI visual representation of the node created by node() execution. **/ initNode : function(nodeUI) { svgContainer.appendChild(nodeUI); }, /** * Called by Viva.Graph.View.renderer to let concrete graphic output * provider remove node from rendering surface. * * @param nodeUI visual representation of the node created by node() execution. **/ releaseNode : function(nodeUI) { svgContainer.removeChild(nodeUI); }, /** * Called by Viva.Graph.View.renderer to let concrete graphic output * provider place given node UI to recommended position pos {x, y}; */ updateNodePosition : function(nodeUI, pos) { nodePositionCallback(nodeUI, pos); }, /** * Called by Viva.Graph.View.renderer to let concrete graphic output * provider place given link of the graph. Pos objects are {x, y}; */ updateLinkPosition : function(link, fromPos, toPos) { linkPositionCallback(link, fromPos, toPos); }, /** * Returns root svg element. * * Note: This is internal method specific to this renderer */ getSvgRoot : function() { return svgRoot; } }; }; /*global Viva*/ Viva.Graph.View.svgNodeFactory = function(graph){ var highlightColor = 'orange', defaultColor = '#999', geom = Viva.Graph.geom(), attachCustomContent = function(nodeUI, node) { nodeUI.size = {w: 10, h: 10}; nodeUI.append('rect') .attr('width', nodeUI.size.w) .attr('height', nodeUI.size.h) .attr('stroke', 'orange') .attr('fill', 'orange'); }, nodeSize = function(nodeUI) { return nodeUI.size; }; return { node : function(node) { var nodeUI = Viva.Graph.svg('g'); attachCustomContent(nodeUI, node); nodeUI.nodeId = node.id; return nodeUI; }, link : function(link) { var fromNode = graph.getNode(link.fromId), nodeUI = fromNode && fromNode.ui; if (nodeUI && !nodeUI.linksContainer ) { var nodeLinks = Viva.Graph.svg('path') .attr('stroke', defaultColor); nodeUI.linksContainer = nodeLinks; return nodeLinks; } return null; }, /** * Sets a callback function for custom nodes contnet. * @param conentCreator(nodeUI, node) - callback function which returns a node content UI. * Image, for example. * @param sizeProvider(nodeUI) - a callback function which accepts nodeUI returned by * contentCreator and returns it's custom rectangular size. * */ customContent : function(contentCreator, sizeProvider) { if (typeof contentCreator !== 'function' || typeof sizeProvider !== 'function') { throw 'Two functions expected: contentCreator(nodeUI, node) and size(nodeUI)'; } attachCustomContent = contentCreator; nodeSize = sizeProvider; }, placeNode : function(nodeUI, fromNodePos) { var linksPath = '', fromNodeSize = nodeSize(nodeUI); graph.forEachLinkedNode(nodeUI.nodeId, function(linkedNode, link) { if (!linkedNode.position || !linkedNode.ui) { return; // not yet defined - ignore. } if (linkedNode.ui === nodeUI) { return; // incoming link - ignore; } if (link.fromId !== nodeUI.nodeId) { return; // we process only outgoing links. } var toNodeSize = nodeSize(linkedNode.ui), toNodePos = linkedNode.position; var from = geom.intersectRect( fromNodePos.x - fromNodeSize.w / 2, // left fromNodePos.y - fromNodeSize.h / 2, // top fromNodePos.x + fromNodeSize.w / 2, // right fromNodePos.y + fromNodeSize.h / 2, // bottom fromNodePos.x, fromNodePos.y, toNodePos.x, toNodePos.y) || fromNodePos; var to = geom.intersectRect( toNodePos.x - toNodeSize.w / 2, // left toNodePos.y - toNodeSize.h / 2, // top toNodePos.x + toNodeSize.w / 2, // right toNodePos.y + toNodeSize.h / 2, // bottom toNodePos.x, toNodePos.y, fromNodePos.x, fromNodePos.y) || toNodePos; linksPath += 'M' + Math.round(from.x) + ' ' + Math.round(from.y) + 'L' + Math.round(to.x) + ' ' + Math.round(to.y); }); nodeUI.attr("transform", "translate(" + (fromNodePos.x - fromNodeSize.w / 2) + ", " + (fromNodePos.y - fromNodeSize.h / 2) + ")"); if (linksPath !== '' && nodeUI.linksContainer) { nodeUI.linksContainer.attr("d", linksPath); } } }; }; /** * @fileOverview Defines a graph renderer that uses CSS based drawings. * * @author Andrei Kashcha (aka anvaka) / http://anvaka.blogspot.com */ /*global Viva, window*/ Viva.Graph.View = Viva.Graph.View || {}; /** * This is heart of the rendering. Class accepts graph to be rendered and rendering settings. * It monitors graph changes and depicts them accordingly. * * @param graph - Viva.Graph.graph() object to be rendered. * @param settings - rendering settings, composed from the following parts (with their defaults shown): * settings = { * // Represents a module that is capable of displaying graph nodes and links. * // all graphics has to correspond to defined interface and can be later easily * // replaced for specific needs (e.g. adding WebGL should be piece of cake as long * // as WebGL has implemented required interface). See svgGraphics for example. * // NOTE: current version supports Viva.Graph.View.cssGraphics() as well. * graphics : Viva.Graph.View.svgGraphics(), * * // Where the renderer should draw graph. Container size matters, because * // renderer will attempt center graph to that size. Also graphics modules * // might depend on it. * container : document.body, * * // Layout algorithm to be used. The algorithm is expected to comply with defined * // interface and is expected to be iterative. Renderer will use it then to calculate * // grpaph's layout. For examples of the interface refer to Viva.Graph.Layout.forceDirected() * // and Viva.Graph.Layout.gem() algorithms. * layout : Viva.Graph.Layout.forceDirected(), * * // Directs renderer to display links. Usually rendering links is the slowest part of this * // library. So if you don't need to display links, consider settings this property to false. * renderLinks : true, * * // Number of layout iterations to run before displaying the graph. The bigger you set this number * // the closer to ideal position graph will apper first time. But be careful: for large graphs * // it can freeze the browser. * prerender : 0 * } */ Viva.Graph.View.renderer = function(graph, settings) { // TODO: This class is getting hard to understand. Consider refactoring. // TODO: I have a technical debt here: fix scaling/recentring! Currently it's total mess. var FRAME_INTERVAL = 30; settings = settings || {}; var layout = settings.layout, graphics = settings.graphics, container = settings.container, animationTimer, rendererInitialized = false, updateCenterRequired = true, currentStep = 0, totalIterationsCount = 0, isStable = false, userInteraction = false, viewPortOffset = { x : 0, y : 0 }, transform = { offsetX : 0, offsetY : 0, scale : 1 }; var prepareSettings = function() { container = container || document.body; layout = layout || Viva.Graph.Layout.forceDirected(graph); graphics = graphics || Viva.Graph.View.svgGraphics(graph, {container : container}); if (typeof settings.renderLinks === 'undefined') { settings.renderLinks = true; } settings.prerender = settings.prerender || 0; }, renderLink = function(link){ var fromNode = graph.getNode(link.fromId); var toNode = graph.getNode(link.toId); if(!fromNode || !toNode) { return; } var from = { x : Math.round(fromNode.position.x), y : Math.round(fromNode.position.y), node: fromNode }, to = { x : Math.round(toNode.position.x), y : Math.round(toNode.position.y), node : toNode }; graphics.updateLinkPosition(link.ui, from, to); }, renderNode = function(node) { var position = { x : Math.round(node.position.x), y : Math.round(node.position.y) }; graphics.updateNodePosition(node.ui, position); }, renderGraph = function(){ if(settings.renderLinks && !graphics.omitLinksRendering) { graph.forEachLink(renderLink); } graph.forEachNode(renderNode); }, onRenderFrame = function() { isStable = layout.step() && !userInteraction; renderGraph(); return !isStable; }, renderIterations = function(iterationsCount) { if (animationTimer) { totalIterationsCount += iterationsCount; return; } if (iterationsCount) { totalIterationsCount += iterationsCount; animationTimer = Viva.Graph.Utils.timer(function() { return onRenderFrame(); }, FRAME_INTERVAL); } else { currentStep = 0; totalIterationsCount = 0; animationTimer = Viva.Graph.Utils.timer(onRenderFrame, FRAME_INTERVAL); } }, resetStable = function(){ isStable = false; animationTimer.restart(); }, // increaseTotalIterations = function(increaseBy){ // if (totalIterationsCount > 0){ // renderIterations(increaseBy); // } // }, prerender = function() { // To get good initial positions for the graph // perform several prerender steps in background. if (typeof settings.prerender === 'number' && settings.prerender > 0){ for (var i = 0; i < settings.prerender; ++i){ layout.step(); } } else { layout.step(); // make one step to init positions property. } }, updateCenter = function() { var graphRect = layout.getGraphRect(), containerSize = Viva.Graph.Utils.getDimension(container); viewPortOffset.x = viewPortOffset.y = 0; transform.offsetX = containerSize.width / 2 - (graphRect.x2 + graphRect.x1) / 2; transform.offsetY = containerSize.height / 2 - (graphRect.y2 + graphRect.y1) / 2; graphics.setInitialOffset(transform.offsetX + viewPortOffset.x, transform.offsetY + viewPortOffset.y); updateCenterRequired = false; }, createNodeUi = function(node) { var nodeUI = graphics.node(node); node.ui = nodeUI; graphics.initNode(nodeUI); if (!node.position) { layout.addNode(node); } renderNode(node); }, removeNodeUi = function (node) { if (node.ui){ graphics.releaseNode(node.ui); node.ui = null; delete node.ui; } layout.removeNode(node); }, createLinkUi = function(link) { var linkUI = graphics.link(link); if (!linkUI) { return; } link.ui = linkUI; graphics.initLink(linkUI); if (!graphics.omitLinksRendering){ renderLink(link); } }, removeLinkUi = function(link) { if (link.ui) { graphics.releaseLink(link.ui); link.ui = null; delete link.ui; } }, listenNodeEvents = function(node) { var wasPinned = false; node.events = Viva.Graph.Utils.dragndrop(node.ui) .onStart(function(){ wasPinned = node.isPinned; node.isPinned = true; userInteraction = true; resetStable(); }) .onDrag(function(e, offset){ node.position.x += offset.x / transform.scale; node.position.y += offset.y / transform.scale; userInteraction = true; //resetStable(); }) .onStop(function(){ node.isPinned = wasPinned; userInteraction = false; //resetStable(); }); }, releaseNodeEvents = function(node) { if (node.events) { // TODO: i'm not sure if this is required in JS world... node.events.release(); node.events = null; delete node.events; } }, initDom = function() { graphics.init(container); graph.forEachNode(createNodeUi); if(settings.renderLinks) { graph.forEachLink(createLinkUi); } }, processNodeChange = function(change) { var node = change.node; if (change.changeType === 'add') { createNodeUi(node); listenNodeEvents(node); if (updateCenterRequired){ updateCenter(); } } else if (change.changeType === 'remove') { releaseNodeEvents(node); removeNodeUi(node); if (graph.getNodesCount() === 0){ updateCenterRequired = true; // Next time when node is added - center the graph. } } else if (change.changeType === 'update') { // TODO: Implement this properly! // releaseNodeEvents(node); // removeNodeUi(node); // createNodeUi(node); // listenNodeEvents(node); } }, processLinkChange = function(change) { var link = change.link; if (change.changeType === 'add') { if (settings.renderLinks) { createLinkUi(link); } layout.addLink(link); } else if (change.changeType === 'remove') { if (settings.renderLinks) { removeLinkUi(link); } layout.removeLink(link); } else if (change.changeType === 'update') { // TODO: implement this properly! // if (settings.renderLinks) { removeLinkUi(link); } // layout.removeLink(link); // if (settings.renderLinks) { createLinkUi(link); } // layout.addLink(link); } }, listenToEvents = function() { Viva.Graph.Utils.events(window).on('resize', function(){ updateCenter(); onRenderFrame(); }); var containerDrag = Viva.Graph.Utils.dragndrop(container); containerDrag.onDrag(function(e, offset){ viewPortOffset.x += offset.x; viewPortOffset.y += offset.y; graphics.translateRel(offset.x, offset.y); renderGraph(); }); containerDrag.onScroll(function(e, scaleOffset, scrollPoint) { var scaleFactor = Math.pow(1 + 0.4, scaleOffset < 0 ? -0.2 : 0.2); transform.scale = graphics.scale(scaleFactor, scrollPoint); }); graph.forEachNode(listenNodeEvents); Viva.Graph.Utils.events(graph).on('changed', function(changes){ for(var i = 0; i < changes.length; ++i){ var change = changes[i]; if (change.node) { processNodeChange(change); } else if (change.link) { processLinkChange(change); } } resetStable(); }); }; return { /** * Performs rendering of the graph. * * @param iterationsCount if specified renderer will run only given number of iterations * and then stop. Otherwise graph rendering is performed infinitely. * * Note: if rendering stopped by used started dragging nodes or new nodes were added to the * graph renderer will give run more iterations to reflect changes. */ run : function(iterationsCount) { if (!rendererInitialized){ prepareSettings(); prerender(); updateCenter(); initDom(); listenToEvents(); rendererInitialized = true; } renderIterations(iterationsCount); return this; }, reset : function(){ graphics.resetScale(); updateCenter(); transform.scale = 1; } }; }; /*global Viva*/ Viva.Graph.serializer = function(){ var checkJSON = function(){ if (typeof JSON === 'undefined' || !JSON.stringify || !JSON.parse) { throw 'JSON serializer is not defined.'; } }; return { /** * Saves graph to JSON format. * * NOTE: ECMAScript 5 (or alike) JSON object is required to be defined * to get proper output. * * @param graph to be saved in JSON format. */ storeToJSON : function(graph) { if (!graph) { throw 'Graph is not defined'; } checkJSON(); var store = { nodes : [], links : [] }; graph.forEachNode(function(node) { store.nodes.push({id: node.id, data : node.data }); }); graph.forEachLink(function(link) { store.links.push({ fromId : link.fromId, toId: link.toId, data : link.data }); }); return JSON.stringify(store); }, /** * Restores graph from JSON string created by storeToJSON() method. * * NOTE: ECMAScript 5 (or alike) JSON object is required to be defined * to get proper output. * * @param jsonString is a string produced by storeToJSON() method. */ loadFromJSON : function(jsonString) { if (typeof jsonString !== 'string') { throw 'String expected in loadFromJSON() metho'; } checkJSON(); var store = JSON.parse(jsonString); var graph = Viva.Graph.graph(); if (!store || !store.nodes || !store.links) { throw 'Passed json string does not represent valid graph'; } for(var i = 0; i < store.nodes.length; ++i) { var parsedNode = store.nodes[i]; if (!parsedNode.id) { throw 'Graph node format is invalid. Node.id is missing'; } graph.addNode(parsedNode.id, parsedNode.data); } for (i = 0; i < store.links.length; ++i) { var link = store.links[i]; if (!link.fromId || !link.toId) { throw 'Graph link format is invalid. Both fromId and toId are required'; } graph.addLink(link.fromId, link.toId, link.data); } return graph; } }; };