@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,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
@@ -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