@jarenjs/core 0.46.5 → 0.49.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,343 @@
1
+ //@ts-check
2
+
3
+ //#region Downsampling
4
+ // A hundred thousand points on a line eight hundred pixels wide is a
5
+ // hundred and twenty five points per pixel. Something has to choose, and
6
+ // the only question is whether the choosing is visible.
7
+ //
8
+ // Two strategies, both established, both deterministic:
9
+ //
10
+ // **lttb** — largest-triangle-three-buckets. The interior is divided
11
+ // into as many buckets as there is budget, and each contributes the
12
+ // one point making the largest triangle with the point already kept
13
+ // and the next bucket's centre of mass. It keeps the shape a reader
14
+ // recognizes: peaks stay peaks, and a slow drift does not become a
15
+ // staircase.
16
+ //
17
+ // **minmax** — each bucket contributes its lowest and highest point,
18
+ // in the order they occurred. It keeps the *envelope* exactly, which
19
+ // is what a reader watching for an excursion is actually looking at,
20
+ // at the cost of the shape between the extremes.
21
+ //
22
+ // Three rules stop either from lying:
23
+ //
24
+ // **A gap is never bridged.** A `null` reading is a measured absence,
25
+ // and a line drawn straight through it claims data that was never
26
+ // collected. So the series is cut at every run of nulls, each run
27
+ // keeps a marker in the output, and every segment is sampled on its
28
+ // own — a peak in a short segment is not competing for budget against
29
+ // a long one's noise.
30
+ //
31
+ // **The ends stay.** Every segment's first and last point survive, so
32
+ // the rendered line starts and ends where the data does — and a
33
+ // series that ends in a run of gaps keeps its LAST instant as that
34
+ // run's marker, so the rendered domain still reaches the end of the
35
+ // data rather than stopping at the final reading.
36
+ //
37
+ // **An impossible target is refused.** Those markers and endpoints are
38
+ // the minimum a faithful picture needs. Asking for fewer points than
39
+ // that has no honest answer, and returning a prettier lie is worse
40
+ // than a refusal naming the number.
41
+ //
42
+ // The result reports `sourceCount` and `renderedCount` beside the
43
+ // points, so a consumer can always say how much of the data it is
44
+ // looking at.
45
+
46
+ import { canonicalSeries } from './normalize.js';
47
+ import { requireSpecMembers } from './selector.js';
48
+
49
+ /** @typedef {import('./normalize.js').Sample} Sample */
50
+
51
+ /**
52
+ * @typedef {Object} Downsampled
53
+ * @property {(Sample & Record<string, any>)[]} points - the kept
54
+ * samples, ascending, with every gap still a gap
55
+ * @property {number} sourceCount - how many samples went in
56
+ * @property {number} renderedCount - how many came out; never more than
57
+ * `target`, and less when a bucket's two extremes were one point
58
+ * @property {string} method - which strategy chose them
59
+ */
60
+
61
+ const METHODS = Object.freeze(['lttb', 'minmax']);
62
+
63
+ /**
64
+ * `downsampleSeries`' closed specification: how many points may come
65
+ * back, which strategy chooses them, and where a row keeps its
66
+ * instant and its reading.
67
+ */
68
+ export const DOWNSAMPLE_MEMBERS = Object.freeze([
69
+ 'target', 'method', 'at', 'value',
70
+ ]);
71
+
72
+ /**
73
+ * Reduce a series to at most `target` points without bridging a gap or
74
+ * moving an end.
75
+ *
76
+ * @param {any[]} rows - the samples, in any order
77
+ * @param {Object} spec
78
+ * @param {number} spec.target - the most points to return, at least the
79
+ * mandatory endpoints and gap markers
80
+ * @param {'lttb'|'minmax'} [spec.method] default `'lttb'`
81
+ * @param {string | ((item: any, index: number) => any)} [spec.at]
82
+ * @param {string | ((item: any, index: number) => any)} [spec.value]
83
+ * @returns {Downsampled}
84
+ * @throws {TypeError} for an unknown method, a target that is not a
85
+ * positive whole number, or a row that is not a canonical sample
86
+ * @throws {RangeError} when `target` cannot hold the segment endpoints
87
+ * and gap markers the data requires
88
+ * @example
89
+ * const { points, sourceCount, renderedCount } =
90
+ * downsampleSeries(readings, { target: 800 });
91
+ */
92
+ export function downsampleSeries(rows, spec) {
93
+ requireSpecMembers(spec, DOWNSAMPLE_MEMBERS, 'downsampleSeries',
94
+ 'a downsample spec is an object with a \'target\'');
95
+ const method = spec.method ?? 'lttb';
96
+ if (typeof method !== 'string' || !METHODS.includes(method)) {
97
+ throw new TypeError(`method is ${METHODS.map((m) => `'${m}'`).join(', ')}, not ${
98
+ JSON.stringify(method)}`);
99
+ }
100
+ const { target } = spec;
101
+ if (!Number.isInteger(target) || target < 1)
102
+ throw new TypeError(`target is a positive whole number of points, not ${target}`);
103
+ const samples = canonicalSeries(rows, spec);
104
+ const sourceCount = samples.length;
105
+ if (sourceCount === 0)
106
+ return { points: [], sourceCount: 0, renderedCount: 0, method };
107
+
108
+ const blocks = blocksOf(samples);
109
+ const mandatory = blocks.reduce((n, b) => n + b.mandatory, 0);
110
+ if (target < mandatory) {
111
+ throw new RangeError(`a target of ${target} cannot hold the ${mandatory} points this series`
112
+ + ` requires — ${blocks.filter((b) => !b.gap).length} segment end(s) and`
113
+ + ` ${blocks.filter((b) => b.gap).length} gap marker(s) — and dropping one of them would`
114
+ + ' draw a line through data that is not there');
115
+ }
116
+ if (sourceCount <= target)
117
+ return { points: samples.slice(), sourceCount, renderedCount: sourceCount, method };
118
+
119
+ allocate(blocks, target - mandatory);
120
+
121
+ /** @type {(Sample & Record<string, any>)[]} */
122
+ const points = [];
123
+ for (const block of blocks) {
124
+ if (block.gap) {
125
+ // the run's first instant, except where the run ends the series:
126
+ // then its last, so the rendered domain still reaches the end of
127
+ // the data rather than stopping at the last reading
128
+ points.push(samples[block.last ? block.to : block.from]);
129
+ continue;
130
+ }
131
+ const length = block.to - block.from + 1;
132
+ const budget = block.mandatory + block.extra;
133
+ if (budget >= length) {
134
+ for (let i = block.from; i <= block.to; i++)
135
+ points.push(samples[i]);
136
+ }
137
+ else if (method === 'lttb')
138
+ lttb(samples, block.from, block.to, budget, points);
139
+ else
140
+ minmax(samples, block.from, block.to, budget, points);
141
+ }
142
+ return { points, sourceCount, renderedCount: points.length, method };
143
+ }
144
+
145
+ /**
146
+ * The series cut into alternating runs of readings and runs of gaps.
147
+ *
148
+ * A reading run must keep both its ends (or its single point); a gap run
149
+ * must keep one marker, which is what leaves a hole in the rendered
150
+ * line. Those are the mandatory points, and their total is the smallest
151
+ * target this series has an honest answer for.
152
+ * @param {Sample[]} samples
153
+ * @returns {{ gap: boolean, from: number, to: number, last: boolean, mandatory: number, extra: number }[]}
154
+ */
155
+ function blocksOf(samples) {
156
+ const out = [];
157
+ let from = 0;
158
+ for (let i = 1; i <= samples.length; i++) {
159
+ const same = i < samples.length
160
+ && (samples[i].value === null) === (samples[from].value === null);
161
+ if (same)
162
+ continue;
163
+ const gap = samples[from].value === null;
164
+ out.push({ gap, from, to: i - 1, last: i === samples.length, mandatory: gap ? 1 : Math.min(2, i - from), extra: 0 });
165
+ from = i;
166
+ }
167
+ return out;
168
+ }
169
+
170
+ /**
171
+ * Hand the budget left over after the mandatory points to the segments,
172
+ * in proportion to how much of each is still unrepresented.
173
+ *
174
+ * Largest remainder, ties to the earlier segment, then a redistribution
175
+ * pass for whatever a segment could not use — so the allocation is a
176
+ * function of the data alone and two runs over the same series produce
177
+ * the same picture.
178
+ * @param {{ gap: boolean, from: number, to: number, last: boolean, mandatory: number, extra: number }[]} blocks
179
+ * @param {number} budget
180
+ * @returns {void}
181
+ */
182
+ function allocate(blocks, budget) {
183
+ const segments = blocks.filter((b) => !b.gap);
184
+ /** How many points of each segment the mandatory ends do not cover. */
185
+ const room = segments.map((s) => (s.to - s.from + 1) - s.mandatory);
186
+ const total = room.reduce((n, r) => n + r, 0);
187
+ if (total === 0 || budget <= 0)
188
+ return;
189
+
190
+ let left = budget;
191
+ const share = new Array(segments.length).fill(0);
192
+ const remainders = [];
193
+ for (let i = 0; i < segments.length; i++) {
194
+ const exact = (budget * room[i]) / total;
195
+ share[i] = Math.min(room[i], Math.floor(exact));
196
+ left -= share[i];
197
+ remainders.push({ i, fraction: exact - Math.floor(exact) });
198
+ }
199
+ remainders.sort((a, b) => (b.fraction - a.fraction) || (a.i - b.i));
200
+ for (const { i } of remainders) {
201
+ if (left <= 0) break;
202
+ if (share[i] < room[i]) {
203
+ share[i]++;
204
+ left--;
205
+ }
206
+ }
207
+ // whatever the caps refused, offered again to whoever still has room
208
+ while (left > 0) {
209
+ let placed = 0;
210
+ for (let i = 0; i < segments.length && left > 0; i++) {
211
+ if (share[i] < room[i]) {
212
+ share[i]++;
213
+ left--;
214
+ placed++;
215
+ }
216
+ }
217
+ if (placed === 0) break;
218
+ }
219
+ for (let i = 0; i < segments.length; i++)
220
+ segments[i].extra = share[i];
221
+ }
222
+
223
+ /**
224
+ * Largest-triangle-three-buckets over one segment, appended in place.
225
+ *
226
+ * The classic formulation: the first point is kept, the interior is cut
227
+ * into `budget - 2` buckets of equal width, and each bucket gives up the
228
+ * point whose triangle with the previously kept point and the next
229
+ * bucket's average has the largest area. Buckets are disjoint and
230
+ * ascending, so the selection is in temporal order by construction.
231
+ * @param {Sample[]} samples
232
+ * @param {number} from
233
+ * @param {number} to
234
+ * @param {number} budget - at least 2, strictly less than the length
235
+ * @param {(Sample & Record<string, any>)[]} out
236
+ * @returns {void}
237
+ */
238
+ function lttb(samples, from, to, budget, out) {
239
+ const length = to - from + 1;
240
+ out.push(samples[from]);
241
+ if (budget > 2) {
242
+ const every = (length - 2) / (budget - 2);
243
+ let kept = from;
244
+ for (let i = 0; i < budget - 2; i++) {
245
+ // the next bucket's centre of mass — the triangle's third corner
246
+ const nextStart = from + Math.floor((i + 1) * every) + 1;
247
+ const nextEnd = Math.min(from + Math.floor((i + 2) * every) + 1, to);
248
+ let avgAt = samples[to].at;
249
+ let avgValue = /** @type {number} */ (samples[to].value);
250
+ if (nextEnd > nextStart) {
251
+ avgAt = 0;
252
+ avgValue = 0;
253
+ for (let j = nextStart; j < nextEnd; j++) {
254
+ avgAt += samples[j].at;
255
+ avgValue += /** @type {number} */ (samples[j].value);
256
+ }
257
+ avgAt /= nextEnd - nextStart;
258
+ avgValue /= nextEnd - nextStart;
259
+ }
260
+ const anchorAt = samples[kept].at;
261
+ const anchorValue = /** @type {number} */ (samples[kept].value);
262
+ const start = from + Math.floor(i * every) + 1;
263
+ const end = Math.min(from + Math.floor((i + 1) * every) + 1, to);
264
+ let best = start;
265
+ let bestArea = -1;
266
+ for (let j = start; j < end; j++) {
267
+ const area = Math.abs((anchorAt - avgAt) * (/** @type {number} */(samples[j].value) - anchorValue)
268
+ - (anchorAt - samples[j].at) * (avgValue - anchorValue));
269
+ if (area > bestArea) {
270
+ bestArea = area;
271
+ best = j;
272
+ }
273
+ }
274
+ out.push(samples[best]);
275
+ kept = best;
276
+ }
277
+ }
278
+ out.push(samples[to]);
279
+ }
280
+
281
+ /**
282
+ * Min/max over one segment, appended in place.
283
+ *
284
+ * The ends are kept, the interior is cut into `floor(slots / 2)` buckets,
285
+ * and each gives up its lowest and highest point in the order they
286
+ * occurred. When a bucket's two extremes are the same point it
287
+ * contributes once, which is why `renderedCount` can be under the
288
+ * target: the envelope is exact, and padding it with a point that is
289
+ * neither extreme would not make it more so.
290
+ * @param {Sample[]} samples
291
+ * @param {number} from
292
+ * @param {number} to
293
+ * @param {number} budget - at least 2, strictly less than the length
294
+ * @param {(Sample & Record<string, any>)[]} out
295
+ * @returns {void}
296
+ */
297
+ function minmax(samples, from, to, budget, out) {
298
+ out.push(samples[from]);
299
+ let slots = budget - 2;
300
+ if (slots > 0) {
301
+ const first = from + 1;
302
+ const length = to - first;
303
+ const buckets = Math.max(1, Math.floor(slots / 2));
304
+ // the value the odd slot's tie-break measures deviation from
305
+ const middle = (/** @type {number} */(samples[from].value)
306
+ + /** @type {number} */(samples[to].value)) / 2;
307
+ for (let b = 0; b < buckets && slots > 0; b++) {
308
+ const start = first + Math.floor((b * length) / buckets);
309
+ const end = first + Math.floor(((b + 1) * length) / buckets);
310
+ if (end <= start)
311
+ continue;
312
+ let low = start;
313
+ let high = start;
314
+ for (let j = start + 1; j < end; j++) {
315
+ const value = /** @type {number} */ (samples[j].value);
316
+ if (value < /** @type {number} */ (samples[low].value)) low = j;
317
+ if (value > /** @type {number} */ (samples[high].value)) high = j;
318
+ }
319
+ if (low === high) {
320
+ out.push(samples[low]);
321
+ slots--;
322
+ continue;
323
+ }
324
+ if (slots === 1) {
325
+ // room for one of the two: the one further from the segment's
326
+ // own midpoint, which is the one an eye would miss
327
+ const lowGap = Math.abs(/** @type {number} */(samples[low].value) - middle);
328
+ const highGap = Math.abs(/** @type {number} */(samples[high].value) - middle);
329
+ out.push(lowGap >= highGap ? samples[low] : samples[high]);
330
+ slots--;
331
+ continue;
332
+ }
333
+ const earlier = Math.min(low, high);
334
+ const later = Math.max(low, high);
335
+ out.push(samples[earlier]);
336
+ out.push(samples[later]);
337
+ slots -= 2;
338
+ }
339
+ }
340
+ out.push(samples[to]);
341
+ }
342
+
343
+ //#endregion
@@ -0,0 +1,86 @@
1
+ //@ts-check
2
+
3
+ //#region @jarenjs/core/series
4
+ // The suite's temporal kernel: one meaning for an instant, one meaning
5
+ // for an interval, and the set algebra over them that a roster, a
6
+ // calendar, an event log, an availability view and a telemetry graph
7
+ // otherwise each rebuild by hand.
8
+ //
9
+ // As with dates and geometry, **there is no type here**. An instant is
10
+ // epoch milliseconds or an RFC 3339 string; a sample is
11
+ // `{ at, value }`; an interval is `{ start, end }` — all of them plain
12
+ // JSON already, so they survive a patch, a schema, a pointer, a stored
13
+ // document and a wire reply unchanged. A `Series` object with methods
14
+ // could do none of that.
15
+ //
16
+ // selector.js where a member lives in a caller's row
17
+ // normalize.js instants, sorted samples and intervals, and the
18
+ // binary bounds a sorted array is read through
19
+ // interval.js half-open `[start, end)` set algebra: contains,
20
+ // overlap, intersect, merge, subtract, gaps,
21
+ // coverage and fixed-width slot enumeration
22
+ // interval-index.js build once, query many — the point and range
23
+ // questions, without asking every span
24
+ // zone.js the injected wall clock: UTC and fixed offsets
25
+ // work alone, a named zone needs the caller's
26
+ // provider, and an hour that happened twice is a
27
+ // refusal rather than a guess
28
+ // bucket.js the boundary ladder, resampling and the five
29
+ // fill policies — bucketing is arithmetic, filling
30
+ // is a policy, and they are kept apart
31
+ // rolling.js aggregates over a window measured in time rather
32
+ // than in rows
33
+ // asof.js the value that was current when this happened,
34
+ // as one forward walk over both sides
35
+ // downsample.js a hundred thousand points onto eight hundred
36
+ // pixels, with the gaps and the ends still there
37
+ //
38
+ // Two rules run through all of it. **Half-open**: an interval holds its
39
+ // start and not its end, so touching spans neither overlap nor
40
+ // double-count, and a boundary instant belongs to exactly one of them.
41
+ // **Nothing reads a clock**: every bound is data, and an operation that
42
+ // needs a window and was given none derives it from its input rather
43
+ // than from "now" — which is what makes every answer here reproducible
44
+ // and cacheable.
45
+ //
46
+ // A named-zone database, a recurrence grammar and a scheduling solver
47
+ // are all deliberately absent. `findSlots` enumerates where a fixed
48
+ // span fits — choosing among the answers is a solver's job — and a zone
49
+ // name is answered by a provider the caller injects, because a tzdb
50
+ // that ships in a library is a tzdb that goes stale in a library.
51
+
52
+ export {
53
+ toEpoch,
54
+ normalizeSeries,
55
+ canonicalSeries,
56
+ normalizeIntervals,
57
+ lowerBoundTime,
58
+ upperBoundTime,
59
+ } from './normalize.js';
60
+
61
+ export {
62
+ containsInstant,
63
+ overlapsInterval,
64
+ intersectInterval,
65
+ mergeIntervals,
66
+ subtractIntervals,
67
+ gapsWithin,
68
+ coverageOf,
69
+ findSlots,
70
+ MERGE_MEMBERS,
71
+ SLOTS_MEMBERS,
72
+ } from './interval.js';
73
+
74
+ export { createIntervalIndex } from './interval-index.js';
75
+
76
+ export { resolveClock, CLOCK_MEMBERS } from './zone.js';
77
+
78
+ export { compileBuckets, resampleSeries, RESAMPLE_MEMBERS } from './bucket.js';
79
+
80
+ export { rollingSeries, ROLLING_MEMBERS } from './rolling.js';
81
+
82
+ export { asOfJoin, ASOF_MEMBERS } from './asof.js';
83
+
84
+ export { downsampleSeries, DOWNSAMPLE_MEMBERS } from './downsample.js';
85
+
86
+ //#endregion
@@ -0,0 +1,181 @@
1
+ //@ts-check
2
+
3
+ //#region Static interval index
4
+ // Build once, query many: which of these spans hold this instant, and
5
+ // which of them overlap this window. Asking every span is O(n) per
6
+ // query, which is what makes a calendar with ten thousand events feel
7
+ // like a calendar with ten thousand events.
8
+ //
9
+ // **Sorting by start is not enough, and the reason is worth stating.**
10
+ // A binary search finds where a query's start falls among the starts —
11
+ // but a span that began a year earlier and has not ended yet sits far
12
+ // to the LEFT of that neighbourhood and still overlaps. A conference
13
+ // week among hourly meetings is exactly that span, and an index that
14
+ // merely cuts around the query silently loses it: the answer stays
15
+ // plausible, and it is wrong.
16
+ //
17
+ // So the index carries a second array: `maxEnd[i]` is the furthest any
18
+ // of the first `i + 1` spans reaches. It is non-decreasing by
19
+ // construction, which makes it binary-searchable too — and the first
20
+ // position where it passes the query's start is the first position
21
+ // where any span can still be live. Everything left of it ended before
22
+ // the query began, whatever its start said.
23
+ //
24
+ // A query is therefore two binary cuts and a walk between them: the
25
+ // left cut from `maxEnd` and the right cut from `starts`. No pass over
26
+ // the whole array, no per-query sort, and no long span quietly missing.
27
+ //
28
+ // **Static** is deliberate, as it is for the spatial box index. The
29
+ // bounds are copied into flat typed arrays at build time, so a query
30
+ // reads no source objects at all and a later mutation of a caller's row
31
+ // cannot change what the index answers. Results are the ORIGINAL items,
32
+ // in ascending start order — an index that returned copies would make
33
+ // "which booking" unanswerable.
34
+
35
+ import { toEpoch, epochAt } from './normalize.js';
36
+ import { selectorOf, requireRow } from './selector.js';
37
+
38
+ /**
39
+ * @typedef {Object} IntervalIndex
40
+ * @property {number} size - how many intervals were indexed
41
+ * @property {(at: number | string) => any[]} at - the items whose
42
+ * `[start, end)` contains this instant
43
+ * @property {(start: number | string, end: number | string) => any[]}
44
+ * overlapping - the items sharing an instant with `[start, end)`
45
+ */
46
+
47
+ /**
48
+ * The first index whose value exceeds `x`, in a non-decreasing array.
49
+ * @param {Float64Array} values
50
+ * @param {number} x
51
+ * @returns {number} an index in `[0, values.length]`
52
+ */
53
+ function firstAbove(values, x) {
54
+ let lo = 0;
55
+ let hi = values.length;
56
+ while (lo < hi) {
57
+ const mid = (lo + hi) >>> 1;
58
+ if (values[mid] <= x) lo = mid + 1;
59
+ else hi = mid;
60
+ }
61
+ return lo;
62
+ }
63
+
64
+ /**
65
+ * The first index whose value reaches `x`, in a non-decreasing array.
66
+ * @param {Float64Array} values
67
+ * @param {number} x
68
+ * @returns {number} an index in `[0, values.length]`
69
+ */
70
+ function firstAtLeast(values, x) {
71
+ let lo = 0;
72
+ let hi = values.length;
73
+ while (lo < hi) {
74
+ const mid = (lo + hi) >>> 1;
75
+ if (values[mid] < x) lo = mid + 1;
76
+ else hi = mid;
77
+ }
78
+ return lo;
79
+ }
80
+
81
+ /**
82
+ * Build a queryable index over `[start, end)` intervals.
83
+ *
84
+ * Both queries answer with the caller's own items, ascending by start
85
+ * and — for items sharing a start — in the order they were given. Every
86
+ * result is a fresh array, so a caller can sort or splice it without
87
+ * reaching into the index.
88
+ *
89
+ * Bounds are read once at build time through the selectors, as epoch
90
+ * milliseconds or RFC 3339 strings, and an interval that is empty,
91
+ * reversed or names no instant is refused here rather than being
92
+ * skipped: an index quietly holding fewer rows than it was given
93
+ * answers every later question wrongly.
94
+ *
95
+ * @param {any[]} items - the rows, in any order
96
+ * @param {Object} [selectors]
97
+ * @param {string | ((item: any, index: number) => any)} [selectors.start]
98
+ * where the lower bound lives (default `'start'`)
99
+ * @param {string | ((item: any, index: number) => any)} [selectors.end]
100
+ * where the upper bound lives (default `'end'`)
101
+ * @returns {IntervalIndex}
102
+ * @throws {TypeError} for a non-array, a row that is not an object, a
103
+ * bound that names no instant, or `end <= start`
104
+ * @example
105
+ * const index = createIntervalIndex(bookings, { start: 'from', end: 'to' });
106
+ * index.at('2026-03-01T10:00:00Z'); // who is booked then
107
+ * index.overlapping(dayStart, dayEnd); // everything touching today
108
+ */
109
+ export function createIntervalIndex(items, selectors = {}) {
110
+ if (!Array.isArray(items))
111
+ throw new TypeError('intervals are an array of rows');
112
+ const readStart = selectorOf(selectors.start ?? 'start', 'start');
113
+ const readEnd = selectorOf(selectors.end ?? 'end', 'end');
114
+
115
+ const count = items.length;
116
+ const rawStart = new Float64Array(count);
117
+ const rawEnd = new Float64Array(count);
118
+ const order = new Array(count);
119
+ let ascending = true;
120
+ for (let i = 0; i < count; i++) {
121
+ const row = requireRow(items[i], i);
122
+ const start = epochAt(readStart(row, i), 'start', i);
123
+ const end = epochAt(readEnd(row, i), 'end', i);
124
+ if (!(start < end))
125
+ throw new TypeError(`row ${i}: an interval ends at ${end}, at or before its start ${start}`);
126
+ rawStart[i] = start;
127
+ rawEnd[i] = end;
128
+ order[i] = i;
129
+ if (i > 0 && rawStart[i - 1] > start)
130
+ ascending = false;
131
+ }
132
+ // stable by construction: equal starts fall back to the input position
133
+ if (!ascending)
134
+ order.sort((a, b) => rawStart[a] - rawStart[b] || a - b);
135
+
136
+ const starts = new Float64Array(count);
137
+ const ends = new Float64Array(count);
138
+ const maxEnd = new Float64Array(count);
139
+ let reach = -Infinity;
140
+ for (let i = 0; i < count; i++) {
141
+ const at = order[i];
142
+ starts[i] = rawStart[at];
143
+ ends[i] = rawEnd[at];
144
+ if (rawEnd[at] > reach)
145
+ reach = rawEnd[at];
146
+ maxEnd[i] = reach;
147
+ }
148
+
149
+ /**
150
+ * @param {number} from - the query's lower bound
151
+ * @param {number} until - the first index past the query's reach
152
+ * @returns {any[]}
153
+ */
154
+ const collect = (from, until) => {
155
+ const out = [];
156
+ // everything before this ended at or before `from`, however early
157
+ // or late it started
158
+ for (let i = firstAbove(maxEnd, from); i < until; i++) {
159
+ if (ends[i] > from)
160
+ out.push(items[order[i]]);
161
+ }
162
+ return out;
163
+ };
164
+
165
+ return {
166
+ size: count,
167
+ at: (instant) => {
168
+ const t = toEpoch(instant);
169
+ return collect(t, firstAbove(starts, t));
170
+ },
171
+ overlapping: (start, end) => {
172
+ const from = toEpoch(start);
173
+ const until = toEpoch(end);
174
+ if (!(from < until))
175
+ throw new TypeError(`the query ends at ${until}, at or before its start ${from}`);
176
+ return collect(from, firstAtLeast(starts, until));
177
+ },
178
+ };
179
+ }
180
+
181
+ //#endregion