diff --git a/src/operators/index.ts b/src/operators/index.ts index 643d4a4..619f79b 100644 --- a/src/operators/index.ts +++ b/src/operators/index.ts @@ -27,3 +27,4 @@ export { join } from './join' export { endWith } from './end-with' export { startWith } from './start-with' export { scan } from './scan' +export { slideThrough, slideThroughCyclic } from './slide-through' diff --git a/src/operators/pairwise.ts b/src/operators/pairwise.ts index 1e98463..a1450cc 100644 --- a/src/operators/pairwise.ts +++ b/src/operators/pairwise.ts @@ -16,6 +16,8 @@ import { Operator } from '../core/types' * Empty iterator and an iterator with a single value will both result in an * empty iterator. * + * This is a specialization of {@link slideThrough}. + * * @returns * Operator * @@ -68,6 +70,8 @@ export function pairwise (): Operator { * yielded pair will consist of the last and the first value of the source * iterator. * + * This is a specialization of {@link slideThroughCyclic}. + * * @returns * Operator * diff --git a/src/operators/slide-through.ts b/src/operators/slide-through.ts new file mode 100644 index 0000000..7ffc889 --- /dev/null +++ b/src/operators/slide-through.ts @@ -0,0 +1,212 @@ +import { Operator } from '../core/types' + + +/** + * @short + * Like `pairwise`, but you *slide through* a custom window size. + * + * @categories + * operator + * + * @description + * Processes an iterable by yielding successive overlapping tuples of a specified + * window size. Each tuple contains `windowSize` consecutive elements from the source + * iterable. As iteration progresses, the window slides forward by one position, + * excluding the first element of the previous window and including the next element + * from the iterable. This operation continues until the source iterable is fully + * consumed. + * + * It follows that the size of the resulting iterable will be the same as the window + * size, unless the size of the source iterable is less than the window size -- in + * that case, the resulting iterable is empty. + * + * This is a generalization of {@link pairwise}. + * + * @since + * 0.0.3 + * + * @parameter + * windowSize + * number + * The size of each yielded tuple. + * + * @returns + * Operator> + * + * @example + * j.pipe( + * [1, 2, 3, 4, 5], + * j.slideThrough(3), + * ) + * // => [[1, 2, 3], [2, 3, 4], [3, 4, 5]] + * + * @example + * j.pipe( + * [1, 2, 3], + * j.slideThrough(4), + * ) + * // => [] + */ +export function slideThrough (windowSize: 1): Operator +export function slideThrough (windowSize: 2): Operator +export function slideThrough (windowSize: 3): Operator +export function slideThrough (windowSize: 4): Operator +export function slideThrough (windowSize: 5): Operator +export function slideThrough (windowSize: 6): Operator +export function slideThrough (windowSize: 7): Operator +export function slideThrough (windowSize: 8): Operator +export function slideThrough (windowSize: 9): Operator +export function slideThrough (windowSize: 10): Operator +export function slideThrough (windowSize: number): Operator> +export function slideThrough (windowSize: number): Operator> { + + if (!Number.isInteger(windowSize) || windowSize < 1) { + throw new RangeError(`Window size must be an integer not less than 1; an attempt was made to define the window size as ${windowSize}.`) + } + + return function* (iterable: Iterable): IterableIterator> { + const window: Array = [] + for (const value of iterable) { + window.push(value) + if (window.length == windowSize) { + yield [...window] + window.shift() + } + } + } + +} + +/** + * @short + * Like `slideThrough`, but *cyclic*. + * + * @categories + * operator + * + * @description + * Processes an iterable by yielding successive overlapping sub-arrays of a specified + * window size, similar to `slideThrough`. However, upon fully consuming the source + * iterable, this operator wraps around to the beginning, continuing the sliding + * window operation in a cyclic manner. The final sub-arrays combine elements from + * the end of the iterable with those from the beginning, forming a continuous loop. + * + * It follows that the size of the resulting iterable will always be either the size + * of the source iterable or the window size, whichever is smaller. + * + * This is a generalization of {@link pairwiseCyclic}. + * + * @since + * 0.0.3 + * + * @parameter + * windowSize + * number + * The size of each yielded tuple. + * + * @returns + * Operator> + * + * @example + * j.pipe( + * [1, 2, 3, 4, 5], + * j.slideThrough(3), + * ) + * // => [ + * // [1, 2, 3], + * // [2, 3, 4], + * // [3, 4, 5], + * // [4, 5, 1], + * // [5, 1, 2], + * // ] + * + * @example + * j.pipe( + * [1, 2, 3], + * j.slideThrough(4), + * ) + * // => [ + * // [1, 2, 3, 1], + * // [2, 3, 1, 2], + * // [3, 1, 2, 3], + * // ] + */ +export function slideThroughCyclic (windowSize: 1): Operator +export function slideThroughCyclic (windowSize: 2): Operator +export function slideThroughCyclic (windowSize: 3): Operator +export function slideThroughCyclic (windowSize: 4): Operator +export function slideThroughCyclic (windowSize: 5): Operator +export function slideThroughCyclic (windowSize: 6): Operator +export function slideThroughCyclic (windowSize: 7): Operator +export function slideThroughCyclic (windowSize: 8): Operator +export function slideThroughCyclic (windowSize: 9): Operator +export function slideThroughCyclic (windowSize: 10): Operator +export function slideThroughCyclic (windowSize: number): Operator> +export function slideThroughCyclic (windowSize: number): Operator> { + + if (!Number.isInteger(windowSize) || windowSize < 1) { + throw new RangeError(`Window size must be an integer not less than 1; an attempt was made to define the window size as ${windowSize}.`) + } + + return function* (iterable: Iterable): IterableIterator> { + + // This will slide across the source iterable. + // We'll be mutating this array, but we'll yield a shallow copy. + const window: Array = [] + + // We need the head later due to the cyclic nature of the operator. + const head: Array = [] + + for (const value of iterable) { + if (head.length < windowSize) { + head.push(value) + } + window.push(value) + if (window.length == windowSize) { + yield [...window] + window.shift() + } + } + + if (head.length === windowSize) { + + // The source iterable is at least as long as the window size, so everything plays out regularly. + // We just need to yield `windowSize - 1` more tuples, and wrap the window to the beginning. + for (let i = 0; i < windowSize - 1; i++) { + window.push(head[i]) + yield [...window] + window.shift() + } + + } else /* head.length < windowSize */ { + + // The source iterable was consumed before the window was fully filled in. + // This means that we didn't yield anything yet, and that both head and window are populated with all + // values yielded from the source observable. + + // Before proceeding, we must handle a special edge case here: the source iterable can be empty. + // In that case we're already done, as the correct result is an empty iterable. + if (head.length == 0) return + + // We firstly populate the window by iterating over the head (which is in this case the whole source iterable) + // as many times as it's needed to fully fill it in. + let index = head.length + while (window.length < windowSize) { + window.push(head[index++ % head.length]) + } + + // We're ready for the first tuple immediately. + yield [...window] + + // Now we slide for the other tuples. + for (let i = 0; i < head.length - 1; i++) { + window.shift() + window.push(head[index++ % head.length]) + yield [...window] + } + + } + + } + +} diff --git a/tests/operators.spec.ts b/tests/operators.spec.ts index c651bbd..389266c 100644 --- a/tests/operators.spec.ts +++ b/tests/operators.spec.ts @@ -1281,6 +1281,164 @@ describe(`Operators`, () => { }) + describe(`slideThrough`, () => { + + it(`throws a RangeError when the window size is not an integer`, () => { + chai.assert.throws(() => j.slideThrough(1.5), RangeError) + }) + + it(`throws a RangeError when the window size is zero`, () => { + chai.assert.throws(() => j.slideThrough(0), RangeError) + }) + + it(`gives empty iterable for a given empty iterable`, () => { + const actual = j.pipe([], j.slideThrough(1)) + chai.assert.deepEqual([...actual], []) + }) + + it(`gives empty iterable for a given iterable of size 1 and window of size 2`, () => { + const actual = j.pipe(['a'], j.slideThrough(2)) + chai.assert.deepEqual([...actual], []) + }) + + it(`yields a singleton for a given iterable of size 1 and window of size 1`, () => { + const actual = j.pipe(['a'], j.slideThrough(1)) + chai.assert.deepEqual([...actual], [['a']]) + }) + + it(`gives a single pair for a given iterable of size 2 and window of size 2`, () => { + const actual = j.pipe(['a', 'b'], j.slideThrough(2)) + chai.assert.deepEqual([...actual], [['a', 'b']]) + }) + + it(`gives two singletons for a given iterable of size 2 and window of size 1`, () => { + const actual = j.pipe(['a', 'b'], j.slideThrough(1)) + chai.assert.deepEqual([...actual], [['a'], ['b']]) + }) + + it(`works for several items and a larger window size`, () => { + const actual = j.pipe(['a', 'b', 'c', 'd', 'e', 'f'], j.slideThrough(4)) + const expected = [ + ['a', 'b', 'c', 'd'], + ['b', 'c', 'd', 'e'], + ['c', 'd', 'e', 'f'], + ] + chai.assert.deepEqual([...actual], expected) + }) + + it(`yields a single tuple when the iterable size and the window size are equal`, () => { + const actual = j.pipe([1, 2, 3, 4, 5], j.slideThrough(5)) + const expected = [ + [1, 2, 3, 4, 5], + ] + chai.assert.deepEqual([...actual], [...expected]) + }) + + it(`@example 1`, () => { + const actual = j.pipe( + [1, 2, 3, 4, 5], + j.slideThrough(3), + ) + const expected = [[1, 2, 3], [2, 3, 4], [3, 4, 5]] + chai.assert.sameDeepOrderedMembers([...actual], [...expected]) + }) + + it(`@example 2`, () => { + const actual = j.pipe( + [1, 2, 3], + j.slideThrough(4), + ) + const expected: number[][] = [] + chai.assert.sameDeepOrderedMembers([...actual], [...expected]) + }) + + }) + + describe(`slideThroughCyclic`, () => { + + it(`throws a RangeError when the window size is not an integer`, () => { + chai.assert.throws(() => j.slideThroughCyclic(1.5), RangeError) + }) + + it(`throws a RangeError when the window size is zero`, () => { + chai.assert.throws(() => j.slideThroughCyclic(0), RangeError) + }) + + it(`yields nothing for a given empty iterable`, () => { + const actual = j.pipe([], j.slideThroughCyclic(1)) + chai.assert.deepEqual([...actual], []) + }) + + it(`yields one quadruple for a given iterable of size one`, () => { + const actual = j.pipe([1], j.slideThroughCyclic(4)) + const expected = [[1, 1, 1, 1]] + chai.assert.deepEqual([...actual], [...expected]) + }) + + it(`yields five tuples given an iterable of five elements and a window size of five`, () => { + const actual = j.pipe([1, 2, 3, 4, 5], j.slideThroughCyclic(5)) + const expected = [ + [1, 2, 3, 4, 5], + [2, 3, 4, 5, 1], + [3, 4, 5, 1, 2], + [4, 5, 1, 2, 3], + [5, 1, 2, 3, 4], + ] + chai.assert.deepEqual([...actual], [...expected]) + }) + + it(`yields five tuples given an iterable of five elements and a window size of three`, () => { + const actual = j.pipe([1, 2, 3, 4, 5], j.slideThroughCyclic(3)) + const expected =[ + [1, 2, 3], + [2, 3, 4], + [3, 4, 5], + [4, 5, 1], + [5, 1, 2], + ] + chai.assert.deepEqual([...actual], [...expected]) + }) + + it(`yields five tuples given an iterable of five elements and a window size of seven`, () => { + const actual = j.pipe([1, 2, 3, 4, 5], j.slideThroughCyclic(7)) + const expected = [ + [1, 2, 3, 4, 5, 1, 2], + [2, 3, 4, 5, 1, 2, 3], + [3, 4, 5, 1, 2, 3, 4], + [4, 5, 1, 2, 3, 4, 5], + [5, 1, 2, 3, 4, 5, 1], + ] + chai.assert.deepEqual([...actual], [...expected]) + }) + + it(`correctly wraps around the iterable several times if needed`, () => { + const actual = j.pipe([1, 2, 3], j.slideThroughCyclic(10)) + const expected = [ + [1, 2, 3, 1, 2, 3, 1, 2, 3, 1], + [2, 3, 1, 2, 3, 1, 2, 3, 1, 2], + [3, 1, 2, 3, 1, 2, 3, 1, 2, 3], + ] + chai.assert.deepEqual([...actual], [...expected]) + }) + + it(`yields two tuples for a given iterable of size two`, () => { + const actual = j.pipe([1, 2], j.slideThroughCyclic(5)) + const expected = [[1, 2, 1, 2, 1], [2, 1, 2, 1, 2]] + chai.assert.deepEqual([...actual], [...expected]) + }) + + it(`yields three tuples given an iterable of three elements and a window size of five`, () => { + const actual = j.pipe([1, 2, 3], j.slideThroughCyclic(5)) + const expected = [ + [1, 2, 3, 1, 2], + [2, 3, 1, 2, 3], + [3, 1, 2, 3, 1], + ] + chai.assert.deepEqual([...actual], [...expected]) + }) + + }) + describe(`sortUsing`, () => { it(`@example 1`, () => {