/**
* @license MIT
* Copyright © 2025 Steve Butler (henspace.com)
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the “Software”),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
/**
* @module hcje/utils
* @description
* General miscellaneous utilities.
*/
/**
* @typedef Dimensions
* @property {number} width
* @property {number} height
*/
/**
* @typedef Coordinate
* @property {number} x
* @property {number} y
*/
/**
* @typedef RectData
* @property {number} x - left position
* @property {number} y - top position
* @property {number} width
* @property {number} height
*/
/**
* @typedef PositionData
* @property {number} x - x position.
* @property {number} y - y - position.
* @property {number} angle - radians.
*/
/**
* Safely parse an integer.
* @param {string} str - String to parse.
* @param {number} [defaultValue = 0] - Value to return on failure. Note this should be an integer, but it is not
* validated.
*/
export function parseInt(str, defaultValue = 0) {
const result = Number.parseInt(str);
return Number.isNaN(result) ? defaultValue : result;
}
/**
* Get random number between min (inclusive) and max (exclusive).
* @param {number} min - Minimum value inclusive.
* @param {number} max - Maximum value exclusive.
* @returns {number}
*/
export function getRandomNumberBetween(min, max) {
return Math.random() * (max - min) + min;
}
/**
* Get random integer in range.
* If max <= min, returns min.
* @param {number} min - Minimum value inclusive
* @param {number} max - Maximum value exclusive
*/
export function getRandomIntExclusive(min, max) {
if (max <= min) {
return Math.ceil(min);
}
const minCeiled = Math.ceil(min);
const maxFloored = Math.floor(max);
return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled);
}
/**
* Get random integer in range.
* If max <= min, returns min.
* @param {number} min - Minimum value inclusive.
* @param {number} max - Maximum value inclusive.
*/
export function getRandomIntInclusive(min, max) {
if (max <= min) {
return Math.ceil(min);
}
const minCeiled = Math.ceil(min);
const maxFloored = Math.floor(max);
return Math.floor(Math.random() * (maxFloored - minCeiled + 1) + minCeiled);
}
/**
* Get dice roll.
* @param {number} [sides = 6] - Number of sides on the dice.
* @returns {number}
*/
export function rollDice(sides = 6) {
return getRandomIntInclusive(1, sides);
}
/**
* Get coin toss.
* @returns {boolean}
*/
export function tossCoin() {
return Math.random() >= 0.5;
}
/**
* Get a random entry from an array or string.
* @param {Array<*>} arr - Array from which to get entry.
* @returns {*} Entry; undefined if arr undefined or empty.
*/
export function getRandomMember(arr) {
if (!arr || arr.length < 1) {
return;
}
const index = getRandomIntExclusive(0, arr.length);
return arr instanceof Array ? arr[index] : arr.charAt(index);
}
/**
* Get greatest common divisor.
* @param {number} a - First number.
* @param {number} b - Second number.
* @returns {number}
*/
export function greatestCommonDivisor(valueA, valueB) {
/**
* Perform calculation without ensuring integers.
*/
function calculate(a, b) {
return b === 0 ? a : calculate(b, a % b);
}
return calculate(Math.floor(valueA), Math.floor(valueB));
}
/**
* Calculate the lowest common multiple of two numbers.
* @param {number} valueA - First number.
* @param {number} valueB - Second number.
* @returns {number}
*/
export function lowestCommonMultiple(valueA, valueB) {
return valueA * valueB / greatestCommonDivisor(valueA, valueB);
}
/**
* Get lowest common multiple of an array of values.
* @param {Array<number>} values - Array of numbers.
* @returns {number}
*/
export function lowestCommonMultipleOfArray(values) {
return values.reduce((a, b) => lowestCommonMultiple(a, b));
}
/**
* Get a random number based on a value and variation. For a value of **N** and variation of **V** the result
* will be a random number between N * (1 - V) and N * (1 + V).
* @param {number} value - Number to adjust.
* @param {number} variation - Variation to apply to the value. This is a proportion of the value, not an absolute
* value.
* @returns {number}
*/
export function jitter(value, variation) {
if (!variation) {
return value;
}
const min = value * (1 - variation);
const max = value * (1 + variation);
return getRandomNumberBetween(min, max);
}
/**
* Clamp a value between range: inclusive.
* @param {number} value - Number to clamp.
* @param {number} min - Minimum value inclusive.
* @param {number} max - Maximum value inclusive.
* @returns {number}
*/
export function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
/**
* Sleep for time.
* @param {number} ms - Period to sleep in milliseconds.
* @returns {Promise}
*/
export function sleep(ms) {
return new Promise((resolve) => {
setTimeout(() => resolve(), ms);
});
}
/**
* Create a [Waiter]{@link module:hcje/utils~Waiter} object. This object allow the program to wait until the
* application calls its **end** method.
* @returns {module:utils~Waiter}
*/
export function createWaiter() {
/** Function called to resolve Promise. @type {function()} */
let resolver;
/**
* Object to allow the program to wait for an unspecified event. Call the wait method will start the the waiter.
* This will return a Promise that will not be fulfilled until the waiter's **end** method is called. Successive
* calls to wait will be rejected.
* @interface
* @alias module:utils~Waiter
*/
const waiter = {
/**
* Wait until the end method is called.
* A subsequent call to wait will reject.
* @returns {Promise} Fulfils to the value passed to the **end** method.
*/
wait: () => {
return new Promise((resolve, reject) => {
if (resolver) {
reject(new Error(`Attempt to call wait on Waiter that is already awaiting resolution.`));
} else {
resolver = resolve;
console.debug('Waiter set to wait for continue to be called.');
}
})
},
/**
* End wait. This will cause the original Promise to fulfil to the provided value.
* @param {*} value - Fulfillment value.
*/
end: (value) => {
if (!resolver) {
console.error("Ignoring call made to end wait on a Waiter that hasn't been started.");
} else {
console.debug(`End wait. Fulfil as ${value}`);
resolver(value);
resolver = undefined;
}
},
/**
* Test whether the waiter is waiting.
* @returns {boolean}
*/
isWaiting: () => !!resolver,
};
return waiter;
}
/**
* Shuffle array. This uses Sattolo's algorithm to ensure every item is
* always in a new position. The original array **IS** modified.
* See [Fisher Yates shuffle]{@link https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shufflehttps://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle}
* and [Sattolo cycle]{@link https://rosettacode.org/wiki/Sattolo_cycle}
* @param {Array<*>} arr - The array to shuffle.
* @returns {Array<*>}
*/
export function shuffle(arr) {
for (let i = arr.length-1; i > 0; i--) {
var j = Math.floor(Math.random() * i);
var tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
return arr;
}
/**
* Content types used to determine the appropriate response header's content type base on the file type.
* @type {Object<string, HeaderInfo[]> }
* @private
*/
const ContentType = {
css: 'text/css',
csv: 'text/csv',
cur: 'image/x-icon',
gif: 'image/gif',
html: 'text/html',
ico: 'image/x-icon',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
json: 'application/json',
js: 'text/javascript',
map: 'application/json',
md: 'text/markdown',
mp3: 'audio/mpeg',
mp4: 'video/mp4',
mpeg: 'video/mpeg',
png: 'image/png',
svg: 'image/svg+xml',
ttf: 'font/ttf',
txt: 'text/plain',
woff: 'font/woff',
woff2: 'font/woff2',
};
/**
* Call to fetch to get text.
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch}
* @param {string | URL | Request} url - Url to fetch.
* @param {boolean} asJson - If true pass the result as JSON.
* @param {string | Object} fallbackResult - Result to return if error.
* @returns {Promise} Fulfils to text or json. On error, fulfils to
* fallbackResult. If fallbackResult is undefined, an error is thrown.
* @throws {Error} Thrown if error occurs and fallbackResult is undefined. Null is a valid fallbackResult
*/
export function fetchTextOrJson(url, asJson, fallbackResult) {
return fetch(url)
.then((response) => {
if (response.ok) {
return asJson ? response.json() : response.text();
} else {
throw new Error(`HTTP error! Status :${response.status}`);
}
})
.catch((error) => {
console.error(error.message);
if (fallbackResult === undefined) {
throw new Error(`Failed to load ${url}: ${error}`);
}
return fallbackResult;
});
}
/**
* Call to fetch to get text.
* @param {string | URL | Request} url - Url to fetch.
* @param {string} fallbackResult - Result if error
* @returns {Promise} Fulfils to text. On error, fulfils to
* fallbackResult or throws error.
* @throws {Error} Thrown if error occurs and fallbackResult is undefined. Null is a valid fallbackResult
*/
export function fetchText(url, fallbackResult) {
return fetchTextOrJson(url, false, fallbackResult);
}
/**
* Call to fetch to get json.
* @param {string | URL | Request} url - Url to fetch.
* @param {Object} fallbackResult - Result if error
* @returns {Promise} Fulfils to Object. On error, fulfils to
* fallbackResult or throws error.
* @throws {Error} Thrown if error occurs and fallbackResult is undefined. Null is a valid fallbackResult
*/
export function fetchJson(url, fallbackResult) {
return fetchTextOrJson(url, true, fallbackResult);
}
/**
* Call to fetch to get an array buffer from a media file.
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch}
* @param {string | URL | Request} url - Url to fetch.
* @param {Object} headers - The [Headers]{@link https://developer.mozilla.org/en-US/docs/Web/API/Headers} to add to
* the request.
* @param {RequestInit} options - If null, the content type is derived from the url.
* See [RequestInit]{@link https://developer.mozilla.org/en-US/docs/Web/API/RequestInit}.
* @returns {Promise} Fulfils to the ArrayBuffer.
* @throws {Error}
*/
export function fetchArrayBuffer(url, options) {
let requestInit = options;
if (!requestInit) {
const fileType = url?.match(/\.([\w\d]+)$/)?.[1];
if (fileType) {
requestInit = {
method: 'GET',
headers: {
'Content-Type' :ContentType[fileType],
}
};
console.debug(`Fetch ${url} with Content-Type = ${requestInit.headers['Content-Type']}`);
}
}
return fetch(url, requestInit)
.then((response) => {
if (response.ok) {
return response.arrayBuffer();
} else {
throw new Error(`HTTP error! Status :${response.status}`);
}
})
}
/**
* Get the name of an asset in the HCJE regardless of where the HCJE has been installed.
* @param {string} name - the name of the asset in the HCJE's asset folder.
* @returns {string}
*/
export function getHcjeAssetHref(name) {
return new URL(`../assets/${name.replace(/(?:\/|\\)/g, '_')}`, import.meta.url).href;
}