diff --git a/README b/README new file mode 100644 index 0000000..08a7e0c --- /dev/null +++ b/README @@ -0,0 +1,5 @@ +The simplest possible force directed graph layout algorithm implemented as a Javascript library that uses SVG objects. It is in no way meant to be production library, just a demonstration of force-directed layout as an adjunct to a conversation on that topic. + +demo1 -- super simple drawing of the taxonomic position of hedgehogs +demo2 -- a sentence diagram, shows custom colors and edge styles +demo3 -- populating a graph with data acquired via jQuery diff --git a/demo1.xhtml b/demo1.xhtml new file mode 100644 index 0000000..de5216b --- /dev/null +++ b/demo1.xhtml @@ -0,0 +1,44 @@ + + + + Simple Graph Layout: Hedgepigs + + + + + + + + \ No newline at end of file diff --git a/demo2.xhtml b/demo2.xhtml new file mode 100644 index 0000000..7e0ce97 --- /dev/null +++ b/demo2.xhtml @@ -0,0 +1,47 @@ + + + + Simple Graph Layout: Sentence Diagram + + + + + + + + \ No newline at end of file diff --git a/demo3.xhtml b/demo3.xhtml new file mode 100644 index 0000000..762b808 --- /dev/null +++ b/demo3.xhtml @@ -0,0 +1,36 @@ + + + + Simple Graph Layout: Last.fm Similar Bands + + + + + + + + + \ No newline at end of file diff --git a/graph.js b/graph.js new file mode 100644 index 0000000..0e9d637 --- /dev/null +++ b/graph.js @@ -0,0 +1,116 @@ +function Graph( canvas_name, width, height ) { + this.svg = "http://www.w3.org/2000/svg"; + this.canvas = document.getElementById(canvas_name); + this.width = width; + this.height = height; + this.canvas.style.width = width + "px"; + this.canvas.style.height = height + "px"; + this.vertices = {}; + this.forcex = {}; + this.forcey = {}; + this.stepsize = 0.0005; + this.iteration = 0; + this.task = null; + + // tunables to adjust the layout + this.repulsion = 200000; // repulsion constant, adjust for wider/narrower spacing + this.spring_length = 20; // base resting length of springs +} + +Graph.prototype.createVertex = function( name, color ) { // XXX -- should support separate id and name + // create an SVG rectangle, attach additional attributed to it + var vertex = document.createElementNS(this.svg, "rect"); + if( color === undefined ) { + color = "#222"; + } + vertex.setAttribute("style", "fill: "+color+"; stroke-width: 1px;"); + vertex.setAttribute("rx", "10px"); // round the edges + // random placement with a 10% margin at the edges + vertex.posx = Math.random() * (this.width * 0.8) + (this.width * 0.1); + vertex.posy = (Math.random() * (this.height * 0.8)) + (this.height * 0.1); + vertex.setAttribute("x", vertex.posx ); + vertex.setAttribute("y", vertex.posy ); + vertex.edges = new Array(); + this.canvas.appendChild(vertex); + + // text label + vertex.name = name; + vertex.textLabel = document.createElementNS(this.svg, "text"); + vertex.textLabel.setAttribute("style", "fill: #fff; stroke-width: 1px;"); + vertex.textLabel.appendChild( document.createTextNode( name ) ); + this.canvas.appendChild( vertex.textLabel ); + + // get the size of the rectangle from the text label's bounding box + vertex.h = vertex.textLabel.getBBox().height + 10; + vertex.w = vertex.textLabel.getBBox().width + 10; + vertex.setAttribute("height", vertex.h + "px"); + vertex.setAttribute("width", vertex.w + "px"); + + this.vertices[name] = vertex; +} + +Graph.prototype.createEdge = function( a, b, style ) { + var line = document.createElementNS(this.svg, "path"); + if( style === undefined ) { + style = "stroke: #444; stroke-width: 3px;"; + } + line.setAttribute("style", style); + this.canvas.insertBefore(line, this.canvas.firstChild); + this.vertices[a].edges[b] = { "dest" : b, "line": line }; + this.vertices[b].edges[a] = { "dest" : a, "line": line }; +} + +Graph.prototype.updateLayout = function() { + for (i in this.vertices) { + this.forcex[i] = 0; + this.forcey[i] = 0; + for (j in this.vertices) { + if( i !== j ) { + // using rectangle's center, bounding box would be better + var deltax = this.vertices[j].posx - this.vertices[i].posx; + var deltay = this.vertices[j].posy - this.vertices[i].posy; + var distance = Math.sqrt(deltax * deltax + deltay * deltay); + + // Coulomb's law -- repulsion varies inversely with distance + this.forcex[i] -= (this.repulsion / Math.pow( distance, 2 )) * deltax; + this.forcey[i] -= (this.repulsion / Math.pow( distance, 2 )) * deltay; + + // spring force along edges, follows Hooke's law + if( this.vertices[i].edges[j] ) { + this.forcex[i] += (distance - this.spring_length) * deltax; + this.forcey[i] += (distance - this.spring_length) * deltay; + } + } + } + } + for (i in this.vertices) { + // update rectangles + this.vertices[i].posx += this.forcex[i] * this.stepsize; + this.vertices[i].posy += this.forcey[i] * this.stepsize; + this.vertices[i].setAttribute("x", this.vertices[i].posx ); + this.vertices[i].setAttribute("y", this.vertices[i].posy ); + // update labels + this.vertices[i].textLabel.setAttribute("x", this.vertices[i].posx + 5 + "px"); + this.vertices[i].textLabel.setAttribute("y", this.vertices[i].posy + (2*this.vertices[i].h/3 )+ "px"); + // update edges + for (j in this.vertices[i].edges) { + this.vertices[i].edges[j].line.setAttribute("d", "M"+(this.vertices[i].posx+(this.vertices[i].w/2))+","+(this.vertices[i].posy+(this.vertices[i].h/2))+" L"+(this.vertices[this.vertices[i].edges[j].dest].posx+(this.vertices[this.vertices[i].edges[j].dest].w/2))+" "+(this.vertices[this.vertices[i].edges[j].dest].posy+(this.vertices[this.vertices[i].edges[j].dest].h/2))); + } + } + this.iteration++; + if( this.iteration > 300 ) // XXX -- should watch for rest state, not just quit after N iterations + this.quit(); +} +Graph.prototype.go = function() { + // already running + if (this.task) { + return; + } + obj = this; + this.iteration = 0; + this.task = window.setInterval(function(){ obj.updateLayout(); }, 1); +} +Graph.prototype.quit = function() { + window.clearInterval(this.task); + this.task = null; +} \ No newline at end of file