Fork me on GitHub
Show:

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

define([
  'aeris/util',
  'aeris/maps/animations/abstractanimation',
  'aeris/promise',
  'aeris/errors/invalidargumenterror',
  'aeris/util/findclosest'
], function(_, AbstractAnimation, Promise, InvalidArgumentError, findClosest) {
  /**
   * Animates a single {aeris.maps.layers.AerisTile} layer.
   *
   * @publicApi
   * @class aeris.maps.animations.TileAnimation
   * @constructor
   * @extends aeris.maps.animations.AbstractAnimation
   *
   * @param {aeris.maps.layers.AerisTile} layer The layer to animate.
   * @param {aeris.maps.animations.options.AnimationOptions} opt_options
   * @param {number} opt_options.timestep Time between animation frames, in milliseconds.
   * @param {aeris.maps.animations.helpers.AnimationLayerLoader=} opt_options.animationLayerLoader
   */
  var TileAnimation = function(layer, opt_options) {
    var options = opt_options || {};

    AbstractAnimation.call(this, options);


    /**
     * The original layer object, which will serve as
     * the 'master' for all animation layer "frames."
     *
     * @type {aeris.maps.layers.AerisTile}
     * @private
     * @property masterLayer_
     */
    this.masterLayer_ = layer;


    /**
     * @property currentLayer_
     * @private
     * @type {?aeris.maps.layers.AerisTile}
     */
    this.currentLayer_ = null;


    /**
     * A hash of {aeris.maps.layers.AerisTile},
     * listed by timestamp.
     *
     * @type {Object.<number,aeris.maps.layers.AerisTile>}
     * @private
     * @property layersByTime_
     */
    this.layersByTime_ = {};


    /**
     * An array of available timestamps.
     *
     * @type {Array.number}
     * @private
     * @property times_
     */
    this.times_ = [];


    // Convert the master layer into a "dummy" view model,
    // with no bound rendering behavior.
    //
    // This will allow the client to manipulate the master layer
    // as a proxy for all other animation frames, without actually
    // showing the layer on the map.
    //
    this.masterLayer_.removeStrategy();

    // Load all the tile layers for the animation
    this.loadAnimationLayers();

    // Reload layers, when our bounds change
    this.listenTo(this, 'change:to change:from', function() {
      this.loadAnimationLayers();
    });

    // Make sure the current layer is loaded, when the masterLayer get a map
    this.listenTo(this.masterLayer_, 'map:set', function() {
      this.preloadLayer_(this.getCurrentLayer());
    });
  };
  _.inherits(TileAnimation, AbstractAnimation);

  /**
   * @property DEFAULT_TIME_TOLERANCE_
   * @static
   * @type {number}
   * @private
   */
  TileAnimation.DEFAULT_TIME_TOLERANCE_ = 1000 * 60 * 60 * 2; // 2 hours

  /**
   * Load the tile layers for the animation.
   *
   * @return {aeris.Promise} Promise to load all layers.
   * @method loadAnimationLayers
   */
  TileAnimation.prototype.loadAnimationLayers = function() {
    var prevCurrentTime = this.getCurrentTime();
    var prevLayersByTime = this.layersByTime_;

    // Create new layers
    var nextTimes = getTimeRange(this.from_, this.to_, this.limit_);
    var nextLayers = nextTimes.reduce(function(lyrs, time) {
      lyrs[time] = this.masterLayer_.clone({
        time: new Date(time),
        map: null,
        autoUpdate: false
      });
      return lyrs;
    }.bind(this), {});

    // Wait for new current layer to load
    var nextCurrentTime = this.getClosestTime_(prevCurrentTime, nextTimes);
    var nextCurrentLayer = nextLayers[nextCurrentTime];

    this.times_ = nextTimes;
    this.layersByTime_ = nextLayers;
    this.bindLayerLoadEvents_();

    this.trigger('load:times', this.times_.slice(0));

    // If there's already layers loaded,
    // preload the new layers, before transitioning.
    // This prevents a "flash" when change bounds
    // (eg. when AIM autoUpdate triggers)
    var transition = (function() {
      this.goToTime(this.getCurrentTime());
      // Remove old layers
      _.each(prevLayersByTime, function(lyr) {
        lyr.destroy();
      });
    }.bind(this));
    if (this.getCurrentLayer()) {
      this.preloadLayer_(nextCurrentLayer)
        .always(function() {
          setTimeout(transition, 1000);
        }, this);
    }
    else {
      transition();
    }
  };

  TileAnimation.prototype.bindLayerLoadEvents_ = function() {
    var triggerLoadReset = _.debounce(function() {
      this.trigger('load:reset', this.getLoadProgress());
    }.bind(this), 15);

    var triggerLoadProgress = (function() {
      var progress = this.getLoadProgress();
      if (progress === 1) {
        this.trigger('load:complete', progress);
      }

      this.trigger('load:progress', progress);
    }.bind(this));

    _.each(this.layersByTime_, function(lyr) {
      lyr.on({
        'load': triggerLoadProgress,
        'load:reset': triggerLoadReset
      });
    }.bind(this));
  };

  /**
   * @method preload
   */
  TileAnimation.prototype.preload = function() {
    var promiseToPreload = new Promise();

    var layers = _.values(this.layersByTime_);

    // Then preload the rest
    var layersToPreload = [this.getCurrentLayer()].concat(_.shuffle(layers));
    Promise.map(layersToPreload, this.preloadLayer_, this)
      .done(promiseToPreload.resolve)
      .fail(promiseToPreload.reject);

    return promiseToPreload;
  };


  /**
   * Preloads a single tile layer.
   *
   * @method preloadLayer_
   * @private
   * @param {aeris.maps.layers.AerisTile} layer
   * @return {aeris.Promise} Promise to load the layer
   */
  TileAnimation.prototype.preloadLayer_ = function(layer) {
    var promiseToPreload = new Promise();

    var doPreload = (function() {
      // Add the layer to the map
      // with opacity 0 (to trigger loading)
      layer.set({
        map: this.masterLayer_.getMap(),
        opacity: layer === this.getCurrentLayer() ? this.masterLayer_.getOpacity() : 0
      });

      // Resolve once we're done loading
      layer.once('load', promiseToPreload.resolve);

      // Give up after a while, so we're not blocking the animation
      setTimeout(function() {
        if (promiseToPreload.getState() === 'pending') {
          // Just call the layer loaded (show whatever we've got)
          layer.trigger('load');
        }
      }.bind(this), 2000);
    }.bind(this));


    // Wait for the last layer to preload,
    // before preloading the next one
    this.lastPromiseToPreload_ || (this.lastPromiseToPreload_ = Promise.resolve());
    this.lastPromiseToPreload_
      .always(function() {
        // If the layer is already loaded, no more work to do.
        if (layer.isLoaded()) {
          promiseToPreload.resolve();
        }
        // Wait for a map to be set, before preloading
        else if (!this.masterLayer_.getMap()) {
          this.masterLayer_.once('map:set', doPreload);
        }
        else {
          doPreload();
        }
      }, this);

    this.lastPromiseToPreload_ = promiseToPreload;
    return promiseToPreload;
  };



  /**
   * @method refreshCurrentLayer_
   * @private
   */
  TileAnimation.prototype.refreshCurrentLayer_ = function() {
    this.goToTime(this.getCurrentTime());
  };

  TileAnimation.prototype.start = function() {
    AbstractAnimation.prototype.start.call(this);
    // preload on start
    // (this gives a little better ux than allowing layers to preload
    //  as we hit them, because `preload()` shuffles the layer order)
    this.preload();
  };


  /**
   * Animates to the layer at the next available time,
   * or loops back to the start.
   * @method next
   */
  TileAnimation.prototype.next = function() {
    var nextTime = this.getNextTime_();

    if (!nextTime) {
      return;
    }

    this.goToTime(nextTime);
  };


  /**
   * Animates to the previous layer,
   * or loops back to the last layer.
   * @method previous
   */
  TileAnimation.prototype.previous = function() {
    var prevTime = this.getPreviousTime_();

    if (!prevTime) {
      return;
    }

    this.goToTime(prevTime);
  };


  /**
   * Animates to the layer at the specified time.
   *
   * If no layer exists at the exact time specified,
   * will use the closest available time.
   *
   * @param {number|Date} time UNIX timestamp or date object.
   * @method goToTime
   */
  TileAnimation.prototype.goToTime = function(time) {
    var nextLayer;
    var currentLayer;
    var haveTimesBeenLoaded = !!this.getTimes().length;

    time = _.isDate(time) ? time.getTime() : time;

    if (!_.isNumeric(time)) {
      throw new InvalidArgumentError('Invalid animation time: time must be a Date or a timestamp (number).');
    }

    currentLayer = this.getCurrentLayer();
    // Note that we may not be able to find a layer in the same tense,
    // in which case this value is null.
    nextLayer = this.getLayerForTimeInSameTense_(time) || null;

    // Set the new layer
    this.currentTime_ = time;

    if (nextLayer === currentLayer) {
      return;
    }

    // If no time layers have been created
    // wait for time layers to be created,
    // then try again. Otherwise, our first
    // frame will  never show.
    if (!haveTimesBeenLoaded) {
      this.listenToOnce(this, 'load:times', function() {
        this.goToTime(this.getCurrentTime());
      });
    }

    if (nextLayer) {
      this.transition_(currentLayer, nextLayer);
    }
    else if (currentLayer) {
      this.transitionOut_(currentLayer);
    }

    this.currentLayer_ = nextLayer;
    this.trigger('change:time', new Date(this.currentTime_));
  };


  /**
   * @return {number} Percentage complete loading tile (1.0 is 100% complete).
   * @method getLoadProgress
   */
  TileAnimation.prototype.getLoadProgress = function() {
    var totalCount = _.keys(this.layersByTime_).length;
    var loadedCount = 0;

    if (!totalCount) {
      return 0;
    }

    _.each(this.layersByTime_, function(layer) {
      if (layer.isLoaded()) {
        loadedCount++;
      }
    }, 0);


    return Math.min(loadedCount / totalCount, 1);
  };


  /**
   * @method hasMap
   */
  TileAnimation.prototype.hasMap = function() {
    return this.masterLayer_.hasMap();
  };


  /**
   * Destroys the tile animation object,
   * clears animation frames from memory.
   *
   *
   * @method destroy
   */
  TileAnimation.prototype.destroy = function() {
    _.invoke(this.layersByTime_, 'destroy');
    this.layersByTime_ = {};
    this.times_.length = 0;

    this.stopListening();

    this.masterLayer_.resetStrategy();
  };


  /**
   * Returns available times.
   * Note that times are loaded asynchronously from the
   * Aeris Interactive Tiles API, so they will not be immediately
   * available.
   *
   * Wait for the 'load:times' event to fire before attempting
   * to grab times.
   *
   * @return {Array.<number>} An array timestamps for which
   *                          they are available tile frames.
   * @method getTimes
   */
  TileAnimation.prototype.getTimes = function() {
    return _.clone(this.times_);
  };


  /**
   * @method getLayerForTimeInSameTense_
   * @private
   * @param {Number} time
   * @return {aeris.maps.layers.AerisTile}
   */
  TileAnimation.prototype.getLayerForTimeInSameTense_ = function(time) {
    return this.layersByTime_[this.getClosestTimeInSameTense_(time)];
  };


  /**
   * Returns the closes available time.
   *
   * @param {number} targetTime UNIX timestamp.
   * @param {Array.<Number>} opt_times Defaults to loaded animation times.
   * @return {number}
   * @private
   * @method getClosestTime_
   */
  TileAnimation.prototype.getClosestTime_ = function(targetTime, opt_times) {
    var times = opt_times || this.times_;
    return findClosest(targetTime, times);
  };


  /**
   * Returns the closes available time.
   *
   * If provided time is in the past, will return
   * the closest past time (and vice versa);
   *
   * @method getClosestTimeInSameTense_
   * @private
   *
   * @param {number} targetTime UNIX timestamp.
   * @param {Array.<Number>} opt_times Defaults to loaded animation times.
   * @return {number}
   */
  TileAnimation.prototype.getClosestTimeInSameTense_ = function(targetTime, opt_times) {
    var isTargetInFuture = targetTime > Date.now();
    var times = opt_times || this.times_;

    // Only look at times that are in the past, if
    // the target is in the past, and vice versa.
    var timesInSameTense = times.filter(function(time) {
      var isTimeInFuture = time > Date.now();
      var isTimeInSameTenseAsTarget = isTimeInFuture && isTargetInFuture || !isTimeInFuture && !isTargetInFuture;

      return isTimeInSameTenseAsTarget;
    });

    return findClosest(targetTime, timesInSameTense);
  };


  /**
   * Transition from one layer to another.
   *
   * @param {?aeris.maps.layers.AerisTile} opt_oldLayer
   * @param {aeris.maps.layers.AerisTile} newLayer
   * @private
   * @method transition_
   */
  TileAnimation.prototype.transition_ = function(opt_oldLayer, newLayer) {


    // If the new layer is not yet loaded,
    // wait to transition until it is.
    // This prevents displaying an "empty" tile layer,
    // and makes it easier to start animations before all
    // layers are loaded.
    if (!newLayer.isLoaded()) {
      this.preloadLayer_(newLayer);
      this.transitionWhenLoaded_(opt_oldLayer, newLayer);
    }


    // Hide all the layers
    // Sometime we have trouble with old layers sticking around.
    // especially when we need to reload layers for new bounds.
    // This a fail-proof way to handle that issue.
    _.without(this.layersByTime_, newLayer).
      forEach(this.transitionOut_, this);

    this.transitionInClosestLoadedLayer_(newLayer);
  };


  /**
   * @param {aeris.maps.layers.AerisTile} layer
   * @method transitionIn_
   * @private
   */
  TileAnimation.prototype.transitionIn_ = function(layer) {
    this.syncLayerToMaster_(layer);
  };


  /**
   * @param {aeris.maps.layers.AerisTile} layer
   * @method transitionOut_
   * @private
   */
  TileAnimation.prototype.transitionOut_ = function(layer) {
    layer.stopListening(this.masterLayer_);
    layer.setOpacity(0);
  };


  /**
   * Handle transition for a layer which has not yet
   * been loaded
   *
   * @param {?aeris.maps.layers.AerisTile} opt_oldLayer
   * @param {aeris.maps.layers.AerisTile} newLayer
   * @method transitionWhenLoaded_
   * @private
   */
  TileAnimation.prototype.transitionWhenLoaded_ = function(opt_oldLayer, newLayer) {
    // Clear any old listeners from this transition
    // (eg. if transition is called twice for the same layer)
    this.stopListening(newLayer, 'load');
    this.listenToOnce(newLayer, 'load', function() {
      if (this.getCurrentLayer() === newLayer) {
        this.transition_(opt_oldLayer, newLayer);
      }
    });
  };


  /**
   * @method transitionInClosestLoadedLayer_
   * @private
   */
  TileAnimation.prototype.transitionInClosestLoadedLayer_ = function(layer) {
    var loadedTimes = _.keys(this.layersByTime_).filter(function(time) {
      return this.layersByTime_[time].isLoaded();
    }, this);
    var closestLoadedTime = this.getClosestTimeInSameTense_(layer.get('time').getTime(), loadedTimes);

    if (!closestLoadedTime) {
      return;
    }


    this.transitionIn_(this.layersByTime_[closestLoadedTime]);
  };


  /**
   * Update the attributes of the provided layer
   * to match those of the master layer.
   *
   * @method syncLayerToMaster_
   * @private
   * @param  {aeris.maps.layers.AerisTile} layer
   */
  TileAnimation.prototype.syncLayerToMaster_ = function(layer) {
    var boundAttrs = [
      'map',
      'opacity',
      'zIndex'
    ];

    // clear any old bindings
    layer.stopListening(this.masterLayer_);

    layer.bindAttributesTo(this.masterLayer_, boundAttrs);
  };


  TileAnimation.prototype.getCurrentLayer = function() {
    return this.currentLayer_;
  };


  TileAnimation.prototype.getNextTime_ = function() {
    return this.isCurrentLayerLast_() ?
      this.times_[0] : this.times_[this.getLayerIndex_() + 1];
  };


  TileAnimation.prototype.getPreviousTime_ = function() {
    var lastTime = _.last(this.times_);
    return this.isCurrentLayerFirst_() ?
      lastTime : this.times_[this.getLayerIndex_() - 1];
  };

  /**
   * @method isCurrentLayer_
   * @private
   * @param {aeris.maps.layers.AerisTile} layer
   * @return {Boolean}
   */
  TileAnimation.prototype.isCurrentLayer_ = function(layer) {
    return layer === this.getCurrentLayer();
  };


  /**
   * @return {boolean} True, if the current layer is the first frame.
   * @private
   * @method isCurrentLayerFirst_
   */
  TileAnimation.prototype.isCurrentLayerFirst_ = function() {
    return this.getLayerIndex_() === 0;
  };


  /**
   * @return {boolean} True, if the current layer is the last frame.
   * @private
   * @method isCurrentLayerLast_
   */
  TileAnimation.prototype.isCurrentLayerLast_ = function() {
    return this.getLayerIndex_() === this.times_.length - 1;
  };


  /**
   * Returns the index of the current layer
   * within this.times_.
   *
   * @return {number}
   * @private
   * @method getLayerIndex_
   */
  TileAnimation.prototype.getLayerIndex_ = function(layer) {
    layer || (layer = this.getCurrentLayer());
    return this.times_.indexOf(layer.get('time').getTime());
  };

  function getTimeRange(from, to, limit) {
    var animationDuration = to - from;
    var MIN_INTERVAL = 1000 * 60;
    var animationInterval = Math.max(Math.floor(animationDuration / limit), MIN_INTERVAL);

    return _.range(from, to, animationInterval);
  }


  return _.expose(TileAnimation, 'aeris.maps.animations.TileAnimation');
});