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.
package/CHANGELOG.md CHANGED
@@ -3,11 +3,14 @@
3
3
  All notable changes to this project are documented here.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
- `pond-ts` and `@pond-ts/react` release together under a single `v*` tag, so this
7
- file covers both packages. Pre-1.0: minor bumps may include new features and
8
- type-level changes; patch bumps are strictly additive.
9
-
10
- [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.29.0...HEAD
6
+ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`, and
7
+ `@pond-ts/fit` release together under a single `v*` tag, so this file covers
8
+ them all. Pre-1.0: minor bumps may include new features and type-level changes;
9
+ patch bumps are strictly additive.
10
+
11
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.31.0...HEAD
12
+ [0.31.0]: https://github.com/pjm17971/pond-ts/compare/v0.30.0...v0.31.0
13
+ [0.30.0]: https://github.com/pjm17971/pond-ts/compare/v0.29.0...v0.30.0
11
14
  [0.29.0]: https://github.com/pjm17971/pond-ts/compare/v0.28.0...v0.29.0
12
15
  [0.28.0]: https://github.com/pjm17971/pond-ts/compare/v0.27.0...v0.28.0
13
16
  [0.27.0]: https://github.com/pjm17971/pond-ts/compare/v0.26.0...v0.27.0
@@ -21,7 +24,94 @@ type-level changes; patch bumps are strictly additive.
21
24
  [0.19.0]: https://github.com/pjm17971/pond-ts/compare/v0.18.0...v0.19.0
22
25
  [0.18.0]: https://github.com/pjm17971/pond-ts/compare/v0.17.1...v0.18.0
23
26
 
24
- ## [Unreleased]
27
+ ## [0.31.0] — 2026-06-28
28
+
29
+ First published release of **`@pond-ts/charts`** and **`@pond-ts/fit`** (both were
30
+ previously `private`). All four packages — `pond-ts`, `@pond-ts/react`,
31
+ `@pond-ts/charts`, `@pond-ts/fit` — now release together, lock-step, under one
32
+ `v*` tag.
33
+
34
+ ### Added — `pond-ts` (core)
35
+
36
+ - **`ValueSeries` + `TimeSeries.byValue(axis)` — the value axis as a closed
37
+ type.** `byValue` re-keys a series onto a monotonic non-time **value axis**
38
+ (distance, cumulative work, …), returning a `ValueSeries` — the value-keyed
39
+ counterpart of `TimeSeries`. It carries the ordering-based operators
40
+ (`axisValues`, `axisAt`, `column`, `nearestIndex`, `sliceByValue`); the
41
+ calendar/clock operators are deliberately absent — a value axis has no
42
+ wall-clock semantics, and the disjoint `ValueSeriesSchema` makes them
43
+ type-impossible. The axis must be **defined, finite, and non-decreasing at
44
+ every row** (it becomes the index); it is dropped from the value columns (it
45
+ is now the key) and the rest reshare zero-copy. Substrate: a new `'value'`
46
+ `KeyKind` + `ValueKeyColumn`. Projection is O(N + C); `nearestIndex` is
47
+ O(log N); `sliceByValue` is O(log N + C) zero-copy. (value-axis RFC Phase 1.)
48
+ - **`scan(source, step, init, options?)` — typed-accumulator running fold.** The
49
+ general form of `cumulative` (the classic `mapAccumL`): the accumulator `A`
50
+ (any value, seeded from `init`) is **decoupled** from the numeric `output` and
51
+ the output column. `step(acc, value, i)` returns `[nextAcc, output]`. With no
52
+ `options.output` the source column is **replaced** in place (as `cumulative`
53
+ does); with `options.output` a **new** column is appended and the source is
54
+ left intact. Missing-cell carry, stored-`NaN`, and multi-entity semantics are
55
+ inherited from `cumulative` (scope per entity with
56
+ `partitionBy(col).scan(...).collect()`). Column-native, O(N + C), no event
57
+ materialization. Enables `split = scan + byColumn` — materialize cross-bin
58
+ state (e.g. hysteresis elevation gain) into a column, then segment it with
59
+ `byColumn`'s pure, order-free reducers. (estela F-geo-2-splits; value-axis
60
+ RFC wave lead.)
61
+
62
+ ### Added — `@pond-ts/charts` (initial release)
63
+
64
+ - **First public release.** A React charting layer over pond-ts — a canvas data
65
+ plane with SVG interactive overlays. `ChartContainer` / `ChartRow` / `Layers`
66
+ composition; `LineChart`, `AreaChart`, `BarChart`, `Scatter`, `BoxPlot`;
67
+ `TimeAxis` / `YAxis` / `XAxis` (time **and** value x-axes); the cursor system
68
+ (staffed flag, per-row cursor modes); shared gap-rendering modes; and the
69
+ estela theme. Peer-depends on `pond-ts`, `@pond-ts/react`, and React 18/19.
70
+
71
+ ### Added — `@pond-ts/fit` (initial release)
72
+
73
+ - **First public release.** A fitness / activity domain library over pond-ts — the
74
+ `Activity` / `Section` façade, unit-safe quantities (`Distance` / `Speed` /
75
+ `Power` / … with `.format()`), geo / power / zones analytics, `Profile` +
76
+ `usingProfile()` → `ProfiledActivity` / `ProfiledSection`, and the `Track`
77
+ value object. Façade-first: one curated flat barrel, with the functional
78
+ operator surface kept internal. Peer-depends on `pond-ts`.
79
+
80
+ ### Changed
81
+
82
+ - **All `@pond-ts/*` peer / dependency ranges widened to `^0.31.0`** for the
83
+ lock-step release.
84
+
85
+ ## [0.30.0] — 2026-06-17
86
+
87
+ ### Added
88
+
89
+ - **`rollingByColumn(col, { radius, at }, mapping)` — evaluate at explicit
90
+ centers.** `at` takes a **non-decreasing** array of center values (e.g. a chart's
91
+ coarse display grid) and returns **one record per center**, instead of the
92
+ default one-per-row. A center with no rows within `±radius` yields each
93
+ reducer's empty value. Same O(n + centers) two-pointer. Closes the
94
+ evaluate-at-grid gap surfaced adopting `rollingByColumn` for a chart variance
95
+ band. (estela F-rolling-by-row.)
96
+ - **`smooth(col, 'movingAverage' | 'loess', { …, missing: 'skip' })` —
97
+ validity-respecting smoothing.** By default (`missing: 'bridge'`) a cell whose
98
+ own value is missing is still assigned a smoothed value from its present
99
+ neighbours — the line is drawn _across_ the hole. `missing: 'skip'` keeps a
100
+ missing cell **missing** in the output, so a sustained dropout (a coast, a
101
+ sensor gap) is preserved as a break rather than fabricated through. Present
102
+ cells smooth over only the present values in their window either way. `ema`
103
+ takes no `missing` option (it is causal and never fabricates across a gap). A
104
+ `maxGap` hard segment boundary is a deferred follow-on. (estela
105
+ F-smooth-interactive.)
106
+
107
+ ### Changed
108
+
109
+ - **`byColumn(…, { inclusive: '(]' })` floor edge is now inclusive.** Under
110
+ `'(]'`, interior bins stay upper-inclusive (`(eᵢ, eᵢ₊₁]`) but the **floor `e₀`
111
+ is inclusive** (bin 0 is `[e₀, e₁]`), so a value at exactly the minimum edge —
112
+ e.g. a `0` W coast/stop sample at a zone floor of 0 — lands in bin 0 instead of
113
+ being dropped (the `include_lowest` convention). Previously the floor was
114
+ exclusive. (estela F-inclusive-floor.)
25
115
 
26
116
  ## [0.29.0] — 2026-06-17
27
117
 
@@ -47,17 +47,21 @@ export function computeByColumn(store, binColName, spec, columnSpecs) {
47
47
  const last = edges.length - 1;
48
48
  // `inclusive` picks the bin a value exactly on an interior edge falls into.
49
49
  // `'[)'` (default): bins are `[eᵢ, eᵢ₊₁)`, lower-inclusive — a boundary value
50
- // goes to the bin ABOVE; range is `[e₀, eₙ)`. `'(]'`: bins are `(eᵢ, eᵢ₊₁]`,
51
- // upper-inclusive (Coggan power/HR zones — a sample at exactly a zone's top
52
- // edge is the LOWER zone) — a boundary value goes to the bin BELOW; range is
53
- // `(e₀, eₙ]`, so the first edge is an exclusive floor (set it below your
54
- // minimum to keep the minimum in bin 0).
50
+ // goes to the bin ABOVE; range is `[e₀, eₙ)`. `'(]'`: interior bins are
51
+ // `(eᵢ, eᵢ₊₁]`, upper-inclusive (Coggan power/HR zones — a sample at exactly
52
+ // a zone's top edge is the LOWER zone) — a boundary value goes to the bin
53
+ // BELOW. The **floor `e₀` is inclusive** (bin 0 is `[e₀, e₁]`), so a value at
54
+ // exactly the minimum edge e.g. a `0` W coast/stop sample — lands in bin 0
55
+ // rather than being dropped (the `include_lowest` convention; estela
56
+ // F-inclusive-floor). Range is `[e₀, eₙ]`.
55
57
  binOf =
56
58
  spec.inclusive === '(]'
57
59
  ? (v) => {
58
- if (v <= edges[0] || v > edges[last])
59
- return NaN; // out of (e₀, eₙ]
60
- // rightmost edge STRICTLY less than v, clamped to a valid bin [0, last)
60
+ if (v < edges[0] || v > edges[last])
61
+ return NaN; // out of [e₀, eₙ]
62
+ // rightmost edge STRICTLY less than v (so an interior edge goes to
63
+ // the bin below), clamped to [0, last); v === e₀ → bin 0 (floor
64
+ // inclusive).
61
65
  let lo = 0;
62
66
  let hi = last;
63
67
  while (lo < hi) {
@@ -0,0 +1,19 @@
1
+ import { ColumnarStore, type ColumnSchema } from '../../columnar/index.js';
2
+ /**
3
+ * **Column-native `byValue`** — the raw `TimeSeries → ValueSeries` projection
4
+ * (RFC `value-axis.md` §6). Re-keys the store onto the monotonic `axis` column
5
+ * (a no-op reindex: the rows already sit in axis order, since the axis is
6
+ * non-decreasing in storage order) and **drops `axis` from the value columns**
7
+ * — it is now the key, and `fromTrustedStore` rejects the duplicate name
8
+ * otherwise. The non-axis value columns + their buffers are shared by reference
9
+ * (zero-copy); only the key column is newly allocated.
10
+ *
11
+ * Returns the reshaped store + the value-keyed output schema. The schema cast
12
+ * is the trust boundary; `TimeSeries.byValue` wraps the store in a
13
+ * `ValueSeries` with the precise `ValueKeyedSchema<S, Axis>` type.
14
+ */
15
+ export declare function byValueOp(store: ColumnarStore<ColumnSchema>, schema: ColumnSchema, axis: string): {
16
+ store: ColumnarStore<ColumnSchema>;
17
+ schema: ColumnSchema;
18
+ };
19
+ //# sourceMappingURL=by-value.d.ts.map
@@ -0,0 +1,91 @@
1
+ import { ColumnarStore, Float64Column, ValueKeyColumn, } from '../../columnar/index.js';
2
+ /**
3
+ * Validates that the `axis` column is a usable value axis — **every cell
4
+ * defined + finite + non-decreasing** — and returns the `Float64Array` to key
5
+ * on. Throws otherwise.
6
+ *
7
+ * This is the monotonicity contract for `byValue`: it lives on the *projection*,
8
+ * not on `ValueKeyColumn` or `byColumn` (Codex review #1). An order-free
9
+ * value-bin aggregation has no monotonic precondition, but promoting a column to
10
+ * the *index* of a series does — and a missing/non-finite cell can't be placed
11
+ * in the ordering, so (unlike a value column, where a gap is fine) the axis must
12
+ * be dense.
13
+ *
14
+ * **Zero-copy fast path (Lever 1).** A packed {@link Float64Column} (every batch
15
+ * value column is one) already holds a contiguous backing buffer. Once the scan
16
+ * has proven `[0, n)` is dense + finite, that buffer *is* the key data, so we
17
+ * hand back a `subarray(0, n)` view rather than allocating and copying a fresh
18
+ * array — the source axis column and the new key then share it read-only (the
19
+ * same zero-copy contract as a slice). `_values.length` can exceed the logical
20
+ * length (capacity-grown columns), hence the `subarray`. A chunked column has no
21
+ * single contiguous buffer, so it falls back to materializing one.
22
+ *
23
+ * Trade-off of the reuse: the returned `subarray` view retains the source
24
+ * column's whole `ArrayBuffer` (including any capacity slack on a grown
25
+ * column), where the old copy released it. Negligible for batch (`_values`
26
+ * is sized to the column) and for a single live projection; only worth
27
+ * revisiting if a path holds many such views at once.
28
+ *
29
+ * The validation read-loop is unavoidable here (it enforces dense + finite +
30
+ * sorted). A future `{ assumeSorted }` fast path could skip it for a
31
+ * caller-guaranteed axis (e.g. a `scan`-produced cumulative distance); that's a
32
+ * trusted-construction seam, deferred until a re-projection hot path earns it.
33
+ */
34
+ function assertMonotonicAxis(store, axis) {
35
+ const col = store.columns.get(axis);
36
+ if (col === undefined) {
37
+ throw new RangeError(`byValue: unknown column '${axis}'`);
38
+ }
39
+ const n = store.length;
40
+ let prev = -Infinity;
41
+ for (let i = 0; i < n; i += 1) {
42
+ const v = col.read(i);
43
+ if (typeof v !== 'number' || !Number.isFinite(v)) {
44
+ throw new RangeError(`byValue: axis '${axis}' must be defined and finite at every row to be the index; ` +
45
+ `row ${i} is ${v === undefined ? 'missing' : String(v)}`);
46
+ }
47
+ if (v < prev) {
48
+ throw new RangeError(`byValue: axis '${axis}' must be non-decreasing; row ${i} (${v}) < previous (${prev})`);
49
+ }
50
+ prev = v;
51
+ }
52
+ // Validation passed → `[0, n)` is dense + finite. Reuse the packed backing
53
+ // buffer zero-copy; materialize only for a (rare) chunked axis column.
54
+ if (col instanceof Float64Column) {
55
+ return col._values.subarray(0, n);
56
+ }
57
+ const out = new Float64Array(n);
58
+ for (let i = 0; i < n; i += 1) {
59
+ out[i] = col.read(i);
60
+ }
61
+ return out;
62
+ }
63
+ /**
64
+ * **Column-native `byValue`** — the raw `TimeSeries → ValueSeries` projection
65
+ * (RFC `value-axis.md` §6). Re-keys the store onto the monotonic `axis` column
66
+ * (a no-op reindex: the rows already sit in axis order, since the axis is
67
+ * non-decreasing in storage order) and **drops `axis` from the value columns**
68
+ * — it is now the key, and `fromTrustedStore` rejects the duplicate name
69
+ * otherwise. The non-axis value columns + their buffers are shared by reference
70
+ * (zero-copy); only the key column is newly allocated.
71
+ *
72
+ * Returns the reshaped store + the value-keyed output schema. The schema cast
73
+ * is the trust boundary; `TimeSeries.byValue` wraps the store in a
74
+ * `ValueSeries` with the precise `ValueKeyedSchema<S, Axis>` type.
75
+ */
76
+ export function byValueOp(store, schema, axis) {
77
+ const values = assertMonotonicAxis(store, axis);
78
+ // `fromValidatedSubarray`, not `new ValueKeyColumn` — `assertMonotonicAxis`
79
+ // already proved finiteness, so skip the constructor's redundant finite scan.
80
+ const keyCol = ValueKeyColumn.fromValidatedSubarray(values, store.length);
81
+ // Drop the axis column from the value columns (it becomes the key).
82
+ const newColumns = new Map(store.columns);
83
+ newColumns.delete(axis);
84
+ const newSchema = [
85
+ { name: axis, kind: 'value' },
86
+ ...schema.slice(1).filter((c) => c.name !== axis),
87
+ ];
88
+ const newStore = ColumnarStore.fromTrustedStore(newSchema, keyCol, newColumns);
89
+ return { store: newStore, schema: newSchema };
90
+ }
91
+ //# sourceMappingURL=by-value.js.map
@@ -0,0 +1,47 @@
1
+ import { type ColumnarStore } from '../../columnar/index.js';
2
+ import type { SeriesSchema } from '../../schema/index.js';
3
+ /**
4
+ * The step function for {@link TimeSeries.scan} — the classic `mapAccumL`:
5
+ * given the carried accumulator, the current (defined) source value, and the
6
+ * row index, return the next accumulator and this row's numeric output. The
7
+ * accumulator type `A` is **decoupled** from the numeric output, which is what
8
+ * `cumulative` — where the accumulator *is* the output *is* a `number` — cannot
9
+ * express (e.g. hysteresis elevation gain carries `(ref, gain)` but emits only
10
+ * `gain`).
11
+ */
12
+ export type ScanStep<A> = (acc: A, value: number, index: number) => readonly [next: A, output: number];
13
+ /**
14
+ * **Column-native `scan`** — a typed-accumulator running fold, the
15
+ * generalization of `cumulativeOp`. Threads `acc: A` (any value, seeded from
16
+ * `init`) across the source column's defined cells, emits one numeric `output`
17
+ * per row, and either **replaces** the source column (`output === undefined`,
18
+ * `cumulative`'s convention) or **appends** a new column (`output` named,
19
+ * `withColumn`'s convention; the source stays intact). Reads straight off
20
+ * `Column.read(i)` — no event materialization, one pass, **O(n)**.
21
+ *
22
+ * Semantics are inherited from `cumulativeOp` so `scan` is consistent, not a
23
+ * new dialect:
24
+ * - A **defined numeric cell** calls `step(acc, value, i)`; the accumulator and
25
+ * the last-emitted output both advance.
26
+ * - A **missing / undefined cell** does *not* call `step`: the accumulator is
27
+ * carried unchanged and the row re-emits the last output, so the output holds
28
+ * flat across a gap (exactly as `cumulative`'s accumulator holds). The output
29
+ * is `undefined` only until the first defined value produces one
30
+ * (`float64ColumnFromArray` derives validity from the `undefined`s).
31
+ * - A **stored `NaN`** is a defined number — `step` is called with it, and a
32
+ * computed non-finite output lands as a defined cell. This is the
33
+ * *trusted-compute* path (matching `cumulative`), not the validated
34
+ * `withColumn` intake; the step author owns output finiteness.
35
+ *
36
+ * Returns the reshaped store + the output schema. The result-schema cast is the
37
+ * single trust boundary; `TimeSeries.scan` wraps the store via
38
+ * `#fromTrustedStore`. A non-numeric source is unreachable through the typed
39
+ * surface (`scan<Source extends NumericColumnNameForSchema<S>>`); on the
40
+ * replace path it fails fast (`withColumnReplaced`'s kind guard), matching
41
+ * `cumulative`.
42
+ */
43
+ export declare function scanOp<S extends SeriesSchema, OutSchema extends SeriesSchema, A>(store: ColumnarStore<S>, schema: S, source: string, step: ScanStep<A>, init: A, output: string | undefined): {
44
+ store: ColumnarStore<OutSchema>;
45
+ schema: OutSchema;
46
+ };
47
+ //# sourceMappingURL=scan.d.ts.map
@@ -0,0 +1,81 @@
1
+ import { float64ColumnFromArray, withColumnAppended, withColumnReplaced, } from '../../columnar/index.js';
2
+ /**
3
+ * **Column-native `scan`** — a typed-accumulator running fold, the
4
+ * generalization of `cumulativeOp`. Threads `acc: A` (any value, seeded from
5
+ * `init`) across the source column's defined cells, emits one numeric `output`
6
+ * per row, and either **replaces** the source column (`output === undefined`,
7
+ * `cumulative`'s convention) or **appends** a new column (`output` named,
8
+ * `withColumn`'s convention; the source stays intact). Reads straight off
9
+ * `Column.read(i)` — no event materialization, one pass, **O(n)**.
10
+ *
11
+ * Semantics are inherited from `cumulativeOp` so `scan` is consistent, not a
12
+ * new dialect:
13
+ * - A **defined numeric cell** calls `step(acc, value, i)`; the accumulator and
14
+ * the last-emitted output both advance.
15
+ * - A **missing / undefined cell** does *not* call `step`: the accumulator is
16
+ * carried unchanged and the row re-emits the last output, so the output holds
17
+ * flat across a gap (exactly as `cumulative`'s accumulator holds). The output
18
+ * is `undefined` only until the first defined value produces one
19
+ * (`float64ColumnFromArray` derives validity from the `undefined`s).
20
+ * - A **stored `NaN`** is a defined number — `step` is called with it, and a
21
+ * computed non-finite output lands as a defined cell. This is the
22
+ * *trusted-compute* path (matching `cumulative`), not the validated
23
+ * `withColumn` intake; the step author owns output finiteness.
24
+ *
25
+ * Returns the reshaped store + the output schema. The result-schema cast is the
26
+ * single trust boundary; `TimeSeries.scan` wraps the store via
27
+ * `#fromTrustedStore`. A non-numeric source is unreachable through the typed
28
+ * surface (`scan<Source extends NumericColumnNameForSchema<S>>`); on the
29
+ * replace path it fails fast (`withColumnReplaced`'s kind guard), matching
30
+ * `cumulative`.
31
+ */
32
+ export function scanOp(store, schema, source, step, init, output) {
33
+ const col = store.columns.get(source);
34
+ if (col === undefined) {
35
+ throw new RangeError(`scan: unknown column '${source}'`);
36
+ }
37
+ const n = store.length;
38
+ const out = new Array(n);
39
+ let acc = init;
40
+ let last;
41
+ for (let i = 0; i < n; i += 1) {
42
+ const raw = col.read(i);
43
+ if (typeof raw === 'number') {
44
+ const r = step(acc, raw, i);
45
+ acc = r[0];
46
+ last = r[1];
47
+ }
48
+ out[i] = last;
49
+ }
50
+ const column = float64ColumnFromArray(out);
51
+ const base = store;
52
+ // Output omitted ⇒ replace the source column in place (cumulative's
53
+ // convention; the source is widened to optional `number`).
54
+ if (output === undefined) {
55
+ const result = withColumnReplaced(base, source, column);
56
+ const outSchema = Object.freeze(schema.map((c, i) => i === 0 || c.name !== source
57
+ ? c
58
+ : { ...c, kind: 'number', required: false }));
59
+ return {
60
+ store: result,
61
+ schema: outSchema,
62
+ };
63
+ }
64
+ // Output named ⇒ append a new column, leaving the source intact. The name
65
+ // must not collide with the key or an existing value column (the schema
66
+ // includes the key at index 0). `withColumnAppended` re-checks, but the
67
+ // explicit guard gives a scan-specific message pointing at the replace path.
68
+ if (schema.some((c) => c.name === output)) {
69
+ throw new RangeError(`scan: output column '${output}' already exists; omit options.output to replace the source column`);
70
+ }
71
+ const result = withColumnAppended(base, output, column);
72
+ const outSchema = Object.freeze([
73
+ ...schema,
74
+ { name: output, kind: 'number' },
75
+ ]);
76
+ return {
77
+ store: result,
78
+ schema: outSchema,
79
+ };
80
+ }
81
+ //# sourceMappingURL=scan.js.map
@@ -4,7 +4,8 @@ import { Sequence } from '../sequence/sequence.js';
4
4
  import type { DurationInput } from '../core/duration.js';
5
5
  import type { TemporalLike } from '../core/temporal.js';
6
6
  import type { BatchSampleStrategy } from '../sequence/sample.js';
7
- import type { AggregateSchema, AlignSchema, BaselineSchema, DedupeKeep, DiffSchema, EventDataForSchema, FillMapping, FillStrategy, MaterializeSchema, NumericColumnNameForSchema, RollingAlignment, RollingSchema, SeriesSchema, SmoothAppendSchema, SmoothMethod, SmoothSchema, ValidatedAggregateMap } from '../schema/index.js';
7
+ import type { AggregateSchema, AlignSchema, AppendColumn, BaselineSchema, DedupeKeep, DiffSchema, EventDataForSchema, FillMapping, FillStrategy, MaterializeSchema, NumericColumnNameForSchema, RollingAlignment, RollingSchema, SeriesSchema, SmoothAppendSchema, SmoothMethod, SmoothSchema, ValidatedAggregateMap } from '../schema/index.js';
8
+ import type { ScanStep } from './operators/scan.js';
8
9
  type SequenceLike = Sequence | BoundedSequence;
9
10
  type AlignMethod = 'hold' | 'linear';
10
11
  type AlignSample = 'begin' | 'center' | 'end';
@@ -303,6 +304,11 @@ export declare class PartitionedTimeSeries<S extends SeriesSchema, K extends str
303
304
  cumulative<const Targets extends NumericColumnNameForSchema<S>>(spec: {
304
305
  [K in Targets]: 'sum' | 'max' | 'min' | 'count' | ((acc: number, value: number) => number);
305
306
  }): PartitionedTimeSeries<DiffSchema<S, Targets>, K>;
307
+ /** Per-partition `scan`. See {@link TimeSeries.scan}. */
308
+ scan<const Source extends NumericColumnNameForSchema<S>, A>(source: Source, step: ScanStep<A>, init: A): PartitionedTimeSeries<DiffSchema<S, Source>, K>;
309
+ scan<const Source extends NumericColumnNameForSchema<S>, const Name extends string, A>(source: Source, step: ScanStep<A>, init: A, options: {
310
+ output: Name;
311
+ }): PartitionedTimeSeries<AppendColumn<S, Name, 'number'>, K>;
306
312
  /** Per-partition `shift`. See {@link TimeSeries.shift}. */
307
313
  shift<const Target extends NumericColumnNameForSchema<S>>(columns: Target | readonly Target[], n: number): PartitionedTimeSeries<DiffSchema<S, Target>, K>;
308
314
  /** Per-partition `aggregate`. See {@link TimeSeries.aggregate}. */
@@ -419,6 +419,9 @@ export class PartitionedTimeSeries {
419
419
  cumulative(spec) {
420
420
  return this.rewrap(PartitionedTimeSeries.applyToSource(this.source, this.by, (g) => g.cumulative(spec)));
421
421
  }
422
+ scan(source, step, init, options) {
423
+ return this.rewrap(PartitionedTimeSeries.applyToSource(this.source, this.by, (g) => g.scan(source, step, init, options)));
424
+ }
422
425
  /** Per-partition `shift`. See {@link TimeSeries.shift}. */
423
426
  shift(columns, n) {
424
427
  return this.rewrap(PartitionedTimeSeries.applyToSource(this.source, this.by, (g) => g.shift(columns, n)));
@@ -3,12 +3,19 @@ import type { ColumnValue } from '../schema/index.js';
3
3
  import type { AggregateColumnSpec } from './aggregate-columns.js';
4
4
  /**
5
5
  * Window spec for {@link TimeSeries.rollingByColumn}: a **centered** window of
6
- * half-width `radius`, in the axis column's own units. The window for a row is
7
- * every row whose axis value lies within `±radius` of it. See
6
+ * half-width `radius`, in the axis column's own units.
7
+ *
8
+ * By default a window is evaluated at **every input row** (one record per row).
9
+ * Pass `at` — a **non-decreasing** array of explicit center values (e.g. a chart's
10
+ * coarse display grid) — to evaluate at those centers instead, returning one
11
+ * record per center. Each center's window is the rows whose axis value lies
12
+ * within `±radius` of it; a center need not coincide with any row, and a center
13
+ * with no rows in range yields each reducer's empty value. See
8
14
  * `docs/notes/rolling-by-column.md`.
9
15
  */
10
16
  export type WindowSpec = {
11
17
  radius: number;
18
+ at?: readonly number[];
12
19
  };
13
20
  /** One windowed record: the mapped aggregates over the window centered at a row. */
14
21
  export type WindowRecord = Record<string, ColumnValue | undefined>;
@@ -55,6 +55,50 @@ export function computeRollingByColumn(store, axisColName, spec, columnSpecs) {
55
55
  // the whole sweep — this is what makes it O(n) rather than O(n · window).
56
56
  const states = columnSpecs.map((s) => rollingStateFor(s.reducer));
57
57
  const specCount = columnSpecs.length;
58
+ // `at` mode (estela F-rolling-by-row): evaluate at explicit ascending centers
59
+ // (e.g. a chart's display grid) rather than at every input row. One record per
60
+ // center; the two-pointer sweeps the same shared states across the centers, so
61
+ // it stays O(n + at.length). A center with no rows in `±radius` empties the
62
+ // window and yields each reducer's empty value.
63
+ if (spec.at !== undefined) {
64
+ const at = spec.at;
65
+ for (let k = 0; k < at.length; k += 1) {
66
+ if (!Number.isFinite(at[k])) {
67
+ throw new RangeError(`rollingByColumn: at[${k}] is not finite`);
68
+ }
69
+ if (k > 0 && at[k] < at[k - 1]) {
70
+ throw new RangeError('rollingByColumn: at centers must be non-decreasing');
71
+ }
72
+ }
73
+ const atOut = new Array(at.length);
74
+ let alo = 0;
75
+ let ahi = 0; // window holds compact positions [alo, ahi)
76
+ for (let k = 0; k < at.length; k += 1) {
77
+ const c = at[k];
78
+ const wlo = c - radius;
79
+ const whi = c + radius;
80
+ while (ahi < m && ax[ahi] <= whi) {
81
+ const r = idx[ahi];
82
+ for (let cc = 0; cc < specCount; cc += 1) {
83
+ states[cc].add(r, sourceCols[cc].read(r));
84
+ }
85
+ ahi += 1;
86
+ }
87
+ while (alo < ahi && ax[alo] < wlo) {
88
+ const r = idx[alo];
89
+ for (let cc = 0; cc < specCount; cc += 1) {
90
+ states[cc].remove(r, sourceCols[cc].read(r));
91
+ }
92
+ alo += 1;
93
+ }
94
+ const rec = {};
95
+ for (let cc = 0; cc < specCount; cc += 1) {
96
+ rec[columnSpecs[cc].output] = states[cc].snapshot();
97
+ }
98
+ atOut[k] = rec;
99
+ }
100
+ return atOut;
101
+ }
58
102
  const out = new Array(n);
59
103
  let lo = 0;
60
104
  let hi = 0; // the window currently holds compact positions [lo, hi)