Fork me on GitHub
Show:

File: ../src/maps/animations/abstractanimation.js

define([
  'aeris/util',
  'aeris/maps/animations/animationinterface',
  'aeris/errors/invalidargumenterror'
], function(_, AnimationInterface, InvalidArgumentError) {
  /**
   * A partially implemented {aeris.maps.animations.AnimationInterface}.
   *
   * @param {Object} opt_options
   * @param {Date} opt_options.from Starting time for the animation.
   * @param {Date} opt_options.to Ending time for the animation.
   * @param {number=} opt_options.limit Maximimum number of time intervals to load.
   * @param {number} opt_options.timestep
   * @param {number} opt_options.speed Number of minutes of weather data
   *        to display within a second.
   * @param {number} opt_options.endDelay Milliseconds to pause between animation loops.
   *
   * @constructor
   * @class aeris.maps.animations.AbstractAnimation
   * @extends aeris.maps.animations.AnimationInterface
   */
  var AbstractAnimation = function(opt_options) {
    var options = _.defaults(opt_options || {}, {
      from: _.now() - (1000 * 60 * 60), // one hour ago
      to: _.now(),
      limit: 20,
      speed: 30,
      timestep: 1000 * 60,
      endDelay: 1000
    });

    /**
     * Number of minutes of weather data
     * to display within a second.
     *
     * @type {number}
     * @private
     * @property speed_
     */
    this.speed_ = options.speed;


    /**
     * Milliseconds between animation frames.
     *
     * @type {number}
     * @private
     * @property timestep_
     */
    this.timestep_ = options.timestep;

    /**
     * Time to wait before repeating animation loop.
     * @type {number} Milliseconds.
     * @private
     * @property endDelay_
     */
    this.endDelay_ = options.endDelay;


    /**
     * Animation start time.
     *
     * @type {Date}
     * @protected
     * @property from_
     */
    this.from_ = options.from;


    /**
     * Animation end time.
     *
     * @default Current time.
     * @type {Date}
     * @protected
     * @property to_
     */
    this.to_ = options.to;


    /**
     * Max number of time "frames"
     * to load and render.
     *
     * @type {number}
     * @protected
     * @property limit_
     */
    this.limit_ = options.limit;


    this.normalizeTimeBounds_();


    /**
     * The time of the current animation frame.
     *
     * @type {number} Timestamp
     * @private
     * @property currentTime_
     */
    this.currentTime_ = Date.now();


    /**
     * A reference to the timer created
     * by window.setInterval
     * @type {number}
     * @private
     * @property animationClock_
     */
    this.animationClock_ = null;


    AnimationInterface.call(this);

    this.keepCurrentTimeInBounds_();
  };

  _.inherits(AbstractAnimation, AnimationInterface);


  /**
   * Start animating the layer.
   *
   * Every second, the layer is animated up
   * by timestep * speed milliseconds.
   * @method start
   */
  AbstractAnimation.prototype.start = function() {
    // Because calling goToTime every second would be
    // clunky, we use a shorter interval time, than
    // adjust our animation increment accordingly.
    var tickInterval = 25;

    if (this.isAnimating()) {
      return;
    }


    // Prevents using endDelay, if we're starting from
    // the end.
    if (this.currentTime_ === this.to_) {
      this.goToTime(this.from_);
    }

    var isEndDelaying = false;
    this.animationClock_ = _.interval(function() {
      var multiplier = tickInterval / 1000;
      var timeIncrement = this.timestep_ * this.speed_ * multiplier;
      var nextTime = this.currentTime_ + timeIncrement;

      // If we're at the end, restart animation
      if (isEndDelaying) {
        return;
      }
      else if (nextTime > this.to_) {
        isEndDelaying = true;
        setTimeout(function() {
          isEndDelaying = false;
          if (this.isAnimating()) {
            this.goToTime(this.from_);
          }
        }.bind(this), this.endDelay_);
      }
      else {
        this.goToTime(nextTime);
      }
    }, tickInterval, this);
  };


  /**
   * @method normalizeTimeBounds_
   * @private
   */
  AbstractAnimation.prototype.normalizeTimeBounds_ = function() {
    if (_.isDate(this.from_)) {
      this.from_ = this.from_.getTime();
    }
    if (_.isDate(this.to_)) {
      this.to_ = this.to_.getTime();
    }
  };


  /**
   * Makes sure that the current time is always within
   * the `from` and `to` bounds of the animation.
   *
   * @method keepCurrentTimeInBounds_
   * @private
   */
  AbstractAnimation.prototype.keepCurrentTimeInBounds_ = function() {
    this.listenTo(this, {
      'change:to': function() {
        if (this.getCurrentTime() > this.getTo()) {
          this.goToTime(this.getTo());
        }
      },
      'change:from': function() {
        if (this.getCurrentTime() < this.getFrom()) {
          this.goToTime(this.getFrom());
        }
      }
    });
  };


  /**
   * Sets the current time to the specified time.
   *
   * Classes extending {aeris.maps.animations.AbstractAnimation}
   * should probably do something more useful here.
   *
   * @param {Date} time
   * @method goToTime
   */
  AbstractAnimation.prototype.goToTime = function(time) {
    this.currentTime_ = _.isDate(time) ? time.getTime() : time;
  };


  /**
   * @method getCurrentTime
   * @return {?Date}
   */
  AbstractAnimation.prototype.getCurrentTime = function() {
    return new Date(this.currentTime_);
  };


  /**
   * Stop animating the layer,
   * and return to the most recent frame
   * @method stop
   */
  AbstractAnimation.prototype.stop = function() {
    this.pause();

    if (!_.isNull(this.to_)) {
      this.goToTime(this.to_);
    }
  };


  /**
   * Stop animation the layer,
   * and stay at the current frame.
   * @method pause
   */
  AbstractAnimation.prototype.pause = function() {
    window.clearInterval(this.animationClock_);
    this.animationClock_ = null;
  };


  /**
   * Set the animation speed.
   *
   * Every second, [timestep] * [speed] milliseconds
   * of tiles are animated.
   *
   * So with a timestep of 360,000 (6 minutes), and a speed of 2:
   *  every second, 12 minutes of tiles will be animated.
   *
   * Setting a negative speed will cause the animation to run in reverse.
   *
   * Also see {aeris.maps.animations.AbstractAnimation}#setTimestamp
   *
   * @param {number} speed
   * @method setSpeed
   */
  AbstractAnimation.prototype.setSpeed = function(speed) {
    if (speed === this.speed_) {
      return;
    }

    if (!_.isNumber(speed)) {
      throw new InvalidArgumentError(speed + ' is not a valid animation speed.');
    }

    this.speed_ = speed;

    if (this.isAnimating()) {
      this.pause();
      this.start();
    }
  };


  /**
   * Sets the animation timestep.
   *
   * See {aeris.maps.animations.AbstractAnimation}#setSpeed
   * for more information on how
   * to use setTimestep and setSpeed to affect your animation speed.
   *
   * @param {number} timestep Timestep, in milliseconds.
   * @method setTimestep
   */
  AbstractAnimation.prototype.setTimestep = function(timestep) {
    if (timestep === this.timestep_) {
      return;
    }

    this.timestep_ = timestep;

    if (this.isAnimating()) {
      this.pause();
      this.start();
    }
  };


  /**
   * @method setFrom
   * @param {Date|number} from
   */
  AbstractAnimation.prototype.setFrom = function(from) {
    var isSame;

    if (from instanceof Date) {
      from = from.getTime();
    }

    isSame = (from === this.from_);

    if (!isSame) {
      this.from_ = from;
      this.trigger('change:from', new Date(this.from_));
    }
  };


  /**
   * @method getFrom
   * @return {Date}
   */
  AbstractAnimation.prototype.getFrom = function() {
    return new Date(this.from_);
  };


  /**
   * @method setTo
   * @param {Date|number} to
   */
  AbstractAnimation.prototype.setTo = function(to) {
    var isSame;

    if (to instanceof Date) {
      to = to.getTime();
    }

    isSame = (to === this.to_);

    if (!isSame) {
      this.to_ = to;
      this.trigger('change:to', new Date(this.to_));
    }
  };


  /**
   * @method getTo
   */
  AbstractAnimation.prototype.getTo = function() {
    return new Date(this.to_);
  };


  /**
   * @return {Boolean} True, if the animation is currently running.
   * @method isAnimating
   */
  AbstractAnimation.prototype.isAnimating = function() {
    return _.isNumber(this.animationClock_);
  };


  return AbstractAnimation;
});