@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.
- package/ARCHITECTURE.md +104 -0
- package/README.md +46 -2
- package/dist/types/dates/civil.d.ts +24 -5
- package/dist/types/dates/index.d.ts +2 -0
- package/dist/types/dates/parse.d.ts +47 -0
- package/dist/types/dates/ticks.d.ts +59 -0
- package/dist/types/math/float64.d.ts +13 -0
- package/dist/types/series/asof.d.ts +88 -0
- package/dist/types/series/bucket.d.ts +206 -0
- package/dist/types/series/downsample.d.ts +53 -0
- package/dist/types/series/index.d.ts +8 -0
- package/dist/types/series/interval-index.d.ts +47 -0
- package/dist/types/series/interval.d.ts +170 -0
- package/dist/types/series/normalize.d.ts +190 -0
- package/dist/types/series/rolling.d.ts +67 -0
- package/dist/types/series/selector.d.ts +29 -0
- package/dist/types/series/zone.d.ts +59 -0
- package/docs/DATES.md +77 -1
- package/docs/SERIES.md +342 -0
- package/package.json +9 -1
- package/src/dates/civil.js +136 -41
- package/src/dates/index.js +4 -0
- package/src/dates/parse.js +410 -0
- package/src/dates/ticks.js +184 -0
- package/src/math/float64.js +29 -0
- package/src/series/asof.js +276 -0
- package/src/series/bucket.js +542 -0
- package/src/series/downsample.js +343 -0
- package/src/series/index.js +86 -0
- package/src/series/interval-index.js +181 -0
- package/src/series/interval.js +374 -0
- package/src/series/normalize.js +330 -0
- package/src/series/rolling.js +229 -0
- package/src/series/selector.js +104 -0
- package/src/series/zone.js +188 -0
|
@@ -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
|