/**
|
|
* @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
|
|
shape: 'ellipse',
|
|
image: undefined,
|
|
widthMin: 16, // px
|
|
widthMax: 64, // px
|
|
fontColor: 'black',
|
|
fontSize: 14, // px
|
|
//fontFace: verdana,
|
|
fontFace: 'arial',
|
|
color: {
|
|
border: '#2B7CE9',
|
|
background: '#97C2FC',
|
|
highlight: {
|
|
border: '#2B7CE9',
|
|
background: '#D2E5FF'
|
|
}
|
|
},
|
|
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
|
|
dash: {
|
|
length: 10,
|
|
gap: 5,
|
|
altLength: undefined
|
|
}
|
|
},
|
|
minForce: 0.05,
|
|
minVelocity: 0.02, // px/s
|
|
maxIterations: 1000 // maximum number of iteration to stabilize
|
|
};
|
|
|
|
var graph = this;
|
|
this.nodes = {}; // object with Node objects
|
|
this.edges = {}; // object with Edge objects
|
|
// TODO: create a counter to keep track on the number of nodes having values
|
|
// TODO: create a counter to keep track on the number of nodes currently moving
|
|
// TODO: create a counter to keep track on the number of edges having values
|
|
|
|
this.nodesData = null; // A DataSet or DataView
|
|
this.edgesData = null; // A DataSet or DataView
|
|
|
|
// create event listeners used to subscribe on the DataSets of the nodes and edges
|
|
var me = this;
|
|
this.nodesListeners = {
|
|
'add': function (event, params) {
|
|
me._addNodes(params.items);
|
|
me.start();
|
|
},
|
|
'update': function (event, params) {
|
|
me._updateNodes(params.items);
|
|
me.start();
|
|
},
|
|
'remove': function (event, params) {
|
|
me._removeNodes(params.items);
|
|
me.start();
|
|
}
|
|
};
|
|
this.edgesListeners = {
|
|
'add': function (event, params) {
|
|
me._addEdges(params.items);
|
|
me.start();
|
|
},
|
|
'update': function (event, params) {
|
|
me._updateEdges(params.items);
|
|
me.start();
|
|
},
|
|
'remove': function (event, params) {
|
|
me._removeEdges(params.items);
|
|
me.start();
|
|
}
|
|
};
|
|
|
|
this.groups = new Groups(); // object with groups
|
|
this.images = new Images(); // object with images
|
|
this.images.setOnloadCallback(function () {
|
|
graph._redraw();
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
|
|
/**
|
|
* Set nodes and edges, and optionally options as well.
|
|
*
|
|
* @param {Object} data Object containing parameters:
|
|
* {Array | DataSet | DataView} [nodes] Array with nodes
|
|
* {Array | DataSet | DataView} [edges] Array with edges
|
|
* {String} [dot] String containing data in DOT format
|
|
* {Options} [options] Object with options
|
|
*/
|
|
Graph.prototype.setData = function(data) {
|
|
if (data && data.dot && (data.nodes || data.edges)) {
|
|
throw new SyntaxError('Data must contain either parameter "dot" or ' +
|
|
' parameter pair "nodes" and "edges", but not both.');
|
|
}
|
|
|
|
// set options
|
|
this.setOptions(data && data.options);
|
|
|
|
// set all data
|
|
if (data && data.dot) {
|
|
// parse DOT file
|
|
if(data && data.dot) {
|
|
var dotData = vis.util.DOTToGraph(data.dot);
|
|
this.setData(dotData);
|
|
return;
|
|
}
|
|
}
|
|
else {
|
|
this._setNodes(data && data.nodes);
|
|
this._setEdges(data && data.edges);
|
|
}
|
|
|
|
// find a stable position or start animating to a stable position
|
|
if (this.stabilize) {
|
|
this._doStabilize();
|
|
}
|
|
this.start();
|
|
};
|
|
|
|
/**
|
|
* 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.dash) {
|
|
if (options.edges.dash.length != undefined) {
|
|
this.constants.edges.dash.length = options.edges.dash.length;
|
|
}
|
|
if (options.edges.dash.gap != undefined) {
|
|
this.constants.edges.dash.gap = options.edges.dash.gap;
|
|
}
|
|
if (options.edges.dash.altLength != undefined) {
|
|
this.constants.edges.dash.altLength = options.edges.dash.altLength;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (options.nodes) {
|
|
for (prop in options.nodes) {
|
|
if (options.nodes.hasOwnProperty(prop)) {
|
|
this.constants.nodes[prop] = options.nodes[prop];
|
|
}
|
|
}
|
|
|
|
if (options.nodes.color) {
|
|
this.constants.nodes.color = Node.parseColor(options.nodes.color);
|
|
}
|
|
|
|
/*
|
|
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.setSize(this.width, this.height);
|
|
this._setTranslation(this.frame.clientWidth / 2, this.frame.clientHeight / 2);
|
|
this._setScale(1);
|
|
};
|
|
|
|
/**
|
|
* fire an event
|
|
* @param {String} event The name of an event, for example 'select'
|
|
* @param {Object} params Optional object with event parameters
|
|
* @private
|
|
*/
|
|
Graph.prototype._trigger = function (event, params) {
|
|
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.
|
|
* @private
|
|
*/
|
|
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);
|
|
}
|
|
|
|
var me = this;
|
|
this.drag = {};
|
|
this.pinch = {};
|
|
this.hammer = Hammer(this.frame.canvas, {
|
|
prevent_default: true
|
|
});
|
|
this.hammer.on('tap', me._onTap.bind(me) );
|
|
this.hammer.on('hold', me._onHold.bind(me) );
|
|
this.hammer.on('pinch', me._onPinch.bind(me) );
|
|
this.hammer.on('touch', me._onTouch.bind(me) );
|
|
this.hammer.on('dragstart', me._onDragStart.bind(me) );
|
|
this.hammer.on('drag', me._onDrag.bind(me) );
|
|
this.hammer.on('dragend', me._onDragEnd.bind(me) );
|
|
this.hammer.on('mousewheel',me._onMouseWheel.bind(me) );
|
|
this.hammer.on('DOMMouseScroll',me._onMouseWheel.bind(me) ); // for FF
|
|
this.hammer.on('mousemove', me._onMouseMoveTitle.bind(me) );
|
|
|
|
// add the frame to the container element
|
|
this.containerElement.appendChild(this.frame);
|
|
};
|
|
|
|
/**
|
|
*
|
|
* @param {{x: Number, y: Number}} pointer
|
|
* @return {Number | null} node
|
|
* @private
|
|
*/
|
|
Graph.prototype._getNodeAt = function (pointer) {
|
|
var x = this._canvasToX(pointer.x);
|
|
var y = this._canvasToY(pointer.y);
|
|
|
|
var obj = {
|
|
left: x,
|
|
top: y,
|
|
right: x,
|
|
bottom: y
|
|
};
|
|
|
|
// if there are overlapping nodes, select the last one, this is the
|
|
// one which is drawn on top of the others
|
|
var overlappingNodes = this._getNodesOverlappingWith(obj);
|
|
return (overlappingNodes.length > 0) ?
|
|
overlappingNodes[overlappingNodes.length - 1] : null;
|
|
};
|
|
|
|
/**
|
|
* Get the pointer location from a touch location
|
|
* @param {{pageX: Number, pageY: Number}} touch
|
|
* @return {{x: Number, y: Number}} pointer
|
|
* @private
|
|
*/
|
|
Graph.prototype._getPointer = function (touch) {
|
|
return {
|
|
x: touch.pageX - vis.util.getAbsoluteLeft(this.frame.canvas),
|
|
y: touch.pageY - vis.util.getAbsoluteTop(this.frame.canvas)
|
|
};
|
|
};
|
|
|
|
/**
|
|
* On start of a touch gesture, store the pointer
|
|
* @param event
|
|
* @private
|
|
*/
|
|
Graph.prototype._onTouch = function (event) {
|
|
this.drag.pointer = this._getPointer(event.gesture.touches[0]);
|
|
this.drag.pinched = false;
|
|
this.pinch.scale = this._getScale();
|
|
};
|
|
|
|
/**
|
|
* handle drag start event
|
|
* @private
|
|
*/
|
|
Graph.prototype._onDragStart = function () {
|
|
var drag = this.drag;
|
|
|
|
drag.selection = [];
|
|
drag.translation = this._getTranslation();
|
|
drag.nodeId = this._getNodeAt(drag.pointer);
|
|
// note: drag.pointer is set in _onTouch to get the initial touch location
|
|
|
|
var node = this.nodes[drag.nodeId];
|
|
if (node) {
|
|
// select the clicked node if not yet selected
|
|
if (!node.isSelected()) {
|
|
this._selectNodes([drag.nodeId]);
|
|
}
|
|
|
|
// create an array with the selected nodes and their original location and status
|
|
var me = this;
|
|
this.selection.forEach(function (id) {
|
|
var node = me.nodes[id];
|
|
if (node) {
|
|
var s = {
|
|
id: id,
|
|
node: node,
|
|
|
|
// store original x, y, xFixed and yFixed, make the node temporarily Fixed
|
|
x: node.x,
|
|
y: node.y,
|
|
xFixed: node.xFixed,
|
|
yFixed: node.yFixed
|
|
};
|
|
|
|
node.xFixed = true;
|
|
node.yFixed = true;
|
|
|
|
drag.selection.push(s);
|
|
}
|
|
});
|
|
|
|
}
|
|
};
|
|
|
|
/**
|
|
* handle drag event
|
|
* @private
|
|
*/
|
|
Graph.prototype._onDrag = function (event) {
|
|
if (this.drag.pinched) {
|
|
return;
|
|
}
|
|
|
|
var pointer = this._getPointer(event.gesture.touches[0]);
|
|
|
|
var me = this,
|
|
drag = this.drag,
|
|
selection = drag.selection;
|
|
if (selection && selection.length) {
|
|
// calculate delta's and new location
|
|
var deltaX = pointer.x - drag.pointer.x,
|
|
deltaY = pointer.y - drag.pointer.y;
|
|
|
|
// update position of all selected nodes
|
|
selection.forEach(function (s) {
|
|
var node = s.node;
|
|
|
|
if (!s.xFixed) {
|
|
node.x = me._canvasToX(me._xToCanvas(s.x) + deltaX);
|
|
}
|
|
|
|
if (!s.yFixed) {
|
|
node.y = me._canvasToY(me._yToCanvas(s.y) + deltaY);
|
|
}
|
|
});
|
|
|
|
// start animation if not yet running
|
|
if (!this.moving) {
|
|
this.moving = true;
|
|
this.start();
|
|
}
|
|
}
|
|
else {
|
|
// move the graph
|
|
var diffX = pointer.x - this.drag.pointer.x;
|
|
var diffY = pointer.y - this.drag.pointer.y;
|
|
|
|
this._setTranslation(
|
|
this.drag.translation.x + diffX,
|
|
this.drag.translation.y + diffY);
|
|
this._redraw();
|
|
|
|
this.moved = true;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* handle drag start event
|
|
* @private
|
|
*/
|
|
Graph.prototype._onDragEnd = function () {
|
|
var selection = this.drag.selection;
|
|
if (selection) {
|
|
selection.forEach(function (s) {
|
|
// restore original xFixed and yFixed
|
|
s.node.xFixed = s.xFixed;
|
|
s.node.yFixed = s.yFixed;
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* handle tap/click event: select/unselect a node
|
|
* @private
|
|
*/
|
|
Graph.prototype._onTap = function (event) {
|
|
var pointer = this._getPointer(event.gesture.touches[0]);
|
|
|
|
var nodeId = this._getNodeAt(pointer);
|
|
var node = this.nodes[nodeId];
|
|
if (node) {
|
|
// select this node
|
|
this._selectNodes([nodeId]);
|
|
|
|
if (!this.moving) {
|
|
this._redraw();
|
|
}
|
|
}
|
|
else {
|
|
// remove selection
|
|
this._unselectNodes();
|
|
this._redraw();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* handle long tap event: multi select nodes
|
|
* @private
|
|
*/
|
|
Graph.prototype._onHold = function (event) {
|
|
var pointer = this._getPointer(event.gesture.touches[0]);
|
|
var nodeId = this._getNodeAt(pointer);
|
|
var node = this.nodes[nodeId];
|
|
if (node) {
|
|
if (!node.isSelected()) {
|
|
// select this node, keep previous selection
|
|
var append = true;
|
|
this._selectNodes([nodeId], append);
|
|
}
|
|
else {
|
|
this._unselectNodes([nodeId]);
|
|
}
|
|
|
|
if (!this.moving) {
|
|
this._redraw();
|
|
}
|
|
}
|
|
else {
|
|
// Do nothing
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle pinch event
|
|
* @param event
|
|
* @private
|
|
*/
|
|
Graph.prototype._onPinch = function (event) {
|
|
var pointer = this._getPointer(event.gesture.center);
|
|
|
|
this.drag.pinched = true;
|
|
if (!('scale' in this.pinch)) {
|
|
this.pinch.scale = 1;
|
|
}
|
|
|
|
// TODO: enable moving while pinching?
|
|
var scale = this.pinch.scale * event.gesture.scale;
|
|
this._zoom(scale, pointer)
|
|
};
|
|
|
|
/**
|
|
* Zoom the graph in or out
|
|
* @param {Number} scale a number around 1, and between 0.01 and 10
|
|
* @param {{x: Number, y: Number}} pointer
|
|
* @return {Number} appliedScale scale is limited within the boundaries
|
|
* @private
|
|
*/
|
|
Graph.prototype._zoom = function(scale, pointer) {
|
|
var scaleOld = this._getScale();
|
|
if (scale < 0.01) {
|
|
scale = 0.01;
|
|
}
|
|
if (scale > 10) {
|
|
scale = 10;
|
|
}
|
|
|
|
var translation = this._getTranslation();
|
|
var scaleFrac = scale / scaleOld;
|
|
var tx = (1 - scaleFrac) * pointer.x + translation.x * scaleFrac;
|
|
var ty = (1 - scaleFrac) * pointer.y + translation.y * scaleFrac;
|
|
|
|
this._setScale(scale);
|
|
this._setTranslation(tx, ty);
|
|
this._redraw();
|
|
|
|
return scale;
|
|
};
|
|
|
|
/**
|
|
* Event handler for mouse wheel event, used to zoom the timeline
|
|
* See http://adomas.org/javascript-mouse-wheel/
|
|
* https://github.com/EightMedia/hammer.js/issues/256
|
|
* @param {MouseEvent} event
|
|
* @private
|
|
*/
|
|
Graph.prototype._onMouseWheel = function(event) {
|
|
// 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) {
|
|
if (!('mouswheelScale' in this.pinch)) {
|
|
this.pinch.mouswheelScale = 1;
|
|
}
|
|
|
|
// calculate the new scale
|
|
var scale = this.pinch.mouswheelScale;
|
|
var zoom = delta / 10;
|
|
if (delta < 0) {
|
|
zoom = zoom / (1 - zoom);
|
|
}
|
|
scale *= (1 + zoom);
|
|
|
|
// calculate the pointer location
|
|
var gesture = util.fakeGesture(this, event);
|
|
var pointer = this._getPointer(gesture.center);
|
|
|
|
// apply the new scale
|
|
scale = this._zoom(scale, pointer);
|
|
|
|
// store the new, applied scale
|
|
this.pinch.mouswheelScale = scale;
|
|
}
|
|
|
|
// Prevent default actions caused by mouse wheel.
|
|
event.preventDefault();
|
|
};
|
|
|
|
|
|
/**
|
|
* Mouse move handler for checking whether the title moves over a node with a title.
|
|
* @param {Event} event
|
|
* @private
|
|
*/
|
|
Graph.prototype._onMouseMoveTitle = function (event) {
|
|
var gesture = util.fakeGesture(this, event);
|
|
var pointer = this._getPointer(gesture.center);
|
|
|
|
// check if the previously selected node is still selected
|
|
if (this.popupNode) {
|
|
this._checkHidePopup(pointer);
|
|
}
|
|
|
|
// start a timeout that will check if the mouse is positioned above
|
|
// an element
|
|
var me = this;
|
|
var checkShow = function() {
|
|
me._checkShowPopup(pointer);
|
|
};
|
|
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 {{x:Number, y:Number}} pointer
|
|
* @private
|
|
*/
|
|
Graph.prototype._checkShowPopup = function (pointer) {
|
|
var obj = {
|
|
left: this._canvasToX(pointer.x),
|
|
top: this._canvasToY(pointer.y),
|
|
right: this._canvasToX(pointer.x),
|
|
bottom: this._canvasToY(pointer.y)
|
|
};
|
|
|
|
var id;
|
|
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 (id in nodes) {
|
|
if (nodes.hasOwnProperty(id)) {
|
|
var node = nodes[id];
|
|
if (node.getTitle() != undefined && node.isOverlappingWith(obj)) {
|
|
this.popupNode = node;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.popupNode == undefined) {
|
|
// search the edges for overlap
|
|
var edges = this.edges;
|
|
for (id in edges) {
|
|
if (edges.hasOwnProperty(id)) {
|
|
var edge = edges[id];
|
|
if (edge.connected && (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 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(pointer.x - 3, pointer.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 {{x:Number, y:Number}} pointer
|
|
* @private
|
|
*/
|
|
Graph.prototype._checkHidePopup = function (pointer) {
|
|
if (!this.popupNode || !this._getNodeAt(pointer) ) {
|
|
this.popupNode = undefined;
|
|
if (this.popup) {
|
|
this.popup.hide();
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
* @private
|
|
*/
|
|
Graph.prototype._unselectNodes = function(selection, triggerSelect) {
|
|
var changed = false;
|
|
var i, iMax, id;
|
|
|
|
if (selection) {
|
|
// remove provided selections
|
|
for (i = 0, iMax = selection.length; i < iMax; i++) {
|
|
id = selection[i];
|
|
this.nodes[id].unselect();
|
|
|
|
var j = 0;
|
|
while (j < this.selection.length) {
|
|
if (this.selection[j] == id) {
|
|
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++) {
|
|
id = this.selection[i];
|
|
this.nodes[id].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 node ids
|
|
* @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
|
|
* @private
|
|
*/
|
|
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 selectionAlreadyThere = true;
|
|
if (selection.length != this.selection.length) {
|
|
selectionAlreadyThere = false;
|
|
}
|
|
else {
|
|
for (i = 0, iMax = Math.min(selection.length, this.selection.length); i < iMax; i++) {
|
|
if (selection[i] != this.selection[i]) {
|
|
selectionAlreadyThere = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (selectionAlreadyThere) {
|
|
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 id = selection[i];
|
|
var isDuplicate = (this.selection.indexOf(id) != -1);
|
|
if (!isDuplicate) {
|
|
this.nodes[id].select();
|
|
this.selection.push(id);
|
|
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 {Number[]} An array with id's of the overlapping nodes
|
|
* @private
|
|
*/
|
|
Graph.prototype._getNodesOverlappingWith = function (obj) {
|
|
var nodes = this.nodes,
|
|
overlappingNodes = [];
|
|
|
|
for (var id in nodes) {
|
|
if (nodes.hasOwnProperty(id)) {
|
|
if (nodes[id].isOverlappingWith(obj)) {
|
|
overlappingNodes.push(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
return overlappingNodes;
|
|
};
|
|
|
|
/**
|
|
* retrieve the currently selected nodes
|
|
* @return {Number[] | String[]} selection An array with the ids of the
|
|
* selected nodes.
|
|
*/
|
|
Graph.prototype.getSelection = function() {
|
|
return this.selection.concat([]);
|
|
};
|
|
|
|
/**
|
|
* select zero or more nodes
|
|
* @param {Number[] | String[]} selection An array with the ids of the
|
|
* selected nodes.
|
|
*/
|
|
Graph.prototype.setSelection = function(selection) {
|
|
var i, iMax, id;
|
|
|
|
if (!selection || (selection.length == undefined))
|
|
throw 'Selection must be an array with ids';
|
|
|
|
// first unselect any selected node
|
|
for (i = 0, iMax = this.selection.length; i < iMax; i++) {
|
|
id = this.selection[i];
|
|
this.nodes[id].unselect();
|
|
}
|
|
|
|
this.selection = [];
|
|
|
|
for (i = 0, iMax = selection.length; i < iMax; i++) {
|
|
id = selection[i];
|
|
|
|
var node = this.nodes[id];
|
|
if (!node) {
|
|
throw new RangeError('Node with id "' + id + '" not found');
|
|
}
|
|
node.select();
|
|
this.selection.push(id);
|
|
}
|
|
|
|
this.redraw();
|
|
};
|
|
|
|
/**
|
|
* Validate the selection: remove ids of nodes which no longer exist
|
|
* @private
|
|
*/
|
|
Graph.prototype._updateSelection = function () {
|
|
var i = 0;
|
|
while (i < this.selection.length) {
|
|
var id = this.selection[i];
|
|
if (!this.nodes[id]) {
|
|
this.selection.splice(i, 1);
|
|
}
|
|
else {
|
|
i++;
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
* @private
|
|
*/
|
|
Graph.prototype._getConnectionCount = function(level) {
|
|
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
|
|
var edges = node.edges;
|
|
for (var i = 0, iMax = edges.length; i < iMax; i++) {
|
|
var edge = edges[i];
|
|
var other = null;
|
|
|
|
// check if connected
|
|
if (edge.from == node)
|
|
other = edge.to;
|
|
else if (edge.to == node)
|
|
other = edge.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 nodes = this.nodes;
|
|
for (var id in nodes) {
|
|
if (nodes.hasOwnProperty(id)) {
|
|
var c = [nodes[id]];
|
|
for (var l = 0; l < level; l++) {
|
|
c = c.concat(getConnectedNodes(c));
|
|
}
|
|
connections.push(c);
|
|
}
|
|
}
|
|
|
|
var hubs = [];
|
|
for (var 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;
|
|
};
|
|
|
|
/**
|
|
* Set a data set with nodes for the graph
|
|
* @param {Array | DataSet | DataView} nodes The data containing the nodes.
|
|
* @private
|
|
*/
|
|
Graph.prototype._setNodes = function(nodes) {
|
|
var oldNodesData = this.nodesData;
|
|
|
|
if (nodes instanceof DataSet || nodes instanceof DataView) {
|
|
this.nodesData = nodes;
|
|
}
|
|
else if (nodes instanceof Array) {
|
|
this.nodesData = new DataSet();
|
|
this.nodesData.add(nodes);
|
|
}
|
|
else if (!nodes) {
|
|
this.nodesData = new DataSet();
|
|
}
|
|
else {
|
|
throw new TypeError('Array or DataSet expected');
|
|
}
|
|
|
|
if (oldNodesData) {
|
|
// unsubscribe from old dataset
|
|
util.forEach(this.nodesListeners, function (callback, event) {
|
|
oldNodesData.unsubscribe(event, callback);
|
|
});
|
|
}
|
|
|
|
// remove drawn nodes
|
|
this.nodes = {};
|
|
|
|
if (this.nodesData) {
|
|
// subscribe to new dataset
|
|
var me = this;
|
|
util.forEach(this.nodesListeners, function (callback, event) {
|
|
me.nodesData.subscribe(event, callback);
|
|
});
|
|
|
|
// draw all new nodes
|
|
var ids = this.nodesData.getIds();
|
|
this._addNodes(ids);
|
|
}
|
|
|
|
this._updateSelection();
|
|
};
|
|
|
|
/**
|
|
* Add nodes
|
|
* @param {Number[] | String[]} ids
|
|
* @private
|
|
*/
|
|
Graph.prototype._addNodes = function(ids) {
|
|
var id;
|
|
for (var i = 0, len = ids.length; i < len; i++) {
|
|
id = ids[i];
|
|
var data = this.nodesData.get(id);
|
|
var node = new Node(data, this.images, this.groups, this.constants);
|
|
this.nodes[id] = node; // note: this may replace an existing node
|
|
|
|
if (!node.isFixed()) {
|
|
// TODO: position new nodes in a smarter way!
|
|
var radius = this.constants.edges.length * 2;
|
|
var count = ids.length;
|
|
var angle = 2 * Math.PI * (i / count);
|
|
node.x = radius * Math.cos(angle);
|
|
node.y = radius * Math.sin(angle);
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
this._reconnectEdges();
|
|
this._updateValueRange(this.nodes);
|
|
};
|
|
|
|
/**
|
|
* Update existing nodes, or create them when not yet existing
|
|
* @param {Number[] | String[]} ids
|
|
* @private
|
|
*/
|
|
Graph.prototype._updateNodes = function(ids) {
|
|
var nodes = this.nodes,
|
|
nodesData = this.nodesData;
|
|
for (var i = 0, len = ids.length; i < len; i++) {
|
|
var id = ids[i];
|
|
var node = nodes[id];
|
|
var data = nodesData.get(id);
|
|
if (node) {
|
|
// update node
|
|
node.setProperties(data, this.constants);
|
|
}
|
|
else {
|
|
// create node
|
|
node = new Node(properties, this.images, this.groups, this.constants);
|
|
nodes[id] = node;
|
|
|
|
if (!node.isFixed()) {
|
|
this.moving = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
this._reconnectEdges();
|
|
this._updateValueRange(nodes);
|
|
};
|
|
|
|
/**
|
|
* Remove existing nodes. If nodes do not exist, the method will just ignore it.
|
|
* @param {Number[] | String[]} ids
|
|
* @private
|
|
*/
|
|
Graph.prototype._removeNodes = function(ids) {
|
|
var nodes = this.nodes;
|
|
for (var i = 0, len = ids.length; i < len; i++) {
|
|
var id = ids[i];
|
|
delete nodes[id];
|
|
}
|
|
|
|
this._reconnectEdges();
|
|
this._updateSelection();
|
|
this._updateValueRange(nodes);
|
|
};
|
|
|
|
/**
|
|
* Load edges by reading the data table
|
|
* @param {Array | DataSet | DataView} edges The data containing the edges.
|
|
* @private
|
|
* @private
|
|
*/
|
|
Graph.prototype._setEdges = function(edges) {
|
|
var oldEdgesData = this.edgesData;
|
|
|
|
if (edges instanceof DataSet || edges instanceof DataView) {
|
|
this.edgesData = edges;
|
|
}
|
|
else if (edges instanceof Array) {
|
|
this.edgesData = new DataSet();
|
|
this.edgesData.add(edges);
|
|
}
|
|
else if (!edges) {
|
|
this.edgesData = new DataSet();
|
|
}
|
|
else {
|
|
throw new TypeError('Array or DataSet expected');
|
|
}
|
|
|
|
if (oldEdgesData) {
|
|
// unsubscribe from old dataset
|
|
util.forEach(this.edgesListeners, function (callback, event) {
|
|
oldEdgesData.unsubscribe(event, callback);
|
|
});
|
|
}
|
|
|
|
// remove drawn edges
|
|
this.edges = {};
|
|
|
|
if (this.edgesData) {
|
|
// subscribe to new dataset
|
|
var me = this;
|
|
util.forEach(this.edgesListeners, function (callback, event) {
|
|
me.edgesData.subscribe(event, callback);
|
|
});
|
|
|
|
// draw all new nodes
|
|
var ids = this.edgesData.getIds();
|
|
this._addEdges(ids);
|
|
}
|
|
|
|
this._reconnectEdges();
|
|
};
|
|
|
|
/**
|
|
* Add edges
|
|
* @param {Number[] | String[]} ids
|
|
* @private
|
|
*/
|
|
Graph.prototype._addEdges = function (ids) {
|
|
var edges = this.edges,
|
|
edgesData = this.edgesData;
|
|
for (var i = 0, len = ids.length; i < len; i++) {
|
|
var id = ids[i];
|
|
|
|
var oldEdge = edges[id];
|
|
if (oldEdge) {
|
|
oldEdge.disconnect();
|
|
}
|
|
|
|
var data = edgesData.get(id);
|
|
edges[id] = new Edge(data, this, this.constants);
|
|
}
|
|
|
|
this.moving = true;
|
|
this._updateValueRange(edges);
|
|
};
|
|
|
|
/**
|
|
* Update existing edges, or create them when not yet existing
|
|
* @param {Number[] | String[]} ids
|
|
* @private
|
|
*/
|
|
Graph.prototype._updateEdges = function (ids) {
|
|
var edges = this.edges,
|
|
edgesData = this.edgesData;
|
|
for (var i = 0, len = ids.length; i < len; i++) {
|
|
var id = ids[i];
|
|
|
|
var data = edgesData.get(id);
|
|
var edge = edges[id];
|
|
if (edge) {
|
|
// update edge
|
|
edge.disconnect();
|
|
edge.setProperties(data, this.constants);
|
|
edge.connect();
|
|
}
|
|
else {
|
|
// create edge
|
|
edge = new Edge(data, this, this.constants);
|
|
this.edges[id] = edge;
|
|
}
|
|
}
|
|
|
|
this.moving = true;
|
|
this._updateValueRange(edges);
|
|
};
|
|
|
|
/**
|
|
* Remove existing edges. Non existing ids will be ignored
|
|
* @param {Number[] | String[]} ids
|
|
* @private
|
|
*/
|
|
Graph.prototype._removeEdges = function (ids) {
|
|
var edges = this.edges;
|
|
for (var i = 0, len = ids.length; i < len; i++) {
|
|
var id = ids[i];
|
|
var edge = edges[id];
|
|
if (edge) {
|
|
edge.disconnect();
|
|
delete edges[id];
|
|
}
|
|
}
|
|
|
|
this.moving = true;
|
|
this._updateValueRange(edges);
|
|
};
|
|
|
|
/**
|
|
* Reconnect all edges
|
|
* @private
|
|
*/
|
|
Graph.prototype._reconnectEdges = function() {
|
|
var id,
|
|
nodes = this.nodes,
|
|
edges = this.edges;
|
|
for (id in nodes) {
|
|
if (nodes.hasOwnProperty(id)) {
|
|
nodes[id].edges = [];
|
|
}
|
|
}
|
|
|
|
for (id in edges) {
|
|
if (edges.hasOwnProperty(id)) {
|
|
var edge = edges[id];
|
|
edge.from = null;
|
|
edge.to = null;
|
|
edge.connect();
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Update the values of all object in the given array according to the current
|
|
* value range of the objects in the array.
|
|
* @param {Object} obj An object containing a set of Edges or Nodes
|
|
* The objects must have a method getValue() and
|
|
* setValueRange(min, max).
|
|
* @private
|
|
*/
|
|
Graph.prototype._updateValueRange = function(obj) {
|
|
var id;
|
|
|
|
// determine the range of the objects
|
|
var valueMin = undefined;
|
|
var valueMax = undefined;
|
|
for (id in obj) {
|
|
if (obj.hasOwnProperty(id)) {
|
|
var value = obj[id].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 objects
|
|
if (valueMin !== undefined && valueMax !== undefined) {
|
|
for (id in obj) {
|
|
if (obj.hasOwnProperty(id)) {
|
|
obj[id].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
|
|
* @private
|
|
*/
|
|
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
|
|
* @private
|
|
*/
|
|
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
|
|
* @private
|
|
*/
|
|
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
|
|
* @private
|
|
*/
|
|
Graph.prototype._setScale = function(scale) {
|
|
this.scale = scale;
|
|
};
|
|
/**
|
|
* Get the current scale of the graph
|
|
* @return {Number} scale Scaling factor 1.0 is unscaled
|
|
* @private
|
|
*/
|
|
Graph.prototype._getScale = function() {
|
|
return this.scale;
|
|
};
|
|
|
|
/**
|
|
* Convert a horizontal point on the HTML canvas to the x-value of the model
|
|
* @param {number} x
|
|
* @returns {number}
|
|
* @private
|
|
*/
|
|
Graph.prototype._canvasToX = function(x) {
|
|
return (x - this.translation.x) / this.scale;
|
|
};
|
|
|
|
/**
|
|
* Convert an x-value in the model to a horizontal point on the HTML canvas
|
|
* @param {number} x
|
|
* @returns {number}
|
|
* @private
|
|
*/
|
|
Graph.prototype._xToCanvas = function(x) {
|
|
return x * this.scale + this.translation.x;
|
|
};
|
|
|
|
/**
|
|
* Convert a vertical point on the HTML canvas to the y-value of the model
|
|
* @param {number} y
|
|
* @returns {number}
|
|
* @private
|
|
*/
|
|
Graph.prototype._canvasToY = function(y) {
|
|
return (y - this.translation.y) / this.scale;
|
|
};
|
|
|
|
/**
|
|
* Convert an y-value in the model to a vertical point on the HTML canvas
|
|
* @param {number} y
|
|
* @returns {number}
|
|
* @private
|
|
*/
|
|
Graph.prototype._yToCanvas = function(y) {
|
|
return y * this.scale + this.translation.y ;
|
|
};
|
|
|
|
/**
|
|
* Redraw all nodes
|
|
* The 2d context of a HTML canvas can be retrieved by canvas.getContext('2d');
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @private
|
|
*/
|
|
Graph.prototype._drawNodes = function(ctx) {
|
|
// first draw the unselected nodes
|
|
var nodes = this.nodes;
|
|
var selected = [];
|
|
for (var id in nodes) {
|
|
if (nodes.hasOwnProperty(id)) {
|
|
if (nodes[id].isSelected()) {
|
|
selected.push(id);
|
|
}
|
|
else {
|
|
nodes[id].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
|
|
* @private
|
|
*/
|
|
Graph.prototype._drawEdges = function(ctx) {
|
|
var edges = this.edges;
|
|
for (var id in edges) {
|
|
if (edges.hasOwnProperty(id)) {
|
|
var edge = edges[id];
|
|
if (edge.connected) {
|
|
edges[id].draw(ctx);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Find a stable position for all nodes
|
|
* @private
|
|
*/
|
|
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
|
|
* @private
|
|
*/
|
|
Graph.prototype._calculateForces = function() {
|
|
// create a local edge to the nodes and edges, that is faster
|
|
var id, dx, dy, angle, distance, fx, fy,
|
|
repulsingForce, springForce, length, edgeLength,
|
|
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 (id in nodes) {
|
|
if (nodes.hasOwnProperty(id)) {
|
|
var node = nodes[id];
|
|
dx = gx - node.x;
|
|
dy = gy - node.y;
|
|
angle = Math.atan2(dy, dx);
|
|
fx = Math.cos(angle) * gravity;
|
|
fy = Math.sin(angle) * gravity;
|
|
|
|
node._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 id1 in nodes) {
|
|
if (nodes.hasOwnProperty(id1)) {
|
|
var node1 = nodes[id1];
|
|
for (var id2 in nodes) {
|
|
if (nodes.hasOwnProperty(id2)) {
|
|
var node2 = nodes[id2];
|
|
// calculate normally distributed force
|
|
dx = node2.x - node1.x;
|
|
dy = node2.y - node1.y;
|
|
distance = Math.sqrt(dx * dx + dy * dy);
|
|
angle = Math.atan2(dy, dx);
|
|
|
|
// TODO: correct factor for repulsing force
|
|
//repulsingForce = 2 * Math.exp(-5 * (distance * distance) / (dmin * dmin) ); // TODO: customize the repulsing force
|
|
//repulsingForce = Math.exp(-1 * (distance * distance) / (dmin * dmin) ); // TODO: customize the repulsing force
|
|
repulsingForce = 1 / (1 + Math.exp((distance / minimumDistance - 1) * steepness)); // TODO: customize the repulsing force
|
|
fx = Math.cos(angle) * repulsingForce;
|
|
fy = Math.sin(angle) * repulsingForce;
|
|
|
|
node1._addForce(-fx, -fy);
|
|
node2._addForce(fx, fy);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* TODO: re-implement repulsion of edges
|
|
for (var n = 0; n < nodes.length; n++) {
|
|
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 (id in edges) {
|
|
if (edges.hasOwnProperty(id)) {
|
|
var edge = edges[id];
|
|
if (edge.connected) {
|
|
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
|
|
* @private
|
|
*/
|
|
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 id in nodes) {
|
|
if (nodes.hasOwnProperty(id) && nodes[id].isMoving(vmin)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
|
|
/**
|
|
* Perform one discrete step for all nodes
|
|
* @private
|
|
*/
|
|
Graph.prototype._discreteStepNodes = function() {
|
|
var interval = this.refreshRate / 1000.0; // in seconds
|
|
var nodes = this.nodes;
|
|
for (var id in nodes) {
|
|
if (nodes.hasOwnProperty(id)) {
|
|
nodes[id].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;
|
|
}
|
|
};
|