@pond-ts/charts 0.71.0 → 0.73.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/API.md CHANGED
@@ -118,7 +118,8 @@ Value-axis wire types
118
118
  - **Temporal range**: `timeRange()`, `overlaps()`, `contains()`,
119
119
  `intersection()`, `overlapping(range)`, `containedBy(range)`, `trim(range)`,
120
120
  `after()`, `before()`, `within()`, `tail(duration)`
121
- - **Key-type conversion**: `asTime({ at })`, `asTimeRange()`, `asInterval()`
121
+ - **Key-type conversion**: `asTime({ at })`, `asTimeRange()`, `asInterval()`;
122
+ `offsetTime(by)` moves every key by a constant (kind kept)
122
123
  - **Filter/slice**: `filter()`, `sample(strategy)`, `slice(begin, end)`
123
124
  - **Column reshape**: `select()`, `rename()`, `map()`, `mapColumns()`,
124
125
  `withColumn()`, `collapse()`
@@ -161,8 +162,8 @@ Deliberately small — the ordering-based slice of the algebra, no calendar ops
161
162
 
162
163
  | Type group | Names | Source |
163
164
  | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
164
- | Schema contract | `SeriesSchema`, `RowForSchema`, `EventForSchema`, `EventDataForSchema`, `EventKeyForSchema`, `TimeSeriesInput`, `TimeSeriesJsonInput` | `packages/core/src/schema/index.ts` |
165
- | Aggregation specs | `AggregateReducer`, `AggregateMap`, `AggregateOutputMap`, `AggregateSchema`, `BinReducerName`, `BinOutput` | `packages/core/src/schema/index.ts`, `packages/core/src/column.ts` |
165
+ | Schema contract | `SeriesSchema`, `RowForSchema`, `EventForSchema`, `EventDataForSchema`, `EventKeyForSchema`, `TimeSeriesInput`, `TimeSeriesJsonInput`, `BatchOrderingMode` | `packages/core/src/schema/index.ts` |
166
+ | Aggregation specs | `AggregateReducer`, `AggregateMap`, `AggregateSchema`, `BinReducerName`, `BinOutput` | `packages/core/src/schema/index.ts`, `packages/core/src/column.ts` |
166
167
  | Operation schemas | `RollingSchema`, `RollingAlignment`, `AlignSchema`, `DiffSchema`, `SmoothSchema`, `SmoothMethod`, `FillStrategy`, `FillMapping` | `packages/core/src/schema/index.ts` |
167
168
  | Column/data kinds | `Column`, `KeyColumn`, `ColumnKind`, `ScalarKind`, `ScalarValue`, `ColumnValue`, `ArrayValue`, `ValidityBitmap` | `packages/core/src/columnar/` |
168
169
  | JSON wire format | `JsonRowFormat`, `JsonRowForSchema`, `JsonObjectRowForSchema`, `JsonValueForKind`, `JsonTimestampInput`, `JsonTimeRangeInput`, `JsonIntervalInput` | `packages/core/src/schema/index.ts` |
@@ -723,7 +724,7 @@ the default for pipelines known at authoring time — see the package README.
723
724
  | Port declaration | `port<T>({ equals, defaultValue })`; types `PortSpec`, `PortSpecMap`, `PortValue`, `PortValues` | `packages/process/src/types.ts` |
724
725
  | Sources | `source<T>()` → `SourceNode` (`set()`), `fromLive(liveSource)` → `LiveSourceNode` (`dispose()`); `GraphSource` (bind contract — looser than core's `LiveSource`, accepts `LiveAggregation`), `SnapshotSource`, `NoInputs` | `packages/process/src/source.ts` |
725
726
  | Graph view | `Graph` (`Graph.from(...roots)`, `nodes`, `order()`, `edges()`, `toJSON()`); types `GraphEdge`, `GraphJson`, `GraphNodeJson`, `GraphEdgeJson` | `packages/process/src/graph.ts` |
726
- | Range output buffers | `prepareRange(length, keep, prior)` → `RangeOutput` (`values`, `bits`, `set`, `clear`) carrying `[0, keep)` forward as blocks — values **and** validity; `sealRange(out, length)` → `Float64Column`; `validityByteCount`. Reached from an op as `ctx.out[n]` | `packages/process/src/column.ts` |
727
+ | Range output buffers | `prepareRange(length, keep, prior)` → `RangeOutput` (`values`, `set`, `clear`; a gap is `NaN` in `values`) carrying `[0, keep)` forward as one block copy; `sealRange(out, length)` → `Float64Column`. Reached from an op as `ctx.out[n]` | `packages/process/src/column.ts` |
727
728
  | Ranged recompute | `graph.setSourceFrom(series, changedFrom)` — declares which row first changed; `graph.recomputes` → `{ ranged, full }`. An op opts in with `OpDef.runRange(ctx)`, receiving `{ from, to, previous, previousView, out }` (type `RangeContext`) — write into `out` and return nothing for the block path alongside the usual context. Requires `lookback`. Falls back to a full `run` whenever anything is missing | `packages/process/src/plan/graph.ts` |
728
729
  | Node budget | `bind(series, { registry, budgetBytes })` — engine-wide cap on retained node values, LRU, enforced after each `run`; `graph.retainedBytes` / `graph.evictions` / `graph.enforceBudget()`. Unbounded when omitted. Skips a node whose consumer still holds its outlet | `packages/process/src/plan/graph.ts` |
729
730
  | Plan history | `requiredHistory(registry, plan)` → `{ known, rows?, undeclared, byOp }` — the minimum safe tail in rows, folded from per-op `OpDef.lookback`. Sums along nesting, maxes across siblings. `known: false` names ops with no declared lookback rather than defaulting to zero (type `HistoryResult`) | `packages/process/src/plan/history.ts` |
package/CHANGELOG.md CHANGED
@@ -8,7 +8,9 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
8
8
  under a single `v*` tag, so this file covers them all. Pre-1.0: minor bumps may
9
9
  include new features and type-level changes; patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pond-ts/pond/compare/v0.71.0...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.73.0...HEAD
12
+ [0.73.0]: https://github.com/pond-ts/pond/compare/v0.72.0...v0.73.0
13
+ [0.72.0]: https://github.com/pond-ts/pond/compare/v0.71.0...v0.72.0
12
14
  [0.71.0]: https://github.com/pond-ts/pond/compare/v0.70.0...v0.71.0
13
15
  [0.70.0]: https://github.com/pond-ts/pond/compare/v0.69.0...v0.70.0
14
16
  [0.69.0]: https://github.com/pond-ts/pond/compare/v0.68.0...v0.69.0
@@ -74,6 +76,334 @@ include new features and type-level changes; patch bumps are strictly additive.
74
76
 
75
77
  ## [Unreleased]
76
78
 
79
+ ## [0.73.0] — 2026-10-12
80
+
81
+ ### Added
82
+
83
+ - **core**: **`ordering: 'strict' | 'reorder' | 'clamp'` on every loader**
84
+ (`new TimeSeries`, `TimeSeries.fromJSON` / `fromColumns` / `fromArrow`,
85
+ `ValueSeries.fromJSON` / `fromColumns` / `fromArrow`), with the new
86
+ `BatchOrderingMode` type exported. Same option name and vocabulary as
87
+ `LiveSeries`' `ordering` (`'strict'` / `'reorder'`), so one concept has one
88
+ name across batch and live. `'strict'` is the default (unchanged);
89
+ `'reorder'` is what `sort: true` did. **`'clamp'`** is new: rows stay in place and a key
90
+ earlier than its predecessor's takes that key (the running max), so a noisy
91
+ feed's lone backwards timestamp lands on its neighbour instead of failing the
92
+ ingest or being moved. Values are untouched, ties pass through, non-finite
93
+ keys are still rejected, value-column `Float64Array`s stay zero-copy, and a
94
+ caller's key buffer is never mutated. `time` keys and `ValueSeries` axes
95
+ only — a `timeRange` / `interval` key throws under `'clamp'`. [PND-INGEST]
96
+
97
+ - `pond-ts`: **`series.offsetTime(by)` moves every key by a constant** —
98
+ milliseconds or a duration string, negative for earlier
99
+ (`yesterday.offsetTime('1d')` overlays yesterday on today; `'1d'` is a fixed
100
+ 24 hours, not a calendar day). The key kind is kept; a `timeRange` /
101
+ `interval` key moves both edges and keeps its labels.
102
+ Value columns are shared, not copied, so the cost is one pass over the key:
103
+ 0.26 ms on 97k rows × 118 columns, where rebuilding the series through its
104
+ rows took ~990 ms (`packages/core/scripts/perf-offset-time.mjs`). `0` returns
105
+ the series itself; a non-finite or unparseable offset throws.
106
+
107
+ - **charts**: **`xOffset` on `<LineChart>`, `<AreaChart>` and `<BandChart>`
108
+ draws each row a fixed time after its key**, without touching the series. A
109
+ 1-minute bar is keyed at its open but its `close` is the price at its end, so
110
+ `<LineChart column="close" xOffset="1m">` puts the line where the closes
111
+ happened — the session's last close on the close, not a bar early — while a
112
+ `<Candlestick>` from the same series still draws at the key. Previously this
113
+ meant rebuilding the whole series with shifted keys. Everything the layer
114
+ reports is in drawn time: the tracker's nearest row and dot, its auto-fit
115
+ x-extent, `sessionBreaks`, a sweep's span. Time series only; a non-zero
116
+ offset on a `ValueSeries` throws.
117
+
118
+ - **charts**: **`<AreaChart sessionBreaks>`** ends the fill and its outline at
119
+ each trading-axis session close and re-starts them at the next open, as
120
+ `<LineChart>` and `<BandChart>` already did — so the line and area styles of
121
+ one intraday series break alike. `gaps="none"` still interpolates a dropout
122
+ inside a session but never across a break, and no inferred gap connector
123
+ spans one.
124
+
125
+ - **charts:** `TradingTimeScale.flatFormat(count, drawn?)` — `drawn` is the
126
+ subset of `ticks(count)` actually rendered; flat promotions walk only those,
127
+ so a dropped tick's period turn carries to the next drawn one. Omitted, it
128
+ labels every tick as before.
129
+
130
+ - **docs**: **The versioning policy is now published** — a "Versioning"
131
+ section in the root README and each package README, and a
132
+ [Versioning](https://pond-ts.org/docs/reference/versioning) page on the
133
+ docs site. Pre-1.0 a minor may break (always marked **breaking** here with a
134
+ migration note), patches never break, all packages release in lock-step,
135
+ and from 1.0 the project follows semver. The page also defines the public
136
+ API: each package's entry-point exports as mapped in `API.md`, excluding
137
+ anything marked experimental or internal.
138
+
139
+ ### Changed
140
+
141
+ - **core** (**breaking**): **A `NaN` in a number column now always means
142
+ missing.** Typed-array intake (`fromColumns`, `withColumn` with a
143
+ `Float64Array`) already turned `NaN` into a gap and row intake already
144
+ rejected it, but an operator's own arithmetic could still write a
145
+ _defined_ `NaN` — `diff` / `rate` / `pctChange` of `Infinity - Infinity`,
146
+ a `cumulative` sum that reaches
147
+ `Infinity` and then meets `-Infinity`, or a `cumulative` custom fold,
148
+ `scan` step or numeric `collapse` reducer that returns `NaN`. Those
149
+ cells are now stored as **missing**: `at(i)` / `get` / `read` return
150
+ `undefined`, `scan()` skips them, and `nullCount()` / `hasMissing()` count
151
+ them. Same in live `diff` / `rate` / `pctChange` / `cumulative` views, in a
152
+ live `map` whose function writes `NaN` into a number field, and in
153
+ `@pond-ts/process` `RangeOutput.set` / `sealRange`. A `cumulative` that hits
154
+ `NaN` keeps its accumulator, so built-in folds read missing from that row on.
155
+ `±Infinity` now follows the same rule — see the next entry. The storage
156
+ switch this unlocked landed in the same release — see "number columns store
157
+ a missing cell as `NaN`" below. **Migration:** code that tested
158
+ `Number.isNaN(v)` on an operator's output should test `v === undefined`.
159
+
160
+ - **core** (**breaking**): **Any non-finite number — `NaN`, `Infinity` or
161
+ `-Infinity` — in a number column means missing.** `Infinity` used to get five
162
+ different answers; it now gets one. A non-finite result is stored as a
163
+ **missing** cell (`undefined` on read, counted by `nullCount()` /
164
+ `hasMissing()`, skipped by `scan()`), so `count() + nullCount() === length`
165
+ always holds. What changed:
166
+ - **No longer throws:** `mapColumns` (a mapper returning `NaN` /
167
+ `±Infinity` — was a `RangeError`), and `rolling` / `aggregate` / `smooth`
168
+ / `baseline` / `byColumn` / `pivotByGroup` / `arrayAggregate` whose result
169
+ overflows (was a `ValidationError`). Those cells are now missing.
170
+ - **No longer a defined value:** `diff` / `rate` / `pctChange` /
171
+ `cumulative` / `scan` / `collapse` results that overflow to `±Infinity`
172
+ (e.g. `diff` of `MAX - (-MAX)`, a `cumulative` sum past `Number.MAX_VALUE`
173
+ — missing from that row on, as its accumulator stays `Infinity`).
174
+ - **Reducers never answer non-finite:** a reducer result that overflows
175
+ (`sum`, `avg`, `stdev`, or a custom reducer returning `Infinity`) is
176
+ `undefined` — in `reduce()` too, which used to return `Infinity`. A
177
+ rolling `sum` / `avg` / `stdev` (batch, live, and `smooth('movingAverage')`)
178
+ reads missing only while the current window overflows, and reads finite
179
+ again once the large values leave it. A mean is still sum-then-divide, so
180
+ `avg([MAX_VALUE, MAX_VALUE])` reads missing although the mean is finite.
181
+ (The column methods `col.sum()` / `col.mean()` are unchanged.)
182
+ - **Doors:** `withColumn` with a `Float64Array` reads `±Infinity` as a gap,
183
+ as it already read `NaN` (was a `ValidationError`). Row intake
184
+ (`new TimeSeries`, `fromJSON`, `LiveSeries.push`) and `withColumn` with a
185
+ plain array still reject `NaN` and `±Infinity` alike; `fromColumns` /
186
+ `fromArrow` already read both as a gap.
187
+ - **Live:** `diff` / `rate` / `pctChange` / `cumulative` / `map` views and
188
+ every live accumulator (`aggregate`, `rolling`, `reduce`) emit `undefined`
189
+ for a non-finite result, so a library view's `toTimeSeries()` no longer
190
+ throws on an overflow. A user `map` keeps the source schema, so a `map` fn
191
+ that writes a non-finite value reads missing too — and `toTimeSeries()`
192
+ then works if that column is optional, but throws the required-column
193
+ `ValidationError` if it is required. `@pond-ts/process` `RangeOutput.set` / `sealRange` / `packColumn`
194
+ store `±Infinity` as missing too.
195
+
196
+ **Migration:** code that caught a `ValidationError` / `RangeError` from these
197
+ operators to detect overflow, or tested `v === Infinity` on their output,
198
+ should test `v === undefined`. Code relying on
199
+ `withColumn(name, float64Array)` to reject `Infinity` must check it before
200
+ the call.
201
+
202
+ - **core** (**breaking**, mostly internal): **Number columns store a missing
203
+ cell as `NaN` in their own `Float64Array`, and keep no separate validity
204
+ bitmap** ([PND-NANREP]). Now that any non-finite number in a number column
205
+ means missing (entries above), the `NaN` is the only marker the column needs.
206
+ Answers do not change, with one exception: a gappy `sum` / `avg` over a
207
+ sub-range (an `aggregate` bucket of 32+ cells) can differ from before in
208
+ the last digit, because the blocked sum now groups its eight lanes from the
209
+ range start rather than from a multiple of 8 — the same kind of rounding
210
+ difference blocked summation already allows. Speed improves — on 1M rows with 4% gaps, `fromColumns`
211
+ is 70% faster, `reduce` `sum` / `avg` / `min` 30–38%, `diff` / `cumulative` /
212
+ `shift` 14–18%, and `nullCount()` on a fresh column 35%. Dense columns are
213
+ mostly unchanged or faster (`fromColumns` 32%); dense `cumulative` measured
214
+ ~11% slower. String, boolean and array columns keep their bitmaps. What you
215
+ can see:
216
+ - **`toFloat64Array()` returns `NaN` at a missing cell** (it used to be
217
+ whatever the buffer held, often `0`). A draw loop now breaks the line at a
218
+ gap with no extra check, and `withColumn(name, col.toFloat64Array())` now
219
+ round-trips gaps.
220
+ - **`fromArrow` copies a `Float64` column that has nulls** (one bulk copy,
221
+ writing `NaN` at each null) instead of sharing Arrow's buffers. Gap-free
222
+ columns are still shared with no copy. `toArrow` builds a number column's
223
+ `nullBitmap` from its `NaN` cells (once, then cached).
224
+ - **`Float64Column` constructor** (trusted, internal) is now
225
+ `(values, length, allFinite = false)` — the `validity` argument is gone,
226
+ and the old four-argument call shape throws a `TypeError`. `allFinite` now means "no
227
+ missing cells". `col.validity` still exists for kind-generic code but is
228
+ built from the `NaN` cells on first read. A column built this way with a
229
+ `NaN` partition value or interval label now reads it as missing (it used
230
+ to be a `NaN` partition / a "not a finite number" error).
231
+ - **`@pond-ts/process`:** `RangeOutput`, `ColumnView` and `ColumnBuffers`
232
+ lose their `bits` field — a gap is `NaN` in `values`. `ColumnBuffers`
233
+ gains `allFinite`. `validityByteCount` is removed (nothing to size). `columnBytes` no longer counts a bitmap for number
234
+ columns.
235
+
236
+ **Migration:** code that read `validity.bits` of a number column, or passed
237
+ `bits` across a worker boundary, should test `Number.isNaN(values[i])`
238
+ instead. Code that relied on `fromArrow` sharing a null-bearing column's
239
+ buffer with the Arrow table should not.
240
+
241
+ - **core**: Out-of-order ingest errors now suggest `{ ordering: 'reorder' }`
242
+ (and `'clamp'` where it applies) instead of `{ sort: true }`. Code matching
243
+ on the old message text needs updating.
244
+
245
+ - **core** (**breaking**): **`select(...keys)` throws on a key that isn't a
246
+ column, and on an array argument.** It used to drop unknown keys silently, so
247
+ a typo — or `select(['a', 'b'])` instead of `select('a', 'b')` — returned an
248
+ empty-payload series with no error. Now an unknown key throws a `RangeError`
249
+ naming it and listing the valid columns, and an array argument throws a
250
+ `TypeError` telling you to spread it (`select(...names)`). Applies to
251
+ `TimeSeries`, `LiveSeries`, `LiveView`, `LiveAggregation` and
252
+ `LiveRollingAggregation` (the live ones throw when the view is built, not on
253
+ the first event). `Event.select` rejects an array argument but still
254
+ tolerates absent keys, since an event payload may omit optional fields.
255
+ Duplicate keys are still accepted and select the column once. **Migration:**
256
+ fix the misspelled column, or spread the array.
257
+
258
+ - **core / react**: **The live column reads are no longer experimental.**
259
+ `LiveView.column()`, `LiveView.keyColumn()`, `LiveView.partitionBy().toMap()`,
260
+ the `LiveColumnGroup` class and `@pond-ts/react`'s `useLiveVersion` shipped in
261
+ v0.19.0 labelled "experimental — may change in 0.19.x". They are now part of
262
+ the stable surface that 1.0 will commit to, with no change to their shape.
263
+
264
+ - **charts** (**breaking**): **An `<AreaChart>`'s `baseline` no longer changes
265
+ the y-axis range.** Since v0.71.0 the default zero baseline (and any numeric baseline)
266
+ was pulled into the auto-fit domain, so an area over data running $100–$200
267
+ drew on a $0–$200 axis, and switching `baseline` rescaled the chart — and
268
+ every other layer sharing its axis. The axis now fits the data, exactly as it
269
+ does for a `<LineChart>` of the same column; `baseline` only moves where the
270
+ fill stops. A baseline outside the axis range rests the fill on the nearest
271
+ edge, so on data that never crosses zero the default now draws the same as
272
+ `baseline="floor"`. **Migration:** where the fill should read from zero — a
273
+ stack, a quantity like throughput — put zero on the axis with
274
+ `<YAxis min={0}>` (`max={0}` for an all-negative series). Areas whose data
275
+ already reaches their baseline, or whose axis is pinned, are unchanged.
276
+
277
+ - `pond-ts` (**breaking**, types only): **`TimeSeries.toJSON()`'s return
278
+ type narrows on `rowFormat`**, matching `LiveSeries.toJSON()`. The default
279
+ / `'array'` form returns `TimeSeriesJsonOutputArray<S>` and
280
+ `{ rowFormat: 'object' }` returns `TimeSeriesJsonOutputObject<S>`, so
281
+ `result.rows` is typed against your schema with no cast; a `rowFormat`
282
+ typed as `JsonRowFormat` gets the union. It used to return the loose
283
+ `TimeSeriesJsonInput<SeriesSchema>`. **Migration:** drop any
284
+ `as TimeSeriesJsonInput<…>` / `as TimeSeriesJsonOutput…<…>` casts (they
285
+ still compile). Two patterns now fail to compile: annotating an
286
+ **object**-form result as `TimeSeriesJsonInput<SeriesSchema>` — including
287
+ a `JsonRowFormat`-typed (union) result, since it can be the object form —
288
+ (use `TimeSeriesJsonInput<typeof schema>`, or no annotation), and reading a
289
+ column by name off a default (tuple) row, which was always `undefined` at
290
+ runtime. `LiveSeries.toJSON()` also gains the `JsonRowFormat` overload, which
291
+ previously had no matching signature. [PND-TSJSONT]
292
+
293
+ - `pond-ts`: **`TimeSeries.toJSON()` tuple rows are ~2.5–3.5× faster at 100k
294
+ rows** — one array per row, filled in place, instead of a mapped array spread
295
+ into a second one. Tuple rows were slower than object rows; they now match or
296
+ beat them. Output unchanged.
297
+
298
+ - **core** (**breaking**): **Every `on()` overload on `LiveAggregation` and
299
+ `LiveRollingAggregation` now returns an unsubscribe function**, matching
300
+ `LiveSeries.on` / `LiveView.on`. `on('bucket')` and `on('update')` used to
301
+ return `this`, so those listeners could never be removed. **Migration:** a
302
+ chained `live.aggregate(…).on('bucket', fn)` now yields the unsubscribe
303
+ function, not the operator (same for `live.rolling(…).on('update', fn)`) —
304
+ assign the operator first, then subscribe.
305
+
306
+ - **core** (**breaking, type-level**): **`LiveAggregation` listeners
307
+ (`'event'`, `'bucket'`) and `LiveRollingAggregation`'s `'event'` listener
308
+ receive the schema-narrowed `EventForSchema<Out>`** — the same type `at()`
309
+ and the `closed()` series hand out — instead of a widened
310
+ `Event<Interval, Record<string, …>>` / `any`. `e.get('col')` is now typed
311
+ per output column, and naming a column the aggregation doesn't produce is a
312
+ compile error. As a result a `LiveAggregation<S, Out>` is now assignable to
313
+ `LiveSource<Out>` ([PND-LIVESRC]). The same narrowing applies to the
314
+ `'event'` listener of the keyed-form fused `live.rolling({...})`
315
+ (`LiveFusedRolling`), `live.reduce(…)` (`LiveReduce`), and the
316
+ clock-triggered partitioned `rolling(…)` variants
317
+ (`LivePartitionedSyncRolling` / `LivePartitionedFusedRolling`), which
318
+ previously typed it `(event: any)`. Both `LiveAggregation` /
319
+ `LiveRollingAggregation` `on()` methods now throw a
320
+ `TypeError` on an unknown event type instead of silently registering the
321
+ listener.
322
+
323
+ ### Deprecated
324
+
325
+ - **core**: **`sort?: boolean`** on the loaders above — use
326
+ `ordering: 'reorder'`. `sort: true` still works for this release and maps to
327
+ `'reorder'`; it will be removed in the next minor. Passing `sort` together with
328
+ a conflicting `ordering` (e.g. `sort: true, ordering: 'clamp'`)
329
+ throws a `ValidationError`.
330
+
331
+ ### Removed
332
+
333
+ - **core** (**breaking**): **`LiveAggregation.on('close', fn)` is removed —
334
+ use `on('event', fn)`**, which it was an alias for. Untyped callers that
335
+ still pass `'close'` get a `TypeError` naming the replacement.
336
+
337
+ - `pond-ts` (**breaking**, types only): **the back-compat type alias
338
+ `AggregateOutputMap` is gone — use `AggregateMap`.** It has been an exact
339
+ alias of `AggregateMap` since v0.23.0, when the shorthand and
340
+ `{ from, using }` spec forms were unified into one map type. The internal
341
+ schema aliases `RollingOutputMapSchema` (identical to `RollingSchema`) and
342
+ `AggregateOutputMapResultSchema` (identical to `AggregateSchema`) are removed
343
+ with it; neither was reachable from a public entry point. **Migration:**
344
+ replace `AggregateOutputMap<S>` with `AggregateMap<S>` — no runtime change.
345
+ The fused-rolling duplicate-output error message now says "use a
346
+ `{ from, using }` spec entry" instead of naming the removed type.
347
+
348
+ ### Fixed
349
+
350
+ - **charts: time-axis tick labels no longer overprint each other.** The tick
351
+ ladder places every session open unconditionally, so two anchors could land
352
+ closer than a label is wide — a session calendar derived from the bars turns
353
+ one stray pre-market print into a one-minute session whose `Sep 29` open sat
354
+ a pixel left of the real `15:30` open, and a seam could crowd the last clock
355
+ tick before it (`21:00` against the next day's `Sep 23`). A time axis now
356
+ measures its labels and drops the one that would collide — a date / month /
357
+ year turn outlasts a session-open clock label, which outlasts a plain one,
358
+ which outlasts a tick in closed-market time (an elapsed axis no longer labels
359
+ a seam with the shut night); of two colliding turns (or opens) the later is
360
+ drawn — then re-labels the
361
+ ticks it kept, so the dates they show stay correct (a stray New Year's print
362
+ can't take `2027` with it; a Sunday-evening futures open can't caption
363
+ Monday), and puts back any dropped tick that fits once a neighbour's label
364
+ narrowed. A blank label (a custom `format` returning `''`) never blocks one.
365
+ Under `align="auto"` the edge labels anchor by the full tick list, so a drop
366
+ never re-anchors another label. Applies to every `align` mode and to custom
367
+ `format`s; explicit `ticks` and `transform` ticks are untouched.
368
+
369
+ - **charts**: a **decimated** `<LineChart sessionBreaks>` with
370
+ `gaps="none"` interpolated straight across each session break, and
371
+ `dashed` / `step` / `fade` drew a gap connector over it — dense intraday data
372
+ decimates, so this was the common case. Decimation marks the break as a gap
373
+ at the break instant, and the draw now cuts there instead of reading it as a
374
+ dropout.
375
+
376
+ ## [0.72.0] — 2026-10-08
377
+
378
+ ### Changed
379
+
380
+ - `pond-ts`: **`join` and `joinMany` work on columns now, not events** — 20
381
+ to 190× faster in the cases below, with the same output. `join` used to
382
+ build an event for every row on both sides, merge them row by row and
383
+ rebuild the columns. Now it walks the two key columns once, then either
384
+ adopts each column as is or gathers it in one pass. A side's value columns
385
+ are adopted as is whenever all its rows land in the output once and in order:
386
+ always the left side of a `left` join, the right side of a `right` join, and
387
+ both sides when the keys match one for one (`joinMany` over a shared grid).
388
+ One year of 1-minute bars, ~97.5k rows a side, `type: 'left'` unless noted
389
+ (`packages/core/scripts/perf-join.mjs`):
390
+
391
+ | Value columns per side | Before | After |
392
+ | ------------------------ | -------- | -------- |
393
+ | 59 and 59 | 1,868 ms | 24 ms |
394
+ | 5 and 5 | 168 ms | 4.2 ms |
395
+ | 59 and 2 | 766 ms | 4.1 ms |
396
+ | `joinMany`, 4 × 5, outer | 457 ms | 15–20 ms |
397
+
398
+ A left join now costs about as much as the **other** side is wide. To bring
399
+ across only the columns you read, narrow that side first; `select` and
400
+ `rename` don't copy:
401
+ `bars.join(spy.select('close').rename({ close: 'spy' }), { type: 'left' })`.
402
+
403
+ One visible difference: on a row with no match, `event.data()` now lists the
404
+ other side's fields as `undefined`, as every other operator's events do. It
405
+ used to leave them out. `get()` returns `undefined` either way.
406
+
77
407
  ## [0.71.0] — 2026-09-24
78
408
 
79
409
  ### Added
@@ -213,8 +543,8 @@ include new features and type-level changes; patch bumps are strictly additive.
213
543
 
214
544
  - `@pond-ts/financial`: **`StudyOutput.id` documents the `@pond-ts/process`
215
545
  bridge**, and a new `test/catalog-process.test.ts` pins it. The field is a
216
- *financial column suffix*; process's `OutputDef.id` is a *process outlet
217
- id*; they share a name and are different namespaces. Process names its own
546
+ _financial column suffix_; process's `OutputDef.id` is a _process outlet
547
+ id_; they share a name and are different namespaces. Process names its own
218
548
  columns (`specId + OutputDef.id`) and matches an op's return to its outputs
219
549
  positionally, so a study's own column names never reach it — which means a
220
550
  registry bridging the two chooses its own suffixes, and for the **twelve**
@@ -227,7 +557,7 @@ include new features and type-level changes; patch bumps are strictly additive.
227
557
  worked around it by hand. No API change: the descriptors, the studies and
228
558
  the guard are all unchanged, and the round-trip test is what stops the two
229
559
  packages drifting — `catalog.test.ts` validates a descriptor against its
230
- *study*, so it is structurally blind to a cross-package disagreement.
560
+ _study_, so it is structurally blind to a cross-package disagreement.
231
561
  - `@pond-ts/process`: **`Skipped.spec` echoes the request verbatim**, and its
232
562
  `params` / `inputs` are typed `unknown` accordingly. The plan pass normalized
233
563
  `params: null` to `{}`, so recomputing an id from the echo produced the
@@ -247,7 +577,6 @@ include new features and type-level changes; patch bumps are strictly additive.
247
577
 
248
578
  (All three reported by Tidal against 0.62.0, after adopting it.)
249
579
 
250
-
251
580
  ## [0.69.0] — 2026-09-13
252
581
 
253
582
  ### Added
package/README.md CHANGED
@@ -69,6 +69,17 @@ Guides, the component reference, and live examples live at
69
69
  **<https://pond-ts.org/docs/charts/>**. Source and issues:
70
70
  [github.com/pond-ts/pond](https://github.com/pond-ts/pond).
71
71
 
72
+ ## Versioning
73
+
74
+ Released in lock-step with `pond-ts` and the other `@pond-ts/*` packages
75
+ under one version — keep them in step. Pre-1.0, a minor release may contain
76
+ breaking changes (each marked **breaking** in the
77
+ [CHANGELOG](https://github.com/pond-ts/pond/blob/main/CHANGELOG.md) with a
78
+ migration note); patch releases never break. From 1.0 the project follows
79
+ semver. See
80
+ [Versioning](https://pond-ts.org/docs/reference/versioning) for what
81
+ counts as the public API.
82
+
72
83
  ## License
73
84
 
74
85
  MIT
@@ -4,6 +4,7 @@ import type { NumericColumn, ValueNumericColumn } from './column-names.js';
4
4
  import type { DecimateOption } from './decimate.js';
5
5
  import { type Curve } from './curve.js';
6
6
  import { type GapMode } from './gaps.js';
7
+ import { type XOffset } from './x-offset.js';
7
8
  export interface AreaChartCommon<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
8
9
  /**
9
10
  * The series' semantic identifier — what the data _is_ / how it should read
@@ -33,23 +34,23 @@ export interface AreaChartCommon<S extends SeriesSchema = SeriesSchema, VS exten
33
34
  /**
34
35
  * The value the fill rests on — the flat edge opposite the value line.
35
36
  *
36
- * - **Omitted ⇒ `0`**: an area measures size from zero, so values above zero
37
- * fill up and values below it fill down, each side's shade fading toward
38
- * the zero line. Zero is pulled into the auto-fit domain, so the axis always
39
- * shows where the fill starts.
37
+ * - **Omitted ⇒ `0`**: values above zero fill up and values below it fill
38
+ * down, each side's shade fading toward the zero line.
40
39
  * - **Another number ⇒ that reference level**, with the same behaviour (e.g.
41
40
  * `baseline={100}` for an index that fills above and below 100). For the
42
41
  * esnet two-colour traffic look, compose two `<AreaChart>`s (an "in" column
43
42
  * and an "out" column, distinct `as` roles).
44
43
  * - **`'floor'` ⇒ the axis's lower bound** (the bottom of the plot): the fill
45
- * rests on whatever the axis starts at, and nothing is added to the domain.
46
- * For a series that never nears zero where zero would flatten the shape — a
47
- * price, an elevation profile.
44
+ * rests on whatever the axis starts at, even when zero is on screen.
48
45
  *
49
- * On a **log** axis zero has no position, so a baseline at or below zero
50
- * falls back to the axis floor and is not pulled into the domain. On an axis
51
- * pinned so that it excludes the baseline, the fill rests on the nearest
52
- * edge (the baseline is clamped into the domain, as a bar's is).
46
+ * **The baseline never changes the axis range.** The axis fits the data, as
47
+ * it does for a `<LineChart>`, so switching `baseline` only moves where the
48
+ * fill stops, never the scale. A baseline outside the axis range rests the
49
+ * fill on the nearest edge (it is clamped into the domain, as a bar's is), so
50
+ * on data that never crosses zero the default looks the same as `'floor'`.
51
+ * To show the fill from zero, put zero on the axis: `<YAxis min={0}>`. On a
52
+ * **log** axis zero has no position, so a baseline at or below zero falls
53
+ * back to the axis floor.
53
54
  */
54
55
  baseline?: number | 'floor';
55
56
  /**
@@ -71,6 +72,21 @@ export interface AreaChartCommon<S extends SeriesSchema = SeriesSchema, VS exten
71
72
  * `'dashed'` / `'step'` connector faintness is the theme's `gap.connectorOpacity`.)
72
73
  */
73
74
  gaps?: GapMode;
75
+ /**
76
+ * End the fill (and its outline) at each **trading-axis discontinuity** — a
77
+ * session / day / lunch close→open — when the container renders on a
78
+ * trading-time axis (a `discontinuities` / `calendar` provider). **Omitted ⇒
79
+ * `false`**: the area runs from the last pre-close point straight to the next
80
+ * open, filling the collapsed gap. `true` ends the shade at the close and
81
+ * re-starts it at the open, the same break `<LineChart sessionBreaks>` draws,
82
+ * so the line and area styles of one intraday series read the same.
83
+ *
84
+ * A **scale** break, orthogonal to {@link gaps} (a **data** break, a NaN run):
85
+ * `gaps="none"` interpolates across a dropout but never across a session
86
+ * break, and no inferred gap connector spans one. A no-op on a continuous
87
+ * axis (no provider) or a provider without `boundaries`.
88
+ */
89
+ sessionBreaks?: boolean;
74
90
  /**
75
91
  * **M4 viewport decimation** (charts decimator wave). **Omitted ⇒ `true`**:
76
92
  * once the visible data is denser than ~2 samples per device pixel, the fill +
@@ -140,6 +156,13 @@ export interface AreaChartCommon<S extends SeriesSchema = SeriesSchema, VS exten
140
156
  * identity** (`as` ?? `column`). The swatch is the resolved area style.
141
157
  */
142
158
  legend?: boolean | string;
159
+ /**
160
+ * Draw each row this long **after its key** — milliseconds or a duration
161
+ * string. **Omitted ⇒ `0`**. Time series only. Same knob as
162
+ * {@link LineChartCommon.xOffset}, so an area and the line it pairs with
163
+ * move together.
164
+ */
165
+ xOffset?: XOffset;
143
166
  /**
144
167
  * @internal Declaration position among the `<Layers>` children, injected by
145
168
  * `Layers` so z-order follows JSX order. Do not set.
@@ -211,6 +234,6 @@ export declare function resolveAreaBaseline(baseline: number | undefined, yScale
211
234
  * </Layers>
212
235
  * ```
213
236
  */
214
- export declare function AreaChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, readout, as: semantic, axis, id, baseline, curve, gaps, decimate, thresholds, bandColors, legend, index, }: AreaChartProps<S, VS>): null;
237
+ export declare function AreaChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series: source, column, readout, as: semantic, axis, id, baseline, curve, gaps, sessionBreaks, decimate, thresholds, bandColors, legend, xOffset, index, }: AreaChartProps<S, VS>): null;
215
238
  export {};
216
239
  //# sourceMappingURL=AreaChart.d.ts.map
package/dist/AreaChart.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
2
  import { ValueSeries } from 'pond-ts';
3
3
  import { assertNumericColumn, fromTimeSeries, fromValueSeries, } from './data.js';
4
- import { areaExtent, areaHitIndex, areaStateStyle, drawArea } from './area.js';
5
- import { drawPartitioned, plotExtentOf, strokeSpanEdges, } from './line.js';
4
+ import { areaHitIndex, areaStateStyle, drawArea } from './area.js';
5
+ import { drawPartitioned, plotExtentOf, strokeSpanEdges, yExtent, } from './line.js';
6
6
  import { sweepSpan } from './sweep.js';
7
7
  import { resolveCurve } from './curve.js';
8
8
  import { DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
9
9
  import { ContainerContext, LayersContext, } from './context.js';
10
10
  import { legendLabelFor, useLegendItems, } from './swatch.js';
11
11
  import { useSlotKey } from './use-slot-key.js';
12
+ import { useXOffset } from './x-offset.js';
12
13
  import { useBandLadder } from './use-band-ladder.js';
13
14
  /** Read a d3 linear scale's domain lower bound (the axis floor) from the plain
14
15
  * `(value) => pixel` function the row hands to `draw`. The runtime object is a
@@ -37,13 +38,17 @@ export function resolveAreaBaseline(baseline, yScale) {
37
38
  return floor;
38
39
  if (!d || d.length === 0)
39
40
  return baseline;
40
- // Clamped into the domain, as a bar's baseline is: under an explicit
41
- // `<YAxis min={40}>` the default 0 is off the plot, and a fill resting
41
+ // Clamped into the domain, as a bar's baseline is: the baseline never
42
+ // widens the axis, so on data that never crosses zero (or under an explicit
43
+ // `<YAxis min={40}>`) the default 0 is off the plot, and a fill resting
42
44
  // there would anchor its fade (and a gap's `'fade'` connector) off-plot.
43
45
  const lo = Math.min(d[0], d[d.length - 1]);
44
46
  const hi = Math.max(d[0], d[d.length - 1]);
45
47
  return Math.min(hi, Math.max(lo, baseline));
46
48
  }
49
+ /** Stable empty boundary list — so `sessionBreaks={false}` keeps a referentially
50
+ * constant array and the layer entry isn't rebuilt every render. */
51
+ const NO_BREAKS = [];
47
52
  /**
48
53
  * An area draw layer: fills between a value `column` and a `baseline`, with a
49
54
  * graded (gradient) shade — opaque at the line, transparent at the baseline —
@@ -64,7 +69,7 @@ export function resolveAreaBaseline(baseline, yScale) {
64
69
  * </Layers>
65
70
  * ```
66
71
  */
67
- export function AreaChart({ series, column, readout, as: semantic, axis, id, baseline, curve, gaps = DEFAULT_GAP_MODE, decimate = true, thresholds, bandColors, legend, index = 0, }) {
72
+ export function AreaChart({ series: source, column, readout, as: semantic, axis, id, baseline, curve, gaps = DEFAULT_GAP_MODE, sessionBreaks = false, decimate = true, thresholds, bandColors, legend, xOffset, index = 0, }) {
68
73
  const container = useContext(ContainerContext);
69
74
  if (container === null) {
70
75
  throw new Error('<AreaChart> must be rendered inside a <ChartContainer>');
@@ -73,11 +78,24 @@ export function AreaChart({ series, column, readout, as: semantic, axis, id, bas
73
78
  if (layers === null) {
74
79
  throw new Error('<AreaChart> must be rendered inside a <Layers>');
75
80
  }
81
+ // The series as drawn: moved by `xOffset` (the source itself when there is
82
+ // none), so every read below shares one axis.
83
+ const series = useXOffset(source, xOffset, 'AreaChart');
76
84
  // `undefined` below means "the axis floor" — the internal helpers' spelling.
77
85
  const baseValue = baseline === 'floor' ? undefined : (baseline ?? 0);
78
86
  const cs = useMemo(() => series instanceof ValueSeries
79
87
  ? fromValueSeries(series, column)
80
88
  : fromTimeSeries(series, column), [series, column]);
89
+ // Trading-axis session breaks: the collapse instants inside this series' span.
90
+ // Data instants, not pixels — view-independent, so pan/zoom reuse them. The
91
+ // same lookup `<LineChart>` / `<BandChart>` do, so the three break alike.
92
+ const sessionBreakInstants = useMemo(() => {
93
+ const provider = container.discontinuities;
94
+ if (!sessionBreaks || provider?.boundaries === undefined || cs.length < 2) {
95
+ return NO_BREAKS;
96
+ }
97
+ return provider.boundaries(cs.x[0], cs.x[cs.length - 1]);
98
+ }, [sessionBreaks, container.discontinuities, cs]);
81
99
  // Readout column values for a value-axis series (time path reads it off the
82
100
  // event) — the tracker reports it alongside the plotted fill so an off-chart
83
101
  // readout can show a source value. See AreaChartProps.readout.
@@ -150,13 +168,9 @@ export function AreaChart({ series, column, readout, as: semantic, axis, id, bas
150
168
  const entry = useMemo(() => ({
151
169
  layer: {
152
170
  as: semantic,
153
- // A baseline with no position on a log axis (zero, the default) is
154
- // left out of the fit: the draw rests it on the floor anyway, and an
155
- // extent of `[0, max]` gives the log fit no positive low end, so it
156
- // would collapse the domain onto the max and clip the series.
157
- yExtent: (scale) => areaExtent(cs, scale === 'log' && baseValue !== undefined && baseValue <= 0
158
- ? undefined
159
- : baseValue),
171
+ // The values alone: the baseline never widens the axis (see
172
+ // `baseline`), so an area fits the same domain a line would.
173
+ yExtent: () => yExtent(cs),
160
174
  // The container infers the shared x scale's kind from its layers — a
161
175
  // ValueSeries plots on a value axis, a TimeSeries on time.
162
176
  xKind: series instanceof ValueSeries ? 'value' : 'time',
@@ -289,7 +303,7 @@ export function AreaChart({ series, column, readout, as: semantic, axis, id, bas
289
303
  // `NaN` and poison every coordinate in the fill path. Fall back
290
304
  // to the axis floor, which is exactly what `'floor'`
291
305
  // already resolves to.
292
- resolveAreaBaseline(baseValue, yScale), curveFactory, gaps, gapConnectorOpacity, decimate, bandLadder);
306
+ resolveAreaBaseline(baseValue, yScale), curveFactory, gaps, gapConnectorOpacity, decimate, bandLadder, sessionBreakInstants);
293
307
  }
294
308
  },
295
309
  },
@@ -307,6 +321,7 @@ export function AreaChart({ series, column, readout, as: semantic, axis, id, bas
307
321
  curveFactory,
308
322
  gaps,
309
323
  gapConnectorOpacity,
324
+ sessionBreakInstants,
310
325
  decimate,
311
326
  bandLadder,
312
327
  axis,