@jarenjs/core 0.46.5 → 0.56.0

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.
Files changed (42) hide show
  1. package/ARCHITECTURE.md +122 -9
  2. package/README.md +50 -3
  3. package/dist/types/async.d.ts +39 -0
  4. package/dist/types/dates/civil.d.ts +24 -5
  5. package/dist/types/dates/index.d.ts +2 -0
  6. package/dist/types/dates/parse.d.ts +47 -0
  7. package/dist/types/dates/ticks.d.ts +59 -0
  8. package/dist/types/math/float64.d.ts +13 -0
  9. package/dist/types/random.d.ts +80 -0
  10. package/dist/types/series/asof.d.ts +88 -0
  11. package/dist/types/series/bucket.d.ts +206 -0
  12. package/dist/types/series/downsample.d.ts +53 -0
  13. package/dist/types/series/index.d.ts +8 -0
  14. package/dist/types/series/interval-index.d.ts +47 -0
  15. package/dist/types/series/interval.d.ts +170 -0
  16. package/dist/types/series/normalize.d.ts +190 -0
  17. package/dist/types/series/rolling.d.ts +67 -0
  18. package/dist/types/series/selector.d.ts +29 -0
  19. package/dist/types/series/zone.d.ts +59 -0
  20. package/dist/types/stats.d.ts +73 -0
  21. package/docs/DATES.md +77 -1
  22. package/docs/GEO.md +2 -2
  23. package/docs/SERIES.md +342 -0
  24. package/package.json +21 -1
  25. package/src/async.js +78 -0
  26. package/src/dates/civil.js +136 -41
  27. package/src/dates/index.js +4 -0
  28. package/src/dates/parse.js +410 -0
  29. package/src/dates/ticks.js +184 -0
  30. package/src/math/float64.js +29 -0
  31. package/src/random.js +125 -0
  32. package/src/series/asof.js +276 -0
  33. package/src/series/bucket.js +542 -0
  34. package/src/series/downsample.js +343 -0
  35. package/src/series/index.js +86 -0
  36. package/src/series/interval-index.js +181 -0
  37. package/src/series/interval.js +374 -0
  38. package/src/series/normalize.js +330 -0
  39. package/src/series/rolling.js +229 -0
  40. package/src/series/selector.js +104 -0
  41. package/src/series/zone.js +188 -0
  42. package/src/stats.js +133 -0
@@ -0,0 +1,542 @@
1
+ //@ts-check
2
+
3
+ //#region Buckets, resampling and fill
4
+ // Two questions that are always asked together and are not the same
5
+ // question. **Bucketing** is "which span does this instant fall in",
6
+ // and it is arithmetic. **Filling** is "what does a span with no
7
+ // readings say", and it is a policy — five of them, none of which is a
8
+ // default worth guessing at.
9
+ //
10
+ // Keeping them apart is the lesson every gapfill implementation
11
+ // eventually learns: an average over an empty hour is not zero, and it
12
+ // is not yesterday's average, and it is not nothing. It is whichever of
13
+ // those the caller asked for, and the aggregate had no opinion.
14
+ //
15
+ // The boundaries themselves come in two flavours, and the difference is
16
+ // physical rather than stylistic:
17
+ //
18
+ // **fixed** `PT15M`, `PT1H`, `P1D`, or a number of milliseconds. The
19
+ // boundary is `origin + k × width`, integer arithmetic all the way
20
+ // down, correct over negative epochs because the division floors.
21
+ //
22
+ // **calendar** `P1M`, `P1Y`, and a whole number of days on a named
23
+ // zone. A month has no width, so the boundary is computed by the
24
+ // calendar kernel from an anchor, and on a named zone it is computed
25
+ // through the caller's provider — which is where a spring-forward
26
+ // midnight that never happened becomes a refusal instead of an hour
27
+ // nobody notices.
28
+ //
29
+ // Nothing here reads a clock: with no explicit `start`/`end` the window
30
+ // is the data's own first and last bucket.
31
+
32
+ import { addToParts } from '../dates/civil.js';
33
+ import { parseDuration, durationToMs } from '../dates/duration.js';
34
+ import { toEpoch, canonicalSeries, lowerBoundTime } from './normalize.js';
35
+ import { requireSpecMembers } from './selector.js';
36
+ import { resolveClock, CLOCK_MEMBERS } from './zone.js';
37
+
38
+ /** @typedef {import('./normalize.js').Sample} Sample */
39
+ /** @typedef {import('./zone.js').Clock} Clock */
40
+
41
+ const MS_PER_DAY = 86400000;
42
+
43
+ /** What a bucket's rows can be reduced to. */
44
+ const AGGREGATES = Object.freeze(['sum', 'mean', 'min', 'max', 'first', 'last', 'count']);
45
+
46
+ /** What an empty bucket says. */
47
+ const FILLS = Object.freeze(['omit', 'null', 'zero', 'locf', 'linear']);
48
+
49
+ /**
50
+ * A compiled bucket ladder: the boundary arithmetic for one `every`,
51
+ * one `origin` and one clock, validated once so a resample never
52
+ * re-reads its own specification.
53
+ * @typedef {Object} CompiledBuckets
54
+ * @property {number | string} every - the width as it was written
55
+ * @property {boolean} calendar - whether boundaries need the calendar
56
+ * @property {string} unit - `'millisecond'`, `'day'` or `'month'`
57
+ * @property {number} amount - the count of that unit
58
+ * @property {number} width - fixed width in milliseconds, or 0 when
59
+ * the ladder is a calendar one
60
+ * @property {string} zone - the clock the boundaries fall on
61
+ * @property {number} origin - the anchor, in epoch milliseconds
62
+ * @property {(index: number) => number} startOf - the boundary at a
63
+ * ladder position; position 0 is `origin`
64
+ * @property {(at: number) => number} indexOf - the ladder position
65
+ * holding an instant
66
+ * @property {(at: number) => number} floor - the boundary of the bucket
67
+ * holding an instant
68
+ */
69
+
70
+ //#endregion
71
+
72
+ //#region the ladder
73
+
74
+ /**
75
+ * The width, taken apart once: which family it belongs to, and how much
76
+ * of that family's unit it is.
77
+ * @param {any} every
78
+ * @param {boolean} zoned - whether the clock is a named zone, where a
79
+ * day is a calendar unit rather than 86,400,000 milliseconds
80
+ * @returns {{ calendar: boolean, unit: string, amount: number, width: number }}
81
+ */
82
+ function requireEvery(every, zoned) {
83
+ if (typeof every === 'number') {
84
+ requirePositiveInteger(every, 'a bucket width in milliseconds');
85
+ return dayOrMillisecond(every, zoned);
86
+ }
87
+ if (typeof every !== 'string')
88
+ throw new TypeError('\'every\' is a positive number of milliseconds or an ISO 8601 duration');
89
+ const parts = parseDuration(every);
90
+ if (parts === null || parts.negative)
91
+ throw new TypeError(`'every' — '${every}' is not a positive ISO 8601 duration`);
92
+ if (parts.years !== 0 || parts.months !== 0) {
93
+ if (parts.weeks !== 0 || parts.days !== 0 || parts.hours !== 0
94
+ || parts.minutes !== 0 || parts.seconds !== 0) {
95
+ throw new TypeError(`'every' — '${every}' mixes calendar and fixed units; a bucket ladder`
96
+ + ' is one family, because a month and an hour do not share a boundary');
97
+ }
98
+ const months = parts.years * 12 + parts.months;
99
+ requirePositiveInteger(months, `'every' — '${every}' in months`);
100
+ return { calendar: true, unit: 'month', amount: months, width: 0 };
101
+ }
102
+ const ms = durationToMs(parts);
103
+ requirePositiveInteger(ms, `'every' — '${every}' in milliseconds`);
104
+ return dayOrMillisecond(ms, zoned);
105
+ }
106
+
107
+ /**
108
+ * A fixed span is milliseconds — except on a named zone, where a whole
109
+ * number of days is a calendar span: a day the clock changed on is 23
110
+ * or 25 hours long, and a ladder that called it 24 would drift a bucket
111
+ * boundary off local midnight for the rest of the year.
112
+ * @param {number} ms
113
+ * @param {boolean} zoned
114
+ * @returns {{ calendar: boolean, unit: string, amount: number, width: number }}
115
+ */
116
+ function dayOrMillisecond(ms, zoned) {
117
+ if (zoned && ms % MS_PER_DAY === 0)
118
+ return { calendar: true, unit: 'day', amount: ms / MS_PER_DAY, width: 0 };
119
+ return { calendar: false, unit: 'millisecond', amount: ms, width: ms };
120
+ }
121
+
122
+ /** @param {number} value @param {string} role @returns {void} */
123
+ function requirePositiveInteger(value, role) {
124
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0)
125
+ throw new TypeError(`${role} is a positive whole number, not ${value}`);
126
+ }
127
+
128
+ /**
129
+ * A duration taken apart on a clock: which family it belongs to, how
130
+ * much of that family's unit it is, and the clock itself.
131
+ *
132
+ * The seam between the two kernels that need the same answer. A bucket
133
+ * ladder and a rolling window both have to know whether `P1M` is
134
+ * arithmetic or a calendar question, and whether `P1D` is 86,400,000
135
+ * milliseconds (it is, on UTC and on a fixed offset) or a day that might
136
+ * be 23 hours long (it is, on a named zone).
137
+ *
138
+ * @param {number | string} every
139
+ * @param {Object} [options] - the clock, as {@link resolveClock} takes it
140
+ * @param {string} [options.zone]
141
+ * @param {number} [options.offset]
142
+ * @param {import('./zone.js').ZoneProvider} [options.provider]
143
+ * @param {'reject' | 'earlier' | 'later'} [options.disambiguation]
144
+ * @returns {{ calendar: boolean, unit: string, amount: number, width: number, clock: Clock }}
145
+ * @throws {TypeError} for a span that is not one positive whole family,
146
+ * or a named zone with no provider
147
+ */
148
+ export function compileSpan(every, options = {}) {
149
+ const clock = resolveClock(options);
150
+ const zoned = options.zone !== undefined && options.zone !== 'UTC';
151
+ return { ...requireEvery(every, zoned), clock };
152
+ }
153
+
154
+ /**
155
+ * The bucket ladder for a specification, validated once and returned as
156
+ * the four boundary operations everything downstream needs.
157
+ *
158
+ * `spec` may be the width alone (`compileBuckets('PT15M')`) or a record
159
+ * with `every` and an optional `origin`. `options` carries the clock —
160
+ * nothing, `{ offset }`, or `{ zone, provider }` — and defaults to UTC.
161
+ *
162
+ * The default `origin` is local `1970-01-01T00:00:00` on that clock, so
163
+ * a daily bucket in `+02:00` falls on local midnight rather than on
164
+ * UTC's, and a monthly bucket falls on the first of the month.
165
+ *
166
+ * @param {number | string | { every: number | string, origin?: number | string }} spec
167
+ * @param {Object} [options] - the clock, as {@link resolveClock} takes it
168
+ * @param {string} [options.zone]
169
+ * @param {number} [options.offset]
170
+ * @param {import('./zone.js').ZoneProvider} [options.provider]
171
+ * @param {'reject' | 'earlier' | 'later'} [options.disambiguation]
172
+ * @returns {CompiledBuckets}
173
+ * @throws {TypeError} for a width that is not a positive whole span, a
174
+ * width mixing calendar and fixed units, an origin naming no instant,
175
+ * or a named zone with no provider
176
+ * @example
177
+ * const b = compileBuckets('PT15M');
178
+ * b.floor(Date.UTC(2026, 0, 1, 9, 7)); // 09:00
179
+ * b.startOf(b.indexOf(0) + 1); // the boundary after the epoch
180
+ */
181
+ export function compileBuckets(spec, options = {}) {
182
+ const record = (typeof spec === 'number' || typeof spec === 'string') ? { every: spec } : spec;
183
+ if (record === null || typeof record !== 'object')
184
+ throw new TypeError('a bucket spec is a width, or a record with an \'every\'');
185
+ const shape = compileSpan(record.every, options);
186
+ const { clock } = shape;
187
+ const origin = record.origin === undefined
188
+ ? clock.epochOf({ year: 1970, month: 1, day: 1, hours: 0, minutes: 0, seconds: 0 })
189
+ : toEpoch(record.origin);
190
+ return shape.calendar
191
+ ? calendarLadder(record.every, shape, origin, clock)
192
+ : fixedLadder(record.every, shape, origin, clock.zone);
193
+ }
194
+
195
+ /**
196
+ * The integer ladder. `Math.floor` rather than a truncating division is
197
+ * the whole of it: before 1970 a truncated quotient rounds towards zero
198
+ * and puts an instant in the bucket after its own.
199
+ * @param {number | string} every
200
+ * @param {{ unit: string, amount: number, width: number }} shape
201
+ * @param {number} origin
202
+ * @param {string} zone
203
+ * @returns {CompiledBuckets}
204
+ */
205
+ function fixedLadder(every, shape, origin, zone) {
206
+ const { width } = shape;
207
+ const startOf = (index) => origin + index * width;
208
+ const indexOf = (at) => Math.floor((at - origin) / width);
209
+ return {
210
+ every, calendar: false, unit: shape.unit, amount: shape.amount, width, zone, origin,
211
+ startOf, indexOf, floor: (at) => origin + Math.floor((at - origin) / width) * width,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * The calendar ladder. A boundary is the anchor moved `index × amount`
217
+ * units by the calendar kernel and read back on the clock, so month
218
+ * lengths, leap days and — on a named zone — the days that are not 24
219
+ * hours long are all the provider's problem rather than a multiplier.
220
+ *
221
+ * `indexOf` estimates from the raw span and then corrects against the
222
+ * boundaries themselves. The estimate is never more than one position
223
+ * out (a month is 28-31 days; a day is 23-25 hours), so the correction
224
+ * is a couple of comparisons rather than a search — but it is a loop
225
+ * with a bound rather than an assumption, because the provider is the
226
+ * caller's code.
227
+ * @param {number | string} every
228
+ * @param {{ unit: string, amount: number }} shape
229
+ * @param {number} origin
230
+ * @param {Clock} clock
231
+ * @returns {CompiledBuckets}
232
+ */
233
+ function calendarLadder(every, shape, origin, clock) {
234
+ const { unit, amount } = shape;
235
+ const anchor = clock.partsAt(origin);
236
+ /** One boundary, memoized: a resample walks the same two repeatedly. */
237
+ let lastIndex = 0;
238
+ let lastStart = origin;
239
+ const startOf = (index) => {
240
+ if (index === lastIndex)
241
+ return lastStart;
242
+ const at = index === 0 ? origin : clock.epochOf(addToParts(anchor, index * amount, unit));
243
+ lastIndex = index;
244
+ lastStart = at;
245
+ return at;
246
+ };
247
+ const estimate = unit === 'month'
248
+ ? (/** @type {number} */ at) => {
249
+ const parts = clock.partsAt(at);
250
+ return Math.floor(((parts.year - anchor.year) * 12 + (parts.month - anchor.month)) / amount);
251
+ }
252
+ : (/** @type {number} */ at) => Math.floor(Math.floor((at - origin) / MS_PER_DAY) / amount);
253
+ const indexOf = (at) => {
254
+ let index = estimate(at);
255
+ for (let guard = 0; startOf(index) > at; guard++) {
256
+ if (guard > 4)
257
+ throw new RangeError(`the '${every}' ladder could not be walked back to ${at}`);
258
+ index--;
259
+ }
260
+ for (let guard = 0; startOf(index + 1) <= at; guard++) {
261
+ if (guard > 4)
262
+ throw new RangeError(`the '${every}' ladder could not be walked forward to ${at}`);
263
+ index++;
264
+ }
265
+ return index;
266
+ };
267
+ return {
268
+ every, calendar: true, unit, amount, width: 0, zone: clock.zone, origin,
269
+ startOf, indexOf, floor: (at) => startOf(indexOf(at)),
270
+ };
271
+ }
272
+
273
+ //#endregion
274
+
275
+ //#region resample
276
+
277
+ /**
278
+ * @param {any} name
279
+ * @param {readonly string[]} allowed
280
+ * @param {string} role
281
+ * @returns {string}
282
+ */
283
+ function requireMember(name, allowed, role) {
284
+ if (typeof name !== 'string' || !allowed.includes(name))
285
+ throw new TypeError(`${role} is ${allowed.map((a) => `'${a}'`).join(', ')}, not ${JSON.stringify(name)}`);
286
+ return name;
287
+ }
288
+
289
+ /**
290
+ * `resampleSeries`' closed specification: the D5 bucket contract, the
291
+ * clock it reads and where a row keeps its instant and its reading.
292
+ */
293
+ export const RESAMPLE_MEMBERS = Object.freeze([
294
+ 'every', 'origin', 'start', 'end', 'aggregate', 'fill', 'at', 'value',
295
+ ...CLOCK_MEMBERS,
296
+ ]);
297
+
298
+ /**
299
+ * Bucket a series, reduce each bucket to one number, and say what the
300
+ * empty ones mean.
301
+ *
302
+ * The result is ascending `{ at, value, count }` records labelled at
303
+ * their bucket's **start**, which is the only label that is a boundary
304
+ * rather than a summary of where the rows happened to land.
305
+ *
306
+ * `count` is the number of **source rows** the bucket held — duplicates
307
+ * and measured gaps included — so it is the honest denominator of what
308
+ * was seen, not of what could be added up. The six value aggregates
309
+ * (`sum`, `mean`, `min`, `max`, `first`, `last`) all skip `null`
310
+ * readings, so `value` is `null` exactly when the bucket had nothing to
311
+ * measure, and `count` tells you whether that was because nobody
312
+ * reported or because everybody reported a gap. `aggregate: 'count'`
313
+ * returns that same row count as the value.
314
+ *
315
+ * The window, when `start`/`end` are not given, is the data's own: the
316
+ * bucket holding the first sample through the bucket holding the last.
317
+ * Pass them when an empty edge matters — an empty Monday is only a
318
+ * missing Monday once the caller says the week starts then.
319
+ *
320
+ * The five fill policies decide what an **empty** bucket says, and
321
+ * nothing else — a bucket that held rows and no numbers reports `null`
322
+ * because that is a measurement:
323
+ *
324
+ * | fill | an empty bucket |
325
+ * |---|---|
326
+ * | `omit` | is not emitted (the default: a gap is not a row) |
327
+ * | `null` | is emitted as `null` |
328
+ * | `zero` | is emitted as `0` |
329
+ * | `locf` | repeats the last value before it |
330
+ * | `linear` | is interpolated between its two neighbours |
331
+ *
332
+ * Neither `locf` nor `linear` invents a value at the leading edge, and
333
+ * `linear` needs a value on **both** sides: with no anchor to carry or
334
+ * to interpolate from, the bucket stays `null`. To seed one, widen the
335
+ * window until the earlier reading falls inside it — the seed is then a
336
+ * bucket with data, which is the only kind of anchor this function will
337
+ * extrapolate from. (With `aggregate: 'count'` an empty bucket is `0` by
338
+ * definition, so fill only decides whether it appears at all.)
339
+ *
340
+ * @param {any[]} rows - the samples, in any order
341
+ * @param {Object} spec
342
+ * @param {number | string} spec.every - the bucket width
343
+ * @param {number | string} [spec.origin] - where a boundary falls
344
+ * (default: local `1970-01-01T00:00:00` on the clock)
345
+ * @param {number | string} [spec.start] - the half-open window's start
346
+ * @param {number | string} [spec.end] - the half-open window's end
347
+ * @param {'sum'|'mean'|'min'|'max'|'first'|'last'|'count'} [spec.aggregate]
348
+ * default `'mean'`
349
+ * @param {'omit'|'null'|'zero'|'locf'|'linear'} [spec.fill] default `'omit'`
350
+ * @param {string} [spec.zone] - a named zone, needing `provider`
351
+ * @param {number} [spec.offset] - minutes east of UTC
352
+ * @param {import('./zone.js').ZoneProvider} [spec.provider]
353
+ * @param {'reject'|'earlier'|'later'} [spec.disambiguation]
354
+ * @param {string | ((item: any, index: number) => any)} [spec.at] - where
355
+ * the instant lives in a source row (default `'at'`)
356
+ * @param {string | ((item: any, index: number) => any)} [spec.value]
357
+ * where the reading lives (default `'value'`)
358
+ * @returns {{ at: number, value: number | null, count: number }[]}
359
+ * @throws {TypeError} for a bad width, origin, window, aggregate or fill,
360
+ * or a row that is not a canonical sample
361
+ * @example
362
+ * resampleSeries(readings, { every: 'PT1H', aggregate: 'mean', fill: 'linear' });
363
+ * resampleSeries(readings, { every: 'P1M', zone: 'Europe/Amsterdam', provider });
364
+ */
365
+ export function resampleSeries(rows, spec) {
366
+ requireSpecMembers(spec, RESAMPLE_MEMBERS, 'resampleSeries',
367
+ 'a resample spec is an object with an \'every\'');
368
+ const samples = canonicalSeries(rows, spec);
369
+ const buckets = compileBuckets(spec, spec);
370
+ const aggregate = requireMember(spec.aggregate ?? 'mean', AGGREGATES, 'aggregate');
371
+ const fill = requireMember(spec.fill ?? 'omit', FILLS, 'fill');
372
+
373
+ const lo = spec.start === undefined ? null : toEpoch(spec.start);
374
+ const hi = spec.end === undefined ? null : toEpoch(spec.end);
375
+ if (lo !== null && hi !== null && !(lo < hi))
376
+ throw new TypeError(`the window ends at ${hi}, at or before its start ${lo}`);
377
+
378
+ const from = lo === null ? 0 : lowerBoundTime(samples, lo);
379
+ const to = hi === null ? samples.length : lowerBoundTime(samples, hi);
380
+ // with no window of its own and no data, there is nothing to enumerate
381
+ // between — and "now" is not an answer this module is allowed to give
382
+ if (from >= to && (lo === null || hi === null))
383
+ return [];
384
+
385
+ const firstAt = lo === null ? samples[from].at : lo;
386
+ // the last instant a bucket may still be emitted for: the explicit end
387
+ // is exclusive, the derived one is the final sample and is inside
388
+ const limit = hi === null ? samples[to - 1].at + 1 : hi;
389
+
390
+ /** @type {{ at: number, value: number | null, count: number }[]} */
391
+ const out = [];
392
+ /** Which emitted rows had no source rows at all — the fill's business. */
393
+ const empties = [];
394
+ let index = buckets.indexOf(firstAt);
395
+ let start = buckets.startOf(index);
396
+ let i = from;
397
+ while (start < limit) {
398
+ const end = buckets.calendar ? buckets.startOf(index + 1) : start + buckets.width;
399
+ let count = 0;
400
+ let n = 0;
401
+ let sum = 0;
402
+ let min = Infinity;
403
+ let max = -Infinity;
404
+ let first = null;
405
+ let last = null;
406
+ while (i < to && samples[i].at < end) {
407
+ const value = samples[i].value;
408
+ count++;
409
+ if (value !== null) {
410
+ n++;
411
+ sum += value;
412
+ if (value < min) min = value;
413
+ if (value > max) max = value;
414
+ if (first === null) first = value;
415
+ last = value;
416
+ }
417
+ i++;
418
+ }
419
+ if (count === 0) {
420
+ if (fill !== 'omit') {
421
+ empties.push(out.length);
422
+ out.push({ at: start, value: aggregate === 'count' ? 0 : emptyValue(fill), count: 0 });
423
+ }
424
+ }
425
+ else {
426
+ out.push({ at: start, count, value: reduce(aggregate, count, n, sum, min, max, first, last) });
427
+ }
428
+ index++;
429
+ start = end;
430
+ }
431
+ if (empties.length !== 0 && aggregate !== 'count') {
432
+ if (fill === 'locf') carryForward(out, empties);
433
+ else if (fill === 'linear') interpolate(out, empties);
434
+ }
435
+ return out;
436
+ }
437
+
438
+ /** @param {string} fill @returns {number | null} */
439
+ function emptyValue(fill) {
440
+ return fill === 'zero' ? 0 : null;
441
+ }
442
+
443
+ /**
444
+ * One bucket's rows, reduced. `null` is the answer whenever nothing in
445
+ * the bucket carried a number, which the six value aggregates agree on.
446
+ * @param {string} aggregate
447
+ * @param {number} count - source rows, gaps included
448
+ * @param {number} n - rows carrying a number
449
+ * @param {number} sum
450
+ * @param {number} min
451
+ * @param {number} max
452
+ * @param {number | null} first
453
+ * @param {number | null} last
454
+ * @returns {number | null}
455
+ */
456
+ function reduce(aggregate, count, n, sum, min, max, first, last) {
457
+ if (aggregate === 'count')
458
+ return count;
459
+ if (n === 0)
460
+ return null;
461
+ switch (aggregate) {
462
+ case 'sum': return sum;
463
+ case 'mean': return sum / n;
464
+ case 'min': return min;
465
+ case 'max': return max;
466
+ case 'first': return first;
467
+ default: return last;
468
+ }
469
+ }
470
+
471
+ /**
472
+ * `locf` — the last value observed before the gap, repeated across it.
473
+ * A gap before the first value stays `null`: carrying a value backwards
474
+ * is inventing one, and this module does not.
475
+ * @param {{ at: number, value: number | null, count: number }[]} out
476
+ * @param {number[]} empties
477
+ * @returns {void}
478
+ */
479
+ function carryForward(out, empties) {
480
+ let carried = null;
481
+ let next = 0;
482
+ for (let i = 0; i < out.length; i++) {
483
+ if (next < empties.length && empties[next] === i) {
484
+ out[i].value = carried;
485
+ next++;
486
+ }
487
+ else if (out[i].value !== null)
488
+ carried = out[i].value;
489
+ }
490
+ }
491
+
492
+ /**
493
+ * `linear` — a straight line between the values on either side of the
494
+ * gap, read at each empty bucket's own start. A run with no value on
495
+ * one side has no line to be on and stays `null`.
496
+ * @param {{ at: number, value: number | null, count: number }[]} out
497
+ * @param {number[]} empties
498
+ * @returns {void}
499
+ */
500
+ function interpolate(out, empties) {
501
+ const isEmpty = new Uint8Array(out.length);
502
+ for (const i of empties) isEmpty[i] = 1;
503
+ for (let i = 0; i < empties.length;) {
504
+ // the maximal run of empty buckets this one starts
505
+ let last = empties[i];
506
+ let j = i + 1;
507
+ while (j < empties.length && empties[j] === last + 1) {
508
+ last = empties[j];
509
+ j++;
510
+ }
511
+ const before = anchor(out, isEmpty, empties[i] - 1, -1);
512
+ const after = anchor(out, isEmpty, last + 1, 1);
513
+ if (before >= 0 && after < out.length) {
514
+ const a = out[before];
515
+ const b = out[after];
516
+ const slope = (/** @type {number} */(b.value) - /** @type {number} */(a.value)) / (b.at - a.at);
517
+ for (let k = empties[i]; k <= last; k++)
518
+ out[k].value = /** @type {number} */(a.value) + slope * (out[k].at - a.at);
519
+ }
520
+ i = j;
521
+ }
522
+ }
523
+
524
+ /**
525
+ * The nearest bucket in one direction that actually measured something.
526
+ * A bucket that held rows and no numbers is not an anchor: it is the
527
+ * report that there was nothing to measure.
528
+ * @param {{ value: number | null }[]} out
529
+ * @param {Uint8Array} isEmpty
530
+ * @param {number} from
531
+ * @param {number} step
532
+ * @returns {number} an index, or one past the end of the search
533
+ */
534
+ function anchor(out, isEmpty, from, step) {
535
+ for (let i = from; i >= 0 && i < out.length; i += step) {
536
+ if (isEmpty[i] === 0 && out[i].value !== null)
537
+ return i;
538
+ }
539
+ return step < 0 ? -1 : out.length;
540
+ }
541
+
542
+ //#endregion