Compare commits

..

2 Commits

Author SHA1 Message Date
Dennis Hotson
f13bd76552 Option to manually tick simulation forward 2013-03-14 23:19:16 +11:00
Dennis Hotson
d7a26171b0 Fixed shitespace 2013-03-14 23:02:23 +11:00
9 changed files with 666 additions and 847 deletions

View File

@ -47,7 +47,7 @@ add nodes and edges to graph and springyui.js for the rendering example.
Springy 1.1+ supports simplified API for adding nodes and edges, see Springy 1.1+ supports simplified API for adding nodes and edges, see
[demo-simple.html](http://dhotson.github.com/springy/demo-simple.html): [demo-simple.html](http://dhotson.github.com/springy/demo-simple.html):
var graph = new Springy.Graph(); var graph = new Graph();
graph.addNodes('mark', 'higgs', 'other', 'etc'); graph.addNodes('mark', 'higgs', 'other', 'etc');
graph.addEdges( graph.addEdges(
['mark', 'higgs'], ['mark', 'higgs'],
@ -67,7 +67,7 @@ Springy 1.2+ also accepts JSON, see
] ]
}; };
var graph = new Springy.Graph(); var graph = new Graph();
graph.loadJSON(graphJSON); graph.loadJSON(graphJSON);
@ -80,7 +80,7 @@ things before you get started.
This is the basic graph API, you can create nodes and edges etc. This is the basic graph API, you can create nodes and edges etc.
// make a new graph // make a new graph
var graph = new Springy.Graph(); var graph = new Graph();
// make some nodes // make some nodes
var node1 = graph.newNode({label: '1'}); var node1 = graph.newNode({label: '1'});
@ -91,12 +91,12 @@ This is the basic graph API, you can create nodes and edges etc.
So now to draw this graph, lets make a layout object: So now to draw this graph, lets make a layout object:
var layout = new Springy.Layout.ForceDirected(graph, 400.0, 400.0, 0.5); var layout = new Layout.ForceDirected(graph, 400.0, 400.0, 0.5);
I've written a Renderer class, which will handle the rendering loop. I've written a Renderer class, which will handle the rendering loop.
You just need to provide some callbacks to do the actual drawing. You just need to provide some callbacks to do the actual drawing.
var renderer = new Springy.Renderer(layout, var renderer = new Renderer(layout,
function clear() { function clear() {
// code to clear screen // code to clear screen
}, },

View File

@ -1,17 +0,0 @@
{
"name": "Springy",
"main": "springy.js",
"version": "2.7.1",
"homepage": "https://github.com/dhotson/springy",
"authors": [
"Dennis Hotson <dennis@99designs.com>"
],
"description": "A force directed graph layout algorithm",
"keywords": [
"graph",
"layout",
"visualization",
"physics"
],
"license": "MIT"
}

View File

@ -22,7 +22,7 @@ var graphJSON = {
}; };
jQuery(function(){ jQuery(function(){
var graph = new Springy.Graph(); var graph = new Graph();
graph.loadJSON(graphJSON); graph.loadJSON(graphJSON);
var springy = jQuery('#springydemo').springy({ var springy = jQuery('#springydemo').springy({

View File

@ -112,7 +112,7 @@ Raphael.fn.connection = function (obj1, obj2, style) {
</script> </script>
<script> <script>
var graph = new Springy.Graph(); var graph = new Graph();
var dennis = graph.newNode({label: 'Dennis'}); var dennis = graph.newNode({label: 'Dennis'});
var michael = graph.newNode({label: 'Michael'}); var michael = graph.newNode({label: 'Michael'});
@ -166,16 +166,16 @@ function moveSet(set, x, y) {
} }
function doit() { function doit() {
var layout = new Springy.Layout.ForceDirected(graph, 640, 480.0, 0.5); var layout = new Layout.ForceDirected(graph, 640, 480.0, 0.5);
var r = Raphael("holder", 640, 480); var r = Raphael("holder", 640, 480);
// calculate bounding box of graph layout.. with ease-in // calculate bounding box of graph layout.. with ease-in
var currentBB = layout.getBoundingBox(); var currentBB = layout.getBoundingBox();
var targetBB = {bottomleft: new Springy.Vector(-2, -2), topright: new Springy.Vector(2, 2)}; var targetBB = {bottomleft: new Vector(-2, -2), topright: new Vector(2, 2)};
// auto adjusting bounding box // auto adjusting bounding box
Springy.requestAnimationFrame(function adjust() { Layout.requestAnimationFrame(function adjust() {
targetBB = layout.getBoundingBox(); targetBB = layout.getBoundingBox();
// current gets 20% closer to target every iteration // current gets 20% closer to target every iteration
currentBB = { currentBB = {
@ -185,7 +185,7 @@ function doit() {
.divide(10)) .divide(10))
}; };
Springy.requestAnimationFrame(adjust); Layout.requestAnimationFrame(adjust);
}); });
// convert to/from screen coordinates // convert to/from screen coordinates
@ -193,11 +193,11 @@ function doit() {
var size = currentBB.topright.subtract(currentBB.bottomleft); var size = currentBB.topright.subtract(currentBB.bottomleft);
var sx = p.subtract(currentBB.bottomleft).divide(size.x).x * r.width; var sx = p.subtract(currentBB.bottomleft).divide(size.x).x * r.width;
var sy = p.subtract(currentBB.bottomleft).divide(size.y).y * r.height; var sy = p.subtract(currentBB.bottomleft).divide(size.y).y * r.height;
return new Springy.Vector(sx, sy); return new Vector(sx, sy);
}; };
var renderer = new Springy.Renderer(layout, var renderer = new Renderer(layout,
function clear() { function clear() {
// code to clear screen // code to clear screen
}, },

View File

@ -4,7 +4,7 @@
<script src="springy.js"></script> <script src="springy.js"></script>
<script src="springyui.js"></script> <script src="springyui.js"></script>
<script> <script>
var graph = new Springy.Graph(); var graph = new Graph();
graph.addNodes('Dennis', 'Michael', 'Jessica', 'Timothy', 'Barbara') graph.addNodes('Dennis', 'Michael', 'Jessica', 'Timothy', 'Barbara')
graph.addNodes('Amphitryon', 'Alcmene', 'Iphicles', 'Heracles'); graph.addNodes('Amphitryon', 'Alcmene', 'Iphicles', 'Heracles');

View File

@ -4,7 +4,7 @@
<script src="springy.js"></script> <script src="springy.js"></script>
<script src="springyui.js"></script> <script src="springyui.js"></script>
<script> <script>
var graph = new Springy.Graph(); var graph = new Graph();
var dennis = graph.newNode({ var dennis = graph.newNode({
label: 'Dennis', label: 'Dennis',

View File

@ -1,21 +0,0 @@
{
"name": "springy",
"version": "2.7.1",
"description": "A force directed graph layout algorithm in JavaScript.",
"main": "springy.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git://github.com/dhotson/springy.git"
},
"keywords": [
"graph",
"layout",
"visualization"
],
"author": "Dennis Hotson <dennis.hotson@gmail.com>",
"license": "MIT",
"readmeFilename": "README.mkdn"
}

View File

@ -1,7 +1,7 @@
/** /**
* Springy v2.7.1 * Springy v1.2.0
* *
* Copyright (c) 2010-2013 Dennis Hotson * Copyright (c) 2010 Dennis Hotson
* *
* Permission is hereby granted, free of charge, to any person * Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation * obtaining a copy of this software and associated documentation
@ -24,26 +24,11 @@
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE. * OTHER DEALINGS IN THE SOFTWARE.
*/ */
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(function () {
return (root.returnExportsGlobal = factory());
});
} else if (typeof exports === 'object') {
// Node. Does not work with strict CommonJS, but
// only CommonJS-like enviroments that support module.exports,
// like Node.
module.exports = factory();
} else {
// Browser globals
root.Springy = factory();
}
}(this, function() {
var Springy = {}; // Enable strict mode for EC5 compatible browsers
"use strict";
var Graph = Springy.Graph = function() { var Graph = function() {
this.nodeSet = {}; this.nodeSet = {};
this.nodes = []; this.nodes = [];
this.edges = []; this.edges = [];
@ -52,30 +37,31 @@
this.nextNodeId = 0; this.nextNodeId = 0;
this.nextEdgeId = 0; this.nextEdgeId = 0;
this.eventListeners = []; this.eventListeners = [];
}; };
var Node = Springy.Node = function(id, data) { var Node = function(id, data) {
this.id = id; this.id = id;
this.data = (data !== undefined) ? data : {}; this.data = (data !== undefined) ? data : {};
// Data fields used by layout algorithm in this file: // Data fields used by layout algorithm in this file:
// this.data.mass // this.data.mass
// Data used by default renderer in springyui.js // Data used by default renderer in springyui.js
// this.data.label // this.data.label
}; };
var Edge = Springy.Edge = function(id, source, target, data) { var Edge = function(id, source, target, data) {
this.id = id; this.id = id;
/** @type {Node} */
this.source = source; this.source = source;
this.target = target; this.target = target;
this.data = (data !== undefined) ? data : {}; this.data = (data !== undefined) ? data : {};
// Edge data field used by layout alorithm // Edge data field used by layout alorithm
// this.data.length // this.data.length
// this.data.type // this.data.type
}; };
Graph.prototype.addNode = function(node) { Graph.prototype.addNode = function(node) {
if (!(node.id in this.nodeSet)) { if (!(node.id in this.nodeSet)) {
this.nodes.push(node); this.nodes.push(node);
} }
@ -84,9 +70,9 @@
this.notify(); this.notify();
return node; return node;
}; };
Graph.prototype.addNodes = function() { Graph.prototype.addNodes = function() {
// accepts variable number of arguments, where each argument // accepts variable number of arguments, where each argument
// is a string that becomes both node identifier and label // is a string that becomes both node identifier and label
for (var i = 0; i < arguments.length; i++) { for (var i = 0; i < arguments.length; i++) {
@ -94,9 +80,9 @@
var node = new Node(name, {label:name}); var node = new Node(name, {label:name});
this.addNode(node); this.addNode(node);
} }
}; };
Graph.prototype.addEdge = function(edge) { Graph.prototype.addEdge = function(edge) {
var exists = false; var exists = false;
this.edges.forEach(function(e) { this.edges.forEach(function(e) {
if (edge.id === e.id) { exists = true; } if (edge.id === e.id) { exists = true; }
@ -124,9 +110,9 @@
this.notify(); this.notify();
return edge; return edge;
}; };
Graph.prototype.addEdges = function() { Graph.prototype.addEdges = function() {
// accepts variable number of arguments, where each argument // accepts variable number of arguments, where each argument
// is a triple [nodeid1, nodeid2, attributes] // is a triple [nodeid1, nodeid2, attributes]
for (var i = 0; i < arguments.length; i++) { for (var i = 0; i < arguments.length; i++) {
@ -143,28 +129,28 @@
this.newEdge(node1, node2, attr); this.newEdge(node1, node2, attr);
} }
}; };
Graph.prototype.newNode = function(data) { Graph.prototype.newNode = function(data) {
var node = new Node(this.nextNodeId++, data); var node = new Node(this.nextNodeId++, data);
this.addNode(node); this.addNode(node);
return node; return node;
}; };
Graph.prototype.newEdge = function(source, target, data) { Graph.prototype.newEdge = function(source, target, data) {
var edge = new Edge(this.nextEdgeId++, source, target, data); var edge = new Edge(this.nextEdgeId++, source, target, data);
this.addEdge(edge); this.addEdge(edge);
return edge; return edge;
}; };
// add nodes and edges from JSON object // add nodes and edges from JSON object
Graph.prototype.loadJSON = function(json) { Graph.prototype.loadJSON = function(json) {
/** /**
Springy's simple JSON format for graphs. Springy's simple JSON format for graphs.
historically, Springy uses separate lists historically, Springy uses separate lists
of nodes and edges: of nodes and edges:
{ {
"nodes": [ "nodes": [
@ -181,7 +167,7 @@
] ]
} }
**/ **/
// parse if a string is passed (EC5+ browsers) // parse if a string is passed (EC5+ browsers)
if (typeof json == 'string' || json instanceof String) { if (typeof json == 'string' || json instanceof String) {
json = JSON.parse( json ); json = JSON.parse( json );
@ -191,21 +177,21 @@
this.addNodes.apply(this, json['nodes']); this.addNodes.apply(this, json['nodes']);
this.addEdges.apply(this, json['edges']); this.addEdges.apply(this, json['edges']);
} }
} }
// find the edges from node1 to node2 // find the edges from node1 to node2
Graph.prototype.getEdges = function(node1, node2) { Graph.prototype.getEdges = function(node1, node2) {
if (node1.id in this.adjacency if (node1.id in this.adjacency
&& node2.id in this.adjacency[node1.id]) { && node2.id in this.adjacency[node1.id]) {
return this.adjacency[node1.id][node2.id]; return this.adjacency[node1.id][node2.id];
} }
return []; return [];
}; };
// remove a node and it's associated edges from the graph // remove a node and it's associated edges from the graph
Graph.prototype.removeNode = function(node) { Graph.prototype.removeNode = function(node) {
if (node.id in this.nodeSet) { if (node.id in this.nodeSet) {
delete this.nodeSet[node.id]; delete this.nodeSet[node.id];
} }
@ -217,10 +203,11 @@
} }
this.detachNode(node); this.detachNode(node);
};
// removes edges associated with a given node };
Graph.prototype.detachNode = function(node) {
// removes edges associated with a given node
Graph.prototype.detachNode = function(node) {
var tmpEdges = this.edges.slice(); var tmpEdges = this.edges.slice();
tmpEdges.forEach(function(e) { tmpEdges.forEach(function(e) {
if (e.source.id === node.id || e.target.id === node.id) { if (e.source.id === node.id || e.target.id === node.id) {
@ -229,10 +216,10 @@
}, this); }, this);
this.notify(); this.notify();
}; };
// remove a node and it's associated edges from the graph // remove a node and it's associated edges from the graph
Graph.prototype.removeEdge = function(edge) { Graph.prototype.removeEdge = function(edge) {
for (var i = this.edges.length - 1; i >= 0; i--) { for (var i = this.edges.length - 1; i >= 0; i--) {
if (this.edges[i].id === edge.id) { if (this.edges[i].id === edge.id) {
this.edges.splice(i, 1); this.edges.splice(i, 1);
@ -248,24 +235,14 @@
this.adjacency[x][y].splice(j, 1); this.adjacency[x][y].splice(j, 1);
} }
} }
// Clean up empty edge arrays
if (this.adjacency[x][y].length == 0) {
delete this.adjacency[x][y];
}
}
// Clean up empty objects
if (isEmpty(this.adjacency[x])) {
delete this.adjacency[x];
} }
} }
this.notify(); this.notify();
}; };
/* Merge a list of nodes and edges into the current graph. eg. /* Merge a list of nodes and edges into the current graph. eg.
var o = { var o = {
nodes: [ nodes: [
{id: 123, data: {type: 'user', userid: 123, displayname: 'aaa'}}, {id: 123, data: {type: 'user', userid: 123, displayname: 'aaa'}},
{id: 234, data: {type: 'user', userid: 234, displayname: 'bbb'}} {id: 234, data: {type: 'user', userid: 234, displayname: 'bbb'}}
@ -273,9 +250,9 @@
edges: [ edges: [
{from: 0, to: 1, type: 'submitted_design', directed: true, data: {weight: }} {from: 0, to: 1, type: 'submitted_design', directed: true, data: {weight: }}
] ]
} }
*/ */
Graph.prototype.merge = function(data) { Graph.prototype.merge = function(data) {
var nodes = []; var nodes = [];
data.nodes.forEach(function(n) { data.nodes.forEach(function(n) {
nodes.push(this.addNode(new Node(n.id, n.data))); nodes.push(this.addNode(new Node(n.id, n.data)));
@ -294,61 +271,59 @@
var edge = this.addEdge(new Edge(id, from, to, e.data)); var edge = this.addEdge(new Edge(id, from, to, e.data));
edge.data.type = e.type; edge.data.type = e.type;
}, this); }, this);
}; };
Graph.prototype.filterNodes = function(fn) { Graph.prototype.filterNodes = function(fn) {
var tmpNodes = this.nodes.slice(); var tmpNodes = this.nodes.slice();
tmpNodes.forEach(function(n) { tmpNodes.forEach(function(n) {
if (!fn(n)) { if (!fn(n)) {
this.removeNode(n); this.removeNode(n);
} }
}, this); }, this);
}; };
Graph.prototype.filterEdges = function(fn) { Graph.prototype.filterEdges = function(fn) {
var tmpEdges = this.edges.slice(); var tmpEdges = this.edges.slice();
tmpEdges.forEach(function(e) { tmpEdges.forEach(function(e) {
if (!fn(e)) { if (!fn(e)) {
this.removeEdge(e); this.removeEdge(e);
} }
}, this); }, this);
}; };
Graph.prototype.addGraphListener = function(obj) { Graph.prototype.addGraphListener = function(obj) {
this.eventListeners.push(obj); this.eventListeners.push(obj);
}; };
Graph.prototype.notify = function() { Graph.prototype.notify = function() {
this.eventListeners.forEach(function(obj){ this.eventListeners.forEach(function(obj){
obj.graphChanged(); obj.graphChanged();
}); });
}; };
// ----------- // -----------
var Layout = Springy.Layout = {}; var Layout = {};
Layout.ForceDirected = function(graph, stiffness, repulsion, damping, minEnergyThreshold, maxSpeed) { Layout.ForceDirected = function(graph, stiffness, repulsion, damping) {
this.graph = graph; this.graph = graph;
this.stiffness = stiffness; // spring stiffness constant this.stiffness = stiffness; // spring stiffness constant
this.repulsion = repulsion; // repulsion constant this.repulsion = repulsion; // repulsion constant
this.damping = damping; // velocity damping factor this.damping = damping; // velocity damping factor
this.minEnergyThreshold = minEnergyThreshold || 0.01; //threshold used to determine render stop
this.maxSpeed = maxSpeed || Infinity; // nodes aren't allowed to exceed this speed
this.nodePoints = {}; // keep track of points associated with nodes this.nodePoints = {}; // keep track of points associated with nodes
this.edgeSprings = {}; // keep track of springs associated with edges this.edgeSprings = {}; // keep track of springs associated with edges
}; };
Layout.ForceDirected.prototype.point = function(node) { Layout.ForceDirected.prototype.point = function(node) {
if (!(node.id in this.nodePoints)) { if (!(node.id in this.nodePoints)) {
var mass = (node.data.mass !== undefined) ? node.data.mass : 1.0; var mass = (node.data.mass !== undefined) ? node.data.mass : 1.0;
this.nodePoints[node.id] = new Layout.ForceDirected.Point(Vector.random(), mass); this.nodePoints[node.id] = new Layout.ForceDirected.Point(Vector.random(), mass);
} }
return this.nodePoints[node.id]; return this.nodePoints[node.id];
}; };
Layout.ForceDirected.prototype.spring = function(edge) { Layout.ForceDirected.prototype.spring = function(edge) {
if (!(edge.id in this.edgeSprings)) { if (!(edge.id in this.edgeSprings)) {
var length = (edge.data.length !== undefined) ? edge.data.length : 1.0; var length = (edge.data.length !== undefined) ? edge.data.length : 1.0;
@ -382,35 +357,35 @@
} }
return this.edgeSprings[edge.id]; return this.edgeSprings[edge.id];
}; };
// callback should accept two arguments: Node, Point // callback should accept two arguments: Node, Point
Layout.ForceDirected.prototype.eachNode = function(callback) { Layout.ForceDirected.prototype.eachNode = function(callback) {
var t = this; var t = this;
this.graph.nodes.forEach(function(n){ this.graph.nodes.forEach(function(n){
callback.call(t, n, t.point(n)); callback.call(t, n, t.point(n));
}); });
}; };
// callback should accept two arguments: Edge, Spring // callback should accept two arguments: Edge, Spring
Layout.ForceDirected.prototype.eachEdge = function(callback) { Layout.ForceDirected.prototype.eachEdge = function(callback) {
var t = this; var t = this;
this.graph.edges.forEach(function(e){ this.graph.edges.forEach(function(e){
callback.call(t, e, t.spring(e)); callback.call(t, e, t.spring(e));
}); });
}; };
// callback should accept one argument: Spring // callback should accept one argument: Spring
Layout.ForceDirected.prototype.eachSpring = function(callback) { Layout.ForceDirected.prototype.eachSpring = function(callback) {
var t = this; var t = this;
this.graph.edges.forEach(function(e){ this.graph.edges.forEach(function(e){
callback.call(t, t.spring(e)); callback.call(t, t.spring(e));
}); });
}; };
// Physics stuff // Physics stuff
Layout.ForceDirected.prototype.applyCoulombsLaw = function() { Layout.ForceDirected.prototype.applyCoulombsLaw = function() {
this.eachNode(function(n1, point1) { this.eachNode(function(n1, point1) {
this.eachNode(function(n2, point2) { this.eachNode(function(n2, point2) {
if (point1 !== point2) if (point1 !== point2)
@ -425,9 +400,9 @@
} }
}); });
}); });
}; };
Layout.ForceDirected.prototype.applyHookesLaw = function() { Layout.ForceDirected.prototype.applyHookesLaw = function() {
this.eachSpring(function(spring){ this.eachSpring(function(spring){
var d = spring.point2.p.subtract(spring.point1.p); // the direction of the spring var d = spring.point2.p.subtract(spring.point1.p); // the direction of the spring
var displacement = spring.length - d.magnitude(); var displacement = spring.length - d.magnitude();
@ -437,38 +412,35 @@
spring.point1.applyForce(direction.multiply(spring.k * displacement * -0.5)); spring.point1.applyForce(direction.multiply(spring.k * displacement * -0.5));
spring.point2.applyForce(direction.multiply(spring.k * displacement * 0.5)); spring.point2.applyForce(direction.multiply(spring.k * displacement * 0.5));
}); });
}; };
Layout.ForceDirected.prototype.attractToCentre = function() { Layout.ForceDirected.prototype.attractToCentre = function() {
this.eachNode(function(node, point) { this.eachNode(function(node, point) {
var direction = point.p.multiply(-1.0); var direction = point.p.multiply(-1.0);
point.applyForce(direction.multiply(this.repulsion / 50.0)); point.applyForce(direction.multiply(this.repulsion / 50.0));
}); });
}; };
Layout.ForceDirected.prototype.updateVelocity = function(timestep) { Layout.ForceDirected.prototype.updateVelocity = function(timestep) {
this.eachNode(function(node, point) { this.eachNode(function(node, point) {
// Is this, along with updatePosition below, the only places that your // Is this, along with updatePosition below, the only places that your
// integration code exist? // integration code exist?
point.v = point.v.add(point.a.multiply(timestep)).multiply(this.damping); point.v = point.v.add(point.a.multiply(timestep)).multiply(this.damping);
if (point.v.magnitude() > this.maxSpeed) {
point.v = point.v.normalise().multiply(this.maxSpeed);
}
point.a = new Vector(0,0); point.a = new Vector(0,0);
}); });
}; };
Layout.ForceDirected.prototype.updatePosition = function(timestep) { Layout.ForceDirected.prototype.updatePosition = function(timestep) {
this.eachNode(function(node, point) { this.eachNode(function(node, point) {
// Same question as above; along with updateVelocity, is this all of // Same question as above; along with updateVelocity, is this all of
// your integration code? // your integration code?
point.p = point.p.add(point.v.multiply(timestep)); point.p = point.p.add(point.v.multiply(timestep));
}); });
}; };
// Calculate the total kinetic energy of the system // Calculate the total kinetic energy of the system
Layout.ForceDirected.prototype.totalEnergy = function(timestep) { Layout.ForceDirected.prototype.totalEnergy = function(timestep) {
var energy = 0.0; var energy = 0.0;
this.eachNode(function(node, point) { this.eachNode(function(node, point) {
var speed = point.v.magnitude(); var speed = point.v.magnitude();
@ -476,34 +448,38 @@
}); });
return energy; return energy;
}; };
var __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; // stolen from coffeescript, thanks jashkenas! ;-) var __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; // stolen from coffeescript, thanks jashkenas! ;-)
Springy.requestAnimationFrame = __bind(this.requestAnimationFrame || Layout.requestAnimationFrame = __bind(window.requestAnimationFrame ||
this.webkitRequestAnimationFrame || window.webkitRequestAnimationFrame ||
this.mozRequestAnimationFrame || window.mozRequestAnimationFrame ||
this.oRequestAnimationFrame || window.oRequestAnimationFrame ||
this.msRequestAnimationFrame || window.msRequestAnimationFrame ||
(function(callback, element) { function(callback, element) {
this.setTimeout(callback, 10); window.setTimeout(callback, 10);
}), this); }, window);
/** Layout.ForceDirected.prototype.tick = function(timestep) {
* Start simulation if it's not running already. this.applyCoulombsLaw();
* In case it's running then the call is ignored, and none of the callbacks passed is ever executed. this.applyHookesLaw();
*/ this.attractToCentre();
Layout.ForceDirected.prototype.start = function(render, onRenderStop, onRenderStart) { this.updateVelocity(timestep);
this.updatePosition(timestep);
};
// start simulation
Layout.ForceDirected.prototype.start = function(render, done) {
var t = this; var t = this;
if (this._started) return; if (this._started) return;
this._started = true; this._started = true;
this._stop = false; this._stop = false;
if (onRenderStart !== undefined) { onRenderStart(); } Layout.requestAnimationFrame(function step() {
Springy.requestAnimationFrame(function step() {
t.tick(0.03); t.tick(0.03);
if (render !== undefined) { if (render !== undefined) {
@ -511,29 +487,21 @@
} }
// stop simulation when energy of the system goes below a threshold // stop simulation when energy of the system goes below a threshold
if (t._stop || t.totalEnergy() < t.minEnergyThreshold) { if (t._stop || t.totalEnergy() < 0.01) {
t._started = false; t._started = false;
if (onRenderStop !== undefined) { onRenderStop(); } if (done !== undefined) { done(); }
} else { } else {
Springy.requestAnimationFrame(step); Layout.requestAnimationFrame(step);
} }
}); });
}; };
Layout.ForceDirected.prototype.stop = function() { Layout.ForceDirected.prototype.stop = function() {
this._stop = true; this._stop = true;
} }
Layout.ForceDirected.prototype.tick = function(timestep) { // Find the nearest point to a particular position
this.applyCoulombsLaw(); Layout.ForceDirected.prototype.nearest = function(pos) {
this.applyHookesLaw();
this.attractToCentre();
this.updateVelocity(timestep);
this.updatePosition(timestep);
};
// Find the nearest point to a particular position
Layout.ForceDirected.prototype.nearest = function(pos) {
var min = {node: null, point: null, distance: null}; var min = {node: null, point: null, distance: null};
var t = this; var t = this;
this.graph.nodes.forEach(function(n){ this.graph.nodes.forEach(function(n){
@ -546,10 +514,10 @@
}); });
return min; return min;
}; };
// returns [bottomleft, topright] // returns [bottomleft, topright]
Layout.ForceDirected.prototype.getBoundingBox = function() { Layout.ForceDirected.prototype.getBoundingBox = function() {
var bottomleft = new Vector(-2,-2); var bottomleft = new Vector(-2,-2);
var topright = new Vector(2,2); var topright = new Vector(2,2);
@ -571,109 +539,91 @@
var padding = topright.subtract(bottomleft).multiply(0.07); // ~5% padding var padding = topright.subtract(bottomleft).multiply(0.07); // ~5% padding
return {bottomleft: bottomleft.subtract(padding), topright: topright.add(padding)}; return {bottomleft: bottomleft.subtract(padding), topright: topright.add(padding)};
}; };
// Vector // Vector
var Vector = Springy.Vector = function(x, y) { var Vector = function(x, y) {
this.x = x; this.x = x;
this.y = y; this.y = y;
}; };
Vector.random = function() { Vector.random = function() {
return new Vector(10.0 * (Math.random() - 0.5), 10.0 * (Math.random() - 0.5)); return new Vector(10.0 * (Math.random() - 0.5), 10.0 * (Math.random() - 0.5));
}; };
Vector.prototype.add = function(v2) { Vector.prototype.add = function(v2) {
return new Vector(this.x + v2.x, this.y + v2.y); return new Vector(this.x + v2.x, this.y + v2.y);
}; };
Vector.prototype.subtract = function(v2) { Vector.prototype.subtract = function(v2) {
return new Vector(this.x - v2.x, this.y - v2.y); return new Vector(this.x - v2.x, this.y - v2.y);
}; };
Vector.prototype.multiply = function(n) { Vector.prototype.multiply = function(n) {
return new Vector(this.x * n, this.y * n); return new Vector(this.x * n, this.y * n);
}; };
Vector.prototype.divide = function(n) { Vector.prototype.divide = function(n) {
return new Vector((this.x / n) || 0, (this.y / n) || 0); // Avoid divide by zero errors.. return new Vector((this.x / n) || 0, (this.y / n) || 0); // Avoid divide by zero errors..
}; };
Vector.prototype.magnitude = function() { Vector.prototype.magnitude = function() {
return Math.sqrt(this.x*this.x + this.y*this.y); return Math.sqrt(this.x*this.x + this.y*this.y);
}; };
Vector.prototype.normal = function() { Vector.prototype.normal = function() {
return new Vector(-this.y, this.x); return new Vector(-this.y, this.x);
}; };
Vector.prototype.normalise = function() { Vector.prototype.normalise = function() {
return this.divide(this.magnitude()); return this.divide(this.magnitude());
}; };
// Point // Point
Layout.ForceDirected.Point = function(position, mass) { Layout.ForceDirected.Point = function(position, mass) {
this.p = position; // position this.p = position; // position
this.m = mass; // mass this.m = mass; // mass
this.v = new Vector(0, 0); // velocity this.v = new Vector(0, 0); // velocity
this.a = new Vector(0, 0); // acceleration this.a = new Vector(0, 0); // acceleration
}; };
Layout.ForceDirected.Point.prototype.applyForce = function(force) { Layout.ForceDirected.Point.prototype.applyForce = function(force) {
this.a = this.a.add(force.divide(this.m)); this.a = this.a.add(force.divide(this.m));
}; };
// Spring // Spring
Layout.ForceDirected.Spring = function(point1, point2, length, k) { Layout.ForceDirected.Spring = function(point1, point2, length, k) {
this.point1 = point1; this.point1 = point1;
this.point2 = point2; this.point2 = point2;
this.length = length; // spring length at rest this.length = length; // spring length at rest
this.k = k; // spring constant (See Hooke's law) .. how stiff the spring is this.k = k; // spring constant (See Hooke's law) .. how stiff the spring is
}; };
// Layout.ForceDirected.Spring.prototype.distanceToPoint = function(point) // Layout.ForceDirected.Spring.prototype.distanceToPoint = function(point)
// { // {
// // hardcore vector arithmetic.. ohh yeah! // // hardcore vector arithmetic.. ohh yeah!
// // .. see http://stackoverflow.com/questions/849211/shortest-distance-between-a-point-and-a-line-segment/865080#865080 // // .. see http://stackoverflow.com/questions/849211/shortest-distance-between-a-point-and-a-line-segment/865080#865080
// var n = this.point2.p.subtract(this.point1.p).normalise().normal(); // var n = this.point2.p.subtract(this.point1.p).normalise().normal();
// var ac = point.p.subtract(this.point1.p); // var ac = point.p.subtract(this.point1.p);
// return Math.abs(ac.x * n.x + ac.y * n.y); // return Math.abs(ac.x * n.x + ac.y * n.y);
// }; // };
/** // Renderer handles the layout rendering loop
* Renderer handles the layout rendering loop function Renderer(layout, clear, drawEdge, drawNode) {
* @param onRenderStop optional callback function that gets executed whenever rendering stops.
* @param onRenderStart optional callback function that gets executed whenever rendering starts.
* @param onRenderFrame optional callback function that gets executed after each frame is rendered.
*/
var Renderer = Springy.Renderer = function(layout, clear, drawEdge, drawNode, onRenderStop, onRenderStart, onRenderFrame) {
this.layout = layout; this.layout = layout;
this.clear = clear; this.clear = clear;
this.drawEdge = drawEdge; this.drawEdge = drawEdge;
this.drawNode = drawNode; this.drawNode = drawNode;
this.onRenderStop = onRenderStop;
this.onRenderStart = onRenderStart;
this.onRenderFrame = onRenderFrame;
this.layout.graph.addGraphListener(this); this.layout.graph.addGraphListener(this);
} }
Renderer.prototype.graphChanged = function(e) { Renderer.prototype.graphChanged = function(e) {
this.start(); this.start();
}; };
/** Renderer.prototype.start = function() {
* Starts the simulation of the layout in use.
*
* Note that in case the algorithm is still or already running then the layout that's in use
* might silently ignore the call, and your optional <code>done</code> callback is never executed.
* At least the built-in ForceDirected layout behaves in this way.
*
* @param done An optional callback function that gets executed when the springy algorithm stops,
* either because it ended or because stop() was called.
*/
Renderer.prototype.start = function(done) {
var t = this; var t = this;
this.layout.start(function render() { this.layout.start(function render() {
t.clear(); t.clear();
@ -685,18 +635,16 @@
t.layout.eachNode(function(node, point) { t.layout.eachNode(function(node, point) {
t.drawNode(node, point.p); t.drawNode(node, point.p);
}); });
});
};
if (t.onRenderFrame !== undefined) { t.onRenderFrame(); } Renderer.prototype.stop = function() {
}, this.onRenderStop, this.onRenderStart);
};
Renderer.prototype.stop = function() {
this.layout.stop(); this.layout.stop();
}; };
// Array.forEach implementation for IE support.. // Array.forEach implementation for IE support..
//https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/forEach //https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/forEach
if ( !Array.prototype.forEach ) { if ( !Array.prototype.forEach ) {
Array.prototype.forEach = function( callback, thisArg ) { Array.prototype.forEach = function( callback, thisArg ) {
var T, k; var T, k;
if ( this == null ) { if ( this == null ) {
@ -720,16 +668,5 @@
k++; k++;
} }
}; };
} }
var isEmpty = function(obj) {
for (var k in obj) {
if (obj.hasOwnProperty(k)) {
return false;
}
}
return true;
};
return Springy;
}));

View File

@ -26,28 +26,24 @@ Copyright (c) 2010 Dennis Hotson
(function() { (function() {
jQuery.fn.springy = function(params) { jQuery.fn.springy = function(params) {
var graph = this.graph = params.graph || new Springy.Graph(); var graph = this.graph = params.graph || new Graph();
var nodeFont = "16px Verdana, sans-serif";
var edgeFont = "8px Verdana, sans-serif";
var stiffness = params.stiffness || 400.0; var stiffness = params.stiffness || 400.0;
var repulsion = params.repulsion || 400.0; var repulsion = params.repulsion || 400.0;
var damping = params.damping || 0.5; var damping = params.damping || 0.5;
var minEnergyThreshold = params.minEnergyThreshold || 0.00001;
var nodeSelected = params.nodeSelected || null; var nodeSelected = params.nodeSelected || null;
var nodeImages = {};
var edgeLabelsUpright = true;
var canvas = this[0]; var canvas = this[0];
var ctx = canvas.getContext("2d"); var ctx = canvas.getContext("2d");
var layout = this.layout = new Springy.Layout.ForceDirected(graph, stiffness, repulsion, damping, minEnergyThreshold); var layout = this.layout = new Layout.ForceDirected(graph, stiffness, repulsion, damping);
// calculate bounding box of graph layout.. with ease-in // calculate bounding box of graph layout.. with ease-in
var currentBB = layout.getBoundingBox(); var currentBB = layout.getBoundingBox();
var targetBB = {bottomleft: new Springy.Vector(-2, -2), topright: new Springy.Vector(2, 2)}; var targetBB = {bottomleft: new Vector(-2, -2), topright: new Vector(2, 2)};
// auto adjusting bounding box // auto adjusting bounding box
Springy.requestAnimationFrame(function adjust() { Layout.requestAnimationFrame(function adjust() {
targetBB = layout.getBoundingBox(); targetBB = layout.getBoundingBox();
// current gets 20% closer to target every iteration // current gets 20% closer to target every iteration
currentBB = { currentBB = {
@ -57,22 +53,22 @@ jQuery.fn.springy = function(params) {
.divide(10)) .divide(10))
}; };
Springy.requestAnimationFrame(adjust); Layout.requestAnimationFrame(adjust);
}); });
// convert to/from screen coordinates // convert to/from screen coordinates
var toScreen = function(p) { toScreen = function(p) {
var size = currentBB.topright.subtract(currentBB.bottomleft); var size = currentBB.topright.subtract(currentBB.bottomleft);
var sx = p.subtract(currentBB.bottomleft).divide(size.x).x * canvas.width; var sx = p.subtract(currentBB.bottomleft).divide(size.x).x * canvas.width;
var sy = p.subtract(currentBB.bottomleft).divide(size.y).y * canvas.height; var sy = p.subtract(currentBB.bottomleft).divide(size.y).y * canvas.height;
return new Springy.Vector(sx, sy); return new Vector(sx, sy);
}; };
var fromScreen = function(s) { fromScreen = function(s) {
var size = currentBB.topright.subtract(currentBB.bottomleft); var size = currentBB.topright.subtract(currentBB.bottomleft);
var px = (s.x / canvas.width) * size.x + currentBB.bottomleft.x; var px = (s.x / canvas.width) * size.x + currentBB.bottomleft.x;
var py = (s.y / canvas.height) * size.y + currentBB.bottomleft.y; var py = (s.y / canvas.height) * size.y + currentBB.bottomleft.y;
return new Springy.Vector(px, py); return new Vector(px, py);
}; };
// half-assed drag and drop // half-assed drag and drop
@ -124,63 +120,27 @@ jQuery.fn.springy = function(params) {
dragged = null; dragged = null;
}); });
var getTextWidth = function(node) { Node.prototype.getWidth = function() {
var text = (node.data.label !== undefined) ? node.data.label : node.id; var text = (this.data.label !== undefined) ? this.data.label : this.id;
if (node._width && node._width[text]) if (this._width && this._width[text])
return node._width[text]; return this._width[text];
ctx.save(); ctx.save();
ctx.font = (node.data.font !== undefined) ? node.data.font : nodeFont; ctx.font = "16px Verdana, sans-serif";
var width = ctx.measureText(text).width; var width = ctx.measureText(text).width + 10;
ctx.restore(); ctx.restore();
node._width || (node._width = {}); this._width || (this._width = {});
node._width[text] = width; this._width[text] = width;
return width; return width;
}; };
var getTextHeight = function(node) { Node.prototype.getHeight = function() {
return 16; return 20;
// In a more modular world, this would actually read the font size, but I think leaving it a constant is sufficient for now.
// If you change the font size, I'd adjust this too.
}; };
var getImageWidth = function(node) { var renderer = this.renderer = new Renderer(layout,
var width = (node.data.image.width !== undefined) ? node.data.image.width : nodeImages[node.data.image.src].object.width;
return width;
}
var getImageHeight = function(node) {
var height = (node.data.image.height !== undefined) ? node.data.image.height : nodeImages[node.data.image.src].object.height;
return height;
}
Springy.Node.prototype.getHeight = function() {
var height;
if (this.data.image == undefined) {
height = getTextHeight(this);
} else {
if (this.data.image.src in nodeImages && nodeImages[this.data.image.src].loaded) {
height = getImageHeight(this);
} else {height = 10;}
}
return height;
}
Springy.Node.prototype.getWidth = function() {
var width;
if (this.data.image == undefined) {
width = getTextWidth(this);
} else {
if (this.data.image.src in nodeImages && nodeImages[this.data.image.src].loaded) {
width = getImageWidth(this);
} else {width = 10;}
}
return width;
}
var renderer = this.renderer = new Springy.Renderer(layout,
function clear() { function clear() {
ctx.clearRect(0,0,canvas.width,canvas.height); ctx.clearRect(0,0,canvas.width,canvas.height);
}, },
@ -190,7 +150,7 @@ jQuery.fn.springy = function(params) {
var x2 = toScreen(p2).x; var x2 = toScreen(p2).x;
var y2 = toScreen(p2).y; var y2 = toScreen(p2).y;
var direction = new Springy.Vector(x2-x1, y2-y1); var direction = new Vector(x2-x1, y2-y1);
var normal = direction.normal().normalise(); var normal = direction.normal().normalise();
var from = graph.getEdges(edge.source, edge.target); var from = graph.getEdges(edge.source, edge.target);
@ -206,20 +166,16 @@ jQuery.fn.springy = function(params) {
} }
} }
//change default to 10.0 to allow text fit between edges var spacing = 6.0;
var spacing = 12.0;
// Figure out how far off center the line should be drawn // Figure out how far off center the line should be drawn
var offset = normal.multiply(-((total - 1) * spacing)/2.0 + (n * spacing)); var offset = normal.multiply(-((total - 1) * spacing)/2.0 + (n * spacing));
var paddingX = 6;
var paddingY = 6;
var s1 = toScreen(p1).add(offset); var s1 = toScreen(p1).add(offset);
var s2 = toScreen(p2).add(offset); var s2 = toScreen(p2).add(offset);
var boxWidth = edge.target.getWidth() + paddingX; var boxWidth = edge.target.getWidth();
var boxHeight = edge.target.getHeight() + paddingY; var boxHeight = edge.target.getHeight();
var intersection = intersect_line_box(s1, s2, {x: x2-boxWidth/2.0, y: y2-boxHeight/2.0}, boxWidth, boxHeight); var intersection = intersect_line_box(s1, s2, {x: x2-boxWidth/2.0, y: y2-boxHeight/2.0}, boxWidth, boxHeight);
@ -276,18 +232,9 @@ jQuery.fn.springy = function(params) {
ctx.save(); ctx.save();
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "top"; ctx.textBaseline = "top";
ctx.font = (edge.data.font !== undefined) ? edge.data.font : edgeFont; ctx.font = "10px Helvetica, sans-serif";
ctx.fillStyle = stroke; ctx.fillStyle = "#5BA6EC";
var angle = Math.atan2(s2.y - s1.y, s2.x - s1.x); ctx.fillText(text, (x1+x2)/2, (y1+y2)/2);
var displacement = -8;
if (edgeLabelsUpright && (angle > Math.PI/2 || angle < -Math.PI/2)) {
displacement = 8;
angle += Math.PI;
}
var textPos = s1.add(s2).divide(2).add(normal.multiply(displacement));
ctx.translate(textPos.x, textPos.y);
ctx.rotate(angle);
ctx.fillText(text, 0,-2);
ctx.restore(); ctx.restore();
} }
@ -297,57 +244,30 @@ jQuery.fn.springy = function(params) {
ctx.save(); ctx.save();
// Pulled out the padding aspect sso that the size functions could be used in multiple places var boxWidth = node.getWidth();
// These should probably be settable by the user (and scoped higher) but this suffices for now var boxHeight = node.getHeight();
var paddingX = 6;
var paddingY = 6;
var contentWidth = node.getWidth();
var contentHeight = node.getHeight();
var boxWidth = contentWidth + paddingX;
var boxHeight = contentHeight + paddingY;
// clear background // clear background
ctx.clearRect(s.x - boxWidth/2, s.y - boxHeight/2, boxWidth, boxHeight); ctx.clearRect(s.x - boxWidth/2, s.y - 10, boxWidth, 20);
// fill background // fill background
if (selected !== null && selected.node !== null && selected.node.id === node.id) { if (selected !== null && nearest.node !== null && selected.node.id === node.id) {
ctx.fillStyle = "#FFFFE0"; ctx.fillStyle = "#FFFFE0";
} else if (nearest !== null && nearest.node !== null && nearest.node.id === node.id) { } else if (nearest !== null && nearest.node !== null && nearest.node.id === node.id) {
ctx.fillStyle = "#EEEEEE"; ctx.fillStyle = "#EEEEEE";
} else { } else {
ctx.fillStyle = "#FFFFFF"; ctx.fillStyle = "#FFFFFF";
} }
ctx.fillRect(s.x - boxWidth/2, s.y - boxHeight/2, boxWidth, boxHeight); ctx.fillRect(s.x - boxWidth/2, s.y - 10, boxWidth, 20);
if (node.data.image == undefined) {
ctx.textAlign = "left"; ctx.textAlign = "left";
ctx.textBaseline = "top"; ctx.textBaseline = "top";
ctx.font = (node.data.font !== undefined) ? node.data.font : nodeFont; ctx.font = "16px Verdana, sans-serif";
ctx.fillStyle = (node.data.color !== undefined) ? node.data.color : "#000000"; ctx.fillStyle = "#000000";
ctx.font = "16px Verdana, sans-serif";
var text = (node.data.label !== undefined) ? node.data.label : node.id; var text = (node.data.label !== undefined) ? node.data.label : node.id;
ctx.fillText(text, s.x - contentWidth/2, s.y - contentHeight/2); ctx.fillText(text, s.x - boxWidth/2 + 5, s.y - 8);
} else {
// Currently we just ignore any labels if the image object is set. One might want to extend this logic to allow for both, or other composite nodes.
var src = node.data.image.src; // There should probably be a sanity check here too, but un-src-ed images aren't exaclty a disaster.
if (src in nodeImages) {
if (nodeImages[src].loaded) {
// Our image is loaded, so it's safe to draw
ctx.drawImage(nodeImages[src].object, s.x - contentWidth/2, s.y - contentHeight/2, contentWidth, contentHeight);
}
}else{
// First time seeing an image with this src address, so add it to our set of image objects
// Note: we index images by their src to avoid making too many duplicates
nodeImages[src] = {};
var img = new Image();
nodeImages[src].object = img;
img.addEventListener("load", function () {
// HTMLImageElement objects are very finicky about being used before they are loaded, so we set a flag when it is done
nodeImages[src].loaded = true;
});
img.src = src;
}
}
ctx.restore(); ctx.restore();
} }
); );
@ -370,7 +290,7 @@ jQuery.fn.springy = function(params) {
return false; return false;
} }
return new Springy.Vector(p1.x + ua * (p2.x - p1.x), p1.y + ua * (p2.y - p1.y)); return new Vector(p1.x + ua * (p2.x - p1.x), p1.y + ua * (p2.y - p1.y));
} }
function intersect_line_box(p1, p2, p3, w, h) { function intersect_line_box(p1, p2, p3, w, h) {