@v4fire/core
Version:
V4Fire core library
694 lines (596 loc) • 14.5 kB
text/typescript
/*!
* V4Fire Core
* https://github.com/V4Fire/Core
*
* Released under the MIT license
* https://github.com/V4Fire/Core/blob/master/LICENSE
*/
/**
* [[include:core/range/README.md]]
* @packageDocumentation
*/
import type { RangeValue, RangeType } from 'core/range/interface';
export * from 'core/range/interface';
/**
* A class to create a range with the specified type.
* The class supports ranges of numbers, strings, and dates.
*
* @typeparam T - range type value
*/
export default class Range<T extends RangeValue> {
/**
* Bottom bound
*/
start: number;
/**
* Top bound
*/
end: number;
/**
* Range type
*/
type: RangeType;
/**
* True if the range is reversed
*/
isReversed: boolean = false;
/**
* @param [start] - start position:
* * if it wrapped by an array, the bound won't be included to the range;
* * If passed as `null`, it means `-Infinite`;
*
* @param [end] - end position:
* * if it wrapped by an array, the bound won't be included to the range;
* * If passed as `null`, it means `Infinite`;
*
* @example
* ```js
* // [0, 1, 2, 3]
* console.log(new Range(0, 3).toArray());
*
* // [0, 1, 2]
* console.log(new Range(0, [3]).toArray());
*
* // ['b', 'c']
* console.log(new Range(['a'], ['d']).toArray());
*
* // []
* console.log(new Range('a', ['a']).toArray());
*
* // 'Wed Oct 18 1989 00:00:00..'
* console.log(new Range(new Date(1989, 9, 18)).string());
*
* // '..Wed Oct 18 1989 00:00:00'
* console.log(new Range(null, new Date(1989, 9, 18)).string());
* ```
*/
constructor(
start: T[] | Nullable<T> | number = -Infinity,
end: T[] | Nullable<T> | number = Infinity
) {
const
unwrap = (v) => Object.isArray(v) ? v[0] : v;
const
unwrappedStart = unwrap(start),
unwrappedEnd = unwrap(end);
let
type;
if (Object.isArray(start)) {
const
r = new Range(unwrappedStart);
if (unwrappedStart !== unwrappedEnd) {
start = r.toType(r.start + (unwrappedStart > unwrappedEnd ? -1 : 1));
} else {
start = NaN;
type = r.type;
}
}
if (Object.isArray(end)) {
const
r = new Range(unwrappedEnd);
if (unwrappedStart !== unwrappedEnd) {
end = r.toType(r.start + (unwrappedStart > unwrappedEnd ? 1 : -1));
} else {
end = NaN;
type ??= r.type;
}
}
if (Object.isString(start) || Object.isString(end)) {
this.type = 'string';
if (Object.isString(start)) {
this.start = codePointAt(start);
} else if (Object.isNumber(start)) {
if (isFinite(start)) {
this.start = codePointAt(String.fromCodePoint(start));
} else {
this.start = start;
}
} else if (start == null) {
this.start = -Infinity;
} else {
this.start = NaN;
}
if (Object.isString(end)) {
this.end = codePointAt(end);
} else if (Object.isNumber(end)) {
if (isFinite(end)) {
this.end = codePointAt(String.fromCodePoint(end));
} else {
this.end = end;
}
} else if (end == null) {
this.end = Infinity;
} else {
this.end = NaN;
}
} else {
this.type = type ?? (Object.isDate(start) || Object.isDate(end) ? 'date' : 'number');
this.start = start == null ? -Infinity : Number(start);
this.end = Number(end);
}
if (this.start > this.end) {
[this.start, this.end] = [this.end, this.start];
this.isReversed = true;
}
}
/**
* Returns an iterator from the range
*/
[Symbol.iterator](): IterableIterator<T> {
return this.values();
}
/**
* Returns true if the range is valid
*
* @example
* ```js
* console.log(new Range('a', {}).isValid() === false);
*
* console.log(new Range(new Date('boom!')).isValid() === false);
*
* // The empty range is not valid
* console.log(new Range([0], [0]).isValid() === false);
* ```
*/
isValid(): boolean {
return !Number.isNaN(this.start) && !Number.isNaN(this.end);
}
/**
* Returns true if the specified element is contained inside the range
* (the element can be a simple value or another range)
*
* @param el
* @example
* ```js
* // true
* console.log(new Range(0, 10).contains(4));
*
* // false
* console.log(new Range(0, [10]).contains(10));
*
* // false
* console.log(new Range(0, 10).contains(12));
*
* // false
* console.log(new Range(0, 10).contains('a'));
*
* // true
* console.log(new Range(0, 10).contains(Range(3, 6)));
*
* // false
* console.log(new Range(0, 10).contains(Range(3, 16)));
*
* // false
* console.log(new Range(0, 10).contains(Range('a', 'b')));
* ```
*/
contains(el: unknown): boolean {
if (el instanceof Range) {
return this.start <= el.start && this.end >= el.end;
}
const val = Object.isString(el) ? codePointAt(el) : Number(el);
return this.start <= val && val <= this.end;
}
/**
* Returns a new range with the latest starting point as its start, and the earliest ending point as its end.
* If the two ranges do not intersect, this will effectively produce an empty range.
*
* The method preserves element ordering of the first range.
* The intersection of ranges with different types will always produce an empty range.
*
* @param range
* @example
* ```js
* // 8..10
* console.log(new Range(0, 10).intersect(new Range([7], 14)).toString());
*
* // 10..7
* console.log(new Range(10, 0).intersect(new Range(7, 14)).toString());
*
* // 7..10
* console.log(new Range(0, 10).intersect(new Range(7)).toString());
*
* // 7..
* console.log(new Range(0).intersect(new Range(7)).toString());
*
* // ''
* console.log(new Range(0, 10).intersect(new Range(11, 14)).toString());
*
* // ''
* console.log(new Range(0, 10).intersect(new Range('a', 'z')).toString());
* ```
*/
intersect(range: Range<T extends string ? string : T>): Range<T> {
const
start = <T>Math.max(this.start, range.start),
end = <T>Math.min(this.end, range.end);
if (this.type !== range.type) {
return new Range(<T>0, [<T>0]);
}
const newRange = start <= end ?
new Range(start, end) :
new Range(<T>0, [<T>0]);
newRange.type = this.type;
newRange.isReversed = this.isReversed;
return newRange;
}
/**
* Returns a new range with the earliest starting point as its start, and the latest ending point as its end.
* If the two ranges do not intersect, this will effectively remove the "gap" between them.
*
* The method preserves element ordering of the first range.
* The union of ranges with different types will always produce an empty range.
*
* @param range
* @example
* ```js
* // 0..13
* console.log(new Range(0, 10).union(new Range(7, [14])).toString());
*
* // 14..0
* console.log(new Range(10, 0).union(new Range(7, 14)).toString());
*
* // 0..
* console.log(new Range(0, 10).union(new Range(7)).toString());
*
* // ..
* console.log(new Range().union(new Range(7)).toString());
*
* // ''
* console.log(new Range(0, 10).union(new Range('a', 'z')).toString());
* ```
*/
union(range: Range<T extends string ? string : T>): Range<T> {
if (this.type !== range.type) {
return new Range(<T>0, [<T>0]);
}
const newRange = new Range<T>(
<T>Math.min(this.start, range.start),
<T>Math.max(this.end, range.end)
);
newRange.type = this.type;
newRange.isReversed = this.isReversed;
return newRange;
}
/**
* Clones the range and returns a new
*/
clone(): Range<T> {
const
range = new Range(<T>this.start, <T>this.end);
range.type = this.type;
range.isReversed = this.isReversed;
return range;
}
/**
* Clones the range with reversing of element ordering and returns a new
*
* @example
* ```js
* // [3, 2, 1, 0]
* console.log(new Range(0, 3).reverse().toArray());
* ```
*/
reverse(): Range<T> {
const range = new Range(<T>this.end, <T>this.start);
range.type = this.type;
return range;
}
/**
* Clamps an element to be within the range if it falls outside.
* If the range is invalid or empty, the method always returns `null`.
*
* @param el
* @example
* ```js
* // 3
* console.log(new Range(0, 10).clamp(3));
*
* // 'd'
* console.log(new Range('a', 'd').clamp('z'));
*
* // null
* console.log(new Range(0, [0]).clamp(10));
* ```
*/
clamp(el: unknown): T | null {
const
val = Object.isString(el) ? codePointAt(el) : Number(el);
if (!this.isValid()) {
return null;
}
if (this.end < val) {
return this.toType(this.end);
}
if (this.start > val) {
return this.toType(this.start);
}
return this.toType(val);
}
/**
* Returns a span of the range.
* The span includes both the start and the end.
*
* If the range is a date range, the value is in milliseconds.
* If the range is invalid or empty, the method always returns `0`.
*
* @example
* ```js
* // 4
* console.log(new Range(7, 10).span());
*
* // 0
* console.log(new Range(0, [0]).span());
* ```
*/
span(): number {
if (!this.isValid()) {
return 0;
}
if (!isFinite(this.start) || !isFinite(this.end)) {
return Infinity;
}
return this.end - this.start + 1;
}
/**
* Returns an iterator from the range
*
* @param [step] - step to iterate elements (for date ranges, it means milliseconds to shift)
* @example
* ```js
* for (const el of new Range(0, 3).values()) {
* // 0 1 2 3
* console.log(el);
* }
*
* for (const el of new Range(0, 3).values(2)) {
* // 0 2
* console.log(el);
* }
* ```
*/
values(step?: number): IterableIterator<T> {
const
that = this,
iter = createIter();
return {
[Symbol.iterator]() {
return this;
},
next: iter.next.bind(iter)
};
function* createIter() {
if (!that.isValid()) {
return;
}
if (step == null || step === 0) {
if (that.type === 'date') {
if (isFinite(that.start) && isFinite(that.end)) {
step = (that.end - that.start) * 0.01;
} else {
step = (30).days();
}
} else {
step = 1;
}
}
if (!Number.isNatural(step)) {
throw new TypeError('Step value can be only a natural number');
}
let
start,
end;
const
isStringRange = that.type === 'string';
if (isFinite(that.start)) {
start = that.start;
} else if (isStringRange) {
start = 0;
} else {
start = Number.MIN_SAFE_INTEGER;
}
if (isFinite(that.end)) {
end = that.end;
} else {
end = Number.MAX_SAFE_INTEGER;
}
if (that.isReversed) {
for (let i = end; i >= start; i -= step) {
try {
yield that.toType(i);
} catch {
break;
}
}
} else {
for (let i = start; i <= end; i += step) {
try {
yield that.toType(i);
} catch {
break;
}
}
}
}
}
/**
* Returns an iterator from the range that produces iteration indices
*
* @param [step] - step to iterate elements (for date ranges, it means milliseconds to shift)
* @example
* ```js
* for (const el of new Range(3, 1).indices()) {
* // 0 1 2
* console.log(el);
* }
*
* for (const el of new Range(0, 3).indices(2)) {
* // 0 1
* console.log(el);
* }
* ```
*/
indices(step?: number): IterableIterator<number> {
const
that = this,
iter = createIter();
return {
[Symbol.iterator]() {
return this;
},
next: iter.next.bind(iter)
};
function* createIter() {
const
iter = that.values(step);
for (let el = iter.next(), i = 0; !el.done; el = iter.next(), i++) {
yield i;
}
}
}
/**
* Returns an iterator from the range that produces pairs of iteration indices and values
*
* @param [step] - step to iterate elements (for date ranges, it means milliseconds to shift)
* @example
* ```js
* for (const el of new Range(3, 1).entries()) {
* // [0, 3] [1, 2] [2 3]
* console.log(el);
* }
*
* for (const el of new Range(0, 3).entries(2)) {
* // [0, 0] [1, 2]
* console.log(el);
* }
* ```
*/
entries(step?: number): IterableIterator<[number, T]> {
const
that = this,
iter = createIter();
return {
[Symbol.iterator]() {
return this;
},
next: iter.next.bind(iter)
};
function* createIter() {
const
iter = that.values(step);
for (let el = iter.next(), i = 0; !el.done; el = iter.next(), i++) {
yield [i, el.value];
}
}
}
/**
* Creates an array from the range and returns it.
* Mind, you can't transform infinite ranges to arrays, but you free to use iterators.
*
* @param [step] - step to iterate elements (for date ranges, it means milliseconds to shift)
* @example
* ```js
* // [0, 3, 6, 9]
* console.log(new Range(0, 10).toArray(3));
*
* // ['a', 'b']
* console.log(new Range('a', ['c']).toArray());
*
* // []
* console.log(new Range(0, [0]).toArray());
* ```
*/
toArray(step?: number): T[] {
if (this.isValid() && !isFinite(this.span())) {
throw new RangeError("Can't create an array of the infinitive range. Use an iterator instead.");
}
return [...this.values(step)];
}
/**
* Creates a string from the range and returns it.
* If the range invalid or empty, the method always returns an empty string.
*
* @example
* ```js
* // 0..10
* console.log(new Range(0, 10).toString());
*
* // 0..9
* console.log(new Range(0, [10]).toString());
*
* // 0..
* console.log(new Range(0).toString());
*
* // ..z
* console.log(new Range(null, 'z').toString());
*
* // ''
* console.log(new Range(0, [0]).toString());
* ```
*/
toString(): string {
if (!this.isValid()) {
return '';
}
const
res = <Array<T | string>>[];
if (isFinite(this.start)) {
res.push(this.toType(this.start));
} else {
res.push('');
}
if (isFinite(this.end)) {
res.push(this.toType(this.end));
} else {
res.push('');
}
if (this.isReversed) {
res.reverse();
}
return res.join('..');
}
/**
* Converts a value to the real range type
*
* @param value
* @example
* ```js
* // j
* console.log(new Range('a', 'z).toType(106));
* ```
*/
toType(value: number): T {
switch (this.type) {
case 'string':
return <T>String.fromCodePoint(value);
case 'date':
return <T>new Date(value);
default:
return <T>value;
}
}
}
function codePointAt(str: string, pos: number = 0): number {
const v = str.codePointAt(pos);
return v == null || Number.isNaN(v) ? NaN : v;
}