Fork me on GitHub
Show:

File: ../src/maps/layers/aeristile.js

define([
  'aeris/util',
  'aeris/config',
  'aeris/promise',
  'aeris/errors/validationerror',
  'aeris/errors/missingapikeyerror',
  'aeris/errors/timeouterror',
  'aeris/errors/unsupportedfeatureerror',
  'aeris/maps/layers/abstracttile',
  'aeris/api/getjson',
  'aeris/maps/layers/config/zindex',
  'aeris/maps/strategy/layers/aeristile',
  'aeris/util/timestring'
], function(_, aerisConfig, Promise, ValidationError, MissingApiKeyError, TimeoutError, UnsupportedFeatureError, BaseTile, getJson, zIndexConfig, AerisTileStrategy, timeString) {
  /**
   * Representation of Aeris Interactive Tile layer.
   *
   * @constructor
   * @class aeris.maps.layers.AerisTile
   * @extends aeris.maps.layers.AbstractTile
   */
  var AerisTile = function(opt_attrs, opt_options) {
    var options = _.extend({
      strategy: AerisTileStrategy,
      validate: true
    }, opt_options);

    var attrs = _.defaults(opt_attrs || {}, {
      subdomains: ['1', '2', '3', '4'],
      server: '//maps{d}.aerisapi.com',
      maxZoom: 27,
      minZoom: 1,

      /**
       * Tile's timestamp.
       * Defaults to 0.
       * Note that request to the AI Tiles API
       * at time '0' will return the latest available tile.
       *
       * @attribute time
       * @type {Date}
       */
      time: new Date(0),

      /**
       * Interactive tile type.
       *
       * @attribute tileType
       * @type {string}
       * @abstract
       */
      tileType: '',


      /**
       * The tile time index to use for displaying the layer.
       *
       * @type {number}
       */
      timeIndex: 0,


      /**
       * The layer's animation step.
       *
       * @type {number}
       */
      animationStep: 1,


      /**
       * Interval at which to update the tile.
       *
       * @attribute autoUpdateInterval
       * @type {number} Milliseconds.
       */
      autoUpdateInterval: AerisTile.updateIntervals.CURRENT,


      /**
       * Whether to auto-update the tile.
       * Auto-updating mean that every this.autoUpdateInterval
       * milliseconds, the tile's time attribute will reset.
       */
      autoUpdate: true,

      /**
       * Aeris API client_id
       *
       * @attribute apiId
       * @type {String}
       */
      apiId: aerisConfig.get('apiId'),

      /**
       * Aeris API client_secret
       *
       * @attribute apiSecret
       * @type {String}
       */
      apiSecret: aerisConfig.get('apiSecret')
    });

    _.defaults(attrs, {
      zIndex: zIndexConfig[attrs.name] || 1,

      /**
       * The type of Aeris Interactive tile
       * to use when the tile's time is set to a future date.
       *
       * @attribute futureTileType
       */
      futureTileType: attrs.tileType
    });


    /**
     * A reference to the timer
     * created with window.setInterval,
     * used for autoUpdating.
     *
     * @type {number}
     * @private
     * @property autoUpdateIntervalTimer_
     */
    this.autoUpdateIntervalTimer_;


    /**
     * Have the tile images for this layer
     * have been loaded?
     *
     * @type {Boolean}
     * @private
     */
    this.loaded_ = false;


    BaseTile.call(this, attrs, options);


    this.bindToApiKeys_();


    /**
     * The tile has automatically updated
     * to the most current time.
     *
     * @event autoUpdate
     */
  };
  _.inherits(AerisTile, BaseTile);


  /**
   * @method initialize
   * @protected
   */
  AerisTile.prototype.initialize = function() {
    var setAutoUpdate = (function() {
      if (this.get('autoUpdate')) {
        this.startAutoUpdate_();
      }
      else {
        this.stopAutoUpdate_();
      }
    }).bind(this);
    setAutoUpdate();      // Setup autoUpdate event on init

    // When autoUpdate property is toggled
    // start or stop auto-updating.
    this.on({
      'change:autoUpdate': setAutoUpdate,
      'change:autoUpdateInterval': function() {
        this.stopAutoUpdate_();
        this.startAutoUpdate_();
      }
    }, this);


    BaseTile.prototype.initialize.apply(this, arguments);
  };


  AerisTile.prototype.bindToApiKeys_ = function() {
    this.listenTo(aerisConfig, 'change:apiId change:apiSecret', function() {
      this.set({
        apiId: this.get('apiId') || aerisConfig.get('apiId'),
        apiSecret: this.get('apiSecret') || aerisConfig.get('apiSecret')
      });
    });
  };


  AerisTile.prototype.startAutoUpdate_ = function() {
    this.autoUpdateIntervalTimer_ = window.setInterval(function() {
      this.set('time', new Date(0));
      this.trigger('autoUpdate');
    }.bind(this), this.get('autoUpdateInterval'));
  };


  AerisTile.prototype.stopAutoUpdate_ = function() {
    if (!this.autoUpdateIntervalTimer_) {
      return;
    }

    window.clearInterval(this.autoUpdateIntervalTimer_);
  };


  /**
   * @method validate
   */
  AerisTile.prototype.validate = function(attrs) {
    var isFutureTile;

    if (!_.isString(attrs.tileType)) {
      return new ValidationError('tileType', 'not a valid string');
    }
    if (!_.isNumber(attrs.autoUpdateInterval)) {
      return new ValidationError('autoUpdateInterval', 'not a valid number');
    }
    if (attrs.minZoom < 1) {
      return new ValidationError('minZoom for Aeris Interactive tiles must be ' +
        'more than 0');
    }

    isFutureTile = attrs.time > new Date();
    if (isFutureTile && attrs.autoUpdate) {
      return new UnsupportedFeatureError('Auto update is not currently supported by for future tiles.' +
        ' Turn off auto update (tile.set(\'autoUpdate\', false) before using future tiles.');
    }

    return BaseTile.prototype.validate.apply(this, arguments);
  };


  /**
   * Update intervals used by the Aeris API
   * @static
   */
  AerisTile.updateIntervals = {
    RADAR: 1000 * 60 * 6,         // every 6 minutes
    CURRENT: 1000 * 60 * 60,      // hourly
    MODIS: 1000 * 60 * 60 * 24,   // daily
    SATELLITE: 1000 * 60 * 30,    // every 30 minutes
    ADVISORIES: 1000 * 60 * 3     // every 3 minutes
  };


  /**
   * @method getUrl
   */
  AerisTile.prototype.getUrl = function() {
    this.ensureApiKeys_();

    return this.get('server') + '/' +
      this.get('apiId') + '_' +
      this.get('apiSecret') +
      '/' + this.getTileTypeEndpoint_() +
      '/{z}/{x}/{y}/{t}.png';
  };


  /**
   * @throws {aeris.errors.MissingApiKeysError}
   * @private
   * @method ensureApiKeys_
   */
  AerisTile.prototype.ensureApiKeys_ = function() {
    this.set({
      apiId: this.get('apiId') || aerisConfig.get('apiId'),
      apiSecret: this.get('apiSecret') || aerisConfig.get('apiSecret')
    });

    if (!this.get('apiId') || !this.get('apiSecret')) {
      throw new MissingApiKeyError('Aeris API id and secret required to render ' +
        'interactive tiles.');
    }
  };


  /**
   * @return {number} UNIX Timestamp.
   * @method getTimestamp
   */
  AerisTile.prototype.getTimestamp = function() {
    return this.get('time').getTime();
  };


  /**
   * Get's the layer's time,
   * formatted for the Aeris API.
   *
   * @return {string} Format: [year][month][date][hours][minutes][seconds].
   * @method getAerisTimeString
   */
  AerisTile.prototype.getAerisTimeString = function() {
    var time = this.get('time');

    // Aeris accepts 0, -1, -2, or -3
    // As 'X' times before now.
    // ie '0.png' returns the most recent tile
    if (time.getTime() <= 0) {
      return time.getTime();
    }

    return timeString.fromDate(time);
  };


  /**
   * Retrieve a list of timestamps for which
   * tile images are available on the AerisAPI server.
   *
   * @return {aeris.Promise} Resolves with arrary of timestamps.
   * @throws {aeris.errors.MissingAPIKeyError}
   * @method loadTileTimes
   */
  AerisTile.prototype.loadTileTimes = function() {
    var promiseToLoadAllTimes = new Promise();
    var pastTimesEndpoint = this.get('tileType');
    var futureTimesEndpoint = this.get('futureTileType');
    var isFutureTimesAvailable = pastTimesEndpoint !== futureTimesEndpoint;

    var loadPromises = [];

    loadPromises.push(this.loadTileTimesForEndpoint_(pastTimesEndpoint));

    if (isFutureTimesAvailable) {
      loadPromises.push(this.loadTileTimesForEndpoint_(futureTimesEndpoint));
    }

    Promise.when(loadPromises).
      done(function(currTimesArgs, futureTimesArgs) {
        var currentTimes = currTimesArgs[0];
        var futureTimes = (futureTimesArgs ? futureTimesArgs[0] : [])
          // Remove any future times that are actually from the past
          // Otherwise, animations will attempt to load those times using the past `tileType`
          .filter(function(time) {
            return time > Date.now();
          });

        promiseToLoadAllTimes.resolve(currentTimes.concat(futureTimes));
      }).
      fail(promiseToLoadAllTimes.reject.bind(promiseToLoadAllTimes));

    return promiseToLoadAllTimes;
  };


  /**
   * @method loadTileTimesForEndpoint_
   * @private
   * @param {string} endpoint
   * @return {aeris.Promise}
   */
  AerisTile.prototype.loadTileTimesForEndpoint_ = function(endpoint) {
    var promiseToLoadTimes = new Promise();
    var url = this.createTileTimesUrlForEndpoint_(endpoint);

    this.ensureApiKeys_();

    getJson(url)
      .done(function(res) {
        var times;

        if (!res.files) {
          promiseToLoadTimes.reject(new Error('Failed to load tile times: no time data was returned.'));
        }

        times = this.parseTileTimes_(res);

        promiseToLoadTimes.resolve(times);
      }.bind(this))
      .fail(function(err) {
        if (err.xhr && err.xhr.status === 401) {
          console.warn('Client does not have access to tile times for ' + endpoint);
          return promiseToLoadTimes.resolve([]);
        }
        promiseToLoadTimes.reject(err);
      });

    return promiseToLoadTimes;
  };


  /**
   * @param {aeris.Promise} promise
   * @param {number} timeout
   * @param {string} message
   * @private
   * @method rejectAfterTimeout_
   */
  AerisTile.prototype.rejectAfterTimeout_ = function(promise, timeout, message) {
    _.delay(function() {
      if (promise.getState() === 'pending') {
        promise.reject(new TimeoutError(message));
      }
    }.bind(this), timeout);
  };


  /**
   * @return {string}
   * @private
   * @method createTileTimesUrl_
   */
  AerisTile.prototype.createTileTimesUrl_ = function() {
    var pastTimesEndpoint = this.get('tileType');

    return this.createTileTimesUrlForEndpoint_(pastTimesEndpoint);
  };


  /**
   * @method createFutureTileTimesUrl_
   * @private
   */
  AerisTile.prototype.createFutureTileTimesUrl_ = function() {
    var futureTimesEndpoint = this.get('futureTileType');

    return this.createTileTimesUrlForEndpoint_(futureTimesEndpoint);
  };


  /**
   * @method createTileTimesUrlForEndpoint_
   * @private
   * @param {string} endpoint Tile type endpoint.
   */
  AerisTile.prototype.createTileTimesUrlForEndpoint_ = function(endpoint) {
    var urlPattern = '{server}/{client_id}_{client_secret}/{tileType}.json';
    var server = this.get('server').replace('{d}', '');

    return urlPattern.
      replace('{server}', server).
      replace('{client_id}', this.get('apiId')).
      replace('{client_secret}', this.get('apiSecret')).
      replace('{tileType}', endpoint);
  };


  /**
   * @param {Object} res Aeris Tile Times API response object.
   * @return {Array.<number>} Array of JS formatted timestamps.
   * @private
   * @method parseTileTimes_
   */
  AerisTile.prototype.parseTileTimes_ = function(res) {
    return res.files.map(function(time) {
      // Convert UNIX timestamp (seconds)
      // to JS timestamp (milliseconds)
      return time.timestamp * 1000;
    });
  };


  /**
   * @method getTileTypeEndpoint_
   * @private
   */
  AerisTile.prototype.getTileTypeEndpoint_ = function() {
    var isFutureTile = this.get('time') > new Date();

    return isFutureTile ? this.get('futureTileType') : this.get('tileType');
  };


  return _.expose(AerisTile, 'aeris.maps.layers.AerisTile');
});