pond-ts 0.29.0 → 0.31.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.
@@ -1,5 +1,7 @@
1
1
  import type { AlignSchema, MaterializeSchema, ArrayAggregateAppendSchema, ArrayAggregateReplaceSchema, ArrayColumnNameForSchema, ArrayExplodeAppendSchema, ArrayExplodeReplaceSchema, BaselineSchema, AggregateReducer, AggregateSchema, AppendColumn, CollapseSchema, EventDataForSchema, EventForSchema, FirstColKind, IntervalKeyedSchema, JsonRowFormat, JoinManySchema, JoinSchema, JoinType, NumericColumnNameForSchema, NormalizedObjectRow, NormalizedRowForSchema, PivotByGroupSchema, PointRowForSchema, PrefixedJoinManySchema, PrefixedJoinSchema, ReduceResult, RenameMap, ValidatedAggregateMap } from '../schema/index.js';
2
- import type { RenameSchema, RollingAlignment, RollingSchema, ColumnValue, DedupeKeep, DiffSchema, FillMapping, FillStrategy, ScalarKind, ScalarValue, SmoothMethod, SmoothAppendSchema, SmoothSchema, SelectSchema, SeriesSchema, TimeKeyedSchema, TimeSeriesJsonInput, TimeSeriesInput, TimeRangeKeyedSchema, ValueColumnKindForName, ValueColumnNameForSchema, ValueColumnsForSchema } from '../schema/index.js';
2
+ import type { RenameSchema, RollingAlignment, RollingSchema, ColumnValue, DedupeKeep, DiffSchema, FillMapping, FillStrategy, ScalarKind, ScalarValue, SmoothMethod, SmoothAppendSchema, SmoothSchema, SelectSchema, SeriesSchema, TimeKeyedSchema, TimeSeriesJsonInput, TimeSeriesInput, TimeRangeKeyedSchema, ValueColumnKindForName, ValueColumnNameForSchema, ValueColumnsForSchema, ValueKeyedSchema } from '../schema/index.js';
3
+ import { type ScanStep } from './operators/scan.js';
4
+ import { ValueSeries } from './value-series.js';
3
5
  import { type BinSpec } from './by-column.js';
4
6
  import { type WindowSpec } from './rolling-by-column.js';
5
7
  import { BoundedSequence } from '../sequence/bounded-sequence.js';
@@ -521,6 +523,29 @@ export declare class TimeSeries<S extends SeriesSchema> {
521
523
  */
522
524
  reduce(column: ValueColumnsForSchema<S>[number]['name'], reducer: AggregateReducer): ColumnValue | undefined;
523
525
  reduce<const Mapping extends ValidatedAggregateMap<S, Mapping>>(mapping: Mapping): ReduceResult<S, Mapping>;
526
+ /**
527
+ * Example: `track.byValue('cumDist')`.
528
+ *
529
+ * **The raw `TimeSeries → ValueSeries` projection** (RFC `value-axis.md` §6):
530
+ * re-key the series onto a monotonic numeric **value axis** (distance,
531
+ * cumulative work, …), returning a {@link ValueSeries} that carries the
532
+ * ordering-based operators (axis read, nearest-by-value, slice-by-value) over
533
+ * that axis instead of time.
534
+ *
535
+ * `axis` must be **defined, finite, and non-decreasing at every row** — it
536
+ * becomes the index, so (unlike a value column) it cannot have gaps; an
537
+ * `assertMonotonicAxis` check throws otherwise. This monotonicity contract
538
+ * lives on the *projection*, not on {@link TimeSeries.byColumn} (whose
539
+ * order-free binning has no such precondition). The re-key is a no-op reindex
540
+ * (the rows already sit in axis order) and the axis column is **dropped from
541
+ * the value columns** (it is now the key); the other columns are shared
542
+ * zero-copy.
543
+ *
544
+ * `byValue` is the projection; `byColumn` is value-axis *aggregation* — pair
545
+ * them via {@link TimeSeries.scan} for stateful splits
546
+ * (`split = scan + byColumn`).
547
+ */
548
+ byValue<const Axis extends NumericColumnNameForSchema<S>>(axis: Axis): Axis extends Axis ? ValueSeries<ValueKeyedSchema<S, Axis>> : never;
524
549
  /**
525
550
  * Example:
526
551
  * `series.byColumn('cumDist', { width: 1000 }, { gain: { from: 'ele', using: 'sum' } })`.
@@ -541,9 +566,11 @@ export declare class TimeSeries<S extends SeriesSchema> {
541
566
  * yields a histogram (distribution).
542
567
  * - `{ edges, inclusive? }` — explicit ascending edges `[e₀ … eₙ]` → `n` bins.
543
568
  * `inclusive` defaults to `'[)'` (bin `i` = `[eᵢ, eᵢ₊₁)`, lower-inclusive);
544
- * pass `'(]'` for upper-inclusive bins (`(eᵢ, eᵢ₊₁]`) — Coggan power / HR
545
- * zones, where a sample exactly on a zone's top edge belongs to the lower
546
- * zone (the first edge becomes an exclusive floor). Always emits all `n` bins.
569
+ * pass `'(]'` for upper-inclusive interior bins (`(eᵢ, eᵢ₊₁]`) — Coggan power
570
+ * / HR zones, where a sample exactly on a zone's top edge belongs to the
571
+ * lower zone. The floor `e₀` stays **inclusive** (bin 0 is `[e₀, e₁]`), so a
572
+ * minimum-edge value (e.g. a `0` W coast sample) lands in bin 0 rather than
573
+ * being dropped. Always emits all `n` bins.
547
574
  *
548
575
  * A row whose bin value is missing / non-finite (or, for `edges`, outside
549
576
  * `[e₀, eₙ)`) contributes to no bin. The reducer non-finite policy still
@@ -577,7 +604,12 @@ export declare class TimeSeries<S extends SeriesSchema> {
577
604
  * reducer non-finite policy still applies to the *source* columns.
578
605
  *
579
606
  * The window is centered and inclusive (`col[i] − radius ≤ col[j] ≤ col[i] +
580
- * radius`); a single O(n) two-pointer sweep maintains the window. See
607
+ * radius`); a single O(n) two-pointer sweep maintains the window.
608
+ *
609
+ * Pass `{ radius, at }` — a **non-decreasing** array of explicit center values
610
+ * (e.g. a chart's coarse display grid) — to evaluate at those centers instead
611
+ * of at every row, returning **one record per center** (a center with no rows
612
+ * in range yields each reducer's empty value). See
581
613
  * `docs/notes/rolling-by-column.md`.
582
614
  */
583
615
  rollingByColumn<const Mapping extends ValidatedAggregateMap<S, Mapping>>(col: NumericColumnNameForSchema<S>, spec: WindowSpec, mapping: Mapping): Array<ReduceResult<S, Mapping>>;
@@ -785,6 +817,56 @@ export declare class TimeSeries<S extends SeriesSchema> {
785
817
  cumulative<const Targets extends NumericColumnNameForSchema<S>>(spec: {
786
818
  [K in Targets]: 'sum' | 'max' | 'min' | 'count' | ((acc: number, value: number) => number);
787
819
  }): TimeSeries<DiffSchema<S, Targets>>;
820
+ /**
821
+ * Example:
822
+ * ```ts
823
+ * // running sum — the cumulative special case, replacing in place:
824
+ * series.scan('work', (acc, v) => [acc + v, acc + v], 0);
825
+ *
826
+ * // typed accumulator into a NEW column (hysteresis elevation gain):
827
+ * track.scan<'cumGain', { ref: number | null; gain: number }>(
828
+ * 'ele',
829
+ * (acc, ele) => {
830
+ * if (acc.ref === null) return [{ ref: ele, gain: 0 }, 0];
831
+ * const d = ele - acc.ref;
832
+ * if (d >= 3) return [{ ref: ele, gain: acc.gain + d }, acc.gain + d];
833
+ * if (d <= -3) return [{ ref: ele, gain: acc.gain }, acc.gain];
834
+ * return [acc, acc.gain]; // within deadband — carry
835
+ * },
836
+ * { ref: null, gain: 0 },
837
+ * { output: 'cumGain' },
838
+ * );
839
+ * ```
840
+ *
841
+ * **Typed-accumulator running fold** (the classic `mapAccumL`) over a numeric
842
+ * column. `step(acc, value, i)` returns `[nextAcc, output]`; the accumulator
843
+ * `A` (inferred from `init`) is **decoupled** from the numeric `output` — the
844
+ * generalization {@link TimeSeries.cumulative} can't express, since there the
845
+ * accumulator *is* the output *is* a `number`. `cumulative` is the scalar
846
+ * special case: `series.cumulative({ x: 'sum' })` is
847
+ * `series.scan('x', (a, v) => [a + v, a + v], 0)`.
848
+ *
849
+ * With no `options.output` the source column is **replaced** in place (widened
850
+ * to optional `number`, as `cumulative` does). With `options.output` a **new**
851
+ * column of that name is appended and the source is left intact (as
852
+ * {@link TimeSeries.withColumn} does); the name must not already exist.
853
+ *
854
+ * **Missing cells carry:** a missing / undefined source cell does not call
855
+ * `step` — the accumulator is held and the row re-emits the last output (so it
856
+ * holds flat across a gap), `undefined` only until the first defined value. A
857
+ * stored `NaN` is a defined number and is passed to `step`; the step author
858
+ * owns output finiteness (this is the trusted-compute path, not the validated
859
+ * `withColumn` intake).
860
+ *
861
+ * **Multi-entity series:** the accumulator threads across all rows in storage
862
+ * order, so it interleaves across entities — `host-A`'s next row folds on top
863
+ * of `host-B`'s. Use `series.partitionBy(col).scan(...).collect()` to scope
864
+ * per entity. See {@link TimeSeries.partitionBy}.
865
+ */
866
+ scan<const Source extends NumericColumnNameForSchema<S>, A>(source: Source, step: ScanStep<A>, init: A): TimeSeries<DiffSchema<S, Source>>;
867
+ scan<const Source extends NumericColumnNameForSchema<S>, const Name extends string, A>(source: Source, step: ScanStep<A>, init: A, options: {
868
+ output: Name;
869
+ }): TimeSeries<AppendColumn<S, Name, 'number'>>;
788
870
  /**
789
871
  * Example: `series.shift("value", 1)`.
790
872
  * Lags column values by N events (positive N) or leads them (negative N).
@@ -998,6 +1080,17 @@ export declare class TimeSeries<S extends SeriesSchema> {
998
1080
  * When `output` is omitted, the smoothed values replace the target column. When `output` is
999
1081
  * supplied, the smoothed values are appended as a new optional numeric column.
1000
1082
  *
1083
+ * **Missing cells** (`movingAverage` / `loess`): by default (`missing:
1084
+ * 'bridge'`) a cell whose own value is missing is still assigned a smoothed
1085
+ * value computed from its present neighbours — i.e. the line is drawn *across*
1086
+ * the hole. Pass `missing: 'skip'` to keep a missing cell **missing** in the
1087
+ * output, so a sustained dropout (a coast, a sensor gap) is preserved as a
1088
+ * break rather than fabricated through. Present cells already smooth over only
1089
+ * the present values in their window either way. `ema` is causal and never
1090
+ * fabricates across a gap, so it takes no `missing` option. (estela
1091
+ * F-smooth-interactive; a `maxGap` hard segment boundary is a deferred
1092
+ * follow-on.)
1093
+ *
1001
1094
  * **Multi-entity series:** the smoothing window pulls values from
1002
1095
  * every entity into each smoothed point — `host-A`'s smoothed value
1003
1096
  * is blended with `host-B`'s and `host-C`'s. On a series carrying
@@ -1013,9 +1106,11 @@ export declare class TimeSeries<S extends SeriesSchema> {
1013
1106
  window: DurationInput;
1014
1107
  alignment?: RollingAlignment;
1015
1108
  output?: Output;
1109
+ missing?: 'skip' | 'bridge';
1016
1110
  } | {
1017
1111
  span: number;
1018
1112
  output?: Output;
1113
+ missing?: 'skip' | 'bridge';
1019
1114
  }): TimeSeries<Output extends string ? SmoothAppendSchema<S, Output> : SmoothSchema<S, Target>>;
1020
1115
  /** Example: `series.slice(0, 10)`. Returns a positional half-open slice of the series. */
1021
1116
  slice(beginIndex?: number, endIndex?: number): TimeSeries<S>;
@@ -1069,6 +1164,20 @@ export declare class TimeSeries<S extends SeriesSchema> {
1069
1164
  atOrBefore(key: KeyLike): EventForSchema<S> | undefined;
1070
1165
  /** Example: `series.atOrAfter(new Time(Date.now()))`. Returns the event with the exact key or the nearest later event, if any. */
1071
1166
  atOrAfter(key: KeyLike): EventForSchema<S> | undefined;
1167
+ /**
1168
+ * Example: `series.nearest(new Time(Date.now()))`. Returns the event whose key
1169
+ * is **closest** to `key` by `begin()` distance, or `undefined` only when the
1170
+ * series is empty.
1171
+ *
1172
+ * Where `atOrBefore` / `atOrAfter` bound to one side, this rounds to the nearer
1173
+ * neighbour (ties go to the earlier event). A key outside the series resolves
1174
+ * to the first or last event — the nearest that exists — so callers that want
1175
+ * "no match past the data" should range-check against {@link timeRange}.
1176
+ *
1177
+ * O(log N) via `bisect`; the columnar key buffer is probed by `begin()`, with
1178
+ * no Event allocation beyond the single result.
1179
+ */
1180
+ nearest(key: KeyLike): EventForSchema<S> | undefined;
1072
1181
  /** Example: `series.timeRange()`. Returns the overall temporal extent of the series, if the series is not empty. */
1073
1182
  timeRange(): TimeRange | undefined;
1074
1183
  /** Example: `series.overlaps(range)`. Returns `true` when the overall series extent overlaps the supplied temporal value. */
@@ -1,5 +1,8 @@
1
1
  import { isAggregateOutputSpec, normalizeAggregateColumns, tryAggregateColumnarTimeKeyed, } from './aggregate-columns.js';
2
2
  import { cumulativeOp, } from './operators/cumulative.js';
3
+ import { scanOp } from './operators/scan.js';
4
+ import { byValueOp } from './operators/by-value.js';
5
+ import { ValueSeries } from './value-series.js';
3
6
  import { diffRateOp } from './operators/diff-rate.js';
4
7
  import { fillOp } from './operators/fill.js';
5
8
  import { mapOp } from './operators/map.js';
@@ -1377,6 +1380,39 @@ export class TimeSeries {
1377
1380
  }
1378
1381
  return result;
1379
1382
  }
1383
+ /**
1384
+ * Example: `track.byValue('cumDist')`.
1385
+ *
1386
+ * **The raw `TimeSeries → ValueSeries` projection** (RFC `value-axis.md` §6):
1387
+ * re-key the series onto a monotonic numeric **value axis** (distance,
1388
+ * cumulative work, …), returning a {@link ValueSeries} that carries the
1389
+ * ordering-based operators (axis read, nearest-by-value, slice-by-value) over
1390
+ * that axis instead of time.
1391
+ *
1392
+ * `axis` must be **defined, finite, and non-decreasing at every row** — it
1393
+ * becomes the index, so (unlike a value column) it cannot have gaps; an
1394
+ * `assertMonotonicAxis` check throws otherwise. This monotonicity contract
1395
+ * lives on the *projection*, not on {@link TimeSeries.byColumn} (whose
1396
+ * order-free binning has no such precondition). The re-key is a no-op reindex
1397
+ * (the rows already sit in axis order) and the axis column is **dropped from
1398
+ * the value columns** (it is now the key); the other columns are shared
1399
+ * zero-copy.
1400
+ *
1401
+ * `byValue` is the projection; `byColumn` is value-axis *aggregation* — pair
1402
+ * them via {@link TimeSeries.scan} for stateful splits
1403
+ * (`split = scan + byColumn`).
1404
+ */
1405
+ byValue(axis) {
1406
+ // The return type is **distributive** over `Axis` (`Axis extends Axis ?`):
1407
+ // for a literal axis it is just `ValueSeries<ValueKeyedSchema<S, Axis>>`,
1408
+ // but for a union axis (e.g. a generic wrapper's `'cumDist' | 'hr'`) it
1409
+ // becomes the discriminated union `ValueSeries<…cumDist> | ValueSeries<…hr>`
1410
+ // — each branch drops only its own axis, so narrowing on `axisName` recovers
1411
+ // the right `column()` names. Without distribution, `ValueKeyedSchema` would
1412
+ // drop *every* union member from the value columns.
1413
+ const { store, schema } = byValueOp(this.#store.store, this.schema, axis);
1414
+ return ValueSeries.fromTrustedStore(this.name, schema, store);
1415
+ }
1380
1416
  /**
1381
1417
  * Example:
1382
1418
  * `series.byColumn('cumDist', { width: 1000 }, { gain: { from: 'ele', using: 'sum' } })`.
@@ -1397,9 +1433,11 @@ export class TimeSeries {
1397
1433
  * yields a histogram (distribution).
1398
1434
  * - `{ edges, inclusive? }` — explicit ascending edges `[e₀ … eₙ]` → `n` bins.
1399
1435
  * `inclusive` defaults to `'[)'` (bin `i` = `[eᵢ, eᵢ₊₁)`, lower-inclusive);
1400
- * pass `'(]'` for upper-inclusive bins (`(eᵢ, eᵢ₊₁]`) — Coggan power / HR
1401
- * zones, where a sample exactly on a zone's top edge belongs to the lower
1402
- * zone (the first edge becomes an exclusive floor). Always emits all `n` bins.
1436
+ * pass `'(]'` for upper-inclusive interior bins (`(eᵢ, eᵢ₊₁]`) — Coggan power
1437
+ * / HR zones, where a sample exactly on a zone's top edge belongs to the
1438
+ * lower zone. The floor `e₀` stays **inclusive** (bin 0 is `[e₀, e₁]`), so a
1439
+ * minimum-edge value (e.g. a `0` W coast sample) lands in bin 0 rather than
1440
+ * being dropped. Always emits all `n` bins.
1403
1441
  *
1404
1442
  * A row whose bin value is missing / non-finite (or, for `edges`, outside
1405
1443
  * `[e₀, eₙ)`) contributes to no bin. The reducer non-finite policy still
@@ -1433,7 +1471,12 @@ export class TimeSeries {
1433
1471
  * reducer non-finite policy still applies to the *source* columns.
1434
1472
  *
1435
1473
  * The window is centered and inclusive (`col[i] − radius ≤ col[j] ≤ col[i] +
1436
- * radius`); a single O(n) two-pointer sweep maintains the window. See
1474
+ * radius`); a single O(n) two-pointer sweep maintains the window.
1475
+ *
1476
+ * Pass `{ radius, at }` — a **non-decreasing** array of explicit center values
1477
+ * (e.g. a chart's coarse display grid) — to evaluate at those centers instead
1478
+ * of at every row, returning **one record per center** (a center with no rows
1479
+ * in range yields each reducer's empty value). See
1437
1480
  * `docs/notes/rolling-by-column.md`.
1438
1481
  */
1439
1482
  rollingByColumn(col, spec, mapping) {
@@ -1673,6 +1716,13 @@ export class TimeSeries {
1673
1716
  const { store, schema } = cumulativeOp(this.#store.store, this.schema, spec);
1674
1717
  return TimeSeries.#fromTrustedStore(this.name, schema, store);
1675
1718
  }
1719
+ scan(source, step, init, options) {
1720
+ // Column-native: the typed-accumulator fold runs straight off the store's
1721
+ // source column in the extracted `scanOp` — no `this.events`
1722
+ // materialization. The method is a thin delegate.
1723
+ const { store, schema } = scanOp(this.#store.store, this.schema, source, step, init, options?.output);
1724
+ return TimeSeries.#fromTrustedStore(this.name, schema, store);
1725
+ }
1676
1726
  /**
1677
1727
  * Example: `series.shift("value", 1)`.
1678
1728
  * Lags column values by N events (positive N) or leads them (negative N).
@@ -2211,6 +2261,17 @@ export class TimeSeries {
2211
2261
  * When `output` is omitted, the smoothed values replace the target column. When `output` is
2212
2262
  * supplied, the smoothed values are appended as a new optional numeric column.
2213
2263
  *
2264
+ * **Missing cells** (`movingAverage` / `loess`): by default (`missing:
2265
+ * 'bridge'`) a cell whose own value is missing is still assigned a smoothed
2266
+ * value computed from its present neighbours — i.e. the line is drawn *across*
2267
+ * the hole. Pass `missing: 'skip'` to keep a missing cell **missing** in the
2268
+ * output, so a sustained dropout (a coast, a sensor gap) is preserved as a
2269
+ * break rather than fabricated through. Present cells already smooth over only
2270
+ * the present values in their window either way. `ema` is causal and never
2271
+ * fabricates across a gap, so it takes no `missing` option. (estela
2272
+ * F-smooth-interactive; a `maxGap` hard segment boundary is a deferred
2273
+ * follow-on.)
2274
+ *
2214
2275
  * **Multi-entity series:** the smoothing window pulls values from
2215
2276
  * every entity into each smoothed point — `host-A`'s smoothed value
2216
2277
  * is blended with `host-B`'s and `host-C`'s. On a series carrying
@@ -2297,8 +2358,14 @@ export class TimeSeries {
2297
2358
  loessValues.push(value);
2298
2359
  }
2299
2360
  }
2361
+ // `missing: 'skip'` — a cell whose own value is missing stays missing
2362
+ // (don't fit a fabricated value across the hole from its present
2363
+ // neighbours). Default `'bridge'` is the prior behaviour (fit everywhere).
2364
+ const skipMissing = options.missing === 'skip';
2300
2365
  const resultRows = this.events.map((event, index) => {
2301
- const smoothed = loessAt(anchors[index], loessAnchors, loessValues, span);
2366
+ const smoothed = skipMissing && sourceValues[index] === undefined
2367
+ ? undefined
2368
+ : loessAt(anchors[index], loessAnchors, loessValues, span);
2302
2369
  const nextEvent = output === undefined
2303
2370
  ? event.set(column, smoothed)
2304
2371
  : event.merge({ [output]: smoothed });
@@ -2321,6 +2388,7 @@ export class TimeSeries {
2321
2388
  const window = options.window;
2322
2389
  const windowMs = parseDuration(window);
2323
2390
  const alignment = options.alignment ?? 'trailing';
2391
+ const skipMissing = options.missing === 'skip';
2324
2392
  const resultValues = new Array(this.events.length);
2325
2393
  let windowStart = 0;
2326
2394
  let windowEnd = 0;
@@ -2387,7 +2455,12 @@ export class TimeSeries {
2387
2455
  }
2388
2456
  const smoothed = snapshot();
2389
2457
  for (let index = groupStart; index < groupEnd; index++) {
2390
- resultValues[index] = smoothed;
2458
+ // `missing: 'skip'` — a cell whose own value is missing stays missing
2459
+ // (don't fabricate it from the window average). Default `'bridge'` fills.
2460
+ resultValues[index] =
2461
+ skipMissing && sourceValues[index] === undefined
2462
+ ? undefined
2463
+ : smoothed;
2391
2464
  }
2392
2465
  groupStart = groupEnd;
2393
2466
  }
@@ -2573,6 +2646,34 @@ export class TimeSeries {
2573
2646
  atOrAfter(key) {
2574
2647
  return this.at(this.bisect(key));
2575
2648
  }
2649
+ /**
2650
+ * Example: `series.nearest(new Time(Date.now()))`. Returns the event whose key
2651
+ * is **closest** to `key` by `begin()` distance, or `undefined` only when the
2652
+ * series is empty.
2653
+ *
2654
+ * Where `atOrBefore` / `atOrAfter` bound to one side, this rounds to the nearer
2655
+ * neighbour (ties go to the earlier event). A key outside the series resolves
2656
+ * to the first or last event — the nearest that exists — so callers that want
2657
+ * "no match past the data" should range-check against {@link timeRange}.
2658
+ *
2659
+ * O(log N) via `bisect`; the columnar key buffer is probed by `begin()`, with
2660
+ * no Event allocation beyond the single result.
2661
+ */
2662
+ nearest(key) {
2663
+ const n = this.#store.length;
2664
+ if (n === 0)
2665
+ return undefined;
2666
+ const normalizedKey = toKey(key);
2667
+ const index = this.bisect(normalizedKey); // first index with keyAt(i) >= key
2668
+ if (index <= 0)
2669
+ return this.at(0);
2670
+ if (index >= n)
2671
+ return this.at(n - 1);
2672
+ const target = normalizedKey.begin();
2673
+ const before = this.#store.keyAt(index - 1).begin();
2674
+ const after = this.#store.keyAt(index).begin();
2675
+ return this.at(target - before <= after - target ? index - 1 : index);
2676
+ }
2576
2677
  /** Example: `series.timeRange()`. Returns the overall temporal extent of the series, if the series is not empty. */
2577
2678
  timeRange() {
2578
2679
  // Columnar key-axis read. The old implementation reduced over
@@ -0,0 +1,53 @@
1
+ import { type Column as ColumnarColumn } from '../columnar/index.js';
2
+ import type { ValueSeriesColumnName, ValueSeriesSchema } from '../schema/index.js';
3
+ /**
4
+ * A **value-keyed series** — the closed value-axis counterpart of
5
+ * `TimeSeries`. Its key is a monotonic non-time axis (distance, cumulative
6
+ * work, …) rather than time, produced by `TimeSeries.byValue(axis)`.
7
+ *
8
+ * `ValueSeries` carries the **ordering-based** operators (read the axis, read
9
+ * value columns, nearest-by-value, slice-by-value) — the part of the series
10
+ * algebra that was never really about time (RFC `value-axis.md` §5). The
11
+ * calendar/clock operators (`Sequence.every`, tz formatting) are deliberately
12
+ * absent: a value axis has no wall-clock semantics, and the disjoint
13
+ * `ValueSeriesSchema` makes them type-impossible here.
14
+ *
15
+ * Minimal by design (RFC §7: adopt the type early, grow the algebra as a second
16
+ * value-axis consumer earns it). Wraps the columnar store directly — a value
17
+ * row is an `(axis, …values)` tuple, not a `Time`-keyed `Event`, so it does not
18
+ * go through the time-only `SeriesStore` / EventKey layer.
19
+ */
20
+ export declare class ValueSeries<VS extends ValueSeriesSchema> {
21
+ #private;
22
+ readonly name: string;
23
+ readonly schema: VS;
24
+ private constructor();
25
+ /** Number of rows. */
26
+ get length(): number;
27
+ /** The axis (key) column's name — e.g. `'cumDist'`. */
28
+ get axisName(): VS[0]['name'];
29
+ /**
30
+ * The axis values (the x of every row), in axis order. **Zero-copy** — the
31
+ * returned `Float64Array` is the live key buffer; treat it as read-only.
32
+ */
33
+ axisValues(): Float64Array;
34
+ /** The axis value at row `i`. Throws if out of range. */
35
+ axisAt(i: number): number;
36
+ /** A value column by name, for direct columnar reads (`.read(i)`, `.values()`). */
37
+ column(name: ValueSeriesColumnName<VS>): ColumnarColumn | undefined;
38
+ /**
39
+ * Index of the row whose axis value is **closest** to `value` — the
40
+ * value-axis cursor primitive. The axis is non-decreasing, so this is a
41
+ * binary search. Returns `-1` for an empty series; clamps to the first / last
42
+ * row when `value` is outside the axis extent.
43
+ */
44
+ nearestIndex(value: number): number;
45
+ /**
46
+ * The contiguous sub-series whose axis value lies in `[lo, hi)` — the
47
+ * value-axis cull (pan / zoom on a value x). Binary-searches the bounds and
48
+ * zero-copy slices the store. `lo >= hi` (or a range outside the extent)
49
+ * yields an empty series.
50
+ */
51
+ sliceByValue(lo: number, hi: number): ValueSeries<VS>;
52
+ }
53
+ //# sourceMappingURL=value-series.d.ts.map
@@ -0,0 +1,105 @@
1
+ import { withRowRange, } from '../columnar/index.js';
2
+ /**
3
+ * A **value-keyed series** — the closed value-axis counterpart of
4
+ * `TimeSeries`. Its key is a monotonic non-time axis (distance, cumulative
5
+ * work, …) rather than time, produced by `TimeSeries.byValue(axis)`.
6
+ *
7
+ * `ValueSeries` carries the **ordering-based** operators (read the axis, read
8
+ * value columns, nearest-by-value, slice-by-value) — the part of the series
9
+ * algebra that was never really about time (RFC `value-axis.md` §5). The
10
+ * calendar/clock operators (`Sequence.every`, tz formatting) are deliberately
11
+ * absent: a value axis has no wall-clock semantics, and the disjoint
12
+ * `ValueSeriesSchema` makes them type-impossible here.
13
+ *
14
+ * Minimal by design (RFC §7: adopt the type early, grow the algebra as a second
15
+ * value-axis consumer earns it). Wraps the columnar store directly — a value
16
+ * row is an `(axis, …values)` tuple, not a `Time`-keyed `Event`, so it does not
17
+ * go through the time-only `SeriesStore` / EventKey layer.
18
+ */
19
+ export class ValueSeries {
20
+ name;
21
+ schema;
22
+ #store;
23
+ /**
24
+ * @internal Trusted construction — `store` must be value-keyed and structurally
25
+ * match `schema` (the invariant `TimeSeries.byValue` / `byValueOp` establish).
26
+ * Not for general use; construct a `ValueSeries` via `TimeSeries.byValue`.
27
+ */
28
+ static fromTrustedStore(name, schema, store) {
29
+ return new ValueSeries(name, schema, store);
30
+ }
31
+ constructor(name, schema, store) {
32
+ this.name = name;
33
+ this.schema = Object.freeze(schema.slice());
34
+ this.#store = store;
35
+ }
36
+ /** Number of rows. */
37
+ get length() {
38
+ return this.#store.length;
39
+ }
40
+ /** The axis (key) column's name — e.g. `'cumDist'`. */
41
+ get axisName() {
42
+ return this.schema[0].name;
43
+ }
44
+ /**
45
+ * The axis values (the x of every row), in axis order. **Zero-copy** — the
46
+ * returned `Float64Array` is the live key buffer; treat it as read-only.
47
+ */
48
+ axisValues() {
49
+ return this.#store.keys.begin;
50
+ }
51
+ /** The axis value at row `i`. Throws if out of range. */
52
+ axisAt(i) {
53
+ return this.#store.keys.beginAt(i);
54
+ }
55
+ /** A value column by name, for direct columnar reads (`.read(i)`, `.values()`). */
56
+ column(name) {
57
+ return this.#store.columns.get(name);
58
+ }
59
+ /**
60
+ * Index of the row whose axis value is **closest** to `value` — the
61
+ * value-axis cursor primitive. The axis is non-decreasing, so this is a
62
+ * binary search. Returns `-1` for an empty series; clamps to the first / last
63
+ * row when `value` is outside the axis extent.
64
+ */
65
+ nearestIndex(value) {
66
+ const n = this.length;
67
+ if (n === 0)
68
+ return -1;
69
+ const ax = this.axisValues();
70
+ const lo = lowerBound(ax, n, value);
71
+ if (lo === 0)
72
+ return 0;
73
+ if (lo === n)
74
+ return n - 1;
75
+ return value - ax[lo - 1] <= ax[lo] - value ? lo - 1 : lo;
76
+ }
77
+ /**
78
+ * The contiguous sub-series whose axis value lies in `[lo, hi)` — the
79
+ * value-axis cull (pan / zoom on a value x). Binary-searches the bounds and
80
+ * zero-copy slices the store. `lo >= hi` (or a range outside the extent)
81
+ * yields an empty series.
82
+ */
83
+ sliceByValue(lo, hi) {
84
+ const ax = this.axisValues();
85
+ const n = this.length;
86
+ const loIdx = lowerBound(ax, n, lo);
87
+ const hiIdx = lowerBound(ax, n, hi);
88
+ const sliced = withRowRange(this.#store, loIdx, hiIdx);
89
+ return ValueSeries.fromTrustedStore(this.name, this.schema, sliced);
90
+ }
91
+ }
92
+ /** First index `i` in `ax[0..n)` with `ax[i] >= target` (lower bound). */
93
+ function lowerBound(ax, n, target) {
94
+ let lo = 0;
95
+ let hi = n;
96
+ while (lo < hi) {
97
+ const mid = (lo + hi) >>> 1;
98
+ if (ax[mid] < target)
99
+ lo = mid + 1;
100
+ else
101
+ hi = mid;
102
+ }
103
+ return lo;
104
+ }
105
+ //# sourceMappingURL=value-series.js.map
@@ -33,7 +33,7 @@ export { type ColumnarRingBufferOptions, ColumnarRingBuffer, } from './ring-buff
33
33
  export { type OnUndefinedPartition, type ScatterByPartitionOptions, scatterByPartition, } from './scatter.js';
34
34
  export { DICT_ENCODE_MIN_LENGTH, DICT_ENCODE_RATIO, StringColumn, buildDictionaryIndex, estimateDictionaryBytes, remapColumnToDictionary, remapIndicesToDictionary, stringColumnDictEncoded, stringColumnFallback, stringColumnFromArray, } from './string-column.js';
35
35
  export { ArrayColumn, EMPTY_ARRAY_SENTINEL, arrayColumnFromArray, } from './array-column.js';
36
- export { type IntervalLabelKind, type KeyColumn, IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, timeKeyColumnFromArray, timeRangeKeyColumnFromPairs, } from './key-column.js';
36
+ export { type IntervalLabelKind, type KeyColumn, IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, ValueKeyColumn, timeKeyColumnFromArray, timeRangeKeyColumnFromPairs, valueKeyColumnFromArray, } from './key-column.js';
37
37
  export { type FromTrustedStoreOptions, ColumnarStore } from './store.js';
38
38
  export { type ColumnBuilder, ArrayColumnBuilder, BooleanColumnBuilder, Float64ColumnBuilder, StringColumnBuilder, columnBuilderForKind, } from './builder.js';
39
39
  export { type AnyColumnKind, type ArrayValue, type ColumnDef, type ColumnSchema, type KeyKind, type ScalarValue, } from './types.js';
@@ -33,7 +33,7 @@ export { ColumnarRingBuffer, } from './ring-buffer.js';
33
33
  export { scatterByPartition, } from './scatter.js';
34
34
  export { DICT_ENCODE_MIN_LENGTH, DICT_ENCODE_RATIO, StringColumn, buildDictionaryIndex, estimateDictionaryBytes, remapColumnToDictionary, remapIndicesToDictionary, stringColumnDictEncoded, stringColumnFallback, stringColumnFromArray, } from './string-column.js';
35
35
  export { ArrayColumn, EMPTY_ARRAY_SENTINEL, arrayColumnFromArray, } from './array-column.js';
36
- export { IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, timeKeyColumnFromArray, timeRangeKeyColumnFromPairs, } from './key-column.js';
36
+ export { IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, ValueKeyColumn, timeKeyColumnFromArray, timeRangeKeyColumnFromPairs, valueKeyColumnFromArray, } from './key-column.js';
37
37
  export { ColumnarStore } from './store.js';
38
38
  export { ArrayColumnBuilder, BooleanColumnBuilder, Float64ColumnBuilder, StringColumnBuilder, columnBuilderForKind, } from './builder.js';
39
39
  export { materialize, withColumnAppended, withColumnReplaced, withColumnsRenamed, withColumnsSelected, withKeyColumn, withRowRange, withRowSelection, } from './view.js';
@@ -33,13 +33,13 @@
33
33
  import { Float64Column } from './column.js';
34
34
  import { StringColumn } from './string-column.js';
35
35
  /** The framework's key-column discriminated union. */
36
- export type KeyColumn = TimeKeyColumn | TimeRangeKeyColumn | IntervalKeyColumn;
36
+ export type KeyColumn = TimeKeyColumn | TimeRangeKeyColumn | IntervalKeyColumn | ValueKeyColumn;
37
37
  /**
38
38
  * Shared interface implemented by every key-column class. Pure
39
39
  * indexed buffer access; the framework knows nothing about
40
40
  * `EventKey` / `Time` / `TimeRange` / `Interval`.
41
41
  */
42
- interface KeyColumnBase<K extends 'time' | 'timeRange' | 'interval'> {
42
+ interface KeyColumnBase<K extends 'time' | 'timeRange' | 'interval' | 'value'> {
43
43
  readonly kind: K;
44
44
  /** Row count. */
45
45
  readonly length: number;
@@ -97,6 +97,34 @@ export declare class TimeKeyColumn implements KeyColumnBase<'time'> {
97
97
  */
98
98
  sliceByIndices(indices: Int32Array): TimeKeyColumn;
99
99
  }
100
+ /**
101
+ * Point key on a **value axis** (distance, cumulative work, …) rather than
102
+ * time — the substrate of a `ValueSeries`. Structurally identical to
103
+ * {@link TimeKeyColumn} (single `Float64Array`, `end === begin`); only the
104
+ * `kind` tag differs, which is what gates the calendar/clock operators
105
+ * (`Sequence.every`, tz tick formatting) off a value-keyed series.
106
+ *
107
+ * The buffer carries finite axis values, not epoch milliseconds. **Ordering
108
+ * (non-decreasing) is NOT enforced here** — the monotonicity contract lives on
109
+ * the `byValue` projection (`assertMonotonicAxis`), so the column stays a dumb
110
+ * indexed buffer (matching how the time columns don't self-validate sort
111
+ * order). Finiteness *is* enforced: a `NaN` / `Infinity` axis value would break
112
+ * bisection and range logic.
113
+ */
114
+ export declare class ValueKeyColumn implements KeyColumnBase<'value'> {
115
+ readonly kind: "value";
116
+ readonly length: number;
117
+ readonly begin: Float64Array;
118
+ /** For a point value key, `end === begin` (same buffer) — a zero-width axis position. */
119
+ readonly end: Float64Array;
120
+ constructor(begin: Float64Array, length: number);
121
+ beginAt(i: number): number;
122
+ endAt(i: number): number;
123
+ /** Zero-copy index-range view. Mirrors {@link TimeKeyColumn.sliceByRange}. */
124
+ sliceByRange(start: number, end: number): ValueKeyColumn;
125
+ /** Gathers rows by index. See {@link TimeKeyColumn.sliceByIndices}. */
126
+ sliceByIndices(indices: Int32Array): ValueKeyColumn;
127
+ }
100
128
  export declare class TimeRangeKeyColumn implements KeyColumnBase<'timeRange'> {
101
129
  readonly kind: "timeRange";
102
130
  readonly length: number;
@@ -181,6 +209,12 @@ export declare class IntervalKeyColumn implements KeyColumnBase<'interval'> {
181
209
  * value columns.
182
210
  */
183
211
  export declare function timeKeyColumnFromArray(timestamps: ReadonlyArray<number>): TimeKeyColumn;
212
+ /**
213
+ * Builds a {@link ValueKeyColumn} from an array of axis values. The values
214
+ * must be finite; ordering (non-decreasing) is the `byValue` projection's
215
+ * contract, not this factory's.
216
+ */
217
+ export declare function valueKeyColumnFromArray(values: ReadonlyArray<number>): ValueKeyColumn;
184
218
  /**
185
219
  * Builds a `TimeRangeKeyColumn` from `[begin, end]` pairs. Each pair
186
220
  * must satisfy `begin <= end`.