← Back to team overview

yellow team mailing list archive

[Merge] lp:~hazmat/juju-gui/component-modules into lp:juju-gui

 

Kapil Thangavelu has proposed merging lp:~hazmat/juju-gui/component-modules into lp:juju-gui.

Requested reviews:
  Juju GUI Hackers (juju-gui)

For more details, see:
https://code.launchpad.net/~hazmat/juju-gui/component-modules/+merge/135501

Ben's branch for env refactoring

Ben's branch for env refactoring... testing

https://codereview.appspot.com/6842084/

-- 
https://code.launchpad.net/~hazmat/juju-gui/component-modules/+merge/135501
Your team Juju GUI Hackers is requested to review the proposed merge of lp:~hazmat/juju-gui/component-modules into lp:juju-gui.
=== modified file 'Makefile'
--- Makefile	2012-11-20 15:10:30 +0000
+++ Makefile	2012-11-21 19:26:21 +0000
@@ -97,7 +97,7 @@
 yuidoc-lint: $(JSFILES)
 	bin/lint-yuidoc
 
-lint: gjslint jshint yuidoc-lint
+lint: gjslint jshint
 
 virtualenv/bin/gjslint virtualenv/bin/fixjsstyle:
 	virtualenv virtualenv

=== modified file 'app/assets/javascripts/d3-components.js'
--- app/assets/javascripts/d3-components.js	2012-11-09 13:39:45 +0000
+++ app/assets/javascripts/d3-components.js	2012-11-21 19:26:21 +0000
@@ -16,23 +16,33 @@
      * @property events
      * @type {object}
      **/
-    events: {
+    _defaultEvents: {
       scene: {},
       d3: {},
       yui: {}
     },
 
+    events: {},
+
     initializer: function() {
-      this.events = Y.merge(this.events);
-    }
+      this.events = Y.mix(this.events, this._defaultEvents,
+                          false, undefined, 0, true);
+    },
+
+    componentBound: function() {},
+    render: function() {},
+    update: function() {}
   }, {
     ATTRS: {
       component: {},
       options: {},
-      container: {getter: function() {
-        return this.get('component').get('container');}}
-    }
-  });
+      container: {
+        getter: function() {
+          var component = this.get('component');
+          return component && component.get('container') || undefined;
+        }
+      }
+    }});
   ns.Module = Module;
 
 
@@ -56,6 +66,8 @@
     initializer: function() {
       this.modules = {};
       this.events = {};
+      // Used to track the renderOnce invocation.
+      this._rendered = false;
     },
 
     /**
@@ -82,6 +94,10 @@
           module = ModClassOrInstance,
           modEvents;
 
+      if (ModClassOrInstance === undefined) {
+        throw 'undefined Module in addModule call';
+      }
+
       if (!(ModClassOrInstance instanceof Module)) {
         module = new ModClassOrInstance();
       }
@@ -94,6 +110,7 @@
       modEvents = module.events;
       this.events[module.name] = modEvents;
       this.bind(module.name);
+      module.componentBound();
       return this;
     },
 
@@ -169,6 +186,14 @@
                         selector, handler, modName, result);
           return;
         }
+
+        // Set up binding context for callback.
+        result.context = module;
+        if (handler.context) {
+          if (handler.context === 'component') {
+            result.context = self;
+          }
+        }
         return result;
       }
 
@@ -193,15 +218,19 @@
         Y.each(['after', 'before', 'on'], function(eventPhase) {
           var resolvedHandler = {};
           Y.each(modEvents.yui, function(handler, name) {
-            handler = _normalizeHandler(handler, module);
+            handler = _normalizeHandler(handler, module, name);
             if (!handler || handler.phase !== eventPhase) {
               return;
             }
-            resolvedHandler[name] = handler.callback;
+            resolvedHandler[name] = handler;
           }, this);
           // Bind resolved event handlers as a group.
           if (Y.Object.keys(resolvedHandler).length) {
-            subscriptions.push(Y[eventPhase](resolvedHandler));
+            Y.each(resolvedHandler, function(handler, name) {
+              subscriptions.push(Y[eventPhase](name,
+                                               handler.callback,
+                                               handler.context));
+            });
           }
         });
       }
@@ -320,10 +349,20 @@
     },
 
     /**
+     * @method renderOnce
+     *
+     * Called the first time render is invoked. See {render}.
+     **/
+    renderOnce: function() {},
+
+    /**
      * @method render
      * @chainable
      *
-     * Render each module bound to the canvas
+     * Render each module bound to the canvas. The first call to
+     * render() will automatically call renderOnce (a noop by default)
+     * and update(). If update requires some render state to operate on
+     * renderOnce is the place to include that setup code.
      */
     render: function() {
       var self = this;
@@ -337,6 +376,12 @@
       // If the container isn't bound to the DOM
       // do so now.
       this.attachContainer();
+      if (!this._rendered) {
+        self.renderOnce();
+        self.update();
+        self._rendered = true;
+      }
+
       // Render modules.
       Y.each(this.modules, renderAndBind, this);
       return this;

=== modified file 'app/modules-debug.js'
--- app/modules-debug.js	2012-11-15 15:44:00 +0000
+++ app/modules-debug.js	2012-11-21 19:26:21 +0000
@@ -37,6 +37,32 @@
         },
 
         // Views
+        'juju-topology-relation': {
+          fullpath: '/juju-ui/views/topology/relation.js'
+        },
+
+        'juju-topology-panzoom': {
+          fullpath: '/juju-ui/views/topology/panzoom.js'
+        },
+
+        'juju-topology-viewport': {
+          fullpath: '/juju-ui/views/topology/viewport.js'
+        },
+
+        'juju-topology-service': {
+          fullpath: '/juju-ui/views/topology/service.js'
+        },
+
+        'juju-topology': {
+          fullpath: '/juju-ui/views/topology/topology.js',
+          require: [
+            'juju-topology-service',
+            'juju-topology-relation',
+            'juju-topology-panzoom',
+            'juju-topology-viewport'
+          ]
+        },
+
         'juju-view-utils': {
           fullpath: '/juju-ui/views/utils.js'
         },
@@ -79,6 +105,7 @@
             'juju-templates',
             'juju-notifications',
             'juju-view-utils',
+            'juju-topology',
             'juju-view-environment',
             'juju-view-service',
             'juju-view-unit',

=== modified file 'app/templates/overview.handlebars'
--- app/templates/overview.handlebars	2012-11-15 15:55:06 +0000
+++ app/templates/overview.handlebars	2012-11-21 19:26:21 +0000
@@ -1,5 +1,5 @@
-<div id="overview">
-    <div id="canvas" class="crosshatch-background">
+<div>
+    <div class="topology-canvas crosshatch-background">
         <div class="environment-menu" id="service-menu">
             <div class="triangle">&nbsp;</div>
             <ul>

=== modified file 'app/views/environment.js'
--- app/views/environment.js	2012-11-20 16:22:21 +0000
+++ app/views/environment.js	2012-11-21 19:26:21 +0000
@@ -2,252 +2,32 @@
 /**
  * Provides the main app class.
  *
- * @module views
+ * @module environment
  */
 
 YUI.add('juju-view-environment', function(Y) {
 
   var views = Y.namespace('juju.views'),
       utils = Y.namespace('juju.views.utils'),
-      Templates = views.Templates,
-      models = Y.namespace('juju.models');
+      models = Y.namespace('juju.models'),
+      Templates = views.Templates;
 
   /**
    * Display an environment.
    *
    * @class environment
-   * @namespace views
+   * @namespace juju.views
    */
   var EnvironmentView = Y.Base.create('EnvironmentView', Y.View,
-                                      [views.JujuBaseView], {
-        events: {
-          '#zoom-out-btn': {click: 'zoom_out'},
-          '#zoom-in-btn': {click: 'zoom_in'},
-          '.graph-list-picker .picker-button': {
-            click: 'showGraphListPicker'
-          },
-          '.graph-list-picker .picker-expanded': {
-            click: 'hideGraphListPicker'
-          },
-          // Menu/Controls
-          '.add-relation': {
-            /** The user clicked on the "Build Relation" menu item. */
-            click: function() {
-              var box = this.get('active_service'),
-                  service = this.serviceForBox(box),
-                  context = this.get('active_context');
-              this.addRelationDragStart(box, context);
-              this.service_click_actions
-                        .toggleControlPanel(box, this, context);
-              this.service_click_actions.addRelationStart(box, this, context);
-            }
-          },
-          '.view-service': {
-            /** The user clicked on the "View" menu item. */
-            click: function() {
-              // Get the service element
-              var box = this.get('active_service'),
-                  service = this.serviceForBox(box);
-              this.service_click_actions
-                        .toggleControlPanel(box, this);
-              this.service_click_actions
-                        .show_service(service, this);
-            }
-          },
-          '.destroy-service': {
-            /** The user clicked on the "Destroy" menu item. */
-            click: function() {
-              // Get the service element
-              var box = this.get('active_service'),
-                  service = this.serviceForBox(box);
-              this.service_click_actions
-                        .toggleControlPanel(box, this);
-              this.service_click_actions
-                        .destroyServiceConfirm(service, this);
-            }
-          }
-        },
-
-        sceneEvents: {
-          // Service Related
-          '.service': {
-            click: 'serviceClick',
-            dblclick: 'serviceDblClick',
-            mouseenter: function(d, self) {
-              var rect = Y.one(this);
-              // Do not fire if this service isn't selectable.
-              if (!self.hasSVGClass(rect, 'selectable-service')) {
-                return;
-              }
-
-              // Do not fire unless we're within the service box.
-              var container = self.get('container'),
-                  mouse_coords = d3.mouse(container.one('svg').getDOMNode());
-              if (!d.containsPoint(mouse_coords, self.zoom)) {
-                return;
-              }
-
-              // Do not fire if we're on the same service.
-              if (d === self.get('addRelationStart_service')) {
-                return;
-              }
-
-              self.set('potential_drop_point_service', d);
-              self.set('potential_drop_point_rect', rect);
-              self.addSVGClass(rect, 'hover');
-
-              // If we have an active dragline, stop redrawing it on mousemove
-              // and draw the line between the two nearest connector points of
-              // the two services.
-              if (self.dragline) {
-                var connectors = d.getConnectorPair(
-                    self.get('addRelationStart_service')),
-                    s = connectors[0],
-                    t = connectors[1];
-                self.dragline.attr('x1', t[0])
-                  .attr('y1', t[1])
-                  .attr('x2', s[0])
-                  .attr('y2', s[1])
-                  .attr('class', 'relation pending-relation dragline');
-              }
-            },
-            mouseleave: function(d, self) {
-              // Do not fire if we aren't looking for a relation endpoint.
-              if (!self.get('potential_drop_point_rect')) {
-                return;
-              }
-
-              // Do not fire if we're within the service box.
-              var container = self.get('container'),
-                  mouse_coords = d3.mouse(container.one('svg').getDOMNode());
-              if (d.containsPoint(mouse_coords, self.zoom)) {
-                return;
-              }
-              var rect = Y.one(this).one('.service-border');
-              self.set('potential_drop_point_service', null);
-              self.set('potential_drop_point_rect', null);
-              self.removeSVGClass(rect, 'hover');
-
-              if (self.dragline) {
-                self.dragline.attr('class',
-                    'relation pending-relation dragline dragging');
-              }
-            },
-            mousemove: 'mousemove'
-          },
-          '.sub-rel-block': {
-            mouseenter: function(d, self) {
-              // Add an 'active' class to all of the subordinate relations
-              // belonging to this service.
-              self.subordinateRelationsForService(d)
-                .forEach(function(p) {
-                    self.addSVGClass('#' + p.id, 'active');
-                  });
-            },
-            mouseleave: function(d, self) {
-              // Remove 'active' class from all subordinate relations.
-              if (!self.keepSubRelationsVisible) {
-                self.removeSVGClass('.subordinate-rel-group', 'active');
-              }
-            },
-            /**
-             * Toggle the visibility of subordinate relations for visibility
-             * or removal.
-             * @param {object} d The data-bound object (the subordinate).
-             * @param {object} self The view.
-             */
-            click: function(d, self) {
-              if (self.keepSubRelationsVisible) {
-                self.hideSubordinateRelations();
-              } else {
-                self.showSubordinateRelations(this);
-              }
-            }
-          },
-          '.service-status': {
-            mouseover: function(d, self) {
-              d3.select(this)
-                .select('.unit-count')
-                .attr('class', 'unit-count show-count');
-            },
-            mouseout: function(d, self) {
-              d3.select(this)
-                .select('.unit-count')
-                .attr('class', 'unit-count hide-count');
-            }
-          },
-
-          // Relation Related
-          '.rel-label': {
-            /** The user clicked on the relation label. */
-            click: 'relationClick',
-            mousemove: 'mousemove'
-          },
-
-          '#canvas rect:first-child': {
-            /**
-             * If the user clicks on the background we cancel any active add
-             * relation.
-             */
-            click: function(d, self) {
-              var container = self.get('container');
-              container.all('.environment-menu.active').removeClass('active');
-              self.service_click_actions.toggleControlPanel(null, self);
-              self.cancelRelationBuild();
-              self.hideSubordinateRelations();
-            },
-            mousemove: 'mousemove'
-          },
-          '.dragline': {
-            /** The user clicked while the dragline was active. */
-            click: function(d, self) {
-              // It was technically the dragline that was clicked, but the
-              // intent was to click on the background, so...
-              self.backgroundClicked();
-            }
-          }
-        },
-
-        d3Events: {
-          '.service': {
-            'mousedown.addrel': function(d, self) {
-              var evt = d3.event;
-              self.longClickTimer = Y.later(750, this, function(d, e) {
-                // Provide some leeway for accidental dragging.
-                if ((Math.abs(d.x - d.oldX) + Math.abs(d.y - d.oldY)) /
-                    2 > 5) {
-                  return;
-                }
-
-                // Sometimes mouseover is fired after the mousedown, so ensure
-                // we have the correct event in d3.event for d3.mouse().
-                d3.event = e;
-
-                // Start the process of adding a relation
-                self.addRelationDragStart(d, this);
-              }, [d, evt], false);
-            },
-            'mouseup.addrel': function(d, self) {
-              // Cancel the long-click timer if it exists.
-              if (self.longClickTimer) {
-                self.longClickTimer.cancel();
-              }
-            }
-          }
-        },
-
+                                      [views.JujuBaseView],
+      {
         initializer: function() {
           console.log('View: Initialized: Env');
           this.publish('navigateTo', {preventable: false});
-
-          // Build a service.id -> BoundingBox map for services.
-          this.service_boxes = {};
-
-          // Track events bound to the canvas
-          this._sceneEvents = [];
         },
 
         render: function() {
+<<<<<<< TREE
           var container = this.get('container');
           EnvironmentView.superclass.render.apply(this, arguments);
           container.setHTML(Templates.overview());
@@ -1796,11 +1576,37 @@
             // Redraw the graph and reattach events.
             db.fire('update');
           }
+=======
+          var container = this.get('container'),
+              topo;
+
+          //If we need the initial HTML template
+          // take care of that.
+          if (!this.svg) {
+            EnvironmentView.superclass.render.apply(this, arguments);
+            container.setHTML(Templates.overview());
+            this.svg = container.one('#overview');
+          }
+
+          if (!this.get('topo')) {
+            topo = new views.Topology();
+            topo.setAttrs({
+              size: [640, 480],
+              env: this.get('env'),
+              db: this.get('db'),
+              container: container});
+            // Bind all the behaviors we need as modules.
+            topo.addModule(views.ServiceModule);
+
+            this.set('topo', topo);
+            topo.update();
+          }
+          topo.render();
+          return this;
+>>>>>>> MERGE-SOURCE
         }
-
       }, {
         ATTRS: {
-          currentServiceClickAction: { value: 'toggleControlPanel' }
         }
       });
 
@@ -1810,12 +1616,12 @@
     'juju-view-utils',
     'juju-models',
     'd3',
+    'd3-components',
     'base-build',
     'handlebars-base',
     'node',
     'svg-layouts',
     'event-resize',
     'slider',
-    'slider-base',
     'view']
 });

=== added directory 'app/views/topology'
=== added file 'app/views/topology/panzoom.js'
--- app/views/topology/panzoom.js	1970-01-01 00:00:00 +0000
+++ app/views/topology/panzoom.js	2012-11-21 19:26:21 +0000
@@ -0,0 +1,42 @@
+'use strict';
+
+YUI.add('juju-topology-panzoom', function(Y) {
+  var views = Y.namespace('juju.views'),
+      models = Y.namespace('juju.models'),
+      d3ns = Y.namespace('d3');
+
+  /**
+   * @module topology-service
+   * @class Service
+   * @namespace juju.views
+   **/
+  var PanZoomModule = Y.Base.create('PanZoomModule', d3ns.Module, [], {
+    initializer: function(options) {
+      PanZoomModule.superclass.constructor.apply(this, arguments);
+    },
+
+    render: function() {
+      PanZoomModule.superclass.render.apply(this, arguments);
+      return this;
+    },
+
+    update: function() {
+      PanZoomModule.superclass.update.apply(this, arguments);
+      return this;
+    }
+
+  }, {
+    ATTRS: {}
+
+  });
+  views.PanZoomModule = PanZoomModule;
+}, '0.1.0', {
+  requires: [
+    'd3',
+    'd3-components',
+    'node',
+    'event',
+    'juju-models',
+    'juju-env'
+  ]
+});

=== added file 'app/views/topology/relation.js'
--- app/views/topology/relation.js	1970-01-01 00:00:00 +0000
+++ app/views/topology/relation.js	2012-11-21 19:26:21 +0000
@@ -0,0 +1,42 @@
+'use strict';
+
+YUI.add('juju-topology-relation', function(Y) {
+  var views = Y.namespace('juju.views'),
+      models = Y.namespace('juju.models'),
+      d3ns = Y.namespace('d3');
+
+  /**
+   * @module topology-service
+   * @class Service
+   * @namespace juju.views
+   **/
+  var RelationModule = Y.Base.create('RelationModule', d3ns.Module, [], {
+    initializer: function(options) {
+      RelationModule.superclass.constructor.apply(this, arguments);
+    },
+
+    render: function() {
+      RelationModule.superclass.render.apply(this, arguments);
+      return this;
+    },
+
+    update: function() {
+      RelationModule.superclass.update.apply(this, arguments);
+      return this;
+    }
+
+  }, {
+    ATTRS: {}
+
+  });
+  views.RelationModule = RelationModule;
+}, '0.1.0', {
+  requires: [
+    'd3',
+    'd3-components',
+    'node',
+    'event',
+    'juju-models',
+    'juju-env'
+  ]
+});

=== added file 'app/views/topology/service.js'
--- app/views/topology/service.js	1970-01-01 00:00:00 +0000
+++ app/views/topology/service.js	2012-11-21 19:26:21 +0000
@@ -0,0 +1,361 @@
+'use strict';
+
+YUI.add('juju-topology-service', function(Y) {
+  var views = Y.namespace('juju.views'),
+      models = Y.namespace('juju.models'),
+      d3ns = Y.namespace('d3');
+
+  /**
+   * @module topology-service
+   * @class Service
+   * @namespace juju.views
+   **/
+  var ServiceModule = Y.Base.create('ServiceModule', d3ns.Module, [], {
+    subordinate_margin: {
+      top: 0.05,
+      bottom: 0.1,
+      left: 0.084848,
+      right: 0.084848},
+
+    service_margin: {
+      top: 0,
+      bottom: 0.1667,
+      left: 0.086758,
+      right: 0.086758},
+
+    initializer: function(options) {
+      ServiceModule.superclass.constructor.apply(this, arguments);
+      // Mapping of serviceId to BoundingBox of service.
+      this.service_boxes = {};
+
+    },
+
+    componentBound: function() {
+      var component = this.get('component');
+      //component.on('sizeChange', this._scaleLayout);
+      this._scaleLayout();
+      this._buildDrag();
+    },
+
+    _scaleLayout: function() {
+      this.layout = d3.layout.pack()
+         .size(this.get('component').get('size'))
+         .value(function(d) {return d.unit_count;})
+         .padding(300);
+    },
+
+    _buildDrag: function() {
+      var container = this.get('component'),
+          self = this;
+
+      this.drag = d3.behavior.drag()
+     .on('dragstart', function(d) {
+            d.oldX = d.x;
+            d.oldY = d.y;
+            Y.one(container).all('.environment-menu.active')
+       .removeClass('active');
+          })
+     .on('drag', function(d, i) {
+            if (self.longClickTimer) {
+              self.longClickTimer.cancel();
+            }
+            d.x += d3.event.dx;
+            d.y += d3.event.dy;
+            d3.select(this).attr('transform', function(d, i) {
+              return d.translateStr();
+            });
+            Y.one(container).all('.environment-menu.active')
+        .removeClass('active');
+          });
+    },
+
+    render: function() {
+      var topology = this.get('component');
+
+      ServiceModule.superclass.render.apply(this, arguments);
+
+      // Enter
+      this.serviceSelection
+      .enter().append('g')
+      .call(this.drag)
+      .attr('class', function(d) {
+            return (d.subordinate ? 'subordinate ' : '') + 'service';
+          })
+      .attr('transform', function(d) { return d.translateStr();});
+
+      // Update.
+      this.drawService(this.serviceSelection);
+
+      // Exit.
+      this.serviceSelection.exit()
+      .remove();
+
+      return this;
+    },
+
+    update: function() {
+      ServiceModule.superclass.update.apply(this, arguments);
+
+      var topology = this.get('component'),
+          db = topology.get('db'),
+          services = db.services.map(views.toBoundingBox),
+          new_services = Y.Object.values(this.service_boxes)
+            .filter(function(boundingBox) {
+            return !Y.Lang.isNumber(boundingBox.x);
+          });
+
+      // Layout new nodes.
+      this.layout
+          .nodes({children: new_services});
+
+      Y.each(services, function(service_box) {
+        var existing = this.service_boxes[service_box.id];
+        if (existing) {
+          service_box.pos = existing.pos;
+        }
+        service_box.margins(service_box.subordinate ?
+                                this.subordinate_margin :
+                                this.service_margin);
+
+        this.service_boxes[service_box.id] = service_box;
+      }, this);
+
+      this.serviceSelection = topology.vis.selectAll('.service')
+          .data(services, function(d) {
+            return d.modelId();});
+
+      return this;
+    },
+
+    drawService: function(node) {
+      var self = this,
+          topology = this.get('component'),
+          service_scale = topology.service_scale,
+          service_scale_width = topology.service_scale_width,
+          service_scale_height = topology.service_scale_height;
+
+      // Size the node for drawing.
+      node
+      .attr('width', function(d) {
+            // NB: if a service has zero units, as is possible with
+            // subordinates, then default to 1 for proper scaling, as
+            // a value of 0 will return a scale of 0 (this does not
+            // affect the unit count, just the scale of the service).
+            var w = service_scale(d.unit_count || 1);
+            d.w = w;
+            return w;
+          })
+      .attr('height', function(d) {
+            var h = service_scale(d.unit_count || 1);
+            d.h = h;
+            return h;
+          });
+
+      // Draw subordinate services.
+      node.filter(function(d) { return d.subordinate; })
+     .append('image')
+     .attr('xlink:href', '/juju-ui/assets/svgs/sub_module.svg')
+     .attr('width', function(d) { return d.w; })
+     .attr('height', function(d) { return d.h; });
+
+      // Draw a subordinate relation indicator.
+      var sub_relation = node.filter(function(d) {
+        return d.subordinate;
+      })
+     .append('g')
+     .attr('class', 'sub-rel-block')
+     .attr('transform', function(d) {
+            // Position the block so that the relation indicator will
+            // appear at the right connector.
+            return 'translate(' + [d.w, d.h / 2 - 26] + ')';
+          });
+
+      sub_relation.append('image')
+     .attr('xlink:href', '/juju-ui/assets/svgs/sub_relation.svg')
+     .attr('width', 87)
+     .attr('height', 47);
+      sub_relation.append('text').append('tspan')
+     .attr('class', 'sub-rel-count')
+     .attr('x', 64)
+     .attr('y', 47 * 0.8)
+     .text(function(d) {
+            return views.subordinateRelationsForService(
+                d, self.modules.relations.rel_pairs).length;
+          });
+      // Draw non-subordinate services services
+      node.filter(function(d) {
+        return !d.subordinate;
+      })
+     .append('image')
+     .attr('xlink:href', '/juju-ui/assets/svgs/service_module.svg')
+     .attr('width', function(d) {
+            return d.w;
+          })
+     .attr('height', function(d) {
+            return d.h;
+          });
+
+      // The following are sizes in pixels of the SVG assets used to
+      // render a service, and are used to in calculating the vertical
+      // positioning of text down along the service block.
+      var service_height = 224,
+          name_size = 22,
+          charm_label_size = 16,
+          name_padding = 26,
+          charm_label_padding = 118;
+
+      var service_labels = node.append('text').append('tspan')
+     .attr('class', 'name')
+     .attr('style', function(d) {
+            // Programmatically size the font.
+            // Number derived from service assets:
+            // font-size 22px when asset is 224px.
+            return 'font-size:' + d.h *
+                (name_size / service_height) + 'px';
+          })
+     .attr('x', function(d) {
+            return d.w / 2;
+          })
+     .attr('y', function(d) {
+            // Number derived from service assets:
+            // padding-top 26px when asset is 224px.
+            return d.h * (name_padding / service_height) + d.h *
+                (name_size / service_height) / 2;
+          })
+     .text(function(d) {return d.id; });
+
+      var charm_labels = node.append('text').append('tspan')
+     .attr('class', 'charm-label')
+     .attr('style', function(d) {
+            // Programmatically size the font.
+            // Number derived from service assets:
+            // font-size 16px when asset is 224px.
+            return 'font-size:' + d.h *
+                (charm_label_size / service_height) + 'px';
+          })
+     .attr('x', function(d) {
+            return d.w / 2;
+          })
+     .attr('y', function(d) {
+            // Number derived from service assets:
+            // padding-top: 118px when asset is 224px.
+            return d.h * (charm_label_padding / service_height) - d.h *
+                (charm_label_size / service_height) / 2;
+          })
+     .attr('dy', '3em')
+     .text(function(d) { return d.charm; });
+
+      // Show whether or not the service is exposed using an
+      // indicator (currently a simple circle).
+      // TODO this will likely change to an image with UI uodates.
+      var exposed_indicator = node.filter(function(d) {
+        return d.exposed;
+      })
+     .append('image')
+     .attr('xlink:href', '/juju-ui/assets/svgs/exposed.svg')
+     .attr('width', function(d) {
+            return d.w / 6;
+          })
+     .attr('height', function(d) {
+            return d.w / 6;
+          })
+     .attr('x', function(d) {
+            return d.w / 10 * 7;
+          })
+     .attr('y', function(d) {
+            return d.getRelativeCenter()[1] - (d.w / 6) / 2;
+          })
+     .attr('class', 'exposed-indicator on');
+      exposed_indicator.append('title')
+     .text(function(d) {
+            return d.exposed ? 'Exposed' : '';
+          });
+
+      // Add the relative health of a service in the form of a pie chart
+      // comprised of units styled appropriately.
+      var status_chart_arc = d3.svg.arc()
+     .innerRadius(0)
+     .outerRadius(function(d) {
+            // Make sure it's exactly as wide as the mask
+            return parseInt(
+                d3.select(this.parentNode)
+         .select('image')
+         .attr('width'), 10) / 2;
+          });
+
+      var status_chart_layout = d3.layout.pie()
+     .value(function(d) { return (d.value ? d.value : 1); })
+     .sort(function(a, b) {
+            // Ensure that the service health graphs will be renders in
+            // the correct order: error - pending - running.
+            var states = {error: 0, pending: 1, running: 2};
+            return states[a.name] - states[b.name];
+          });
+
+      // Append to status charts to non-subordinate services
+      var status_chart = node.append('g')
+     .attr('class', 'service-status')
+     .attr('transform', function(d) {
+            return 'translate(' + d.getRelativeCenter() + ')';
+          });
+
+      // Add a mask svg
+      status_chart.append('image')
+     .attr('xlink:href', '/juju-ui/assets/svgs/service_health_mask.svg')
+     .attr('width', function(d) {
+            return d.w / 3;
+          })
+     .attr('height', function(d) {
+            return d.h / 3;
+          })
+     .attr('x', function() {
+            return -d3.select(this).attr('width') / 2;
+          })
+     .attr('y', function() {
+            return -d3.select(this).attr('height') / 2;
+          });
+
+      // Add the path after the mask image (since it requires the mask's
+      // width to set its own).
+      var status_arcs = status_chart.selectAll('path')
+     .data(function(d) {
+            var aggregate_map = d.aggregated_status,
+                aggregate_list = [];
+            Y.Object.each(aggregate_map, function(count, state) {
+              aggregate_list.push({name: state, value: count});
+            });
+
+            return status_chart_layout(aggregate_list);
+          }).enter().insert('path', 'image')
+     .attr('d', status_chart_arc)
+     .attr('class', function(d) { return 'status-' + d.data.name; })
+     .attr('fill-rule', 'evenodd')
+     .append('title').text(function(d) {
+            return d.data.name;
+          });
+
+      // Add the unit counts, visible only on hover.
+      var unit_count = status_chart.append('text')
+     .attr('class', 'unit-count hide-count')
+     .text(function(d) {
+            return views.humanizeNumber(d.unit_count);
+          });
+    }
+
+
+
+  }, {
+    ATTRS: {}
+
+  });
+  views.ServiceModule = ServiceModule;
+}, '0.1.0', {
+  requires: [
+    'd3',
+    'd3-components',
+    'node',
+    'event',
+    'juju-models',
+    'juju-env'
+  ]
+});

=== added file 'app/views/topology/topology.js'
--- app/views/topology/topology.js	1970-01-01 00:00:00 +0000
+++ app/views/topology/topology.js	2012-11-21 19:26:21 +0000
@@ -0,0 +1,133 @@
+'use strict';
+
+YUI.add('juju-topology', function(Y) {
+  var views = Y.namespace('juju.views'),
+      models = Y.namespace('juju.models'),
+      d3ns = Y.namespace('d3');
+
+  /**
+   * Topology models and renders the SVG of the envionment topology
+   * with its associated behaviors.
+   *
+   * The line of where to put code (in the Topology vs a Module) isn't 100%
+   * clear. The rule of thumb to follow is that shared state, policy and
+   * configuration belong here. If the only shared requirement on shared state
+   * is watch/event like behavior fire an event and place the logic in a module.
+   *
+   * @class Topology
+   * @namespace juju.views
+   **/
+  var Topology = Y.Base.create('Topology', d3ns.Component, [], {
+    initializer: function(options) {
+      Topology.superclass.constructor.apply(this, arguments);
+      options = options || {};
+
+    },
+
+    render: function() {
+      Topology.superclass.render.apply(this, arguments);
+      return this;
+    },
+
+    renderOnce: function() {
+      var self = this,
+          vis,
+          width = this.get('width'),
+          height = this.get('height'),
+          container = this.get('container');
+
+      if (this.svg) {
+        return;
+      }
+      container.setHTML(views.Templates.overview());
+      // Take the first element.
+      this.svg = container.one(':first-child');
+
+      this.service_scale = d3.scale.log().range([150, 200]);
+      this.service_scale_width = d3.scale.log().range([164, 200]),
+      this.service_scale_height = d3.scale.log().range([64, 100]);
+      this.xscale = d3.scale.linear()
+      .domain([-width / 2, width / 2])
+      .range([0, width]),
+      this.yscale = d3.scale.linear()
+      .domain([-height / 2, height / 2])
+      .range([height, 0]);
+
+      // Default zoom behavior, customized in bindings.
+      this.zoom = d3.behavior.zoom();
+
+      // Set up the visualization with a pack layout.
+      vis = d3.select(container.getDOMNode())
+      .selectAll('.topology-canvas')
+      .append('svg:svg')
+      .attr('pointer-events', 'all')
+      .attr('width', width)
+      .attr('height', height)
+      .append('svg:g')
+      .append('g');
+
+      vis.append('svg:rect')
+      .attr('class', 'graph')
+      .attr('fill', 'rgba(255,255,255,0)');
+
+      this.vis = vis;
+
+      return this;
+    }
+
+  }, {
+    ATTRS: {
+      /**
+       * @property {models.Database} db
+       **/
+      db: {},
+      /**
+       * @property {store.Environment} env
+       **/
+      env: {},
+      /**
+       * @property {Array} size
+       * A [width, height] tuple representing canvas size.
+       **/
+      size: {value: [640, 480]},
+      /**
+       * @property {Number} scale
+       **/
+      scale: {
+        getter: function() {return this.zoom.scale();},
+        setter: function(v) {this.zoom.scale(v);}
+      },
+      /**
+       * @property {Array} transform
+       **/
+      transform: {
+        getter: function() {return this.get('zoom').transform();},
+        setter: function(v) {this.get('zoom').transform(v);}
+      },
+
+      width: {
+        getter: function() {return this.get('size')[0];}
+      },
+
+      height: {
+        getter: function() {return this.get('size')[1];}
+      }
+    }
+
+  });
+  views.Topology = Topology;
+}, '0.1.0', {
+  requires: [
+    'd3',
+    'd3-components',
+    'node',
+    'event',
+    'juju-templates',
+    'juju-models',
+    'juju-env',
+    'juju-topology-service',
+    'juju-topology-relation',
+    'juju-topology-panzoom',
+    'juju-topology-viewport'
+  ]
+});

=== added file 'app/views/topology/viewport.js'
--- app/views/topology/viewport.js	1970-01-01 00:00:00 +0000
+++ app/views/topology/viewport.js	2012-11-21 19:26:21 +0000
@@ -0,0 +1,41 @@
+'use strict';
+
+YUI.add('juju-topology-viewport', function(Y) {
+  var views = Y.namespace('juju.views'),
+      models = Y.namespace('juju.models'),
+      d3ns = Y.namespace('d3');
+
+  /**
+   * @module topology-service
+   * @class Service
+   * @namespace juju.views
+   **/
+  var ViewportModule = Y.Base.create('ViewportModule', d3ns.Module, [], {
+    initializer: function(options) {
+      ViewportModule.superclass.constructor.apply(this, arguments);
+    },
+
+    render: function() {
+      ViewportModule.superclass.render.apply(this, arguments);
+      return this;
+    },
+
+    update: function() {
+      ViewportModule.superclass.update.apply(this, arguments);
+      return this;
+    }
+
+  }, {
+    ATTRS: {}
+  });
+  views.ViewportModule = ViewportModule;
+}, '0.1.0', {
+  requires: [
+    'd3',
+    'd3-components',
+    'node',
+    'event',
+    'juju-models',
+    'juju-env'
+  ]
+});

=== modified file 'test/index.html'
--- test/index.html	2012-11-20 15:35:30 +0000
+++ test/index.html	2012-11-21 19:26:21 +0000
@@ -16,6 +16,7 @@
   </script>
 
   <script src="test_d3_components.js"></script>
+  <script src="test_topology.js"></script>
   <script src="test_env.js"></script>
   <script src="test_model.js"></script>
   <script src="test_notifications.js"></script>

=== modified file 'test/test_d3_components.js'
--- test/test_d3_components.js	2012-11-09 14:17:58 +0000
+++ test/test_d3_components.js	2012-11-21 19:26:21 +0000
@@ -30,6 +30,10 @@
 
         cancelHandler: function(evt) {
           state.cancelled = true;
+        },
+
+        componentBound: function() {
+          state.bound = true;
         }
       });
 
@@ -76,10 +80,6 @@
     Y.fire('cancel');
     state.cancelled.should.equal(true);
 
-    // XXX: While on the plane I determined that things like
-    // 'events' are sharing state with other runs/modules.
-    // This must be fixed before this can work again.
-
     // Manually set state, remove the module and test again
     state.cancelled = false;
     comp.removeModule('TestModule');
@@ -97,6 +97,10 @@
     comp.render();
     Y.one('.thing').simulate('click');
     state.thing.should.equal('decorated');
+
+    // Also verify that the module's componentChanged binding
+    // took place.
+    state.bound.should.equal(true);
   });
 
   it('should allow event bindings through the use of a declartive object',

=== modified file 'test/test_environment_view.js'
--- test/test_environment_view.js	2012-11-20 16:22:21 +0000
+++ test/test_environment_view.js	2012-11-21 19:26:21 +0000
@@ -104,7 +104,7 @@
 
     beforeEach(function(done) {
       container = Y.Node.create('<div id="test-container" />');
-      Y.one('body').prepend(container);
+      Y.one('body').append(container);
       db = new models.Database();
       db.on_delta({data: environment_delta});
       done();
@@ -354,7 +354,7 @@
            env: env
          }).render();
          var service = container.one('.service'),
-             add_rel = container.one('.add-relation'),
+             add_rel = container.one('#service-menu .add-relation'),
              after_evt;
 
          // Mock endpoints
@@ -382,13 +382,7 @@
            return endpoints;
          };
 
-         // Toggle the control panel for the Add Relation button.
-         view.service_click_actions.toggleControlPanel(
-             d3.select(service.getDOMNode()).datum(),
-             view,
-             service);
-         // Mock an event object so that d3.mouse does not throw a NPE.
-         d3.event = {};
+         service.simulate('click');
          add_rel.simulate('click');
          container.all('.selectable-service')
                .size()
@@ -396,11 +390,7 @@
          container.all('.dragline')
                .size()
                .should.equal(1);
-         // Start the process of adding a relation.
-         view.service_click_actions.ambiguousAddRelationCheck(
-             d3.select(service.next().getDOMNode()).datum(),
-             view,
-             service.next());
+         service.next().simulate('click');
          container.all('.selectable-service').size()
             .should.equal(0);
          // The database is initialized with three relations in beforeEach.

=== added file 'test/test_topology.js'
--- test/test_topology.js	1970-01-01 00:00:00 +0000
+++ test/test_topology.js	2012-11-21 19:26:21 +0000
@@ -0,0 +1,97 @@
+
+'use strict';
+
+describe.only('topology', function() {
+  var Y, NS, views,
+      TestModule, modA, state,
+      container, topo,
+      models,
+      db;
+
+  before(function(done) {
+    Y = YUI(GlobalConfig).use(['juju-topology',
+                               'd3-components',
+                               'node',
+                               'node-event-simulate'],
+    function(Y) {
+      NS = Y.namespace('d3');
+      views = Y.namespace('juju.views');
+      models = Y.namespace('juju.models');
+
+      TestModule = Y.Base.create('TestModule', NS.Module, [], {
+        events: {
+          scene: { '.thing': {click: 'decorateThing'}},
+          d3: {'.target': {click: 'targetTarget'}},
+          yui: {
+            cancel: 'cancelHandler'
+          }
+        },
+
+        decorateThing: function(evt) {
+          state.thing = 'decorated';
+        },
+
+        targetTarget: function(evt) {
+          state.targeted = true;
+        },
+
+        cancelHandler: function(evt) {
+          state.cancelled = true;
+        }
+      });
+
+      done();
+    });
+  });
+
+  beforeEach(function() {
+    container = Y.Node.create('<div id="test" style="visibility: hidden">' +
+                              '<button class="thing"></button>' +
+                              '<button class="target"></button>' +
+                              '</div>');
+    state = {};
+  });
+
+  afterEach(function() {
+    container.remove();
+    container.destroy();
+    if (topo) {
+      topo.unbind();
+    }
+    if (db) {
+      db.destroy();
+    }
+  });
+
+  it('should be able to create a topology with default modules', function() {
+    topo = new views.Topology();
+    topo.setAttrs({container: container});
+    topo.addModule(TestModule);
+    topo.render();
+
+    // Verify that we have built the default scene.
+    Y.Lang.isValue(topo.svg).should.equal(true);
+  });
+
+  function createStandardTopo() {
+    db = new models.Database();
+    topo = new views.Topology();
+    topo.setAttrs({container: container, db: db});
+    topo.addModule(views.ServiceModule);
+    topo.addModule(views.RelationModule);
+    topo.addModule(views.PanZoomModule);
+    topo.addModule(views.ViewportModule);
+    return topo;
+  }
+
+  it('should be able to create a topology with standard env view modules',
+     function() {
+       topo = createStandardTopo();
+       topo.render();
+       // Verify that we have built the default scene.
+       Y.Lang.isValue(topo.svg).should.equal(true);
+     });
+
+});
+
+


Follow ups