Initial commit
This commit is contained in:
commit
8f9d858ad1
7
README
Normal file
7
README
Normal file
|
@ -0,0 +1,7 @@
|
|||
Springy - Force directed graph layout algorithm in JavaScript
|
||||
----
|
||||
|
||||
See demo.html for example of usage.
|
||||
|
||||
Some proper docs coming soon..
|
||||
|
199
demo.html
Normal file
199
demo.html
Normal file
|
@ -0,0 +1,199 @@
|
|||
<html>
|
||||
<body>
|
||||
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.3.2/jquery.min.js"></script>
|
||||
<script src="raphael-min.js"></script>
|
||||
<script src="springy.js"></script>
|
||||
<script>
|
||||
|
||||
var graph = new Graph();
|
||||
|
||||
var n1 = graph.newNode({label: "1"});
|
||||
var n2 = graph.newNode({label: "2"});
|
||||
var n3 = graph.newNode({label: "3"});
|
||||
var n4 = graph.newNode({label: "4"});
|
||||
var n5 = graph.newNode({label: "5"});
|
||||
var n6 = graph.newNode({label: "6", fill: '#EEF'});
|
||||
var n7 = graph.newNode({label: "7"});
|
||||
var n8 = graph.newNode({label: "8"});
|
||||
var n9 = graph.newNode({label: "9"});
|
||||
var n10 = graph.newNode({label: "10"});
|
||||
var n11 = graph.newNode({label: "11", fill: '#EFE'});
|
||||
var n12 = graph.newNode({label: "12", fill: '#FEE'});
|
||||
var n13 = graph.newNode({label: "13"});
|
||||
var n14 = graph.newNode({label: "14"});
|
||||
var n15 = graph.newNode({label: "15"});
|
||||
|
||||
var e1 = graph.newEdge(n1, n2);
|
||||
var e2 = graph.newEdge(n2, n3);
|
||||
var e3 = graph.newEdge(n3, n4);
|
||||
var e4 = graph.newEdge(n4, n1);
|
||||
var e5 = graph.newEdge(n3, n5);
|
||||
var e6 = graph.newEdge(n4, n5);
|
||||
var e7 = graph.newEdge(n5, n6);
|
||||
var e8 = graph.newEdge(n5, n7);
|
||||
var e9 = graph.newEdge(n1, n8);
|
||||
var e10 = graph.newEdge(n2, n9);
|
||||
var e11 = graph.newEdge(n9, n10);
|
||||
var e12 = graph.newEdge(n9, n11);
|
||||
var e13 = graph.newEdge(n1, n12);
|
||||
var e14 = graph.newEdge(n12, n13);
|
||||
var e15 = graph.newEdge(n13, n14);
|
||||
var e16 = graph.newEdge(n14, n15);
|
||||
var e17 = graph.newEdge(n15, n5);
|
||||
var e18 = graph.newEdge(n12, n14);
|
||||
|
||||
// -----------
|
||||
|
||||
var width = 800;
|
||||
var height = 600;
|
||||
var zoom = 50.0;
|
||||
|
||||
// convert to/from screen coordinates
|
||||
toScreen = function(p) {
|
||||
return {
|
||||
x: p.x * zoom + width/2.0,
|
||||
y: p.y * zoom + height/2.0
|
||||
};
|
||||
};
|
||||
|
||||
fromScreen = function(s) {
|
||||
return {
|
||||
x: (s.x - width/2.0) / zoom,
|
||||
y: (s.y - height/2.0) / zoom
|
||||
};
|
||||
};
|
||||
|
||||
var paper = Raphael(10, 10, width, height);
|
||||
|
||||
var layout = new Layout.ForceDirected(graph, 500.0, 300.0, 0.5);
|
||||
|
||||
var boxWidth = 60;
|
||||
var boxHeight = 20;
|
||||
|
||||
var renderer = new Renderer(10, layout,
|
||||
function clear()
|
||||
{
|
||||
paper.clear();
|
||||
var r = paper.rect(0,0,width-1,height-1);
|
||||
r.attr("fill", "#FFFFFF");
|
||||
r.attr("stroke", "none");
|
||||
},
|
||||
function drawEdge(edge, p1, p2)
|
||||
{
|
||||
var x1 = Math.floor(toScreen(p1).x);
|
||||
var y1 = Math.floor(toScreen(p1).y);
|
||||
var x2 = Math.floor(toScreen(p2).x);
|
||||
var y2 = Math.floor(toScreen(p2).y);
|
||||
var c = paper.path(["M", x1, y1, "L", x2, y2]);
|
||||
c.attr("stroke-width", 1.0);
|
||||
|
||||
var point = intersect_line_box(toScreen(p1), toScreen(p2), {x: x2-boxWidth/2.0, y: y2-boxHeight/2.0}, boxWidth, boxHeight);
|
||||
var x = point.x;
|
||||
var y = point.y;
|
||||
|
||||
var arrow = paper.path(["M", -7, 3, "L", 0, 0, "L", 7, 0, "L", 0, 0, "L", -7, -3, "L", -6, 0, "z"]);
|
||||
arrow.rotate(Math.atan2(y2 - y1, x2 - x1) * (180.0 / Math.PI));
|
||||
arrow.translate(x, y);
|
||||
arrow.attr("fill", "black");
|
||||
arrow.attr("stroke", "none");
|
||||
},
|
||||
function drawNode(node, p)
|
||||
{
|
||||
|
||||
var fill = typeof(node.data.fill) !== 'undefined' ? node.data.fill : "#FFFFFF";
|
||||
|
||||
var s = toScreen(p);
|
||||
var rect = paper.rect(s.x - boxWidth/2.0, s.y - boxHeight/2.0, boxWidth, boxHeight, 3);
|
||||
rect.attr("fill", fill);
|
||||
rect.attr("stroke-width", 1.0);
|
||||
|
||||
if (typeof(node.data.label) !== 'undefined')
|
||||
{
|
||||
var text = paper.text(s.x, s.y, node.data.label);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
renderer.start();
|
||||
|
||||
|
||||
// half-assed drag and drop
|
||||
var selected = null;
|
||||
jQuery('svg').mousedown(function(e){
|
||||
var pos = jQuery(this).position();
|
||||
var p = fromScreen({x: e.pageX - pos.left, y: e.pageY - pos.top});
|
||||
selected = layout.nearest(p);
|
||||
|
||||
selected.oldm = selected.point.m;
|
||||
selected.olddata = selected.node.data;
|
||||
selected.node.data = jQuery.extend(true, {}, selected.node.data); // deep copy
|
||||
|
||||
selected.point.m = 1000.0;
|
||||
selected.node.data.fill = '#EEEEEE';
|
||||
|
||||
});
|
||||
|
||||
jQuery('svg').mousemove(function(e){
|
||||
if (selected !== null)
|
||||
{
|
||||
var pos = jQuery(this).position();
|
||||
var p = fromScreen({x: e.pageX - pos.left, y: e.pageY - pos.top});
|
||||
|
||||
selected.point.p.x = p.x;
|
||||
selected.point.p.y = p.y;
|
||||
}
|
||||
renderer.start();
|
||||
});
|
||||
|
||||
jQuery(window).bind('mouseup',function(e){
|
||||
if (selected !== null)
|
||||
{
|
||||
selected.node.data = selected.olddata;
|
||||
}
|
||||
selected = null;
|
||||
});
|
||||
|
||||
|
||||
// helpers for figuring out where to draw arrows
|
||||
function intersect_line_line(p1, p2, p3, p4)
|
||||
{
|
||||
var denom = ((p4.y - p3.y)*(p2.x - p1.x) - (p4.x - p3.x)*(p2.y - p1.y));
|
||||
|
||||
// lines are parallel
|
||||
if (denom === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var ua = ((p4.x - p3.x)*(p1.y - p3.y) - (p4.y - p3.y)*(p1.x - p3.x)) / denom;
|
||||
var ub = ((p2.x - p1.x)*(p1.y - p3.y) - (p2.y - p1.y)*(p1.x - p3.x)) / denom;
|
||||
|
||||
if (ua < 0 || ua > 1 || ub < 0 || ub > 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
x: p1.x + ua * (p2.x - p1.x),
|
||||
y: p1.y + ua * (p2.y - p1.y)
|
||||
};
|
||||
}
|
||||
|
||||
function intersect_line_box(p1, p2, p3, w, h)
|
||||
{
|
||||
var tl = {x: p3.x, y: p3.y};
|
||||
var tr = {x: p3.x + w, y: p3.y};
|
||||
var bl = {x: p3.x, y: p3.y + h};
|
||||
var br = {x: p3.x + w, y: p3.y + h};
|
||||
|
||||
var result;
|
||||
if (result = intersect_line_line(p1, p2, tl, tr)) { return result; } // top
|
||||
if (result = intersect_line_line(p1, p2, tr, br)) { return result; } // right
|
||||
if (result = intersect_line_line(p1, p2, br, bl)) { return result; } // bottom
|
||||
if (result = intersect_line_line(p1, p2, bl, tl)) { return result; } // left
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
7
raphael-min.js
vendored
Normal file
7
raphael-min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
366
springy.js
Normal file
366
springy.js
Normal file
|
@ -0,0 +1,366 @@
|
|||
|
||||
var Graph = function()
|
||||
{
|
||||
this.nodeSet = {};
|
||||
this.nodes = [];
|
||||
this.edges = [];
|
||||
|
||||
this.eventListeners = [];
|
||||
};
|
||||
|
||||
Node = function(data)
|
||||
{
|
||||
this.id = (Node.nextId += 1);
|
||||
this.data = typeof(data) !== 'undefined' ? data : {};
|
||||
};
|
||||
Node.nextId = 0;
|
||||
|
||||
Edge = function(source, target, data)
|
||||
{
|
||||
this.id = (Edge.nextId += 1);
|
||||
this.source = source;
|
||||
this.target = target;
|
||||
this.data = typeof(data) !== 'undefined' ? data : {};
|
||||
};
|
||||
Edge.nextId = 0;
|
||||
|
||||
Graph.prototype.addNode = function(node)
|
||||
{
|
||||
this.nodes.push(node);
|
||||
this.nodeSet[node.id] = node;
|
||||
this.notify();
|
||||
return node;
|
||||
};
|
||||
|
||||
Graph.prototype.addEdge = function(edge)
|
||||
{
|
||||
this.edges.push(edge);
|
||||
this.notify();
|
||||
return edge;
|
||||
};
|
||||
|
||||
Graph.prototype.newNode = function(data)
|
||||
{
|
||||
var node = new Node(data);
|
||||
this.addNode(node);
|
||||
return node;
|
||||
};
|
||||
|
||||
Graph.prototype.newEdge = function(source, target, data)
|
||||
{
|
||||
var edge = new Edge(source, target, data);
|
||||
this.addEdge(edge);
|
||||
return edge;
|
||||
};
|
||||
|
||||
|
||||
Graph.prototype.addGraphListener = function(obj)
|
||||
{
|
||||
this.eventListeners.push(obj);
|
||||
};
|
||||
|
||||
Graph.prototype.notify = function()
|
||||
{
|
||||
this.eventListeners.forEach(function(obj){
|
||||
obj.graphChanged();
|
||||
});
|
||||
};
|
||||
|
||||
// -----------
|
||||
var Layout = {};
|
||||
Layout.ForceDirected = function(graph, stiffness, repulsion, damping)
|
||||
{
|
||||
this.graph = graph;
|
||||
this.stiffness = stiffness; // spring stiffness constant
|
||||
this.repulsion = repulsion; // repulsion constant
|
||||
this.damping = damping; // velocity damping factor
|
||||
|
||||
this.nodePoints = {}; // keep track of points associated with nodes
|
||||
this.edgeSprings = {}; // keep track of points associated with nodes
|
||||
this.extraSprings = []; // springs that aren't associated with any edges
|
||||
|
||||
this.intervalId = null;
|
||||
};
|
||||
|
||||
Layout.ForceDirected.prototype.point = function(node)
|
||||
{
|
||||
if (typeof(this.nodePoints[node.id]) === 'undefined')
|
||||
{
|
||||
var mass = typeof(node.data.mass) !== 'undefined' ? node.data.mass : 1.0;
|
||||
this.nodePoints[node.id] = new Layout.ForceDirected.Point(Layout.ForceDirected.Vector.random(), mass);
|
||||
}
|
||||
|
||||
return this.nodePoints[node.id];
|
||||
};
|
||||
|
||||
Layout.ForceDirected.prototype.spring = function(edge)
|
||||
{
|
||||
if (typeof(this.edgeSprings[edge.id]) === 'undefined')
|
||||
{
|
||||
var length = typeof(edge.data.length) !== 'undefined' ? edge.data.length : 1.0;
|
||||
this.edgeSprings[edge.id] = new Layout.ForceDirected.Spring(
|
||||
this.point(edge.source), this.point(edge.target), length, this.stiffness
|
||||
);
|
||||
}
|
||||
|
||||
return this.edgeSprings[edge.id];
|
||||
};
|
||||
|
||||
// callback should accept two arguments: Node, Point
|
||||
Layout.ForceDirected.prototype.eachNode = function(callback)
|
||||
{
|
||||
var t = this;
|
||||
this.graph.nodes.forEach(function(n){
|
||||
callback.call(t, n, t.point(n));
|
||||
});
|
||||
};
|
||||
|
||||
// callback should accept two arguments: Edge, Spring
|
||||
Layout.ForceDirected.prototype.eachEdge = function(callback)
|
||||
{
|
||||
var t = this;
|
||||
this.graph.edges.forEach(function(e){
|
||||
callback.call(t, e, t.spring(e));
|
||||
});
|
||||
};
|
||||
|
||||
// callback should accept one argument: Spring
|
||||
Layout.ForceDirected.prototype.eachSpring = function(callback)
|
||||
{
|
||||
var t = this;
|
||||
this.graph.edges.forEach(function(e){
|
||||
callback.call(t, t.spring(e));
|
||||
});
|
||||
|
||||
this.extraSprings.forEach(function(s){
|
||||
callback.call(t, s);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Physics stuff
|
||||
Layout.ForceDirected.prototype.applyCoulombsLaw = function()
|
||||
{
|
||||
this.eachNode(function(n1, point1) {
|
||||
this.eachNode(function(n2, point2) {
|
||||
if (point1 !== point2)
|
||||
{
|
||||
var d = point1.p.subtract(point2.p);
|
||||
var distance = d.magnitude() + 1.0;
|
||||
var direction = d.normalise();
|
||||
|
||||
// apply force to each end point
|
||||
point1.applyForce(direction.multiply(this.repulsion).divide(distance * distance * 0.5));
|
||||
point2.applyForce(direction.multiply(this.repulsion).divide(distance * distance * -0.5));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
Layout.ForceDirected.prototype.applyHookesLaw = function()
|
||||
{
|
||||
this.eachSpring(function(spring){
|
||||
var d = spring.point2.p.subtract(spring.point1.p); // the direction of the spring
|
||||
var displacement = spring.length - d.magnitude();
|
||||
var direction = d.normalise();
|
||||
|
||||
// apply force to each end point
|
||||
spring.point1.applyForce(direction.multiply(spring.k * displacement * -0.5));
|
||||
spring.point2.applyForce(direction.multiply(spring.k * displacement * 0.5));
|
||||
});
|
||||
};
|
||||
|
||||
Layout.ForceDirected.prototype.attractToCentre = function()
|
||||
{
|
||||
this.eachNode(function(node, point) {
|
||||
var direction = point.p.multiply(-1.0);
|
||||
point.applyForce(direction.multiply(this.repulsion / 5.0));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Layout.ForceDirected.prototype.updateVelocity = function(timestep)
|
||||
{
|
||||
this.eachNode(function(node, point) {
|
||||
point.v = point.v.add(point.f.multiply(timestep)).multiply(this.damping);
|
||||
point.f = new Layout.ForceDirected.Vector(0,0);
|
||||
});
|
||||
};
|
||||
|
||||
Layout.ForceDirected.prototype.updatePosition = function(timestep)
|
||||
{
|
||||
this.eachNode(function(node, point) {
|
||||
point.p = point.p.add(point.v.multiply(timestep));
|
||||
});
|
||||
};
|
||||
|
||||
Layout.ForceDirected.prototype.totalEnergy = function(timestep)
|
||||
{
|
||||
var energy = 0.0;
|
||||
this.eachNode(function(node, point) {
|
||||
var speed = point.v.magnitude();
|
||||
energy += speed * speed;
|
||||
});
|
||||
|
||||
return energy;
|
||||
};
|
||||
|
||||
|
||||
// start simulation
|
||||
Layout.ForceDirected.prototype.start = function(interval, render, done)
|
||||
{
|
||||
var t = this;
|
||||
|
||||
if (this.intervalId !== null) {
|
||||
return; // already running
|
||||
}
|
||||
|
||||
this.intervalId = setInterval(function() {
|
||||
t.applyCoulombsLaw();
|
||||
t.applyHookesLaw();
|
||||
t.attractToCentre();
|
||||
t.updateVelocity(0.05);
|
||||
t.updatePosition(0.05);
|
||||
|
||||
if (typeof(render) !== 'undefined') { render(); }
|
||||
|
||||
// stop simulation when energy of the system goes below a threshold
|
||||
if (t.totalEnergy() < 0.1)
|
||||
{
|
||||
clearInterval(t.intervalId);
|
||||
t.intervalId = null;
|
||||
if (typeof(done) !== 'undefined') { done(); }
|
||||
}
|
||||
}, interval);
|
||||
};
|
||||
|
||||
// Find the nearest point to a particular position
|
||||
Layout.ForceDirected.prototype.nearest = function(pos)
|
||||
{
|
||||
var min = {node: null, point: null, distance: null};
|
||||
var t = this;
|
||||
this.graph.nodes.forEach(function(n){
|
||||
var point = t.point(n);
|
||||
var distance = point.p.subtract(pos).magnitude();
|
||||
|
||||
if (min.distance === null || distance < min.distance)
|
||||
{
|
||||
min = {node: n, point: point, distance: distance};
|
||||
}
|
||||
});
|
||||
|
||||
return min;
|
||||
};
|
||||
|
||||
// Vector
|
||||
Layout.ForceDirected.Vector = function(x, y)
|
||||
{
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
};
|
||||
|
||||
Layout.ForceDirected.Vector.random = function()
|
||||
{
|
||||
return new Layout.ForceDirected.Vector(2.0 * (Math.random() - 0.5), 2.0 * (Math.random() - 0.5));
|
||||
};
|
||||
|
||||
Layout.ForceDirected.Vector.prototype.add = function(v2)
|
||||
{
|
||||
return new Layout.ForceDirected.Vector(this.x + v2.x, this.y + v2.y);
|
||||
};
|
||||
|
||||
Layout.ForceDirected.Vector.prototype.subtract = function(v2)
|
||||
{
|
||||
return new Layout.ForceDirected.Vector(this.x - v2.x, this.y - v2.y);
|
||||
};
|
||||
|
||||
Layout.ForceDirected.Vector.prototype.multiply = function(n)
|
||||
{
|
||||
return new Layout.ForceDirected.Vector(this.x * n, this.y * n);
|
||||
};
|
||||
|
||||
Layout.ForceDirected.Vector.prototype.divide = function(n)
|
||||
{
|
||||
return new Layout.ForceDirected.Vector(this.x / n, this.y / n);
|
||||
};
|
||||
|
||||
Layout.ForceDirected.Vector.prototype.magnitude = function()
|
||||
{
|
||||
return Math.sqrt(this.x*this.x + this.y*this.y);
|
||||
};
|
||||
|
||||
Layout.ForceDirected.Vector.prototype.normal = function()
|
||||
{
|
||||
return new Layout.ForceDirected.Vector(-this.y, this.x);
|
||||
};
|
||||
|
||||
Layout.ForceDirected.Vector.prototype.normalise = function()
|
||||
{
|
||||
return this.divide(this.magnitude());
|
||||
};
|
||||
|
||||
// Point
|
||||
Layout.ForceDirected.Point = function(position, mass)
|
||||
{
|
||||
this.p = position; // position
|
||||
this.m = mass; // mass
|
||||
this.v = new Layout.ForceDirected.Vector(0, 0); // velocity
|
||||
this.f = new Layout.ForceDirected.Vector(0, 0); // force
|
||||
};
|
||||
|
||||
Layout.ForceDirected.Point.prototype.applyForce = function(force)
|
||||
{
|
||||
this.f = this.f.add(force.divide(this.m));
|
||||
};
|
||||
|
||||
// Spring
|
||||
Layout.ForceDirected.Spring = function(point1, point2, length, k)
|
||||
{
|
||||
this.point1 = point1;
|
||||
this.point2 = point2;
|
||||
this.length = length; // spring length at rest
|
||||
this.k = k; // spring constant (See Hooke's law) .. how stiff the spring is
|
||||
};
|
||||
|
||||
// Layout.ForceDirected.Spring.prototype.distanceToPoint = function(point)
|
||||
// {
|
||||
// // hardcore vector arithmetic.. ohh yeah!
|
||||
// // .. 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 ac = point.p.subtract(this.point1.p);
|
||||
// return Math.abs(ac.x * n.x + ac.y * n.y);
|
||||
// };
|
||||
|
||||
// Renderer handles the layout rendering loop
|
||||
function Renderer(interval, layout, clear, drawEdge, drawNode)
|
||||
{
|
||||
this.interval = interval;
|
||||
this.layout = layout;
|
||||
this.clear = clear;
|
||||
this.drawEdge = drawEdge;
|
||||
this.drawNode = drawNode;
|
||||
|
||||
this.layout.graph.addGraphListener(this);
|
||||
}
|
||||
|
||||
Renderer.prototype.graphChanged = function(e)
|
||||
{
|
||||
this.start();
|
||||
};
|
||||
|
||||
Renderer.prototype.start = function()
|
||||
{
|
||||
var t = this;
|
||||
this.layout.start(50, function render() {
|
||||
t.clear();
|
||||
|
||||
t.layout.eachEdge(function(edge, spring) {
|
||||
t.drawEdge(edge, spring.point1.p, spring.point2.p);
|
||||
});
|
||||
|
||||
t.layout.eachNode(function(node, point) {
|
||||
t.drawNode(node, point.p);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user