API Docs for:
Show:

File: app/views/topology/service.js

'use strict';

/**
 * Provide the ServiceModule class.
 *
 * @module topology
 * @submodule topology.service
 */

YUI.add('juju-topology-service', function(Y) {
  var views = Y.namespace('juju.views'),
      models = Y.namespace('juju.models'),
      utils = Y.namespace('juju.views.utils'),
      d3ns = Y.namespace('d3'),
      Templates = views.Templates;

  /**
   * Manage service rendering and events.
   *
   * ## Emitted events:
   *
   * - *clearState:* clear all possible states that the environment view can be
   *   in as it pertains to actions (building a relation, viewing
   *   a service menu, etc.)
   * - *snapToService:* fired when mousing over a service, causing the pending
   *   relation dragline to snap to the service rather than
   *   following the mouse.
   * - *snapOutOfService:* fired when mousing out of a service, causing the
   *   pending relation line to follow the mouse again.
   * - *addRelationDrag:*
   * - *addRelationDragStart:*
   * - *addRelationDragEnd:* fired when creating a relation through the long-
   *   click process, when moving the cursor over the environment, and when
   *   dropping the endpoint on a valid service.
   * - *cancelRelationBuild:* fired when dropping a pending relation line
   *   started through the long-click method somewhere other than a valid
   *   service.
   * - *serviceMoved:* fired when a service block is dragged so that relation
   *   endpoints can follow it.
   * - *navigateTo:* fired when clicking the "View Service" menu item or when
   *   double-clicking a service.
   *
   * @class ServiceModule
   */
  var ServiceModule = Y.Base.create('ServiceModule', d3ns.Module, [], {
    events: {
      scene: {
        '.service': {
          click: 'serviceClick',
          dblclick: 'serviceDblClick',
          mouseenter: 'serviceMouseEnter',
          mouseleave: 'serviceMouseLeave',
          mousemove: 'serviceMouseMove'
        },

        '.service-status': {
          mouseover: 'serviceStatusMouseOver',
          mouseout: 'serviceStatusMouseOut'
        },
        '.zoom-plane': {
          click: 'canvasClick'
        },
        // Menu/Controls
        '.view-service': {
          click: 'viewServiceClick',
          touchstart: 'viewServiceClick'
        },
        '.destroy-service': {
          click: 'destroyServiceClick'
        }
      },
      d3: {
        '.service': {
          'mousedown.addrel': 'serviceAddRelMouseDown',
          'mouseup.addrel': 'serviceAddRelMouseUp'
        }
      },
      yui: {
        /**
          Show a hidden service (set opacity to 1.0).

          @event show
          @param {Object} An object with a d3 selection attribute.
        */
        show: 'show',
        /**
          Hide a given service (set opacity to 0).

          @event hide
          @param {Object} An object with a d3 selection attribute.
        */
        hide: 'hide',
        /**
          Fade a given service (set opacity to 0.2).

          @event fade
          @param {Object} An object with a d3 selection attribute.
        */
        fade: 'fade',
        /**
          Start the service drag process or the add-relation dragline process.

          @event dragstart
          @param {Object} box The service box that's being dragged.
          @param {Object} self This class.
        */
        dragstart: 'dragstart',
        /**
          Event fired while a service is being dragged or dragline being moved.

          @event drag
          @param {Object} box The service box that's being dragged.
          @param {Object} self This class.
        */
        drag: 'drag',
        /**
          Event fired after a service is being dragged or dragline being moved.

          @event dragend
          @param {Object} box The service box that's being dragged.
          @param {Object} self This class.
        */
        dragend: 'dragend',
        /**
          Hide a service's click-actions menu.

          @event hideServiceMenu
        */
        hideServiceMenu: 'hideServiceMenu',
        /**
          Clear view state as pertaining to services.

          @event clearState
        */
        clearState: 'clearStateHandler',
        /**
          Update the service menu location.

          @event rescaled
        */
        rescaled: 'updateServiceMenuLocation'
      }
    },

    // Margins applied on update to Box instances.
    subordinate_margins: {
      top: 0.05, bottom: 0.1, left: 0.084848, right: 0.084848},
    service_margins: {
      top: 0, bottom: 0.1667, left: 0.086758, right: 0.086758},

    initializer: function(options) {
      ServiceModule.superclass.constructor.apply(this, arguments);

      // Set a default
      this.set('currentServiceClickAction', 'toggleServiceMenu');
    },

    /**
      Attaches the touchstart event handlers for the service elements. This is
      required because touchstart does not appear to bubble in Chrome for
      Android 4.2.2.

      @method attachTouchstartEvents
      @param {Object} data D3 data object.
      @param {DOM Element} node SVG DOM element.
    */
    attachTouchstartEvents: function(data, node) {
      var topo = this.get('component'),
          yuiNode = Y.Node(node);

      // Do not attach the event to the ghost nodes
      if (!d3.select(node).classed('pending')) {
        yuiNode.on('touchstart', this._touchstartServiceTap, this, topo);
      }
    },

    /**
      Callback for the touchstart event handlers on the service svg elements

      @method _touchstartServiceClick
      @param {Object} e event object from tap.
      @param {Object} topo topography instance reference.
    */
    _touchstartServiceTap: function(e, topo) {
      // To execute the serviceClick method under the same context as
      // click we call it under the touch target context
      var node = e.currentTarget.getDOMNode(),
          box = d3.select(node).datum();
      // If we're dragging with two fingers, ignore this as a tap and let drag
      // take over.
      if (e.touches.length > 1) {
        box.tapped = false;
        return;
      }
      box.tapped = true;
      this.serviceClick.call(
          node,
          box,
          this,
          // Specifying the event type to avoid d3.mouse() error
          'touch'
      );
    },

    /**
      Handles the click or tap on the service svg elements.

      It is executed under the context of the clicked/tapped DOM element

      @method serviceClick
      @param {Object} box service object model instance.
      @param {Object} self this service module instance.
      @param {String} eType string representing if it's 'touch' or not.
    */
    serviceClick: function(box, self, eType) {
      // Ignore if we clicked outside the actual service node.
      var topo = self.get('component');
      var container = self.get('container');

      // This check is required because d3.mouse() throws an internal error
      // on touch events
      if (eType !== 'touch') {
        var mouse_coords = d3.mouse(container.one('svg').getDOMNode());
        if (!box.containsPoint(mouse_coords, topo.zoom)) {
          return;
        }
      } else {
        // Touch events will also fire a click event about 300ms later. If this
        // event isn't ignored, the service menu will disappear 300ms after it
        // appears, so set a flag to ignore that event.
        box.ignoreNextClick = true;
      }

      if (box.ignoreNextClick) {
        box.ignoreNextClick = false;
        return;
      }

      // If the service box is pending, ensure that the charm panel is
      // visible, but don't do anything else.
      if (box.pending) {
        // Prevent the clickoutside event from firing and immediately closing
        // the panel.
        d3.event.halt();
        // Ensure service menus are closed.
        topo.fire('clearState');
        views.CharmPanel.getInstance().show();
        return;
      }
      // serviceClick is being called after dragend is processed.  In those
      // cases the current click action should not be invoked.
      if (topo.ignoreServiceClick) {
        topo.ignoreServiceClick = false;
        return;
      }
      // Get the current click action
      var curr_click_action = self.get('currentServiceClickAction');

      // Fire the action named in the following scheme:
      //   service_click_action.<action>
      // with the service, the SVG node, and the view
      // as arguments.
      self[curr_click_action](box);
    },

    serviceDblClick: function(box, self) {
      if (box.pending) {
        return;
      }
      // Just show the service on double-click.
      var topo = self.get('component'),
          service = box.model;
      // The browser sends a click event right before the dblclick one, and it
      // opens the service menu: close it before moving to the service details.
      self.hideServiceMenu();
      self.show_service(service);
    },

    serviceMouseEnter: function(box, context) {
      var rect = Y.one(this);
      // Do not fire if this service isn't selectable.
      if (box.pending || !utils.hasSVGClass(rect, 'selectable-service')) {
        return;
      }

      // Do not fire unless we're within the service box.
      var topo = context.get('component');
      var container = context.get('container');
      var mouse_coords = d3.mouse(container.one('svg').getDOMNode());
      if (!box.containsPoint(mouse_coords, topo.zoom)) {
        return;
      }

      topo.fire('snapToService', { service: box, rect: rect });
    },

    serviceMouseLeave: function(box, context) {
      // Do not fire if we're within the service box.
      var topo = context.get('component');
      var container = context.get('container');
      var mouse_coords = d3.mouse(container.one('svg').getDOMNode());
      if (box.pending || box.containsPoint(mouse_coords, topo.zoom)) {
        return;
      }
      var rect = Y.one(this).one('.service-border');
      utils.removeSVGClass(rect, 'hover');

      topo.fire('snapOutOfService');
    },

    /**
     * Handle a mouse moving over a service.
     *
     * @method serviceMouseMove
     * @param {object} d Unused.
     * @param {object} context Unused.
     * @return {undefined} Side effects only.
     */
    serviceMouseMove: function(box, context) {
      if (box.pending) {
        return;
      }
      var topo = context.get('component');
      topo.fire('mouseMove');
    },

    /**
     * Handle mouseover service status
     *
     * @method serviceStatusMouseOver
     */
    serviceStatusMouseOver: function(box, context) {
      d3.select(this)
        .select('.unit-count')
        .attr('class', 'unit-count show-count');
    },

    serviceStatusMouseOut: function(box, context) {
      d3.select(this)
        .select('.unit-count')
        .attr('class', 'unit-count hide-count');
    },

    /**
     * If the user clicks on the background we cancel any active add
     * relation.
     *
     * @method canvasClick
     */
    canvasClick: function(box, self) {
      var topo = self.get('component');
      topo.fire('clearState');
    },

    /**
     * Clear any stateful actions (menus, etc.) when a clearState event is
     * received.
     *
     * @method clearStateHandler
     * @return {undefined} Side effects only.
     */
    clearStateHandler: function() {
      var container = this.get('container'),
          topo = this.get('component');
      container.all('.environment-menu.active').removeClass('active');
      this.hideServiceMenu();
    },

    /**
     * The user clicked on the "View" menu item.
     *
     * @method viewServiceClick
     */
    viewServiceClick: function(_, context) {
      // Get the service element
      var topo = context.get('component');
      var box = topo.get('active_service');
      var service = box.model;
      context.hideServiceMenu();
      context.show_service(service);
    },

    /**
     * The user clicked on the "Destroy" menu item.
     *
     * @method destroyServiceClick
     */
    destroyServiceClick: function(_, context) {
      // Get the service element
      var topo = context.get('component');
      var box = topo.get('active_service');
      context.hideServiceMenu();
      context.destroyServiceConfirm(box);
    },

    serviceAddRelMouseDown: function(box, context) {
      if (box.pending) {
        return;
      }
      var evt = d3.event;
      var topo = context.get('component');
      context.longClickTimer = Y.later(750, this, function(d, e) {
        // Provide some leeway for accidental dragging.
        if ((Math.abs(box.x - box.oldX) + Math.abs(box.y - box.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
        topo.fire('addRelationDragStart', {service: box});
      }, [box, evt], false);
    },

    serviceAddRelMouseUp: function(box, context) {
      // Cancel the long-click timer if it exists.
      if (context.longClickTimer) {
        context.longClickTimer.cancel();
      }
    },
    /*
     * Sync view models with current db.models.
     *
     * @method updateData
     */
    updateData: function() {
      //model data
      var topo = this.get('component');
      var vis = topo.vis;
      var db = topo.get('db');

      views.toBoundingBoxes(this, db.services.visible(), topo.service_boxes);

      // Nodes are mapped by modelId tuples.
      this.node = vis.selectAll('.service')
                     .data(Y.Object.values(topo.service_boxes),
                           function(d) {return d.modelId;});
    },

    /**
     * Handle drag events for a service.
     *
     * @param {Box} box A bounding box.
     * @param {Module} self Service Module.
     * @return {undefined} Side effects only.
     * @method dragstart
     */
    dragstart: function(box, self) {
      var topo = self.get('component');
      box.oldX = box.x;
      box.oldY = box.y;
      box.inDrag = views.DRAG_START;
    },

    dragend: function(box,  self) {
      var topo = self.get('component');
      if (box.tapped) {
        box.tapped = false;
        if (!topo.buildingRelation) {
          return;
        }
      }
      if (topo.buildingRelation) {
        topo.ignoreServiceClick = true;
        topo.fire('addRelationDragEnd');
      }
      else {
        // If the service hasn't been dragged (in the case of long-click to add
        // relation, or a double-fired event) or the old and new coordinates
        // are the same, exit.
        if (!box.inDrag ||
            (box.oldX === box.x &&
             box.oldY === box.y)) {
          return;
        }
        // If the service is still pending, persist x/y coordinates in order
        // to set them as annotations when the service is created.
        if (box.pending) {
          box.model.set('dragged', true);
          box.model.set('x', box.x);
          box.model.set('y', box.y);
          return;
        }
        topo.get('env').update_annotations(
            box.id, 'service', {'gui-x': box.x, 'gui-y': box.y},
            function() {
              box.inDrag = false;
            });
      }
    },

    /**
     * Specialized drag event handler
     * when called as an event handler it
     * Allows optional extra param, pos
     * which when used overrides the mouse
     * handling. This method can then be
     * though of as 'drag to position'.
     *
     * @method drag
     * @param {Box} d viewModel BoundingBox.
     * @param {ServiceModule} self ServiceModule.
     * @param {Object} pos (optional) containing x/y numbers.
     * @param {Boolean} includeTransition (optional) Use transition to drag.
     *
     * [At the time of this writing useTransition works in practice but
     * introduces a timing issue in the tests.]
     */
    drag: function(box, self, pos, includeTransition) {
      if (box.tapped) {
        return;
      }
      var topo = self.get('component');
      var selection = d3.select(this);

      if (topo.buildingRelation) {
        topo.fire('addRelationDrag', { box: box });
        return;
      }
      if (self.longClickTimer) {
        self.longClickTimer.cancel();
      }
      // Translate the service (and, potentially, menu).
      // If a position was provided, update the box's coordinates and the
      // selection's bound data.
      if (pos) {
        box.x = pos.x;
        box.y = pos.y;
        // Explicitly reassign data.
        selection = selection.data([box]);
      } else {
        box.x += d3.event.dx;
        box.y += d3.event.dy;
      }

      if (includeTransition) {
        selection = selection.transition()
                             .duration(500)
                             .ease('elastic');
      }

      selection.attr('transform', function(d, i) {
        return d.translateStr;
      });
      if (topo.get('active_service') === box) {
        self.updateServiceMenuLocation();
      }

      // Remove any active menus.
      self.get('container').all('.environment-menu.active')
          .removeClass('active');
      if (box.inDrag === views.DRAG_START) {
        self.hideServiceMenu();
        box.inDrag = views.DRAG_ACTIVE;
      }
      topo.fire('cancelRelationBuild');
      // Update relation lines for just this service.
      topo.fire('serviceMoved', { service: box });
    },

    /*
     * Attempt to reuse as much of the existing graph and view models
     * as possible to re-render the graph.
     *
     * @method update
     */
    update: function() {
      var self = this,
          topo = this.get('component'),
          width = topo.get('width'),
          height = topo.get('height');

      if (!this.service_scale) {
        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]);
      }

      if (!this.tree) {
        this.tree = d3.layout.pack()
                      .size([width, height])
                      .value(function(d) {
                          return Math.max(d.unit_count, 1);
                        })
                      .padding(300);
      }

      if (!this.dragBehavior) {
        this.dragBehavior = d3.behavior.drag()
            .on('dragstart', function(d) { self.dragstart.call(this, d, self);})
            .on('drag', function(d) { self.drag.call(this, d, self);})
            .on('dragend', function(d) { self.dragend.call(this, d, self);});
      }

      //Process any changed data.
      this.updateData();

      // Generate a node for each service, draw it as a rect with
      // labels for service and charm.
      var node = this.node;

      // Rerun the pack layout.
      // Pack doesn't honor existing positions and will
      // re-layout the entire graph. As a short term work
      // around we layout only new nodes. This has the side
      // effect that service blocks can overlap and will
      // be fixed later.
      var new_services = Y.Object.values(topo.service_boxes)
                          .filter(function(boundingBox) {
                            return !Y.Lang.isNumber(boundingBox.x);
                          });
      if (new_services.length > 0) {
        this.tree.nodes({children: new_services});
      }
      // enter
      node
        .enter().append('g')
        .attr({
            'pointer-events': 'all', // IE doesn't drag properly without this.
            'class': function(d) {
              return (d.subordinate ? 'subordinate ' : '') +
                  (d.pending ? 'pending ' : '') + 'service';
            },
            'transform': function(d) {return d.translateStr;}})
        .call(this.dragBehavior)
        .call(self.createServiceNode, self);

      // Update all nodes.
      self.updateServiceNodes(node);

      // Remove old nodes.
      node.exit()
          .each(function(d) {
            delete topo.service_boxes[d.id];
          })
          .remove();
    },

    /**
     * Get a d3 selected node for a given service by id.
     *
     * @method getServiceNode
     * @return  {d3.selection} selection || null.
     */
    getServiceNode: function(id) {
      if (this.node === undefined) {
        return null;
      }
      var node = this.node.filter(function(d, i) {
        return d.id === id;
      });
      return node && node[0][0] || null;
    },

    /**
     * Fill a service node with empty structures that will be filled out
     * in the update stage.
     *
     * @param {object} node the node to construct.
     * @param {object} self reference to the view instance.
     * @return {null} side effects only.
     * @method createServiceNode
     */
    createServiceNode: function(node, self) {
      node.append('image')
        .attr('class', 'service-block-image');

      node.append('text').append('tspan')
        .attr('class', 'name')
        .text(function(d) {return d.displayName; });

      node.append('text').append('tspan')
        .attr({'class': 'charm-label',
            'dy': '3em'})
        .text(function(d) { return d.charm; });

      // Append status charts to service nodes.
      var status_chart = node.append('g')
        .attr('class', 'service-status');

      // Add a mask svg
      status_chart.append('image')
        .attr({'xlink:href': '/juju-ui/assets/svgs/service_health_mask.svg',
            'class': 'service-health-mask'});

      // Add the unit counts, visible only on hover.
      status_chart.append('text')
        .attr('class', 'unit-count hide-count');

      // Manually attach the touchstart event (see method for details)
      node.each(function(data) {
        self.attachTouchstartEvents(data, this);
      });
    },

    /**
     * Fill the empty structures within a service node such that they
     * match the db.
     *
     * @param {object} node the collection of nodes to update.
     * @return {null} side effects only.
     * @method updateServiceNodes
     */
    updateServiceNodes: function(node) {
      var self = this,
          topo = this.get('component'),
          landscape = topo.get('landscape'),
          service_scale = this.service_scale,
          service_scale_width = this.service_scale_width,
          service_scale_height = this.service_scale_height;

      // Apply Position Annotations
      // This is done after the services_boxes
      // binding as the event handler will
      // use that index.
      node.each(function(d) {
        var service = d.model,
            annotations = service.get('annotations'),
            x, y;

        if (!annotations) {return;}

        // If there are x/y annotations on the service model and they are
        // different from the node's current x/y coordinates, update the
        // node, as the annotations may have been set in another session.
        x = annotations['gui-x'],
        y = annotations['gui-y'];
        if (!d ||
            (x !== undefined && x !== d.x) &&
            (y !== undefined && y !== d.y)) {
          // Delete gui-x and gui-y from annotations as we use the values.
          // This is to prevent deltas coming in on a service while it is
          // being dragged from resetting its position during the drag.
          delete annotations['gui-x'];
          delete annotations['gui-y'];
          // Only update position if we're not already in a drag state (the
          // current drag supercedes any previous annotations).
          if (!d.inDrag) {
            self.drag.call(this, d, self, {x: x, y: y});
          }
        }});

      // Mark subordinates as such.  This is needed for when a new service
      // is created.
      node.filter(function(d) {
        return d.subordinate;
      })
        .classed('subordinate', true);

      // 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;},
      'height': function(d) {
        var h = service_scale(d.unit_count || 1);
        d.h = h;
        return h;
      }
      });
      node.select('.service-block-image')
        .attr({'xlink:href': function(d) {
            return d.subordinate ?
                '/juju-ui/assets/svgs/sub_module.svg' :
                '/juju-ui/assets/svgs/service_module.svg';
          },
          'width': function(d) { return d.w; },
          'height': function(d) { return d.h; }
          });

      // Draw a subordinate relation indicator.
      var subRelationIndicator = node.filter(function(d) {
        return d.subordinate &&
            d3.select(this)
                  .select('.sub-rel-block').empty();
      })
        .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] + ')';
          });

      subRelationIndicator.append('image')
        .attr({'xlink:href': '/juju-ui/assets/svgs/sub_relation.svg',
            'width': 87,
            'height': 47});
      subRelationIndicator.append('text').append('tspan')
        .attr({'class': 'sub-rel-count',
            'x': 64,
            'y': 47 * 0.8});

      // Landscape badge
      if (landscape) {
        // Remove any existing badge.
        node.select('.landscape-badge').remove();
        node.each(function(d) {
          var landscapeAsset;
          var securityBadge = landscape.getLandscapeBadge(
              d.model, 'security', 'round');
          var rebootBadge = landscape.getLandscapeBadge(
              d.model, 'reboot', 'round');

          if (securityBadge && rebootBadge) {
            landscapeAsset =
                '/juju-ui/assets/images/landscape_restart_round.png';
          } else if (securityBadge) {
            landscapeAsset =
                '/juju-ui/assets/images/landscape_security_round.png';
          } else if (rebootBadge) {
            landscapeAsset =
                '/juju-ui/assets/images/landscape_restart_round.png';
          }
          if (landscapeAsset) {
            d3.select(this).append('image')
            .attr({'xlink:href': landscapeAsset,
                  'class': 'landscape-badge',
                  'width': 30,
                  'height': 30,
                  'x': function(box) {return box.w * 0.13;},
                  'y': function(box) { return box.h / 2 - 30;}
                });
          }
        });
      }
      // 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;

      node.select('.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';
          },
          'x': function(d) { return d.w / 2; },
          '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;
          }
          });
      node.select('.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';
          },
          'x': function(d) { return d.w / 2;},
          '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;
          }
          });

      // Show whether or not the service is exposed using an indicator.
      node.filter(function(d) {
        return d.exposed;
      })
        .append('image')
        .attr({'class': 'exposed-indicator on',
            'xlink:href': '/juju-ui/assets/svgs/exposed.svg',
            'width': function(d) { return d.w / 6;},
            'height': function(d) { return d.w / 6;},
            'x': function(d) { return d.w / 10 * 7;},
            'y': function(d) { return d.relativeCenter[1] - (d.w / 6) / 2;}
          })
        .append('title')
        .text(function(d) {
            return d.exposed ? 'Exposed' : '';
          });

      // Remove exposed indicator from nodes that are no longer exposed.
      node.filter(function(d) {
        return !d.exposed &&
            !d3.select(this)
                .select('.exposed-indicator').empty();
      }).select('.exposed-indicator').remove();

      // 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 with a bit
            // of leeway for the border.
            var outerRadius = parseInt(
                d3.select(this.parentNode)
                  .select('.service-health-mask')
                  .attr('width'), 10) / 2.05;

            // NB: although this causes a calculation function to have
            // side effects, it does allow us to test that the health
            // graph was sized properly by accessing this attribute.
            d3.select(this.parentNode)
              .attr('data-outerradius', outerRadius);
            return outerRadius;
          });

      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];
          });

      node.select('.service-status')
        .attr('transform', function(d) {
            return 'translate(' + d.relativeCenter + ')';
          });
      node.select('.service-health-mask')
        .attr({'width': function(d) {return d.w / 3;},
            'height': function(d) { return d.h / 3;},
            'x': function() { return -d3.select(this).attr('width') / 2;},
            'y': function() { return -d3.select(this).attr('height') / 2;}
          });

      // Remove the path object as the data bound to it will cause some
      // updates to fail because the test in enter() will not pass.
      node.select('.service-status')
        .selectAll('path')
        .remove();

      // Add the path after the mask image (since it requires the mask's
      // width to set its own).
      node.select('.service-status')
        .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,
            'class': function(d) { return 'status-' + d.data.name;},
            'fill-rule': 'evenodd'})
        .append('title').text(function(d) {
            return d.data.name;
          });

      node.select('.unit-count')
        .text(function(d) {
            return utils.humanizeNumber(d.unit_count);
          });
    },


    /*
     * Show/hide/fade selection.
     */
    show: function(evt) {
      var selection = evt.selection;
      selection.attr('opacity', '1.0')
                .style('display', 'block');
    },

    hide: function(evt) {
      var selection = evt.selection;
      selection.attr('opacity', '0')
            .style('display', 'none');
    },

    fade: function(evt) {
      var selection = evt.selection,
          alpha = evt.alpha;
      selection.transition()
            .duration(400)
            .attr('opacity', alpha !== undefined && alpha || '0.2');
    },

    /**
     * The user clicked on the environment view background.
     *
     * If we are in the middle of adding a relation, cancel the relation
     * adding.
     *
     * @method backgroundClicked
     * @return {undefined} Side effects only.
     */
    backgroundClicked: function() {
      var topo = this.get('component');
      topo.fire('clearState');
    },

    updateServiceMenuLocation: function() {
      var topo = this.get('component'),
          container = this.get('container'),
          cp = container.one('.environment-menu.active'),
          service = topo.get('active_service'),
          tr = topo.get('translate'),
          z = topo.get('scale');

      if (service && cp) {
        var cp_width = cp.getDOMNode().getClientRects()[0].width,
            menu_left = (service.x * z + service.w * z / 2 <
                         topo.get('width') * z / 2),
            service_center = service.relativeCenter;

        if (menu_left) {
          cp.removeClass('left')
            .addClass('right');
        } else {
          cp.removeClass('right')
            .addClass('left');
        }
        // Set the position of the div in the following way:
        // top: aligned to the scaled/panned service minus the
        //   location of the tip of the arrow (68px down the menu,
        //   via css) such that the arrow always points at the service.
        // left: aligned to the scaled/panned service; if the
        //   service is left of the midline, display it to the
        //   right, and vice versa.
        cp.setStyles({
          'top': service.y * z + tr[1] + (service_center[1] * z) - 68,
          'left': service.x * z +
              (menu_left ? service.w * z : -(cp_width)) + tr[0]
        });
      }
    },

    /**
     * Show (if hidden) or hide (if shown) the service menu.
     *
     * @method toggleServiceMenu
     * @param {object} box The presentation state for the service.
     * @return {undefined} Side effects only.
     */
    toggleServiceMenu: function(box) {
      var serviceMenu = this.get('container').one('#service-menu');

      if (serviceMenu.hasClass('active') || !box) {
        this.hideServiceMenu();
      } else {
        this.showServiceMenu(box);
      }
    },

    /**
     * Show the service menu.
     *
     * @method showServiceMenu
     * @param {object} box The presentation state for the service.
     * @return {undefined} Side effects only.
     */
    showServiceMenu: function(box) {
      var serviceMenu = this.get('container').one('#service-menu');
      var topo = this.get('component');
      var service = box.model;
      var landscape = topo.get('landscape');
      var landscapeReboot = serviceMenu.one('.landscape-reboot').hide();
      var landscapeSecurity = serviceMenu.one('.landscape-security').hide();
      var securityURL, rebootURL;

      // Update landscape links and show/hide as needed.
      if (landscape) {
        rebootURL = landscape.getLandscapeURL(service, 'reboot');
        securityURL = landscape.getLandscapeURL(service, 'security');

        if (rebootURL && service['landscape-needs-reboot']) {
          landscapeReboot.show().one('a').set('href', rebootURL);
        }
        if (securityURL && service['landscape-security-upgrades']) {
          landscapeSecurity.show().one('a').set('href', securityURL);
        }
      }

      if (box && !serviceMenu.hasClass('active')) {
        topo.set('active_service', box);
        topo.set('active_context', box.node);
        serviceMenu.addClass('active');
        // We do not want the user destroying the Juju GUI service.
        if (utils.isGuiService(service)) {
          serviceMenu.one('.destroy-service').addClass('disabled');
        }
        this.updateServiceMenuLocation();
      }
    },

    /**
     * Hide the service menu.
     *
     * @method hideServiceMenu
     * @param {object} box The presentation state for the service (unused).
     * @return {undefined} Side effects only.
     */
    hideServiceMenu: function(box) {
      var serviceMenu = this.get('container').one('#service-menu');
      var topo = this.get('component');

      if (serviceMenu.hasClass('active')) {
        serviceMenu.removeClass('active');
        topo.set('active_service', null);
        topo.set('active_context', null);
        // Most services can be destroyed via the GUI.
        serviceMenu.one('.destroy-service').removeClass('disabled');
      }
    },

    /*
     * View a service
     *
     * @method show_service
     */
    show_service: function(service) {
      var topo = this.get('component');
      var nsRouter = topo.get('nsRouter');
      var getModelURL = topo.get('getModelURL');

      topo.detachContainer();
      topo.fire('navigateTo', {
        url: nsRouter.url({gui: getModelURL(service)})
      });
    },

    /*
     * Show a dialog before destroying a service
     *
     * @method destroyServiceConfirm
     */
    destroyServiceConfirm: function(box) {
      // Set service in view.
      this.set('destroy_service', box.model);

      // Show dialog.
      this.set('destroy_dialog', views.createModalPanel(
          'Are you sure you want to destroy the service? ' +
          'This cannot be undone.',
          '#destroy-modal-panel',
          'Destroy Service',
          Y.bind(function(ev) {
            ev.preventDefault();
            var btn = ev.target;
            btn.set('disabled', true);
            this.destroyService(btn);
          }, this)));
    },

    /*
     * Destroy a service.
     *
     * @method destroyService
     */
    destroyService: function(btn) {
      var env = this.get('component').get('env'),
          service = this.get('destroy_service');
      env.destroy_service(service.get('id'),
                          Y.bind(this._destroyCallback, this,
                                 service, btn));
    },

    _destroyCallback: function(service, btn, ev) {
      var getModelURL = this.get('component').get('getModelURL'),
          topo = this.get('component'),
          db = topo.get('db');
      if (ev.err) {
        db.notifications.add(
            new models.Notification({
              title: 'Error destroying service',
              message: 'Service name: ' + ev.service_name,
              level: 'error',
              link: getModelURL(service),
              modelId: service
            }));
      } else {
        var relations = db.relations.get_relations_for_service(service);
        Y.each(relations, function(relation) {
          relation.destroy();
        });
        service.destroy();
        topo.update();
      }
      this.get('destroy_dialog').hide();
      btn.set('disabled', false);
    }
  }, {
    ATTRS: {}
  });

  views.ServiceModule = ServiceModule;

}, '0.1.0', {
  requires: [
    'd3',
    'd3-components',
    'juju-templates',
    'juju-models',
    'juju-env'
  ]
});