I cooked up a scheme where the nodes are absolute positioned div elements that can be styled with CSS, and the edges are drawn with Protovis. I pass an object that defines the edge properties to a Javascript function that uses JQuery to find the exact positions of the nodes, and then I use Protovis to draw the edges.
Example:
HTML:
<div id="workflowContainer"> <!-- -- Draw Simple divs to represent workflow nodes, and connect them with Protovis. -- -- Nodes are positioned absolutely. -- Node positions can be static and manually determined, -- or dynamic and determined by server-side or client-side -- code. This example uses hard coded node positions. --> <div id="workflowChart" > <!-- Clickable node --> <a href=""><div id="startFlow" style="top 0; left: 440px;">Start</div></a> <!-- Foo branch --> <!-- Unclickable node --> <div id="foo1Flow" style="top: 100px; left: 200px;">Foo 1</div> <a href=""><div id="foo2Flow" style="top: 175px; left: 100px;">Foo 2</div></a> <div id="fooChoice1Flow" style="top: 300px; left: 0px;">Foo Choice 1</div> <div id="fooChoice2Flow" class="inactive" style="top: 300px; left: 165px;">Foo Choice 2</div> <div id="fooChoice3Flow" class="inactive" style="top: 300px; left: 360px;">Foo Choice 3</div> <div id="fooOptionFlow" style="top: 400px; left: 50px;">Foo Option</div> <a href=""><div id="fooCombineFlow" style="top: 500px; left: 200px;">Foo Combine</div></a> <a href=""><div id="fooSplit1Flow" style="top: 575px; left: 25px;">Foo Split 1</div></a> <a href=""><div id="fooSplit2Flow" style="top: 575px; left: 250px;">Foo Split 2</div></a> <!-- bar branch --> <div id="barFlow" style="top: 100px; left: 700px;">Bar</div> <a href=""><div id="bar1Flow" class="inactive" style="top: 200px; left: 550px;">Bar 1</div></a> <a href=""><div id="bar2Flow" class="inactive" style="top: 200px; left: 825px;">Bar 2</div></a> </div> </div>
CSS:
/* Contains both nodes and edges. */ #workflowChartContainer { position: relative; width: 1000px; } /* This is where the edges will be drawn by protovis. */ #workflowChartContainer span { position: absolute; top: 0; left: 0; background: transparent; z-index: 1000; /* SVG needs to be drawn on top of existing layout. */ } #workflowChart { position: relative; top: 0; left: 0; height: 700px; width: 1000px; } #workflowChart div { border-color: #5b9bea; background-color: #b9cde5; position: absolute; margin: 0; padding: 4px; border: 2px solid #5b9bea; background: #b9cde5; border-radius: 4px; -moz-border-radius: 4px; -webkit-border-radius: 4px; color: #000; z-index: 10000; /* Needs to be drawn on top of SVG to be clickable. */ } #workflowChart a { cursor: pointer; } #workflowChart a div { border-color: #f89c51; background: #fcd5b5; } #workflowChart div.inactive { border-color: #ccc; background-color: #eee; color: #ccc; } #workflowChart div:hover { border-color: #700000; }
Javascript:
/* Initialize workflow screen. */ var initWorkflow = function() { // List HTML nodes to connect. // // The edges are hardcoded in this example, // but could easily be made dynamic. var edges = [ { source: 'startFlow', target: 'foo1Flow' }, { source: 'foo1Flow', target: 'foo2Flow' }, { source: 'foo2Flow', target: 'fooChoice1Flow' }, { source: 'foo2Flow', target: 'fooChoice2Flow' }, { source: 'foo2Flow', target: 'fooChoice3Flow' }, { source: 'fooChoice1Flow', target: 'fooOptionFlow' }, { source: 'fooChoice2Flow', target: 'fooOptionFlow' }, { source: 'fooOptionFlow', target: 'fooCombineFlow' }, { source: 'fooChoice3Flow', target: 'fooCombineFlow' }, { source: 'fooCombineFlow', target: 'fooSplit1Flow' }, { source: 'fooCombineFlow', target: 'fooSplit2Flow' }, { source: 'startFlow', target: 'barFlow' }, { source: 'barFlow', target: 'bar1Flow' }, { source: 'barFlow', target: 'bar2Flow' }, ]; // Us JQUery to set height and width equal to background div. var workflow = $('#workflowChart'), h = workflow.height(), w = workflow.width(); // Create Protovis Panel used to render SVG. var vis = new pv.Panel() .width(w) .height(h) .antialias(false); // Attach Panel to dom vis.$dom = workflow[0]; // Render connectors drawEdges(vis, edges); var test = vis.render(); }; /* Draw edges specified in input array. */ var drawEdges = function(vis, edges) { // Direction indicators, var directions = []; $.each(edges, function(idx, item){ // Color of edges var color = '#000'; // Arrow radius var r = 5; // Use JQuery to get source and destination elements var source = $('#' + item.source); var target = $('#' + item.target); if (!(source.length && target.length)) { // One of the nodes is not present in the DOM; skip it. return; } var data = edgeCoords(source, target); if (item.sourceLOffset) { data[0].left += item.sourceLOffset; } if (item.targetLOffset) { data[1].left += item.targetLOffset; } if (source.hasClass('inactive') || target.hasClass('inactive')) { // If target is disabled, change the edge color. color = '#ccc'; } // Use Protovis to draw edge line. vis.add(pv.Line) .data(data) .left(function(d) {return d.left;}) .top(function(d) { if (d.type === 'target') { return d.top - (r * 2); } return d.top; }) .interpolate('linear') .segmented(false) .strokeStyle(color) .lineWidth(2); // Here you may want to calculate an angle // to twist the direction arrows to make the graph // prettier. I've left out the code to keep thing simple. var a = 0; // Add direction indicators to array. var d = data[1]; directions.push({ left: d.left, top: d.top - (r * 2), angle: a, color: color }); }); // Use Protovis to draw all direction indicators // // Here you may want to check and make // sure you're only drawing a single indicator // at each position, to avoid drawing multiple // indicators for targets that have multiple sources. // I've left out the code for simplicity. vis.add(pv.Dot) .data(directions) .left(function (d) {return d.left;}) .top(function (d) {return d.top;}) .radius(r) .angle(function (d) {return d.angle;}) .shape("triangle") .strokeStyle(function (d) {return d.color;}) .fillStyle(function (d) {return d.color;}); }; /* Returns the bottom-middle offset for a dom element. */ var bottomMiddle = function(node) { var coords = node.position(); coords.top += node.outerHeight(); coords.left += node.width() / 2; return coords; }; /* Returns the top-middle offset for a dom element. */ var topMiddle = function(node) { var coords = node.position(); coords.left += node.width() / 2; return coords; }; /* Return start/end coordinates for an edge. */ var edgeCoords = function(source, target) { var coords = [bottomMiddle(source), topMiddle(target)]; coords[0].type = 'source'; coords[1].type = 'target'; return coords; };