@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,184 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
//#region time-axis ticks
|
|
4
|
+
// The locale-free planner behind a time axis: which calendar step a
|
|
5
|
+
// domain should be read in, and where that step's boundaries fall. It
|
|
6
|
+
// lives beside the calendar arithmetic rather than in a renderer
|
|
7
|
+
// because a chart axis, a timeline and a bucketed series all want the
|
|
8
|
+
// same answer, and a second copy of this ladder would be a second set
|
|
9
|
+
// of tick positions.
|
|
10
|
+
//
|
|
11
|
+
// Positions only: these functions return domain values in epoch
|
|
12
|
+
// milliseconds. Turning one into a coordinate is the caller's scale,
|
|
13
|
+
// and turning one into a label is `compileDateFormat` (format.js).
|
|
14
|
+
|
|
15
|
+
import { niceStep, axisTicksLinear } from '../math/float64.js';
|
|
16
|
+
import {
|
|
17
|
+
partsFromEpoch, startOfParts, addToParts, isDateUnit,
|
|
18
|
+
daysFromCivil, civilFromDays, isoWeekdayFromDays,
|
|
19
|
+
} from './civil.js';
|
|
20
|
+
import { epochOfRFC3339Parts } from './rfc3339.js';
|
|
21
|
+
|
|
22
|
+
// The steps a clock and a calendar actually have. The 1/2/5 ladder is
|
|
23
|
+
// right for quantities and wrong for time: it puts ticks 50 seconds or
|
|
24
|
+
// 8.64 days apart, which no reader converts back into a date. Each
|
|
25
|
+
// entry is [unit, amount]; the unit names are the calendar's.
|
|
26
|
+
const TIME_STEPS = Object.freeze([
|
|
27
|
+
['second', 1], ['second', 5], ['second', 15], ['second', 30],
|
|
28
|
+
['minute', 1], ['minute', 5], ['minute', 15], ['minute', 30],
|
|
29
|
+
['hour', 1], ['hour', 3], ['hour', 6], ['hour', 12],
|
|
30
|
+
['day', 1], ['day', 2], ['week', 1], ['week', 2],
|
|
31
|
+
['month', 1], ['month', 3], ['month', 6],
|
|
32
|
+
['year', 1],
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The first instant the parts contract can spell, `0000-01-01T00:00:00Z`.
|
|
37
|
+
* A negative year is that contract's "no date half" sentinel
|
|
38
|
+
* (rfc3339.js), so an older domain has no calendar boundary to land on
|
|
39
|
+
* and this planner has no ticks to offer it.
|
|
40
|
+
*/
|
|
41
|
+
const CALENDAR_MIN_MS = -62167219200000;
|
|
42
|
+
|
|
43
|
+
// approximate widths, used only to pick a step near the target count
|
|
44
|
+
const STEP_MS = Object.freeze({
|
|
45
|
+
second: 1000, minute: 60000, hour: 3600000, day: 86400000,
|
|
46
|
+
week: 604800000, month: 2629800000, year: 31557600000,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Choose the calendar step closest to covering `span` in `count` ticks.
|
|
51
|
+
*
|
|
52
|
+
* A year is the coarsest unit a calendar has, so above one year the
|
|
53
|
+
* ladder continues as whole years on the same 1/2/5×10^k steps — 2, 5,
|
|
54
|
+
* 10, 50, 1000 years. Without that continuation a millennial domain
|
|
55
|
+
* asks for one tick per year and runs out of axis long before it runs
|
|
56
|
+
* out of domain.
|
|
57
|
+
*
|
|
58
|
+
* @param {number} span - Domain width in milliseconds
|
|
59
|
+
* @param {number} count - Desired tick count
|
|
60
|
+
* @returns {[string, number]} a [unit, amount] pair
|
|
61
|
+
*/
|
|
62
|
+
export function niceTimeStep(span, count) {
|
|
63
|
+
const target = span / Math.max(1, count);
|
|
64
|
+
if (target > STEP_MS.year)
|
|
65
|
+
return ['year', Math.max(1, niceStep(target / STEP_MS.year))];
|
|
66
|
+
let best = TIME_STEPS[0];
|
|
67
|
+
let bestErr = Infinity;
|
|
68
|
+
for (let i = 0; i < TIME_STEPS.length; i++) {
|
|
69
|
+
const [unit, amount] = TIME_STEPS[i];
|
|
70
|
+
const err = Math.abs(Math.log(STEP_MS[unit] * amount / target));
|
|
71
|
+
if (err < bestErr) {
|
|
72
|
+
bestErr = err;
|
|
73
|
+
best = TIME_STEPS[i];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return /** @type {[string, number]} */ (best);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Time ticks on calendar boundaries: the first tick is the start of a
|
|
81
|
+
* `[unit, amount]` step at or after `min`, and each following one is a
|
|
82
|
+
* whole step later. A multi-unit step lands on a multiple of its own
|
|
83
|
+
* amount — months on 1, 4, 7, 10 and years on 1900, 1950, 2000 — so an
|
|
84
|
+
* axis reads as a calendar rather than as offsets from wherever the
|
|
85
|
+
* data happened to start.
|
|
86
|
+
*
|
|
87
|
+
* @param {number} min - Domain minimum, epoch milliseconds
|
|
88
|
+
* @param {number} max - Domain maximum, epoch milliseconds
|
|
89
|
+
* @param {number} [count] - Desired tick count (approximate)
|
|
90
|
+
* @returns {number[]} tick values in epoch milliseconds
|
|
91
|
+
*/
|
|
92
|
+
export function axisTicksTime(min, max, count = 4) {
|
|
93
|
+
if (!Number.isFinite(min) || !Number.isFinite(max))
|
|
94
|
+
return [];
|
|
95
|
+
if (min === max)
|
|
96
|
+
return [min];
|
|
97
|
+
if (min > max)
|
|
98
|
+
return axisTicksTime(max, min, count);
|
|
99
|
+
const span = max - min;
|
|
100
|
+
// below a second the calendar has nothing to say; the numeric ladder does
|
|
101
|
+
if (span < 1000 * count)
|
|
102
|
+
return axisTicksLinear(min, max, count);
|
|
103
|
+
const [unit, amount] = niceTimeStep(span, count);
|
|
104
|
+
return timeTicksEvery(min, max, unit, amount);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Time ticks on a step the CALLER chose, rather than one this module
|
|
109
|
+
* picked for a target count: the first tick is the start of the
|
|
110
|
+
* `amount`-wide `unit` at or after `min`, and each following one is a
|
|
111
|
+
* whole step later. This is the boundary rule {@link axisTicksTime}
|
|
112
|
+
* uses once it has decided a step, exposed on its own because a
|
|
113
|
+
* consumer whose document declares its own interval — Mermaid's Gantt
|
|
114
|
+
* `tickInterval 1week` is the one in this repo — needs the same
|
|
115
|
+
* boundaries without the ladder choosing for it.
|
|
116
|
+
*
|
|
117
|
+
* A multi-unit step lands on a multiple of its own amount, so months
|
|
118
|
+
* fall on 1, 4, 7, 10 and years on 1900, 1950, 2000. A `week` step
|
|
119
|
+
* starts on Monday unless `weekStart` names another ISO weekday, which
|
|
120
|
+
* is what a document that says "weeks begin on Sunday" means.
|
|
121
|
+
*
|
|
122
|
+
* @param {number} min - Domain minimum, epoch milliseconds
|
|
123
|
+
* @param {number} max - Domain maximum, epoch milliseconds
|
|
124
|
+
* @param {string} unit - a `DATE_UNITS` member (`'day'`, `'week'`, …)
|
|
125
|
+
* @param {number} [amount] - whole steps per tick, at least 1
|
|
126
|
+
* @param {{ weekStart?: number, limit?: number }} [options] -
|
|
127
|
+
* `weekStart` is an ISO weekday, 1 (Monday) to 7 (Sunday);
|
|
128
|
+
* `limit` caps the tick count (default 1000)
|
|
129
|
+
* @returns {number[]} tick values in epoch milliseconds
|
|
130
|
+
* @example
|
|
131
|
+
* timeTicksEvery(Date.UTC(2024, 0, 3), Date.UTC(2024, 0, 20), 'week', 1);
|
|
132
|
+
* // the Mondays of 2024-01-08 and 2024-01-15
|
|
133
|
+
*/
|
|
134
|
+
export function timeTicksEvery(min, max, unit, amount = 1, options = {}) {
|
|
135
|
+
if (!Number.isFinite(min) || !Number.isFinite(max) || max < min)
|
|
136
|
+
return [];
|
|
137
|
+
if (!isDateUnit(unit) || !Number.isInteger(amount) || amount < 1)
|
|
138
|
+
return [];
|
|
139
|
+
if (min < CALENDAR_MIN_MS)
|
|
140
|
+
return [];
|
|
141
|
+
const limit = options.limit ?? 1000;
|
|
142
|
+
const from = partsFromEpoch(min);
|
|
143
|
+
let parts = startOfParts(from, unit);
|
|
144
|
+
if (amount > 1 && unit === 'month') {
|
|
145
|
+
// quarters and half-years start on month 1, 4, 7, 10 (or 1, 7)
|
|
146
|
+
parts = { ...parts, month: Math.floor((parts.month - 1) / amount) * amount + 1 };
|
|
147
|
+
}
|
|
148
|
+
else if (amount > 1 && unit === 'year') {
|
|
149
|
+
parts = { ...parts, year: Math.floor(parts.year / amount) * amount };
|
|
150
|
+
}
|
|
151
|
+
else if (unit === 'week') {
|
|
152
|
+
parts = { ...parts, ...weekStartDay(from, options.weekStart ?? 1) };
|
|
153
|
+
}
|
|
154
|
+
const ticks = [];
|
|
155
|
+
let ms = epochOfRFC3339Parts(parts);
|
|
156
|
+
while (ms < min) {
|
|
157
|
+
parts = addToParts(parts, amount, unit);
|
|
158
|
+
ms = epochOfRFC3339Parts(parts);
|
|
159
|
+
}
|
|
160
|
+
// the step is never zero, so this terminates; the cap is a guard
|
|
161
|
+
// against a pathological domain rather than an expected path
|
|
162
|
+
for (let i = 0; ms <= max && i < limit; i++) {
|
|
163
|
+
ticks.push(ms);
|
|
164
|
+
parts = addToParts(parts, amount, unit);
|
|
165
|
+
ms = epochOfRFC3339Parts(parts);
|
|
166
|
+
}
|
|
167
|
+
return ticks;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The civil date of the `weekStart`-day on or before `parts`' own day.
|
|
172
|
+
* @param {object} parts
|
|
173
|
+
* @param {number} weekStart - ISO weekday, 1 (Monday) to 7 (Sunday)
|
|
174
|
+
* @returns {{ year: number, month: number, day: number }}
|
|
175
|
+
*/
|
|
176
|
+
function weekStartDay(parts, weekStart) {
|
|
177
|
+
const start = weekStart >= 1 && weekStart <= 7 ? weekStart : 1;
|
|
178
|
+
const z = daysFromCivil(parts.year, parts.month, parts.day);
|
|
179
|
+
let back = isoWeekdayFromDays(z) - start;
|
|
180
|
+
if (back < 0) back += 7;
|
|
181
|
+
return civilFromDays(z - back);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
//#endregion
|
package/src/math/float64.js
CHANGED
|
@@ -491,6 +491,35 @@ export function niceStep(span, count = 1) {
|
|
|
491
491
|
return factor * base;
|
|
492
492
|
}
|
|
493
493
|
|
|
494
|
+
/**
|
|
495
|
+
* Ticks on the {@link niceStep} ladder inside `[min, max]`: every whole
|
|
496
|
+
* multiple of the step the domain contains, rounded to the step's own
|
|
497
|
+
* precision so an axis never shows `0.30000000000000004`. A degenerate
|
|
498
|
+
* domain yields a single tick, a reversed one reads the same as its
|
|
499
|
+
* forward twin, and a non-finite bound yields nothing.
|
|
500
|
+
*
|
|
501
|
+
* @param {number} min - Domain minimum
|
|
502
|
+
* @param {number} max - Domain maximum
|
|
503
|
+
* @param {number} [count] - Desired tick count (approximate)
|
|
504
|
+
* @returns {number[]}
|
|
505
|
+
*/
|
|
506
|
+
export function axisTicksLinear(min, max, count = 5) {
|
|
507
|
+
if (!Number.isFinite(min) || !Number.isFinite(max))
|
|
508
|
+
return [];
|
|
509
|
+
if (min === max)
|
|
510
|
+
return [min];
|
|
511
|
+
if (min > max)
|
|
512
|
+
return axisTicksLinear(max, min, count);
|
|
513
|
+
const step = niceStep(max - min, count);
|
|
514
|
+
const decimals = Math.max(0, -Math.floor(Math.log10(step)));
|
|
515
|
+
const ticks = [];
|
|
516
|
+
const first = Math.ceil(min / step);
|
|
517
|
+
const last = Math.floor(max / step);
|
|
518
|
+
for (let i = first; i <= last; ++i)
|
|
519
|
+
ticks.push(Number((i * step).toFixed(decimals)));
|
|
520
|
+
return ticks;
|
|
521
|
+
}
|
|
522
|
+
|
|
494
523
|
/**
|
|
495
524
|
* Clamp a value into the unit interval `[0, 1]` — the fraction every
|
|
496
525
|
* unit-space geometry stage emits. `NaN` passes through as `NaN` rather
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
//#region As-of joins
|
|
4
|
+
// "What was the price when this trade printed." "Which shift was on
|
|
5
|
+
// duty when this alarm fired." "What did the thermostat last read
|
|
6
|
+
// before this door opened." One question, asked of two series that
|
|
7
|
+
// share a timeline and nothing else — no common key, no matching
|
|
8
|
+
// instants, and no promise that either side reported when the other
|
|
9
|
+
// did.
|
|
10
|
+
//
|
|
11
|
+
// It is a join, so it is easy to write wrong twice:
|
|
12
|
+
//
|
|
13
|
+
// **Once per left row.** Filtering the right side inside the loop is
|
|
14
|
+
// the obvious implementation and it is O(n·m). Both sides are sorted,
|
|
15
|
+
// and a sorted pair is one walk: this module keeps a single cursor
|
|
16
|
+
// that only ever moves forward, so the whole join is O(n + m) after
|
|
17
|
+
// normalization — and with a key, one partition pass plus a cursor
|
|
18
|
+
// per key.
|
|
19
|
+
//
|
|
20
|
+
// **By dropping rows.** A left row with no match is still a left row.
|
|
21
|
+
// It comes back as `{ left, right: null, distance: null }`, because a
|
|
22
|
+
// join that quietly returns fewer rows than it was given is how a
|
|
23
|
+
// report loses the events nothing explained.
|
|
24
|
+
//
|
|
25
|
+
// Three directions, and the tie rules are pinned rather than emergent:
|
|
26
|
+
// at an equal instant the **last** right-side row wins (duplicates are
|
|
27
|
+
// two readings, and the later one is the one "as of" means), and a
|
|
28
|
+
// `nearest` tie between an earlier and a later row chooses the earlier,
|
|
29
|
+
// because a value that has already been observed is evidence and one
|
|
30
|
+
// that has not is a forecast.
|
|
31
|
+
|
|
32
|
+
import { canonicalSeries } from './normalize.js';
|
|
33
|
+
import { selectorOf, requireSpecMembers } from './selector.js';
|
|
34
|
+
import { parseDuration, durationToMs } from '../dates/duration.js';
|
|
35
|
+
|
|
36
|
+
/** @typedef {import('./normalize.js').Sample} Sample */
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One left row and whatever the right side had to say about it.
|
|
40
|
+
* @typedef {Object} AsOfMatch
|
|
41
|
+
* @property {any} left - the canonical left sample
|
|
42
|
+
* @property {any | null} right - the canonical right sample, or `null`
|
|
43
|
+
* @property {number | null} distance - milliseconds between the two
|
|
44
|
+
* instants, never negative; `null` when there was no match
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
const DIRECTIONS = Object.freeze(['backward', 'forward', 'nearest']);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A non-negative tolerance in milliseconds, from a number or a fixed
|
|
51
|
+
* ISO 8601 duration. A calendar duration is refused: "within a month"
|
|
52
|
+
* has no width until it is told which month.
|
|
53
|
+
* @param {any} spec
|
|
54
|
+
* @returns {number}
|
|
55
|
+
*/
|
|
56
|
+
function requireTolerance(spec) {
|
|
57
|
+
let ms = spec;
|
|
58
|
+
if (typeof spec === 'string') {
|
|
59
|
+
const parts = parseDuration(spec);
|
|
60
|
+
if (parts === null || parts.negative)
|
|
61
|
+
throw new TypeError(`tolerance '${spec}' is not a non-negative ISO 8601 duration`);
|
|
62
|
+
ms = durationToMs(parts);
|
|
63
|
+
if (!Number.isFinite(ms))
|
|
64
|
+
throw new TypeError(`tolerance '${spec}' is a calendar duration and has no fixed width`);
|
|
65
|
+
}
|
|
66
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0)
|
|
67
|
+
throw new TypeError('tolerance is a non-negative number of milliseconds or a fixed ISO 8601 duration');
|
|
68
|
+
return ms;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* For each position, the last position sharing its instant — so the
|
|
73
|
+
* right-bias rule is a lookup rather than a scan through duplicates.
|
|
74
|
+
* @param {Sample[]} rows - ascending by `at`
|
|
75
|
+
* @returns {Int32Array}
|
|
76
|
+
*/
|
|
77
|
+
function lastAtSameInstant(rows) {
|
|
78
|
+
const out = new Int32Array(rows.length);
|
|
79
|
+
for (let i = rows.length - 1; i >= 0; i--)
|
|
80
|
+
out[i] = (i + 1 < rows.length && rows[i + 1].at === rows[i].at) ? out[i + 1] : i;
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `asOfJoin`'s closed specification: which way to look, how far, what
|
|
86
|
+
* makes two rows comparable, and where each side keeps its members.
|
|
87
|
+
*
|
|
88
|
+
* This is the one series kernel whose spelling a query DOCUMENT cannot
|
|
89
|
+
* reuse: `left`/`right` are nested selector records, and §8.16 flattens
|
|
90
|
+
* them to `by`, `leftAt` and `rightAt` so the whole spec stays a
|
|
91
|
+
* literal. `@jarenjs/json` therefore keeps its own list, and says so.
|
|
92
|
+
*/
|
|
93
|
+
export const ASOF_MEMBERS = Object.freeze([
|
|
94
|
+
'direction', 'tolerance', 'key', 'left', 'right',
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Join each left sample to the right sample that was current for it.
|
|
99
|
+
*
|
|
100
|
+
* The result is one record **per left row**, in the left series'
|
|
101
|
+
* normalized order — sorted by instant, with rows sharing an instant in
|
|
102
|
+
* the order they arrived. Both `left` and `right` are canonical samples:
|
|
103
|
+
* shallow copies carrying every member of the source row plus a numeric
|
|
104
|
+
* `at` and `value`, so a consumer reads `match.right.at` rather than
|
|
105
|
+
* parsing a timestamp a second time.
|
|
106
|
+
*
|
|
107
|
+
* | direction | the right row chosen |
|
|
108
|
+
* |---|---|
|
|
109
|
+
* | `backward` | the last one at or before the left instant (default) |
|
|
110
|
+
* | `forward` | the last one at or after it |
|
|
111
|
+
* | `nearest` | whichever is closer; a tie chooses `backward` |
|
|
112
|
+
*
|
|
113
|
+
* At an equal instant the **last** right-side row wins in every
|
|
114
|
+
* direction: duplicates are two readings in the same millisecond, and
|
|
115
|
+
* "as of" means the later one.
|
|
116
|
+
*
|
|
117
|
+
* `tolerance` is the furthest a match may be, in milliseconds or as a
|
|
118
|
+
* fixed duration. Beyond it there is no match — not a distant one.
|
|
119
|
+
*
|
|
120
|
+
* `key` joins within groups: a property name or a function, applied to
|
|
121
|
+
* both sides (or `left.key`/`right.key` when the two sides spell it
|
|
122
|
+
* differently). The right side is partitioned **once**; no left row ever
|
|
123
|
+
* filters it.
|
|
124
|
+
*
|
|
125
|
+
* @param {any[]} left
|
|
126
|
+
* @param {any[]} right
|
|
127
|
+
* @param {Object} [spec]
|
|
128
|
+
* @param {'backward'|'forward'|'nearest'} [spec.direction] default `'backward'`
|
|
129
|
+
* @param {number | string} [spec.tolerance] - the furthest a match may be
|
|
130
|
+
* @param {string | ((item: any, index: number) => any)} [spec.key] - the
|
|
131
|
+
* group both sides join within
|
|
132
|
+
* @param {{ at?: any, value?: any, key?: any }} [spec.left] - where the
|
|
133
|
+
* left side's members live
|
|
134
|
+
* @param {{ at?: any, value?: any, key?: any }} [spec.right] - where the
|
|
135
|
+
* right side's members live
|
|
136
|
+
* @returns {AsOfMatch[]}
|
|
137
|
+
* @throws {TypeError} for an unknown direction, a tolerance that is not
|
|
138
|
+
* a non-negative fixed width, or a row that is not a canonical sample
|
|
139
|
+
* @example
|
|
140
|
+
* asOfJoin(trades, quotes); // the last quote at or before
|
|
141
|
+
* asOfJoin(alarms, shifts, { direction: 'nearest', tolerance: 'PT1H' });
|
|
142
|
+
* asOfJoin(readings, calibrations, { key: 'sensor' }); // per sensor
|
|
143
|
+
*/
|
|
144
|
+
export function asOfJoin(left, right, spec = {}) {
|
|
145
|
+
requireSpecMembers(spec, ASOF_MEMBERS, 'asOfJoin', 'an as-of spec is an object');
|
|
146
|
+
const direction = spec.direction ?? 'backward';
|
|
147
|
+
if (typeof direction !== 'string' || !DIRECTIONS.includes(direction)) {
|
|
148
|
+
throw new TypeError(`direction is ${DIRECTIONS.map((d) => `'${d}'`).join(', ')}, not ${
|
|
149
|
+
JSON.stringify(direction)}`);
|
|
150
|
+
}
|
|
151
|
+
const tolerance = spec.tolerance === undefined ? Infinity : requireTolerance(spec.tolerance);
|
|
152
|
+
const leftRows = canonicalSeries(left, spec.left);
|
|
153
|
+
const rightRows = canonicalSeries(right, spec.right);
|
|
154
|
+
|
|
155
|
+
const keySpec = spec.left?.key ?? spec.key;
|
|
156
|
+
const rightKeySpec = spec.right?.key ?? spec.key;
|
|
157
|
+
if (keySpec === undefined)
|
|
158
|
+
return joinRun(leftRows, rightRows, direction, tolerance);
|
|
159
|
+
|
|
160
|
+
const readLeftKey = selectorOf(keySpec, 'key');
|
|
161
|
+
const readRightKey = selectorOf(rightKeySpec ?? keySpec, 'key');
|
|
162
|
+
return joinKeyed(leftRows, rightRows, readLeftKey, readRightKey, direction, tolerance);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The unkeyed walk: one cursor over the right side, moved forward and
|
|
167
|
+
* never back, because the left side is sorted too.
|
|
168
|
+
* @param {Sample[]} leftRows
|
|
169
|
+
* @param {Sample[]} rightRows
|
|
170
|
+
* @param {string} direction
|
|
171
|
+
* @param {number} tolerance
|
|
172
|
+
* @returns {AsOfMatch[]}
|
|
173
|
+
*/
|
|
174
|
+
function joinRun(leftRows, rightRows, direction, tolerance) {
|
|
175
|
+
const groupEnd = lastAtSameInstant(rightRows);
|
|
176
|
+
const out = new Array(leftRows.length);
|
|
177
|
+
let cursor = 0;
|
|
178
|
+
for (let i = 0; i < leftRows.length; i++) {
|
|
179
|
+
while (cursor < rightRows.length && rightRows[cursor].at <= leftRows[i].at)
|
|
180
|
+
cursor++;
|
|
181
|
+
out[i] = pick(leftRows[i], rightRows, groupEnd, cursor, direction, tolerance);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The keyed walk: the right side partitioned once into per-key runs
|
|
188
|
+
* (each already sorted, because the whole was), then one cursor per key.
|
|
189
|
+
* A left row whose key the right side never carried is unmatched, which
|
|
190
|
+
* is an answer rather than an omission.
|
|
191
|
+
* @param {Sample[]} leftRows
|
|
192
|
+
* @param {Sample[]} rightRows
|
|
193
|
+
* @param {(item: any, index: number) => any} readLeftKey
|
|
194
|
+
* @param {(item: any, index: number) => any} readRightKey
|
|
195
|
+
* @param {string} direction
|
|
196
|
+
* @param {number} tolerance
|
|
197
|
+
* @returns {AsOfMatch[]}
|
|
198
|
+
*/
|
|
199
|
+
function joinKeyed(leftRows, rightRows, readLeftKey, readRightKey, direction, tolerance) {
|
|
200
|
+
/** @type {Map<any, { rows: Sample[], groupEnd: Int32Array | null, cursor: number }>} */
|
|
201
|
+
const groups = new Map();
|
|
202
|
+
for (let i = 0; i < rightRows.length; i++) {
|
|
203
|
+
const key = readRightKey(rightRows[i], i);
|
|
204
|
+
let group = groups.get(key);
|
|
205
|
+
if (group === undefined) {
|
|
206
|
+
group = { rows: [], groupEnd: null, cursor: 0 };
|
|
207
|
+
groups.set(key, group);
|
|
208
|
+
}
|
|
209
|
+
group.rows.push(rightRows[i]);
|
|
210
|
+
}
|
|
211
|
+
for (const group of groups.values())
|
|
212
|
+
group.groupEnd = lastAtSameInstant(group.rows);
|
|
213
|
+
|
|
214
|
+
const out = new Array(leftRows.length);
|
|
215
|
+
for (let i = 0; i < leftRows.length; i++) {
|
|
216
|
+
const group = groups.get(readLeftKey(leftRows[i], i));
|
|
217
|
+
if (group === undefined) {
|
|
218
|
+
out[i] = { left: leftRows[i], right: null, distance: null };
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
while (group.cursor < group.rows.length && group.rows[group.cursor].at <= leftRows[i].at)
|
|
222
|
+
group.cursor++;
|
|
223
|
+
out[i] = pick(leftRows[i], group.rows, /** @type {Int32Array} */(group.groupEnd),
|
|
224
|
+
group.cursor, direction, tolerance);
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* One left row's answer, given the cursor already standing at the first
|
|
231
|
+
* right row strictly after it.
|
|
232
|
+
*
|
|
233
|
+
* `cursor - 1` is therefore the last right row at or before the left
|
|
234
|
+
* instant — the right-bias rule, for free — and `groupEnd[cursor]` is
|
|
235
|
+
* the last row of the first instant strictly after it, which is the
|
|
236
|
+
* same rule looking the other way.
|
|
237
|
+
* @param {Sample} leftRow
|
|
238
|
+
* @param {Sample[]} rightRows
|
|
239
|
+
* @param {Int32Array} groupEnd
|
|
240
|
+
* @param {number} cursor
|
|
241
|
+
* @param {string} direction
|
|
242
|
+
* @param {number} tolerance
|
|
243
|
+
* @returns {AsOfMatch}
|
|
244
|
+
*/
|
|
245
|
+
function pick(leftRow, rightRows, groupEnd, cursor, direction, tolerance) {
|
|
246
|
+
const back = cursor - 1;
|
|
247
|
+
const forward = (back >= 0 && rightRows[back].at === leftRow.at)
|
|
248
|
+
? back
|
|
249
|
+
: (cursor < rightRows.length ? groupEnd[cursor] : -1);
|
|
250
|
+
const backDistance = back >= 0 ? leftRow.at - rightRows[back].at : Infinity;
|
|
251
|
+
const forwardDistance = forward >= 0 ? rightRows[forward].at - leftRow.at : Infinity;
|
|
252
|
+
|
|
253
|
+
let index;
|
|
254
|
+
let distance;
|
|
255
|
+
if (direction === 'backward') {
|
|
256
|
+
index = back;
|
|
257
|
+
distance = backDistance;
|
|
258
|
+
}
|
|
259
|
+
else if (direction === 'forward') {
|
|
260
|
+
index = forward;
|
|
261
|
+
distance = forwardDistance;
|
|
262
|
+
}
|
|
263
|
+
else if (backDistance <= forwardDistance) {
|
|
264
|
+
index = back;
|
|
265
|
+
distance = backDistance;
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
index = forward;
|
|
269
|
+
distance = forwardDistance;
|
|
270
|
+
}
|
|
271
|
+
if (index < 0 || distance > tolerance)
|
|
272
|
+
return { left: leftRow, right: null, distance: null };
|
|
273
|
+
return { left: leftRow, right: rightRows[index], distance };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
//#endregion
|