pond-ts 0.42.0 → 0.44.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.
package/CHANGELOG.md CHANGED
@@ -8,7 +8,9 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
8
8
  tag, so this file covers them all. Pre-1.0: minor bumps may include new features
9
9
  and type-level changes; patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.42.0...HEAD
11
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.44.0...HEAD
12
+ [0.44.0]: https://github.com/pjm17971/pond-ts/compare/v0.43.0...v0.44.0
13
+ [0.43.0]: https://github.com/pjm17971/pond-ts/compare/v0.42.0...v0.43.0
12
14
  [0.42.0]: https://github.com/pjm17971/pond-ts/compare/v0.41.0...v0.42.0
13
15
  [0.41.0]: https://github.com/pjm17971/pond-ts/compare/v0.40.0...v0.41.0
14
16
  [0.40.0]: https://github.com/pjm17971/pond-ts/compare/v0.39.0...v0.40.0
@@ -40,6 +42,157 @@ and type-level changes; patch bumps are strictly additive.
40
42
 
41
43
  ## [Unreleased]
42
44
 
45
+ ## [0.44.0] — 2026-07-11
46
+
47
+ The **value-axis charts** release: cross-sectional data (a volatility smile keyed
48
+ by strike) becomes a first-class charting surface. `ValueSeries.fromColumns` is
49
+ the direct columnar door; `<ScatterChart>` and `<BoxPlot>` join `<LineChart>` on
50
+ the value axis; `<BoxPlot>` gains range-only (bid→ask) marks, `offset` pairing,
51
+ and `capWidth`; and the region cursor works on value axes and snaps to histogram
52
+ bins.
53
+
54
+ ### Added
55
+
56
+ - **`<BoxPlot>` finished for the value axis + range-only marks** (`@pond-ts/charts`).
57
+ Four coordinated changes, driven by the volatility smile's per-strike bid/ask IV
58
+ segments (`docs/notes/vol-smile-followups-2026-07.md` §1):
59
+ - **Accepts a `ValueSeries`** (`series.byValue('strike')` /
60
+ `ValueSeries.fromColumns`) — boxes on a value axis, the same instanceof branch
61
+ as `<LineChart>` / `<ScatterChart>`. The box **width** now comes from neighbour
62
+ spacing for a **point** key (a `ValueSeries`, or a point-keyed `TimeSeries`) —
63
+ like bars/candles — instead of collapsing to the 1px floor; an interval-keyed
64
+ `TimeSeries` still uses its `[begin, end)`.
65
+ - **Optional `q1`/`median`/`q3`** — omit `q1`+`q3` for a **range-only** box: a
66
+ whisker-only `lower→upper` segment, no body (a bid→ask IV mark honestly named,
67
+ not a candlestick abuse). Omitting exactly one of `q1`/`q3` throws.
68
+ - **`offset` prop** (`<BoxPlot>` and `<ScatterChart>`) — a **pixel** shift for
69
+ pairing same-key marks (call/put at one strike) side by side, zoom-stable. On
70
+ the scatter it moves the draw **and** the click hit-test together; on the box
71
+ the readout hit-tests in un-shifted data space (keep the offset small).
72
+ - **`capWidth` prop** (`<BoxPlot>`) — the whisker end-cap width in **pixels**
73
+ (else half the box width). A small fixed cap keeps two `offset`-paired marks'
74
+ T-bars from overlapping when the value-axis slot is wide; clamped to the box
75
+ width, `'whisker'` shape only.
76
+ - **Readout labels** carry the series' `as` identity (`iv upper`, `iv median`)
77
+ when set, instead of bare column names — the `as ?? column` convention
78
+ Line/Scatter already use.
79
+ - **`<ScatterChart>` accepts a `ValueSeries`** (`@pond-ts/charts`) — scatter
80
+ marks on the value axis, the same instanceof-branched adapter as
81
+ `<LineChart>` (the container infers the x kind from the data). The
82
+ data-driven `radius` / `color` encodings work unchanged on a value axis;
83
+ the per-point `label` reads through a new columnar branch (a `ValueSeries`
84
+ has no per-row events) — IV marks keyed by strike with open-interest
85
+ radius is the driving composition (vol smile). New value-axis Storybook
86
+ fan-out (`ValueAxis` / `ValueAxisEncoded` / `ValueAxisSmile` /
87
+ `ValueAxisFlag`) + Linux visual baselines.
88
+
89
+ - **`ValueSeries.fromColumns({ name, schema, columns, sort? })`** (`pond-ts`) —
90
+ the direct columnar door into value-land, for data that is _natively_
91
+ value-keyed (cross-sectional): an options chain keyed by strike, a spectrum
92
+ keyed by frequency. Exact `TimeSeries.fromColumns` contract with the axis in
93
+ place of time — same polymorphic `number[]` / `Float64Array` inputs, same
94
+ zero-copy adoption, same stable opt-in `sort`, same gap rule — the two doors
95
+ share one ingest engine. Previously cross-sectional callers had to launder
96
+ the axis through a fake `time` column (`TimeSeries.fromColumns` +
97
+ `byValue`); that detour is no longer needed.
98
+
99
+ - **The region cursor snaps to a histogram's bins** (`@pond-ts/charts`). On a
100
+ `<BarChart>` histogram, `cursor="region"` now snaps **bar by bar** with no
101
+ `cursorSequence`: hovering highlights the bar under the pointer, a drag extends
102
+ across whole bars, and `onRegionSelect` reports the selected bin range
103
+ `[lo, hi]` at the bar edges. The bar layer publishes its `[begin, end)` spans
104
+ (a new internal `binIntervals` channel) as the region cursor's snap buckets —
105
+ the same machinery a `cursorSequence` drives on a time axis, so it also covers a
106
+ time-axis histogram. Only a **vertical** bar layer on a continuous (time /
107
+ value) x axis publishes bins; a horizontal chart (value on x) and a categorical
108
+ axis stay freeform / excluded. An explicit `cursorSequence` still takes
109
+ precedence. New `HistogramBins` region-cursor story.
110
+
111
+ ### Changed
112
+
113
+ - **BREAKING (`@pond-ts/charts`): the region cursor works on a value x-axis, and
114
+ `onRegionSelect` reports a neutral `[lo, hi]` pair.** The drag-select callback
115
+ fired a `TimeRange`; it now fires `readonly [number, number]` in **axis units** —
116
+ epoch ms on a time axis, the axis value (strike, distance, …) on a value axis —
117
+ mirroring the container's polymorphic `range` input (which never takes the axis
118
+ _kind_ from its value). A time-axis consumer that wants a `TimeRange` builds one
119
+ from the pair (`new TimeRange({ start: lo, end: hi })`). The cursor itself is
120
+ ungated from time-only to any **continuous** x-axis (time **or** value; a
121
+ **category** axis stays excluded — an ordinal-slot select is a different gesture).
122
+ Bucket **snapping** stays time-only (a `cursorSequence` bucket is a time
123
+ interval), so a value axis is always **freeform** (hover line + raw-span drag).
124
+
125
+ ## [0.43.0] — 2026-07-11
126
+
127
+ The **categorical x-axis** release: a first-class ordinal band scale (ticker /
128
+ account / expiry on x — the transpose view of a time series), plus the charts
129
+ **interaction** wave that landed after v0.42.0 was cut — the region cursor and
130
+ its drag-to-select gesture, per-bin band colour, and annotation edges that snap
131
+ to session boundaries.
132
+
133
+ ### Added
134
+
135
+ - **`@pond-ts/charts`: a first-class categorical x-axis.** `<BarChart
136
+ categories={[{ label, value }]}>` draws one bar per category on an ordinal
137
+ **band scale** (the transpose view's "columns on x" — ticker / account / zone
138
+ on x). The container infers `xKind:'category'` and builds a `scaleBand` over the
139
+ labels; `<CategoryAxis>` ticks once per category. Colour per category via
140
+ `binColors`; selection reports the category name. **Negative** category values
141
+ draw below the baseline (the P&L / delta case) — a single-series category bar
142
+ honours its sign. New exports `scaleBand` / `ScaleBand`, `CategoryAxis`,
143
+ `categoryStack` / `CategoryDatum`. Additive — a new x-kind alongside time /
144
+ value; existing charts are unchanged. First slice of the categorical-axis RFC
145
+ (`docs/rfcs/categorical-axis.md`, Phase 1).
146
+ - **`@pond-ts/charts`: `transposeRow`** — read one **row** of a wide `TimeSeries`
147
+ **across** into `{ label, value }[]` for `<BarChart categories>`: the schema's
148
+ numeric columns (a `pivotByGroup` output's per-group columns, a term
149
+ structure's per-expiry columns) become the categories at one instant. Pick the
150
+ row with `at` (`'last'` — the head/live row — by default; `'first'`, an index,
151
+ or `{ time }`); bound / order the set with `columns`.
152
+ - **`@pond-ts/charts`: stable per-column selection identity.** `SelectInfo` gains
153
+ an optional **`mark`** — a stable per-mark identity within a layer. A categorical
154
+ bar reports its **column name** as `mark`, and a controlled `selected` echo /
155
+ the highlight match key on `(id, mark)`, so a pinned selection survives a column
156
+ reorder (the slot index doesn't; the name does). Additive — `mark` is
157
+ `undefined` for a time / value bar (whose sample `key` is already its identity).
158
+ Plus a category-axis **label policy**: a dense axis thins (keeps every k-th) and
159
+ ellipsis-truncates its labels so they stay legible while every bar draws.
160
+ - **`@pond-ts/charts`: region cursor (`cursor="region"`).** A shaded **band**
161
+ highlights the bucket under the pointer, bucketed by a new **`cursorSequence`**
162
+ prop — a `Sequence` (`Sequence.every('15m')`, `Sequence.calendar('week')`)
163
+ realized over the view, or a `BoundedSequence` (a `TradingCalendar`'s
164
+ `sessionSequence()` / `barSequence()`) used as-is. The band maps through the x
165
+ scale, so on a trading-time axis the closed part of a bucket collapses (crops
166
+ to live sessions). Time-axis only (a no-op on a value axis). (#409, #413)
167
+ - **`@pond-ts/charts`: draggable region cursor → one-shot select.** Opt-in
168
+ **`onRegionSelect?: (range: TimeRange) => void`** makes the region cursor
169
+ draggable: the band extends bucket by bucket and fires **once** on release
170
+ with the selected `[start, end)` `TimeRange` (the cursor doesn't keep it —
171
+ typical use is to zoom the view). With **no `cursorSequence`** it degenerates
172
+ to a hover **line** + **freeform** drag. **`regionSelectModifier="shift"`**
173
+ resolves the gesture conflict with `panZoom` (plain drag pans, shift-drag
174
+ selects); omitted, a region-drag preempts pan. (#416)
175
+ - **`@pond-ts/charts`: `binColors` — per-bin colour for single-series bars.**
176
+ `<BarChart binColors={[...]}>` colours each bar/band segment individually (one
177
+ colour per bin, in order), the single-series analog of the stacked `colors`
178
+ prop — used by the category axis (colour per category) and any single-series
179
+ band chart. (#408)
180
+ - **`@pond-ts/charts`: annotation edges snap to session boundaries.** When a
181
+ `<ChartContainer>` carries a trading calendar (disjoint x axis), dragging a
182
+ `<Region>` edge (or creating one) snaps to the nearest **session boundary**
183
+ rather than raw wall-clock, so a drawn span aligns with real market sessions.
184
+ (#410)
185
+
186
+ ### Fixed
187
+
188
+ - **`@pond-ts/charts`: region body-move no longer distorts across a session
189
+ boundary.** On a trading-time (discontinuous) axis, dragging a `<Region>`
190
+ annotation by its body now translates it rigidly in pixel space, so the box
191
+ keeps its width as it crosses a collapsed gap (it previously applied one
192
+ value-delta to both edges, which stretched the box in the different
193
+ rate-contexts either side of a session boundary). No-op on a continuous axis.
194
+ (#405)
195
+
43
196
  ## [0.42.0] — 2026-07-10
44
197
 
45
198
  The **trading-calendar** release: a new `@pond-ts/financial` package (its first
@@ -82,6 +235,16 @@ collapses closed-market time (weekends, holidays, overnight, lunch breaks).
82
235
  (`theme.axis.sessionDivider`), aligned with the labels.
83
236
  - `Charts/TradingTimeAxis` stories (weekend/holiday/half-day/intraday,
84
237
  continuous-vs-trading, daily-months, proportional-vs-uniform).
238
+ - **`@pond-ts/charts`: first-class histograms.** `<BarChart>` gains **stacking**
239
+ — a group-by dimension → stacked segments, from a wide series (`columns`), a
240
+ `Map<group, TimeSeries>` (the `partitionBy().aggregate().toMap()` shape), or a
241
+ `byColumn` `bins` array; per-group colour via `colors` or theme roles — and an
242
+ **`orientation`** prop (`'vertical'` default | `'horizontal'`, bars grow right
243
+ with the bins on a y band axis). New readers `stacksFromGroups` /
244
+ `stacksFromColumns` / `stacksFromBins` plus `StackedBarSeries` / `BinRecord` /
245
+ `Orientation` types. All data generation composes from existing operators
246
+ (`aggregate` / `byColumn` / `partitionBy`); no core changes. Guide: How-to
247
+ guides → Histograms. (#401)
85
248
  - **`@pond-ts/charts`:** selection now has a stable series identity. `SelectInfo`
86
249
  carries an `id`, and `BarChart` / `ScatterChart` take an optional `id` prop —
87
250
  the series identity used for selection + hover. An `id` **gates interactivity**:
@@ -0,0 +1,33 @@
1
+ import { ColumnarStore, type ColumnSchema, type KeyColumn } from '../../columnar/index.js';
2
+ /** The per-column raw input the `fromColumns` doors accept. */
3
+ export type RawColumns = Record<string, ReadonlyArray<number | null | undefined> | Float64Array>;
4
+ /**
5
+ * The shared columnar-ingress engine behind `TimeSeries.fromColumns` and
6
+ * `ValueSeries.fromColumns`. Both doors are the same machine — normalize the
7
+ * key column (adopt a `Float64Array` zero-copy, convert a `number[]`),
8
+ * optionally sort by key (stable permutation, disables adoption), enforce the
9
+ * non-decreasing-key invariant, pack each `'number'` value column
10
+ * (`null`/`undefined` → `NaN`, one gap rule for both input types) — and differ
11
+ * only in the key column they mint (`TimeKeyColumn` vs `ValueKeyColumn`) and
12
+ * the words their errors use. `op` prefixes every message (so a throw names
13
+ * the door the caller went through) and `keyNoun` names the key in the
14
+ * out-of-order error (`timestamps` / `axis values`).
15
+ *
16
+ * `makeKey` runs **before** the ordering scan, matching the original inline
17
+ * order of checks: a non-finite key fails in the key-column constructor first,
18
+ * ordering second. The per-element loops are the measured hot path
19
+ * (`scripts/perf-from-columns.mjs`) — moved here verbatim, including the
20
+ * manual copy loops (see the inline notes on why not `Float64Array.from`).
21
+ *
22
+ * The caller owns what stays door-specific: the schema[0] kind gate (with its
23
+ * own message) and wrapping the returned store in its series type.
24
+ */
25
+ export declare function ingestColumnsToStore(input: {
26
+ op: string;
27
+ keyNoun: string;
28
+ schema: ColumnSchema;
29
+ columns: RawColumns;
30
+ sort: boolean;
31
+ makeKey: (begin: Float64Array, count: number) => KeyColumn;
32
+ }): ColumnarStore<ColumnSchema>;
33
+ //# sourceMappingURL=ingest-columns.d.ts.map
@@ -0,0 +1,146 @@
1
+ import { ColumnarStore, Float64Column, validityFromPredicate, } from '../../columnar/index.js';
2
+ import { ValidationError } from '../../core/errors.js';
3
+ /**
4
+ * The shared columnar-ingress engine behind `TimeSeries.fromColumns` and
5
+ * `ValueSeries.fromColumns`. Both doors are the same machine — normalize the
6
+ * key column (adopt a `Float64Array` zero-copy, convert a `number[]`),
7
+ * optionally sort by key (stable permutation, disables adoption), enforce the
8
+ * non-decreasing-key invariant, pack each `'number'` value column
9
+ * (`null`/`undefined` → `NaN`, one gap rule for both input types) — and differ
10
+ * only in the key column they mint (`TimeKeyColumn` vs `ValueKeyColumn`) and
11
+ * the words their errors use. `op` prefixes every message (so a throw names
12
+ * the door the caller went through) and `keyNoun` names the key in the
13
+ * out-of-order error (`timestamps` / `axis values`).
14
+ *
15
+ * `makeKey` runs **before** the ordering scan, matching the original inline
16
+ * order of checks: a non-finite key fails in the key-column constructor first,
17
+ * ordering second. The per-element loops are the measured hot path
18
+ * (`scripts/perf-from-columns.mjs`) — moved here verbatim, including the
19
+ * manual copy loops (see the inline notes on why not `Float64Array.from`).
20
+ *
21
+ * The caller owns what stays door-specific: the schema[0] kind gate (with its
22
+ * own message) and wrapping the returned store in its series type.
23
+ */
24
+ export function ingestColumnsToStore(input) {
25
+ const { op, keyNoun, schema, columns, sort, makeKey } = input;
26
+ const keyDef = schema[0];
27
+ if (keyDef === undefined) {
28
+ throw new ValidationError(`${op}: schema must have at least a key column`);
29
+ }
30
+ const keyRaw = columns[keyDef.name];
31
+ if (keyRaw === undefined) {
32
+ throw new ValidationError(`${op}: missing key column '${keyDef.name}'`);
33
+ }
34
+ // Key buffer; the key-column constructor asserts all finite. A manual loop,
35
+ // not `Float64Array.from(arr, mapFn)`: supplying a map function forces V8's
36
+ // generic iterable-protocol path even for a plain array, ~15-20x slower
37
+ // than a preallocated-buffer copy at 100k-element scale — measured, not
38
+ // theoretical (see the pond-columnar-ingest spike's ingest regression).
39
+ let rawBegin;
40
+ if (keyRaw instanceof Float64Array) {
41
+ rawBegin = keyRaw;
42
+ }
43
+ else {
44
+ rawBegin = new Float64Array(keyRaw.length);
45
+ for (let j = 0; j < keyRaw.length; j += 1) {
46
+ const v = keyRaw[j];
47
+ rawBegin[j] = v == null ? NaN : Number(v);
48
+ }
49
+ }
50
+ const count = rawBegin.length;
51
+ // `sort: true` — reorder every column by ascending key before construction.
52
+ // Compute the row permutation once (a stable sort of the index array; V8's
53
+ // Array.sort is stable, so equal keys keep input order, matching fromJSON's
54
+ // stable intake), then remap the key + each value column through it below.
55
+ // `order` stays null on the (default) trusted fast path, so no allocation /
56
+ // copy is paid unless asked. A non-finite key is left for the key column's
57
+ // constructor to reject — sorting can't make it valid.
58
+ let begin;
59
+ let order = null;
60
+ if (sort) {
61
+ const idx = Array.from({ length: count }, (_, i) => i);
62
+ idx.sort((a, b) => rawBegin[a] - rawBegin[b]);
63
+ order = Uint32Array.from(idx);
64
+ begin = new Float64Array(count);
65
+ for (let j = 0; j < count; j += 1)
66
+ begin[j] = rawBegin[order[j]];
67
+ }
68
+ else {
69
+ begin = rawBegin;
70
+ }
71
+ // Throws on any non-finite key value.
72
+ const keys = makeKey(begin, count);
73
+ // Enforce the non-decreasing-key invariant that `fromJSON`'s
74
+ // `validateAndNormalize` guarantees. Trusted construction skips row
75
+ // materialization + kind re-validation, but NOT this correctness contract:
76
+ // bisect-based operators (crop, `atTime`, range queries) rely on it, so an
77
+ // unsorted columnar input must fail loudly here rather than build a silently
78
+ // broken series. One O(N) scan over already-finite values — negligible next
79
+ // to decode. (When `sort` is set the keys are now non-decreasing, so this is
80
+ // a cheap post-condition check rather than a rejection.)
81
+ for (let j = 1; j < count; j += 1) {
82
+ if (begin[j] < begin[j - 1]) {
83
+ throw new ValidationError(`${op}: key column '${keyDef.name}' is out of order at index ${j} ` +
84
+ `(${begin[j]} < ${begin[j - 1]}) — ${keyNoun} must be non-decreasing; ` +
85
+ `pass { sort: true } or pre-sort the columns`);
86
+ }
87
+ }
88
+ // Value columns — packed directly (missing-aware) from the arrays.
89
+ const columnMap = new Map();
90
+ for (let i = 1; i < schema.length; i += 1) {
91
+ const def = schema[i];
92
+ if (def.kind !== 'number') {
93
+ throw new ValidationError(`${op}: v1 supports 'number' value columns; column '${def.name}' is '${def.kind}'`);
94
+ }
95
+ const raw = columns[def.name];
96
+ if (raw === undefined) {
97
+ throw new ValidationError(`${op}: missing column '${def.name}'`);
98
+ }
99
+ if (raw.length !== count) {
100
+ throw new ValidationError(`${op}: column '${def.name}' length ${raw.length} does not match key length ${count}`);
101
+ }
102
+ // Normalize to a Float64Array either way — adopt if already typed (the
103
+ // fast path a protobuf / fixed-point decoder hits, zero-copy), else
104
+ // convert (`null`/`undefined` -> `NaN`) — then apply ONE validity rule
105
+ // to both: a cell is a gap iff it's non-finite. This must be identical
106
+ // regardless of input type: an earlier version used `float64ColumnFromArray`
107
+ // for the `number[]` branch, which treats a `NaN` *value* (as opposed to
108
+ // `null`) as defined-but-non-finite rather than missing, diverging from
109
+ // the `Float64Array` branch's `Number.isFinite` gap signal — the same
110
+ // wire value would silently mean different things depending on which
111
+ // array type decoded it.
112
+ // Manual loop, not `Float64Array.from(arr, mapFn)` — see the key-column
113
+ // comment above; the cost applies identically here.
114
+ let values;
115
+ if (order !== null) {
116
+ // Sorting: reorder into a fresh buffer through the key permutation (no
117
+ // zero-copy adoption — the rows are being moved). Same missing rule
118
+ // (`null`/`undefined` → NaN) applied while remapping.
119
+ values = new Float64Array(count);
120
+ if (raw instanceof Float64Array) {
121
+ for (let j = 0; j < count; j += 1)
122
+ values[j] = raw[order[j]];
123
+ }
124
+ else {
125
+ for (let j = 0; j < count; j += 1) {
126
+ const v = raw[order[j]];
127
+ values[j] = v == null ? NaN : v;
128
+ }
129
+ }
130
+ }
131
+ else if (raw instanceof Float64Array) {
132
+ values = raw;
133
+ }
134
+ else {
135
+ values = new Float64Array(count);
136
+ for (let j = 0; j < count; j += 1) {
137
+ const v = raw[j];
138
+ values[j] = v == null ? NaN : v;
139
+ }
140
+ }
141
+ const validity = validityFromPredicate(count, (j) => Number.isFinite(values[j]));
142
+ columnMap.set(def.name, new Float64Column(values, count, validity, validity === undefined));
143
+ }
144
+ return ColumnarStore.fromTrustedStore(schema, keys, columnMap);
145
+ }
146
+ //# sourceMappingURL=ingest-columns.js.map
@@ -131,9 +131,10 @@ export declare class TimeSeries<S extends SeriesSchema> {
131
131
  * value columns throw for now — extend as consumers need.
132
132
  *
133
133
  * @throws ValidationError on a missing column, a length mismatch, an
134
- * unsupported kind, a non-finite timestamp key, or an out-of-order
135
- * (decreasing) timestamp when `sort` is not set — keys must be non-decreasing,
136
- * same as `fromJSON`.
134
+ * unsupported kind, or an out-of-order (decreasing) timestamp when `sort`
135
+ * is not set — keys must be non-decreasing, same as `fromJSON`. Throws
136
+ * RangeError on a non-finite timestamp key (from the key-column
137
+ * constructor) or a duplicate column name.
137
138
  */
138
139
  static fromColumns<S extends SeriesSchema>(input: {
139
140
  name: string;
@@ -2,6 +2,7 @@ import { isAggregateOutputSpec, normalizeAggregateColumns, tryAggregateColumnarT
2
2
  import { cumulativeOp, } from './operators/cumulative.js';
3
3
  import { scanOp } from './operators/scan.js';
4
4
  import { byValueOp } from './operators/by-value.js';
5
+ import { ingestColumnsToStore } from './operators/ingest-columns.js';
5
6
  import { ValueSeries } from './value-series.js';
6
7
  import { diffRateOp } from './operators/diff-rate.js';
7
8
  import { fillOp } from './operators/fill.js';
@@ -18,7 +19,7 @@ import { TimeRange } from '../core/time-range.js';
18
19
  import { compareEventKeys } from '../core/temporal.js';
19
20
  import { PartitionedTimeSeries } from './partitioned-time-series.js';
20
21
  import { Sequence } from '../sequence/sequence.js';
21
- import { ColumnarStore, Float64Column, IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, float64ColumnFromArray, stringColumnFromArray, validityFromPredicate, withColumnAppended, withColumnsRenamed, withColumnsSelected, withKeyColumn, withRowRange, withRowSelection, } from '../columnar/index.js';
22
+ import { ColumnarStore, IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, float64ColumnFromArray, stringColumnFromArray, withColumnAppended, withColumnsRenamed, withColumnsSelected, withKeyColumn, withRowRange, withRowSelection, } from '../columnar/index.js';
22
23
  import { ValidationError } from '../core/errors.js';
23
24
  import { SeriesStore } from '../live/series-store.js';
24
25
  import { parseDuration } from '../core/duration.js';
@@ -530,9 +531,10 @@ export class TimeSeries {
530
531
  * value columns throw for now — extend as consumers need.
531
532
  *
532
533
  * @throws ValidationError on a missing column, a length mismatch, an
533
- * unsupported kind, a non-finite timestamp key, or an out-of-order
534
- * (decreasing) timestamp when `sort` is not set — keys must be non-decreasing,
535
- * same as `fromJSON`.
534
+ * unsupported kind, or an out-of-order (decreasing) timestamp when `sort`
535
+ * is not set — keys must be non-decreasing, same as `fromJSON`. Throws
536
+ * RangeError on a non-finite timestamp key (from the key-column
537
+ * constructor) or a duplicate column name.
536
538
  */
537
539
  static fromColumns(input) {
538
540
  const { name, schema, columns, sort = false } = input;
@@ -544,121 +546,17 @@ export class TimeSeries {
544
546
  if (keyDef.kind !== 'time') {
545
547
  throw new ValidationError(`fromColumns: v1 supports a 'time' key; schema[0] '${keyDef.name}' is '${keyDef.kind}'`);
546
548
  }
547
- const keyRaw = columns[keyDef.name];
548
- if (keyRaw === undefined) {
549
- throw new ValidationError(`fromColumns: missing key column '${keyDef.name}'`);
550
- }
551
- // epoch-ms buffer; TimeKeyColumn asserts all finite. A manual loop, not
552
- // `Float64Array.from(arr, mapFn)`: supplying a map function forces V8's
553
- // generic iterable-protocol path even for a plain array, ~15-20x slower
554
- // than a preallocated-buffer copy at 100k-element scale — measured, not
555
- // theoretical (see the pond-columnar-ingest spike's ingest regression).
556
- let rawBegin;
557
- if (keyRaw instanceof Float64Array) {
558
- rawBegin = keyRaw;
559
- }
560
- else {
561
- rawBegin = new Float64Array(keyRaw.length);
562
- for (let j = 0; j < keyRaw.length; j += 1) {
563
- const v = keyRaw[j];
564
- rawBegin[j] = v == null ? NaN : Number(v);
565
- }
566
- }
567
- const count = rawBegin.length;
568
- // `sort: true` — reorder every column by ascending key before construction.
569
- // Compute the row permutation once (a stable sort of the index array; V8's
570
- // Array.sort is stable, so equal keys keep input order, matching fromJSON's
571
- // stable intake), then remap the key + each value column through it below.
572
- // `order` stays null on the (default) trusted fast path, so no allocation /
573
- // copy is paid unless asked. A non-finite key is left for `TimeKeyColumn` to
574
- // reject — sorting can't make it valid.
575
- let begin;
576
- let order = null;
577
- if (sort) {
578
- const idx = Array.from({ length: count }, (_, i) => i);
579
- idx.sort((a, b) => rawBegin[a] - rawBegin[b]);
580
- order = Uint32Array.from(idx);
581
- begin = new Float64Array(count);
582
- for (let j = 0; j < count; j += 1)
583
- begin[j] = rawBegin[order[j]];
584
- }
585
- else {
586
- begin = rawBegin;
587
- }
588
- // Throws on any non-finite timestamp.
589
- const keys = new TimeKeyColumn(begin, count);
590
- // Enforce the non-decreasing-key invariant that `fromJSON`'s
591
- // `validateAndNormalize` guarantees. Trusted construction skips row
592
- // materialization + kind re-validation, but NOT this correctness contract:
593
- // bisect-based operators (crop, `atTime`, range queries) rely on it, so an
594
- // unsorted columnar input must fail loudly here rather than build a silently
595
- // broken series. One O(N) scan over already-finite values — negligible next
596
- // to decode. (When `sort` is set the keys are now non-decreasing, so this is
597
- // a cheap post-condition check rather than a rejection.)
598
- for (let j = 1; j < count; j += 1) {
599
- if (begin[j] < begin[j - 1]) {
600
- throw new ValidationError(`fromColumns: key column '${keyDef.name}' is out of order at index ${j} ` +
601
- `(${begin[j]} < ${begin[j - 1]}) — timestamps must be non-decreasing; ` +
602
- `pass { sort: true } or pre-sort the columns`);
603
- }
604
- }
605
- // Value columns — packed directly (missing-aware) from the arrays.
606
- const columnMap = new Map();
607
- for (let i = 1; i < schema.length; i += 1) {
608
- const def = schema[i];
609
- if (def.kind !== 'number') {
610
- throw new ValidationError(`fromColumns: v1 supports 'number' value columns; column '${def.name}' is '${def.kind}'`);
611
- }
612
- const raw = columns[def.name];
613
- if (raw === undefined) {
614
- throw new ValidationError(`fromColumns: missing column '${def.name}'`);
615
- }
616
- if (raw.length !== count) {
617
- throw new ValidationError(`fromColumns: column '${def.name}' length ${raw.length} does not match key length ${count}`);
618
- }
619
- // Normalize to a Float64Array either way — adopt if already typed (the
620
- // fast path a protobuf / fixed-point decoder hits, zero-copy), else
621
- // convert (`null`/`undefined` -> `NaN`) — then apply ONE validity rule
622
- // to both: a cell is a gap iff it's non-finite. This must be identical
623
- // regardless of input type: an earlier version used `float64ColumnFromArray`
624
- // for the `number[]` branch, which treats a `NaN` *value* (as opposed to
625
- // `null`) as defined-but-non-finite rather than missing, diverging from
626
- // the `Float64Array` branch's `Number.isFinite` gap signal — the same
627
- // wire value would silently mean different things depending on which
628
- // array type decoded it.
629
- // Manual loop, not `Float64Array.from(arr, mapFn)` — see the key-column
630
- // comment above; the cost applies identically here.
631
- let values;
632
- if (order !== null) {
633
- // Sorting: reorder into a fresh buffer through the key permutation (no
634
- // zero-copy adoption — the rows are being moved). Same missing rule
635
- // (`null`/`undefined` → NaN) applied while remapping.
636
- values = new Float64Array(count);
637
- if (raw instanceof Float64Array) {
638
- for (let j = 0; j < count; j += 1)
639
- values[j] = raw[order[j]];
640
- }
641
- else {
642
- for (let j = 0; j < count; j += 1) {
643
- const v = raw[order[j]];
644
- values[j] = v == null ? NaN : v;
645
- }
646
- }
647
- }
648
- else if (raw instanceof Float64Array) {
649
- values = raw;
650
- }
651
- else {
652
- values = new Float64Array(count);
653
- for (let j = 0; j < count; j += 1) {
654
- const v = raw[j];
655
- values[j] = v == null ? NaN : v;
656
- }
657
- }
658
- const validity = validityFromPredicate(count, (j) => Number.isFinite(values[j]));
659
- columnMap.set(def.name, new Float64Column(values, count, validity, validity === undefined));
660
- }
661
- const store = ColumnarStore.fromTrustedStore(schema, keys, columnMap);
549
+ // The normalize -> sort -> key -> order-check -> pack pipeline is shared
550
+ // with `ValueSeries.fromColumns` -- one ingest engine, two key kinds (see
551
+ // `operators/ingest-columns.ts`).
552
+ const store = ingestColumnsToStore({
553
+ op: 'fromColumns',
554
+ keyNoun: 'timestamps',
555
+ schema: schema,
556
+ columns,
557
+ sort,
558
+ makeKey: (begin, count) => new TimeKeyColumn(begin, count),
559
+ });
662
560
  return TimeSeries.#fromTrustedStore(name, schema, store);
663
561
  }
664
562
  /**
@@ -3,7 +3,12 @@ import type { ValueSeriesColumnName, ValueSeriesSchema } from '../schema/index.j
3
3
  /**
4
4
  * A **value-keyed series** — the closed value-axis counterpart of
5
5
  * `TimeSeries`. Its key is a monotonic non-time axis (distance, cumulative
6
- * work, …) rather than time, produced by `TimeSeries.byValue(axis)`.
6
+ * work, …). Two doors in: **project** a `TimeSeries` onto one of its monotonic
7
+ * columns (`TimeSeries.byValue(axis)` — a track re-keyed by cumulative
8
+ * distance), or **construct directly** from columnar arrays
9
+ * ({@link ValueSeries.fromColumns}) when the data is natively value-keyed and
10
+ * never had a meaningful time key per row — cross-sectional data such as an
11
+ * options chain keyed by strike or a spectrum keyed by frequency.
7
12
  *
8
13
  * `ValueSeries` carries the **ordering-based** operators (read the axis, read
9
14
  * value columns, nearest-by-value, slice-by-value) — the part of the series
@@ -21,6 +26,57 @@ export declare class ValueSeries<VS extends ValueSeriesSchema> {
21
26
  #private;
22
27
  readonly name: string;
23
28
  readonly schema: VS;
29
+ /**
30
+ * Example: `ValueSeries.fromColumns({ name, schema, columns })`.
31
+ *
32
+ * The **direct columnar door** into value-land — for data that is *natively*
33
+ * value-keyed and never had a meaningful per-row time key: an options chain
34
+ * keyed by strike, a spectrum keyed by frequency, a profile keyed by depth.
35
+ * (Data that starts life time-keyed projects in via `TimeSeries.byValue`
36
+ * instead; before this door existed, cross-sectional callers had to launder
37
+ * their axis through a fake `time` column just to reach
38
+ * `TimeSeries.fromColumns` + `byValue`.)
39
+ *
40
+ * The exact `TimeSeries.fromColumns` contract, with the axis in place of
41
+ * time — the two doors share one ingest engine. `schema[0]` is the
42
+ * `'value'`-kind **axis** column; each `columns` entry is one column's
43
+ * values, keyed by schema column name and aligned by index. Values may be a
44
+ * plain `number[]` **or** a `Float64Array`; a value cell is a gap (missing)
45
+ * iff it's `null`/`undefined` or non-finite — identical rule for both input
46
+ * types.
47
+ *
48
+ * **`Float64Array` inputs are adopted, not copied** (zero-copy): the
49
+ * resulting series' columns alias the caller's buffers; pass a fresh buffer
50
+ * if that matters. (**`sort` disables the adoption** — a reorder needs its
51
+ * own buffers.)
52
+ *
53
+ * **Ordering.** The axis must be **defined, finite, and non-decreasing** —
54
+ * it becomes the index (the same contract `byValue` enforces with
55
+ * `assertMonotonicAxis`), so an out-of-order axis throws by default. Pass
56
+ * **`sort: true`** to sort the rows by axis value before construction — the
57
+ * stable sort every unordered snapshot wants (e.g. a keyed live feed that
58
+ * delivers rows in update order, not axis order).
59
+ *
60
+ * **v1 scope:** `number` value columns, matching `TimeSeries.fromColumns`.
61
+ *
62
+ * @throws ValidationError on a non-`'value'` axis kind, a missing column, a
63
+ * length mismatch, a non-`'number'` value column, or an out-of-order axis
64
+ * when `sort` is not set. Throws RangeError on a non-finite
65
+ * (`null`/`NaN`/`±Infinity`) axis cell — sorting can't make it valid — or
66
+ * a duplicate column name (the axis name repeated among the value columns).
67
+ */
68
+ static fromColumns<VS extends ValueSeriesSchema>(input: {
69
+ name: string;
70
+ schema: VS;
71
+ columns: Record<string, ReadonlyArray<number | null | undefined> | Float64Array>;
72
+ /**
73
+ * Sort the rows by axis value before construction (off by default), for a
74
+ * payload whose rows aren't guaranteed ordered. Stable; disables the
75
+ * `Float64Array` zero-copy adoption (columns are reordered into fresh
76
+ * buffers).
77
+ */
78
+ sort?: boolean;
79
+ }): ValueSeries<VS>;
24
80
  private constructor();
25
81
  /** Number of rows. */
26
82
  get length(): number;
@@ -1,8 +1,15 @@
1
- import { withRowRange, } from '../columnar/index.js';
1
+ import { ValueKeyColumn, withRowRange, } from '../columnar/index.js';
2
+ import { ValidationError } from '../core/errors.js';
3
+ import { ingestColumnsToStore } from './operators/ingest-columns.js';
2
4
  /**
3
5
  * A **value-keyed series** — the closed value-axis counterpart of
4
6
  * `TimeSeries`. Its key is a monotonic non-time axis (distance, cumulative
5
- * work, …) rather than time, produced by `TimeSeries.byValue(axis)`.
7
+ * work, …). Two doors in: **project** a `TimeSeries` onto one of its monotonic
8
+ * columns (`TimeSeries.byValue(axis)` — a track re-keyed by cumulative
9
+ * distance), or **construct directly** from columnar arrays
10
+ * ({@link ValueSeries.fromColumns}) when the data is natively value-keyed and
11
+ * never had a meaningful time key per row — cross-sectional data such as an
12
+ * options chain keyed by strike or a spectrum keyed by frequency.
6
13
  *
7
14
  * `ValueSeries` carries the **ordering-based** operators (read the axis, read
8
15
  * value columns, nearest-by-value, slice-by-value) — the part of the series
@@ -23,11 +30,70 @@ export class ValueSeries {
23
30
  /**
24
31
  * @internal Trusted construction — `store` must be value-keyed and structurally
25
32
  * match `schema` (the invariant `TimeSeries.byValue` / `byValueOp` establish).
26
- * Not for general use; construct a `ValueSeries` via `TimeSeries.byValue`.
33
+ * Not for general use; construct a `ValueSeries` via `TimeSeries.byValue` or
34
+ * {@link ValueSeries.fromColumns}.
27
35
  */
28
36
  static fromTrustedStore(name, schema, store) {
29
37
  return new ValueSeries(name, schema, store);
30
38
  }
39
+ /**
40
+ * Example: `ValueSeries.fromColumns({ name, schema, columns })`.
41
+ *
42
+ * The **direct columnar door** into value-land — for data that is *natively*
43
+ * value-keyed and never had a meaningful per-row time key: an options chain
44
+ * keyed by strike, a spectrum keyed by frequency, a profile keyed by depth.
45
+ * (Data that starts life time-keyed projects in via `TimeSeries.byValue`
46
+ * instead; before this door existed, cross-sectional callers had to launder
47
+ * their axis through a fake `time` column just to reach
48
+ * `TimeSeries.fromColumns` + `byValue`.)
49
+ *
50
+ * The exact `TimeSeries.fromColumns` contract, with the axis in place of
51
+ * time — the two doors share one ingest engine. `schema[0]` is the
52
+ * `'value'`-kind **axis** column; each `columns` entry is one column's
53
+ * values, keyed by schema column name and aligned by index. Values may be a
54
+ * plain `number[]` **or** a `Float64Array`; a value cell is a gap (missing)
55
+ * iff it's `null`/`undefined` or non-finite — identical rule for both input
56
+ * types.
57
+ *
58
+ * **`Float64Array` inputs are adopted, not copied** (zero-copy): the
59
+ * resulting series' columns alias the caller's buffers; pass a fresh buffer
60
+ * if that matters. (**`sort` disables the adoption** — a reorder needs its
61
+ * own buffers.)
62
+ *
63
+ * **Ordering.** The axis must be **defined, finite, and non-decreasing** —
64
+ * it becomes the index (the same contract `byValue` enforces with
65
+ * `assertMonotonicAxis`), so an out-of-order axis throws by default. Pass
66
+ * **`sort: true`** to sort the rows by axis value before construction — the
67
+ * stable sort every unordered snapshot wants (e.g. a keyed live feed that
68
+ * delivers rows in update order, not axis order).
69
+ *
70
+ * **v1 scope:** `number` value columns, matching `TimeSeries.fromColumns`.
71
+ *
72
+ * @throws ValidationError on a non-`'value'` axis kind, a missing column, a
73
+ * length mismatch, a non-`'number'` value column, or an out-of-order axis
74
+ * when `sort` is not set. Throws RangeError on a non-finite
75
+ * (`null`/`NaN`/`±Infinity`) axis cell — sorting can't make it valid — or
76
+ * a duplicate column name (the axis name repeated among the value columns).
77
+ */
78
+ static fromColumns(input) {
79
+ const { name, schema, columns, sort = false } = input;
80
+ const keyDef = schema[0];
81
+ if (keyDef === undefined) {
82
+ throw new ValidationError('ValueSeries.fromColumns: schema must have at least an axis column');
83
+ }
84
+ if (keyDef.kind !== 'value') {
85
+ throw new ValidationError(`ValueSeries.fromColumns: schema[0] '${keyDef.name}' must be the 'value'-kind axis column; got '${keyDef.kind}'`);
86
+ }
87
+ const store = ingestColumnsToStore({
88
+ op: 'ValueSeries.fromColumns',
89
+ keyNoun: 'axis values',
90
+ schema: schema,
91
+ columns,
92
+ sort,
93
+ makeKey: (begin, count) => new ValueKeyColumn(begin, count),
94
+ });
95
+ return new ValueSeries(name, schema, store);
96
+ }
31
97
  constructor(name, schema, store) {
32
98
  this.name = name;
33
99
  this.schema = Object.freeze(schema.slice());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pond-ts",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "description": "TypeScript-first time series primitives",
5
5
  "license": "MIT",
6
6
  "repository": {