pond-ts 0.41.0 → 0.43.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,12 +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
- 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.41.0...HEAD
6
+ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
7
+ `@pond-ts/fit`, and `@pond-ts/financial` — release together under a single `v*`
8
+ tag, so this file covers them all. Pre-1.0: minor bumps may include new features
9
+ and type-level changes; patch bumps are strictly additive.
10
+
11
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.43.0...HEAD
12
+ [0.43.0]: https://github.com/pjm17971/pond-ts/compare/v0.42.0...v0.43.0
13
+ [0.42.0]: https://github.com/pjm17971/pond-ts/compare/v0.41.0...v0.42.0
12
14
  [0.41.0]: https://github.com/pjm17971/pond-ts/compare/v0.40.0...v0.41.0
13
15
  [0.40.0]: https://github.com/pjm17971/pond-ts/compare/v0.39.0...v0.40.0
14
16
  [0.39.0]: https://github.com/pjm17971/pond-ts/compare/v0.38.0...v0.39.0
@@ -39,6 +41,155 @@ patch bumps are strictly additive.
39
41
 
40
42
  ## [Unreleased]
41
43
 
44
+ ## [0.43.0] — 2026-07-11
45
+
46
+ The **categorical x-axis** release: a first-class ordinal band scale (ticker /
47
+ account / expiry on x — the transpose view of a time series), plus the charts
48
+ **interaction** wave that landed after v0.42.0 was cut — the region cursor and
49
+ its drag-to-select gesture, per-bin band colour, and annotation edges that snap
50
+ to session boundaries.
51
+
52
+ ### Added
53
+
54
+ - **`@pond-ts/charts`: a first-class categorical x-axis.** `<BarChart
55
+ categories={[{ label, value }]}>` draws one bar per category on an ordinal
56
+ **band scale** (the transpose view's "columns on x" — ticker / account / zone
57
+ on x). The container infers `xKind:'category'` and builds a `scaleBand` over the
58
+ labels; `<CategoryAxis>` ticks once per category. Colour per category via
59
+ `binColors`; selection reports the category name. **Negative** category values
60
+ draw below the baseline (the P&L / delta case) — a single-series category bar
61
+ honours its sign. New exports `scaleBand` / `ScaleBand`, `CategoryAxis`,
62
+ `categoryStack` / `CategoryDatum`. Additive — a new x-kind alongside time /
63
+ value; existing charts are unchanged. First slice of the categorical-axis RFC
64
+ (`docs/rfcs/categorical-axis.md`, Phase 1).
65
+ - **`@pond-ts/charts`: `transposeRow`** — read one **row** of a wide `TimeSeries`
66
+ **across** into `{ label, value }[]` for `<BarChart categories>`: the schema's
67
+ numeric columns (a `pivotByGroup` output's per-group columns, a term
68
+ structure's per-expiry columns) become the categories at one instant. Pick the
69
+ row with `at` (`'last'` — the head/live row — by default; `'first'`, an index,
70
+ or `{ time }`); bound / order the set with `columns`.
71
+ - **`@pond-ts/charts`: stable per-column selection identity.** `SelectInfo` gains
72
+ an optional **`mark`** — a stable per-mark identity within a layer. A categorical
73
+ bar reports its **column name** as `mark`, and a controlled `selected` echo /
74
+ the highlight match key on `(id, mark)`, so a pinned selection survives a column
75
+ reorder (the slot index doesn't; the name does). Additive — `mark` is
76
+ `undefined` for a time / value bar (whose sample `key` is already its identity).
77
+ Plus a category-axis **label policy**: a dense axis thins (keeps every k-th) and
78
+ ellipsis-truncates its labels so they stay legible while every bar draws.
79
+ - **`@pond-ts/charts`: region cursor (`cursor="region"`).** A shaded **band**
80
+ highlights the bucket under the pointer, bucketed by a new **`cursorSequence`**
81
+ prop — a `Sequence` (`Sequence.every('15m')`, `Sequence.calendar('week')`)
82
+ realized over the view, or a `BoundedSequence` (a `TradingCalendar`'s
83
+ `sessionSequence()` / `barSequence()`) used as-is. The band maps through the x
84
+ scale, so on a trading-time axis the closed part of a bucket collapses (crops
85
+ to live sessions). Time-axis only (a no-op on a value axis). (#409, #413)
86
+ - **`@pond-ts/charts`: draggable region cursor → one-shot select.** Opt-in
87
+ **`onRegionSelect?: (range: TimeRange) => void`** makes the region cursor
88
+ draggable: the band extends bucket by bucket and fires **once** on release
89
+ with the selected `[start, end)` `TimeRange` (the cursor doesn't keep it —
90
+ typical use is to zoom the view). With **no `cursorSequence`** it degenerates
91
+ to a hover **line** + **freeform** drag. **`regionSelectModifier="shift"`**
92
+ resolves the gesture conflict with `panZoom` (plain drag pans, shift-drag
93
+ selects); omitted, a region-drag preempts pan. (#416)
94
+ - **`@pond-ts/charts`: `binColors` — per-bin colour for single-series bars.**
95
+ `<BarChart binColors={[...]}>` colours each bar/band segment individually (one
96
+ colour per bin, in order), the single-series analog of the stacked `colors`
97
+ prop — used by the category axis (colour per category) and any single-series
98
+ band chart. (#408)
99
+ - **`@pond-ts/charts`: annotation edges snap to session boundaries.** When a
100
+ `<ChartContainer>` carries a trading calendar (disjoint x axis), dragging a
101
+ `<Region>` edge (or creating one) snaps to the nearest **session boundary**
102
+ rather than raw wall-clock, so a drawn span aligns with real market sessions.
103
+ (#410)
104
+
105
+ ### Fixed
106
+
107
+ - **`@pond-ts/charts`: region body-move no longer distorts across a session
108
+ boundary.** On a trading-time (discontinuous) axis, dragging a `<Region>`
109
+ annotation by its body now translates it rigidly in pixel space, so the box
110
+ keeps its width as it crosses a collapsed gap (it previously applied one
111
+ value-delta to both edges, which stretched the box in the different
112
+ rate-contexts either side of a session boundary). No-op on a continuous axis.
113
+ (#405)
114
+
115
+ ## [0.42.0] — 2026-07-10
116
+
117
+ The **trading-calendar** release: a new `@pond-ts/financial` package (its first
118
+ publish) and a discontinuous **trading-time x axis** in `@pond-ts/charts` that
119
+ collapses closed-market time (weekends, holidays, overnight, lunch breaks).
120
+
121
+ ### Added
122
+
123
+ - **`@pond-ts/financial` — new package (first release).** A calendar/analytics
124
+ layer on `pond-ts` (peer dep; ESM, no React; `@js-temporal/polyfill` for
125
+ DST-correct session generation):
126
+ - **`TradingCalendar`** — `fromSessions` (explicit schedule) and `fromRules`
127
+ (weekmask / holidays / early-closes / breaks, DST-correct via Temporal);
128
+ query surface (`sessionOn`, `sessionContaining`, `isOpen`, `sessionsInRange`,
129
+ `nextSession`, `previousSession`).
130
+ - **Bucketing seam** — `sessionSequence()` / `barSequence(period)` return a
131
+ `BoundedSequence` that flows straight through `aggregate` / `materialize`,
132
+ so every bucket is a real trading session/bar (no weekend/holiday buckets,
133
+ no bucket spanning a closure). Zero core edits.
134
+ - **`tagSessions(series, { column?, stamped? })`** — appends a session-id
135
+ column (`number | undefined`) for `partitionBy` so stateful ops don't bridge
136
+ a session boundary. `stamped: 'close'` bins a bar stamped at its close into
137
+ the closing session (`(open, close]`) for OHLC feeds.
138
+ - **`DiscontinuityProvider`** — the d3fc-style 5-method provider
139
+ (`clampUp`/`clampDown`/`distance`/`offset`/`copy` + optional `boundaries`);
140
+ `identityDiscontinuity`, `weekendSkip` (bundled reference), and
141
+ `segmentDiscontinuity(segments, { spacing })`.
142
+ - **`TradingCalendar.discontinuities({ range?, spacing?, period? })`** — the
143
+ chart-ready provider; `spacing: 'proportional'` (default, true-time) or
144
+ `'uniform'` (equal-width per session/bar, the TradingView ordinal look).
145
+ - **`@pond-ts/charts`: trading-time x axis.** Pass a `@pond-ts/financial`
146
+ provider (structurally — charts never imports that package) to collapse
147
+ closed-market gaps:
148
+ - **`ChartContainer` `discontinuities` prop** (low-level) and **`calendar` +
149
+ `spacing` props** (high-level sugar; `calendar` is a structural
150
+ `TradingCalendarLike`, `spacing` defaults to proportional).
151
+ - **`scaleTradingTime`** — a d3-scale-shaped discontinuous time scale; ticks
152
+ coarsen to a **calendar grain** (week/month/quarter/year starts) with
153
+ date/year labels, and **session dividers** draw at the collapse points
154
+ (`theme.axis.sessionDivider`), aligned with the labels.
155
+ - `Charts/TradingTimeAxis` stories (weekend/holiday/half-day/intraday,
156
+ continuous-vs-trading, daily-months, proportional-vs-uniform).
157
+ - **`@pond-ts/charts`: first-class histograms.** `<BarChart>` gains **stacking**
158
+ — a group-by dimension → stacked segments, from a wide series (`columns`), a
159
+ `Map<group, TimeSeries>` (the `partitionBy().aggregate().toMap()` shape), or a
160
+ `byColumn` `bins` array; per-group colour via `colors` or theme roles — and an
161
+ **`orientation`** prop (`'vertical'` default | `'horizontal'`, bars grow right
162
+ with the bins on a y band axis). New readers `stacksFromGroups` /
163
+ `stacksFromColumns` / `stacksFromBins` plus `StackedBarSeries` / `BinRecord` /
164
+ `Orientation` types. All data generation composes from existing operators
165
+ (`aggregate` / `byColumn` / `partitionBy`); no core changes. Guide: How-to
166
+ guides → Histograms. (#401)
167
+ - **`@pond-ts/charts`:** selection now has a stable series identity. `SelectInfo`
168
+ carries an `id`, and `BarChart` / `ScatterChart` take an optional `id` prop —
169
+ the series identity used for selection + hover. An `id` **gates interactivity**:
170
+ a layer is selectable/hoverable only when given one (a layer with no `id`
171
+ renders and reads out but can't be selected). A dev-warning fires when
172
+ `selected`/`onSelect` is wired but no layer carries an `id`. First slice of the
173
+ selection RFC (`docs/rfcs/selection.md`, Amendments 2–3).
174
+ - **`pond-ts`: `bin(W, 'minMaxFirstLast')`** — the four-channel M4 downsampling
175
+ reducer (per-bin min/max/first/last, validity-aware, chunked-delegating); the
176
+ foundation for the charts decimator wave.
177
+ - **`pond-ts`: `binBy`** — key-domain bucketed reduction (the M4 gappy-data
178
+ decimation path).
179
+
180
+ ### Changed
181
+
182
+ - **BREAKING (`@pond-ts/charts`):** `SelectInfo` gained a required `id` field
183
+ (`{ id, key, value, color, label }`) — `id` is the selection identity, `key` /
184
+ `value` are now click provenance. Code that constructs a `SelectInfo` by hand
185
+ must add `id`, and selection equality/dedup now keys on `id`, not the sample
186
+ `begin`.
187
+ - **BREAKING (`@pond-ts/charts`):** `BarChart` / `ScatterChart` selection now
188
+ requires an explicit `id` prop — the previous implicit `as ?? column` selection
189
+ identity is gone. A selectable bar/scatter layer must add `id` (e.g.
190
+ `<BarChart series={s} column="v" id="v" />`); without it the layer is
191
+ display-only.
192
+
42
193
  ## [0.41.0] — 2026-07-06
43
194
 
44
195
  ### Added
package/dist/column.d.ts CHANGED
@@ -69,9 +69,14 @@ export type PublicColumnForKind<K extends 'number' | 'boolean' | 'string' | 'arr
69
69
  * convention (e.g. `'p95'`, `'p99.9'`) where `q` is in `[0, 100]` —
70
70
  * runtime check enforces the range. The fused `'minMax'` is special:
71
71
  * it returns a two-channel `{ lo, hi }` rather than a single
72
- * `Float64Array`.
72
+ * `Float64Array`. The fused `'minMaxFirstLast'` extends that to four
73
+ * channels `{ lo, hi, first, last }` — the M4 downsampling reducer
74
+ * (Jugel et al., VLDB 2014): `first`/`last` are the first/last
75
+ * defined value in each bin, which keep a decimated polyline
76
+ * continuous across bin boundaries (the line enters at `first`,
77
+ * exits at `last`).
73
78
  */
74
- export type BinReducerName = 'min' | 'max' | 'sum' | 'mean' | 'stdev' | 'median' | 'count' | `p${number}` | 'minMax';
79
+ export type BinReducerName = 'min' | 'max' | 'sum' | 'mean' | 'stdev' | 'median' | 'count' | `p${number}` | 'minMax' | 'minMaxFirstLast';
75
80
  /**
76
81
  * Output type for `Float64Column.bin(W, reducer)`. Narrows
77
82
  * on the reducer name so consumers don't need a runtime cast:
@@ -84,6 +89,12 @@ export type BinReducerName = 'min' | 'max' | 'sum' | 'mean' | 'stdev' | 'median'
84
89
  * — stride-1 access per channel matches the canvas-2D inner draw
85
90
  * loop's per-pixel `lo[px]` / `hi[px]` reads. Empty bins on both
86
91
  * channels are `NaN`.
92
+ * - `'minMaxFirstLast'` produces the four-channel
93
+ * `{ lo, hi, first, last }` (all `Float64Array(W)`) — the M4
94
+ * reducer. `first`/`last` carry the bin's first/last defined
95
+ * value so a decimated line stays continuous at bin seams. Empty
96
+ * bins are `NaN` on all four channels (canvas-friendly: a `NaN`
97
+ * vertex breaks the sub-path, the correct "no data here" visual).
87
98
  *
88
99
  * Generalized for future multi-point reducers (e.g. LTTB) — those
89
100
  * would land as their own output shape (e.g. `{ keys, values }`
@@ -92,6 +103,11 @@ export type BinReducerName = 'min' | 'max' | 'sum' | 'mean' | 'stdev' | 'median'
92
103
  export type BinOutput<R extends BinReducerName> = R extends 'minMax' ? {
93
104
  lo: Float64Array;
94
105
  hi: Float64Array;
106
+ } : R extends 'minMaxFirstLast' ? {
107
+ lo: Float64Array;
108
+ hi: Float64Array;
109
+ first: Float64Array;
110
+ last: Float64Array;
95
111
  } : Float64Array;
96
112
  /**
97
113
  * Public key-column class for a single first-column kind.
@@ -239,8 +255,9 @@ declare module './columnar/column.js' {
239
255
  * and §8 worked example.
240
256
  *
241
257
  * Returns a fresh `Float64Array(bins)` for scalar reducers,
242
- * or `{ lo: Float64Array(bins); hi: Float64Array(bins) }` for
243
- * `'minMax'`. An earlier revision exposed an optional
258
+ * `{ lo, hi }` for `'minMax'`, or the four-channel
259
+ * `{ lo, hi, first, last }` for `'minMaxFirstLast'` (the M4
260
+ * downsampling reducer). An earlier revision exposed an optional
244
261
  * `{ out }` parameter for buffer reuse; the
245
262
  * chart-experiment M2.3 measurement showed it contributed
246
263
  * nothing on top of the chart-side Y-from-bin-output
@@ -250,6 +267,16 @@ declare module './columnar/column.js' {
250
267
  * V8 nursery GC budget.
251
268
  */
252
269
  bin<R extends BinReducerName>(bins: number, reducer: R): BinOutput<R>;
270
+ /**
271
+ * Key-domain bucketed reduction — buckets by explicit sorted
272
+ * `edges` over a parallel monotonic `key`, rather than by equal
273
+ * index count. The M4 decimator's correct form for irregular /
274
+ * gappy data (a bin maps to a range of the key axis, not of
275
+ * indices). Same reducers and output shapes as `bin`. O(n + W).
276
+ * See the impl JSDoc for the merge-walk and edge/precondition
277
+ * semantics.
278
+ */
279
+ binBy<R extends BinReducerName>(key: ArrayLike<number>, edges: ArrayLike<number>, reducer: R): BinOutput<R>;
253
280
  }
254
281
  interface BooleanColumn {
255
282
  at(i: number): boolean | undefined;
@@ -320,6 +347,7 @@ declare module './columnar/chunked-column.js' {
320
347
  */
321
348
  toFloat64Array(): Float64Array;
322
349
  bin<R extends BinReducerName>(bins: number, reducer: R): BinOutput<R>;
350
+ binBy<R extends BinReducerName>(key: ArrayLike<number>, edges: ArrayLike<number>, reducer: R): BinOutput<R>;
323
351
  }
324
352
  interface ChunkedBooleanColumn {
325
353
  at(i: number): boolean | undefined;
package/dist/column.js CHANGED
@@ -233,43 +233,117 @@ Float64Column.prototype.bin = function (bins, reducer) {
233
233
  throw new RangeError(`Float64Column.bin: bins must be a positive integer, got ${bins}`);
234
234
  }
235
235
  const n = this.length;
236
+ // Equal-width **index** boundaries: bucket b spans the index range
237
+ // [bounds[b], bounds[b + 1]). `Math.floor((b * n) / bins)` is the
238
+ // same split the per-bin loops used inline before the boundary
239
+ // derivation was factored out (see `reduceFloat64ByBounds`).
240
+ const bounds = new Int32Array(bins + 1);
241
+ for (let b = 1; b < bins; b += 1)
242
+ bounds[b] = Math.floor((b * n) / bins);
243
+ bounds[bins] = n;
244
+ return reduceFloat64ByBounds(this, bounds, reducer, 'bin');
245
+ };
246
+ /**
247
+ * Key-domain bucketed reduction — the M4 decimator's correct form
248
+ * for **irregular / gappy** data. Where `bin` splits by equal index
249
+ * count (right only for uniformly spaced samples — see its
250
+ * uniform-sampling precondition), `binBy` buckets by an explicit set
251
+ * of sorted `edges` over a parallel monotonic `key`, so a bin maps
252
+ * to a range of the **key axis** (pixel-time, or a value axis), not
253
+ * a range of indices. Chart use:
254
+ *
255
+ * ```ts
256
+ * // edges = W+1 device-pixel column boundaries in time, unioned
257
+ * // with any gap edges (so no bucket spans a gap).
258
+ * const { lo, hi, first, last } = valueCol.binBy(
259
+ * timeKeys, edges, 'minMaxFirstLast',
260
+ * );
261
+ * ```
262
+ *
263
+ * Because `key` is non-decreasing, every key-interval maps to a
264
+ * **contiguous** index range, so `binBy` derives per-bucket index
265
+ * boundaries in one O(n + W) merge walk and then runs the exact same
266
+ * per-bucket reducer engine as `bin` — only the boundary derivation
267
+ * differs. Bucket `b` covers samples with `edges[b] <= key <
268
+ * edges[b + 1]`; the **final** upper edge is inclusive so a sample
269
+ * exactly at the max edge lands in the last bucket. An empty bucket
270
+ * (a gap with no samples) reduces to `NaN` — the canvas
271
+ * sub-path-break sentinel, same as `bin`'s empty bins.
272
+ *
273
+ * **Preconditions** (documented, not re-asserted per frame — this is
274
+ * a per-pixel hot path): `key` is non-decreasing and `key.length ===
275
+ * this.length`; `edges` is ascending. A non-ascending `edges` is
276
+ * caught (cheap, once per bucket); a non-monotonic `key` is the
277
+ * caller's responsibility (the chart adapter and `byValue` both
278
+ * guarantee it by construction).
279
+ *
280
+ * Reducers and output shapes are identical to `bin` (`'minMax'` →
281
+ * `{ lo, hi }`, `'minMaxFirstLast'` → `{ lo, hi, first, last }`,
282
+ * scalars → `Float64Array(W)`).
283
+ */
284
+ Float64Column.prototype.binBy = function (key, edges, reducer) {
285
+ const n = this.length;
286
+ if (key.length !== n) {
287
+ throw new RangeError(`Float64Column.binBy: key length ${key.length} must equal column length ${n}`);
288
+ }
289
+ const W = edges.length - 1;
290
+ if (W < 1) {
291
+ throw new RangeError(`Float64Column.binBy: edges must have at least 2 entries (1 bucket), got length ${edges.length}`);
292
+ }
293
+ // Merge-walk the monotonic key against the sorted edges → per-bucket
294
+ // index boundaries. `i` only moves forward (both key and edges
295
+ // ascend), so this is a single O(n + W) pass. bounds[b] = first
296
+ // index with key >= edges[b]; the final bound uses key > edges[W]
297
+ // so the max edge is inclusive.
298
+ const bounds = new Int32Array(W + 1);
299
+ let i = 0;
300
+ let prevEdge = edges[0];
301
+ for (let b = 0; b <= W; b += 1) {
302
+ const e = edges[b];
303
+ if (b > 0 && e < prevEdge) {
304
+ throw new RangeError(`Float64Column.binBy: edges must be ascending; edges[${b}]=${e} < edges[${b - 1}]=${prevEdge}`);
305
+ }
306
+ prevEdge = e;
307
+ if (b < W) {
308
+ while (i < n && key[i] < e)
309
+ i += 1;
310
+ }
311
+ else {
312
+ while (i < n && key[i] <= e)
313
+ i += 1;
314
+ }
315
+ bounds[b] = i;
316
+ }
317
+ return reduceFloat64ByBounds(this, bounds, reducer, 'binBy');
318
+ };
319
+ /**
320
+ * Shared per-bucket reduction engine behind `bin` and `binBy`. Given
321
+ * ascending index boundaries (`bounds[b]..bounds[b + 1]` is bucket
322
+ * b's index range) and a reducer, produces the per-bucket output.
323
+ * `bin` supplies equal-index boundaries; `binBy` supplies
324
+ * key-edge-derived boundaries — the reduction is identical either
325
+ * way. `method` names the caller for error messages.
326
+ *
327
+ * The three fused-`minMax*` paths hoist the validity / finiteness
328
+ * branch **outside** the bucket loop (it's the same for every
329
+ * bucket), which is why they're inlined here rather than dispatching
330
+ * through `sliceByRange().minMax()` per bucket.
331
+ */
332
+ function reduceFloat64ByBounds(col, bounds, reducer, method) {
333
+ const W = bounds.length - 1;
334
+ const values = col._values;
335
+ const validity = col.validity;
236
336
  // ─── Fused minMax — two-channel output ─────────────────────────
237
- //
238
- // Special-cased because it's the chart's per-pixel hot path and
239
- // its output shape ({lo, hi}) doesn't fit the scalar Float64Array
240
- // mold. Each bin walks once, fuses both reductions, writes both
241
- // channels — half the memory traffic of `[min(), max()]`.
242
337
  if (reducer === 'minMax') {
243
- const lo = new Float64Array(bins);
244
- const hi = new Float64Array(bins);
245
- // Inlined per-bin walk over `this._values[start..end)` rather
246
- // than `this.sliceByRange(start, end).minMax()` per bin. The
247
- // sliced version allocated a Float64Column + Float64Array
248
- // subarray view + optionally a validity-bitmap slice on every
249
- // bin, then dispatched into the minMax method. Inlining
250
- // hoists the validity branch (it's the same for every bin)
251
- // and removes the per-bin construction overhead.
252
- //
253
- // Measured wins (`scripts/perf-bin.mjs`, median of 30 × 3
254
- // runs): ~23% on fine-bins (N=100k, W=1024) where per-bin
255
- // overhead is the biggest fraction of work; ~9% on chart-
256
- // typical (N=1M, W=1024); within noise (~5%) on N=10M where
257
- // the inner buffer walk dominates.
258
- //
259
- // The inner-loop math is byte-identical to
260
- // `Float64Column.prototype.minMax` (same NaN parity, same
261
- // empty handling) — just over a (start, end) slice of the
262
- // underlying buffer.
263
- const values = this._values;
264
- const validity = this.validity;
265
- if (!this.allFinite) {
266
- // Guarded path: skip non-finite values (reducer non-finite policy —
267
- // docs/notes/reducer-nan-policy.md), matching `minMax()` / the reducer
268
- // min/max. A bin with no finite value (all-missing or all-non-finite)
269
- // keeps the NaN empty-bin sentinel.
270
- for (let b = 0; b < bins; b += 1) {
271
- const start = Math.floor((b * n) / bins);
272
- const end = Math.floor(((b + 1) * n) / bins);
338
+ const lo = new Float64Array(W);
339
+ const hi = new Float64Array(W);
340
+ if (!col.allFinite) {
341
+ // Guarded path: skip non-finite values (reducer non-finite
342
+ // policy docs/notes/reducer-nan-policy.md). A bucket with no
343
+ // finite value keeps the NaN empty-bucket sentinel.
344
+ for (let b = 0; b < W; b += 1) {
345
+ const start = bounds[b];
346
+ const end = bounds[b + 1];
273
347
  let loVal;
274
348
  let hiVal;
275
349
  for (let i = start; i < end; i += 1) {
@@ -301,9 +375,9 @@ Float64Column.prototype.bin = function (bins, reducer) {
301
375
  return { lo, hi };
302
376
  }
303
377
  if (validity === undefined) {
304
- for (let b = 0; b < bins; b += 1) {
305
- const start = Math.floor((b * n) / bins);
306
- const end = Math.floor(((b + 1) * n) / bins);
378
+ for (let b = 0; b < W; b += 1) {
379
+ const start = bounds[b];
380
+ const end = bounds[b + 1];
307
381
  if (end <= start) {
308
382
  lo[b] = NaN;
309
383
  hi[b] = NaN;
@@ -321,12 +395,10 @@ Float64Column.prototype.bin = function (bins, reducer) {
321
395
  }
322
396
  }
323
397
  else {
324
- // Validity path — use isDefined directly on the original
325
- // bitmap with the original index. Avoids the per-bin
326
- // validity-bitmap slice that sliceByRange would have done.
327
- for (let b = 0; b < bins; b += 1) {
328
- const start = Math.floor((b * n) / bins);
329
- const end = Math.floor(((b + 1) * n) / bins);
398
+ // Validity path — isDefined directly on the original bitmap.
399
+ for (let b = 0; b < W; b += 1) {
400
+ const start = bounds[b];
401
+ const end = bounds[b + 1];
330
402
  if (end <= start) {
331
403
  lo[b] = NaN;
332
404
  hi[b] = NaN;
@@ -336,7 +408,6 @@ Float64Column.prototype.bin = function (bins, reducer) {
336
408
  while (i < end && !validity.isDefined(i))
337
409
  i += 1;
338
410
  if (i >= end) {
339
- // All cells in this bin are undefined.
340
411
  lo[b] = NaN;
341
412
  hi[b] = NaN;
342
413
  continue;
@@ -356,39 +427,154 @@ Float64Column.prototype.bin = function (bins, reducer) {
356
427
  }
357
428
  return { lo, hi };
358
429
  }
430
+ // ─── Fused minMaxFirstLast — four-channel output (M4) ──────────
431
+ //
432
+ // Extends fused minMax with per-bucket first/last (first/last
433
+ // defined-and-finite value), so a downsampled polyline enters each
434
+ // bucket at `first` and leaves at `last` — continuous across bucket
435
+ // seams. All four channels agree on which cells count; an empty
436
+ // bucket is NaN on all four.
437
+ if (reducer === 'minMaxFirstLast') {
438
+ const lo = new Float64Array(W);
439
+ const hi = new Float64Array(W);
440
+ const first = new Float64Array(W);
441
+ const last = new Float64Array(W);
442
+ if (!col.allFinite) {
443
+ for (let b = 0; b < W; b += 1) {
444
+ const start = bounds[b];
445
+ const end = bounds[b + 1];
446
+ let loVal;
447
+ let hiVal = 0;
448
+ let firstVal = 0;
449
+ let lastVal = 0;
450
+ for (let i = start; i < end; i += 1) {
451
+ if (validity !== undefined && !validity.isDefined(i))
452
+ continue;
453
+ const x = values[i];
454
+ if (!Number.isFinite(x))
455
+ continue;
456
+ if (loVal === undefined) {
457
+ loVal = x;
458
+ hiVal = x;
459
+ firstVal = x;
460
+ }
461
+ else {
462
+ if (x < loVal)
463
+ loVal = x;
464
+ if (x > hiVal)
465
+ hiVal = x;
466
+ }
467
+ lastVal = x;
468
+ }
469
+ if (loVal === undefined) {
470
+ lo[b] = NaN;
471
+ hi[b] = NaN;
472
+ first[b] = NaN;
473
+ last[b] = NaN;
474
+ }
475
+ else {
476
+ lo[b] = loVal;
477
+ hi[b] = hiVal;
478
+ first[b] = firstVal;
479
+ last[b] = lastVal;
480
+ }
481
+ }
482
+ return { lo, hi, first, last };
483
+ }
484
+ if (validity === undefined) {
485
+ for (let b = 0; b < W; b += 1) {
486
+ const start = bounds[b];
487
+ const end = bounds[b + 1];
488
+ if (end <= start) {
489
+ lo[b] = NaN;
490
+ hi[b] = NaN;
491
+ first[b] = NaN;
492
+ last[b] = NaN;
493
+ continue;
494
+ }
495
+ let loVal = values[start];
496
+ let hiVal = loVal;
497
+ for (let i = start + 1; i < end; i += 1) {
498
+ const x = values[i];
499
+ loVal = loVal <= x ? loVal : x;
500
+ hiVal = hiVal >= x ? hiVal : x;
501
+ }
502
+ lo[b] = loVal;
503
+ hi[b] = hiVal;
504
+ first[b] = values[start];
505
+ last[b] = values[end - 1];
506
+ }
507
+ return { lo, hi, first, last };
508
+ }
509
+ for (let b = 0; b < W; b += 1) {
510
+ const start = bounds[b];
511
+ const end = bounds[b + 1];
512
+ if (end <= start) {
513
+ lo[b] = NaN;
514
+ hi[b] = NaN;
515
+ first[b] = NaN;
516
+ last[b] = NaN;
517
+ continue;
518
+ }
519
+ let i = start;
520
+ while (i < end && !validity.isDefined(i))
521
+ i += 1;
522
+ if (i >= end) {
523
+ lo[b] = NaN;
524
+ hi[b] = NaN;
525
+ first[b] = NaN;
526
+ last[b] = NaN;
527
+ continue;
528
+ }
529
+ const firstVal = values[i];
530
+ let loVal = firstVal;
531
+ let hiVal = firstVal;
532
+ let lastVal = firstVal;
533
+ for (i += 1; i < end; i += 1) {
534
+ if (!validity.isDefined(i))
535
+ continue;
536
+ const x = values[i];
537
+ loVal = loVal <= x ? loVal : x;
538
+ hiVal = hiVal >= x ? hiVal : x;
539
+ lastVal = x;
540
+ }
541
+ lo[b] = loVal;
542
+ hi[b] = hiVal;
543
+ first[b] = firstVal;
544
+ last[b] = lastVal;
545
+ }
546
+ return { lo, hi, first, last };
547
+ }
359
548
  // ─── Scalar reducers ──────────────────────────────────────────
360
549
  //
361
- // Dispatch to the registered reducer's reduceColumn fast path
362
- // (PR #153) once per bin. The 'mean' → 'avg' mapping mirrors
363
- // the public Float64Column.mean() shim. Percentile-via-string
364
- // ('p95', 'p99.9', etc.) routes through resolveReducer's
365
- // parsePercentile.
550
+ // Dispatch to the registered reducer's reduceColumn fast path once
551
+ // per bucket. 'mean' → 'avg' mirrors the Float64Column.mean() shim;
552
+ // percentile-via-string ('p95', ) routes through resolveReducer.
366
553
  const internalName = reducer === 'mean' ? 'avg' : reducer;
367
554
  let reducerDef;
368
555
  try {
369
556
  reducerDef = resolveReducer(internalName);
370
557
  }
371
558
  catch {
372
- throw new TypeError(`Float64Column.bin: unknown reducer '${reducer}'`);
559
+ throw new TypeError(`Float64Column.${method}: unknown reducer '${reducer}'`);
373
560
  }
374
561
  if (reducerDef.reduceColumn === undefined) {
375
- throw new TypeError(`Float64Column.bin: reducer '${reducer}' has no reduceColumn fast path`);
562
+ throw new TypeError(`Float64Column.${method}: reducer '${reducer}' has no reduceColumn fast path`);
376
563
  }
377
- const out = new Float64Array(bins);
378
- for (let b = 0; b < bins; b += 1) {
379
- const start = Math.floor((b * n) / bins);
380
- const end = Math.floor(((b + 1) * n) / bins);
564
+ const out = new Float64Array(W);
565
+ for (let b = 0; b < W; b += 1) {
566
+ const start = bounds[b];
567
+ const end = bounds[b + 1];
381
568
  if (end <= start) {
382
- // Preserve mathematical-empty for sum / count (both = 0);
383
- // NaN for reducers whose empty is undefined.
569
+ // Mathematical-empty for sum / count (0); NaN otherwise.
384
570
  out[b] = reducer === 'sum' || reducer === 'count' ? 0 : NaN;
385
571
  continue;
386
572
  }
387
- const result = reducerDef.reduceColumn(this.sliceByRange(start, end));
573
+ const result = reducerDef.reduceColumn(col.sliceByRange(start, end));
388
574
  out[b] = typeof result === 'number' ? result : NaN;
389
575
  }
390
576
  return out;
391
- };
577
+ }
392
578
  // ─── BooleanColumn runtime implementations ───────────────────────
393
579
  BooleanColumn.prototype.at = function (i) {
394
580
  return this.read(i);
@@ -691,6 +877,12 @@ ChunkedFloat64Column.prototype.bin = function (bins, reducer) {
691
877
  // equal-sized chunks at a chart-friendly bin count).
692
878
  return materializeChunkedFloat64(this).bin(bins, reducer);
693
879
  };
880
+ ChunkedFloat64Column.prototype.binBy = function (key, edges, reducer) {
881
+ // v1: materialize then delegate, mirroring `bin`. The key array is
882
+ // parallel to the logical row order, so it needs no per-chunk
883
+ // remap — materialize preserves row order.
884
+ return materializeChunkedFloat64(this).binBy(key, edges, reducer);
885
+ };
694
886
  // ─── ChunkedBooleanColumn runtime implementations ────────────────
695
887
  ChunkedBooleanColumn.prototype.at = function (i) {
696
888
  return this.read(i);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pond-ts",
3
- "version": "0.41.0",
3
+ "version": "0.43.0",
4
4
  "description": "TypeScript-first time series primitives",
5
5
  "license": "MIT",
6
6
  "repository": {