azkaban-memoizeit

New embedded flow UI.

1/8/2014 11:37:07 PM

Details

diff --git a/src/java/azkaban/webapp/servlet/ProjectManagerServlet.java b/src/java/azkaban/webapp/servlet/ProjectManagerServlet.java
index 2a4458d..3bb7194 100644
--- a/src/java/azkaban/webapp/servlet/ProjectManagerServlet.java
+++ b/src/java/azkaban/webapp/servlet/ProjectManagerServlet.java
@@ -525,9 +525,9 @@ public class ProjectManagerServlet extends LoginAbstractAzkabanServlet {
 			nodeObj.put("type", node.getType());
 			if (node.getEmbeddedFlowId() != null) {
 				nodeObj.put("flowId", node.getEmbeddedFlowId());
-//				HashMap<String, Object> embeddedNodeObj = new HashMap<String, Object>();
-//				fillFlowInfo2(project, node.getEmbeddedFlowId(), embeddedNodeObj);
-//				nodeObj.put("flowData", embeddedNodeObj);
+				HashMap<String, Object> embeddedNodeObj = new HashMap<String, Object>();
+				fillFlowInfo2(project, node.getEmbeddedFlowId(), embeddedNodeObj);
+				nodeObj.put("flowData", embeddedNodeObj);
 			}
 			
 			nodeList.add(nodeObj);
diff --git a/src/java/azkaban/webapp/servlet/velocity/flowpage.vm b/src/java/azkaban/webapp/servlet/velocity/flowpage.vm
index 67cde66..6519a3d 100644
--- a/src/java/azkaban/webapp/servlet/velocity/flowpage.vm
+++ b/src/java/azkaban/webapp/servlet/velocity/flowpage.vm
@@ -26,11 +26,14 @@
 		<script type="text/javascript" src="${context}/js/backbone-0.9.10-min.js"></script>
 		<script type="text/javascript" src="${context}/js/jquery.simplemodal-1.4.4.js"></script>
 		
+		<script type="text/javascript" src="${context}/js/raphael-min.js"></script>
+		<script type="text/javascript" src="${context}/js/svgutils.js"></script>
 		<script type="text/javascript" src="${context}/js/azkaban.date.utils.js"></script>
 		<script type="text/javascript" src="${context}/js/azkaban.ajax.utils.js"></script>
 		<script type="text/javascript" src="${context}/js/azkaban.nav.js"></script>
 		<script type="text/javascript" src="${context}/js/azkaban.layout.js"></script>
 		<script type="text/javascript" src="${context}/js/svgNavigate.js"></script>
+		<script type="text/javascript" src="${context}/js/d3.v3.min.js"></script>
 		<script type="text/javascript" src="${context}/js/azkaban.svg.flow.loader.js"></script>
 		<script type="text/javascript" src="${context}/js/azkaban.svg.graph.view.js"></script>
 		<script type="text/javascript" src="${context}/js/azkaban.flow.extended.view.js"></script>
diff --git a/src/web/css/azkaban-graph.css b/src/web/css/azkaban-graph.css
index b5b6b57..c78a508 100644
--- a/src/web/css/azkaban-graph.css
+++ b/src/web/css/azkaban-graph.css
@@ -1,3 +1,126 @@
+.nodebox text {
+	pointer-events: none;
+}
+
+.nodebox image {
+	pointer-events: none;
+}
+
+/* Nodes */
+.node:hover {
+	cursor: pointer;
+}
+
+.nodebox > .border:hover {
+	fill-opacity: 0.7;
+}
+
+.nodebox > .flowborder:hover {
+	stroke-opacity: 0.7;
+}
+
+.border {
+	stroke-width: 1;
+}
+
+.flownode .nodebox .flowborder {
+	stroke-width: 1.25;
+	fill: #FFF;
+	fill-opacity: 0.8;
+}
+
+.READY > g > rect {
+	fill: #DDD;
+	stroke: #CCC;
+}
+
+.READY > g > text {
+	fill: #000;
+}
+
+.RUNNING > g > rect {
+	fill: #39b3d7;
+	stroke: #39b3d7;
+}
+
+.RUNNING > g > text {
+	fill: #FFF;
+}
+
+.SUCCEEDED > g > rect {
+	fill: #5cb85c;
+	stroke: #4cae4c;
+}
+
+.SUCCEEDED > g > text {
+	fill: #FFF;
+}
+
+.FAILED > g > rect {
+	fill: #d2322d;
+	stroke: #d2322d;
+}
+
+.FAILED > g > text {
+	fill: #FFF;
+}
+
+.FAILED_FINISHING > g > rect {
+	fill: #ed9c28;
+	stroke: #ed9c28;
+}
+
+.FAILED_FINISHING > g > text {
+	fill: #FFF;
+}
+
+.DISABLED > g > rect {
+	fill: #800000;
+	stroke: #800000;
+}
+
+.DISABLED > g > rect {
+	fill: #800000;
+	stroke: #800000;
+}
+
+.SKIPPED > g > rect {
+	fill: #CCC;
+	stroke: #CCC;
+}
+
+.SKIPPED > g > rect {
+	fill: #CCC;
+	stroke: #CCC;
+}
+
+.DISABLED {
+	opacity: 0.25;
+}
+
+.SKIPPED {
+	opacity: 0.25;
+}
+
+/* Edges */
+.edge {
+	stroke: #CCC;
+	stroke-width: 1.5;
+}
+
+.edge:hover {
+	stroke: #009FC9;
+	stroke-width: 1.5;
+}
+
+/*
+svg text1 {
+	pointer-events: none;
+}
+
+svg g.nodebox1 {
+	pointer-events: none;
+}
 
 svg .edge {
 	stroke: #BBB;
@@ -97,7 +220,7 @@ svg .selected .nodebox rect {
 }
 
 svg .node rect {
-	fill: #F8F8F8;
+	fill: #CCC;
 	stroke: #CCC;
 	stroke-width: 2;
 }
@@ -115,7 +238,7 @@ svg .node:hover rect {
 }
 
 svg .READY rect {
-	fill: #EEE;
+	fill: #CCC;
 }
 
 svg .RUNNING rect {
@@ -244,4 +367,5 @@ svg circle.SKIPPED {
 .svgTiny {
 	width: 100%;
 	height: 100%;
-}
\ No newline at end of file
+}
+*/
diff --git a/src/web/js/azkaban.context.menu.js b/src/web/js/azkaban.context.menu.js
index 7bca798..d0163a7 100644
--- a/src/web/js/azkaban.context.menu.js
+++ b/src/web/js/azkaban.context.menu.js
@@ -1,97 +1,112 @@
+/*
+ * Copyright 2012 LinkedIn Corp.
+ * 
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ * 
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
 $.namespace('azkaban');
 
 azkaban.ContextMenuView = Backbone.View.extend({
-	events :  {
-	},
-	initialize : function(settings) {
-		var div = this.el;
-		$('body').click(function(e) {
-			$(".contextMenu").remove();
-		});
-		$('body').bind("contextmenu", function(e) {$(".contextMenu").remove()});
-	},
-	show : function(evt, menu) {
-		console.log("Show context menu");
-		$(".contextMenu").remove();
-		var x = evt.pageX;
-		var y = evt.pageY;
+        events :  {
+        },
+        initialize : function(settings) {
+                var div = this.el;
+                $('body').click(function(e) {
+                        $(".contextMenu").remove();
+                });
+                $('body').bind("contextmenu", function(e) {$(".contextMenu").remove()});
+        },
+        show : function(evt, menu) {
+                console.log("Show context menu");
+                $(".contextMenu").remove();
+                var x = evt.pageX;
+                var y = evt.pageY;
 
-		var contextMenu = this.setupMenu(menu);
-		$(contextMenu).css({top: y, left: x});
-		
-		$(this.el).after(contextMenu);
-	},
-	hide : function(evt) {
-		console.log("Hide context menu");
-		$(".contextMenu").remove();
-	},
-	handleClick: function(evt) {
-		console.log("handling click");
-	},
-	setupMenu: function(menu) {
-		var contextMenu = document.createElement("div");
-		$(contextMenu).addClass("contextMenu");
-		var ul = document.createElement("ul");
-		$(contextMenu).append(ul);
+                var contextMenu = this.setupMenu(menu);
+                $(contextMenu).css({top: y, left: x});
+                $(this.el).after(contextMenu);
+        },
+        hide : function(evt) {
+                console.log("Hide context menu");
+                $(".contextMenu").remove();
+        },
+        handleClick: function(evt) {
+                console.log("handling click");
+        },
+        setupMenu: function(menu) {
+                var contextMenu = document.createElement("div");
+                $(contextMenu).addClass("contextMenu");
+                var ul = document.createElement("ul");
+                $(contextMenu).append(ul);
 
-		for (var i = 0; i < menu.length; ++i) {
-			var menuItem = document.createElement("li");
-			if (menu[i].break) {
-				$(menuItem).addClass("break");
-			}
-			else {
-				var title = menu[i].title;
-				var callback = menu[i].callback;
-				$(menuItem).addClass("menuitem");
-				$(menuItem).text(title);
-				menuItem.callback = callback;
-				$(menuItem).click(function() { 
-					$(contextMenu).hide(); 
-					this.callback.call();});
-					
-				if (menu[i].submenu) {
-					var expandSymbol = document.createElement("div");
-					$(expandSymbol).addClass("expandSymbol");
-					$(menuItem).append(expandSymbol);
-					
-					var subMenu = this.setupMenu(menu[i].submenu);
-					$(subMenu).addClass("subMenu");
-					subMenu.parent = contextMenu;
-					menuItem.subMenu = subMenu;
-					$(subMenu).hide();
-					$(this.el).after(subMenu);
-					
-					$(menuItem).mouseenter(function() {
-						$(".subMenu").hide();
-						var menuItem = this;
-						menuItem.selected = true;
-						setTimeout(function() {
-							if (menuItem.selected) {
-								var offset = $(menuItem).offset();
-								var left = offset.left;
-								var top = offset.top;
-								var width = $(menuItem).width();
-								var subMenu = menuItem.subMenu;
-								
-								var newLeft = left + width - 5;
-								$(subMenu).css({left: newLeft, top: top});
-								$(subMenu).show();
-							}
-						}, 500);
-					});
-					$(menuItem).mouseleave(function() {this.selected = false;});
-				}
-			}
+                for (var i = 0; i < menu.length; ++i) {
+                        var menuItem = document.createElement("li");
+                        if (menu[i].break) {
+                                $(menuItem).addClass("break");
+                        }
+                        else {
+                                var title = menu[i].title;
+                                var callback = menu[i].callback;
+                                $(menuItem).addClass("menuitem");
+                                $(menuItem).text(title);
+                                menuItem.callback = callback;
+                                $(menuItem).click(function() { 
+                                        $(contextMenu).hide(); 
+                                        this.callback.call();});
+                                        
+                                if (menu[i].submenu) {
+                                        var expandSymbol = document.createElement("div");
+                                        $(expandSymbol).addClass("expandSymbol");
+                                        $(menuItem).append(expandSymbol);
+                                        
+                                        var subMenu = this.setupMenu(menu[i].submenu);
+                                        $(subMenu).addClass("subMenu");
+                                        subMenu.parent = contextMenu;
+                                        menuItem.subMenu = subMenu;
+                                        $(subMenu).hide();
+                                        $(this.el).after(subMenu);
+                                        
+                                        $(menuItem).mouseenter(function() {
+                                                $(".subMenu").hide();
+                                                var menuItem = this;
+                                                menuItem.selected = true;
+                                                setTimeout(function() {
+                                                        if (menuItem.selected) {
+                                                                var offset = $(menuItem).offset();
+                                                                var left = offset.left;
+                                                                var top = offset.top;
+                                                                var width = $(menuItem).width();
+                                                                var subMenu = menuItem.subMenu;
+                                                                
+                                                                var newLeft = left + width - 5;
+                                                                $(subMenu).css({left: newLeft, top: top});
+                                                                $(subMenu).show();
+                                                        }
+                                                }, 500);
+                                        });
+                                        $(menuItem).mouseleave(function() {this.selected = false;});
+                                }
+                        }
 
-			$(ul).append(menuItem);
-		}
+                        $(ul).append(menuItem);
+                }
 
-		return contextMenu;
-	}
+                return contextMenu;
+        }
 });
 
 var contextMenuView;
 $(function() {
-	contextMenuView = new azkaban.ContextMenuView({el:$('#contextMenu')});
-	contextMenuView.hide();
+        contextMenuView = new azkaban.ContextMenuView({el:$('#contextMenu')});
+        contextMenuView.hide();
 });
\ No newline at end of file
diff --git a/src/web/js/azkaban.flow.job.view.js b/src/web/js/azkaban.flow.job.view.js
index a85f9a2..55eaa25 100644
--- a/src/web/js/azkaban.flow.job.view.js
+++ b/src/web/js/azkaban.flow.job.view.js
@@ -93,7 +93,6 @@ azkaban.JobListView = Backbone.View.extend({
 	render: function(self) {
 		var data = this.model.get("data");
 		var nodes = data.nodes;
-		var edges = data.edges;
 		
 		this.listNodes = {}; 
 		if (nodes.length == 0) {
diff --git a/src/web/js/azkaban.flow.view.js b/src/web/js/azkaban.flow.view.js
index 010291a..a9d6808 100644
--- a/src/web/js/azkaban.flow.view.js
+++ b/src/web/js/azkaban.flow.view.js
@@ -24,32 +24,6 @@ var handleJobMenuClick = function(action, el, pos) {
 	}
 }
 
-function hasClass(el, name) 
-{
-	var classes = el.getAttribute("class");
-	if (classes == null) {
-		return false;
-	}
-   return new RegExp('(\\s|^)'+name+'(\\s|$)').test(classes);
-}
-
-function addClass(el, name)
-{
-   if (!hasClass(el, name)) { 
-   		var classes = el.getAttribute("class");
-   		classes += classes ? ' ' + name : '' +name;
-   		el.setAttribute("class", classes);
-   }
-}
-
-function removeClass(el, name)
-{
-   if (hasClass(el, name)) {
-      var classes = el.getAttribute("class");
-      el.setAttribute("class", classes.replace(new RegExp('(\\s|^)'+name+'(\\s|$)'),' ').replace(/^\s+|\s+$/g, ''));
-   }
-}
-
 var flowTabView;
 azkaban.FlowTabView= Backbone.View.extend({
   events : {
@@ -294,55 +268,49 @@ $(function() {
 	mainSvgGraphView = new azkaban.SvgGraphView({el:$('#svgDiv'), model: graphModel, rightClick:  { "node": nodeClickCallback, "edge": edgeClickCallback, "graph": graphClickCallback }});
 	jobsListView = new azkaban.JobListView({el:$('#jobList'), model: graphModel, contextMenuCallback: jobClickCallback});
 	
-	var requestURL = contextURL + "/manager";
+	setTimeout(
+		function() { 
+			processFlowData(jsonFlowObject);
+			graphModel.set({data:jsonFlowObject});
+    		graphModel.trigger("change:graph");
+		}, 
+	10);
 
-	// Set up the Flow options view. Create a new one every time :p
-	 $('#executebtn').click( function() {
-		 closeAllSubDisplays();
-	  	var data = graphModel.get("data");
-	  	var nodes = data.nodes;
-	  
-	    var executingData = {
-	  		project: projectName,
-	  		ajax: "executeFlow",
-	  		flow: flowId
-		};
-	
-	  	flowExecuteDialogView.show(executingData);
-	 });
 
-	$.get(
-	      requestURL,
-	      {"project": projectName, "ajax":"fetchflowgraph", "flow":flowId},
-	      function(data) {
-	    	  parseFlowData(data, graphModel);
-	          graphModel.trigger("change:graph");
-	          
-	          // Handle the hash changes here so the graph finishes rendering first.
-	          if (window.location.hash) {
-				var hash = window.location.hash;
-				
-				if (hash == "#executions") {
-					flowTabView.handleExecutionLinkClick();
-				}
-				else if (hash == "#graph") {
-					// Redundant, but we may want to change the default. 
-					selected = "graph";
-				}
-				else {
-					if ("#page" == hash.substring(0, "#page".length)) {
-						var page = hash.substring("#page".length, hash.length);
-						console.log("page " + page);
-						flowTabView.handleExecutionLinkClick();
-						executionModel.set({"page": parseInt(page)});
-					}
-					else {
-						selected = "graph";
-					}
-				}
-			}
-	      },
-	      "json"
-	    );
+//
+//	$.get(
+//	      requestURL,
+//	      {},
+//	      function(data) {
+//	    	  processFlowData(data);
+//	    	  graphModel.set({data:data});
+//	          graphModel.trigger("change:graph");
+//	          
+//	          // Handle the hash changes here so the graph finishes rendering first.
+//	          if (window.location.hash) {
+//				var hash = window.location.hash;
+//				
+//				if (hash == "#executions") {
+//					flowTabView.handleExecutionLinkClick();
+//				}
+//				else if (hash == "#graph") {
+//					// Redundant, but we may want to change the default. 
+//					selected = "graph";
+//				}
+//				else {
+//					if ("#page" == hash.substring(0, "#page".length)) {
+//						var page = hash.substring("#page".length, hash.length);
+//						console.log("page " + page);
+//						flowTabView.handleExecutionLinkClick();
+//						executionModel.set({"page": parseInt(page)});
+//					}
+//					else {
+//						selected = "graph";
+//					}
+//				}
+//			}
+//	      },
+//	      "json"
+//	    );
 
 });
diff --git a/src/web/js/azkaban.job.status.utils.js b/src/web/js/azkaban.job.status.utils.js
index 7d4b45d..bc2d3bf 100644
--- a/src/web/js/azkaban.job.status.utils.js
+++ b/src/web/js/azkaban.job.status.utils.js
@@ -13,29 +13,3 @@ var statusStringMap = {
 	"UNKNOWN": "Unknown",
 	"PAUSED": "Paused"
 };
-
-function hasClass(el, name) 
-{
-	var classes = el.getAttribute("class");
-	if (classes == null) {
-		return false;
-	}
-   return new RegExp('(\\s|^)'+name+'(\\s|$)').test(classes);
-}
-
-function addClass(el, name)
-{
-   if (!hasClass(el, name)) { 
-   		var classes = el.getAttribute("class");
-   		classes += classes ? ' ' + name : '' +name;
-   		el.setAttribute("class", classes);
-   }
-}
-
-function removeClass(el, name)
-{
-   if (hasClass(el, name)) {
-      var classes = el.getAttribute("class");
-      el.setAttribute("class", classes.replace(new RegExp('(\\s|^)'+name+'(\\s|$)'),' ').replace(/^\s+|\s+$/g, ''));
-   }
-}
\ No newline at end of file
diff --git a/src/web/js/azkaban.layout.js b/src/web/js/azkaban.layout.js
index 07debec..ba02b06 100644
--- a/src/web/js/azkaban.layout.js
+++ b/src/web/js/azkaban.layout.js
@@ -18,7 +18,6 @@ var idSort = function(a, b) {
 
 function prepareLayout(nodes, hmargin, layers, nodeMap) {
 	var maxLayer = 0;
-	var numLayer = 0;
 	var nodeQueue = new Array();
 	// Find start layers first
 	for (var i=0; i < nodes.length; ++i) {
@@ -61,8 +60,6 @@ function prepareLayout(nodes, hmargin, layers, nodeMap) {
 	
 	// Assign to layers
 	for (var i = 0; i < nodes.length; ++i) {
-		numLayer = Math.max(numLayer, nodes[i].level);
-
 		var width = nodes[i].width ? nodes[i].width : nodes[i].label.length * 11.5 + 4;
 		var height = nodes[i].height ? nodes[i].height : 1;
 		var node = { id: nodes[i].id, node: nodes[i], level: nodes[i].level, in:[], out:[], width: width + hmargin, x:0, height:height };
@@ -76,10 +73,13 @@ function prepareLayout(nodes, hmargin, layers, nodeMap) {
 		layers[node.level].push(node);
 	}
 	
-	layers.numLayer = numLayer;
 	layers.maxLayer = maxLayer;
 }
 
+function respaceGraph(nodes, edges) {
+	
+}
+
 function layoutGraph(nodes, edges, hmargin) {
 	var startLayer = [];
 
@@ -92,7 +92,6 @@ function layoutGraph(nodes, edges, hmargin) {
 	
 	prepareLayout(nodes, hmargin, layers, nodeMap);
 	var maxLayer = layers.maxLayer;
-	var numLayer = layers.numLayer;
 	
 	// Create dummy nodes
 	var edgeDummies = {};
diff --git a/src/web/js/azkaban.staging.flow.view.js b/src/web/js/azkaban.staging.flow.view.js
index 3f417bc..7e12947 100644
--- a/src/web/js/azkaban.staging.flow.view.js
+++ b/src/web/js/azkaban.staging.flow.view.js
@@ -152,32 +152,6 @@ function recurseAllDescendents(nodes, disabledMap, id, disable) {
 	}
 }
 
-function hasClass(el, name) 
-{
-	var classes = el.getAttribute("class");
-	if (classes == null) {
-		return false;
-	}
-   return new RegExp('(\\s|^)'+name+'(\\s|$)').test(classes);
-}
-
-function addClass(el, name)
-{
-   if (!hasClass(el, name)) { 
-   		var classes = el.getAttribute("class");
-   		classes += classes ? ' ' + name : '' +name;
-   		el.setAttribute("class", classes);
-   }
-}
-
-function removeClass(el, name)
-{
-   if (hasClass(el, name)) {
-      var classes = el.getAttribute("class");
-      el.setAttribute("class", classes.replace(new RegExp('(\\s|^)'+name+'(\\s|$)'),' ').replace(/^\s+|\s+$/g, ''));
-   }
-}
-
 var flowTabView;
 azkaban.FlowTabView= Backbone.View.extend({
   events : {
diff --git a/src/web/js/azkaban.svg.flow.loader.js b/src/web/js/azkaban.svg.flow.loader.js
index 801877c..88e46cf 100644
--- a/src/web/js/azkaban.svg.flow.loader.js
+++ b/src/web/js/azkaban.svg.flow.loader.js
@@ -2,46 +2,6 @@ var extendedViewPanels = {};
 var extendedDataModels = {};
 var openJobDisplayCallback = function(nodeId, flowId, evt) {
 	console.log("Open up data");
-	var target = evt.currentTarget;
-	var node = target.nodeobj;
-	
-	// If target panel exists, than we display and skip.
-	var targetPanel = node.panel;
-	if (targetPanel) {
-		$("#flowInfoBase").before(targetPanel);
-		targetPanel.showExtendedView(evt);
-	}
-	else {
-		var targetModel = node.dataModel;
-		var flowId = flowId;
-		
-		if (!targetModel) {
-			var requestURL = contextURL + "/manager";
-			var newParentPath = node.parentPath ? node.parentPath + ":" + flowId : flowId;
-			node.parentPath = newParentPath;
-			
-			$.get(
-		      requestURL,
-		      {"project": projectName, "ajax":"fetchflownodedata", "flow":flowId, "node": node.id},
-		      function(data) {
-		  		var graphModel = new azkaban.GraphModel();
-		  		graphModel.set({id: data.id, flow: data.flowData, type: data.type, props: data.props});
-				
-		  		var flowData = data.flowData;
-		  		if (flowData) {
-		  			parseFlowData(flowData, graphModel, newParentPath);
-		  		}
-		  		
-		  		node.dataModel = graphModel;
-		  		createNewPanel(node, graphModel, evt);
-		      },
-		      "json"
-		    );
-		}
-		else {
-			createNewPanel(node, targetModel, evt);
-		}
-	}
 
 	/*
 	$("#flowInfoBase").before(cloneStuff);
@@ -74,7 +34,7 @@ var createNewPanel = function(node, model, evt) {
 	
 	var nodeInfoPanelID = parentPath ? parentPath + ":" + node.id + "-info" : node.id + "-info";
 	var cloneStuff = $("#flowInfoBase").clone();
-	cloneStuff.nodeobj = node;
+	cloneStuff.data = node;
 	$(cloneStuff).attr("id", nodeInfoPanelID);
 	$("#flowInfoBase").before(cloneStuff);
 	
@@ -83,15 +43,20 @@ var createNewPanel = function(node, model, evt) {
 	backboneView.showExtendedView(evt);
 }
 
-var parseFlowData = function(data, model, parentPath) {
+/**
+ * Processes the flow data from Javascript
+ */
+var processFlowData = function(data) {
 	var nodes = {};
 	var edges = new Array();
+
+	//Create a node map
 	for (var i=0; i < data.nodes.length; ++i) {
 		var node = data.nodes[i];
 		nodes[node.id] = node;
 	}
 
-    var nodeQueue = new Array();
+	// Create each node in and out nodes. Create an edge list.
 	for (var i=0; i < data.nodes.length; ++i) {
 		var node = data.nodes[i];
 		if (node.in) {
@@ -109,54 +74,53 @@ var parseFlowData = function(data, model, parentPath) {
 				edges.push({to: node.id, from: fromNode.id});
 			}
 		}
-		else {
-			// Queue used for breath first.
-			nodeQueue.push(node);
-		}
 	}
 
-	// Iterate over the nodes again
-	var embeddedFlows = {};
-	var newParentPath = parentPath ? parentPath + ":" + data.flow : data.flow;
-	
+	// Iterate over the nodes again. Parse the data if they're embedded flow data.
+	// Assign each nodes to the parent flow data.
 	for (var key in nodes) {
 		var node = nodes[key];
-		node.parentPath = newParentPath;
+		node.parent = data;
 		if (node.type == "flow" && node.flowData) {
-			var graphModel = new azkaban.GraphModel();
-
-			node.flowData.id = node.id;
-			node.flowData.flowId = node.flowId;
-			parseFlowData(node.flowData, graphModel, newParentPath);
-			graphModel.set({id: node.id, flow: node.flowData, type: node.type, props: node.props});
-			graphModel.set({isEmbedded: true});
-			node.dataModel = graphModel;
+			processFlowData(node.flowData);
+			// Weird cycle. Evaluate whether we can instead unwrap these things.
+			node.flowData.node = node;
 		}
 	}
 	
+	// Assign the node map and the edge list
 	console.log("data fetched");
-	model.set({flow: data.flow});
-	model.set({data: data});
-	model.set({nodes: nodes});
-	model.set({edges: edges});
-	model.set({disabled: {}});
+	data.nodeMap = nodes;
+	data.edges = edges;
+	data.disabled = {};
 }
 
 var closeAllSubDisplays = function() {
 	$(".flowExtendedView").hide();
 }
 
-var nodeClickCallback = function(event, model, type) {
+var nodeClickCallback = function(event, model, node) {
 	console.log("Node clicked callback");
+
 	var target = event.currentTarget;
-	var jobId = target.jobid;
-	var flowId = model.get("flow");
+	var type = node.type;
+	var flowId = node.flowId;
+	var jobId = node.id;
+	
 	var requestURL = contextURL + "/manager?project=" + projectName + "&flow=" + flowId + "&job=" + jobId;
+	var menu = [];
 
-	if (event.currentTarget.jobtype == "flow") {
+	if (type == "flow") {
 		var flowRequestURL = contextURL + "/manager?project=" + projectName + "&flow=" + event.currentTarget.flowId;
-		menu = [
-				{title: "View Flow...", callback: function() {openJobDisplayCallback(jobId, flowId, event)}},
+		if (node.expanded) {
+			menu = [{title: "Collapse Flow...", callback: function() {model.trigger("collapseFlow", node);}}];
+		}
+		else {
+			menu = [{title: "Expand Flow...", callback: function() {model.trigger("expandFlow", node);}}];
+		}
+
+		$.merge(menu, [
+				{title: "View Properties...", callback: function() {openJobDisplayCallback(jobId, flowId, event)}},
 				{break: 1},
 				{title: "Open Flow...", callback: function() {window.location.href=flowRequestURL;}},
 				{title: "Open Flow in New Window...", callback: function() {window.open(flowRequestURL);}},
@@ -164,17 +128,17 @@ var nodeClickCallback = function(event, model, type) {
 				{title: "Open Properties...", callback: function() {window.location.href=requestURL;}},
 				{title: "Open Properties in New Window...", callback: function() {window.open(requestURL);}},
 				{break: 1},
-				{title: "Center Flow", callback: function() {model.trigger("centerNode", jobId)}}
-		];
+				{title: "Center Flow", callback: function() {model.trigger("centerNode", node);}}
+		]);
 	}
 	else {
 		menu = [
-				{title: "View Job...", callback: function() {openJobDisplayCallback(jobId, flowId, event)}},
+				{title: "View Properties...", callback: function() {openJobDisplayCallback(jobId, flowId, event)}},
 				{break: 1},
 				{title: "Open Job...", callback: function() {window.location.href=requestURL;}},
 				{title: "Open Job in New Window...", callback: function() {window.open(requestURL);}},
 				{break: 1},
-				{title: "Center Job", callback: function() {model.trigger("centerNode", jobId)}}
+				{title: "Center Job", callback: function() {model.trigger("centerNode", node)}}
 		];
 	}
 	contextMenuView.show(event, menu);
@@ -183,6 +147,7 @@ var nodeClickCallback = function(event, model, type) {
 var jobClickCallback = function(event, model) {
 	console.log("Node clicked callback");
 	var jobId = event.currentTarget.jobid;
+	var node = target.data;
 	var requestURL = contextURL + "/manager?project=" + projectName + "&flow=" + flowId + "&job=" + jobId;
 
 	var menu;
@@ -197,7 +162,7 @@ var jobClickCallback = function(event, model) {
 				{title: "Open Properties...", callback: function() {window.location.href=requestURL;}},
 				{title: "Open Properties in New Window...", callback: function() {window.open(requestURL);}},
 				{break: 1},
-				{title: "Center Flow", callback: function() {model.trigger("centerNode", jobId)}}
+				{title: "Center Flow", callback: function() {model.trigger("centerNode", node)}}
 		];
 	}
 	else {
@@ -207,7 +172,7 @@ var jobClickCallback = function(event, model) {
 				{title: "Open Job...", callback: function() {window.location.href=requestURL;}},
 				{title: "Open Job in New Window...", callback: function() {window.open(requestURL);}},
 				{break: 1},
-				{title: "Center Job", callback: function() {graphModel.trigger("centerNode", jobId)}}
+				{title: "Center Job", callback: function() {graphModel.trigger("centerNode", node)}}
 		];
 	}
 	contextMenuView.show(event, menu);
@@ -219,6 +184,7 @@ var edgeClickCallback = function(event, model) {
 
 var graphClickCallback = function(event, model) {
 	console.log("Graph clicked callback");
+
 	var jobId = event.currentTarget.jobid;
 	var flowId = model.get("flowId");
 	var requestURL = contextURL + "/manager?project=" + projectName + "&flow=" + flowId;
@@ -232,3 +198,4 @@ var graphClickCallback = function(event, model) {
 	
 	contextMenuView.show(event, menu);
 }
+
diff --git a/src/web/js/azkaban.svg.graph.view.js b/src/web/js/azkaban.svg.graph.view.js
index 1bd78de..eecc5cc 100644
--- a/src/web/js/azkaban.svg.graph.view.js
+++ b/src/web/js/azkaban.svg.graph.view.js
@@ -1,36 +1,11 @@
-function hasClass(el, name) 
-{
-	var classes = el.getAttribute("class");
-	if (classes == null) {
-		return false;
-	}
-   return new RegExp('(\\s|^)'+name+'(\\s|$)').test(classes);
-}
-
-function addClass(el, name)
-{
-   if (!hasClass(el, name)) { 
-   		var classes = el.getAttribute("class");
-   		classes += classes ? ' ' + name : '' +name;
-   		el.setAttribute("class", classes);
-   }
-}
-
-function removeClass(el, name)
-{
-   if (hasClass(el, name)) {
-      var classes = el.getAttribute("class");
-      el.setAttribute("class", classes.replace(new RegExp('(\\s|^)'+name+'(\\s|$)'),' ').replace(/^\s+|\s+$/g, ''));
-   }
-}
-
+$.namespace('azkaban');
 
 azkaban.SvgGraphView = Backbone.View.extend({
 	events: {
-		"click g" : "clickGraph",
-		"contextmenu" : "handleRightClick",
-		"contextmenu g" : "handleRightClick",
-		"contextmenu polyline": "handleRightClick"
+
+	},
+	test: function() {
+		console.log("test");
 	},
 	initialize: function(settings) {
 		this.model.bind('change:selected', this.changeSelected, this);
@@ -40,7 +15,9 @@ azkaban.SvgGraphView = Backbone.View.extend({
 		this.model.bind('change:update', this.handleStatusUpdate, this);
 		this.model.bind('change:disabled', this.handleDisabledChange, this);
 		this.model.bind('change:updateAll', this.handleUpdateAllStatus, this);
-		
+		this.model.bind('expandFlow', this.expandFlow, this);
+		this.model.bind('collapseFlow', this.collapseFlow, this);
+
 		this.graphMargin = settings.graphMargin ? settings.graphMargin : 25;
 		this.svgns = "http://www.w3.org/2000/svg";
 		this.xlinksn = "http://www.w3.org/1999/xlink";
@@ -48,91 +25,75 @@ azkaban.SvgGraphView = Backbone.View.extend({
 		var graphDiv = this.el[0];
 		var svg = $(this.el).find('svg')[0];
 		this.svgGraph = svg;
-		
+		$(this.svgGraph).svg();
+		this.svg = $(svg).svg('get');
+
+		// Create mainG node
 		var gNode = document.createElementNS(this.svgns, 'g');
+		gNode.setAttribute("class", "main graph");
 		svg.appendChild(gNode);
 		this.mainG = gNode;
+		
 		if (settings.rightClick) {
 			this.rightClick = settings.rightClick;
 		}
 
 		$(svg).svgNavigate();
 		
+		var self = this;
+		if (self.rightClick && self.rightClick.graph) {
+			$(svg).on("contextmenu", function(evt) {
+				console.log("graph click");
+				var currentTarget = evt.currentTarget;
+				
+				self.rightClick.graph(evt, self.model, currentTarget.data);
+				return false;
+			});
+		}
+
 		if (settings.render) {
 			this.render();
 		}
 	},
-	initializeDefs: function(self) {
-		var def = document.createElementNS(svgns, 'defs');
-		def.setAttribute("id", "buttonDefs");
-
-		// ArrowHead
-		var arrowHeadMarker = document.createElementNS(svgns, 'marker');
-		arrowHeadMarker.setAttribute("id", "triangle");
-		arrowHeadMarker.setAttribute("viewBox", "0 0 10 10");
-		arrowHeadMarker.setAttribute("refX", "5");
-		arrowHeadMarker.setAttribute("refY", "5");
-		arrowHeadMarker.setAttribute("markerUnits", "strokeWidth");
-		arrowHeadMarker.setAttribute("markerWidth", "4");
-		arrowHeadMarker.setAttribute("markerHeight", "3");
-		arrowHeadMarker.setAttribute("orient", "auto");
-		var path = document.createElementNS(svgns, 'polyline');
-		arrowHeadMarker.appendChild(path);
-		path.setAttribute("points", "0,0 10,5 0,10 1,5");
-
-		def.appendChild(arrowHeadMarker);
-		
-		this.svgGraph.appendChild(def);
+	render: function() {
+		console.log("graph render");
+		
+		this.graphBounds = this.renderGraph(this.model.get("data"), this.mainG);
+		this.resetPanZoom(0);
 	},
-	render: function(self) {
+	renderGraph: function(data, g) {
 		console.log("graph render");
 
-		// Clean everything
-		while (this.mainG.lastChild) {
-			this.mainG.removeChild(this.mainG.lastChild);
-		}
-
-		var data = this.model.get("data");
+		g.data = data;
 		var nodes = data.nodes;
-		var edges = this.model.get("edges");
-		
+		var edges = data.edges;
+		var nodeMap = data.nodeMap;
+
+		// Create a g node for edges, so that they're forced in the back.
+		var edgeG = this.svg.group(g);
 		if (nodes.length == 0) {
 			console.log("No results");
 			return;
 		};
-		
-		var bounds = {};
-		this.nodes = {};
+
+		// Assign labels
 		for (var i = 0; i < nodes.length; ++i) {
-			this.nodes[nodes[i].id] = nodes[i];
 			nodes[i].label = nodes[i].id;
 		}
 		
-		this.gNodes = {};
 		for (var i = 0; i < nodes.length; ++i) {
-			this.drawNode(this, nodes[i]);
+			this.drawNode(this, nodes[i], g);
 		}
-		
+
 		// layout
 		layoutGraph(nodes, edges, 10);
-		this.moveNodes(bounds);
+		var bounds = this.calculateBounds(nodes);
+		this.moveNodes(nodes);
 		
 		for (var i = 0; i < edges.length; ++i) {
-			var inNodes = this.nodes[edges[i].to].inNodes;
-			if (!inNodes) {
-				inNodes = {};
-				this.nodes[edges[i].to].inNodes = inNodes;
-			}
-			inNodes[edges[i].from] = this.nodes[edges[i].from];
-			
-			var outNodes = this.nodes[edges[i].from].outNodes;
-			if (!outNodes) {
-				outNodes = {};
-				this.nodes[edges[i].from].outNodes = outNodes;
-			}
-			outNodes[edges[i].to] = this.nodes[edges[i].to];
-
-			this.drawEdge(this, edges[i]);
+			edges[i].toNode = nodeMap[edges[i].to];
+			edges[i].fromNode = nodeMap[edges[i].from];
+			this.drawEdge(this, edges[i], edgeG);
 		}
 		
 		this.model.set({"flowId":data.flowId, "edges": edges});
@@ -143,16 +104,56 @@ azkaban.SvgGraphView = Backbone.View.extend({
 		bounds.maxX = bounds.maxX ? bounds.maxX + margin : margin;
 		bounds.maxY = bounds.maxY ? bounds.maxY + margin : margin;
 		
-		this.assignInitialStatus(self);
+		this.assignInitialStatus(this, data);
 		
-		if (this.model.get("disabled")) {
+		if (data.disabled && data.disabled.length > 0) {
 			this.handleDisabledChange(self);
 		}
-		else {
-			this.model.set({"disabled":[]})
+
+/*
+		if (this.rightClick) {
+			var callbacks = this.rightClick;
+			var currentTarget = self.currentTarget;
+			if (callbacks.node && currentTarget.jobid) {
+				callbacks.node(self, this.model, currentTarget.nodeobj);
+			}
+			else if (callbacks.edge && (currentTarget.nodeName == "polyline" || currentTarget.nodeName == "line")) {
+				callbacks.edge(self, this.model);
+			}
+			else if (callbacks.graph) {
+				callbacks.graph(self, this.model);
+			}
+			return false;
 		}
-		this.graphBounds = bounds;
-		this.resetPanZoom(0);
+	
+*/
+
+		var self = this;
+		if (self.rightClick) {
+			if (self.rightClick.node) {
+				// Proper children selectors don't work properly on svg
+				for (var i = 0; i < nodes.length; ++i) {
+					$(nodes[i].gNode).on("contextmenu", function(evt) {
+						console.log("node click");
+						var currentTarget = evt.currentTarget;
+						self.rightClick.node(evt, self.model, currentTarget.data);
+						return false;
+					});
+				}
+			}
+			if (this.rightClick.graph) {
+				$(g).on("contextmenu", function(evt) {
+					console.log("graph click");
+					var currentTarget = evt.currentTarget;
+				
+					self.rightClick.graph(evt, self.model, currentTarget.data);
+					return false;
+				});
+			}
+
+		};
+
+		return bounds;
 	},
 	handleDisabledChange: function(evt) {
 		var disabledMap = this.model.get("disabled");
@@ -169,17 +170,14 @@ azkaban.SvgGraphView = Backbone.View.extend({
 			}
 		}
 	},
-	assignInitialStatus: function(evt) {
-		var data = this.model.get("data");
+	assignInitialStatus: function(evt, data) {
 		for (var i = 0; i < data.nodes.length; ++i) {
 			var updateNode = data.nodes[i];
-			var g = this.gNodes[updateNode.id];
-			if (updateNode.status) {
-				addClass(g, updateNode.status);
-			}
-			else {
-				addClass(g, "READY");
-			}
+			var g = updateNode.gNode;
+			var initialStatus = updateNode.status ? updateNode.status : "READY";
+
+			addClass(g, initialStatus);
+			$(g).attr("title", initialStatus);
 		}
 	},
 	changeSelected: function(self) {
@@ -220,6 +218,7 @@ azkaban.SvgGraphView = Backbone.View.extend({
 				this.handleRemoveAllStatus(g);
 				
 				addClass(g, updateNode.status);
+				$(g).attr("title", updateNode.status);
 			}
 		}
 	},
@@ -240,7 +239,7 @@ azkaban.SvgGraphView = Backbone.View.extend({
 			var callbacks = this.rightClick;
 			var currentTarget = self.currentTarget;
 			if (callbacks.node && currentTarget.jobid) {
-				callbacks.node(self, this.model);
+				callbacks.node(self, this.model, currentTarget.nodeobj);
 			}
 			else if (callbacks.edge && (currentTarget.nodeName == "polyline" || currentTarget.nodeName == "line")) {
 				callbacks.edge(self, this.model);
@@ -253,367 +252,322 @@ azkaban.SvgGraphView = Backbone.View.extend({
 	
 		return true;
 	},	
-	drawEdge: function(self, edge) {
-		var svg = self.svgGraph;
+	drawEdge: function(self, edge, g) {
+		var svg = this.svg;
 		var svgns = self.svgns;
 		
-		var startNode = this.nodes[edge.from];
-		var endNode = this.nodes[edge.to];
+		var startNode = edge.fromNode;
+		var endNode = edge.toNode;
 		
-		var startPointY = startNode.y + startNode.height/2 - 3;
-		var endPointY = endNode.y - endNode.height/2 + 3;
-		if (edge.guides) {
-			var pointString = "" + startNode.x + "," + startPointY + " ";
+		var startPointY = startNode.y + startNode.height/2;
+		var endPointY = endNode.y - endNode.height/2;
 
+		if (edge.guides) {
+			// Create guide array
+			var pointArray = new Array();
+			pointArray.push([startNode.x, startPointY]);
 			for (var i = 0; i < edge.guides.length; ++i ) {
-				edgeGuidePoint = edge.guides[i];
-				pointString += edgeGuidePoint.x + "," + edgeGuidePoint.y + " ";
+				var edgeGuidePoint = edge.guides[i];
+				pointArray.push([edgeGuidePoint.x, edgeGuidePoint.y]);
 			}
-			
-			pointString += endNode.x + "," + endPointY;
-			var polyLine = document.createElementNS(svgns, "polyline");
-			polyLine.setAttribute("class", "edge");
-			polyLine.setAttribute("points", pointString);
-			polyLine.setAttribute("style", "fill:none;stroke-width:3");
-			$(self.mainG).prepend(polyLine);
+			pointArray.push([endNode.x, endPointY]);
+		
+			edge.line = svg.polyline(g, pointArray, {class:"edge", fill:"none"});
+			edge.line.data = edge;
+			edge.oldpoints = pointArray;
 		}
 		else { 
-			var line = document.createElementNS(svgns, 'line');
-			line.setAttribute("class", "edge");
-			line.setAttribute("x1", startNode.x);
-			line.setAttribute("y1", startPointY);
-			line.setAttribute("x2", endNode.x);
-			line.setAttribute("y2", endPointY);
-			line.setAttribute("style", "stroke-width:3");
-			$(self.mainG).prepend(line);
+			edge.line = svg.line(g, startNode.x, startPointY, endNode.x, endPointY, {class:"edge"});
+			edge.line.data = edge;
 		}
 	},
-	drawNode: function(self, node) {
+	drawNode: function(self, node, g) {
 		if (node.type == 'flow') {
-			this.drawFlowNode(self, node);
+			this.drawFlowNode(self, node, g);
 		}
 		else {
-			this.drawBoxNode(self,node);
-			//this.drawCircleNode2(self,node);
+			this.drawBoxNode(self, node, g);
 		}
+// 		
+// 		var boundingBox = node.gNode.getBBox();
+// 		node.width = boundingBox.width;
+// 		node.height = boundingBox.height;
+// 		node.centerX = node.width/2;
+// 		node.centerY = node.height/2;
 	},
-	moveNodes: function(bounds) {
-		var nodes = this.nodes;
-		var gNodes = this.gNodes;
-		
-		for (var key in nodes) {
-			var node = nodes[key];
-			var gNode = gNodes[node.id];
-			var centerX = node.centerX;
-			var centerY = node.centerY;
+	moveNodes: function(nodes) {
+		var svg = this.svg;
+		for (var i = 0; i < nodes.length; ++i) {
+			var node = nodes[i];
+			var gNode = node.gNode;
 			
-			gNode.setAttribute("transform", "translate(" + node.x + "," + node.y + ")");
-			this.addBounds(bounds, {minX:node.x - centerX, minY: node.y - centerY, maxX: node.x + centerX, maxY: node.y + centerY});
+			svg.change(gNode, {"transform": translateStr(node.x, node.y)});
 		}
 	},
-	drawFlowNode: function(self, node) {
-		var svg = self.svgGraph;
-		var svgns = self.svgns;
+	expandFlow: function(node) {
+		var svg = this.svg;
+		var gnode = node.gNode;
+		node.expanded = true;
 
-		var height = 26;
-		var nodeG = document.createElementNS(svgns, "g");
-		nodeG.setAttribute("class", "jobnode");
-		nodeG.setAttribute("font-family", "helvetica");
-		nodeG.setAttribute("transform", "translate(" + node.x + "," + node.y + ")");
-		this.gNodes[node.id] = nodeG;
-		
-		var innerG = document.createElementNS(svgns, "g");
-		innerG.setAttribute("class", "nodebox");
-		
-		var rect = document.createElementNS(svgns, 'rect');
-		rect.setAttribute("rx", 3);
-		rect.setAttribute("ry", 5);
-		rect.setAttribute("style", "width:inherit;stroke-opacity:1");
-		//rect.setAttribute("class", "nodecontainer");
-		
-		var text = document.createElementNS(svgns, 'text');
-		var textLabel = document.createTextNode(node.label);
-		text.appendChild(textLabel);
-		text.setAttribute("y", 1);
-		text.setAttribute("height", 10);
-
-		var flowIdText = document.createElementNS(svgns, 'text');
-		var flowIdLabel = document.createTextNode(node.flowId);
-		flowIdText.appendChild(flowIdLabel);
-		flowIdText.setAttribute("y", 11);
-		flowIdText.setAttribute("font-size", 8);
+		var innerG = gnode.innerG;
+		var borderRect = innerG.borderRect;
+		var labelG = innerG.labelG;
+		var flowData = node.flowData;
+		
+		var bbox;
+		if (!innerG.expandedFlow) {
+			var topmargin= 30, bottommargin=5;
+			var hmargin = 10;
+		
+			var expandedFlow = svg.group(innerG, "", {class: "expandedGraph"});
+			this.renderGraph(flowData, expandedFlow);
+			innerG.expandedFlow = expandedFlow;
+			removeClass(innerG, "collapsed");
+			addClass(innerG, "expanded");
+			node.expandedWidth = node.width;
+			node.expandedHeight = node.height;
+		}
+		else {
+			$(innerG.expandedFlow).show();
+			removeClass(innerG, "collapsed");
+			addClass(innerG, "expanded");
+			node.width = node.expandedWidth;
+			node.height = node.expandedHeight;		
+		}
 		
-		var iconHeight = 20;
-		var iconWidth = 21;
-		var iconMargin = 4;
-		var iconNode = document.createElementNS(svgns, 'image');
-		iconNode.setAttribute("width", iconHeight);
-		iconNode.setAttribute("height", iconWidth);
-		iconNode.setAttribute("x", 0);
-		iconNode.setAttribute("y", -10);
-		iconNode.setAttributeNS('http://www.w3.org/1999/xlink', "xlink:href", contextURL + "/images/graph-icon.png");
-		
-		innerG.appendChild(rect);
-		innerG.appendChild(text);
-		innerG.appendChild(flowIdText);
-		innerG.appendChild(iconNode);
-		innerG.jobid = node.id;
-		innerG.jobtype = "flow";
-		innerG.flowId = node.flowId;
-		innerG.nodeobj = node;
-		
-		nodeG.appendChild(innerG);
-		self.mainG.appendChild(nodeG);
-
-		var horizontalMargin = 8;
-		var verticalMargin = 2;
+		this.relayoutFlow(node);
 		
-		// Need to get text width after attaching to SVG.
-		var subLabelTextLength = flowIdText.getComputedTextLength();
-		
-		var computeTextLength = text.getComputedTextLength();
-		var computeTextHeight = 22;
-		
-		var width = computeTextLength > subLabelTextLength ? computeTextLength : subLabelTextLength;
-		width += iconWidth + iconMargin;
-		var halfWidth = width/2;
-		var halfHeight = height/2;
-		
-		// Margin for surrounding box.
-		var boxWidth = width + horizontalMargin * 2;
-		var boxHeight = height + verticalMargin * 2;
-		
-		node.width = boxWidth;
-		node.height = boxHeight;
-		node.centerX = boxWidth/2;
-		node.centerY = boxHeight/2;
-		
-		var textXOffset = -halfWidth + iconWidth + iconMargin;
-		iconNode.setAttribute("x", -halfWidth);
-		text.setAttribute("x", textXOffset);
-		flowIdText.setAttribute("x",textXOffset);
-		rect.setAttribute("x", -node.centerX);
-		rect.setAttribute("y", -node.centerY);
-		rect.setAttribute("width", node.width);
-		rect.setAttribute("height", node.height);
-		
-		nodeG.setAttribute("class", "node");
-		nodeG.jobid=node.id;
-		nodeG.jobobj=node;
+		var bounds = this.calculateBounds(this.model.get("data").nodes);
+
+		var margin = this.graphMargin;
+		bounds.minX = bounds.minX ? bounds.minX - margin : -margin;
+		bounds.minY = bounds.minY ? bounds.minY - margin : -margin;
+		bounds.maxX = bounds.maxX ? bounds.maxX + margin : margin;
+		bounds.maxY = bounds.maxY ? bounds.maxY + margin : margin;
+		this.graphBounds = bounds;
 	},
-	drawBoxNode: function(self, node) {
-		var svg = self.svgGraph;
-		var svgns = self.svgns;
+	collapseFlow: function(node) {
+		console.log("Collapsing flow");
+		var svg = this.svg;
+		var gnode = node.gNode;
+		node.expanded = false;
+	
+		var innerG = gnode.innerG;
+		var borderRect = innerG.borderRect;
+		var labelG = innerG.labelG;
+		var flowData = node.flowData;
 
-		var height = 18;
-		var nodeG = document.createElementNS(svgns, "g");
-		nodeG.setAttribute("class", "jobnode");
-		nodeG.setAttribute("font-family", "helvetica");
-		nodeG.setAttribute("transform", "translate(" + node.x + "," + node.y + ")");
-		this.gNodes[node.id] = nodeG;
-		
-		var innerG = document.createElementNS(svgns, "g");
-		innerG.setAttribute("class", "nodebox");
-		
-		var rect = document.createElementNS(svgns, 'rect');
-		rect.setAttribute("rx", 3);
-		rect.setAttribute("ry", 5);
-		rect.setAttribute("style", "width:inherit;stroke-opacity:1");
-		//rect.setAttribute("class", "nodecontainer");
-		
-		var text = document.createElementNS(svgns, 'text');
-		var textLabel = document.createTextNode(node.label);
-		text.appendChild(textLabel);
-		text.setAttribute("y", 6);
-		text.setAttribute("height", 10); 
+		removeClass(innerG, "expanded");
+		addClass(innerG, "collapsed");
 
-		//this.addBounds(bounds, {minX:node.x - xOffset, minY: node.y - yOffset, maxX: node.x + xOffset, maxY: node.y + yOffset});
+		node.height = node.collapsedHeight;
+		node.width = node.collapsedWidth;
 
-		innerG.appendChild(rect);
-		innerG.appendChild(text);
-		innerG.jobid = node.id;
-		innerG.nodeobj = node;
-		
-		nodeG.appendChild(innerG);
-		self.mainG.appendChild(nodeG);
+		$(innerG.expandedFlow).hide();
+		this.relayoutFlow(node);
+
+		var bounds = this.calculateBounds(this.model.get("data").nodes);
+
+		var margin = this.graphMargin;
+		bounds.minX = bounds.minX ? bounds.minX - margin : -margin;
+		bounds.minY = bounds.minY ? bounds.minY - margin : -margin;
+		bounds.maxX = bounds.maxX ? bounds.maxX + margin : margin;
+		bounds.maxY = bounds.maxY ? bounds.maxY + margin : margin;
+		this.graphBounds = bounds;
 
-		var horizontalMargin = 8;
-		var verticalMargin = 2;
-		
-		// Need to get text width after attaching to SVG.
-		var computeText = text.getComputedTextLength();
-		var computeTextHeight = 22;
-		var halfWidth = computeText/2;
-		var halfHeight = height/2;
-		
-		// Margin for surrounding box.
-		var boxWidth = computeText + horizontalMargin * 2;
-		var boxHeight = height + verticalMargin * 2;
-		
-		node.width = boxWidth;
-		node.height = boxHeight;
-		node.centerX = boxWidth/2;
-		node.centerY = boxHeight/2;
-		
-		text.setAttribute("x", -halfWidth);
-		rect.setAttribute("x", -node.centerX);
-		rect.setAttribute("y", -node.centerY);
-		rect.setAttribute("width", node.width);
-		rect.setAttribute("height", node.height);
-		
-		nodeG.setAttribute("class", "node");
-		nodeG.jobid=node.id;
-		nodeG.j=node;
 	},
-	drawCircleNode: function(self, node, bounds) {
-		var svg = self.svgGraph;
-		var svgns = self.svgns;
+	relayoutFlow: function(node) {
+		if (node.expanded) {
+			this.layoutExpandedFlowNode(node);
+		}
 
-		var xOffset = 10;
-		var yOffset = 10;
-		
-		var height = 18;
-		var nodeG = document.createElementNS(svgns, "g");
-		nodeG.setAttribute("class", "jobnode");
-		nodeG.setAttribute("font-family", "helvetica");
-		nodeG.setAttribute("transform", "translate(" + node.x + "," + node.y + ")");
-		this.gNodes[node.id] = nodeG;
-		
-		var innerG = document.createElementNS(svgns, "g");
-		innerG.setAttribute("transform", "translate(-10,-10)");
+		var parent = node.parent;
+		if (parent) {
+			layoutGraph(parent.nodes, parent.edges, 10);
+			if (parent.node) {
+				this.relayoutFlow(parent.node);
+			}
+		}
 		
-		var circle = document.createElementNS(svgns, 'circle');
-		circle.setAttribute("cy", 10);
-		circle.setAttribute("cx", 10);
-		circle.setAttribute("r", 12);
-		circle.setAttribute("style", "width:inherit;stroke-opacity:1");
+		// Move all points again.
+		this.moveNodeEdges(parent.nodes, parent.edges);
+		this.animateExpandedFlowNode(node, 250);
 		
-		//circle.setAttribute("class", "border");
-		//circle.setAttribute("class", "nodecontainer");
+	},
+	moveNodeEdges: function(nodes, edges) {
+		var svg = this.svg;
 		
-		var text = document.createElementNS(svgns, 'text');
-		var textLabel = document.createTextNode(node.label);
-		text.appendChild(textLabel);
-		text.setAttribute("x", 0);
-		text.setAttribute("y", 0);
+		for (var i = 0; i < nodes.length; ++i) {
+			var node = nodes[i];
+			var gNode = node.gNode;
+			
+			$(gNode).animate({"svgTransform": translateStr(node.x, node.y)}, 250);
+		}
+			
+		for (var j = 0; j < edges.length; ++j) {
+			var edge = edges[j];
+			var startNode = edge.fromNode;
+			var endNode = edge.toNode;
+			var line = edge.line;
+				
+			var startPointY = startNode.y + startNode.height/2;
+			var endPointY = endNode.y - endNode.height/2;
+			
+			if (edge.guides) {
+				// Create guide array
+				var pointArray = new Array();
+				pointArray.push([startNode.x, startPointY]);
+				for (var i = 0; i < edge.guides.length; ++i ) {
+					var edgeGuidePoint = edge.guides[i];
+					pointArray.push([edgeGuidePoint.x, edgeGuidePoint.y]);
+				}
+				pointArray.push([endNode.x, endPointY]);
+				
+				animatePolylineEdge(svg, edge, pointArray, 250);
+				edge.oldpoints = pointArray;
+			}
+			else {
+				$(line).animate({svgX1: startNode.x, svgY1: startPointY, svgX2: endNode.x, svgY2: endPointY});
+			}
+		}
 
-		//this.addBounds(bounds, {minX:node.x - xOffset, minY: node.y - yOffset, maxX: node.x + xOffset, maxY: node.y + yOffset});
-		
-		var backRect = document.createElementNS(svgns, 'rect');
-		backRect.setAttribute("x", 0);
-		backRect.setAttribute("y", 2);
-		backRect.setAttribute("class", "backboard");
-		backRect.setAttribute("width", 10);
-		backRect.setAttribute("height", 15);
-		
-		innerG.appendChild(circle);
-		innerG.appendChild(backRect);
-		innerG.appendChild(text);
-		innerG.jobid = node.id;
+	},
+	calculateBounds: function(nodes) {
+		var bounds = {};
+		var node = nodes[0];
+		bounds.minX = node.x - 10;
+		bounds.minY = node.y - 10;
+		bounds.maxX = node.x + 10;
+		bounds.maxY = node.y + 10;
 
-		nodeG.appendChild(innerG);
-		self.mainG.appendChild(nodeG);
+		for (var i = 0; i < nodes.length; ++i) {
+			node = nodes[i];
+			var centerX = node.width/2;
+			var centerY = node.height/2;
 
+			var minX = node.x - centerX;
+			var minY = node.y - centerY;
+			var maxX = node.x + centerX;
+			var maxY = node.y + centerY; 
+			
+			bounds.minX = Math.min(bounds.minX, minX);
+			bounds.minY = Math.min(bounds.minY, minY);
+			bounds.maxX = Math.max(bounds.maxX, maxX);
+			bounds.maxY = Math.max(bounds.maxY, maxY);
+		}
+		bounds.width = bounds.maxX - bounds.minX;
+		bounds.height = bounds.maxY - bounds.minY;
 		
+		return bounds;
+	},
+	drawBoxNode: function(self, node, g) {
+		var svg = this.svg;
 		var horizontalMargin = 8;
 		var verticalMargin = 2;
-		// Need to get text width after attaching to SVG.
-		var computeText = text.getComputedTextLength();
-		var halfWidth = computeText/2;
-		text.setAttribute("x", -halfWidth + 10);
-		backRect.setAttribute("x", -halfWidth);
-		backRect.setAttribute("width", computeText + 20);
-
-		// Margin for surrounding box.
-		var boxWidth = computeText + horizontalMargin * 2;
-		var boxHeight = height + verticalMargin * 2;
-		
-		node.width = boxWidth;
-		node.height = boxHeight;
-		node.centerX = boxWidth/2;
-		node.centerY = boxHeight/2;
-		
-		nodeG.setAttribute("class", "node");
-		nodeG.nodeobj=node;
-		nodeG.jobid=node.id;
-	},
-	drawCircleNode2: function(self, node, bounds) {
-		var svg = self.svgGraph;
-		var svgns = self.svgns;
-
-		var xOffset = 10;
-		var yOffset = 10;
-		
-		var height = 18;
-		var nodeG = document.createElementNS(svgns, "g");
-		nodeG.setAttribute("class", "jobnode");
-		nodeG.setAttribute("font-family", "helvetica");
-		nodeG.setAttribute("transform", "translate(" + node.x + "," + node.y + ")");
-		this.gNodes[node.id] = nodeG;
 		
-		var innerG = document.createElementNS(svgns, "g");
-		innerG.setAttribute("transform", "translate(-10,-10)");
+		var nodeG = svg.group(g, "", {class:"node jobnode"});
 		
-		var circle = document.createElementNS(svgns, 'circle');
-		circle.setAttribute("cy", 10);
-		circle.setAttribute("cx", 10);
-		circle.setAttribute("r", 12);
-		circle.setAttribute("style", "width:inherit;stroke-opacity:1;stroke:rgb(100,100,100);stroke-width:5");
-		//circle.setAttribute("class", "border");
-		//circle.setAttribute("class", "nodecontainer");
-		
-		var text = document.createElementNS(svgns, 'text');
-		var textLabel = document.createTextNode(node.label);
-		text.appendChild(textLabel);
-		text.setAttribute("x", 0);
-		text.setAttribute("y", 0);
+		var innerG = svg.group(nodeG, "", {class:"nodebox"});
+		var borderRect = svg.rect(innerG, 0, 0, 10, 10, 3, 3, {class: "border"});
+		var jobNameText = svg.text(innerG, horizontalMargin, 16, node.label);
+		nodeG.innerG = innerG;
+		innerG.borderRect = borderRect;		
 
-		//this.addBounds(bounds, {minX:node.x - xOffset, minY: node.y - yOffset, maxX: node.x + xOffset, maxY: node.y + yOffset});
+		var labelBBox = jobNameText.getBBox();
+		var totalWidth = labelBBox.width + 2*horizontalMargin;
+		var totalHeight = labelBBox.height + 2*verticalMargin;
+		svg.change(borderRect, {width: totalWidth, height: totalHeight});
+		svg.change(jobNameText, {y: (totalHeight + labelBBox.height)/2 - 3});
+		svg.change(innerG, {transform: translateStr(-totalWidth/2, -totalHeight/2)});
+		
+		node.width=totalWidth;
+		node.height=totalHeight;
+		
+		node.gNode = nodeG;
+		nodeG.data = node;
+	},
+	drawFlowNode: function(self, node, g) {
+		var svg = this.svg;
 		
-		var backRect = document.createElementNS(svgns, 'rect');
-		backRect.setAttribute("x", 0);
-		backRect.setAttribute("y", 2);
-		backRect.setAttribute("class", "backboard");
-		backRect.setAttribute("width", 10);
-		backRect.setAttribute("height", 15);
+		// Base flow node
+		var nodeG = svg.group(g, "", {"class": "node flownode"});
 		
-		innerG.appendChild(circle);
-		innerG.appendChild(backRect);
-//		innerG.appendChild(text);
-		innerG.jobid = node.id;
+		// Create all the elements
+		var innerG = svg.group(nodeG, "", {class: "nodebox collapsed"});
+		var borderRect = svg.rect(innerG, 0, 0, 10, 10, 3, 3, {class: "flowborder"});
+		
+		// Create label
+		var labelG = svg.group(innerG);
+		var iconHeight = 20;
+		var iconWidth = 21;
+		var textOffset = iconWidth + 4;
+		var jobNameText = svg.text(labelG, textOffset, 1, node.label);
+		var flowIdText = svg.text(labelG, textOffset, 11, node.flowId, {"font-size": 8})
+		var tempLabelG = labelG.getBBox();
+		var iconImage = svg.image(labelG, 0, -iconHeight/2, iconWidth, iconHeight, contextURL + "/images/graph-icon.png", {}); 
 
-		nodeG.appendChild(innerG);
-		self.mainG.appendChild(nodeG);
+		// Assign key values to make searching quicker
+		node.gNode=nodeG;
+		nodeG.data=node;
 
-		
+		// Do this because jquery svg selectors don't work
+		nodeG.innerG = innerG;
+		innerG.borderRect = borderRect;
+		innerG.labelG = labelG;
+
+		// Layout everything in the node
+		this.layoutFlowNode(self, node);
+	},
+	layoutFlowNode: function(self, node) {
+		var svg = this.svg;
 		var horizontalMargin = 8;
 		var verticalMargin = 2;
-		// Need to get text width after attaching to SVG.
-		var computeText = 150;
-		var halfWidth = computeText/2;
-		text.setAttribute("x", -halfWidth + 10);
-		backRect.setAttribute("x", -halfWidth);
-		backRect.setAttribute("width", computeText + 20);
-
-		// Margin for surrounding box.
-		var boxWidth = computeText + horizontalMargin * 2;
-		var boxHeight = height + verticalMargin * 2;
-		
-//		innerG.removeChild(text);
-		
-		node.width = boxWidth;
-		node.height = boxHeight;
-		node.centerX = boxWidth/2;
-		node.centerY = boxHeight/2;
-		
-		nodeG.setAttribute("class", "node");
-		nodeG.jobid=node.id;
+
+		var gNode = node.gNode;
+		var innerG = gNode.innerG;
+		var borderRect = innerG.borderRect;
+		var labelG = innerG.labelG;
+
+		var labelBBox = labelG.getBBox();
+		var totalWidth = labelBBox.width + 2*horizontalMargin;
+		var totalHeight = labelBBox.height + 2*verticalMargin;
+
+		svg.change(labelG, {transform: translateStr(horizontalMargin, labelBBox.height/2 + verticalMargin)});
+		svg.change(innerG, {transform: translateStr(-totalWidth/2, -totalHeight/2)});
+		svg.change(borderRect, {width: totalWidth, height: totalHeight});
+
+		node.height = totalHeight;
+		node.width = totalWidth;
+		node.collapsedHeight = totalHeight;
+		node.collapsedWidth = totalWidth;
+	},
+	layoutExpandedFlowNode: function(node) {
+		var svg = this.svg;
+		var topmargin= 30, bottommargin=5;
+		var hmargin = 10;
+		
+		var gNode = node.gNode;
+		var innerG = gNode.innerG;
+		var borderRect = innerG.borderRect;
+		var labelG = innerG.labelG;
+		var expandedFlow = innerG.expandedFlow;
+		
+		var bound = this.calculateBounds(node.flowData.nodes);
+		
+		node.height = bound.height + topmargin + bottommargin;
+		node.width = bound.width + hmargin*2;
+		svg.change(expandedFlow, {transform: translateStr(-bound.minX + hmargin, -bound.minY + topmargin)});
+		//$(innerG).animate({svgTransform: translateStr(-node.width/2, -node.height/2)}, 50);
+		//$(borderRect).animate({svgWidth: node.width, svgHeight: node.height}, 50);
 	},
-	addBounds: function(toBounds, addBounds) {
-		toBounds.minX = toBounds.minX ? Math.min(toBounds.minX, addBounds.minX) : addBounds.minX;
-		toBounds.minY = toBounds.minY ? Math.min(toBounds.minY, addBounds.minY) : addBounds.minY;
-		toBounds.maxX = toBounds.maxX ? Math.max(toBounds.maxX, addBounds.maxX) : addBounds.maxX;
-		toBounds.maxY = toBounds.maxY ? Math.max(toBounds.maxY, addBounds.maxY) : addBounds.maxY;
+	animateExpandedFlowNode: function(node, time) {
+		var gNode = node.gNode;
+		var innerG = gNode.innerG;
+		var borderRect = innerG.borderRect;
+
+		$(innerG).animate({svgTransform: translateStr(-node.width/2, -node.height/2)}, time);
+		$(borderRect).animate({svgWidth: node.width, svgHeight: node.height}, time);
+		$(borderRect).animate({svgFill: 'white'}, time);
 	},
 	resetPanZoom : function(duration) {
 		var bounds = this.graphBounds;
@@ -621,18 +575,32 @@ azkaban.SvgGraphView = Backbone.View.extend({
 
 		this.panZoom(param);
 	},
-	centerNode: function(jobId) {
-		var node = this.nodes[jobId];
-		
-		var offset = 150;
-		var widthHeight = offset*2;
-		var x = node.x - offset;
-		var y = node.y - offset;
+	centerNode: function(node) {
+		// The magic of affine transformation. 
+		// Multiply the inverse root matrix with the current matrix to get the node position.
+		// Rather do this than to traverse backwards through the scene graph.
+		var ctm = node.gNode.getCTM();
+		var transform = node.gNode.getTransformToElement();
+		var globalCTM = this.mainG.getCTM().inverse();
+		var otherTransform = globalCTM.multiply(ctm);
+		// Also a beauty of affine transformation. The translate is always the left most column of the matrix.
+		var x = otherTransform.e - node.width/2;
+		var y = otherTransform.f - node.height/2;
+
+		this.panZoom({x: x, y: y, width: node.width, height: node.height});
+	},
+	globalNodePosition: function(gNode) {
+		if (node.parent.node) {
 		
-		this.panZoom({x: x, y: y, width: widthHeight, height: widthHeight});
+			var parentPos = this.globalNodePosition(node.parent.node);
+			return {x: parentPos.x + node.x, y: parentPos.y + node.y};
+		}
+		else {
+			return {x: node.x, y: node.y};		
+		}
 	},
 	panZoom: function(params) {
 		params.maxScale = 2;
 		$(this.svgGraph).svgNavigate("transformToBox", params);
 	}
-});
\ No newline at end of file
+});
diff --git a/src/web/js/svgutils.js b/src/web/js/svgutils.js
new file mode 100644
index 0000000..33dea78
--- /dev/null
+++ b/src/web/js/svgutils.js
@@ -0,0 +1,70 @@
+function hasClass(el, name) 
+{
+	var classes = el.getAttribute("class");
+	if (classes == null) {
+		return false;
+	}
+   return new RegExp('(\\s|^)'+name+'(\\s|$)').test(classes);
+}
+
+function addClass(el, name)
+{
+   if (!hasClass(el, name)) { 
+   		var classes = el.getAttribute("class");
+   		if (classes) {
+   			classes += ' ' + name;
+   		}
+   		else {
+   			classes = name;
+   		}
+   		el.setAttribute("class", classes);
+   }
+}
+
+function removeClass(el, name)
+{
+   if (hasClass(el, name)) {
+      var classes = el.getAttribute("class");
+      el.setAttribute("class", classes.replace(new RegExp('(\\s|^)'+name+'(\\s|$)'),' ').replace(/^\s+|\s+$/g, ''));
+   }
+}
+
+function translateStr(x, y) {
+	return "translate(" + x + "," + y + ")"; 
+}
+
+function animatePolylineEdge(svg, edge, newPoints, time) {
+	var oldEdgeGuides = edge.oldpoints;
+	
+	var interval = 10;
+	var numsteps = time/interval;
+	
+	var deltaEdges = new Array();
+ 	for (var i=0; i < oldEdgeGuides.length; ++i) {
+		var startPoint = oldEdgeGuides[i];
+		var endPoint = newPoints[i];
+		
+		var deltaX = (endPoint[0] - startPoint[0])/numsteps;
+		var deltaY = (endPoint[1] - startPoint[1])/numsteps;
+		deltaEdges.push([deltaX, deltaY]);
+	}
+	
+	animatePolyLineLoop(svg, edge, oldEdgeGuides, deltaEdges, numsteps, 25);
+}
+
+function animatePolyLineLoop(svg, edge, lastPoints, deltaEdges, step, time) {
+	for (var i=0; i < deltaEdges.length; ++i) {
+		lastPoints[i][0] += deltaEdges[i][0];
+		lastPoints[i][1] += deltaEdges[i][1];
+	}
+	
+	svg.change(edge.line, {points: lastPoints});
+	if (step > 0) {
+		setTimeout(
+			function(){
+				animatePolyLineLoop(svg, edge, lastPoints, deltaEdges, step - 1);
+			}, 
+			time
+		);
+	}
+}
\ No newline at end of file