@jarenjs/core 0.46.4 → 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 CHANGED
@@ -109,7 +109,9 @@ flowchart TB
109
109
  DatesRfc["rfc3339.js<br/>RFC 3339 / ISO 8601"]
110
110
  DatesCivil["civil.js<br/>Gregorian day-number math"]
111
111
  DatesFormat["format.js<br/>LDML pattern compiler"]
112
+ DatesParse["parse.js<br/>LDML pattern parser"]
112
113
  DatesDuration["duration.js<br/>ISO 8601 durations"]
114
+ DatesTicks["ticks.js<br/>Time-axis step ladder"]
113
115
  end
114
116
 
115
117
  subgraph GeoModule["Spatial"]
@@ -130,6 +132,14 @@ flowchart TB
130
132
  VectorIndex["vector/index.js<br/>Similarity kernels, normalization, packed form"]
131
133
  end
132
134
 
135
+ subgraph SeriesModule["Temporal"]
136
+ SeriesIndex["series/index.js"]
137
+ SeriesSelector["selector.js<br/>Where a member lives in a row"]
138
+ SeriesNormalize["normalize.js<br/>Instants, stable sort, binary bounds"]
139
+ SeriesInterval["interval.js<br/>Half-open set algebra"]
140
+ SeriesTree["interval-index.js<br/>Prefix-max-end interval index"]
141
+ end
142
+
133
143
  subgraph MathModules["Mathematics"]
134
144
  MathIndex["math/index.js"]
135
145
  Int32Math["int32.js<br/>Fixed-point math"]
@@ -191,6 +201,7 @@ flowchart TB
191
201
  DatesIndex --> DatesCivil
192
202
  DatesIndex --> DatesFormat
193
203
  DatesIndex --> DatesDuration
204
+ DatesIndex --> DatesTicks
194
205
 
195
206
  GeoIndex --> GeoPred
196
207
  GeoIndex --> GeoDist
@@ -462,7 +473,9 @@ intermediate and is plain data, never an opaque handle.
462
473
  | `rfc3339.js` | validation, lexical decomposition, epoch conversion |
463
474
  | `civil.js` | proleptic Gregorian arithmetic over integers |
464
475
  | `format.js` | LDML pattern → compiled formatter |
476
+ | `parse.js` | LDML pattern → compiled strict parser (the formatter's inverse) |
465
477
  | `duration.js` | ISO 8601 duration decomposition and date arithmetic |
478
+ | `ticks.js` | the time-axis step ladder and its calendar boundaries |
466
479
 
467
480
  Calendar math goes through day numbers (`daysFromCivil`/`civilFromDays`),
468
481
  never through `Date`: the conversions are ~10 integer operations and allocate
@@ -475,6 +488,23 @@ allocates nothing per value. Formatting is the two-stage compiler again — a
475
488
  pattern is scanned once into a chain of appenders, which measured ~4.5×
476
489
  against re-scanning it per call.
477
490
 
491
+ Parsing is the same compiler run backwards. `compileDateParser` scans a
492
+ pattern once into a chain of readers and returns a function from text to the
493
+ same parts record `parseRFC3339Parts` produces, so every calendar function
494
+ takes its output unchanged. It is strict in three ways that a permissive
495
+ reader is not: trailing input fails, an impossible civil date (31 February,
496
+ hour 24) fails rather than rolling forward into the next month, and a
497
+ fixed-width token reads exactly its width. Failure is `null` — malformed
498
+ *text* is data — while a malformed *pattern* throws at compile time. The
499
+ tokens that only ever format, because they are derived from a date rather
500
+ than fields of one (`EEEE`, `DDD`, `ww`, `Q`), are compile errors with a
501
+ message saying which and why, rather than tokens that quietly read nothing.
502
+ A consumer whose patterns are not LDML — Mermaid's Gantt `dateFormat` is
503
+ moment's grammar and its `axisFormat` is strftime — adapts its own tokens
504
+ onto this vocabulary rather than handing its spelling to this compiler:
505
+ LDML's `YYYY` is the week-numbering year, so `YYYY-MM-DD` read as LDML
506
+ answers 2019 for 2018-12-31.
507
+
478
508
  Locale-dependent presentation (month and weekday names, meridiem, relative
479
509
  phrasing) is deliberately **not** here, so this package stays zero-dependency
480
510
  and free of data that drifts per language: `compileDateFormat` takes a names
@@ -532,11 +562,31 @@ import {
532
562
  const parts = parseRFC3339Parts('2026-01-31');
533
563
  addToParts(parts, 1, 'month'); // 2026-02-28 — month math clamps
534
564
  startOfParts(parts, 'week'); // the Monday of that week (ISO 8601)
565
+ addToParts(parts, 3, 'hour'); // TypeError — a full-date has no clock
535
566
 
536
567
  const fmt = compileDateFormat("yyyy-'W'ww"); // compile once…
537
568
  fmt(parts); // …call many: '2026-W05'
569
+
570
+ const read = compileDateParser('dd-MM-yyyy');
571
+ read('06-01-2014'); // { year: 2014, month: 1, day: 6, … }
572
+ read('06-01-2014x'); // null — trailing input
573
+ read('31-02-2014'); // null — February has no 31st
574
+ ```
575
+
576
+ **Ticks a caller chose, or ticks this module chose:**
577
+
578
+ ```javascript
579
+ import { axisTicksTime, timeTicksEvery } from '@jarenjs/core/dates';
580
+
581
+ axisTicksTime(from, to, 4); // the ladder picks the step
582
+ timeTicksEvery(from, to, 'week', 1, { weekStart: 7 }); // Sundays
538
583
  ```
539
584
 
585
+ `axisTicksTime` is the chart axis's planner; `timeTicksEvery` is the same
586
+ boundary rule with the step supplied, which is what a document declaring its
587
+ own interval (Mermaid's `tickInterval 1week`) needs. One ladder, so a chart
588
+ and a timeline cannot disagree about where a tick falls.
589
+
540
590
  ### 5b. Geo Module (`geo/`)
541
591
 
542
592
  The spatial kernel. As with dates, **there is no geometry type**: the
@@ -721,6 +771,60 @@ their reason to exist; they guarantee a finite answer. The fixed-arity vector
721
771
  classes in `math/` (`Vec2f64`, `Vec3f64`) are 2D/3D geometry with a
722
772
  different convention and a different job, and they stay where they are.
723
773
 
774
+ ### 5d. Series Module (`series/`)
775
+
776
+ The temporal kernel: one meaning for an instant, one for a sorted series of
777
+ readings, one for an interval, and the set algebra over them. As with dates,
778
+ **there is no type** — an instant is epoch milliseconds or an RFC 3339 string,
779
+ a sample is `{ at, value }`, an interval is `{ start, end }` — and, as with
780
+ dates, **there is no `now`**: every bound is data, so every answer here is
781
+ reproducible and cacheable.
782
+
783
+ | File | Owns |
784
+ |---|---|
785
+ | `selector.js` | where a member lives in a caller's row — a property name or a function, so `on`/`recorded_at`/`from`/`to` are read where they are |
786
+ | `normalize.js` | `toEpoch`, `normalizeSeries`, `normalizeIntervals` and the `lowerBoundTime`/`upperBoundTime` cuts a sorted array is read through |
787
+ | `interval.js` | `containsInstant`, `overlapsInterval`, `intersectInterval`, `mergeIntervals`, `subtractIntervals`, `gapsWithin`, `coverageOf`, `findSlots` |
788
+ | `interval-index.js` | `createIntervalIndex` — build once, query many, by point or by range |
789
+
790
+ Four decisions carry it. **Half-open `[start, end)`**: an interval holds its
791
+ start and not its end, so touching intervals neither overlap nor double-count
792
+ and a boundary instant belongs to exactly one of them — while `mergeIntervals`
793
+ *joins* touching spans by default, because availability means continuous cover,
794
+ with `{ adjacent: false }` as the explicit other answer. **Nothing is dropped**:
795
+ a member that names no instant, or an interval that is empty, reversed or
796
+ non-finite, is a refusal naming the row rather than a silently shorter result,
797
+ and the sort is stable so duplicate instants keep their order and both count.
798
+ **No clock**: `gapsWithin` and `coverageOf` derive their window from the input's
799
+ own hull when given none, because the only other default would be "now".
800
+
801
+ And **the index cuts on both ends**. Sorting by start alone is the bug: a span
802
+ that began long before a query sits far to the left of the query's own
803
+ neighbourhood and still overlaps it, so a binary search around the query loses
804
+ it while looking plausible. `createIntervalIndex` therefore carries a prefix
805
+ maximum end beside the starts — non-decreasing by construction, hence
806
+ binary-searchable — and the first position where it passes the query's start is
807
+ the first position where anything can still be live. A query is two binary cuts
808
+ and a walk between them, O(log n + k), returning the caller's own rows in a
809
+ fresh array. Like the packed-Hilbert box index it is **static**: bounds are
810
+ copied into flat typed arrays at build time, so a query reads no source object
811
+ and a row mutated afterwards cannot change what the index answers.
812
+
813
+ Buckets, resampling, rolling windows and as-of joins are the layer above this
814
+ one; named zones, recurrence grammars and scheduling solvers are outside it.
815
+ `findSlots` enumerates where a fixed-width span fits — choosing among the
816
+ answers is a solver's job, deliberately not this kernel's.
817
+
818
+ ```javascript
819
+ import { createIntervalIndex, mergeIntervals, findSlots } from '@jarenjs/core/series';
820
+
821
+ const shifts = [{ start: 0, end: 4 * 3600_000 }, { start: 4 * 3600_000, end: 8 * 3600_000 }];
822
+ mergeIntervals(shifts); // one span — touching IS continuous cover
823
+ mergeIntervals(shifts, { adjacent: false }); // two — a handover is two shifts
824
+ findSlots(shifts, { duration: 'PT30M' }).length; // 16
825
+ createIntervalIndex(shifts).at(4 * 3600_000); // the second shift only: [start, end)
826
+ ```
827
+
724
828
  ### 6. Text Module (`text/`)
725
829
 
726
830
  Comprehensive string format validation organized by domain.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @jarenjs/core
2
2
 
3
- The zero-dependency foundation of [Jaren](https://github.com/jklarenbeek/jarenjs). Everything the rest of the suite is built on lives here — type guards, Unicode-aware string handling, a large text-validation toolbox, number range helpers, fixed-point and vector math, the calendar kernel, the spatial kernel, the vector kernel, the message-catalog compiler, unit/currency conversion and a finance library.
3
+ The zero-dependency foundation of [Jaren](https://github.com/jklarenbeek/jarenjs). Everything the rest of the suite is built on lives here — type guards, Unicode-aware string handling, a large text-validation toolbox, number range helpers, fixed-point and vector math, the calendar kernel, the spatial kernel, the vector kernel, the interval kernel, the message-catalog compiler, unit/currency conversion and a finance library.
4
4
 
5
5
  None of it depends on JSON Schema: every module can be used standalone in any JavaScript project.
6
6
 
@@ -24,6 +24,7 @@ None of it depends on JSON Schema: every module can be used standalone in any Ja
24
24
  | `@jarenjs/core/dates` | RFC 3339 / ISO 8601 validation, plus the calendar kernel: integer date arithmetic, compiled formatting, durations |
25
25
  | `@jarenjs/core/geo` | the spatial kernel over GeoJSON: robust orientation, great-circle measurement, rings, bounding boxes, geohash, GeoJSON/WKT validity, a packed-Hilbert box index, Web Mercator and Douglas-Peucker simplification |
26
26
  | `@jarenjs/core/vector` | the vector kernel over plain arrays: dot, cosine and Euclidean similarity (higher-is-better; a malformed pair scores 0), l2 normalization, the packed little-endian Float32 form and the one shape guard (`isVector`) |
27
+ | `@jarenjs/core/series` | the temporal kernel over plain records: instant/sample/interval normalization with a stable sort, half-open `[start, end)` set algebra (overlap, merge, subtract, gaps, coverage, slot enumeration), a static interval index, and the series operations built on them — fixed and calendar buckets with five fill policies, time-width rolling aggregates, as-of joins and gap-aware downsampling, on an injected zone seam |
27
28
  | `@jarenjs/core/text` | text validators: emails, hostnames, IPs, URIs/IRIs, UUIDs, punycode, ... |
28
29
  | `@jarenjs/core/math` | int32/float64 math and 2D/3D vector classes; the linear `remap` and unit-interval `clamp01` |
29
30
  | `@jarenjs/core/finance` | zero-dependency finance/trading formulas: TVM, cash flow, amortization, interest, depreciation, bonds, technical indicators, returns/risk |
@@ -65,7 +66,7 @@ Grouped by file: `email` (RFC 5321 + internationalized addresses), `host` (hostn
65
66
 
66
67
  ## Dates, numbers and math
67
68
 
68
- - `dates` — RFC 3339 date/time/date-time validation and parsing (leap years and month lengths included), the more lenient ISO date-time forms, and the suite's calendar kernel: proleptic Gregorian arithmetic over integer day numbers (`addToParts`, `startOfParts`, `endOfParts` — month math clamps, so 31 Jan plus a month is 28 Feb), ISO 8601 duration decomposition, and `compileDateFormat`, which turns an LDML pattern into a formatter once instead of re-scanning it per call. Dates stay JSON: an RFC 3339 string or epoch milliseconds, never a wrapper object. Locale names live in `@jarenjs/locales`, so a pattern needing `MMMM` takes a names provider. The full module reference is [docs/DATES.md](./docs/DATES.md).
69
+ - `dates` — RFC 3339 date/time/date-time validation and parsing (leap years and month lengths included), the more lenient ISO date-time forms, and the suite's calendar kernel: proleptic Gregorian arithmetic over integer day numbers (`addToParts`, `startOfParts`, `endOfParts` — month math clamps, so 31 Jan plus a month is 28 Feb, and an operation reading a half the value has not got is refused rather than guessed), ISO 8601 duration decomposition, the locale-free time-axis step ladder (`niceTimeStep`, `axisTicksTime`, and `timeTicksEvery` for a step the caller declares) a chart and a timeline both read, and the two-stage pattern compilers `compileDateFormat` and its strict inverse `compileDateParser`, which turn an LDML pattern into a formatter or a parser once instead of re-scanning it per call — the parser refuses trailing input, an impossible civil date, and the tokens that only format because they are derived from a date rather than fields of one. Dates stay JSON: an RFC 3339 string or epoch milliseconds, never a wrapper object. Locale names live in `@jarenjs/locales`, so a pattern needing `MMMM` takes a names provider. The full module reference is [docs/DATES.md](./docs/DATES.md); the interval algebra built on top of it is [`series`](#intervals-and-series).
69
70
  - `integer`/`float`/`bigint` — range constants and validators for every fixed-width type from `int8` to `uint64` and `float16` to `float64`, including float increment/decrement in representable steps.
70
71
  - `math` — asm.js-style typed math (`Int32`, `Float64`) and vector classes (`Vec2i32`, `Vec2f64`, `Vec3f64`) with a fast integer sine approximation; reference in [docs/MATH.md](./docs/MATH.md).
71
72
 
@@ -123,6 +124,49 @@ Three rules, kept by every function so that no caller has to check them again:
123
124
 
124
125
  The packed form is `4·d` bytes of little-endian binary32 — the value a database column stores; components round to `Math.fround` and come back exactly. Unpacking aligned bytes on a little-endian host is a *view*, not a copy, which is what a sweep over ten thousand fetched rows is paid for by; misaligned bytes (a pooled `Buffer`, an odd offset into a record) and big-endian hosts take the copy path to the same values. The client that produces embeddings — and a deterministic reference embedder for tests — lives in [`@jarenjs/ai`](../ai/README.md#embeddings).
125
126
 
127
+ ## Intervals and series
128
+
129
+ `@jarenjs/core/series` is the suite's temporal kernel: one meaning for an interval, one meaning for a sorted series of timestamped readings, the set algebra over them, and the five operations every consumer of a timeline otherwise rebuilds by hand — bucket, fill, roll, join as-of, downsample. As with dates, there is no type — an instant is epoch milliseconds or an RFC 3339 string, a sample is `{ at, value }`, an interval is `{ start, end }` — so every value stays a plain JSON item, and nothing here reads a clock.
130
+
131
+ ```javascript
132
+ import { createIntervalIndex, mergeIntervals, gapsWithin, findSlots } from '@jarenjs/core/series';
133
+
134
+ const shifts = [
135
+ { from: '2026-03-02T09:00:00Z', to: '2026-03-02T13:00:00Z', who: 'ada' },
136
+ { from: '2026-03-02T13:00:00Z', to: '2026-03-02T17:00:00Z', who: 'grace' },
137
+ ];
138
+ const index = createIntervalIndex(shifts, { start: 'from', end: 'to' });
139
+ index.at('2026-03-02T13:00:00Z'); // [grace] — half-open, so the handover belongs to one shift
140
+
141
+ const cover = shifts.map((s) => ({ start: s.from, end: s.to }));
142
+ mergeIntervals(cover); // one span, 09:00–17:00 — touching IS continuous cover
143
+ findSlots(cover, { duration: 'PT30M' }); // sixteen half-hour slots, one straddling the handover
144
+ ```
145
+
146
+ ```javascript
147
+ import { resampleSeries, rollingSeries, asOfJoin, downsampleSeries } from '@jarenjs/core/series';
148
+
149
+ resampleSeries(readings, { every: 'PT1H', aggregate: 'mean', fill: 'linear' });
150
+ resampleSeries(readings, { every: 'P1M', zone: 'Europe/Amsterdam', provider });
151
+ rollingSeries(readings, { width: 'PT5M', aggregate: 'max', minPeriods: 3 });
152
+ asOfJoin(trades, quotes, { direction: 'nearest', tolerance: 'PT1S', key: 'symbol' });
153
+ downsampleSeries(readings, { target: 2000 }); // → { points, sourceCount, renderedCount, method }
154
+ ```
155
+
156
+ Seven decisions carry the module:
157
+
158
+ - **Half-open, everywhere.** `[start, end)` holds its start and not its end, so a day ends exactly where the next begins, a boundary instant belongs to exactly one of two touching intervals, and nothing is counted twice. Touching intervals therefore do *not* overlap — back-to-back bookings are not a double booking — while `mergeIntervals` joins them by default, because availability asks whether there is continuous cover. `{ adjacent: false }` is the other answer, spelled out rather than guessed.
159
+ - **A row is never dropped, and a duplicate is never merged away.** A member that cannot become a finite instant is a refusal naming the row, not a silently shorter result; an empty (`[t, t)`), reversed or non-finite interval is refused at the point it was written. The sort is stable, so two readings in the same millisecond keep their input order and both count.
160
+ - **Nothing reads a clock.** `gapsWithin` and `coverageOf` derive their window from the input's own hull when given none, because the only other default would be "now" — and an operation that read the clock could not be cached, reproduced or run in a test twice.
161
+ - **The index cuts on both ends, and cannot lose a long span.** Sorting by start alone is the bug: a conference week that began before an hourly meeting sits far to the left of that meeting's neighbourhood and still overlaps it. `createIntervalIndex` carries a prefix maximum end beside the starts — non-decreasing, so binary-searchable — and a query becomes two binary cuts and a walk between them, O(log n + k), returning the caller's own rows. It is static: bounds are copied at build time, so a query reads no source object and a row mutated afterwards changes nothing.
162
+
163
+ - **Bucketing is arithmetic; filling is a policy.** An average over an empty hour is not zero, and it is not yesterday's average, and it is not nothing — it is whichever of `omit | null | zero | locf | linear` the caller asked for, and the aggregate had no opinion. `count` reports source rows (duplicates and measured gaps included) beside every value, so `null` and "nobody reported" are distinguishable. Neither `locf` nor `linear` invents a value at the leading edge.
164
+ - **A rolling window is a duration, not a row count.** Sixty rows of a sensor reporting every second is a minute; sixty rows of a sensor that dropped half its readings is two minutes. The window is `(at − width, at]`, so two readings in the same millisecond share it and therefore share an answer. `sum`/`mean`/`count` carry a running total, `min`/`max` use a monotone deque, `first`/`last` are forward-only pointers — the complexity is structural, and the tests count the reads rather than the milliseconds.
165
+ - **Time zones are injected, never bundled.** UTC and fixed offsets work with no setup; a named zone takes the caller's own `provider`, because a bundled tzdb is megabytes that go stale on a government's timetable. A local time that never happened, or happened twice, is a refusal unless `disambiguation` says `earlier` or `later`. A sampler never bridges a gap, never moves an end, and refuses a target too small to hold both rather than drawing a prettier line.
166
+ - **A specification is closed.** Every kernel that takes one publishes the members it admits, and anything else is a refusal naming the near miss — because `minPeriod` for `minPeriods` quietly ignored is a window with no minimum and a plausible number to show for it, and `timezone` for `zone` is precisely the silent fall back to UTC the injected clock exists to prevent. The query language reads the same lists, minus the `provider`, which is a pair of functions and therefore not something a document can carry.
167
+
168
+ The kernel is measured against one-pass loops written for one question and against the query vocabulary a consumer had instead, and both ratios are published: it costs about twice a hand-written loop and answers about a hundred times faster than the generic route. Named time zones' data, recurrence grammars and scheduling solvers are deliberately outside it. The full module reference is [docs/SERIES.md](./docs/SERIES.md).
169
+
126
170
  ## Messages
127
171
 
128
172
  `@jarenjs/core/message` is the template/catalog compiler behind the validator's uniform error messages and the form layer's labels — msgid plus parameters in, rendered text out, so every message is translatable by swapping a catalog ([`@jarenjs/locales`](../locales) ships eleven language packs over it):
@@ -111,36 +111,55 @@ export declare function fixedUnitMs(unit: string): number;
111
111
  /**
112
112
  * Add a signed amount of calendar units to a parts record, returning a
113
113
  * new one. The input is never mutated and its lexical shape is kept: a
114
- * full-date stays a full-date, and a value keeps its own UTC offset
115
- * rather than being normalized.
114
+ * full-date stays a full-date, a full-time stays a full-time, and a
115
+ * value keeps its own UTC offset rather than being normalized.
116
116
  *
117
117
  * Month and year arithmetic **clamps** to the end of the target month —
118
118
  * 2026-01-31 plus one month is 2026-02-28 — which is the rule every
119
119
  * mainstream date library uses, because the alternative (overflowing
120
120
  * into March) makes `add(1, 'month')` non-monotonic.
121
121
  *
122
+ * A fraction is a quantity only where the unit has an exact conversion.
123
+ * A fixed-width fraction becomes whole milliseconds, so `1.5 day` is
124
+ * thirty-six hours; a calendar fraction is refused unless it lands on a
125
+ * whole month, because half of January is not a length. The clock those
126
+ * milliseconds land on has to exist: a full-date can be moved by whole
127
+ * days but not by half of one.
128
+ *
122
129
  * @param {object} parts - a parts record from `parseRFC3339Parts`
123
- * @param {number} amount - signed count, may be fractional only for
124
- * fixed-width units (a fractional month has no meaning)
130
+ * @param {number} amount - signed count, fractional only where the unit
131
+ * converts exactly and the value has the half to carry it
125
132
  * @param {string} unit - a {@link DATE_UNITS} member
126
133
  * @returns {object} a new parts record
134
+ * @throws {TypeError} for an unknown unit, a fraction with no exact
135
+ * conversion, or a value missing a half the operation reads
127
136
  */
128
137
  export declare function addToParts(parts: object, amount: number, unit: string): object;
129
138
  /**
130
139
  * Truncate a parts record to the start of a calendar unit, returning a
131
140
  * new one. `week` starts on Monday (ISO 8601).
141
+ *
142
+ * `day` and coarser name a boundary the calendar has, so the start of
143
+ * the day of a full-time is midnight. A sub-day unit names a boundary
144
+ * inside a day, which a value with no clock does not have, and is
145
+ * refused rather than answered from a clock that is not there.
146
+ *
132
147
  * @param {object} parts - a parts record
133
148
  * @param {string} unit - a {@link DATE_UNITS} member
134
149
  * @returns {object} a new parts record
150
+ * @throws {TypeError} for an unknown unit, or a value missing a half
151
+ * the truncation reads
135
152
  */
136
153
  export declare function startOfParts(parts: object, unit: string): object;
137
154
  /**
138
155
  * The last representable instant inside a calendar unit: the start of
139
156
  * the next unit less one millisecond. A value with no time half is
140
157
  * truncated to the unit's last *day* instead, so a full-date stays a
141
- * full-date.
158
+ * full-date, and it refuses the same sub-day units `startOfParts` does.
142
159
  * @param {object} parts - a parts record
143
160
  * @param {string} unit - a {@link DATE_UNITS} member
144
161
  * @returns {object} a new parts record
162
+ * @throws {TypeError} for an unknown unit, or a value missing a half
163
+ * the truncation reads
145
164
  */
146
165
  export declare function endOfParts(parts: object, unit: string): object;
@@ -1,4 +1,6 @@
1
1
  export * from './rfc3339.js';
2
2
  export * from './civil.js';
3
3
  export * from './format.js';
4
+ export * from './parse.js';
4
5
  export * from './duration.js';
6
+ export * from './ticks.js';
@@ -0,0 +1,47 @@
1
+ export type Reader = (text: string, i: number, out: any) => number;
2
+ /**
3
+ * Compile an LDML date pattern into a strict parser.
4
+ *
5
+ * The returned function takes a string and returns a parts record in
6
+ * exactly the shape `parseRFC3339Parts` produces — so
7
+ * `epochOfRFC3339Parts`, `formatRFC3339Parts`, `addToParts` and every
8
+ * other calendar function accept it unchanged — or `null` when the text
9
+ * does not match. Failure is `null`, never a throw and never a partial
10
+ * record: a malformed *pattern* is the programmer's error and throws at
11
+ * compile time; malformed *text* is data and is reported as no match.
12
+ *
13
+ * Strict means three things:
14
+ *
15
+ * 1. **Full consumption.** Trailing text fails; `yyyy-MM-dd` does not
16
+ * read `2026-01-02T03:04`.
17
+ * 2. **Impossible civil dates fail.** 31 February and hour 24 are not
18
+ * silently rolled forward into March and the next day.
19
+ * 3. **Fixed-width tokens are fixed width.** `MM` reads exactly two
20
+ * digits, `M` one or two, and neither reads three.
21
+ *
22
+ * The lexical family of the result follows the pattern: a pattern with
23
+ * only date tokens yields a full-date (`hours`/`minutes`/`seconds` are
24
+ * the contract's `-1`), one with only time tokens a full-time
25
+ * (`year`/`month`/`day` are `-1`), and one with both a date-time. A
26
+ * pattern with no field token at all is a compile error — it could only
27
+ * ever return the same empty record.
28
+ *
29
+ * Two tokens are not strict inverses of an arbitrary value and are
30
+ * documented rather than refused: `yy` reads 00–68 as 2000–2068 and
31
+ * 69–99 as 1969–1999 (the POSIX pivot moment uses), and `h`/`hh`
32
+ * without an `a` in the same pattern read as the morning, so 12 is
33
+ * midnight. Both round-trip their own spelling exactly.
34
+ *
35
+ * @param {string} pattern - an LDML pattern, e.g. `'yyyy-MM-dd'`
36
+ * @param {import('./format.js').DateNames} [names] - locale names, required only if the
37
+ * pattern uses `MMM`/`MMMM`/`a`
38
+ * @returns {(text: string) => object | null} the compiled parser
39
+ * @throws {TypeError} on an unterminated quote, a derived token, a name
40
+ * token with no provider, or a pattern that reads no field
41
+ * @example
42
+ * const read = compileDateParser('dd-MM-yyyy');
43
+ * read('06-01-2014'); // { year: 2014, month: 1, day: 6, hours: -1, … }
44
+ * read('06-01-2014x'); // null — trailing input
45
+ * read('31-02-2014'); // null — February has no 31st
46
+ */
47
+ export declare function compileDateParser(pattern: string, names?: import('./format.js').DateNames): (text: string) => object | null;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Choose the calendar step closest to covering `span` in `count` ticks.
3
+ *
4
+ * A year is the coarsest unit a calendar has, so above one year the
5
+ * ladder continues as whole years on the same 1/2/5×10^k steps — 2, 5,
6
+ * 10, 50, 1000 years. Without that continuation a millennial domain
7
+ * asks for one tick per year and runs out of axis long before it runs
8
+ * out of domain.
9
+ *
10
+ * @param {number} span - Domain width in milliseconds
11
+ * @param {number} count - Desired tick count
12
+ * @returns {[string, number]} a [unit, amount] pair
13
+ */
14
+ export declare function niceTimeStep(span: number, count: number): [string, number];
15
+ /**
16
+ * Time ticks on calendar boundaries: the first tick is the start of a
17
+ * `[unit, amount]` step at or after `min`, and each following one is a
18
+ * whole step later. A multi-unit step lands on a multiple of its own
19
+ * amount — months on 1, 4, 7, 10 and years on 1900, 1950, 2000 — so an
20
+ * axis reads as a calendar rather than as offsets from wherever the
21
+ * data happened to start.
22
+ *
23
+ * @param {number} min - Domain minimum, epoch milliseconds
24
+ * @param {number} max - Domain maximum, epoch milliseconds
25
+ * @param {number} [count] - Desired tick count (approximate)
26
+ * @returns {number[]} tick values in epoch milliseconds
27
+ */
28
+ export declare function axisTicksTime(min: number, max: number, count?: number): number[];
29
+ /**
30
+ * Time ticks on a step the CALLER chose, rather than one this module
31
+ * picked for a target count: the first tick is the start of the
32
+ * `amount`-wide `unit` at or after `min`, and each following one is a
33
+ * whole step later. This is the boundary rule {@link axisTicksTime}
34
+ * uses once it has decided a step, exposed on its own because a
35
+ * consumer whose document declares its own interval — Mermaid's Gantt
36
+ * `tickInterval 1week` is the one in this repo — needs the same
37
+ * boundaries without the ladder choosing for it.
38
+ *
39
+ * A multi-unit step lands on a multiple of its own amount, so months
40
+ * fall on 1, 4, 7, 10 and years on 1900, 1950, 2000. A `week` step
41
+ * starts on Monday unless `weekStart` names another ISO weekday, which
42
+ * is what a document that says "weeks begin on Sunday" means.
43
+ *
44
+ * @param {number} min - Domain minimum, epoch milliseconds
45
+ * @param {number} max - Domain maximum, epoch milliseconds
46
+ * @param {string} unit - a `DATE_UNITS` member (`'day'`, `'week'`, …)
47
+ * @param {number} [amount] - whole steps per tick, at least 1
48
+ * @param {{ weekStart?: number, limit?: number }} [options] -
49
+ * `weekStart` is an ISO weekday, 1 (Monday) to 7 (Sunday);
50
+ * `limit` caps the tick count (default 1000)
51
+ * @returns {number[]} tick values in epoch milliseconds
52
+ * @example
53
+ * timeTicksEvery(Date.UTC(2024, 0, 3), Date.UTC(2024, 0, 20), 'week', 1);
54
+ * // the Mondays of 2024-01-08 and 2024-01-15
55
+ */
56
+ export declare function timeTicksEvery(min: number, max: number, unit: string, amount?: number, options?: {
57
+ weekStart?: number;
58
+ limit?: number;
59
+ }): number[];
@@ -222,6 +222,19 @@ export declare function remap(v: number, smin: number, smax: number, dmin: numbe
222
222
  * @returns {number}
223
223
  */
224
224
  export declare function niceStep(span: number, count?: number): number;
225
+ /**
226
+ * Ticks on the {@link niceStep} ladder inside `[min, max]`: every whole
227
+ * multiple of the step the domain contains, rounded to the step's own
228
+ * precision so an axis never shows `0.30000000000000004`. A degenerate
229
+ * domain yields a single tick, a reversed one reads the same as its
230
+ * forward twin, and a non-finite bound yields nothing.
231
+ *
232
+ * @param {number} min - Domain minimum
233
+ * @param {number} max - Domain maximum
234
+ * @param {number} [count] - Desired tick count (approximate)
235
+ * @returns {number[]}
236
+ */
237
+ export declare function axisTicksLinear(min: number, max: number, count?: number): number[];
225
238
  /**
226
239
  * Clamp a value into the unit interval `[0, 1]` — the fraction every
227
240
  * unit-space geometry stage emits. `NaN` passes through as `NaN` rather
@@ -0,0 +1,88 @@
1
+ export type Sample = import('./normalize.js').Sample;
2
+ export type AsOfMatch = {
3
+ /**
4
+ * - the canonical left sample
5
+ */
6
+ left: any;
7
+ /**
8
+ * - the canonical right sample, or `null`
9
+ */
10
+ right: any | null;
11
+ /**
12
+ * - milliseconds between the two
13
+ * instants, never negative; `null` when there was no match
14
+ */
15
+ distance: number | null;
16
+ };
17
+ /**
18
+ * `asOfJoin`'s closed specification: which way to look, how far, what
19
+ * makes two rows comparable, and where each side keeps its members.
20
+ *
21
+ * This is the one series kernel whose spelling a query DOCUMENT cannot
22
+ * reuse: `left`/`right` are nested selector records, and §8.16 flattens
23
+ * them to `by`, `leftAt` and `rightAt` so the whole spec stays a
24
+ * literal. `@jarenjs/json` therefore keeps its own list, and says so.
25
+ */
26
+ export declare const ASOF_MEMBERS: readonly string[];
27
+ /**
28
+ * Join each left sample to the right sample that was current for it.
29
+ *
30
+ * The result is one record **per left row**, in the left series'
31
+ * normalized order — sorted by instant, with rows sharing an instant in
32
+ * the order they arrived. Both `left` and `right` are canonical samples:
33
+ * shallow copies carrying every member of the source row plus a numeric
34
+ * `at` and `value`, so a consumer reads `match.right.at` rather than
35
+ * parsing a timestamp a second time.
36
+ *
37
+ * | direction | the right row chosen |
38
+ * |---|---|
39
+ * | `backward` | the last one at or before the left instant (default) |
40
+ * | `forward` | the last one at or after it |
41
+ * | `nearest` | whichever is closer; a tie chooses `backward` |
42
+ *
43
+ * At an equal instant the **last** right-side row wins in every
44
+ * direction: duplicates are two readings in the same millisecond, and
45
+ * "as of" means the later one.
46
+ *
47
+ * `tolerance` is the furthest a match may be, in milliseconds or as a
48
+ * fixed duration. Beyond it there is no match — not a distant one.
49
+ *
50
+ * `key` joins within groups: a property name or a function, applied to
51
+ * both sides (or `left.key`/`right.key` when the two sides spell it
52
+ * differently). The right side is partitioned **once**; no left row ever
53
+ * filters it.
54
+ *
55
+ * @param {any[]} left
56
+ * @param {any[]} right
57
+ * @param {Object} [spec]
58
+ * @param {'backward'|'forward'|'nearest'} [spec.direction] default `'backward'`
59
+ * @param {number | string} [spec.tolerance] - the furthest a match may be
60
+ * @param {string | ((item: any, index: number) => any)} [spec.key] - the
61
+ * group both sides join within
62
+ * @param {{ at?: any, value?: any, key?: any }} [spec.left] - where the
63
+ * left side's members live
64
+ * @param {{ at?: any, value?: any, key?: any }} [spec.right] - where the
65
+ * right side's members live
66
+ * @returns {AsOfMatch[]}
67
+ * @throws {TypeError} for an unknown direction, a tolerance that is not
68
+ * a non-negative fixed width, or a row that is not a canonical sample
69
+ * @example
70
+ * asOfJoin(trades, quotes); // the last quote at or before
71
+ * asOfJoin(alarms, shifts, { direction: 'nearest', tolerance: 'PT1H' });
72
+ * asOfJoin(readings, calibrations, { key: 'sensor' }); // per sensor
73
+ */
74
+ export declare function asOfJoin(left: any[], right: any[], spec?: {
75
+ direction?: 'backward' | 'forward' | 'nearest';
76
+ tolerance?: number | string;
77
+ key?: string | ((item: any, index: number) => any);
78
+ left?: {
79
+ at?: any;
80
+ value?: any;
81
+ key?: any;
82
+ };
83
+ right?: {
84
+ at?: any;
85
+ value?: any;
86
+ key?: any;
87
+ };
88
+ }): AsOfMatch[];