/** * @constructor Graph * Create a graph visualization, displaying nodes and edges. * * @param {Element} container The DOM element in which the Graph will * be created. Normally a div element. * @param {Object} data An object containing parameters * {Array} nodes * {Array} edges * @param {Object} options Options */ function Graph (container, data, options) { // create variables and set default values this.containerElement = container; this.width = "100%"; this.height = "100%"; this.refreshRate = 50; // milliseconds this.stabilize = true; // stabilize before displaying the graph this.selectable = true; // set constant values this.constants = { "nodes": { "radiusMin": 5, "radiusMax": 20, "radius": 5, "distance": 100, // px "style": "rect", "image": undefined, "widthMin": 16, // px "widthMax": 64, // px "fontColor": "black", "fontSize": 14, // px //"fontFace": "verdana", "fontFace": "arial", "borderColor": "#2B7CE9", "backgroundColor": "#97C2FC", "highlightColor": "#D2E5FF", "group": undefined }, "edges": { "widthMin": 1, "widthMax": 15, "width": 1, "style": "line", "color": "#343434", "fontColor": "#343434", "fontSize": 14, // px "fontFace": "arial", //"distance": 100, //px "length": 100, // px "dashlength": 10, "dashgap": 5 }, "minForce": 0.05, "minVelocity": 0.02, // px/s "maxIterations": 1000 // maximum number of iteration to stabilize }; this.nodes = []; // array with Node objects this.edges = []; // array with Edge objects this.images = new Graph.Images(); // object with images this.groups = new Graph.Groups(); // object with groups // properties of the data this.moving = false; // True if any of the nodes have an undefined position this.selection = []; this.timer = undefined; // create a frame and canvas this._create(); // apply options this.setOptions(options); // draw data this.setData(data); } /** * Main drawing logic. This is the function that needs to be called * in the html page, to draw the Graph. * * A data table with the events must be provided, and an options table. * @param {Object} data Object containing parameters: * {Array} nodes Array with nodes * {Array} edges Array with edges * {Options} [options] Object with options */ Graph.prototype.setData = function(data) { if (data.options) { this.setOptions(data.options); } // set all data this.setNodes(data.nodes); this.setEdges(data.edges); this._reposition(); // TODO: bad solution if (this.stabilize) { this._doStabilize(); } this.start(); // create an onload callback method for the images var graph = this; var callback = function () { graph._redraw(); }; this.images.setOnloadCallback(callback); // fire the ready event this.trigger('ready'); }; /** * Set options * @param {Object} options */ Graph.prototype.setOptions = function (options) { if (options) { // retrieve parameter values if (options.width != undefined) {this.width = options.width;} if (options.height != undefined) {this.height = options.height;} if (options.stabilize != undefined) {this.stabilize = options.stabilize;} if (options.selectable != undefined) {this.selectable = options.selectable;} // TODO: work out these options and document them if (options.edges) { for (var prop in options.edges) { if (options.edges.hasOwnProperty(prop)) { this.constants.edges[prop] = options.edges[prop]; } } if (options.edges.length != undefined && options.nodes && options.nodes.distance == undefined) { this.constants.edges.length = options.edges.length; this.constants.nodes.distance = options.edges.length * 1.25; } if (!options.edges.fontColor) { this.constants.edges.fontColor = options.edges.color; } // Added to support dashed lines // David Jordan // 2012-08-08 if (options.edges.dashlength != undefined) { this.constants.edges.dashlength = options.edges.dashlength; } if (options.edges.dashgap != undefined) { this.constants.edges.dashgap = options.edges.dashgap; } if (options.edges.altdashlength != undefined) { this.constants.edges.altdashlength = options.edges.altdashlength; } } if (options.nodes) { for (prop in options.nodes) { if (options.nodes.hasOwnProperty(prop)) { this.constants.nodes[prop] = options.nodes[prop]; } } /* if (options.nodes.widthMin) this.constants.nodes.radiusMin = options.nodes.widthMin; if (options.nodes.widthMax) this.constants.nodes.radiusMax = options.nodes.widthMax; */ } if (options.groups) { for (var groupname in options.groups) { if (options.groups.hasOwnProperty(groupname)) { var group = options.groups[groupname]; this.groups.add(groupname, group); } } } this._setBackgroundColor(options.backgroundColor); } this._setSize(this.width, this.height); this._setTranslation(0, 0); this._setScale(1.0); }; /** * fire an event * @param {String} event The name of an event, for example "select" or "ready" * @param {Object} params Optional object with event parameters */ Graph.prototype.trigger = function (event, params) { // trigger the edges event bus events.trigger(this, event, params); // trigger the google event bus if (typeof google !== 'undefined' && google.visualization && google.visualization.events) { google.visualization.events.trigger(this, event, params); } }; /** * Create the main frame for the Graph. * This function is executed once when a Graph object is created. The frame * contains a canvas, and this canvas contains all objects like the axis and * nodes. */ Graph.prototype._create = function () { // remove all elements from the container element. while (this.containerElement.hasChildNodes()) { this.containerElement.removeChild(this.containerElement.firstChild); } this.frame = document.createElement("div"); this.frame.className = "graph-frame"; this.frame.style.position = "relative"; this.frame.style.overflow = "hidden"; // create the graph canvas (HTML canvas element) this.frame.canvas = document.createElement( "canvas" ); this.frame.canvas.style.position = "relative"; this.frame.appendChild(this.frame.canvas); if (!this.frame.canvas.getContext) { var noCanvas = document.createElement( "DIV" ); noCanvas.style.color = "red"; noCanvas.style.fontWeight = "bold" ; noCanvas.style.padding = "10px"; noCanvas.innerHTML = "Error: your browser does not support HTML canvas"; this.frame.canvas.appendChild(noCanvas); } // create event listeners var me = this; var onmousedown = function (event) {me._onMouseDown(event);}; var onmousemove = function (event) {me._onMouseMoveTitle(event);}; var onmousewheel = function (event) {me._onMouseWheel(event);}; var ontouchstart = function (event) {me._onTouchStart(event);}; vis.util.addEventListener(this.frame.canvas, "mousedown", onmousedown); vis.util.addEventListener(this.frame.canvas, "mousemove", onmousemove); vis.util.addEventListener(this.frame.canvas, "mousewheel", onmousewheel); vis.util.addEventListener(this.frame.canvas, "touchstart", ontouchstart); // add the frame to the container element this.containerElement.appendChild(this.frame); }; /** * Set the background and border styling for the graph * @param {String | Object} backgroundColor */ Graph.prototype._setBackgroundColor = function(backgroundColor) { var fill = "white"; var stroke = "lightgray"; var strokeWidth = 1; if (typeof(backgroundColor) == "string") { fill = backgroundColor; stroke = "none"; strokeWidth = 0; } else if (typeof(backgroundColor) == "object") { if (backgroundColor.fill != undefined) fill = backgroundColor.fill; if (backgroundColor.stroke != undefined) stroke = backgroundColor.stroke; if (backgroundColor.strokeWidth != undefined) strokeWidth = backgroundColor.strokeWidth; } else if (backgroundColor == undefined) { // use use defaults } else { throw "Unsupported type of backgroundColor"; } this.frame.style.boxSizing = 'border-box'; this.frame.style.backgroundColor = fill; this.frame.style.borderColor = stroke; this.frame.style.borderWidth = strokeWidth + "px"; this.frame.style.borderStyle = "solid"; }; /** * handle on mouse down event */ Graph.prototype._onMouseDown = function (event) { event = event || window.event; if (!this.selectable) { return; } // check if mouse is still down (may be up when focus is lost for example // in an iframe) if (this.leftButtonDown) { this._onMouseUp(event); } // only react on left mouse button down this.leftButtonDown = event.which ? (event.which == 1) : (event.button == 1); if (!this.leftButtonDown && !this.touchDown) { return; } // add event listeners to handle moving the contents // we store the function onmousemove and onmouseup in the timeline, so we can // remove the eventlisteners lateron in the function mouseUp() var me = this; if (!this.onmousemove) { this.onmousemove = function (event) {me._onMouseMove(event);}; vis.util.addEventListener(document, "mousemove", me.onmousemove); } if (!this.onmouseup) { this.onmouseup = function (event) {me._onMouseUp(event);}; vis.util.addEventListener(document, "mouseup", me.onmouseup); } vis.util.preventDefault(event); // store the start x and y position of the mouse this.startMouseX = event.clientX || event.targetTouches[0].clientX; this.startMouseY = event.clientY || event.targetTouches[0].clientY; this.startFrameLeft = vis.util.getAbsoluteLeft(this.frame.canvas); this.startFrameTop = vis.util.getAbsoluteTop(this.frame.canvas); this.startTranslation = this._getTranslation(); this.ctrlKeyDown = event.ctrlKey; this.shiftKeyDown = event.shiftKey; var obj = { "left" : this._xToCanvas(this.startMouseX - this.startFrameLeft), "top" : this._yToCanvas(this.startMouseY - this.startFrameTop), "right" : this._xToCanvas(this.startMouseX - this.startFrameLeft), "bottom" : this._yToCanvas(this.startMouseY - this.startFrameTop) }; var overlappingNodes = this._getNodesOverlappingWith(obj); // if there are overlapping nodes, select the last one, this is the // one which is drawn on top of the others this.startClickedObj = (overlappingNodes.length > 0) ? overlappingNodes[overlappingNodes.length - 1] : undefined; if (this.startClickedObj) { // move clicked node with the mouse // make the clicked node temporarily fixed, and store their original state var node = this.nodes[this.startClickedObj.row]; this.startClickedObj.xFixed = node.xFixed; this.startClickedObj.yFixed = node.yFixed; node.xFixed = true; node.yFixed = true; if (!this.ctrlKeyDown || !node.isSelected()) { // select this node this._selectNodes([this.startClickedObj], this.ctrlKeyDown); } else { // unselect this node this._unselectNodes([this.startClickedObj]); } if (!this.moving) { this._redraw(); } } else if (this.shiftKeyDown) { // start selection of multiple nodes } else { // start moving the graph this.moved = false; } }; /** * handle on mouse move event */ Graph.prototype._onMouseMove = function (event) { event = event || window.event; if (!this.selectable) { return; } var mouseX = event.clientX || (event.targetTouches && event.targetTouches[0].clientX) || 0; var mouseY = event.clientY || (event.targetTouches && event.targetTouches[0].clientY) || 0; this.mouseX = mouseX; this.mouseY = mouseY; if (this.startClickedObj) { var node = this.nodes[this.startClickedObj.row]; if (!this.startClickedObj.xFixed) node.x = this._xToCanvas(mouseX - this.startFrameLeft); if (!this.startClickedObj.yFixed) node.y = this._yToCanvas(mouseY - this.startFrameTop); // start animation if not yet running if (!this.moving) { this.moving = true; this.start(); } } else if (this.shiftKeyDown) { // draw a rect from start mouse location to current mouse location if (this.frame.selRect == undefined) { this.frame.selRect = document.createElement("DIV"); this.frame.appendChild(this.frame.selRect); this.frame.selRect.style.position = "absolute"; this.frame.selRect.style.border = "1px dashed red"; } var left = Math.min(this.startMouseX, mouseX) - this.startFrameLeft; var top = Math.min(this.startMouseY, mouseY) - this.startFrameTop; var right = Math.max(this.startMouseX, mouseX) - this.startFrameLeft; var bottom = Math.max(this.startMouseY, mouseY) - this.startFrameTop; this.frame.selRect.style.left = left + "px"; this.frame.selRect.style.top = top + "px"; this.frame.selRect.style.width = (right - left) + "px"; this.frame.selRect.style.height = (bottom - top) + "px"; } else { // move the graph var diffX = mouseX - this.startMouseX; var diffY = mouseY - this.startMouseY; this._setTranslation( this.startTranslation.x + diffX, this.startTranslation.y + diffY); this._redraw(); this.moved = true; } vis.util.preventDefault(event); }; /** * handle on mouse up event */ Graph.prototype._onMouseUp = function (event) { event = event || window.event; if (!this.selectable) { return; } // remove event listeners here, important for Safari if (this.onmousemove) { vis.util.removeEventListener(document, "mousemove", this.onmousemove); this.onmousemove = undefined; } if (this.onmouseup) { vis.util.removeEventListener(document, "mouseup", this.onmouseup); this.onmouseup = undefined; } vis.util.preventDefault(event); // check selected nodes var endMouseX = event.clientX || this.mouseX || 0; var endMouseY = event.clientY || this.mouseY || 0; var ctrlKey = event ? event.ctrlKey : window.event.ctrlKey; if (this.startClickedObj) { // restore the original fixed state var node = this.nodes[this.startClickedObj.row]; node.xFixed = this.startClickedObj.xFixed; node.yFixed = this.startClickedObj.yFixed; } else if (this.shiftKeyDown) { // select nodes inside selection area var obj = { "left": this._xToCanvas(Math.min(this.startMouseX, endMouseX) - this.startFrameLeft), "top": this._yToCanvas(Math.min(this.startMouseY, endMouseY) - this.startFrameTop), "right": this._xToCanvas(Math.max(this.startMouseX, endMouseX) - this.startFrameLeft), "bottom": this._yToCanvas(Math.max(this.startMouseY, endMouseY) - this.startFrameTop) }; var overlappingNodes = this._getNodesOverlappingWith(obj); this._selectNodes(overlappingNodes, ctrlKey); this.redraw(); // remove the selection rectangle if (this.frame.selRect) { this.frame.removeChild(this.frame.selRect); this.frame.selRect = undefined; } } else { if (!this.ctrlKeyDown && !this.moved) { // remove selection this._unselectNodes(); this._redraw(); } } this.leftButtonDown = false; this.ctrlKeyDown = false; }; /** * Event handler for mouse wheel event, used to zoom the timeline * Code from http://adomas.org/javascript-mouse-wheel/ * @param {event} event The event */ Graph.prototype._onMouseWheel = function(event) { event = event || window.event; var mouseX = event.clientX; var mouseY = event.clientY; // retrieve delta var delta = 0; if (event.wheelDelta) { /* IE/Opera. */ delta = event.wheelDelta/120; } else if (event.detail) { /* Mozilla case. */ // In Mozilla, sign of delta is different than in IE. // Also, delta is multiple of 3. delta = -event.detail/3; } // If delta is nonzero, handle it. // Basically, delta is now positive if wheel was scrolled up, // and negative, if wheel was scrolled down. if (delta) { // determine zoom factor, and adjust the zoom factor such that zooming in // and zooming out correspond wich each other var zoom = delta / 10; if (delta < 0) { zoom = zoom / (1 - zoom); } var scaleOld = this._getScale(); var scaleNew = scaleOld * (1 + zoom); if (scaleNew < 0.01) { scaleNew = 0.01; } if (scaleNew > 10) { scaleNew = 10; } var frameLeft = vis.util.getAbsoluteLeft(this.frame.canvas); var frameTop = vis.util.getAbsoluteTop(this.frame.canvas); var x = mouseX - frameLeft; var y = mouseY - frameTop; var translation = this._getTranslation(); var scaleFrac = scaleNew / scaleOld; var tx = (1 - scaleFrac) * x + translation.x * scaleFrac; var ty = (1 - scaleFrac) * y + translation.y * scaleFrac; this._setScale(scaleNew); this._setTranslation(tx, ty); this._redraw(); } // Prevent default actions caused by mouse wheel. // That might be ugly, but we handle scrolls somehow // anyway, so don't bother here... vis.util.preventDefault(event); }; /** * Mouse move handler for checking whether the title moves over a node with a title. */ Graph.prototype._onMouseMoveTitle = function (event) { event = event || window.event; var startMouseX = event.clientX; var startMouseY = event.clientY; this.startFrameLeft = this.startFrameLeft || vis.util.getAbsoluteLeft(this.frame.canvas); this.startFrameTop = this.startFrameTop || vis.util.getAbsoluteTop(this.frame.canvas); var x = startMouseX - this.startFrameLeft; var y = startMouseY - this.startFrameTop; // check if the previously selected node is still selected if (this.popupNode) { this._checkHidePopup(x, y); } // start a timeout that will check if the mouse is positioned above // an element var me = this; var checkShow = function() { me._checkShowPopup(x, y); }; if (this.popupTimer) { clearInterval(this.popupTimer); // stop any running timer } if (!this.leftButtonDown) { this.popupTimer = setTimeout(checkShow, 300); } }; /** * Check if there is an element on the given position in the graph * (a node or edge). If so, and if this element has a title, * show a popup window with its title. * * @param {number} x * @param {number} y */ Graph.prototype._checkShowPopup = function (x, y) { var obj = { "left" : this._xToCanvas(x), "top" : this._yToCanvas(y), "right" : this._xToCanvas(x), "bottom" : this._yToCanvas(y) }; var i, len; var lastPopupNode = this.popupNode; if (this.popupNode == undefined) { // search the nodes for overlap, select the top one in case of multiple nodes var nodes = this.nodes; for (i = nodes.length - 1; i >= 0; i--) { var node = nodes[i]; if (node.getTitle() != undefined && node.isOverlappingWith(obj)) { this.popupNode = node; break; } } } if (this.popupNode == undefined) { // search the edges for overlap var allEdges = this.edges; for (i = 0, len = allEdges.length; i < len; i++) { var edge = allEdges[i]; if (edge.getTitle() != undefined && edge.isOverlappingWith(obj)) { this.popupNode = edge; break; } } } if (this.popupNode) { // show popup message window if (this.popupNode != lastPopupNode) { var me = this; if (!me.popup) { me.popup = new Graph.Popup(me.frame); } // adjust a small offset such that the mouse cursor is located in the // bottom left location of the popup, and you can easily move over the // popup area me.popup.setPosition(x - 3, y - 3); me.popup.setText(me.popupNode.getTitle()); me.popup.show(); } } else { if (this.popup) { this.popup.hide(); } } }; /** * Check if the popup must be hided, which is the case when the mouse is no * longer hovering on the object * @param {number} x * @param {number} y */ Graph.prototype._checkHidePopup = function (x, y) { var obj = { "left" : x, "top" : y, "right" : x, "bottom" : y }; if (!this.popupNode || !this.popupNode.isOverlappingWith(obj) ) { this.popupNode = undefined; if (this.popup) { this.popup.hide(); } } }; /** * Event handler for touchstart event on mobile devices */ Graph.prototype._onTouchStart = function(event) { vis.util.preventDefault(event); if (this.touchDown) { // if already moving, return return; } this.touchDown = true; var me = this; if (!this.ontouchmove) { this.ontouchmove = function (event) {me._onTouchMove(event);}; vis.util.addEventListener(document, "touchmove", this.ontouchmove); } if (!this.ontouchend) { this.ontouchend = function (event) {me._onTouchEnd(event);}; vis.util.addEventListener(document, "touchend", this.ontouchend); } this._onMouseDown(event); }; /** * Event handler for touchmove event on mobile devices */ Graph.prototype._onTouchMove = function(event) { vis.util.preventDefault(event); this._onMouseMove(event); }; /** * Event handler for touchend event on mobile devices */ Graph.prototype._onTouchEnd = function(event) { vis.util.preventDefault(event); this.touchDown = false; if (this.ontouchmove) { vis.util.removeEventListener(document, "touchmove", this.ontouchmove); this.ontouchmove = undefined; } if (this.ontouchend) { vis.util.removeEventListener(document, "touchend", this.ontouchend); this.ontouchend = undefined; } this._onMouseUp(event); }; /** * Unselect selected nodes. If no selection array is provided, all nodes * are unselected * @param {Object[]} selection Array with selection objects, each selection * object has a parameter row. Optional * @param {Boolean} triggerSelect If true (default), the select event * is triggered when nodes are unselected * @return {Boolean} changed True if the selection is changed */ Graph.prototype._unselectNodes = function(selection, triggerSelect) { var changed = false; var i, iMax, row; if (selection) { // remove provided selections for (i = 0, iMax = selection.length; i < iMax; i++) { row = selection[i].row; this.nodes[row].unselect(); var j = 0; while (j < this.selection.length) { if (this.selection[j].row == row) { this.selection.splice(j, 1); changed = true; } else { j++; } } } } else if (this.selection && this.selection.length) { // remove all selections for (i = 0, iMax = this.selection.length; i < iMax; i++) { row = this.selection[i].row; this.nodes[row].unselect(); changed = true; } this.selection = []; } if (changed && (triggerSelect == true || triggerSelect == undefined)) { // fire the select event this.trigger('select'); } return changed; }; /** * select all nodes on given location x, y * @param {Array} selection an array with selection objects. Each selection * object has a parameter row * @param {boolean} append If true, the new selection will be appended to the * current selection (except for duplicate entries) * @return {Boolean} changed True if the selection is changed */ Graph.prototype._selectNodes = function(selection, append) { var changed = false; var i, iMax; // TODO: the selectNodes method is a little messy, rework this // check if the current selection equals the desired selection var selectionAlreadyDone = true; if (selection.length != this.selection.length) { selectionAlreadyDone = false; } else { for (i = 0, iMax = Math.min(selection.length, this.selection.length); i < iMax; i++) { if (selection[i].row != this.selection[i].row) { selectionAlreadyDone = false; break; } } } if (selectionAlreadyDone) { return changed; } if (append == undefined || append == false) { // first deselect any selected node var triggerSelect = false; changed = this._unselectNodes(undefined, triggerSelect); } for (i = 0, iMax = selection.length; i < iMax; i++) { // add each of the new selections, but only when they are not duplicate var row = selection[i].row; var isDuplicate = false; for (var j = 0, jMax = this.selection.length; j < jMax; j++) { if (this.selection[j].row == row) { isDuplicate = true; break; } } if (!isDuplicate) { this.nodes[row].select(); this.selection.push(selection[i]); changed = true; } } if (changed) { // fire the select event this.trigger('select'); } return changed; }; /** * retrieve all nodes overlapping with given object * @param {Object} obj An object with parameters left, top, right, bottom * @return {Object[]} An array with selection objects containing * the parameter row. */ Graph.prototype._getNodesOverlappingWith = function (obj) { var overlappingNodes = []; for (var i = 0; i < this.nodes.length; i++) { if (this.nodes[i].isOverlappingWith(obj)) { var sel = {"row": i}; overlappingNodes.push(sel); } } return overlappingNodes; }; /** * retrieve the currently selected nodes * @return {Object[]} an array with zero or more objects. Each object * contains the parameter row */ Graph.prototype.getSelection = function() { var selection = []; for (var i = 0; i < this.selection.length; i++) { var row = this.selection[i].row; selection.push({"row": row}); } return selection; }; /** * select zero or more nodes * @param {object[]} selection an array with zero or more objects. Each object * contains the parameter row */ Graph.prototype.setSelection = function(selection) { var i, iMax, row; if (selection.length == undefined) throw "Selection must be an array with objects"; // first unselect any selected node for (i = 0, iMax = this.selection.length; i < iMax; i++) { row = this.selection[i].row; this.nodes[row].unselect(); } this.selection = []; for (i = 0, iMax = selection.length; i < iMax; i++) { row = selection[i].row; if (row == undefined) throw "Parameter row missing in selection object"; if (row > this.nodes.length-1) throw "Parameter row out of range"; var sel = {"row": row}; this.selection.push(sel); this.nodes[row].select(); } this.redraw(); }; /** * Temporary method to test calculating a hub value for the nodes * @param {number} level Maximum number edges between two nodes in order * to call them connected. Optional, 1 by default * @return {Number[]} connectioncount array with the connection count * for each node */ Graph.prototype._getConnectionCount = function(level) { var conn = this.edges; if (level == undefined) { level = 1; } // get the nodes connected to given nodes function getConnectedNodes(nodes) { var connectedNodes = []; for (var j = 0, jMax = nodes.length; j < jMax; j++) { var node = nodes[j]; // find all nodes connected to this node for (var i = 0, iMax = conn.length; i < iMax; i++) { var other = null; // check if connected if (conn[i].from == node) other = conn[i].to; else if (conn[i].to == node) other = conn[i].from; // check if the other node is not already in the list with nodes var k, kMax; if (other) { for (k = 0, kMax = nodes.length; k < kMax; k++) { if (nodes[k] == other) { other = null; break; } } } if (other) { for (k = 0, kMax = connectedNodes.length; k < kMax; k++) { if (connectedNodes[k] == other) { other = null; break; } } } if (other) connectedNodes.push(other); } } return connectedNodes; } var connections = []; var level0 = []; var nodes = this.nodes; var i, iMax; for (i = 0, iMax = nodes.length; i < iMax; i++) { var c = [nodes[i]]; for (var l = 0; l < level; l++) { c = c.concat(getConnectedNodes(c)); } connections.push(c); } var hubs = []; for (i = 0, len = connections.length; i < len; i++) { hubs.push(connections[i].length); } return hubs; }; /** * Set a new size for the graph * @param {string} width Width in pixels or percentage (for example "800px" * or "50%") * @param {string} height Height in pixels or percentage (for example "400px" * or "30%") */ Graph.prototype._setSize = function(width, height) { this.frame.style.width = width; this.frame.style.height = height; this.frame.canvas.style.width = "100%"; this.frame.canvas.style.height = "100%"; this.frame.canvas.width = this.frame.canvas.clientWidth; this.frame.canvas.height = this.frame.canvas.clientHeight; }; /** * Load all nodes by reading the data table nodesTable * @param {Array} nodes The data containing the nodes. */ Graph.prototype.setNodes = function(nodes) { this.selection = []; this.nodes = []; this.moving = false; if (!nodes) { return; } this.nodesTable = nodes; var hasValues = false; var rowCount = nodes.length; for (var i = 0; i < rowCount; i++) { var properties = nodes[i]; if (properties.value != undefined) { hasValues = true; } if (properties.id == undefined) { throw "Column 'id' missing in table with nodes (row " + i + ")"; } this._createNode(properties); } // calculate scaling function when value is provided if (hasValues) { this._updateValueRange(this.nodes); } }; /** * Create a node with the given properties * If the new node has an id identical to an existing node, the existing * node will be overwritten. * The properties can contain a property "action", which can have values * "create", "update", or "delete" * @param {Object} properties An object with properties */ Graph.prototype._createNode = function(properties) { var action = properties.action ? properties.action : "update"; var id, index, newNode, oldNode; if (action === "create") { // create the node newNode = new Graph.Node(properties, this.images, this.groups, this.constants); id = properties.id; index = (id !== undefined) ? this._findNode(id) : undefined; if (index !== undefined) { // replace node oldNode = this.nodes[index]; this.nodes[index] = newNode; // remove selection of old node if (oldNode.selected) { this._unselectNodes([{'row': index}], false); } /* TODO: implement this? -> will give performance issues, searching all edges and nodes... // update edges linking to this node var edgesTable = this.edges; for (var i = 0, iMax = edgesTable.length; i < iMax; i++) { var edge = edgesTable[i]; if (edge.from == oldNode) { edge.from = newNode; } if (edge.to == oldNode) { edge.to = newNode; } } */ } else { // add new node this.nodes.push(newNode); } if (!newNode.isFixed()) { // note: no not use node.isMoving() here, as that gives the current // velocity of the node, which is zero after creation of the node. this.moving = true; } } else if (action === "update") { // update existing node, or create it when not yet existing id = properties.id; if (id === undefined) { throw "Cannot update a node without id"; } index = this._findNode(id); if (index !== undefined) { // update node this.nodes[index].setProperties(properties, this.constants); } else { // create node newNode = new Graph.Node(properties, this.images, this.groups, this.constants); this.nodes.push(newNode); if (!newNode.isFixed()) { // note: no not use node.isMoving() here, as that gives the current // velocity of the node, which is zero after creation of the node. this.moving = true; } } } else if (action === "delete") { // delete existing node id = properties.id; if (id === undefined) { throw "Cannot delete node without its id"; } index = this._findNode(id); if (index !== undefined) { oldNode = this.nodes[index]; // remove selection of old node if (oldNode.selected) { this._unselectNodes([{'row': index}], false); } this.nodes.splice(index, 1); } else { throw "Node with id " + id + " not found"; } } else { throw "Unknown action " + action + ". Choose 'create', 'update', or 'delete'."; } }; /** * Find a node by its id * @param {Number} id Id of the node * @return {Number} index Index of the node in the array this.nodes, or * undefined when not found. * */ Graph.prototype._findNode = function (id) { var nodes = this.nodes; for (var n = 0, len = nodes.length; n < len; n++) { if (nodes[n].id === id) { return n; } } return undefined; }; /** * Find a node by its rowNumber * @param {Number} row Row number of the node * @return {Graph.Node} node     The node with the given row number, or *                            undefined when not found. */ Graph.prototype._findNodeByRow = function (row) { return this.nodes[row]; }; /** * Load edges by reading the data table * @param {Array} edges The data containing the edges. */ Graph.prototype.setEdges = function(edges) { this.edges = []; if (!edges) { return; } this.edgesTable = edges; var hasValues = false; var rowCount = edges.length; for (var i = 0; i < rowCount; i++) { var properties = edges[i]; if (properties.from === undefined) { throw "Column 'from' missing in table with edges (row " + i + ")"; } if (properties.to === undefined) { throw "Column 'to' missing in table with edges (row " + i + ")"; } if (properties.value != undefined) { hasValues = true; } this._createEdge(properties); } // calculate scaling function when value is provided if (hasValues) { this._updateValueRange(this.edges); } }; /** * Create a edge with the given properties * If the new edge has an id identical to an existing edge, the existing * edge will be overwritten or updated. * The properties can contain a property "action", which can have values * "create", "update", or "delete" * @param {Object} properties An object with properties */ Graph.prototype._createEdge = function(properties) { var action = properties.action ? properties.action : "create"; var id, index, edge, oldEdge, newEdge; if (action === "create") { // create the edge, or replace it if already existing id = properties.id; index = (id !== undefined) ? this._findEdge(id) : undefined; edge = new Graph.Edge(properties, this, this.constants); if (index !== undefined) { // replace existing edge oldEdge = this.edges[index]; oldEdge.from.detachEdge(oldEdge); oldEdge.to.detachEdge(oldEdge); this.edges[index] = edge; } else { // add new edge this.edges.push(edge); } edge.from.attachEdge(edge); edge.to.attachEdge(edge); } else if (action === "update") { // update existing edge, or create the edge if not existing id = properties.id; if (id === undefined) { throw "Cannot update a edge without id"; } index = this._findEdge(id); if (index !== undefined) { // update edge edge = this.edges[index]; edge.from.detachEdge(edge); edge.to.detachEdge(edge); edge.setProperties(properties, this.constants); edge.from.attachEdge(edge); edge.to.attachEdge(edge); } else { // add new edge edge = new Graph.Edge(properties, this, this.constants); edge.from.attachEdge(edge); edge.to.attachEdge(edge); this.edges.push(edge); } } else if (action === "delete") { // delete existing edge id = properties.id; if (id === undefined) { throw "Cannot delete edge without its id"; } index = this._findEdge(id); if (index !== undefined) { oldEdge = this.edges[id]; edge.from.detachEdge(oldEdge); edge.to.detachEdge(oldEdge); this.edges.splice(index, 1); } else { throw "Edge with id " + id + " not found"; } } else { throw "Unknown action " + action + ". Choose 'create', 'update', or 'delete'."; } }; /** * Update the references to oldNode in all edges. * @param {Node} oldNode * @param {Node} newNode */ // TODO: start utilizing this method _updateNodeReferences Graph.prototype._updateNodeReferences = function(oldNode, newNode) { var edges = this.edges; for (var i = 0, iMax = edges.length; i < iMax; i++) { var edge = edges[i]; if (edge.from === oldNode) { edge.from = newNode; } if (edge.to === oldNode) { edge.to = newNode; } } }; /** * Find a edge by its id * @param {Number} id Id of the edge * @return {Number} index Index of the edge in the array this.edges, or * undefined when not found. * */ Graph.prototype._findEdge = function (id) { var edges = this.edges; for (var n = 0, len = edges.length; n < len; n++) { if (edges[n].id === id) { return n; } } return undefined; }; /** * Find a edge by its row * @param {Number} row Row of the edge * @return {Graph.Edge} the found edge, or undefined when not found */ Graph.prototype._findEdgeByRow = function (row) { return this.edges[row]; }; /** * Update the values of all object in the given array according to the current * value range of the objects in the array. * @param {Array} array. An array with objects like Edges or Nodes * The objects must have a method getValue() and * setValueRange(min, max). */ Graph.prototype._updateValueRange = function(array) { var count = array.length; var i; // determine the range of the node values var valueMin = undefined; var valueMax = undefined; for (i = 0; i < count; i++) { var value = array[i].getValue(); if (value !== undefined) { valueMin = (valueMin === undefined) ? value : Math.min(value, valueMin); valueMax = (valueMax === undefined) ? value : Math.max(value, valueMax); } } // adjust the range of all nodes if (valueMin !== undefined && valueMax !== undefined) { for (i = 0; i < count; i++) { array[i].setValueRange(valueMin, valueMax); } } }; /** * Redraw the graph with the current data * chart will be resized too. */ Graph.prototype.redraw = function() { this._setSize(this.width, this.height); this._redraw(); }; /** * Redraw the graph with the current data */ Graph.prototype._redraw = function() { var ctx = this.frame.canvas.getContext("2d"); // clear the canvas var w = this.frame.canvas.width; var h = this.frame.canvas.height; ctx.clearRect(0, 0, w, h); // set scaling and translation ctx.save(); ctx.translate(this.translation.x, this.translation.y); ctx.scale(this.scale, this.scale); this._drawEdges(ctx); this._drawNodes(ctx); // restore original scaling and translation ctx.restore(); }; /** * Set the translation of the graph * @param {Number} offsetX Horizontal offset * @param {Number} offsetY Vertical offset */ Graph.prototype._setTranslation = function(offsetX, offsetY) { if (this.translation === undefined) { this.translation = { "x": 0, "y": 0 }; } if (offsetX !== undefined) { this.translation.x = offsetX; } if (offsetY !== undefined) { this.translation.y = offsetY; } }; /** * Get the translation of the graph * @return {Object} translation An object with parameters x and y, both a number */ Graph.prototype._getTranslation = function() { return { "x": this.translation.x, "y": this.translation.y }; }; /** * Scale the graph * @param {Number} scale Scaling factor 1.0 is unscaled */ Graph.prototype._setScale = function(scale) { this.scale = scale; }; /** * Get the current scale of the graph * @return {Number} scale Scaling factor 1.0 is unscaled */ Graph.prototype._getScale = function() { return this.scale; }; Graph.prototype._xToCanvas = function(x) { return (x - this.translation.x) / this.scale; }; Graph.prototype._canvasToX = function(x) { return x * this.scale + this.translation.x; }; Graph.prototype._yToCanvas = function(y) { return (y - this.translation.y) / this.scale; }; Graph.prototype._canvasToY = function(y) { return y * this.scale + this.translation.y ; }; /** * Get a node by its id * @param {number} id * @return {Node} node, or null if not found */ Graph.prototype._getNode = function(id) { for (var i = 0; i < this.nodes.length; i++) { if (this.nodes[i].id == id) return this.nodes[i]; } return null; }; /** * Redraw all nodes * The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d"); * @param {CanvasRenderingContext2D} ctx */ Graph.prototype._drawNodes = function(ctx) { // first draw the unselected nodes var nodes = this.nodes; var selected = []; for (var i = 0, iMax = nodes.length; i < iMax; i++) { if (nodes[i].isSelected()) { selected.push(i); } else { nodes[i].draw(ctx); } } // draw the selected nodes on top for (var s = 0, sMax = selected.length; s < sMax; s++) { nodes[selected[s]].draw(ctx); } }; /** * Redraw all edges * The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d"); * @param {CanvasRenderingContext2D} ctx */ Graph.prototype._drawEdges = function(ctx) { var edges = this.edges; for (var i = 0, iMax = edges.length; i < iMax; i++) { edges[i].draw(ctx); } }; /** * Recalculate the best positions for all nodes */ Graph.prototype._reposition = function() { // TODO: implement function reposition /* var w = this.frame.canvas.clientWidth; var h = this.frame.canvas.clientHeight; for (var i = 0; i < this.nodes.length; i++) { if (!this.nodes[i].xFixed) this.nodes[i].x = w * Math.random(); if (!this.nodes[i].yFixed) this.nodes[i].y = h * Math.random(); } //*/ //* // TODO var radius = this.constants.edges.length * 2; var cx = this.frame.canvas.clientWidth / 2; var cy = this.frame.canvas.clientHeight / 2; for (var i = 0; i < this.nodes.length; i++) { var angle = 2*Math.PI * (i / this.nodes.length); if (!this.nodes[i].xFixed) this.nodes[i].x = cx + radius * Math.cos(angle); if (!this.nodes[i].yFixed) this.nodes[i].y = cy + radius * Math.sin(angle); } //*/ /* // TODO var radius = this.constants.edges.length * 2; var w = this.frame.canvas.clientWidth, h = this.frame.canvas.clientHeight; var cx = this.frame.canvas.clientWidth / 2; var cy = this.frame.canvas.clientHeight / 2; var s = Math.sqrt(this.nodes.length); for (var i = 0; i < this.nodes.length; i++) { //var angle = 2*Math.PI * (i / this.nodes.length); if (!this.nodes[i].xFixed) this.nodes[i].x = w/s * (i % s); if (!this.nodes[i].yFixed) this.nodes[i].y = h/s * (i / s); } //*/ /* var cx = this.frame.canvas.clientWidth / 2; var cy = this.frame.canvas.clientHeight / 2; for (var i = 0; i < this.nodes.length; i++) { this.nodes[i].x = cx; this.nodes[i].y = cy; } //*/ }; /** * Find a stable position for all nodes */ Graph.prototype._doStabilize = function() { var start = new Date(); // find stable position var count = 0; var vmin = this.constants.minVelocity; var stable = false; while (!stable && count < this.constants.maxIterations) { this._calculateForces(); this._discreteStepNodes(); stable = !this._isMoving(vmin); count++; } var end = new Date(); //console.log("Stabilized in " + (end-start) + " ms, " + count + " iterations" ); // TODO: cleanup }; /** * Calculate the external forces acting on the nodes * Forces are caused by: edges, repulsing forces between nodes, gravity */ Graph.prototype._calculateForces = function(nodeId) { // create a local edge to the nodes and edges, that is faster var nodes = this.nodes, edges = this.edges; // gravity, add a small constant force to pull the nodes towards the center of // the graph // Also, the forces are reset to zero in this loop by using _setForce instead // of _addForce var gravity = 0.01, gx = this.frame.canvas.clientWidth / 2, gy = this.frame.canvas.clientHeight / 2; for (var n = 0; n < nodes.length; n++) { var dx = gx - nodes[n].x, dy = gy - nodes[n].y, angle = Math.atan2(dy, dx), fx = Math.cos(angle) * gravity, fy = Math.sin(angle) * gravity; this.nodes[n]._setForce(fx, fy); } // repulsing forces between nodes var minimumDistance = this.constants.nodes.distance, steepness = 10; // higher value gives steeper slope of the force around the given minimumDistance for (var n = 0; n < nodes.length; n++) { for (var n2 = n + 1; n2 < this.nodes.length; n2++) { //var dmin = (nodes[n].width + nodes[n].height + nodes[n2].width + nodes[n2].height) / 1 || minimumDistance, // TODO: dmin //var dmin = (nodes[n].width + nodes[n2].width)/2 || minimumDistance, // TODO: dmin //dmin = 40 + ((nodes[n].width/2 + nodes[n2].width/2) || 0), // calculate normally distributed force var dx = nodes[n2].x - nodes[n].x, dy = nodes[n2].y - nodes[n].y, distance = Math.sqrt(dx * dx + dy * dy), angle = Math.atan2(dy, dx), // TODO: correct factor for repulsing force //var repulsingforce = 2 * Math.exp(-5 * (distance * distance) / (dmin * dmin) ); // TODO: customize the repulsing force //repulsingforce = Math.exp(-1 * (distance * distance) / (dmin * dmin) ), // TODO: customize the repulsing force repulsingforce = 1 / (1 + Math.exp((distance / minimumDistance - 1) * steepness)), // TODO: customize the repulsing force fx = Math.cos(angle) * repulsingforce, fy = Math.sin(angle) * repulsingforce; this.nodes[n]._addForce(-fx, -fy); this.nodes[n2]._addForce(fx, fy); } /* TODO: re-implement repulsion of edges for (var l = 0; l < edges.length; l++) { var lx = edges[l].from.x+(edges[l].to.x - edges[l].from.x)/2, ly = edges[l].from.y+(edges[l].to.y - edges[l].from.y)/2, // calculate normally distributed force dx = nodes[n].x - lx, dy = nodes[n].y - ly, distance = Math.sqrt(dx * dx + dy * dy), angle = Math.atan2(dy, dx), // TODO: correct factor for repulsing force //var repulsingforce = 2 * Math.exp(-5 * (distance * distance) / (dmin * dmin) ); // TODO: customize the repulsing force //repulsingforce = Math.exp(-1 * (distance * distance) / (dmin * dmin) ), // TODO: customize the repulsing force repulsingforce = 1 / (1 + Math.exp((distance / (minimumDistance / 2) - 1) * steepness)), // TODO: customize the repulsing force fx = Math.cos(angle) * repulsingforce, fy = Math.sin(angle) * repulsingforce; nodes[n]._addForce(fx, fy); edges[l].from._addForce(-fx/2,-fy/2); edges[l].to._addForce(-fx/2,-fy/2); } */ } // forces caused by the edges, modelled as springs for (var l = 0, lMax = edges.length; l < lMax; l++) { var edge = edges[l], dx = (edge.to.x - edge.from.x), dy = (edge.to.y - edge.from.y), //edgeLength = (edge.from.width + edge.from.height + edge.to.width + edge.to.height)/2 || edge.length, // TODO: dmin //edgeLength = (edge.from.width + edge.to.width)/2 || edge.length, // TODO: dmin //edgeLength = 20 + ((edge.from.width + edge.to.width) || 0) / 2, edgeLength = edge.length, length = Math.sqrt(dx * dx + dy * dy), angle = Math.atan2(dy, dx), springforce = edge.stiffness * (edgeLength - length), fx = Math.cos(angle) * springforce, fy = Math.sin(angle) * springforce; edge.from._addForce(-fx, -fy); edge.to._addForce(fx, fy); } /* TODO: re-implement repulsion of edges // repulsing forces between edges var minimumDistance = this.constants.edges.distance, steepness = 10; // higher value gives steeper slope of the force around the given minimumDistance for (var l = 0; l < edges.length; l++) { //Keep distance from other edge centers for (var l2 = l + 1; l2 < this.edges.length; l2++) { //var dmin = (nodes[n].width + nodes[n].height + nodes[n2].width + nodes[n2].height) / 1 || minimumDistance, // TODO: dmin //var dmin = (nodes[n].width + nodes[n2].width)/2 || minimumDistance, // TODO: dmin //dmin = 40 + ((nodes[n].width/2 + nodes[n2].width/2) || 0), var lx = edges[l].from.x+(edges[l].to.x - edges[l].from.x)/2, ly = edges[l].from.y+(edges[l].to.y - edges[l].from.y)/2, l2x = edges[l2].from.x+(edges[l2].to.x - edges[l2].from.x)/2, l2y = edges[l2].from.y+(edges[l2].to.y - edges[l2].from.y)/2, // calculate normally distributed force dx = l2x - lx, dy = l2y - ly, distance = Math.sqrt(dx * dx + dy * dy), angle = Math.atan2(dy, dx), // TODO: correct factor for repulsing force //var repulsingforce = 2 * Math.exp(-5 * (distance * distance) / (dmin * dmin) ); // TODO: customize the repulsing force //repulsingforce = Math.exp(-1 * (distance * distance) / (dmin * dmin) ), // TODO: customize the repulsing force repulsingforce = 1 / (1 + Math.exp((distance / minimumDistance - 1) * steepness)), // TODO: customize the repulsing force fx = Math.cos(angle) * repulsingforce, fy = Math.sin(angle) * repulsingforce; edges[l].from._addForce(-fx, -fy); edges[l].to._addForce(-fx, -fy); edges[l2].from._addForce(fx, fy); edges[l2].to._addForce(fx, fy); } } */ }; /** * Check if any of the nodes is still moving * @param {number} vmin the minimum velocity considered as "moving" * @return {boolean} true if moving, false if non of the nodes is moving */ Graph.prototype._isMoving = function(vmin) { // TODO: ismoving does not work well: should check the kinetic energy, not its velocity var nodes = this.nodes; for (var n = 0, nMax = nodes.length; n < nMax; n++) { if (nodes[n].isMoving(vmin)) { return true; } } return false; }; /** * Perform one discrete step for all nodes */ Graph.prototype._discreteStepNodes = function() { var interval = this.refreshRate / 1000.0; // in seconds var nodes = this.nodes; for (var n = 0, nMax = nodes.length; n < nMax; n++) { nodes[n].discreteStep(interval); } }; /** * Start animating nodes and edges */ Graph.prototype.start = function() { if (this.moving) { this._calculateForces(); this._discreteStepNodes(); var vmin = this.constants.minVelocity; this.moving = this._isMoving(vmin); } if (this.moving) { // start animation. only start timer if it is not already running if (!this.timer) { var graph = this; this.timer = window.setTimeout(function () { graph.timer = undefined; graph.start(); graph._redraw(); }, this.refreshRate); } } else { this._redraw(); } }; /** * Stop animating nodes and edges. */ Graph.prototype.stop = function () { if (this.timer) { window.clearInterval(this.timer); this.timer = undefined; } }; /**--------------------------------------------------------------------------**/ /** * @class Node * A node. A node can be connected to other nodes via one or multiple edges. * @param {object} properties An object containing properties for the node. All * properties are optional, except for the id. * {number} id Id of the node. Required * {string} text Title for the node * {number} x Horizontal position of the node * {number} y Vertical position of the node * {string} style Drawing style, available: * "database", "circle", "rect", * "image", "text", "dot", "star", * "triangle", "triangleDown", * "square" * {string} image An image url * {string} title An title text, can be HTML * {anytype} group A group name or number * @param {Graph.Images} imagelist A list with images. Only needed * when the node has an image * @param {Graph.Groups} grouplist A list with groups. Needed for * retrieving group properties * @param {Object} constants An object with default values for * example for the color */ Graph.Node = function (properties, imagelist, grouplist, constants) { this.selected = false; this.edges = []; // all edges connected to this node this.group = constants.nodes.group; this.fontSize = constants.nodes.fontSize; this.fontFace = constants.nodes.fontFace; this.fontColor = constants.nodes.fontColor; this.borderColor = constants.nodes.borderColor; this.backgroundColor = constants.nodes.backgroundColor; this.highlightColor = constants.nodes.highlightColor; // set defaults for the properties this.id = undefined; this.style = constants.nodes.style; this.image = constants.nodes.image; this.x = 0; this.y = 0; this.xFixed = false; this.yFixed = false; this.radius = constants.nodes.radius; this.radiusFixed = false; this.radiusMin = constants.nodes.radiusMin; this.radiusMax = constants.nodes.radiusMax; this.imagelist = imagelist; this.grouplist = grouplist; this.setProperties(properties, constants); // mass, force, velocity this.mass = 50; // kg (mass is adjusted for the number of connected edges) this.fx = 0.0; // external force x this.fy = 0.0; // external force y this.vx = 0.0; // velocity x this.vy = 0.0; // velocity y this.minForce = constants.minForce; this.damping = 0.9; // damping factor }; /** * Attach a edge to the node * @param {Graph.Edge} edge */ Graph.Node.prototype.attachEdge = function(edge) { this.edges.push(edge); this._updateMass(); }; /** * Detach a edge from the node * @param {Graph.Edge} edge */ Graph.Node.prototype.detachEdge = function(edge) { var index = this.edges.indexOf(edge); if (index != -1) { this.edges.splice(index, 1); } this._updateMass(); }; /** * Update the nodes mass, which is determined by the number of edges connecting * to it (more edges -> heavier node). * @private */ Graph.Node.prototype._updateMass = function() { this.mass = 50 + 20 * this.edges.length; // kg }; /** * Set or overwrite properties for the node * @param {Object} properties an object with properties * @param {Object} constants and object with default, global properties */ Graph.Node.prototype.setProperties = function(properties, constants) { if (!properties) { return; } // basic properties if (properties.id != undefined) {this.id = properties.id;} if (properties.text != undefined) {this.text = properties.text;} if (properties.title != undefined) {this.title = properties.title;} if (properties.group != undefined) {this.group = properties.group;} if (properties.x != undefined) {this.x = properties.x;} if (properties.y != undefined) {this.y = properties.y;} if (properties.value != undefined) {this.value = properties.value;} if (this.id === undefined) { throw "Node must have an id"; } // copy group properties if (this.group) { var groupObj = this.grouplist.get(this.group); for (var prop in groupObj) { if (groupObj.hasOwnProperty(prop)) { this[prop] = groupObj[prop]; } } } // individual style properties if (properties.style != undefined) {this.style = properties.style;} if (properties.image != undefined) {this.image = properties.image;} if (properties.radius != undefined) {this.radius = properties.radius;} if (properties.borderColor != undefined) {this.borderColor = properties.borderColor;} if (properties.backgroundColor != undefined){this.backgroundColor = properties.backgroundColor;} if (properties.highlightColor != undefined) {this.highlightColor = properties.highlightColor;} if (properties.fontColor != undefined) {this.fontColor = properties.fontColor;} if (properties.fontSize != undefined) {this.fontSize = properties.fontSize;} if (properties.fontFace != undefined) {this.fontFace = properties.fontFace;} if (this.image != undefined) { if (this.imagelist) { this.imageObj = this.imagelist.load(this.image); } else { throw "No imagelist provided"; } } this.xFixed = this.xFixed || (properties.x != undefined); this.yFixed = this.yFixed || (properties.y != undefined); this.radiusFixed = this.radiusFixed || (properties.radius != undefined); if (this.style == 'image') { this.radiusMin = constants.nodes.widthMin; this.radiusMax = constants.nodes.widthMax; } // choose draw method depending on the style var style = this.style; switch (style) { case 'database': this.draw = this._drawDatabase; this.resize = this._resizeDatabase; break; case 'rect': this.draw = this._drawRect; this.resize = this._resizeRect; break; case 'circle': this.draw = this._drawCircle; this.resize = this._resizeCircle; break; // TODO: add ellipse shape // TODO: add diamond shape case 'image': this.draw = this._drawImage; this.resize = this._resizeImage; break; case 'text': this.draw = this._drawText; this.resize = this._resizeText; break; case 'dot': this.draw = this._drawDot; this.resize = this._resizeShape; break; case 'square': this.draw = this._drawSquare; this.resize = this._resizeShape; break; case 'triangle': this.draw = this._drawTriangle; this.resize = this._resizeShape; break; case 'triangleDown': this.draw = this._drawTriangleDown; this.resize = this._resizeShape; break; case 'star': this.draw = this._drawStar; this.resize = this._resizeShape; break; default: this.draw = this._drawRect; this.resize = this._resizeRect; break; } // reset the size of the node, this can be changed this._reset(); }; /** * select this node */ Graph.Node.prototype.select = function() { this.selected = true; this._reset(); }; /** * unselect this node */ Graph.Node.prototype.unselect = function() { this.selected = false; this._reset(); }; /** * Reset the calculated size of the node, forces it to recalculate its size */ Graph.Node.prototype._reset = function() { this.width = undefined; this.height = undefined; }; /** * get the title of this node. * @return {string} title The title of the node, or undefined when no title * has been set. */ Graph.Node.prototype.getTitle = function() { return this.title; }; /** * Calculate the distance to the border of the Node * @param {CanvasRenderingContext2D} ctx * @param {Number} angle Angle in radians * @returns {number} distance Distance to the border in pixels */ Graph.Node.prototype.distanceToBorder = function (ctx, angle) { var borderWidth = 1; if (!this.width) { this.resize(ctx); } //noinspection FallthroughInSwitchStatementJS switch (this.style) { case 'circle': case 'dot': return this.radius + borderWidth; // TODO: implement distanceToBorder for database // TODO: implement distanceToBorder for triangle // TODO: implement distanceToBorder for triangleDown case 'rect': case 'image': case 'text': default: if (this.width) { return Math.min( Math.abs(this.width / 2 / Math.cos(angle)), Math.abs(this.height / 2 / Math.sin(angle))) + borderWidth; // TODO: reckon with border radius too in case of rect } else { return 0; } } // TODO: implement calculation of distance to border for all shapes }; /** * Set forces acting on the node * @param {number} fx Force in horizontal direction * @param {number} fy Force in vertical direction */ Graph.Node.prototype._setForce = function(fx, fy) { this.fx = fx; this.fy = fy; }; /** * Add forces acting on the node * @param {number} fx Force in horizontal direction * @param {number} fy Force in vertical direction */ Graph.Node.prototype._addForce = function(fx, fy) { this.fx += fx; this.fy += fy; }; /** * Perform one discrete step for the node * @param {number} interval Time interval in seconds */ Graph.Node.prototype.discreteStep = function(interval) { if (!this.xFixed) { var dx = -this.damping * this.vx; // damping force var ax = (this.fx + dx) / this.mass; // acceleration this.vx += ax / interval; // velocity this.x += this.vx / interval; // position } if (!this.yFixed) { var dy = -this.damping * this.vy; // damping force var ay = (this.fy + dy) / this.mass; // acceleration this.vy += ay / interval; // velocity this.y += this.vy / interval; // position } }; /** * Check if this node has a fixed x and y position * @return {boolean} true if fixed, false if not */ Graph.Node.prototype.isFixed = function() { return (this.xFixed && this.yFixed); }; /** * Check if this node is moving * @param {number} vmin the minimum velocity considered as "moving" * @return {boolean} true if moving, false if it has no velocity */ // TODO: replace this method with calculating the kinetic energy Graph.Node.prototype.isMoving = function(vmin) { return (Math.abs(this.vx) > vmin || Math.abs(this.vy) > vmin || (!this.xFixed && Math.abs(this.fx) > this.minForce) || (!this.yFixed && Math.abs(this.fy) > this.minForce)); }; /** * check if this node is selecte * @return {boolean} selected True if node is selected, else false */ Graph.Node.prototype.isSelected = function() { return this.selected; }; /** * Retrieve the value of the node. Can be undefined * @return {Number} value */ Graph.Node.prototype.getValue = function() { return this.value; }; /** * Calculate the distance from the nodes location to the given location (x,y) * @param {Number} x * @param {Number} y * @return {Number} value */ Graph.Node.prototype.getDistance = function(x, y) { var dx = this.x - x, dy = this.y - y; return Math.sqrt(dx * dx + dy * dy); }; /** * Adjust the value range of the node. The node will adjust it's radius * based on its value. * @param {Number} min * @param {Number} max */ Graph.Node.prototype.setValueRange = function(min, max) { if (!this.radiusFixed && this.value !== undefined) { var scale = (this.radiusMax - this.radiusMin) / (max - min); this.radius = (this.value - min) * scale + this.radiusMin; } }; /** * Draw this node in the given canvas * The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d"); * @param {CanvasRenderingContext2D} ctx */ Graph.Node.prototype.draw = function(ctx) { throw "Draw method not initialized for node"; }; /** * Recalculate the size of this node in the given canvas * The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d"); * @param {CanvasRenderingContext2D} ctx */ Graph.Node.prototype.resize = function(ctx) { throw "Resize method not initialized for node"; }; /** * Check if this object is overlapping with the provided object * @param {Object} obj an object with parameters left, top, right, bottom * @return {boolean} True if location is located on node */ Graph.Node.prototype.isOverlappingWith = function(obj) { return (this.left < obj.right && this.left + this.width > obj.left && this.top < obj.bottom && this.top + this.height > obj.top); }; Graph.Node.prototype._resizeImage = function (ctx) { // TODO: pre calculate the image size if (!this.width) { // undefined or 0 var width, height; if (this.value) { var scale = this.imageObj.height / this.imageObj.width; width = this.radius || this.imageObj.width; height = this.radius * scale || this.imageObj.height; } else { width = this.imageObj.width; height = this.imageObj.height; } this.width = width; this.height = height; } }; Graph.Node.prototype._drawImage = function (ctx) { this._resizeImage(ctx); this.left = this.x - this.width / 2; this.top = this.y - this.height / 2; var yText; if (this.imageObj) { ctx.drawImage(this.imageObj, this.left, this.top, this.width, this.height); yText = this.y + this.height / 2; } else { // image still loading... just draw the text for now yText = this.y; } this._text(ctx, this.text, this.x, yText, undefined, "top"); }; Graph.Node.prototype._resizeRect = function (ctx) { if (!this.width) { var margin = 5; var textSize = this.getTextSize(ctx); this.width = textSize.width + 2 * margin; this.height = textSize.height + 2 * margin; } }; Graph.Node.prototype._drawRect = function (ctx) { this._resizeRect(ctx); this.left = this.x - this.width / 2; this.top = this.y - this.height / 2; ctx.strokeStyle = this.borderColor; ctx.fillStyle = this.selected ? this.highlightColor : this.backgroundColor; ctx.lineWidth = this.selected ? 2.0 : 1.0; ctx.roundRect(this.left, this.top, this.width, this.height, this.radius); ctx.fill(); ctx.stroke(); this._text(ctx, this.text, this.x, this.y); }; Graph.Node.prototype._resizeDatabase = function (ctx) { if (!this.width) { var margin = 5; var textSize = this.getTextSize(ctx); var size = textSize.width + 2 * margin; this.width = size; this.height = size; } }; Graph.Node.prototype._drawDatabase = function (ctx) { this._resizeDatabase(ctx); this.left = this.x - this.width / 2; this.top = this.y - this.height / 2; ctx.strokeStyle = this.borderColor; ctx.fillStyle = this.selected ? this.highlightColor : this.backgroundColor; ctx.lineWidth = this.selected ? 2.0 : 1.0; ctx.database(this.x - this.width/2, this.y - this.height*0.5, this.width, this.height); ctx.fill(); ctx.stroke(); this._text(ctx, this.text, this.x, this.y); }; Graph.Node.prototype._resizeCircle = function (ctx) { if (!this.width) { var margin = 5; var textSize = this.getTextSize(ctx); var diameter = Math.max(textSize.width, textSize.height) + 2 * margin; this.radius = diameter / 2; this.width = diameter; this.height = diameter; } }; Graph.Node.prototype._drawCircle = function (ctx) { this._resizeCircle(ctx); this.left = this.x - this.width / 2; this.top = this.y - this.height / 2; ctx.strokeStyle = this.borderColor; ctx.fillStyle = this.selected ? this.highlightColor : this.backgroundColor; ctx.lineWidth = this.selected ? 2.0 : 1.0; ctx.circle(this.x, this.y, this.radius); ctx.fill(); ctx.stroke(); this._text(ctx, this.text, this.x, this.y); }; Graph.Node.prototype._drawDot = function (ctx) { this._drawShape(ctx, 'circle'); }; Graph.Node.prototype._drawTriangle = function (ctx) { this._drawShape(ctx, 'triangle'); }; Graph.Node.prototype._drawTriangleDown = function (ctx) { this._drawShape(ctx, 'triangleDown'); }; Graph.Node.prototype._drawSquare = function (ctx) { this._drawShape(ctx, 'square'); }; Graph.Node.prototype._drawStar = function (ctx) { this._drawShape(ctx, 'star'); }; Graph.Node.prototype._resizeShape = function (ctx) { if (!this.width) { var size = 2 * this.radius; this.width = size; this.height = size; } }; Graph.Node.prototype._drawShape = function (ctx, shape) { this._resizeShape(ctx); this.left = this.x - this.width / 2; this.top = this.y - this.height / 2; ctx.strokeStyle = this.borderColor; ctx.fillStyle = this.selected ? this.highlightColor : this.backgroundColor; ctx.lineWidth = this.selected ? 2.0 : 1.0; ctx[shape](this.x, this.y, this.radius); ctx.fill(); ctx.stroke(); if (this.text) { this._text(ctx, this.text, this.x, this.y + this.height / 2, undefined, 'top'); } }; Graph.Node.prototype._resizeText = function (ctx) { if (!this.width) { var margin = 5; var textSize = this.getTextSize(ctx); this.width = textSize.width + 2 * margin; this.height = textSize.height + 2 * margin; } }; Graph.Node.prototype._drawText = function (ctx) { this._resizeText(ctx); this.left = this.x - this.width / 2; this.top = this.y - this.height / 2; this._text(ctx, this.text, this.x, this.y); }; Graph.Node.prototype._text = function (ctx, text, x, y, align, baseline) { if (text) { ctx.font = (this.selected ? "bold " : "") + this.fontSize + "px " + this.fontFace; ctx.fillStyle = this.fontColor || "black"; ctx.textAlign = align || "center"; ctx.textBaseline = baseline || "middle"; var lines = text.split('\n'), lineCount = lines.length, fontSize = (this.fontSize + 4), yLine = y + (1 - lineCount) / 2 * fontSize; for (var i = 0; i < lineCount; i++) { ctx.fillText(lines[i], x, yLine); yLine += fontSize; } } }; Graph.Node.prototype.getTextSize = function(ctx) { if (this.text != undefined) { ctx.font = (this.selected ? "bold " : "") + this.fontSize + "px " + this.fontFace; var lines = this.text.split('\n'), height = (this.fontSize + 4) * lines.length, width = 0; for (var i = 0, iMax = lines.length; i < iMax; i++) { width = Math.max(width, ctx.measureText(lines[i]).width); } return {"width": width, "height": height}; } else { return {"width": 0, "height": 0}; } }; /**--------------------------------------------------------------------------**/ /** * @class Edge * * A edge connects two nodes * @param {Object} properties Object with properties. Must contain * At least properties from and to. * Available properties: from (number), * to (number), color (string), * width (number), style (string), * length (number), title (string) * @param {Graph} graph A graph object, used to find and edge to * nodes. * @param {Object} constants An object with default values for * example for the color */ Graph.Edge = function (properties, graph, constants) { if (!graph) { throw "No graph provided"; } this.graph = graph; // initialize constants this.widthMin = constants.edges.widthMin; this.widthMax = constants.edges.widthMax; // initialize variables this.id = undefined; this.style = constants.edges.style; this.title = undefined; this.width = constants.edges.width; this.value = undefined; this.length = constants.edges.length; // Added to support dashed lines // David Jordan // 2012-08-08 this.dashlength = constants.edges.dashlength; this.dashgap = constants.edges.dashgap; this.altdashlength = constants.edges.altdashlength; this.stiffness = undefined; // depends on the length of the edge this.color = constants.edges.color; this.widthFixed = false; this.lengthFixed = false; this.setProperties(properties, constants); }; /** * Set or overwrite properties for the edge * @param {Object} properties an object with properties * @param {Object} constants and object with default, global properties */ Graph.Edge.prototype.setProperties = function(properties, constants) { if (!properties) { return; } if (properties.from != undefined) {this.from = this.graph._getNode(properties.from);} if (properties.to != undefined) {this.to = this.graph._getNode(properties.to);} if (properties.id != undefined) {this.id = properties.id;} if (properties.style != undefined) {this.style = properties.style;} if (properties.text != undefined) {this.text = properties.text;} if (this.text) { this.fontSize = constants.edges.fontSize; this.fontFace = constants.edges.fontFace; this.fontColor = constants.edges.fontColor; if (properties.fontColor != undefined) {this.fontColor = properties.fontColor;} if (properties.fontSize != undefined) {this.fontSize = properties.fontSize;} if (properties.fontFace != undefined) {this.fontFace = properties.fontFace;} } if (properties.title != undefined) {this.title = properties.title;} if (properties.width != undefined) {this.width = properties.width;} if (properties.value != undefined) {this.value = properties.value;} if (properties.length != undefined) {this.length = properties.length;} // Added to support dashed lines // David Jordan // 2012-08-08 if (properties.dashlength != undefined) {this.dashlength = properties.dashlength;} if (properties.dashgap != undefined) {this.dashgap = properties.dashgap;} if (properties.altdashlength != undefined) {this.altdashlength = properties.altdashlength;} if (properties.color != undefined) {this.color = properties.color;} if (!this.from) { throw "Node with id " + properties.from + " not found"; } if (!this.to) { throw "Node with id " + properties.to + " not found"; } this.widthFixed = this.widthFixed || (properties.width != undefined); this.lengthFixed = this.lengthFixed || (properties.length != undefined); this.stiffness = 1 / this.length; // initialize animation if (this.style === 'arrow') { this.arrows = [0.5]; } // set draw method based on style switch (this.style) { case 'line': this.draw = this._drawLine; break; case 'arrow': this.draw = this._drawArrow; break; case 'arrow-end': this.draw = this._drawArrowEnd; break; case 'dash-line': this.draw = this._drawDashLine; break; default: this.draw = this._drawLine; break; } }; /** * get the title of this edge. * @return {string} title The title of the edge, or undefined when no title * has been set. */ Graph.Edge.prototype.getTitle = function() { return this.title; }; /** * Retrieve the value of the edge. Can be undefined * @return {Number} value */ Graph.Edge.prototype.getValue = function() { return this.value; } /** * Adjust the value range of the edge. The edge will adjust it's width * based on its value. * @param {Number} min * @param {Number} max */ Graph.Edge.prototype.setValueRange = function(min, max) { if (!this.widthFixed && this.value !== undefined) { var factor = (this.widthMax - this.widthMin) / (max - min); this.width = (this.value - min) * factor + this.widthMin; } }; /** * Check if the length is fixed. * @return {boolean} lengthFixed True if the length is fixed, else false */ Graph.Edge.prototype.isLengthFixed = function() { return this.lengthFixed; }; /** * Retrieve the length of the edge. Can be undefined * @return {Number} length */ Graph.Edge.prototype.getLength = function() { return this.length; }; /** * Adjust the length of the edge. This can only be done when the length * is not fixed (which is the case when the edge is created with a length property) * @param {Number} length */ Graph.Edge.prototype.setLength = function(length) { if (!this.lengthFixed) { this.length = length; } }; /** * Retrieve the length of the edges dashes. Can be undefined * @author David Jordan * @date 2012-08-08 * @return {Number} dashlength */ Graph.Edge.prototype.getDashLength = function() { return this.dashlength; }; /** * Adjust the length of the edges dashes. * @author David Jordan * @date 2012-08-08 * @param {Number} dashlength */ Graph.Edge.prototype.setDashLength = function(dashlength) { this.dashlength = dashlength; }; /** * Retrieve the length of the edges dashes gaps. Can be undefined * @author David Jordan * @date 2012-08-08 * @return {Number} dashgap */ Graph.Edge.prototype.getDashGap = function() { return this.dashgap; }; /** * Adjust the length of the edges dashes gaps. * @author David Jordan * @date 2012-08-08 * @param {Number} dashgap */ Graph.Edge.prototype.setDashGap = function(dashgap) { this.dashgap = dashgap; }; /** * Retrieve the length of the edges alternate dashes. Can be undefined * @author David Jordan * @date 2012-08-08 * @return {Number} altdashlength */ Graph.Edge.prototype.getAltDashLength = function() { return this.altdashlength; }; /** * Adjust the length of the edges alternate dashes. * @author David Jordan * @date 2012-08-08 * @param {Number} altdashlength */ Graph.Edge.prototype.setAltDashLength = function(altdashlength) { this.altdashlength = altdashlength; }; /** * Redraw a edge * Draw this edge in the given canvas * The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d"); * @param {CanvasRenderingContext2D} ctx */ Graph.Edge.prototype.draw = function(ctx) { throw "Method draw not initialized in edge"; }; /** * Check if this object is overlapping with the provided object * @param {Object} obj an object with parameters left, top * @return {boolean} True if location is located on the edge */ Graph.Edge.prototype.isOverlappingWith = function(obj) { var distMax = 10; var xFrom = this.from.x; var yFrom = this.from.y; var xTo = this.to.x; var yTo = this.to.y; var xObj = obj.left; var yObj = obj.top; var dist = Graph._dist(xFrom, yFrom, xTo, yTo, xObj, yObj); return (dist < distMax); }; /** * Calculate the distance between a point (x3,y3) and a line segment from * (x1,y1) to (x2,y2). * http://stackoverflow.com/questions/849211/shortest-distancae-between-a-point-and-a-line-segment * @param {number} x1 * @param {number} y1 * @param {number} x2 * @param {number} y2 * @param {number} x3 * @param {number} y3 */ Graph._dist = function (x1,y1, x2,y2, x3,y3) { // x3,y3 is the point var px = x2-x1, py = y2-y1, something = px*px + py*py, u = ((x3 - x1) * px + (y3 - y1) * py) / something; if (u > 1) { u = 1; } else if (u < 0) { u = 0; } var x = x1 + u * px, y = y1 + u * py, dx = x - x3, dy = y - y3; //# Note: If the actual distance does not matter, //# if you only want to compare what this function //# returns to other results of this function, you //# can just return the squared distance instead //# (i.e. remove the sqrt) to gain a little performance return Math.sqrt(dx*dx + dy*dy); }; /** * Redraw a edge as a line * Draw this edge in the given canvas * The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d"); * @param {CanvasRenderingContext2D} ctx */ Graph.Edge.prototype._drawLine = function(ctx) { // set style ctx.strokeStyle = this.color; ctx.lineWidth = this._getLineWidth(); var point; if (this.from != this.to) { // draw line this._line(ctx); // draw text if (this.text) { point = this._pointOnLine(0.5); this._text(ctx, this.text, point.x, point.y); } } else { var radius = this.length / 2 / Math.PI; var x, y; var node = this.from; if (!node.width) { node.resize(ctx); } if (node.width > node.height) { x = node.x + node.width / 2; y = node.y - radius; } else { x = node.x + radius; y = node.y - node.height / 2; } this._circle(ctx, x, y, radius); point = this._pointOnCircle(x, y, radius, 0.5); this._text(ctx, this.text, point.x, point.y); } }; /** * Get the line width of the edge. Depends on width and whether one of the * connected nodes is selected. * @return {Number} width * @private */ Graph.Edge.prototype._getLineWidth = function() { if (this.from.selected || this.to.selected) { return Math.min(this.width * 2, this.widthMax); } else { return this.width; } }; /** * Draw a line between two nodes * @param {CanvasRenderingContext2D} ctx * @private */ Graph.Edge.prototype._line = function (ctx) { // draw a straight line ctx.beginPath(); ctx.moveTo(this.from.x, this.from.y); ctx.lineTo(this.to.x, this.to.y); ctx.stroke(); }; /** * Draw a line from a node to itself, a circle * @param {CanvasRenderingContext2D} ctx * @param {Number} x * @param {Number} y * @param {Number} radius * @private */ Graph.Edge.prototype._circle = function (ctx, x, y, radius) { // draw a circle ctx.beginPath(); ctx.arc(x, y, radius, 0, 2 * Math.PI, false); ctx.stroke(); }; /** * Draw text with white background and with the middle at (x, y) * @param {CanvasRenderingContext2D} ctx * @param {String} text * @param {Number} x * @param {Number} y */ Graph.Edge.prototype._text = function (ctx, text, x, y) { if (text) { // TODO: cache the calculated size ctx.font = ((this.from.selected || this.to.selected) ? "bold " : "") + this.fontSize + "px " + this.fontFace; ctx.fillStyle = 'white'; var width = ctx.measureText(this.text).width; var height = this.fontSize; var left = x - width / 2; var top = y - height / 2; ctx.fillRect(left, top, width, height); // draw text ctx.fillStyle = this.fontColor || "black"; ctx.textAlign = "left"; ctx.textBaseline = "top"; ctx.fillText(this.text, left, top); } }; /** * Sets up the dashedLine functionality for drawing * Original code came from http://stackoverflow.com/questions/4576724/dotted-stroke-in-canvas * @author David Jordan * @date 2012-08-08 */ var CP = (typeof window !== 'undefined') && window.CanvasRenderingContext2D && CanvasRenderingContext2D.prototype; if (CP && CP.lineTo){ CP.dashedLine = function(x,y,x2,y2,dashArray){ if (!dashArray) dashArray=[10,5]; if (dashLength==0) dashLength = 0.001; // Hack for Safari var dashCount = dashArray.length; this.moveTo(x, y); var dx = (x2-x), dy = (y2-y); var slope = dy/dx; var distRemaining = Math.sqrt( dx*dx + dy*dy ); var dashIndex=0, draw=true; while (distRemaining>=0.1){ var dashLength = dashArray[dashIndex++%dashCount]; if (dashLength > distRemaining) dashLength = distRemaining; var xStep = Math.sqrt( dashLength*dashLength / (1 + slope*slope) ); if (dx<0) xStep = -xStep; x += xStep y += slope*xStep; this[draw ? 'lineTo' : 'moveTo'](x,y); distRemaining -= dashLength; draw = !draw; } } } /** * Redraw a edge as a dashed line * Draw this edge in the given canvas * @author David Jordan * @date 2012-08-08 * The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d"); * @param {CanvasRenderingContext2D} ctx */ Graph.Edge.prototype._drawDashLine = function(ctx) { // set style ctx.strokeStyle = this.color; ctx.lineWidth = this._getLineWidth(); // draw dashed line ctx.beginPath(); ctx.lineCap = 'round'; if (this.altdashlength != undefined) //If an alt dash value has been set add to the array this value { ctx.dashedLine(this.from.x,this.from.y,this.to.x,this.to.y,[this.dashlength,this.dashgap,this.altdashlength,this.dashgap]); } else if (this.dashlength != undefined && this.dashgap != undefined) //If a dash and gap value has been set add to the array this value { ctx.dashedLine(this.from.x,this.from.y,this.to.x,this.to.y,[this.dashlength,this.dashgap]); } else //If all else fails draw a line { ctx.moveTo(this.from.x, this.from.y); ctx.lineTo(this.to.x, this.to.y); } ctx.stroke(); // draw text if (this.text) { var point = this._pointOnLine(0.5); this._text(ctx, this.text, point.x, point.y); } }; /** * Get a point on a line * @param {Number} percentage. Value between 0 (line start) and 1 (line end) * @return {Object} point * @private */ Graph.Edge.prototype._pointOnLine = function (percentage) { return { x: (1 - percentage) * this.from.x + percentage * this.to.x, y: (1 - percentage) * this.from.y + percentage * this.to.y } }; /** * Get a point on a circle * @param {Number} x * @param {Number} y * @param {Number} radius * @param {Number} percentage. Value between 0 (line start) and 1 (line end) * @return {Object} point * @private */ Graph.Edge.prototype._pointOnCircle = function (x, y, radius, percentage) { var angle = (percentage - 3/8) * 2 * Math.PI; return { x: x + radius * Math.cos(angle), y: y - radius * Math.sin(angle) } }; /** * Redraw a edge as a line with an arrow * Draw this edge in the given canvas * The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d"); * @param {CanvasRenderingContext2D} ctx */ Graph.Edge.prototype._drawArrow = function(ctx) { var point; // set style ctx.strokeStyle = this.color; ctx.fillStyle = this.color; ctx.lineWidth = this._getLineWidth(); if (this.from != this.to) { // draw line this._line(ctx); // draw all arrows var angle = Math.atan2((this.to.y - this.from.y), (this.to.x - this.from.x)); var length = 10 + 5 * this.width; // TODO: make customizable? for (var a in this.arrows) { if (this.arrows.hasOwnProperty(a)) { point = this._pointOnLine(this.arrows[a]); ctx.arrow(point.x, point.y, angle, length); ctx.fill(); ctx.stroke(); } } // draw text if (this.text) { point = this._pointOnLine(0.5); this._text(ctx, this.text, point.x, point.y); } } else { // draw circle var radius = this.length / 2 / Math.PI; var x, y; var node = this.from; if (!node.width) { node.resize(ctx); } if (node.width > node.height) { x = node.x + node.width / 2; y = node.y - radius; } else { x = node.x + radius; y = node.y - node.height / 2; } this._circle(ctx, x, y, radius); // draw all arrows var angle = 0.2 * Math.PI; var length = 10 + 5 * this.width; // TODO: make customizable? for (var a in this.arrows) { if (this.arrows.hasOwnProperty(a)) { point = this._pointOnCircle(x, y, radius, this.arrows[a]); ctx.arrow(point.x, point.y, angle, length); ctx.fill(); ctx.stroke(); } } // draw text if (this.text) { point = this._pointOnCircle(x, y, radius, 0.5); this._text(ctx, this.text, point.x, point.y); } } }; /** * Redraw a edge as a line with an arrow * Draw this edge in the given canvas * The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d"); * @param {CanvasRenderingContext2D} ctx */ Graph.Edge.prototype._drawArrowEnd = function(ctx) { // set style ctx.strokeStyle = this.color; ctx.fillStyle = this.color; ctx.lineWidth = this._getLineWidth(); // draw line var angle, length; if (this.from != this.to) { // calculate length and angle of the line angle = Math.atan2((this.to.y - this.from.y), (this.to.x - this.from.x)); var dx = (this.to.x - this.from.x); var dy = (this.to.y - this.from.y); var lEdge = Math.sqrt(dx * dx + dy * dy); var lFrom = this.to.distanceToBorder(ctx, angle + Math.PI); var pFrom = (lEdge - lFrom) / lEdge; var xFrom = (pFrom) * this.from.x + (1 - pFrom) * this.to.x; var yFrom = (pFrom) * this.from.y + (1 - pFrom) * this.to.y; var lTo = this.to.distanceToBorder(ctx, angle); var pTo = (lEdge - lTo) / lEdge; var xTo = (1 - pTo) * this.from.x + pTo * this.to.x; var yTo = (1 - pTo) * this.from.y + pTo * this.to.y; ctx.beginPath(); ctx.moveTo(xFrom, yFrom); ctx.lineTo(xTo, yTo); ctx.stroke(); // draw arrow at the end of the line length = 10 + 5 * this.width; // TODO: make customizable? ctx.arrow(xTo, yTo, angle, length); ctx.fill(); ctx.stroke(); // draw text if (this.text) { var point = this._pointOnLine(0.5); this._text(ctx, this.text, point.x, point.y); } } else { // draw circle var radius = this.length / 2 / Math.PI; var x, y, arrow; var node = this.from; if (!node.width) { node.resize(ctx); } if (node.width > node.height) { x = node.x + node.width / 2; y = node.y - radius; arrow = { x: x, y: node.y, angle: 0.9 * Math.PI }; } else { x = node.x + radius; y = node.y - node.height / 2; arrow = { x: node.x, y: y, angle: 0.6 * Math.PI }; } ctx.beginPath(); // TODO: do not draw a circle, but an arc // TODO: similarly, for a line without arrows, draw to the border of the nodes instead of the center ctx.arc(x, y, radius, 0, 2 * Math.PI, false); ctx.stroke(); // draw all arrows length = 10 + 5 * this.width; // TODO: make customizable? ctx.arrow(arrow.x, arrow.y, arrow.angle, length); ctx.fill(); ctx.stroke(); // draw text if (this.text) { point = this._pointOnCircle(x, y, radius, 0.5); this._text(ctx, this.text, point.x, point.y); } } }; /**--------------------------------------------------------------------------**/ /** * @class Images * This class loades images and keeps them stored. */ Graph.Images = function () { this.images = {}; this.callback = undefined; }; /** * Set an onload callback function. This will be called each time an image * is loaded * @param {function} callback */ Graph.Images.prototype.setOnloadCallback = function(callback) { this.callback = callback; }; /** * * @param {string} url Url of the image * @return {Image} img The image object */ Graph.Images.prototype.load = function(url) { var img = this.images[url]; if (img == undefined) { // create the image var images = this; img = new Image(); this.images[url] = img; img.onload = function() { if (images.callback) { images.callback(this); } }; img.src = url; } return img; }; /**--------------------------------------------------------------------------**/ /** * @class Groups * This class can store groups and properties specific for groups. */ Graph.Groups = function () { this.clear(); this.defaultIndex = 0; }; /** * default constants for group colors */ Graph.Groups.DEFAULT = [ {"borderColor": "#2B7CE9", "backgroundColor": "#97C2FC", "highlightColor": "#D2E5FF"}, // blue {"borderColor": "#FFA500", "backgroundColor": "#FFFF00", "highlightColor": "#FFFFA3"}, // yellow {"borderColor": "#FA0A10", "backgroundColor": "#FB7E81", "highlightColor": "#FFAFB1"}, // red {"borderColor": "#41A906", "backgroundColor": "#7BE141", "highlightColor": "#A1EC76"}, // green {"borderColor": "#E129F0", "backgroundColor": "#EB7DF4", "highlightColor": "#F0B3F5"}, // magenta {"borderColor": "#7C29F0", "backgroundColor": "#AD85E4", "highlightColor": "#D3BDF0"}, // purple {"borderColor": "#C37F00", "backgroundColor": "#FFA807", "highlightColor": "#FFCA66"}, // orange {"borderColor": "#4220FB", "backgroundColor": "#6E6EFD", "highlightColor": "#9B9BFD"}, // darkblue {"borderColor": "#FD5A77", "backgroundColor": "#FFC0CB", "highlightColor": "#FFD1D9"}, // pink {"borderColor": "#4AD63A", "backgroundColor": "#C2FABC", "highlightColor": "#E6FFE3"} // mint ]; /** * Clear all groups */ Graph.Groups.prototype.clear = function () { this.groups = {}; this.groups.length = function() { var i = 0; for ( var p in this ) { if (this.hasOwnProperty(p)) { i++; } } return i; } }; /** * get group properties of a groupname. If groupname is not found, a new group * is added. * @param {*} groupname Can be a number, string, Date, etc. * @return {Object} group The created group, containing all group properties */ Graph.Groups.prototype.get = function (groupname) { var group = this.groups[groupname]; if (group == undefined) { // create new group var index = this.defaultIndex % Graph.Groups.DEFAULT.length; this.defaultIndex++; group = {}; group.borderColor = Graph.Groups.DEFAULT[index].borderColor; group.backgroundColor = Graph.Groups.DEFAULT[index].backgroundColor; group.highlightColor = Graph.Groups.DEFAULT[index].highlightColor; this.groups[groupname] = group; } return group; }; /** * Add a custom group style * @param {String} groupname * @param {Object} style An object containing borderColor, * backgroundColor, etc. * @return {Object} group The created group object */ Graph.Groups.prototype.add = function (groupname, style) { this.groups[groupname] = style; return style; }; /** * Check if given object is a Javascript Array * @param {*} obj * @return {Boolean} isArray true if the given object is an array */ // See http://stackoverflow.com/questions/2943805/javascript-instanceof-typeof-in-gwt-jsni Graph.isArray = function (obj) { if (obj instanceof Array) { return true; } return (Object.prototype.toString.call(obj) === '[object Array]'); }; /**--------------------------------------------------------------------------**/ /** * Popup is a class to create a popup window with some text * @param {Element} container The container object. * @param {Number} x * @param {Number} y * @param {String} text */ Graph.Popup = function (container, x, y, text) { if (container) { this.container = container; } else { this.container = document.body; } this.x = 0; this.y = 0; this.padding = 5; if (x !== undefined && y !== undefined ) { this.setPosition(x, y); } if (text !== undefined) { this.setText(text); } // create the frame this.frame = document.createElement("div"); var style = this.frame.style; style.position = "absolute"; style.visibility = "hidden"; style.border = "1px solid #666"; style.color = "black"; style.padding = this.padding + "px"; style.backgroundColor = "#FFFFC6"; style.borderRadius = "3px"; style.MozBorderRadius = "3px"; style.WebkitBorderRadius = "3px"; style.boxShadow = "3px 3px 10px rgba(128, 128, 128, 0.5)"; style.whiteSpace = "nowrap"; this.container.appendChild(this.frame); }; /** * @param {number} x Horizontal position of the popup window * @param {number} y Vertical position of the popup window */ Graph.Popup.prototype.setPosition = function(x, y) { this.x = parseInt(x); this.y = parseInt(y); }; /** * Set the text for the popup window. This can be HTML code * @param {string} text */ Graph.Popup.prototype.setText = function(text) { this.frame.innerHTML = text; }; /** * Show the popup window * @param {boolean} show Optional. Show or hide the window */ Graph.Popup.prototype.show = function (show) { if (show === undefined) { show = true; } if (show) { var height = this.frame.clientHeight; var width = this.frame.clientWidth; var maxHeight = this.frame.parentNode.clientHeight; var maxWidth = this.frame.parentNode.clientWidth; var top = (this.y - height); if (top + height + this.padding > maxHeight) { top = maxHeight - height - this.padding; } if (top < this.padding) { top = this.padding; } var left = this.x; if (left + width + this.padding > maxWidth) { left = maxWidth - width - this.padding; } if (left < this.padding) { left = this.padding; } this.frame.style.left = left + "px"; this.frame.style.top = top + "px"; this.frame.style.visibility = "visible"; } else { this.hide(); } }; /** * Hide the popup window */ Graph.Popup.prototype.hide = function () { this.frame.style.visibility = "hidden"; }; /**--------------------------------------------------------------------------**/ if (typeof CanvasRenderingContext2D !== 'undefined') { /** * Draw a circle shape */ CanvasRenderingContext2D.prototype.circle = function(x, y, r) { this.beginPath(); this.arc(x, y, r, 0, 2*Math.PI, false); }; /** * Draw a square shape * @param {Number} x horizontal center * @param {Number} y vertical center * @param {Number} r size, width and height of the square */ CanvasRenderingContext2D.prototype.square = function(x, y, r) { this.beginPath(); this.rect(x - r, y - r, r * 2, r * 2); }; /** * Draw a triangle shape * @param {Number} x horizontal center * @param {Number} y vertical center * @param {Number} r radius, half the length of the sides of the triangle */ CanvasRenderingContext2D.prototype.triangle = function(x, y, r) { // http://en.wikipedia.org/wiki/Equilateral_triangle this.beginPath(); var s = r * 2; var s2 = s / 2; var ir = Math.sqrt(3) / 6 * s; // radius of inner circle var h = Math.sqrt(s * s - s2 * s2); // height this.moveTo(x, y - (h - ir)); this.lineTo(x + s2, y + ir); this.lineTo(x - s2, y + ir); this.lineTo(x, y - (h - ir)); this.closePath(); }; /** * Draw a triangle shape in downward orientation * @param {Number} x horizontal center * @param {Number} y vertical center * @param {Number} r radius */ CanvasRenderingContext2D.prototype.triangleDown = function(x, y, r) { // http://en.wikipedia.org/wiki/Equilateral_triangle this.beginPath(); var s = r * 2; var s2 = s / 2; var ir = Math.sqrt(3) / 6 * s; // radius of inner circle var h = Math.sqrt(s * s - s2 * s2); // height this.moveTo(x, y + (h - ir)); this.lineTo(x + s2, y - ir); this.lineTo(x - s2, y - ir); this.lineTo(x, y + (h - ir)); this.closePath(); }; /** * Draw a star shape, a star with 5 points * @param {Number} x horizontal center * @param {Number} y vertical center * @param {Number} r radius, half the length of the sides of the triangle */ CanvasRenderingContext2D.prototype.star = function(x, y, r) { // http://www.html5canvastutorials.com/labs/html5-canvas-star-spinner/ this.beginPath(); for (var n = 0; n < 10; n++) { var radius = (n % 2 === 0) ? r * 1.3 : r * 0.5; this.lineTo( x + radius * Math.sin(n * 2 * Math.PI / 10), y - radius * Math.cos(n * 2 * Math.PI / 10) ); } this.closePath(); }; /** * http://stackoverflow.com/questions/1255512/how-to-draw-a-rounded-rectangle-on-html-canvas */ CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) { var r2d = Math.PI/180; if( w - ( 2 * r ) < 0 ) { r = ( w / 2 ); } //ensure that the radius isn't too large for x if( h - ( 2 * r ) < 0 ) { r = ( h / 2 ); } //ensure that the radius isn't too large for y this.beginPath(); this.moveTo(x+r,y); this.lineTo(x+w-r,y); this.arc(x+w-r,y+r,r,r2d*270,r2d*360,false); this.lineTo(x+w,y+h-r); this.arc(x+w-r,y+h-r,r,0,r2d*90,false); this.lineTo(x+r,y+h); this.arc(x+r,y+h-r,r,r2d*90,r2d*180,false); this.lineTo(x,y+r); this.arc(x+r,y+r,r,r2d*180,r2d*270,false); }; /** * http://stackoverflow.com/questions/2172798/how-to-draw-an-oval-in-html5-canvas */ CanvasRenderingContext2D.prototype.ellipse = function(x, y, w, h) { var kappa = .5522848, ox = (w / 2) * kappa, // control point offset horizontal oy = (h / 2) * kappa, // control point offset vertical xe = x + w, // x-end ye = y + h, // y-end xm = x + w / 2, // x-middle ym = y + h / 2; // y-middle this.beginPath(); this.moveTo(x, ym); this.bezierCurveTo(x, ym - oy, xm - ox, y, xm, y); this.bezierCurveTo(xm + ox, y, xe, ym - oy, xe, ym); this.bezierCurveTo(xe, ym + oy, xm + ox, ye, xm, ye); this.bezierCurveTo(xm - ox, ye, x, ym + oy, x, ym); }; /** * http://stackoverflow.com/questions/2172798/how-to-draw-an-oval-in-html5-canvas */ CanvasRenderingContext2D.prototype.database = function(x, y, w, h) { var f = 1/3; var wEllipse = w; var hEllipse = h * f; var kappa = .5522848, ox = (wEllipse / 2) * kappa, // control point offset horizontal oy = (hEllipse / 2) * kappa, // control point offset vertical xe = x + wEllipse, // x-end ye = y + hEllipse, // y-end xm = x + wEllipse / 2, // x-middle ym = y + hEllipse / 2, // y-middle ymb = y + (h - hEllipse/2), // y-midlle, bottom ellipse yeb = y + h; // y-end, bottom ellipse this.beginPath(); this.moveTo(xe, ym); this.bezierCurveTo(xe, ym + oy, xm + ox, ye, xm, ye); this.bezierCurveTo(xm - ox, ye, x, ym + oy, x, ym); this.bezierCurveTo(x, ym - oy, xm - ox, y, xm, y); this.bezierCurveTo(xm + ox, y, xe, ym - oy, xe, ym); this.lineTo(xe, ymb); this.bezierCurveTo(xe, ymb + oy, xm + ox, yeb, xm, yeb); this.bezierCurveTo(xm - ox, yeb, x, ymb + oy, x, ymb); this.lineTo(x, ym); }; /** * Draw an arrow point (no line) */ CanvasRenderingContext2D.prototype.arrow = function(x, y, angle, length) { // tail var xt = x - length * Math.cos(angle); var yt = y - length * Math.sin(angle); // inner tail // TODO: allow to customize different shapes var xi = x - length * 0.9 * Math.cos(angle); var yi = y - length * 0.9 * Math.sin(angle); // left var xl = xt + length / 3 * Math.cos(angle + 0.5 * Math.PI); var yl = yt + length / 3 * Math.sin(angle + 0.5 * Math.PI); // right var xr = xt + length / 3 * Math.cos(angle - 0.5 * Math.PI); var yr = yt + length / 3 * Math.sin(angle - 0.5 * Math.PI); this.beginPath(); this.moveTo(x, y); this.lineTo(xl, yl); this.lineTo(xi, yi); this.lineTo(xr, yr); this.closePath(); }; // TODO: add diamond shape } /*----------------------------------------------------------------------------*/ // utility methods Graph.util = {}; /** * Parse a text source containing data in DOT language into a JSON object. * The object contains two lists: one with nodes and one with edges. * @param {String} data Text containing a graph in DOT-notation * @return {Object} json An object containing two parameters: * {Object[]} nodes * {Object[]} edges */ Graph.util.parseDOT = function (data) { /** * Test whether given character is a whitespace character * @param {String} c * @return {Boolean} isWhitespace */ function isWhitespace(c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; } /** * Test whether given character is a delimeter * @param {String} c * @return {Boolean} isDelimeter */ function isDelimeter(c) { return '[]{}();,=->'.indexOf(c) != -1; } var i = -1; // current index in the data var c = ''; // current character in the data /** * Read the next character from the data */ function next() { i++; c = data[i]; } /** * Preview the next character in the data * @returns {String} nextChar */ function previewNext () { return data[i + 1]; } /** * Preview the next character in the data * @returns {String} nextChar */ function previewPrevious () { return data[i + 1]; } /** * Get a text description of the the current index in the data * @return {String} desc */ function pos() { return '(char ' + i + ')'; } /** * Skip whitespace and comments */ function parseWhitespace() { // skip whitespace while (c && isWhitespace(c)) { next(); } // test for comment var cNext = data[i + 1]; var cPrev = data[i - 1]; var c2 = c + cNext; if (c2 == '/*') { // block comment. skip until the block is closed while (c && !(c == '*' && data[i + 1] == '/')) { next(); } next(); next(); parseWhitespace(); } else if (c2 == '//' || (c == '#' && cPrev == '\n')) { // line comment. skip until the next return while (c && c != '\n') { next(); } next(); parseWhitespace(); } } /** * Parse a string * The string may be enclosed by double quotes * @return {String | undefined} value */ function parseString() { parseWhitespace(); var name = ''; if (c == '"') { next(); while (c && c != '"') { name += c; next(); } next(); // skip the closing quote } else { while (c && !isWhitespace(c) && !isDelimeter(c)) { name += c; next(); } // cast string to number or boolean var number = Number(name); if (!isNaN(number)) { name = number; } else if (name == 'true') { name = true; } else if (name == 'false') { name = false; } else if (name == 'null') { name = null; } } return name; } /** * Parse a value, can be a string, number, or boolean. * The value may be enclosed by double quotes * @return {String | Number | Boolean | undefined} value */ function parseValue() { parseWhitespace(); if (c == '"') { return parseString(); } else { var value = parseString(); if (value != undefined) { // cast string to number or boolean var number = Number(value); if (!isNaN(number)) { value = number; } else if (value == 'true') { value = true; } else if (value == 'false') { value = false; } else if (value == 'null') { value = null; } } return value; } } /** * Parse a set with attributes, * for example [label="1.000", style=solid] * @return {Object | undefined} attr */ function parseAttributes() { parseWhitespace(); if (c == '[') { next(); var attr = {}; while (c && c != ']') { parseWhitespace(); var name = parseString(); if (!name) { throw new SyntaxError('Attribute name expected ' + pos()); } parseWhitespace(); if (c != '=') { throw new SyntaxError('Equal sign = expected ' + pos()); } next(); var value = parseValue(); if (!value) { throw new SyntaxError('Attribute value expected ' + pos()); } attr[name] = value; parseWhitespace(); if (c ==',') { next(); } } next(); return attr; } else { return undefined; } } /** * Parse a directed or undirected arrow '->' or '--' * @return {String | undefined} arrow */ function parseArrow() { parseWhitespace(); if (c == '-') { next(); if (c == '>' || c == '-') { var arrow = '-' + c; next(); return arrow; } else { throw new SyntaxError('Arrow "->" or "--" expected ' + pos()); } } return undefined; } /** * Parse a line separator ';' * @return {String | undefined} separator */ function parseSeparator() { parseWhitespace(); if (c == ';') { next(); return ';'; } return undefined; } /** * Merge all properties of object b into object b * @param {Object} a * @param {Object} b */ function merge (a, b) { if (a && b) { for (var name in b) { if (b.hasOwnProperty(name)) { a[name] = b[name]; } } } } var nodeMap = {}; var edgeList = []; /** * Register a node with attributes * @param {String} id * @param {Object} [attr] */ function addNode(id, attr) { var node = { id: String(id), attr: attr || {} }; if (!nodeMap[id]) { nodeMap[id] = node; } else { merge(nodeMap[id].attr, node.attr); } } /** * Register an edge * @param {String} from * @param {String} to * @param {String} type A string "->" or "--" * @param {Object} [attr] */ function addEdge(from, to, type, attr) { edgeList.push({ from: String(from), to: String(to), type: type, attr: attr || {} }); } // find the opening curly bracket next(); while (c && c != '{') { next(); } if (c != '{') { throw new SyntaxError('Invalid data. Curly bracket { expected ' + pos()) } next(); // parse all data until a closing curly bracket is encountered while (c && c != '}') { // parse node id and optional node attributes var id = parseString(); if (id == undefined) { throw new SyntaxError('String with id expected ' + pos()); } var attr = parseAttributes(); addNode(id, attr); // TODO: parse global attributes "graph", "node", "edge" // parse arrow var type = parseArrow(); while (type) { // parse node id var prevId = id; id = parseString(); if (id == undefined) { throw new SyntaxError('String with id expected ' + pos()); } addNode(id); // parse edge attributes and register edge attr = parseAttributes(); addEdge(prevId, id, type, attr); // parse next arrow (optional) type = parseArrow(); } // parse separator (optional) parseSeparator(); parseWhitespace(); } if (c != '}') { throw new SyntaxError('Invalid data. Curly bracket } expected'); } // crop data between the curly brackets var start = data.indexOf('{'); var end = data.indexOf('}', start); var text = (start != -1 && end != -1) ? data.substring(start + 1, end) : undefined; if (!text) { throw new Error('Invalid data. no curly brackets containing data found'); } // return the results var nodeList = []; for (id in nodeMap) { if (nodeMap.hasOwnProperty(id)) { nodeList.push(nodeMap[id]); } } return { nodes: nodeList, edges: edgeList } }; /** * Convert a string containing a graph in DOT language into a map containing * with nodes and edges in the format of graph. * @param {String} data Text containing a graph in DOT-notation * @return {Object} graphData */ Graph.util.DOTToGraph = function (data) { // parse the DOT file var dotData = Graph.util.parseDOT(data); var graphData = { nodes: [], edges: [], options: { nodes: {}, edges: {} } }; /** * Merge the properties of object b into object a, and adjust properties * not supported by Graph (for example replace "shape" with "style" * @param {Object} a * @param {Object} b * @param {Array} [ignore] Optional array with property names to be ignored */ function merge (a, b, ignore) { for (var prop in b) { if (b.hasOwnProperty(prop) && (!ignore || ignore.indexOf(prop) == -1)) { a[prop] = b[prop]; } } // Convert aliases to configuration settings supported by Graph if (a.label) { a.text = a.label; delete a.label; } if (a.shape) { a.style = a.shape; delete a.shape; } } dotData.nodes.forEach(function (node) { if (node.id.toLowerCase() == 'graph') { merge(graphData.options, node.attr); } else if (node.id.toLowerCase() == 'node') { merge(graphData.options.nodes, node.attr); } else if (node.id.toLowerCase() == 'edge') { merge(graphData.options.edges, node.attr); } else { var graphNode = {}; graphNode.id = node.id; graphNode.text = node.id; merge(graphNode, node.attr); graphData.nodes.push(graphNode); } }); dotData.edges.forEach(function (edge) { var graphEdge = {}; graphEdge.from = edge.from; graphEdge.to = edge.to; graphEdge.text = edge.id; graphEdge.style = (edge.type == '->') ? 'arrow-end' : 'line'; merge(graphEdge, edge.attr); graphData.edges.push(graphEdge); }); return graphData; };