index.js

const { EventEmitter } = require('events');
const ExistsError = require('./errors/ExistsError');
const NotExistsError = require('./errors/NotExistsError');

class MinRand {
	/**
	 * Creates a new MinRand instance
	 * @param {Array} dataset An array containing the dataset
	 * @param {Object} [usedDataPoints={}] A key-value object containing the arrays of used datapoints. Converted to a Map internally.
	 * @throws {TypeError}
	 */
	constructor(dataset, usedDataPoints = {}) {
		if (!Array.isArray(dataset)) {
			throw new TypeError('Dataset must be an array');
		}

		if (!(typeof usedDataPoints === 'object' && !Array.isArray(usedDataPoints))) {
			throw new TypeError('Used data points must be an object');
		}

		this.dataset = dataset;
		this.usedDataPoints = new Map(Object.entries(usedDataPoints));

		this.usedResetPercentage = 0.9;
		this.eventEmitter = new EventEmitter();
	}

	/**
	 * Returns the EventEmitter
	 * @returns {EventEmitter} The EventEmitter
	 * @see {@link MinRand#event:reset}
	 */
	getEventEmitter() {
		return this.eventEmitter;
	}

	/**
	 * Returns the dataset
	 * @returns {Array} An array containing the dataset
	 */
	getDataset() {
		return this.dataset;
	}

	/**
	 * Sets the dataset
	 * @param {Array} dataset An array containing the dataset
	 * @throws {TypeError}
	 */
	setDataset(dataset) {
		if (!Array.isArray(dataset)) {
			throw new TypeError('Dataset must be an array');
		}

		this.dataset = dataset;
	}

	/**
	 * Returns the usage percentage at which the first half of the used datapoint store will be removed
	 * @returns {double} The percentage in decimal form
	 */
	getUsedResetPercentage() {
		return this.usedResetPercentage;
	}

	/**
	 * Sets the usage percentage at which the first half of the used datapoint store will be removed
	 * @param {double} percentage A percentage in decimal form between 0.01 and 0.99 (inclusive)
	 * @throws {TypeError}
	 * @throws {RangeError}
	 */
	setUsedResetPercentage(percentage) {
		if (typeof percentage !== 'number' || !percentage.toString().match(/^[0-9]+(?:\.[0-9]+)?$/)) {
			throw new TypeError('Percentage must be numerical');
		}

		if (percentage < 0.01 || percentage > 0.99) {
			throw new RangeError('Percentage must be between 0.01 and 0.99 (inclusive)');
		}

		this.usedResetPercentage = percentage;
	}

	/**
	 * Removes a value from the dataset array, and all used datapoint arrays
	 * @param {*} value The value to remove
	 */
	removeDatasetPoint(value) {
		this.dataset = this.dataset.filter(e => e !== value);

		this.usedDataPoints.forEach((v, k) => {
			var _v = v.filter(e => e !== value);
			this.usedDataPoints.set(k, _v);
		});
	}

	/**
	 * Adds a value to the dataset array
	 * @param {*} value The value to add
	 */
	addDatasetPoint(value) {
		this.dataset.push(value);
	}

	/**
	 * Returns the used data points map
	 * @returns {Map} The used data points map
	 */
	getUsedDataPoints() {
		return this.usedDataPoints;
	}

	/**
	 * Gets the used datapoints for the specified key
	 * @param {*} key The key
	 * @returns {Array|undefined} An array of used datapoints for this key. Undefined if it does not exist
	 */
	getUsedDataPointArray(key) {
		return this.usedDataPoints.get(key);
	}

	/**
	 * Sets the used datapoints for the specified key
	 * @param {*} key The key
	 * @param {*} array The array of datapoints
	 * @throws {TypeError}
	 */
	setUsedDataPointArray(key, array) {
		if (!Array.isArray(array)) {
			throw new TypeError('Dataset must be an array');
		}

		this.usedDataPoints.set(key, array);
	}

	/**
	 * Adds a used datapoint to a key
	 * @param {*} key The key
	 * @param {*} datapoint The datapoint to add
	 * @throws {NotExistsError} If the key does not exist or if the datapoint is not already in the dataset
	 * @throws {ExistsError} If the datapoint already exists for the specified key
	 */
	addUsedDataPoint(key, datapoint) {
		var _dataPoints = this.usedDataPoints.get(key);

		if (!_dataPoints) {
			throw new NotExistsError();
		}

		if (!this.dataset.includes(datapoint)) {
			throw new NotExistsError('That datapoint does not exist');
		}

		if (_dataPoints.includes(datapoint)) {
			throw new ExistsError('That datapoint already exists');
		}

		_dataPoints.push(datapoint);
	}

	/**
	 * Adds an empty used data point array for the specified key
	 * @param {*} key The key
	 * @param {boolean} [force=false] Whether to override an existing key
	 * @throws {ExistsError} An error thrown if the key already exists
	 */
	addUsedDataPointKey(key, force = false) {
		if (!force && this.usedDataPoints.has(key)) {
			throw new ExistsError();
		}

		this.usedDataPoints.set(key, []);
	}

	/**
	 * Removes a used datapoint key
	 * @param {*} key The key
	 * @throws {NotExistsError}
	 */
	removeUsedDataPointKey(key) {
		if (!this.usedDataPoints.has(key)) {
			throw new NotExistsError();
		}

		this.usedDataPoints.delete(key);
	}

	/**
	 * Removes the first half of the used datapoints for the specified key, if needed
	 * @param {*} key The key
	 * @returns {boolean} Whether the reset occurred
	 * @private
	 */
	_resetIfNeeded(key) {
		var _thisUDP = this.usedDataPoints.get(key);

		if (this.dataset.length > 0) {
			if (_thisUDP.length >= Math.floor(this.dataset.length * this.usedResetPercentage)) {
				var _halfLength = Math.ceil(_thisUDP.length / 2);
				//console.log(`${_thisUDP.length} datapoints used. Clearing first ${_halfLength} datapoints from used datapoints array`);
				var _thisUDPRight = _thisUDP.slice(_halfLength);
				this.usedDataPoints.set(key, _thisUDPRight);

				return true;
			}
		}

		return false;
	}

	/**
	 * Gets a random datapoint not in the used datapoint store specified by key,
	 * and adds this to the used datapoint array for the key
	 * @param {*} key The key
	 * @returns {*} The random datapoint
	 * @fires MinRand#reset
	 * @throws {NotExistsError}
	 */
	getRandomDataPoint(key) {
		if (!this.usedDataPoints.has(key)) {
			throw new NotExistsError();
		}

		if (this._resetIfNeeded(key)) {
			/**
			 * The reset event fires when the first half of the used datapoints for the specified key is removed
			 * @event MinRand#reset
			 * @type {object}
			 * @property {*} key The key that was reset
			 */
			this.eventEmitter.emit('reset', {
				key
			});
		}

		// This was originally done using Array.prototype.filter(),
		// but for a native function, I expected it to be a lot faster.
		// The code below seems to be the fastest filtering algorithm.
		// Speed of getting 100000 random values:
		// Array.prototype.filter: 834ms
		// This code: 477ms
		// A custom C++ filtering algorithm was implemented as a test,
		// but due to my lack of C++ knowledge, took 1954ms to complete
		// and was therefore removed.
		var udpMap = this.usedDataPoints.get(key);
		var udpSet = new Set(udpMap);
		var randArr = [];
		for (var i = 0; i < this.dataset.length; i++) {
			// Array.prototype.includes() is slow (>1000ms for this test). Using a set is much faster.
			if (!udpSet.has(this.dataset[i])) {
				randArr.push(this.dataset[i]);
			}
		}

		var randDataPoint = randArr[Math.floor(Math.random() * randArr.length)];

		this.usedDataPoints.get(key).push(randDataPoint);

		return randDataPoint;
	}
}

module.exports = MinRand;