Source: AppChartController.js

/**
 * Exposes a controller to be used with AngularJS directives in order to draw
 * a visualization graph using the AppChart class & D3.js. The controller
 * takes care about feeding the angular.js variables from the directive into
 * the AppChart object and modifying them when interacting with the chart.
 * @module AppChartController
 * @exports AppChartController
 */
define(["AppChart"], function(AppChart) {

  /**
   * Sets up a controller to glue together the generic AppChart chart drawing
   * component with an AngularJS directive & scope. The directive that uses this
   * controller must bind the following scope variables to the controller: <ul>
   *   <li>highlights - the highlights object representing the internal
   *     visualization status</li>
   *   <li>data - the data object to visualize</li>
   * </ul>
   * @constructor
   * @param $scope the AngularJS scope of the directive where the chart should
   *   be placed.
   * @param $element the DOM element of the directive
   * @param ctrlName the name of the controller in the angularJS scope
   */
  AppChartController = function($scope, $element, ctrlName) {
    // the container object for AppChart is the first child of the directive
    this.container = $element.children().first()[0];
    // memorize scope since some callbacks will use $scope.$apply(...)
    this.$scope = $scope;
    // attach handlers to "highlights" object
    n = ctrlName;
    $scope.$watch(n + ".highlights.x", this.highlightsUpdatedHandler());
    $scope.$watch(n + ".highlights.hotspots", this.highlightsUpdatedHandler());
    $scope.$watch(n + ".highlights.seriesId", this.highlightsUpdatedHandler());
  };

  /**
   * Returns the factory function of the controller. The factory is used in
   * the directives .controller property. You have to specify some "options"
   * to the factory. The factory callback returned will accept exactly two
   * parameters: $scope and $element.
   * @static
   * @param ctrlName the name of the controller object in the AngularJS scope
   */
  AppChartController.getFactory = function(ctrlName) {
    return function($scope, $element) {
      return new AppChartController($scope, $element, ctrlName);
    };
  };

  /**
   * This function must be called when the directive using this controller links
   * the directive to a DOM element. It takes care of actually drawing the
   * chart into the DOM element.
   * @param chartOptions the chart options to create the AppChart with
   * @param dataTransformer the data transform function to apply to the data
   *   before the chart is drawn
   */
  AppChartController.prototype.link = function(dataTransformer, chartOptions) {
    this.chartOptions = chartOptions;
    this.dataTransformer = dataTransformer;
    // define chart options based on supplied data
    // create AppChart object and wire callbacks from controller to it
    this.chart = new AppChart(this.container, this.chartOptions);
    this.chart.attachSeriesHighlightedHandler(
        this.seriesHighlightedHandler());
    this.chart.attachSeriesUnhighlightedHandler(
        this.seriesUnhighlightedHandler());
    this.chart.attachMouseLeaveHandler(
        this.xyUnhighlightedHandler());
    this.chart.attachMouseMovedHandler(
        this.xyHighlightedHandler());
    this.chart.attachZoomedPannedHandler(
        this.zoomedPannedHandler());
    // initialize highlights, bind data, rescale chart and redraw chart
    this.highlightsUpdatedHandler()();
    this.dataUpdated();
    this.fitChart();
    this.chart.draw();
  };

  /**
   * To be called if the data property of the associated data object in the
   * directive / controller is changed or initialized.
   */
  AppChartController.prototype.dataUpdated = function() {
    if (this.chart)
      this.chart.bind(this.data, this.dataTransformer);
  };

  /**
   * Returns a callback that can be invoked if the highlights object supplied
   * to the directive / controller is changed. Will delegate the highlight
   * change to the included AppChart object.
   */
  AppChartController.prototype.highlightsUpdatedHandler = function() {
    var self = this;
    return function() {
      if (self.chart) {
        self.chart.highlight({
          x: self.highlights.x,
          y: self.highlights.y,
          seriesId: self.highlights.seriesId,
          hotspots: self.highlights.hotspots
        });
      }
    }
  };

  /**
   * Initializes the display area (x- and y-axis domain) of the chart based
   * on whether the highlights object specifies an area the chart should
   * navigate to via the xRange property and base on whether the highlight
   * object's "looseness" property dictates that the chart should be re-fit
   * to the maximum viewable area.
   */
  AppChartController.prototype.fitChart = function() {
    var minX = undefined, maxX = undefined,
        minY = undefined, maxY = undefined,
        minXRange = undefined, maxXRange = undefined;
    // get the x-axis and y-axis extent of the data with a padding of 20% above
    // and below the maximum value
    var extentX = this.chart.getXExtent(0.2),
        extentY = this.chart.getYExtent(0.2);
    // if the highlights object speicifies an area, start with this
    if (this.highlights.xRange) {
      minXRange = this.highlights.xRange[0];
      maxXRange = this.highlights.xRange[1];
    }
    // check if we do have a valid range now based on the highlights object
    if (minXRange !== undefined && maxXRange !== undefined) {
      minX = minXRange;
      maxX = maxXRange;
      // if we also have looseness > 1, that is, we should NOT re-fit the chart
      // based on the data values extent unless no highlights.xRange property is
      // specified with either the minX or maxX extent
      if (this.highlights.looseness > 1) {
        if (minX === undefined || extentX[0] < minX)
          this.highlights.xRange[0] = minX = extentX[0];
        if (maxX === undefined || extentY[1] > maxX)
          this.highlights.xRange[1] = maxX = extentX[1];
      }
    } else {
      // if we do NOT have a valid range based on highlights, just use the
      // data extent
      minX = extentX[0];
      maxX = extentX[1];
    }
    // if values are constant along Y axis, we add a padding of +1/-1
    if (extentY[0] == extentY[1])
      this.chart.dimensions(minX, maxX, extentY[0]-1, extentY[1]+1);
    else
      this.chart.dimensions(minX, maxX, extentY[0], extentY[1]);
  };

  /**
   * Returns a callback that should be invoked every time the highlights.xRange
   * value is changed. Will take care that all charts scroll uniformely along
   * the x axis.
   */
  AppChartController.prototype.xRangeUpdatedHandler = function() {
    var self = this;
    return function() {
      if (self.highlights.xRange) {
        minX = self.highlights.xRange[0];
        maxX = self.highlights.xRange[1];
        self.chart.dimensions(minX, maxX, undefined, undefined);
      }
    }
  };

  /**
   * Returns a callback that should be invoked any time a series is highlighted.
   * The callback accepts exactly one parameter: the seriesId. Will take care
   * that the highlights object (seriesId and looseness) is updated accordingly.
   */
  AppChartController.prototype.seriesHighlightedHandler = function() {
    var self = this;
    return function(seriesId) {
      self.$scope.$apply(function() {
        self.highlights.seriesId = seriesId;
        if (self.highlights.looseness < 1)
          self.highlights.looseness = 1;
      });
    };
  };

  /**
  * Returns a callback that should be invoked any time a series is
  * unhighlighted. The callback accepts exactly one parameter: the seriesId.
  * Will take care that the highlights object (seriesId and looseness) is
  * updated accordingly.
  */
  AppChartController.prototype.seriesUnhighlightedHandler = function() {
    var self = this;
    return function(seriesId) {
      self.$scope.$apply(function() {
        self.highlights.seriesId = false;
        if (self.highlights.looseness < 1)
          self.highlights.looseness = 1;
      });
    };
  };

  /**
   * Returns a callback that should be invoked any time the x/y extent changes
   * because the user zooms or pans in the graph. Updates the highlights object
   * (xRange property) accordingly.
   */
  AppChartController.prototype.zoomedPannedHandler = function() {
    var self = this;
    return function(minX, maxX, minY, maxY) {
      self.$scope.$apply(function() {
        self.highlights.xRange = [minX, maxX];
      });
    };
  };

  /**
   * Returns a callback that should be invoked any time a x/y value in the
   * graph is highlighted (effectively, any time the mouse moves). Callback
   * will accept a x and y domain coordinate as its 2 parameters and update
   * highlights.x and highlights.y accordingly.
   */
  AppChartController.prototype.xyHighlightedHandler = function() {
    var self = this;
    return function(x, y) {
      self.$scope.$apply(function() {
          self.highlights.x = x;
          self.highlights.y = y;
          if (self.highlights.looseness < 1)
            self.highlights.looseness = 1;
        });
    };
  };

  /**
   * Returns a callback that should be invoked any time the mouse leaves the
   * graph area or no point at all is highlighted in the graph.
   */
  AppChartController.prototype.xyUnhighlightedHandler = function() {
    var self = this;
    return function() {
      self.$scope.$apply(function() {
          self.highlights.x = false;
          self.highlights.seriesesValues = {};
          if (self.highlights.looseness < 1)
            self.highlights.looseness = 1;
        });
    };
  };

  /**
   * Returns a callback that should be invoked any time the "looseness" value
   * of the highlights object is changed. The "looseness" value specifies
   * which amount of "guiding" the UI should enforce (looseness = 0:
   * storytelling mode, looseness 1 = storytelling mode with user
   * starting to explore the graph, looseness = 2: user has collapsed the
   * storytelling, looseness = 3: user has completely 'gone off on its own' and
   * selected other metrics, zoomed/panned in the graph etc.)
   */
  AppChartController.prototype.loosenessUpdatedHandler = function() {
    var self = this;
    return function() {
      if (self.highlights.looseness == 2) {
        self.highlights.xRange = [undefined, undefined];
        self.fitChart();
      }
    }
  };

  return AppChartController;
});