@pond-ts/charts 0.69.0 → 0.70.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
@@ -716,31 +716,31 @@ Typed dataflow graphs for pipelines whose **shape is data** (runtime-assembled,
716
716
  user-edited, one computation fanned out to several consumers). Chaining stays
717
717
  the default for pipelines known at authoring time — see the package README.
718
718
 
719
- | Group | Exports | Source |
720
- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
721
- | Worker pool (Node) | `HostPool` (`start`, `run`, `close`, `size`, `inFlight`); types `HostPoolOptions`, `PoolSetup`, `PoolSetupConfig`; `toWire` / `fromWire`, types `WireResult`, `WireColumn` — subpath `@pond-ts/process/pool` | `packages/process/src/pool/index.ts` |
722
- | Ports | `Inlet`, `Outlet` (typed fields on `node.in` / `node.out`; `get()`, `peek()`, `version`, `connect`, `disconnect`) | `packages/process/src/port.ts` |
723
- | Nodes | `Node` (`in`, `out`, `dirty`, `error`, `invalidate()`), `defineNode` (reusable multi-output node type), `derive` (single-output, wired inline) | `packages/process/src/node.ts` |
724
- | Port declaration | `port<T>({ equals, defaultValue })`; types `PortSpec`, `PortSpecMap`, `PortValue`, `PortValues` | `packages/process/src/types.ts` |
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` |
726
- | Graph view | `Graph` (`Graph.from(...roots)`, `nodes`, `order()`, `edges()`, `toJSON()`); types `GraphEdge`, `GraphJson`, `GraphNodeJson`, `GraphEdgeJson` | `packages/process/src/graph.ts` |
727
- | 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` |
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` |
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` |
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` |
731
- | Column values | `packColumn` (values → packed `Float64Column`, NaN = missing), `columnBytes` (retained size, for a byte budget), `appendColumn` (column → series; boxing-free when gapless), `columnBuffers` / `columnFromBuffers` (the buffer pair a column is, for an isolate boundary; type `ColumnBuffers`), `columnView` (zero-copy borrowed read view for in-process folds; type `ColumnView`) | `packages/process/src/column.ts` |
732
- | Plan — registry | `createRegistry({ folds })` / `Registry` (`define`, `get`, `foldFor`, `outputsOf`, `resolveParams` (`{ validate: false }` applies defaults and skips every check), `byFamily`, `describe`, `toJsonSchema`), param builders `int` / `num` / `choice` / `flag`, `UnknownOpError`, `ParamError` | `packages/process/src/plan/registry.ts`, `params.ts` |
733
- | Plan — identity | `specId(registry, spec, { validate })` (content-addressed, param-order invariant, defaults materialized; `validate: false` is **total** — names a spec that would not compile in a separate `p1?:` namespace that cannot collide with a valid id; a valid spec's id is identical either way), `SpecIdOptions`, `refToId`, `explain`, `unitOf`, `columnsOf`, `dependsOn`, `outputKey` | `packages/process/src/plan/identity.ts` |
734
- | Plan — types | `Spec`, `Plan`, `Input` (column name \| `Spec` \| `PickedOutput`), `SpecRef`, `Def` (`OpDef` \| `FoldDef`), `OpContext`, `OpResult`, `FoldContext`, `FactBody`, `isFold`, `ParamDef`, `Params`, `Units`, `InputDef`, `OutputDef` | `packages/process/src/plan/types.ts` |
735
- | Plan — bind / run | `bind(series, { registry, units })` → `BoundGraph` (`compile`, `setSource`, `ids`, `series`, `columnOf`), `run(graph, { plan, select, onError })` → `RunResult`, `UnitError`, `UnknownColumnError` (a raw string input naming no column of the bound series, checked over the whole closure and re-checked on the warm path) | `packages/process/src/plan/graph.ts`, `run.ts` |
736
- | Plan — request/response | `RunRequest` (`PlanRequest` \| `SlotRequest`), `RunOptions`, `RunResult`, `Select` (`{ on, output?, name? }` — points at a node; what comes back is what that node produces), `ErrorPolicy`, `Fact` (carries `op`), `OutputInfo`, `Skipped` (`spec`, `select`, `reason`, `code` — the failure's kind, matching the error class a throw would have carried), `NodeTiming` (`slot`, `pulled`, `cached`, `ms`, `inputs`) | `packages/process/src/plan/run.ts` |
737
- | Plan — host | `createHost({ registry, units, sources })` → `Host` (`add`, `has`, `datasets`, `graphFor`, `run`, `runAsync`), `toWire`, `UnknownDatasetError`; local-string `Envelope` (`PlanEnvelope` \| `SlotEnvelope`), remote-capable `AsyncEnvelope` (`AsyncPlanEnvelope` \| `AsyncSlotEnvelope` \| `Envelope`), `DatasetInfo`, `WireResult` | `packages/process/src/plan/host.ts` |
738
- | Plan — slots | `expandSlots(slots, columns)` → `Map<slot, Spec>` (expands to the nested form, so ids match by construction; `slot#Output` picks one output), `SlotError`; types `SlotDef` (`{ op, params, in }`), `Slots` | `packages/process/src/plan/slots.ts` |
739
- | Plan — builder | `plan(from)` → low-level `PlanBuilder`; `process(registry, from)` → typed fluent `ProcessBuilder` (`column`, op methods, `outputs`), `BuilderError`; types `NodeHandle`, `OutputHandle`, `FluentColumnRef`, `SingleColumnNode`, `MultiColumnNode`, `ColumnSelection`, `FactRef`, `BuiltRequest` | `packages/process/src/plan/builder.ts`, `fluent.ts` |
740
- | Plan — async sources | `defineSource({ name, load })`, `createSourceRegistry()` / `SourceRegistry`, `sourceId`, `UnknownSourceError`; types `SourceRef`, `SourceParams`, `LoadedSource` (value + revision), `SourceLoadContext`, `SourceDef` | `packages/process/src/plan/source.ts` |
741
- | Plan — folds | `STANDARD_FOLDS` and the four it holds — `last`, `extremes`, `percentileRank`, `shape` — pre-registered by `createRegistry()`; each a plain `FoldDef`, so a consumer can `define` over one | `packages/process/src/plan/folds.ts` |
742
- | Errors | `ProcessError` (base; `code` — a stable per-class literal, minification-proof, also surfaced on `Skipped`), `CycleError`, `UnconnectedInputError`, `MissingOutputError`, `UnsetSourceError` | `packages/process/src/errors.ts` |
743
- | Node type helpers | `NodeSpec`, `NodeFactory`, `InletsFor`, `OutletsFor`, `OutletValue`, `SpecsForOutlets`, `DerivedOutput` | `packages/process/src/node.ts` |
719
+ | Group | Exports | Source |
720
+ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
721
+ | Worker pool (Node) | `HostPool` (`start`, `run`, `close`, `size`, `inFlight`); types `HostPoolOptions`, `PoolSetup`, `PoolSetupConfig`; `toWire` / `fromWire`, types `WireResult`, `WireColumn` — subpath `@pond-ts/process/pool` | `packages/process/src/pool/index.ts` |
722
+ | Ports | `Inlet`, `Outlet` (typed fields on `node.in` / `node.out`; `get()`, `peek()`, `version`, `connect`, `disconnect`) | `packages/process/src/port.ts` |
723
+ | Nodes | `Node` (`in`, `out`, `dirty`, `error`, `invalidate()`), `defineNode` (reusable multi-output node type), `derive` (single-output, wired inline) | `packages/process/src/node.ts` |
724
+ | Port declaration | `port<T>({ equals, defaultValue })`; types `PortSpec`, `PortSpecMap`, `PortValue`, `PortValues` | `packages/process/src/types.ts` |
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` |
726
+ | Graph view | `Graph` (`Graph.from(...roots)`, `nodes`, `order()`, `edges()`, `toJSON()`); types `GraphEdge`, `GraphJson`, `GraphNodeJson`, `GraphEdgeJson` | `packages/process/src/graph.ts` |
727
+ | 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` |
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` |
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` |
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` |
731
+ | Column values | `packColumn` (values → packed `Float64Column`, NaN = missing), `columnBytes` (retained size, for a byte budget), `appendColumn` (column → series; boxing-free when gapless), `columnBuffers` / `columnFromBuffers` (the buffer pair a column is, for an isolate boundary; type `ColumnBuffers`), `columnView` (zero-copy borrowed read view for in-process folds; type `ColumnView`) | `packages/process/src/column.ts` |
732
+ | Plan — registry | `createRegistry({ folds })` / `Registry` (`define`, `get`, `foldFor`, `outputsOf`, `resolveParams` (`{ validate: false }` applies defaults and skips every check), `byFamily`, `describe`, `toJsonSchema`, `checkArity`), param builders `int` / `num` / `choice` / `flag`, `UnknownOpError`, `ParamError`, `ArityError` | `packages/process/src/plan/registry.ts`, `params.ts` |
733
+ | Plan — identity | `specId(registry, spec, { validate })` (content-addressed, param-order invariant, defaults materialized; `validate: false` is **total over arbitrary JSON** — names a spec that would not compile, including malformed shapes, in a separate `p1?:` namespace that cannot collide with a valid id; a valid spec's id is identical either way. Judges op existence, params **and arity**), `SpecIdOptions`, `refToId`, `explain`, `unitOf`, `columnsOf`, `dependsOn`, `outputKey` | `packages/process/src/plan/identity.ts` |
734
+ | Plan — types | `Spec`, `Plan`, `Input` (column name \| `Spec` \| `PickedOutput`), `SpecRef`, `Def` (`OpDef` \| `FoldDef`), `OpContext`, `OpResult`, `FoldContext`, `FactBody`, `isFold`, `ParamDef`, `Params`, `Units`, `InputDef`, `OutputDef` | `packages/process/src/plan/types.ts` |
735
+ | Plan — bind / run | `bind(series, { registry, units })` → `BoundGraph` (`compile`, `setSource`, `ids`, `series`, `columnOf`), `run(graph, { plan, select, onError })` → `RunResult`, `UnitError`, `UnknownColumnError` (a raw string input naming no column of the bound series, checked over the whole closure and re-checked on the warm path) | `packages/process/src/plan/graph.ts`, `run.ts` |
736
+ | Plan — request/response | `RunRequest` (`PlanRequest` \| `SlotRequest`), `RunOptions`, `RunResult`, `Select` (`{ on, output?, name? }` — points at a node; what comes back is what that node produces), `ErrorPolicy`, `Fact` (carries `op`), `OutputInfo`, `Skipped` (`spec` — echoed verbatim, `params`/`inputs` typed `unknown`; `select`, `reason`, `code` — the failure's kind, matching the error class a throw would have carried), `NodeTiming` (`slot`, `pulled`, `cached`, `ms`, `inputs`) | `packages/process/src/plan/run.ts` |
737
+ | Plan — host | `createHost({ registry, units, sources })` → `Host` (`add`, `has`, `datasets`, `graphFor`, `run`, `runAsync`), `toWire`, `UnknownDatasetError`; local-string `Envelope` (`PlanEnvelope` \| `SlotEnvelope`), remote-capable `AsyncEnvelope` (`AsyncPlanEnvelope` \| `AsyncSlotEnvelope` \| `Envelope`), `DatasetInfo`, `WireResult` | `packages/process/src/plan/host.ts` |
738
+ | Plan — slots | `expandSlots(slots, columns)` → `Map<slot, Spec>` (expands to the nested form, so ids match by construction; `slot#Output` picks one output), `SlotError`; types `SlotDef` (`{ op, params, in }`), `Slots` | `packages/process/src/plan/slots.ts` |
739
+ | Plan — builder | `plan(from)` → low-level `PlanBuilder`; `process(registry, from)` → typed fluent `ProcessBuilder` (`column`, op methods, `outputs`), `BuilderError`; types `NodeHandle`, `OutputHandle`, `FluentColumnRef`, `SingleColumnNode`, `MultiColumnNode`, `ColumnSelection`, `FactRef`, `BuiltRequest` | `packages/process/src/plan/builder.ts`, `fluent.ts` |
740
+ | Plan — async sources | `defineSource({ name, load })`, `createSourceRegistry()` / `SourceRegistry`, `sourceId`, `UnknownSourceError`; types `SourceRef`, `SourceParams`, `LoadedSource` (value + revision), `SourceLoadContext`, `SourceDef` | `packages/process/src/plan/source.ts` |
741
+ | Plan — folds | `STANDARD_FOLDS` and the four it holds — `last`, `extremes`, `percentileRank`, `shape` — pre-registered by `createRegistry()`; each a plain `FoldDef`, so a consumer can `define` over one | `packages/process/src/plan/folds.ts` |
742
+ | Errors | `ProcessError` (base; `code` — a stable per-class literal, minification-proof, also surfaced on `Skipped`), `CycleError`, `UnconnectedInputError`, `MissingOutputError`, `UnsetSourceError` | `packages/process/src/errors.ts` |
743
+ | Node type helpers | `NodeSpec`, `NodeFactory`, `InletsFor`, `OutletsFor`, `OutletValue`, `SpecsForOutlets`, `DerivedOutput` | `packages/process/src/node.ts` |
744
744
 
745
745
  Note: this package's `npm test` includes a `test:dts` step that typechecks the
746
746
  **emitted** `dist/*.d.ts` from a consumer's perspective (`test-dts/`,
package/CHANGELOG.md CHANGED
@@ -8,7 +8,8 @@ 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.69.0...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.70.0...HEAD
12
+ [0.70.0]: https://github.com/pond-ts/pond/compare/v0.69.0...v0.70.0
12
13
  [0.69.0]: https://github.com/pond-ts/pond/compare/v0.68.0...v0.69.0
13
14
  [0.68.0]: https://github.com/pond-ts/pond/compare/v0.67.0...v0.68.0
14
15
  [0.67.0]: https://github.com/pond-ts/pond/compare/v0.66.0...v0.67.0
@@ -72,6 +73,71 @@ include new features and type-level changes; patch bumps are strictly additive.
72
73
 
73
74
  ## [Unreleased]
74
75
 
76
+ ## [0.70.0] — 2026-09-18
77
+
78
+ ### Added
79
+
80
+ - `@pond-ts/charts`: **`<BandChart sessionBreaks>`** — the same trading-axis
81
+ session break `<LineChart>` has had since v0.45.0. On a discontinuous
82
+ (`discontinuities` / `calendar`) axis the fill previously ran a near-vertical
83
+ sliver from one session's last sample to the next session's first, because
84
+ the collapsed overnight gap put them a pixel apart; `sessionBreaks` ends the
85
+ envelope at the close and re-starts it at the open, so a band and its centre
86
+ line break in step. A **scale** break, orthogonal to the NaN **data** gaps a
87
+ band always breaks at; default `false` (unchanged output). Decimation
88
+ composes: `decimateBand` folds each break instant into the pixel-column
89
+ edges and bakes a `NaN` sample at it, so no column merges two sessions'
90
+ envelopes. Stories `Axes/TradingTimeAxis / SessionBreaksBand` and
91
+ `Performance/Decimation / TradingSessionBreaksBand`.
92
+ - `@pond-ts/process`: **`ArityError`**, and **arity is now part of what `specId`
93
+ can judge**. A spec whose `inputs` count does not match the op's — including
94
+ one carrying no `inputs` at all — was named `p1:sma(;period=20)`, a _valid_
95
+ id for something that cannot compile, and then died at `compile` as a bare
96
+ `TypeError` reading `.length` of `undefined`, reaching the consumer as a
97
+ `Skipped` with **no `code`** (which under that contract means "op code
98
+ threw"). Arity is decidable from the registry alone, so strict `specId` now
99
+ raises `ArityError` and lenient mode marks the id `p1?:`.
100
+ `Registry.checkArity(op, inputs)` is the shared check `compile` uses too.
101
+
102
+ ### Changed
103
+
104
+ - `@pond-ts/financial`: **`StudyOutput.id` documents the `@pond-ts/process`
105
+ bridge**, and a new `test/catalog-process.test.ts` pins it. The field is a
106
+ *financial column suffix*; process's `OutputDef.id` is a *process outlet
107
+ id*; they share a name and are different namespaces. Process names its own
108
+ columns (`specId + OutputDef.id`) and matches an op's return to its outputs
109
+ positionally, so a study's own column names never reach it — which means a
110
+ registry bridging the two chooses its own suffixes, and for the **twelve**
111
+ multi-output studies that claim the bare prefix (`trix`, `superTrend`,
112
+ `klinger`, …) it must: process rejects `''` on a multi-output op, because
113
+ there the column would be named exactly the spec id, itself a legal column
114
+ reference. The map is `outputs.length > 1 && id === ''` → `'value'` — not
115
+ `id === ''`, which would rename all seventy-odd single-output columns for
116
+ nothing. Reported by a consumer that hit the throw at module load and
117
+ worked around it by hand. No API change: the descriptors, the studies and
118
+ the guard are all unchanged, and the round-trip test is what stops the two
119
+ packages drifting — `catalog.test.ts` validates a descriptor against its
120
+ *study*, so it is structurally blind to a cross-package disagreement.
121
+ - `@pond-ts/process`: **`Skipped.spec` echoes the request verbatim**, and its
122
+ `params` / `inputs` are typed `unknown` accordingly. The plan pass normalized
123
+ `params: null` to `{}`, so recomputing an id from the echo produced the
124
+ _defaulted spec's valid id_ — keying a broken persisted entry's report onto a
125
+ legitimate node. The selector pass echoed the original all along, so the two
126
+ passes disagreed. **Migration:** a consumer reading `entry.spec.params` now
127
+ narrows it (`entry.spec.params as Record<string, unknown>`, or a guard) —
128
+ which is the point, since the value may be exactly the malformed thing that
129
+ failed.
130
+ - `@pond-ts/process`: `specId(…, { validate: false })` is **total over
131
+ arbitrary JSON**, not just over well-typed specs. `params: null`, a
132
+ non-array `inputs`, and an input entry that is neither a column name nor a
133
+ spec each used to raise a `TypeError`; they are now named in the `p1?:`
134
+ namespace, distinctly enough that two differently-broken specs stay two ids.
135
+ Strict mode reports them as `ParamError` / `ArityError` / `ProcessError`
136
+ rather than crashing.
137
+
138
+ (All three reported by Tidal against 0.62.0, after adopting it.)
139
+
140
+
75
141
  ## [0.69.0] — 2026-09-13
76
142
 
77
143
  ### Added
@@ -27,6 +27,23 @@ export interface BandChartCommon<S extends SeriesSchema = SeriesSchema, VS exten
27
27
  * Denoise the underlying values with `smooth()`, not this.
28
28
  */
29
29
  curve?: Curve;
30
+ /**
31
+ * Break the envelope at each **trading-axis discontinuity** (a session / day /
32
+ * lunch close→open) when the container renders on a trading-time axis (a
33
+ * `discontinuities` / `calendar` provider). **Omitted ⇒ `false`**: the fill
34
+ * connects the last pre-close sample straight to the next open across the
35
+ * collapsed gap (a near-vertical sliver). `true` ends the fill at the close
36
+ * and re-starts it at the open — the intraday look, where one session's
37
+ * envelope shouldn't visually flow into the next. Same semantics as
38
+ * {@link LineChart}'s `sessionBreaks`, so a band and its centre line break in
39
+ * step.
40
+ *
41
+ * This is a **scale** break (driven by the axis's collapsed gaps), orthogonal
42
+ * to a **data** break (a NaN run on either edge, which a band always breaks
43
+ * at). A no-op on a continuous axis (no provider) or a provider without
44
+ * `boundaries`.
45
+ */
46
+ sessionBreaks?: boolean;
30
47
  /**
31
48
  * **M4 viewport decimation** (charts decimator wave). **Omitted ⇒ `true`**:
32
49
  * once the visible envelope is denser than ~2 samples per device pixel, it is
@@ -34,6 +51,8 @@ export interface BandChartCommon<S extends SeriesSchema = SeriesSchema, VS exten
34
51
  * the samples span, so it covers the same pixels from O(plot width) points.
35
52
  * Applies with a linear `curve`; pass `false` to always fill every sample, or
36
53
  * `{ threshold }` to tune. Shares {@link LineChart}'s `DecimateOption`.
54
+ * Composes with {@link sessionBreaks}: the break instants are folded into the
55
+ * pixel-column edges so no column merges two sessions' envelopes.
37
56
  */
38
57
  decimate?: DecimateOption;
39
58
  /**
@@ -92,6 +111,6 @@ export type BandChartProps<S extends SeriesSchema = SeriesSchema, VS extends Val
92
111
  * </Layers>
93
112
  * ```
94
113
  */
95
- export declare function BandChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, lower, upper, as: semantic, axis, curve, decimate, legend, index, }: BandChartProps<S, VS>): null;
114
+ export declare function BandChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, lower, upper, as: semantic, axis, curve, sessionBreaks, decimate, legend, index, }: BandChartProps<S, VS>): null;
96
115
  export {};
97
116
  //# sourceMappingURL=BandChart.d.ts.map
package/dist/BandChart.js CHANGED
@@ -6,6 +6,9 @@ import { resolveCurve } from './curve.js';
6
6
  import { ContainerContext, LayersContext } from './context.js';
7
7
  import { legendLabelFor, useLegendItems, } from './swatch.js';
8
8
  import { useSlotKey } from './use-slot-key.js';
9
+ /** Stable empty boundary list — so `sessionBreaks={false}` keeps a referentially
10
+ * constant array and the layer entry isn't rebuilt every render. */
11
+ const NO_BREAKS = [];
9
12
  /**
10
13
  * A variance-band draw layer: fills the envelope between the `lower` and `upper`
11
14
  * columns of `series` (typically `rollingByColumn` percentiles), gap-aware, and
@@ -23,7 +26,7 @@ import { useSlotKey } from './use-slot-key.js';
23
26
  * </Layers>
24
27
  * ```
25
28
  */
26
- export function BandChart({ series, lower, upper, as: semantic, axis, curve, decimate = true, legend, index = 0, }) {
29
+ export function BandChart({ series, lower, upper, as: semantic, axis, curve, sessionBreaks = false, decimate = true, legend, index = 0, }) {
27
30
  const container = useContext(ContainerContext);
28
31
  if (container === null) {
29
32
  throw new Error('<BandChart> must be rendered inside a <ChartContainer>');
@@ -35,6 +38,18 @@ export function BandChart({ series, lower, upper, as: semantic, axis, curve, dec
35
38
  const bs = useMemo(() => series instanceof ValueSeries
36
39
  ? bandFromValueSeries(series, lower, upper)
37
40
  : bandFromTimeSeries(series, lower, upper), [series, lower, upper]);
41
+ // Trading-axis session breaks: the collapse instants inside this band's span
42
+ // (session/day/lunch opens the axis skips). Data instants, not pixels — so the
43
+ // set is view-independent (pan/zoom reuse it). Only computed when opted in and
44
+ // the container carries a boundary-reporting discontinuity provider. The same
45
+ // lookup `<LineChart>` does, so a band and its centre line break identically.
46
+ const sessionBreakInstants = useMemo(() => {
47
+ const provider = container.discontinuities;
48
+ if (!sessionBreaks || provider?.boundaries === undefined || bs.length < 2) {
49
+ return NO_BREAKS;
50
+ }
51
+ return provider.boundaries(bs.x[0], bs.x[bs.length - 1]);
52
+ }, [sessionBreaks, container.discontinuities, bs]);
38
53
  // Styling: semantic identifier → theme band style. The single styling channel.
39
54
  const { band } = container.theme;
40
55
  const style = (semantic !== undefined ? band[semantic] : undefined) ?? band.default;
@@ -118,7 +133,7 @@ export function BandChart({ series, lower, upper, as: semantic, axis, curve, dec
118
133
  },
119
134
  ];
120
135
  },
121
- draw: (ctx, xScale, yScale) => drawBand(ctx, bs, xScale, yScale, style, curveFactory, decimate),
136
+ draw: (ctx, xScale, yScale) => drawBand(ctx, bs, xScale, yScale, style, curveFactory, sessionBreakInstants, decimate),
122
137
  },
123
138
  axisId: axis,
124
139
  index,
@@ -129,6 +144,7 @@ export function BandChart({ series, lower, upper, as: semantic, axis, curve, dec
129
144
  upper,
130
145
  style,
131
146
  curveFactory,
147
+ sessionBreakInstants,
132
148
  decimate,
133
149
  axis,
134
150
  index,
package/dist/band.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type CurveFactory } from 'd3-shape';
2
2
  import type { BandSeries } from './data.js';
3
- import type { Scale } from './line.js';
3
+ import { type Scale } from './line.js';
4
4
  import type { BandStyle } from './theme.js';
5
5
  import type { LayerDrawStats } from './context.js';
6
6
  import { type DecimateOption } from './decimate.js';
@@ -25,9 +25,17 @@ export declare function bandExtent(band: BandSeries): [number, number] | null;
25
25
  * filled envelope's break wants its own treatment (sharp edge vs. blurred),
26
26
  * still to be designed; for now a band always breaks honestly at a gap.
27
27
  *
28
+ * `boundaries` are trading-axis **session-break** instants (`<BandChart
29
+ * sessionBreaks>`): the envelope is split into per-session runs wherever one
30
+ * falls between two consecutive samples (see {@link sessionRuns}), each run its
31
+ * own closed subpath, so the fill ends at the last pre-close sample and re-starts
32
+ * at the first post-open one — a **scale** break, orthogonal to the NaN **data**
33
+ * gaps handled within each run. With no boundaries the output is identical to a
34
+ * single-pass draw. Mirrors `drawLine`'s treatment exactly.
35
+ *
28
36
  * `band.lower` (a `Float64Array`) is the datum iterable; every accessor reads by
29
37
  * index, so there's no per-point object allocation. `globalAlpha` carries the
30
38
  * opacity and is restored so it doesn't leak into later layers.
31
39
  */
32
- export declare function drawBand(ctx: CanvasRenderingContext2D, band: BandSeries, xScale: Scale, yScale: Scale, style: BandStyle, curve?: CurveFactory, decimate?: DecimateOption): LayerDrawStats;
40
+ export declare function drawBand(ctx: CanvasRenderingContext2D, band: BandSeries, xScale: Scale, yScale: Scale, style: BandStyle, curve?: CurveFactory, boundaries?: readonly number[], decimate?: DecimateOption): LayerDrawStats;
33
41
  //# sourceMappingURL=band.d.ts.map
package/dist/band.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import { area as d3area, curveLinear } from 'd3-shape';
2
+ import { sessionRuns } from './line.js';
2
3
  import { cullBandSeries } from './culling.js';
3
4
  import { decimateBand } from './decimate.js';
4
5
  import { gapUnscalable } from './gaps.js';
6
+ /** Shared empty boundary list — passed to `sessionRuns` when a decimated band
7
+ * already carries its session breaks as baked-in `NaN` samples. */
8
+ const EMPTY_BOUNDARIES = [];
5
9
  /**
6
10
  * The `[min, max]` vertical extent of the **drawn** band — the lowest `lower`
7
11
  * and highest `upper` over samples where both edges are finite — or `null` if
@@ -37,11 +41,19 @@ export function bandExtent(band) {
37
41
  * filled envelope's break wants its own treatment (sharp edge vs. blurred),
38
42
  * still to be designed; for now a band always breaks honestly at a gap.
39
43
  *
44
+ * `boundaries` are trading-axis **session-break** instants (`<BandChart
45
+ * sessionBreaks>`): the envelope is split into per-session runs wherever one
46
+ * falls between two consecutive samples (see {@link sessionRuns}), each run its
47
+ * own closed subpath, so the fill ends at the last pre-close sample and re-starts
48
+ * at the first post-open one — a **scale** break, orthogonal to the NaN **data**
49
+ * gaps handled within each run. With no boundaries the output is identical to a
50
+ * single-pass draw. Mirrors `drawLine`'s treatment exactly.
51
+ *
40
52
  * `band.lower` (a `Float64Array`) is the datum iterable; every accessor reads by
41
53
  * index, so there's no per-point object allocation. `globalAlpha` carries the
42
54
  * opacity and is restored so it doesn't leak into later layers.
43
55
  */
44
- export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear, decimate = true) {
56
+ export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear, boundaries = [], decimate = true) {
45
57
  const sourceCount = band.length; // pre-cull, pre-decimation (for draw stats)
46
58
  // Viewport culling (Phase 2): clip the envelope to the visible slice (+1 each
47
59
  // side) before filling, so a pan strokes O(visible). The solid fill has no
@@ -54,11 +66,13 @@ export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear,
54
66
  // pixels. Gated off a smoothing `curve` (which would distort the per-column
55
67
  // envelope) and `decimate === false`; `decimateBand` itself no-ops on a sparse
56
68
  // envelope or a domainless test scale, so this stays byte-identical there.
69
+ // The session-break instants ride along so a column never straddles a break
70
+ // and the decimated envelope carries the breaks as baked-in NaN samples.
57
71
  let decimated = false;
58
72
  if (decimate !== false && curve === curveLinear) {
59
73
  const k = typeof decimate === 'object' ? decimate.threshold : undefined;
60
74
  const before = band;
61
- band = decimateBand(band, xScale, ctx, k);
75
+ band = decimateBand(band, xScale, ctx, k, boundaries);
62
76
  decimated = band !== before;
63
77
  }
64
78
  // An edge with no position on the y scale becomes an ordinary NaN gap, so the
@@ -73,18 +87,32 @@ export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear,
73
87
  if (gapLower !== band.lower || gapUpper !== band.upper) {
74
88
  band = { ...band, lower: gapLower, upper: gapUpper };
75
89
  }
76
- const gen = d3area()
77
- .defined((_, i) => Number.isFinite(band.lower[i]) && Number.isFinite(band.upper[i]))
78
- .x((_, i) => xScale(band.x[i]))
79
- .y0((_, i) => yScale(band.lower[i]))
80
- .y1((_, i) => yScale(band.upper[i]))
81
- .curve(curve)
82
- .context(ctx);
90
+ // Split into independent index runs at each session break; no boundary inside
91
+ // the data ⇒ one run over the whole envelope (the hot path — no slicing, so the
92
+ // draw is byte-identical to the pre-boundary single pass). When the band was
93
+ // decimated, `decimateBand` already baked the breaks in as NaN samples aligned
94
+ // to the break instants, so re-cutting here would mis-attribute the boundary
95
+ // samples — pass `[]` and let the baked-in breaks split the sessions.
96
+ const runs = sessionRuns(band.x, band.length, decimated ? EMPTY_BOUNDARIES : boundaries);
97
+ const singleRun = runs.length === 1;
83
98
  ctx.save();
84
99
  ctx.fillStyle = style.fill;
85
100
  ctx.globalAlpha = style.opacity;
101
+ // One path across every run. Each run's generator opens with its own moveTo
102
+ // (and closes its own polygon), so a run boundary is a clean pen-up — the
103
+ // session break — and a single fill covers them all.
86
104
  ctx.beginPath();
87
- gen(band.lower);
105
+ for (const [s, e] of runs) {
106
+ const gen = d3area()
107
+ .defined((_, j) => Number.isFinite(band.lower[s + j]) &&
108
+ Number.isFinite(band.upper[s + j]))
109
+ .x((_, j) => xScale(band.x[s + j]))
110
+ .y0((_, j) => yScale(band.lower[s + j]))
111
+ .y1((_, j) => yScale(band.upper[s + j]))
112
+ .curve(curve)
113
+ .context(ctx);
114
+ gen(singleRun ? band.lower : band.lower.subarray(s, e));
115
+ }
88
116
  ctx.fill();
89
117
  ctx.restore();
90
118
  return { sourceCount, drawnCount: band.length, decimated };
@@ -224,8 +224,17 @@ export declare function m4Polyline(edges: Float64Array, mn: Float64Array, mx: Fl
224
224
  * `upper` are finite **together** per sample (the paired-percentile shape bands
225
225
  * are built from); a column where only one edge has finite samples would bin a
226
226
  * band segment that no single sample carried.
227
+ *
228
+ * `boundaries` are trading-axis session-break instants (`<BandChart
229
+ * sessionBreaks>`), handled exactly as {@link decimateM4} does for a line: each
230
+ * in-domain instant is unioned into the bucket edges so no column merges two
231
+ * sessions' envelopes across the discontinuity, **and** a `NaN` sample is
232
+ * emitted at the instant so the fill ends at the close and re-starts at the
233
+ * open — otherwise the closing and opening columns would sit as adjacent finite
234
+ * samples and the envelope would flow straight across the collapsed gap. The
235
+ * caller's `sessionRuns` then sees the break baked in and passes no boundaries.
227
236
  */
228
- export declare function decimateBand(band: BandSeries, xScale: Scale, ctx: CanvasRenderingContext2D, k?: number): BandSeries;
237
+ export declare function decimateBand(band: BandSeries, xScale: Scale, ctx: CanvasRenderingContext2D, k?: number, boundaries?: readonly number[]): BandSeries;
229
238
  /**
230
239
  * Decimate an {@link OhlcSeries} to one **aggregate candle per device-pixel
231
240
  * column** — `open = first`, `high = max`, `low = min`, `close = last` over the
package/dist/decimate.js CHANGED
@@ -400,8 +400,17 @@ export function m4Polyline(edges, mn, mx, first, last, W, breakAt = NO_BREAKS) {
400
400
  * `upper` are finite **together** per sample (the paired-percentile shape bands
401
401
  * are built from); a column where only one edge has finite samples would bin a
402
402
  * band segment that no single sample carried.
403
+ *
404
+ * `boundaries` are trading-axis session-break instants (`<BandChart
405
+ * sessionBreaks>`), handled exactly as {@link decimateM4} does for a line: each
406
+ * in-domain instant is unioned into the bucket edges so no column merges two
407
+ * sessions' envelopes across the discontinuity, **and** a `NaN` sample is
408
+ * emitted at the instant so the fill ends at the close and re-starts at the
409
+ * open — otherwise the closing and opening columns would sit as adjacent finite
410
+ * samples and the envelope would flow straight across the collapsed gap. The
411
+ * caller's `sessionRuns` then sees the break baked in and passes no boundaries.
403
412
  */
404
- export function decimateBand(band, xScale, ctx, k = 2) {
413
+ export function decimateBand(band, xScale, ctx, k = 2, boundaries = []) {
405
414
  if (!shouldDecimateCount(band.length, ctx, k))
406
415
  return band;
407
416
  const dom = scaleDomain(xScale);
@@ -412,18 +421,50 @@ export function decimateBand(band, xScale, ctx, k = 2) {
412
421
  if (invert === null || plotWidthCss === null)
413
422
  return band;
414
423
  const W = deviceBucketCount(ctx);
415
- const edges = pixelEdges(invert, plotWidthCss, W);
424
+ const pixels = pixelEdges(invert, plotWidthCss, W);
425
+ // Session-break instants inside the visible domain — unioned into the edges
426
+ // (so a column never straddles a break) AND marked as explicit break samples.
427
+ // `mergeGapEdges` keeps their exact values, so the set matches the edges.
428
+ const breaks = boundaries.length > 0
429
+ ? boundaries.filter((b) => b > dom[0] && b < dom[1])
430
+ : [];
431
+ const edges = breaks.length > 0 ? mergeGapEdges(pixels, breaks, dom[0], dom[1]) : pixels;
432
+ const buckets = edges.length - 1;
416
433
  const lowerMin = new Float64Column(band.lower, band.length).binBy(band.x, edges, 'min');
417
434
  const upperMax = new Float64Column(band.upper, band.length).binBy(band.x, edges, 'max');
418
- const x = new Float64Array(W);
419
- const lower = new Float64Array(W);
420
- const upper = new Float64Array(W);
421
- for (let b = 0; b < W; b += 1) {
422
- x[b] = (edges[b] + edges[b + 1]) / 2; // column centre
423
- lower[b] = lowerMin[b]; // NaN on an empty column the fill break
424
- upper[b] = upperMax[b];
435
+ const breakAt = breaks.length > 0 ? new Set(breaks) : null;
436
+ // One sample per column + one NaN break slot per session break. `breaks` are
437
+ // strictly inside the domain and `mergeGapEdges` keeps every one, so each
438
+ // lands on some `edges[b]` with `b > 0` and the arrays fill exactly; the
439
+ // subarray trim below is a guard against a provider instant that sorts ahead
440
+ // of the first pixel edge under float rounding, not an expected path.
441
+ const cap = buckets + (breakAt === null ? 0 : breakAt.size);
442
+ const x = new Float64Array(cap);
443
+ const lower = new Float64Array(cap);
444
+ const upper = new Float64Array(cap);
445
+ let n = 0;
446
+ for (let b = 0; b < buckets; b += 1) {
447
+ // Explicit session break: this column opens a new session → end the fill
448
+ // first. A NaN on both edges is the band's own gap signal (`.defined`).
449
+ if (breakAt !== null && b > 0 && breakAt.has(edges[b])) {
450
+ x[n] = edges[b];
451
+ lower[n] = NaN;
452
+ upper[n] = NaN;
453
+ n += 1;
454
+ }
455
+ x[n] = (edges[b] + edges[b + 1]) / 2; // column centre
456
+ lower[n] = lowerMin[b]; // NaN on an empty column → the fill break
457
+ upper[n] = upperMax[b];
458
+ n += 1;
425
459
  }
426
- return { x, lower, upper, length: W };
460
+ return n === cap
461
+ ? { x, lower, upper, length: n }
462
+ : {
463
+ x: x.subarray(0, n),
464
+ lower: lower.subarray(0, n),
465
+ upper: upper.subarray(0, n),
466
+ length: n,
467
+ };
427
468
  }
428
469
  /**
429
470
  * Decimate an {@link OhlcSeries} to one **aggregate candle per device-pixel
@@ -50,6 +50,25 @@ export declare function ticks(sessions: Session[], stepMs: number): TimeSeries<t
50
50
  * pen-up. (Plain {@link ticks} walks continuously across sessions, so close ≈
51
51
  * next open and the break is invisible — this is the fixture that shows it.) */
52
52
  export declare function gappingTicks(sessions: Session[], stepMs: number): TimeSeries<typeof tickSchema>;
53
+ export declare const envelopeSchema: readonly [{
54
+ readonly name: "time";
55
+ readonly kind: "time";
56
+ }, {
57
+ readonly name: "price";
58
+ readonly kind: "number";
59
+ }, {
60
+ readonly name: "lo";
61
+ readonly kind: "number";
62
+ }, {
63
+ readonly name: "hi";
64
+ readonly kind: "number";
65
+ }];
66
+ /** {@link gappingTicks} plus a `lo` / `hi` envelope around the price (a slowly
67
+ * breathing spread), so a `<BandChart>` on the trading axis shows the same
68
+ * overnight jump the line does: connected, the fill runs a near-vertical sliver
69
+ * from one session's close to the next open; with `sessionBreaks` it ends at
70
+ * the close and re-starts at the open. */
71
+ export declare function gappingEnvelope(sessions: Session[], stepMs: number): TimeSeries<typeof envelopeSchema>;
53
72
  export declare const OHLC: {
54
73
  readonly open: {
55
74
  readonly from: "price";
@@ -195,6 +195,30 @@ export function gappingTicks(sessions, stepMs) {
195
195
  });
196
196
  return new TimeSeries({ name: 'ticks', schema: tickSchema, rows });
197
197
  }
198
+ export const envelopeSchema = [
199
+ { name: 'time', kind: 'time' },
200
+ { name: 'price', kind: 'number' },
201
+ { name: 'lo', kind: 'number' },
202
+ { name: 'hi', kind: 'number' },
203
+ ];
204
+ /** {@link gappingTicks} plus a `lo` / `hi` envelope around the price (a slowly
205
+ * breathing spread), so a `<BandChart>` on the trading axis shows the same
206
+ * overnight jump the line does: connected, the fill runs a near-vertical sliver
207
+ * from one session's close to the next open; with `sessionBreaks` it ends at
208
+ * the close and re-starts at the open. */
209
+ export function gappingEnvelope(sessions, stepMs) {
210
+ const rows = [];
211
+ let i = 0;
212
+ sessions.forEach((s, si) => {
213
+ const base = 100 + si * 6; // each session gaps ~6 above the last
214
+ for (let t = s.open; t < s.close; t += stepMs, i++) {
215
+ const price = base + 4 * Math.sin(i / 18) + 1.5 * Math.sin(i / 3.5);
216
+ const spread = 1.5 + 0.8 * Math.sin(i / 40);
217
+ rows.push([t, price, price - spread, price + spread]);
218
+ }
219
+ });
220
+ return new TimeSeries({ name: 'envelope', schema: envelopeSchema, rows });
221
+ }
198
222
  export const OHLC = {
199
223
  open: { from: 'price', using: 'first' },
200
224
  high: { from: 'price', using: 'max' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.69.0",
3
+ "version": "0.70.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first React time-series charts for pond-ts: line, area, band, bar, scatter, box, candlestick, with cursors, selection and annotations",
6
6
  "keywords": [
@@ -62,8 +62,8 @@
62
62
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
63
63
  },
64
64
  "peerDependencies": {
65
- "@pond-ts/react": "^0.69.0",
66
- "pond-ts": "^0.69.0",
65
+ "@pond-ts/react": "^0.70.0",
66
+ "pond-ts": "^0.70.0",
67
67
  "react": "^18.0.0 || ^19.0.0"
68
68
  },
69
69
  "devDependencies": {