high-water-mark
Version:
Zero-dependency tracking of a high water mark over a series of values with configurable options including a low water mark.
188 lines (161 loc) • 4.95 kB
JavaScript
const { EventEmitter } = require('events');
const validateOptions = require('./validate-options');
const DEFAULT_OPTIONS = {
mode: 'all',
timeLimit: 60000,
countLimit: 10,
invert: false,
};
/**
* Track a high water mark by all-time, time interval or count
*
* @class HighWaterMark
* @extends {EventEmitter}
* @param {object} [options]
* @param {string} [options.mode=all] All time: 'all', Time limited: 'time', Count limited: 'count'
* @param {boolean} [options.invert=false] When true tracks the low water mark instead of the high
* @param {integer} [options.timeLimit=60000] When mode = 'time' the amount of time over which the high water mark is monitored (ms)
* @param {integer} [options.countLimit=10] When mode = 'count' the number of items over which the high water mark is monitored
*/
class HighWaterMark extends EventEmitter {
constructor(options) {
super();
// combine and store options
this._options = { ...DEFAULT_OPTIONS, ...(options ?? {}) };
// validate options, throws if invalid combination selected
validateOptions(this._options, DEFAULT_OPTIONS);
// track if the highest is up-to-date
// yes by default because by definition this returns undefined if no values have been sent
this._highestUpToDate = true;
// set up initial values
this._history = [];
this._highest = undefined;
}
/**
* Store a new value to the high water mark
*
* @param {number} value
* @memberof HighWaterMark
* @fires HighWaterMark#newHigh
*/
store(value) {
// change detection
let change = false;
// all time high water mark
if (this._options.mode === 'all') {
change = this._isNewWaterMark(value, this._highest);
if (change) {
this._setHighest(value);
this._emitnewHigh();
}
return;
}
if (this._options.mode === 'time') {
// add new value and timestamp
this._history.push({ value, time: Date.now() });
// reset flag
this._highestUpToDate = false;
}
if (this._options.mode === 'count') {
// add new value at end
this._history.push(value);
// manage array length
if (this._history.length > this._options.countLimit) this._history.shift();
// reset flag
this._highestUpToDate = false;
}
// if there are subscribers calculate the new highest (not done on every update for performance)
// then send event
if (this.listenerCount('newHigh') > 0) {
const oldHighest = this._highest;
this._calcHighest();
if (oldHighest !== this._highest) {
this._emitnewHigh();
}
}
}
/**
* A new high water mark has been reached
* @event HighWaterMark#newHigh
* @memberof HighWaterMark
* @returns {number} Value of new high water mark
*/
_emitnewHigh() {
this.emit('newHigh', this._highest);
}
/**
* Reset the high water mark. Deletes all history if using mode = time or count
*
* @memberof HighWaterMark
*/
reset() {
this._highest = undefined;
this._highestUpToDate = true;
this._history = [];
}
/**
* Set the internal highest and reset the flag
*
* @private
* @memberof HighWaterMark
*/
_setHighest(value) {
this._highest = value;
// the highest is never considered up-to-date in time mode
if (this._options.mode === 'time') return;
this._highestUpToDate = true;
}
/**
* Calculate the highest value of the array and set flag
*
* @private
* @memberof HighWaterMark
*/
_calcHighest() {
// no calculation required if already up to date (typically mode='all')
if (this._highestUpToDate) return this._highest;
// remove out of date values from the array in mode = 'time'
if (this._options.mode === 'time') {
const oldest = Date.now() - this._options.timeLimit;
this._history = this._history.filter((value) => value.time > oldest);
}
// no special handling is required for count, the array is managed at set-time
// find max of the array
this._setHighest(this._history.reduce((prev, cur) => {
if (this._isNewWaterMark(cur?.value ?? cur, prev)) {
return cur.value ?? cur;
}
return prev;
}, undefined));
return this._highest;
}
/**
* Tests if a value is greater than the high water mark (low water mark when invert = true)
*
* @private
* @param {number} value Value to test
* @param {number} highest Current highest value (lowest when invert = true)
* @param {boolean} [invert=false] When true test for low water mark
*/
_isNewWaterMark(value, highest) {
// if highest is not supplied this is the highest
// this allows for easily using this in a .reduce loop where highest may not be defined on the first iteration
if (highest === undefined) return true;
if (!this._options.invert) return value > highest;
return value < highest;
}
valueOf() {
return this._calcHighest();
}
/**
* Get the current high water mark
*
* @readonly
* @memberof HighWaterMark
* @returns {number} Value of high water mark
*/
get value() {
return this.valueOf();
}
}
module.exports = HighWaterMark;