@applicvision/js-toolbox
Version:
A collection of tools for modern JavaScript development
452 lines (439 loc) • 15.9 kB
JavaScript
/**
* @template T
* @extends Array<T>
*/
export default class Ayay extends Array {
/**
* @template T
* @param {Iterable<T> | ArrayLike<T>} iterable
* @returns {Ayay<T>}
*/
static from(iterable) {
return super.from(iterable)
}
/**
* @template T
* @param {T[]} items
* @returns {Ayay<T>}
*/
static of(...items) {
return super.of(...items)
}
/**
* Creates a new array with one item removed.
* @param {number} index index to remove
* @returns {Ayay<T>}
*/
arrayByRemovingItemAtIndex(index) {
return this.slice(0, index).concat(this.slice(index + 1));
}
/**
* Creates a new array with one item inserted.
* @param item The new item
* @param {number} atIndex The insertion index
* @returns {Ayay<T>}
*/
arrayByInsertingItem(item, atIndex) {
return this.slice(0, atIndex).concat(item, this.slice(atIndex));
}
/**
* Returns a new array with one item moved from one position to another.
* @param {number} fromIndex The index to move from
* @param {*} toIndex The destination index
* @returns {Ayay}
*/
arrayByMoving(fromIndex, toIndex) {
const movedItem = this[fromIndex];
return this
.arrayByRemovingItemAtIndex(fromIndex)
.arrayByInsertingItem(movedItem, toIndex - (fromIndex < toIndex));
}
/**
* Returns a new array, which is in reverse order. Unlike Array.prototype.reverse(),
* this method does not mutate the original array.
* @returns {Ayay}
*/
reversedArray() {
return this.map((item, index, array) => array.at(-index - 1));
}
/**
* Performs a shallow compare with another array-like object.
* If lengths are equal and every item is strictly equal to the item in the argument array, true is returned.
* @param {any[]} array item to compare with
*/
isShallowIdenticalWith(array) {
return array instanceof Array &&
this.length === array.length &&
this.every((item, index) => item === array[index]);
}
/**
* Returns a new array by replacing the item at the specified position.
* @param {number} index The index of the item to replace
* @param {any} replacement The new item
* @returns {Ayay}
*/
arrayByReplacingItemAtIndex(index, replacement) {
return this.slice(0, index).concat(replacement, this.slice(index + 1));
}
/**
* Creates a new array by swapping items at given positions.
* @param {number} index the first index
* @param {number} withIndex the other index
* @returns {Ayay}
*/
arrayBySwappingItems(index, withIndex) {
return this
.arrayByReplacingItemAtIndex(index, this[withIndex])
.arrayByReplacingItemAtIndex(withIndex, this[index]);
}
/**
* Creates a new array sorted using the comparator function.
* @param {(item1: any, item2: any) => number} comparator Called with arguments `item1` and `item2`, return negative value to sort `item1` before `item2`.
* @returns {Ayay}
*/
arraySortedBy(comparator) {
const sortedArray = new Ayay();
this.forEach(item => {
let startIndex = 0, endIndex = sortedArray.length;
while (startIndex !== endIndex) {
const middleIndex = Math.floor((startIndex + endIndex) / 2);
if (comparator(item, sortedArray[middleIndex]) > 0) {
startIndex = middleIndex + 1;
} else {
endIndex = middleIndex;
}
}
sortedArray.splice(startIndex, 0, item);
});
return sortedArray;
}
/**
* Calculates the sum of all items in the array.
* @param {(item: any) => number } [mapper] Optional function to transform item before adding to sum.
* @param {object} [thisArg] An object to which the `this` keyword can refer in the mapper function. If `thisArg` is omitted, undefined is used as the `this` value.
* @returns {number}
*/
sumItems(mapper = item => item, thisArg) {
return this.reduce((total, item) => total + mapper.call(thisArg, item), 0);
}
/**
* Calculates the average of the items in the array.
* @param {(item: any) => number} [mapper] Optional function called on each item to transform it to a number.
* @param {object} [thisArg] An object to which the `this` keyword can refer in the mapper function. If `thisArg` is omitted, undefined is used as the `this` value.
* @returns {number}
*/
average(mapper = item => item, thisArg) {
return this.sumItems(mapper, thisArg) / this.length;
}
/**
* Sum items as long as the `condition` is met.
* @param {(value: any, index: number) => boolean} condition Function to determine if iteration should continue.
* @param {(item: any, index: number, array: Ayay) => number} [mapper] Optional function to transform item before adding to sum.
* @param {object} [thisArg] An object to which the `this` keyword can refer in the callbackfn function. If `thisArg` is omitted, undefined is used as the `this` value.
* @returns
*/
sumItemsWhile(condition, mapper = item => item, thisArg) {
return this.reduceWhile((total, item) => total + mapper.call(thisArg, item), 0, condition, thisArg);
}
/**
* The last element in the array.
*/
get last() {
return this.at(-1)
}
/**
* The index of the last element in the array.
*/
get lastIndex() {
return this.length - 1;
}
/**
* The first element in the array.
*/
get first() {
return this[0];
}
/**
* a getter which returns the same as the `toVanilla()` function.
*/
get vanilla() {
return this.toVanilla()
}
/**
* Checks whether the array is empty or not.
*/
isEmpty() {
return this.length === 0;
}
/**
* For an array of functions, calling `.pipe` will return a function that will pass a value through all the array's functions.
* Example: `Ayay.of(n => n * 2, n => n * 3).pipe(1) // 6`
* @type {(any) => any}
*/
get pipe() {
return this.reduce.bind(this, (val, func) => func(val));
}
/**
* Returns a new array with ordered indices of all items (`[1, 2, 3,..., length - 1]`).
* @returns {Ayay}
*/
indices() {
return this.map((item, index) => index);
}
/**
* Returns a new array with all `null` and `undefined` elements removed.
* @param {(item: any, index: number, array: Ayay) => any} [mapper] Optional transform function called for each element.
* @param {object} [thisArg] An object to which the `this` keyword can refer in the callbackfn function. If `thisArg` is omitted, undefined is used as the `this` value.
* @returns {Ayay}
*/
compactMap(mapper, thisArg) {
return (mapper ? this.map(mapper, thisArg) : this)
.filter(item => item != null);
}
/**
* Filter the array using an asynchronous tester function.
* @param {(item: any, index: number) => Promise<boolean>} tester Async function called for each item in the array.
* @returns {Promise<Ayay>}
*/
async asyncFilter(tester) {
return Ayay.of(await Promise.all(this.map(async (item, index) =>
await tester(item, index) ? item : null
))).compactMap()
}
/**
* Returns a new Array containing chunks of the original array of the given size.
* @param {number} chunkSize The size of the chunks
* @returns {Ayay}
*/
chunksOf(chunkSize) {
return this.isEmpty() ?
this :
Ayay.of(this.slice(0, chunkSize)).concat(this.slice(chunkSize).chunksOf(chunkSize));
}
/**
* Similar to ordinary `forEach`, but before each `iterator` call,
* the `condition` function will be called. If that function returns false, iteration will stop.
* @param {(value: any, index: number, array: Ayay) => void} iterator Function called for each item.
* @param {(value: any, index: number) => boolean} condition Function to determine if iteration should continue.
* @param {object} [thisArg] An object to which the `this` keyword can refer in the callbackfn function. If `thisArg` is omitted, undefined is used as the `this` value.
*/
iterateWhile(iterator, condition, thisArg) {
for (let index = 0; index < this.length && condition.call(thisArg, this[index], index); index += 1) {
iterator.call(thisArg, this[index], index, this);
}
}
/**
* Similar to ordinary `reduce`, but before each `reducer` call,
* the condition function will be called. If that function returns false, iteration will stop, and the function will return the accumulated value.
* @param {(value: any, index: number, array: Ayay) => void} reducer
* @param {any} initialValue The initial value of the iteration
* @param {(value: any, index: number) => boolean} condition Function to determine if iteration should continue.
* @param {object} [thisArg] An object to which the `this` keyword can refer in the callbackfn function. If `thisArg` is omitted, undefined is used as the `this` value.
* @returns {any}
*/
reduceWhile(reducer, initialValue, condition, thisArg) {
let returnValue = initialValue;
this.iterateWhile(
(element, index) => returnValue = reducer.call(thisArg, returnValue, element, index, this),
(element, index) => condition.call(thisArg, returnValue, element, index),
);
return returnValue;
}
/**
* Transform the array using the mapper function. Before each call to `mapper`, `condition` is called. If `condition` returns false, the function returns the mapped collection, which can be shorter than the original array.
* @param {(item: any, index: number, array: Ayay) => any} mapper Function called for each item in array
* @param {(item: any, index: number, array: Ayay) => boolean} condition Function
* @param {object} [thisArg] An object to which the `this` keyword can refer in the callbackfn function. If `thisArg` is omitted, undefined is used as the `this` value.
* @returns {Ayay}
*/
mapWhile(mapper, condition, thisArg) {
return this.reduceWhile(
(mappedArray, element, index) => mappedArray.concat(mapper.call(thisArg, element, index, this)),
Ayay.of(),
(mappedArray, element, index) => condition.call(thisArg, element, index, this)
);
}
/**
* Returns array with unique items. Pass a function to select how to check uniqueness.
* @param mapper
* @returns {Ayay} Ayay with unique items
*/
uniqueItems(mapper = item => item) {
const uniqueItems = new Set();
return this.filter((...args) => {
const unique = mapper(...args);
if (!uniqueItems.has(unique)) {
uniqueItems.add(unique)
return true;
}
});
}
/**
* Returns a new array containing all but the last item of the original array.
* @returns {Ayay}
*/
arrayByDroppingLastItem() {
return this.arrayByRemovingItemAtIndex(this.lastIndex);
}
/**
* Joins elements putting `lastSeperator` between two last elements.
* @param {string} [lastSeparator='and'] The last separator
*/
prettyJoin(lastSeparator = 'and') {
return []
.concat(this.arrayByDroppingLastItem().join(', ') || [])
.concat(this.slice(-1))
.join(` ${lastSeparator} `);
}
/**
* Constructs a new Ayay by splitting a string on the format returned by `prettyJoin`, for example `'cat and dog'`.
* @param {string} string Input string
* @param {string} [lastSeparator='and'] The word between the two last items. Default 'and'.
* @returns {Ayay}
*/
static fromPrettyJoined(string, lastSeparator = 'and') {
return Ayay.from(string.split(` ${lastSeparator} `)
.flatMap(part => part.split(', ')))
}
/**
* Creates an array instance with given size and content.
* @param {number} size The length of the new array
* @param {any} template The content for each item.
* If it is an object, each item in the array will be a shallow copy.
* If it is a function, it will be called for each item with the index.
* @returns {Ayay}
*/
static seedWith(size, template = 'ayay') {
const newArray = new Ayay(size);
switch (typeof template) {
case 'function': return newArray.fill(0).map((_, index) => template(index));
case 'object': return newArray.fill(0).map(() => template instanceof Array ? [...template] : { ...template });
default: return newArray.fill(template);
}
}
/**
* Returns a shallow copy as an ordinary Array instance.
*/
toVanilla() {
return [...this];
}
// Random utils
/**
* Returns a shallow copy of the array, with elements shuffled.
* @returns {Ayay}
*/
shuffledArray() {
return this.sampleOfSize(this.length);
}
/**
* Returns a random element from the array.
*/
randomItem() {
return this[this.randomIndex()];
}
/**
* Returns a random index of the array.
* @returns {number}
*/
randomIndex() {
return Math.floor(Math.random() * this.length);
}
/**
* Returns a new array with some of the items of the original array, in random order.
* @param {number} size The size of the sample
* @returns {Ayay}
*/
sampleOfSize(size) {
if (size === 0 || this.length === 0) {
return Ayay.of();
}
const pickedIndex = this.randomIndex();
return Ayay.of(this[pickedIndex],
...this.arrayByRemovingItemAtIndex(pickedIndex).sampleOfSize(size - 1)
);
}
/**
* Returns an array holding all possible permutations of the original array.
* @returns {Ayay}
*/
permutations() {
return this.length <= 1 ? [this] : this.map(
(firstElement, index) =>
this.arrayByRemovingItemAtIndex(index)
.permutations()
.map(permutation => [firstElement, ...permutation])
).flat()
}
// Object utils
/**
* Resolves a value from an object using the elements in the array as a key path.
* Example `Ayay.of('a', 'b', 'c').useAsKeyPathIn({a: {b: {c: 'yes'}}}) // 'yes'`
* @param {any} object The object to get from
* @param {any} [defaultValue] Optional value to return if nothing is found at given keypath
*/
useAsKeyPathIn(object, defaultValue) {
const value = this.reduceWhile(
(currentTarget, key) => currentTarget && currentTarget[key],
object,
target => target
);
return value === undefined ? defaultValue : value;
}
/**
* Use the array elements as keys to pick in a given object.
* Returns a new object with given keys picked from `object`, and the remaining keys omitted.
* @param {object} object The object to pick from
*/
pickInObject(object) {
return this.reduce(
(pickedObject, key) => Object.assign(pickedObject, key in object && { [key]: object[key] }),
{}
);
}
/**
* Use the array elements as keys to omit in a given object.
* Returns a new object with given keys omitted from `object`.
* @param {object} object
*/
omitInObject(object) {
return this.reduce(
(omittedObject, key) => {
const { [key]: omitted, ...keptItems } = omittedObject;
return keptItems;
},
object);
}
/**
* Converts the array into an object using the provider mappers. Default behaviour converts values to keys, and indices to values:
* `Ayay.of('a', 'b', 'c').transpose() // {a: 1, b: 2, c: 3}`
* @param {{keyMapper?: (value: any, index: number) => string, valueMapper?: (value: any, index: number) => any }} [param0]
*/
transpose({ keyMapper = value => value, valueMapper = (value, index) => index } = {}) {
return this.reduce(
(object, item, index) => Object.assign(object, {
[keyMapper(item, index, this)]: valueMapper(item, index, this)
}),
{}
);
}
/**
* Group items by either a value of a property, or by a function called for each item.
* Returns an object with group names as keys, and group members as values.
* Example:
*
* `Ayay.of({name: 'carrot', type: 'integer'}, {name: 'tomato', type}, {name: 'banana', type: 'fruit'}
* ).groupBy('type', ({name}) => name) // { vegetable: ['carrot', 'tomato'], fruit: ['banana'] }`
* @param {string|(value: any) => string} groupDeterminant String or function called with item to determine group for item.
* @param {(value: any) => any} [valueMapper] Optional transform function for the value. Defaults to identity function.
* @returns {{[group: string]: Ayay}}
*/
groupBy(groupDeterminant, valueMapper = value => value) {
return this.reduce((groups, item) => {
const group = typeof groupDeterminant == 'string' ? item[groupDeterminant] : groupDeterminant(item)
if (group) {
(groups[group] ??= []).push(valueMapper(item))
}
return groups
}, {})
}
}