API Docs for: 0.0.1
Show:

File: lib/range.js

/*
* niViz -- snow profiles visualization
* Copyright (C) 2015 WSL/SLF - Fluelastrasse 11 - 7260 Davos Dorf - Switzerland.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

(function (niviz) {
  'use strict';

  // --- Module Dependencies ---

  var properties = Object.defineProperties;


  /** @module niviz */

  /**
   * A range of snow profiles.
   *
   * @class Range
   * @constructor
   *
   * @param {Array<Profile>} [profiles] an existing profiles array to use
   * @param {Number} [height] initial height
   *
   */
  function Range(profiles, height) {
    /**
     * @property profiles
     * @type Array<Profile>
     */
    this.profiles = profiles || [];
    this._features = [];
  }

  function addFeatures(profile, array) {
    profile.features.forEach(function (feature) {
      if (array.indexOf(feature) === -1) array.push(feature);
    });
  };

  properties(Range.prototype, {

    /**
     * The height at the top of the topmost layer of all profiles,
     * measured from the ground.
     *
     * @property top
     * @type Number
     */
    top: {
      get: function top$get () {
        return Math.max.apply(Math, this.profiles.map( function(p) {
          return p.top;
        }));
      }
    },

    /**
     * The height at the bottom of the lowest layer of all the profile,
     * measured from the ground.
     *
     * @property bottom
     * @type Number
     */
    bottom: {
      get: function top$get () {
        return Math.min.apply(Math, this.profiles.map( function(p) {
          return p.bottom;
        }));
      }
    },

    /**
     * The maximal distance from top to bottom,
     * i. e. maximal height of all profiles.
     *
     * @property height
     * @type Number
     */
    height: {
      get: function height$get () {
        return this.top - this.bottom;
      }
    },

    /**
     * @property from
     * @type Moment
     */
    from: {
      get: function () {
        return (this.empty) ? null : this.profiles[0].date;
      }
    },

    /**
     * @property to
     * @type Moment
     */
    to: {
      get: function () {
        return (this.empty) ? null : this.profiles[this.profiles.length - 1].date;
      }
    },

    /**
     * @property length
     * @type Number
     */
    length: {
      get: function () { return this.profiles.length; }
    },

    /**
     * @property empty
     * @type Boolean
     */
    empty: {
      get: function () { return this.profiles.length === 0; }
    },

    /**
     * @property features
     * @type Array<string>
     */
    features: {
      get: function () {
        if (!this._features.length) this.updateFeatures();
        return this._features;
      }
    }
  });

  /**
   * If the station profiles receive an update, this function needs to be called
   * in order to update the features array
   *
   * @method updateFeatures
   * @chainable
   */
  Range.prototype.updateFeatures = function () {
    this.profiles.forEach(function (profile) {
      addFeatures(profile, this._features);
    }, this);

    return this;
  };

  // get a single profile by index or by date other matcher?
  Range.prototype.get = function (idx) {
    return this.profiles[normalize(idx, this.profiles.length)];
  };

  /**
   * Toggle whether to expose all layers of all profiles present
   * or just the ones above the ground, i. e. with a top value > 0.
   *
   * @method showsoil
   * @chainable
   *
   * @param {Boolean} enable true = expose all layers, false = hide soil layers
   */
  Range.prototype.showsoil = function (enable) {

    this.profiles.forEach(function (profile) {
      profile.showsoil(enable);
    }, this);

    return this;
  };

  /**
   * Get a sub range of profiles by index or by dates.
   *
   * Note: The new range object will share profile references
   * with the current range!
   *
   * @method range
   *
   * @param {Number, Moment} from index or date
   * @param {Number, Moment} to index or date
   *
   * @return Range
   */
  Range.prototype.range = function (from, to) {

    if (typeof from === 'number')
      return new Range(this.profiles.slice(from, to));

    if (this.matches(from, to))
      return new Range(this.profiles, this.height);


    var i, ii, profile, range = new Range();

    for (i = 0, ii = this.profiles.length; i < ii; ++i)  {
      profile = this.profiles[i];

      if (profile.date.isBefore(from)) continue;
      if (profile.date.isAfter(to)) break;

      range.push(profile);
    }

    return range;
  };

  /**
   * Get the closest index for the given date.
   *
   * @method index
   *
   * @param {Moment} date
   * @return Number
   */
  Range.prototype.index = function (date) {
    var i = 0, ii = this.profiles.length, profile, diff, min;

    if (!moment.isMoment(date)) return this.$index;

    if (this.$index) {
      profile = this.profiles[this.$index];

      if (profile.date.diff(date) <= 0) {
        i = this.$index;
      } else {
        i = this.$index;
        for (i; i >= 0; --i) {
          profile = this.profiles[i];
          diff = Math.abs(profile.date.diff(date));

          if (diff < min || min === undefined) {
            min = diff;
            this.$index = i;
          }

          if (this.$index !== i) break; // diff is becoming bigger
        }

        return this.$index;
      }
    }

    for (i; i < ii; ++i) {
      profile = this.profiles[i];
      diff = Math.abs(profile.date.diff(date));

      if (diff < min || min === undefined) {
        min = diff;
        this.$index = i;
      }

      if (this.$index !== i) break; // diff is becoming bigger
    }

    return this.$index;
  };

  /**
   * Adds a profile to the range and updates
   * the range's height.
   *
   * @method push
   * @chainable
   *
   * @param {Profile} profile
   */
  Range.prototype.push = function (profile) {
    this.profiles.push(profile);
    return this;
  };

  /**
   * Inserts a profile to the range and updates the range's height.
   * NOTE: In case there is already a profile present with the
   *       same date, the profile to be inserted is ignored.
   *
   * @method insert
   * @chainable
   *
   * @param {Profile} profile
   */
  Range.prototype.insert = function (profile) {
    var i, ii, inserted = false, diff;

    for (i = 0, ii = this.profiles.length; i < ii; ++i) {
      diff = this.profiles[i].date.diff(profile.date);
      if (diff > 0) {
        this.profiles.splice(i, 0, profile);
        inserted = true;
        break;
      } else if (diff === 0) {
        console.dir('Inserting profile: Two profiles have the same date ('
                    + profile.date.format('YYYY-MM-DDTHH:mm') + '), ignoring one');
        inserted = true;
        break;
      }
    }

    if (!inserted) this.profiles.push(profile);

    return this;
  };

  /**
   * Merge two range objects by adding the profiles of the object passed.
   *
   * @method merge
   * @chainable
   *
   * @param {Range} range
   */
  Range.prototype.merge = function (range) {
    if (!range.profiles) throw ('Merge failed: objects not compatible');

    range.profiles.forEach(function (profile) {
      this.insert(profile);
    }, this);

    return this;
  };

  /**
   * Calculate the average timestep of the range.
   *
   * @method avgstep
   * @param {Profile} profile
   * @return {Number} The average timestep in minutes
   */
  Range.prototype.avgstep = function () {
    if (this.length > 1) {
      return Math.round(this.duration('minutes') / (this.length - 1));
    }

    // the default timestep assumed if there is only one profile present:
    return 180;
  };

  /**
   * Calculate the duration of the range.
   *
   * @method duration
   * @param {unit} The moment unit to use (default: hours)
   * @return {Number} The duration in the unit specified
   */
  Range.prototype.duration = function (unit) {
    if (!this.from || !this.to) return null;

    return this.to.diff(this.from, unit || 'hours');
  };

  /**
   * Whether or not the range matches the passed-in
   * interval exactly.
   *
   * @method matches
   *
   * @param {Array<Moment>, Moment} from
   * @param {Moment} [to] to
   *
   * @return {Boolean}
   */
  Range.prototype.matches = function (from, to) {
    if (Array.isArray(from)) {
      to = from[1]; from = from[0];
    }

    return from && from.isSame(this.from) && to && to.isSame(this.to);
  };

  /**
   * Get maximal value for a certain property present in the profiles.
   *
   * @method max
   *
   * @param {String} prop Property name
   * @return {Number}
   */
  Range.prototype.max = function (prop) {
    if (!this.profiles.length) return undefined;

    return this.profiles.reduce(function (max, next) {
      return Math.max(max, next[prop]);
    }, Number.NEGATIVE_INFINITY);
  };

  // --- Helper ---

  function normalize(idx, total) {
    if (idx < 0) idx = total + idx;

    if (idx < 0 || idx >= total)
      throw new RangeError('index out of bounds');

    return idx;
  }

  // --- Module Export ---
  niviz.Range = Range;

}(niviz));