Fork me on GitHub
Show:

File: ../src/maps/animations/helpers/timelayersfactory.js

define([
  'aeris/util'
], function(_) {
  /**
   * Helper class for creating a collection of layers
   * from an array of timestamps
   *
   * @class aeris.maps.animations.helpers.TimeLayersFactory
   *
   * @constructor
   * @override
   *
   * @param {aeris.maps.layers.AerisTile} baseLayer
   * @param {Array.<number>} times Timestamps.
   *
   * @param {aeris.maps.animations.options.AnimationOptions=} opt_options
   */
  var TimeLayersFactory = function(baseLayer, times, opt_options) {
    var options = _.defaults(opt_options || {}, {
      from: null,
      to: null,
      limit: null
    });


    /**
     * @type {?Date}
     * @private
     * @property from_
     */
    this.from_ = options.from;


    /**
     * @type {?Date}
     * @private
     * @property to_
     */
    this.to_ = options.to;


    /**
     * @type {number}
     * @private
     * @property limit_
     */
    this.limit_ = options.limit;


    /**
     * @type {aeris.maps.layers.AerisTile}
     * @private
     * @property baseLayer_
     */
    this.baseLayer_ = baseLayer;


    /**
     * A hash of layers for times.
     * @type {Object.<number,aeris.maps.layers.AerisTile>}
     * @private
     */
    this.timeLayers_ = {};


    /**
     * @type {Array.<number>}
     * @private
     * @property times_
     */
    this.times_ = [];

    if (times) {
      this.setTimes(times);
    }
  };


  /**
   * @param {Array.<number>} times
   * @method setTimes
   */
  TimeLayersFactory.prototype.setTimes = function(times) {
    var removedTimes = _.difference(this.times_, times);

    // Clean up removed times
    removedTimes.forEach(this.removeTime_, this);

    this.times_ = times;
  };


  /**
   * @method removeTime_
   * @private
   */
  TimeLayersFactory.prototype.removeTime_ = function(time) {
    this.times_ = _.without(this.times_, time);

    // Clean the layer we made for this time
    if (this.timeLayers_[time]) {
      this.timeLayers_[time].destroy();
      delete this.timeLayers_[time];
    }
  };


  /**
   * @method setFrom
   * @param {number} from Timestamp.
   */
  TimeLayersFactory.prototype.setFrom = function(from) {
    this.from_ = from;
  };


  /**
   * @method setTo
   * @param {number} to Timestamp.
   */
  TimeLayersFactory.prototype.setTo = function(to) {
    this.to_ = to;
  };


  /**
   * @method setLimit
   * @param {number} limit
   */
  TimeLayersFactory.prototype.setLimit = function(limit) {
    this.limit_ = limit;
  };


  /**
   * @return {Object.<number,aeris.maps.layer.AerisTile>} A hash of timestamps to layers.
   * @method createTimeLayers
   */
  TimeLayersFactory.prototype.createTimeLayers = function() {
    this.resetTimeLayers_();
    this.prepareTimes_();

    // Make sure we have at least one time layer.
    if (!this.times_.length) {
      this.timeLayers_[Date.now()] = this.createLayerForTime_(this.baseLayer_.get('time').getTime());
      return this.timeLayers_;
    }

    // We want random times for layer creation
    // So our layers are not loaded in sequential
    // chronological order (faster perceived load time).
    this.shuffleTimes_();

    _.each(this.times_, function(time) {
      this.timeLayers_[time] = this.createLayerForTime_(time);
    }, this);

    // Make sure times are sorted
    this.sortTimes_();

    return this.timeLayers_;
  };


  /**
   * @method resetTimeLayers_
   * @private
   */
  TimeLayersFactory.prototype.resetTimeLayers_ = function() {
    _.each(this.timeLayers_, function(layer, time) {
      this.removeTime_(time);
    }, this);
  };


  /**
   * @private
   * @method prepareTimes_
   */
  TimeLayersFactory.prototype.prepareTimes_ = function() {
    this.ensureTimeBoundsOptions_();
    this.constrainTimes_(this.from_, this.to_, true);

    if (this.limit_) {
      this.thinTimes_(this.limit_);
    }
  };


  /**
   * @param {number} time
   * @return {aeris.maps.layers.AerisTile}
   * @private
   * @method createLayerForTime_
   */
  TimeLayersFactory.prototype.createLayerForTime_ = function(time) {
    return this.baseLayer_.clone({
      time: new Date(time),
      map: null,
      autoUpdate: false
    });
  };


  /**
   * @private
   * @method ensureTimeBoundsOptions_
   */
  TimeLayersFactory.prototype.ensureTimeBoundsOptions_ = function() {
    this.from_ || (this.from_ = Math.min.apply(null, this.times_));
    this.to_ || (this.to_ = Math.max.apply(null, this.times_));
  };


  /**
   * @param {number} minTime
   * @param {number} maxTime
   * @private
   * @method constrainTimes_
   */
  TimeLayersFactory.prototype.constrainTimes_ = function(minTime, maxTime) {
    this.times_.forEach(function(time) {
      var isOutOfBounds = time < minTime || time > maxTime;

      if (isOutOfBounds) {
        this.removeTime_(time);
      }
    }, this);
  };


  /**
   * @param {number} limit
   * @private
   * @method thinTimes_
   */
  TimeLayersFactory.prototype.thinTimes_ = function(limit) {
    var isTimeAlreadyAdded;
    var latestTime, earliestTime, mostCurrentTime, closestTime;
    var step, thinnedTimes;

    if (this.times_.length <= limit) {
      return;
    }

    earliestTime = Math.min.apply(Math, this.times_);
    latestTime = Math.max.apply(Math, this.times_);
    mostCurrentTime = this.getClosestTime_(Date.now());

    // Always include earliest, latest, and most-current times.
    thinnedTimes = _.uniq([earliestTime, mostCurrentTime, latestTime]);

    // Add times at regular time intervals
    // Example:
    //  - We start with [0, 200, 1024]
    //  - We add [0, 512, 1024] --> [0, 200, 512, 1024]
    //  - We add [0, 256, 512, 1024] --> [0, 200, 256, 512, 1024]
    //  etc.. until we reach our limit.

    step = (latestTime - earliestTime) / 2;
    while (thinnedTimes.length < limit && step >= 1) {
      var timesToAdd = [];
      var amountUnderLimit = limit - thinnedTimes.length;
      var time = earliestTime;

      // Grab times at every `step` interval
      while (time < latestTime) {
        time += step;

        closestTime = this.getClosestTime_(time);
        isTimeAlreadyAdded = _.contains(thinnedTimes, closestTime) || _.contains(timesToAdd, closestTime);

        if (!isTimeAlreadyAdded) {
          timesToAdd.push(closestTime);
        }
      }

      // Make sure we're not going over the limit
      if (timesToAdd.length > amountUnderLimit) {
        timesToAdd.length = amountUnderLimit;
      }

      // Add the new set of times
      thinnedTimes.push.apply(thinnedTimes, timesToAdd);

      // Use a smaller interval for the next iteration
      step = Math.floor(step / 2);
    }

    this.setTimes(thinnedTimes);
  };

  // TODO: move into util function,
  // to share with TileAnimation
  // Or, maybe all of the layer/time collection logic
  // should be moved into a helper/base-class/mixin
  // Then TileAnimation would call
  // this.myHelper_.getTileForTime(time), instead of
  // having to handle all that logic itself.
  // Or, maybe TimeLayerCollection should be it's own class,
  // and it should handle all of that logic.
  /**
   * @method getClosestTime_
   *
   * @param {number} targetTime
   * @return {number}
   * @private
   */
  TimeLayersFactory.prototype.getClosestTime_ = function(targetTime) {
    var closest = this.times_[0];
    var diff = Math.abs(targetTime - closest);

    _.each(this.times_, function(time) {
      var newDiff = Math.abs(targetTime - time);
      if (newDiff < diff) {
        diff = newDiff;
        closest = time;
      }
    }, this);

    return closest;
  };


  /**
   * @private
   * @method shuffleTimes_
   */
  TimeLayersFactory.prototype.shuffleTimes_ = function() {
    this.times_ = _.sample(this.times_, this.times_.length);
  };


  /**
   * @private
   * @method sortTimes_
   */
  TimeLayersFactory.prototype.sortTimes_ = function() {
    this.times_ = this.getOrderedTimes();
  };


  /**
   * @return {Array.<number>} Timestamps in chronological order.
   * @method getOrderedTimes
   */
  TimeLayersFactory.prototype.getOrderedTimes = function() {
    return this.times_.sort(function(a, b) {
      return a > b ? 1 : -1;
    });
  };


  /**
   * @method destroy
   */
  TimeLayersFactory.prototype.destroy = function() {
    this.times_.length = 0;
  };


  return TimeLayersFactory;
});