/*
* 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));