@pond-ts/charts 0.48.1 → 0.49.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +200 -1
  2. package/dist/AreaChart.d.ts +18 -1
  3. package/dist/AreaChart.js +23 -2
  4. package/dist/BandChart.d.ts +21 -2
  5. package/dist/BandChart.js +68 -9
  6. package/dist/BarChart.d.ts +12 -1
  7. package/dist/BarChart.js +34 -1
  8. package/dist/BoxPlot.d.ts +18 -1
  9. package/dist/BoxPlot.js +60 -3
  10. package/dist/Candlestick.d.ts +8 -1
  11. package/dist/Candlestick.js +40 -6
  12. package/dist/ChartContainer.d.ts +23 -13
  13. package/dist/ChartContainer.js +86 -32
  14. package/dist/ChartRow.js +22 -3
  15. package/dist/Layers.js +37 -14
  16. package/dist/Legend.d.ts +62 -0
  17. package/dist/Legend.js +169 -0
  18. package/dist/LineChart.d.ts +20 -1
  19. package/dist/LineChart.js +23 -2
  20. package/dist/ScatterChart.d.ts +8 -1
  21. package/dist/ScatterChart.js +24 -1
  22. package/dist/XAxis.js +9 -2
  23. package/dist/YAxis.d.ts +9 -1
  24. package/dist/YAxis.js +27 -6
  25. package/dist/annotations.d.ts +21 -3
  26. package/dist/annotations.js +36 -15
  27. package/dist/area.d.ts +2 -1
  28. package/dist/area.js +29 -4
  29. package/dist/band.d.ts +2 -1
  30. package/dist/band.js +18 -1
  31. package/dist/bars.js +8 -1
  32. package/dist/box.d.ts +14 -1
  33. package/dist/box.js +56 -2
  34. package/dist/context.d.ts +51 -4
  35. package/dist/culling.d.ts +165 -0
  36. package/dist/culling.js +286 -0
  37. package/dist/data.d.ts +3 -1
  38. package/dist/decimate.d.ts +193 -0
  39. package/dist/decimate.js +359 -0
  40. package/dist/format.d.ts +20 -11
  41. package/dist/index.d.ts +6 -0
  42. package/dist/index.js +6 -0
  43. package/dist/line.d.ts +2 -1
  44. package/dist/line.js +38 -3
  45. package/dist/ohlc.js +6 -1
  46. package/dist/scatter.js +42 -7
  47. package/dist/swatch.d.ts +104 -0
  48. package/dist/swatch.js +96 -0
  49. package/dist/theme.d.ts +27 -0
  50. package/dist/theme.js +12 -0
  51. package/dist/useChartLegend.d.ts +106 -0
  52. package/dist/useChartLegend.js +122 -0
  53. package/dist/yticks.d.ts +20 -0
  54. package/dist/yticks.js +28 -0
  55. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -8,7 +8,8 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
8
8
  tag, so this file covers them all. Pre-1.0: minor bumps may include new features
9
9
  and type-level changes; patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pond-ts/pond/compare/v0.48.1...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.49.0...HEAD
12
+ [0.49.0]: https://github.com/pond-ts/pond/compare/v0.48.1...v0.49.0
12
13
  [0.48.1]: https://github.com/pond-ts/pond/compare/v0.48.0...v0.48.1
13
14
  [0.48.0]: https://github.com/pond-ts/pond/compare/v0.47.0...v0.48.0
14
15
  [0.47.0]: https://github.com/pond-ts/pond/compare/v0.46.0...v0.47.0
@@ -48,6 +49,204 @@ and type-level changes; patch bumps are strictly additive.
48
49
 
49
50
  ## [Unreleased]
50
51
 
52
+ ## [0.49.0] — 2026-07-21
53
+
54
+ ### Added
55
+
56
+ - **charts:** **Annotation theme roles** (#508 item 3, Tidal vol-surface
57
+ friction). `theme.annotation` gains an optional **`roles`** map (role name →
58
+ `{ color, fillOpacity? }`), and `<Baseline>` / `<Marker>` / `<Region>` gain a
59
+ **`role`** prop that recolours that mark from `roles[role]` while keeping the
60
+ shared depth ramp — so a smile can place a green ATM baseline, a distinct
61
+ reference marker, and an amber zone at once without the whole register
62
+ shifting together. Resolves `roles[role] ?? annotation` (an unknown/unset
63
+ role is the base register). **Colour stays a theme concern** — there is no
64
+ per-mark colour prop (the one-styling-channel discipline; consistent with the
65
+ per-box red/green reject). `cssVarTheme` carries the `roles` map through
66
+ unchanged (deep-merged). `theme.annotation.roles` is optional, so existing
67
+ themes are unaffected.
68
+ - **charts:** **`<BoxPlot id>` — box selection** (#508 item 5, Tidal
69
+ vol-surface friction). A `BoxPlot` with an `id` is now clickable on the same
70
+ id-gated contract `<BarChart>` / `<ScatterChart>` carry
71
+ (`selected`/`onSelect`, `hovered`/`onHover`): a click anywhere on a box —
72
+ body or whisker, a range-only bid→ask segment included — selects it via
73
+ rect-containment (`boxAt`, the interval-mark analog of `barAt`, **not** the
74
+ continuous nearest-point threshold), and the selected box outlines (hovered
75
+ fainter, reusing `theme.box.stroke`, no new token). `key` is the box's `x`
76
+ (its span begin). Without an `id` the box stays display-only. Independent of
77
+ the box's `cursorFlag` cursor opt-out — selection rides the separate
78
+ `hitTest` path.
79
+ - **charts:** **`<YAxis tickCount>` + height-derived tick density** (#508
80
+ item 4, Tidal vol-surface friction). The y axis's auto-tick count now
81
+ follows the **row height** by default — a short strip (e.g. a 72px
82
+ histogram lane) gets fewer ticks than a tall row instead of crushing the
83
+ same ~5 labels into the space (mirrors the width-derived trading-time x
84
+ axis). A new **`tickCount`** prop pins an explicit target; explicit
85
+ `ticks` still overrides both. The count is resolved once per axis on the
86
+ row (`resolveYTickCount`) and shared by the axis labels, the cursor-readout
87
+ formatter, and the row gridlines, so a label / its gridline / its readout
88
+ stay on the same `ticks(count)` (replaces three hardcoded `5`s that agreed
89
+ by convention).
90
+
91
+ - **charts:** **`<Legend>` — the series key** (#508 item 2, Tidal vol-surface
92
+ friction; design per the sender's sketch on the issue). Every draw layer now
93
+ registers its readout identity (`as ?? column`) plus its **resolved** style
94
+ as a `SwatchSpec` (line stroke+dash, area fill+line, band envelope, scatter
95
+ dot, box whisker, bar fill, candle up/down pair), and `<Legend />` renders
96
+ the registry as a small card anchored to a corner of the rows block
97
+ (`placement`, default `top-right`) — so the key can never drift from the
98
+ plot. Rows follow chart-row → declaration order; identities collapse
99
+ (`id ?? label`, as the tracker readout merges keys); a **stacked bar
100
+ registers one row per group** with its resolved fill. Per-layer control via
101
+ a new `legend` prop on all seven marks: `false` opts out, a string renames.
102
+ Interactions are **id-gated** (the shipped selection contract): rows whose
103
+ layer has an `id` echo hover into the container and toggle selection on
104
+ click; `onRowHover` / `onRowClick` take over when provided; series
105
+ show/hide deliberately stays consumer-side. **Scope follows placement:** at
106
+ the container level the card lists every row; placed inside a `<Layers>` it
107
+ scopes to that `<ChartRow>` and anchors to that row's plot (a per-row legend
108
+ needs no prop). `<Legend items={…}>` renders explicit rows — inside a
109
+ container or standalone (a dashboard-side key).
110
+ New optional `theme.legend` slot (background/border/text; derives from
111
+ `chip`/`axis` tokens when absent, so hand-built themes keep compiling).
112
+ **Headless variant:** `useChartLegend()` serves the entries as data —
113
+ `rows` (**items grouped by chart row**; each item is
114
+ `label` + resolved swatch + `id` + live `selected`/`hovered`; a flat list
115
+ is `rows.flatMap((r) => r.items)`) plus chart-synced `hover`/`select`
116
+ verbs, the container's axis `gutters` (for aligning a custom layout to the
117
+ plot), and **`cursorTime`** (the cursor's axis instant, `null` when idle) —
118
+ the seams for a legend that is a design of its own: horizontal chip rows,
119
+ ticker-compare with the secondary dimmed, and **current-or-cursor values**
120
+ per item (`series.nearest(cursorTime)`, else the latest sample; item
121
+ labels also match the tracker's sample labels, so an `onTrackerChanged`
122
+ merge is a label-keyed join); `<Legend>` itself renders through the same
123
+ core, so the two can't disagree. **Scope follows placement** for both the
124
+ card and the hook: inside a `<Layers>` they scope to that `<ChartRow>` and
125
+ the card anchors to that row's plot; container-level stays all-rows. Card
126
+ polish per the first design pass: plot-area-inset placement, selection
127
+ reads by contrast (the selected item bold, others dulled — the
128
+ ticker-compare treatment), a dashed line's swatch hand-renders as a
129
+ canonical three-dash glyph, and a bar's swatch is a centred rounded square.
130
+ New exports: `Legend`, `LegendProps`, `LegendPlacement`, `SwatchSpec`,
131
+ `LegendItemInput`, `useChartLegend`, `ChartLegend`, `LegendRow`,
132
+ `LegendItem`.
133
+ - **charts:** **`cursorFormat` reaches value axes** (#508 item 1, Tidal
134
+ vol-surface friction) — the container's readout channel now applies on a
135
+ value x axis exactly as on a time axis: a **string** is a d3 _number_
136
+ specifier there, a **function** receives
137
+ `(value, { grain: undefined, defaultText })` (no time grain to hand over;
138
+ `defaultText` is the axis-default text). The readout also gains its
139
+ documented precedence on the axis strip on **every** axis kind:
140
+ `cursorFormat → axis format → container`, so an explicit `<XAxis format>`
141
+ keeps ticks terse while `cursorFormat` makes the cursor pill / marker
142
+ indicators / annotation auto-labels precise (`+2.0σ` ticks, `+1.83σ` pill).
143
+ A `transform`ed axis is exempt (its pill speaks the derived unit); a
144
+ category axis reads names and ignores `cursorFormat`. Type change:
145
+ `CursorFormat`'s function form widens `ctx.grain` from `TimeGrain` to
146
+ `TimeGrain | undefined`; the frame gains an optional `formatReadout`
147
+ channel (see Fixed below for the `formatTime` clarification).
148
+ - **charts:** **M4 line decimation** (charts decimator wave, Phase 3) — the
149
+ `<LineChart>` now draws from the per-device-pixel-column min/max/first/last
150
+ (pond's `binBy(…, 'minMaxFirstLast')`) once the visible data exceeds ~2 samples
151
+ per pixel, so a **fully-visible** dense series (which viewport culling can't
152
+ help) draws from O(plot width) points instead of every sample: a 1M-point line
153
+ drops from ~34 ms/frame to ~3.4 ms, under the 60 fps budget. It is **visually
154
+ lossless** (an e2e pixel-diff bounds the decimated-vs-full difference to a thin
155
+ sub-pixel AA seam) and **preserves single-sample anomalies** (the min/max
156
+ channel keeps the spike M4 is chosen over LTTB to keep). Bucket edges are the
157
+ scale's pixel range inverted back to key space, so each bucket is one pixel
158
+ column on **any** scale, including a non-affine `TradingTimeScale`. Auto-on with
159
+ an opt-out / tuning prop
160
+ `decimate={false | { threshold }}` (new `DecimateOption` export). **All gap
161
+ modes** decimate: a §2.2 **gap-edge union** folds each ≥1-pixel interior gap's
162
+ boundaries into the bucket edges, so the gap reduces to its own empty (`NaN`)
163
+ bucket with exact pre/post-gap values — `'empty'` breaks precisely, `'none'`
164
+ bridges, and the `dashed`/`step`/`fade` connectors still draw across the gap.
165
+ **`sessionBreaks` decimate too** — the same union folds each trading-axis
166
+ session-break instant into the bucket edges so no bucket merges two sessions'
167
+ extremes across the discontinuity, and `sessionRuns` then splits the decimated
168
+ series into clean per-session subpaths (a dense intraday line on a trading-time
169
+ axis decimates _and_ breaks at each session open). Still gated off only a
170
+ non-linear `curve` (documented backlog). Interaction reads the source series
171
+ (§2.3), so readouts/selection are unaffected.
172
+ - **charts:** M4 decimation extended to **`<AreaChart>` and `<BandChart>`** (same
173
+ auto-on `decimate={false | { threshold }}` prop). An **area** reuses the line M4
174
+ on its outline (gap-edge union included) with the fill following under the
175
+ full-series gradient — so a dense filled area shrinks its fill + outline work to
176
+ O(plot width). A **band** decimates to the per-pixel-column **min(`lower`) /
177
+ max(`upper`)** — the widest envelope the samples span, so it can never invert
178
+ (§2.5) and covers the same pixels; the win is the canvas fill (≈W vertices vs
179
+ every sample). Both gated off a non-linear `curve`; interaction unaffected.
180
+ - **charts:** **Viewport culling** (charts decimator wave, Phase 2). Line, area,
181
+ and band layers now clip to the **visible slice** of their key column — plus
182
+ one entry/exit point each side so the segment crossing each plot edge still
183
+ draws — before any path work, so a pan/zoom repaint costs O(visible) instead
184
+ of O(N). On a large series zoomed in, the per-frame `drawLine` path-generation
185
+ cost drops from ~29 ms at 1M points (over the 16.67 ms/60 fps budget — the
186
+ cause of the #256 pan-FPS collapse) to ~0.06 ms; a fully-visible series is
187
+ unchanged (culling no-ops, keeping that draw byte-identical — the baseline the
188
+ Phase 3 M4 decimator addresses). Interaction is unaffected: `sampleAt` /
189
+ `hitTest` read the full source series, so a hover readout or selection never
190
+ shifts when the window resizes (the decimator RFC §2.3 invariant). Culling is
191
+ automatic and internal — no API change.
192
+ - **charts:** Viewport culling extended to the **per-mark** layers — scatter,
193
+ bars, candles, and boxes. These loop over independent marks with index-keyed
194
+ accessors (a scatter's `colorAt(i)`, a bar's `begin[i]` selection match), so
195
+ rather than a subarray view they cull by **restricting the draw loop to the
196
+ visible index range** (the original `i` is preserved, so every accessor and
197
+ selection/hover match stays correct). Point marks (scatter) use the same
198
+ entry/exit window as lines; interval marks (bars / candles / boxes) use a
199
+ span-overlap window that keeps a wide mark crossing a plot edge. Same
200
+ automatic, internal, interaction-safe behaviour — a 500k-bar chart zoomed in
201
+ drops from ~23 ms/frame to ~0.16 ms.
202
+ - **charts:** Scatter culling is now **radius-aware**. The per-mark index margin
203
+ above measures neighbours in sample count, but a scatter mark's disc has a
204
+ pixel radius independent of sample spacing — so a dense scatter of fat marks
205
+ could drop an edge bubble whose _centre_ sits several samples off-screen while
206
+ its _disc_ still overlaps the plot edge (a subtle flicker under pan). The
207
+ scatter draw now widens the visible window by its max drawn radius (converted
208
+ px→data through the scale, plus any `offsetPx` nudge) before culling, so every
209
+ mark that can paint into the plot is kept. Small-radius scatters are unaffected
210
+ (they re-expand by a few pixels — usually the same window). Interval marks
211
+ (bars / candles / boxes) don't need this — their width _is_ their x-span.
212
+
213
+ ### Changed
214
+
215
+ - **charts:** **Tracker labels key on `as` across the multi-value marks**
216
+ (F-charts-8 §3, the `<Legend>` label-source prerequisite). With a semantic
217
+ `as`, a `<BandChart>`'s edge samples now read `"<as> lower"` / `"<as>
218
+ upper"` and a `<Candlestick showOHLC>`'s quote pills read `"<as> high"` /
219
+ `"<as> open"` / … — the series-name + role convention `<BoxPlot>` already
220
+ shipped (`iv upper`) — so readout/legend merge keys are the series
221
+ identity, not raw column names or bare role words. Without an `as`,
222
+ labels are unchanged (column names / role words). Consumers keying
223
+ `onTrackerChanged` readouts on the old labels while setting `as` should
224
+ key on the new `"<as> <role>"` form.
225
+
226
+ ### Fixed
227
+
228
+ - **charts:** **`cursorFormat` no longer leaks into tick labels** — setting
229
+ `timeFormat` _and_ `cursorFormat` together on a time axis rendered the
230
+ **tick labels** with `cursorFormat` (the ladder was disqualified by
231
+ `timeFormat`, and the tick fallback read the merged readout formatter).
232
+ The frame now carries two channels: `formatTime` is the label channel
233
+ (`timeFormat`-shaped, never `cursorFormat` — restoring its documented
234
+ meaning), and the new optional `formatReadout` carries `cursorFormat` to
235
+ the readout consumers (crosshair pill + in-plot cursor time, marker
236
+ indicators, annotation auto-labels). Frame consumers that read
237
+ `ContainerFrame.formatTime` for a readout should use
238
+ `formatReadout ?? formatTime`.
239
+ - **charts:** **Region drag-select no longer races the pointer stream**
240
+ (#508 item 7) — the drag anchor was container **state** read back through
241
+ the rendered frame at `pointerup`, so a batched/untrusted pointer stream
242
+ (automation, jsdom — plausibly a very fast flick under load) could deliver
243
+ `down→up` before the anchor committed: the select was **silently dropped**
244
+ and the late-committing anchor **leaked**, leaving the band stuck.
245
+ Human-paced trusted input hid this (React flushes trusted discrete events
246
+ synchronously). Gesture logic now reads a ref (the same ref+state
247
+ discipline the annotation-create drag already used); the state stays
248
+ paint-only. Deterministic regression tests cover both pacings.
249
+
51
250
  ## [0.48.1] — 2026-07-19
52
251
 
53
252
  ### Fixed
@@ -1,5 +1,6 @@
1
1
  import { ValueSeries } from 'pond-ts';
2
2
  import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
3
+ import type { DecimateOption } from './decimate.js';
3
4
  import { type Curve } from './curve.js';
4
5
  import { type GapMode } from './gaps.js';
5
6
  export interface AreaChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
@@ -65,6 +66,22 @@ export interface AreaChartProps<S extends SeriesSchema = SeriesSchema, VS extend
65
66
  * `'dashed'` / `'step'` connector faintness is the theme's `gap.connectorOpacity`.)
66
67
  */
67
68
  gaps?: GapMode;
69
+ /**
70
+ * **M4 viewport decimation** (charts decimator wave). **Omitted ⇒ `true`**:
71
+ * once the visible data is denser than ~2 samples per device pixel, the fill +
72
+ * outline are drawn from the per-pixel-column M4 buckets (a visually-lossless
73
+ * polyline of O(plot width) points) instead of every sample. Applies with a
74
+ * linear `curve`; pass `false` to always draw every point, or `{ threshold }`
75
+ * to tune the samples-per-pixel factor. Shares {@link LineChart}'s
76
+ * `DecimateOption`.
77
+ */
78
+ decimate?: DecimateOption;
79
+ /**
80
+ * This layer's `<Legend>` row: `false` ⇒ no row (opt out), a string ⇒ the
81
+ * row's display name. **Omitted ⇒ a row named by the layer's readout
82
+ * identity** (`as` ?? `column`). The swatch is the resolved area style.
83
+ */
84
+ legend?: boolean | string;
68
85
  /**
69
86
  * @internal Declaration position among the `<Layers>` children, injected by
70
87
  * `Layers` so z-order follows JSX order. Do not set.
@@ -91,5 +108,5 @@ export interface AreaChartProps<S extends SeriesSchema = SeriesSchema, VS extend
91
108
  * </Layers>
92
109
  * ```
93
110
  */
94
- export declare function AreaChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, axis, baseline, curve, gaps, index, }: AreaChartProps<S, VS>): null;
111
+ export declare function AreaChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, axis, baseline, curve, gaps, decimate, legend, index, }: AreaChartProps<S, VS>): null;
95
112
  //# sourceMappingURL=AreaChart.d.ts.map
package/dist/AreaChart.js CHANGED
@@ -5,6 +5,7 @@ import { areaExtent, drawArea } from './area.js';
5
5
  import { resolveCurve } from './curve.js';
6
6
  import { DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
7
7
  import { ContainerContext, LayersContext } from './context.js';
8
+ import { legendLabelFor, useLegendItems, } from './swatch.js';
8
9
  import { useSlotKey } from './use-slot-key.js';
9
10
  /** Read a d3 linear scale's domain lower bound (the axis floor) from the plain
10
11
  * `(value) => pixel` function the row hands to `draw`. The runtime object is a
@@ -35,7 +36,7 @@ function domainFloor(yScale) {
35
36
  * </Layers>
36
37
  * ```
37
38
  */
38
- export function AreaChart({ series, column, as: semantic, axis, baseline, curve, gaps = DEFAULT_GAP_MODE, index = 0, }) {
39
+ export function AreaChart({ series, column, as: semantic, axis, baseline, curve, gaps = DEFAULT_GAP_MODE, decimate = true, legend, index = 0, }) {
39
40
  const container = useContext(ContainerContext);
40
41
  if (container === null) {
41
42
  throw new Error('<AreaChart> must be rendered inside a <ChartContainer>');
@@ -96,7 +97,7 @@ export function AreaChart({ series, column, as: semantic, axis, baseline, curve,
96
97
  // Omitted baseline rests on the axis floor (resolved late from the
97
98
  // scale, so it tracks the auto-fit domain); a fixed baseline is used
98
99
  // verbatim.
99
- baseline ?? domainFloor(yScale), curveFactory, gaps, gapConnectorOpacity),
100
+ baseline ?? domainFloor(yScale), curveFactory, gaps, gapConnectorOpacity, decimate),
100
101
  },
101
102
  axisId: axis,
102
103
  index,
@@ -110,6 +111,7 @@ export function AreaChart({ series, column, as: semantic, axis, baseline, curve,
110
111
  curveFactory,
111
112
  gaps,
112
113
  gapConnectorOpacity,
114
+ decimate,
113
115
  axis,
114
116
  index,
115
117
  ]);
@@ -127,6 +129,25 @@ export function AreaChart({ series, column, as: semantic, axis, baseline, curve,
127
129
  useEffect(() => {
128
130
  registerTrackerSource(slot, entry.layer);
129
131
  }, [registerTrackerSource, slot, entry.layer]);
132
+ // And a legend row: the readout identity + the resolved area style (top line
133
+ // over the translucent fill), so a `<Legend>` swatch can never drift.
134
+ const legendRows = useMemo(() => {
135
+ const name = legendLabelFor(legend, label);
136
+ return name === null
137
+ ? null
138
+ : [
139
+ {
140
+ label: name,
141
+ swatch: {
142
+ kind: 'area',
143
+ line: style.color,
144
+ fill: style.fill,
145
+ fillOpacity: style.fillOpacity,
146
+ },
147
+ },
148
+ ];
149
+ }, [legend, label, style]);
150
+ useLegendItems(container, slot, index, legendRows);
130
151
  return null;
131
152
  }
132
153
  //# sourceMappingURL=AreaChart.js.map
@@ -1,6 +1,7 @@
1
1
  import { ValueSeries } from 'pond-ts';
2
2
  import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
3
3
  import { type Curve } from './curve.js';
4
+ import type { DecimateOption } from './decimate.js';
4
5
  export interface BandChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
5
6
  /**
6
7
  * The source series. A `TimeSeries` fills the envelope against the time axis;
@@ -23,7 +24,9 @@ export interface BandChartProps<S extends SeriesSchema = SeriesSchema, VS extend
23
24
  * p5/p95 envelope, `inner` for p25/p75). The theme maps it to a
24
25
  * {@link BandStyle} (`theme.band[as] ?? theme.band.default`). **Omitted ⇒ the
25
26
  * `default` band style** — no per-component fill/opacity override (restyle via
26
- * the theme, the single styling channel).
27
+ * the theme, the single styling channel). It's also the series identity for
28
+ * the tracker readout: with an `as`, the edge samples read `"<as> lower"` /
29
+ * `"<as> upper"` (else the raw column names).
27
30
  */
28
31
  as?: string;
29
32
  /**
@@ -39,6 +42,22 @@ export interface BandChartProps<S extends SeriesSchema = SeriesSchema, VS extend
39
42
  * Denoise the underlying values with `smooth()`, not this.
40
43
  */
41
44
  curve?: Curve;
45
+ /**
46
+ * **M4 viewport decimation** (charts decimator wave). **Omitted ⇒ `true`**:
47
+ * once the visible envelope is denser than ~2 samples per device pixel, it is
48
+ * drawn from the per-pixel-column **min(lower) / max(upper)** — the widest band
49
+ * the samples span, so it covers the same pixels from O(plot width) points.
50
+ * Applies with a linear `curve`; pass `false` to always fill every sample, or
51
+ * `{ threshold }` to tune. Shares {@link LineChart}'s `DecimateOption`.
52
+ */
53
+ decimate?: DecimateOption;
54
+ /**
55
+ * This layer's `<Legend>` row: `false` ⇒ no row (opt out), a string ⇒ the
56
+ * row's display name. **Omitted ⇒ a row named by the layer's readout
57
+ * identity** (`as`, else `"<lower>–<upper>"`). The swatch is the resolved
58
+ * band fill.
59
+ */
60
+ legend?: boolean | string;
42
61
  /**
43
62
  * @internal Declaration position among the `<Layers>` children, injected by
44
63
  * `Layers` so z-order follows JSX order. Do not set.
@@ -62,5 +81,5 @@ export interface BandChartProps<S extends SeriesSchema = SeriesSchema, VS extend
62
81
  * </Layers>
63
82
  * ```
64
83
  */
65
- export declare function BandChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, lower, upper, as: semantic, axis, curve, index, }: BandChartProps<S, VS>): null;
84
+ 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;
66
85
  //# sourceMappingURL=BandChart.d.ts.map
package/dist/BandChart.js CHANGED
@@ -4,6 +4,7 @@ import { bandFromTimeSeries, bandFromValueSeries } from './data.js';
4
4
  import { bandExtent, drawBand } from './band.js';
5
5
  import { resolveCurve } from './curve.js';
6
6
  import { ContainerContext, LayersContext } from './context.js';
7
+ import { legendLabelFor, useLegendItems, } from './swatch.js';
7
8
  import { useSlotKey } from './use-slot-key.js';
8
9
  /**
9
10
  * A variance-band draw layer: fills the envelope between the `lower` and `upper`
@@ -22,7 +23,7 @@ import { useSlotKey } from './use-slot-key.js';
22
23
  * </Layers>
23
24
  * ```
24
25
  */
25
- export function BandChart({ series, lower, upper, as: semantic, axis, curve, index = 0, }) {
26
+ export function BandChart({ series, lower, upper, as: semantic, axis, curve, decimate = true, legend, index = 0, }) {
26
27
  const container = useContext(ContainerContext);
27
28
  if (container === null) {
28
29
  throw new Error('<BandChart> must be rendered inside a <ChartContainer>');
@@ -38,6 +39,14 @@ export function BandChart({ series, lower, upper, as: semantic, axis, curve, ind
38
39
  const { band } = container.theme;
39
40
  const style = (semantic !== undefined ? band[semantic] : undefined) ?? band.default;
40
41
  const curveFactory = resolveCurve(curve);
42
+ // Readout label per edge: with a semantic `as`, an edge reads under the series
43
+ // name + role (`iv lower`, `iv upper`) — the `as ?? column` convention Line /
44
+ // Scatter use and the exact shape BoxPlot's qLabel ships — so two bands (or a
45
+ // band and its centre line) merge readout keys by series, not by raw column
46
+ // names (F-charts-8 §3). With no `as`, the column name stands (self-evident).
47
+ const edgeLabel = useMemo(() => {
48
+ return (col, role) => semantic !== undefined ? `${semantic} ${role}` : col;
49
+ }, [semantic]);
41
50
  const entry = useMemo(() => ({
42
51
  layer: {
43
52
  yExtent: () => bandExtent(bs),
@@ -62,8 +71,18 @@ export function BandChart({ series, lower, upper, as: semantic, axis, curve, ind
62
71
  if (!Number.isFinite(lo) || !Number.isFinite(hi))
63
72
  return [];
64
73
  return [
65
- { x: bs.x[i], value: lo, color: style.fill, label: lower },
66
- { x: bs.x[i], value: hi, color: style.fill, label: upper },
74
+ {
75
+ x: bs.x[i],
76
+ value: lo,
77
+ color: style.fill,
78
+ label: edgeLabel(lower, 'lower'),
79
+ },
80
+ {
81
+ x: bs.x[i],
82
+ value: hi,
83
+ color: style.fill,
84
+ label: edgeLabel(upper, 'upper'),
85
+ },
67
86
  ];
68
87
  }
69
88
  const e = series.nearest(x);
@@ -80,18 +99,40 @@ export function BandChart({ series, lower, upper, as: semantic, axis, curve, ind
80
99
  !Number.isFinite(hi)) {
81
100
  return [];
82
101
  }
83
- // Both edges, labelled by their column (e.g. p25 / p75), in the band's
84
- // fill colour. A gap on either edge yields no readout (like the fill).
102
+ // Both edges, labelled by series + role under an `as` (else by their
103
+ // column, e.g. p25 / p75), in the band's fill colour. A gap on either
104
+ // edge yields no readout (like the fill).
85
105
  return [
86
- { x: e.begin(), value: lo, color: style.fill, label: lower },
87
- { x: e.begin(), value: hi, color: style.fill, label: upper },
106
+ {
107
+ x: e.begin(),
108
+ value: lo,
109
+ color: style.fill,
110
+ label: edgeLabel(lower, 'lower'),
111
+ },
112
+ {
113
+ x: e.begin(),
114
+ value: hi,
115
+ color: style.fill,
116
+ label: edgeLabel(upper, 'upper'),
117
+ },
88
118
  ];
89
119
  },
90
- draw: (ctx, xScale, yScale) => drawBand(ctx, bs, xScale, yScale, style, curveFactory),
120
+ draw: (ctx, xScale, yScale) => drawBand(ctx, bs, xScale, yScale, style, curveFactory, decimate),
91
121
  },
92
122
  axisId: axis,
93
123
  index,
94
- }), [bs, series, lower, upper, style, curveFactory, axis, index]);
124
+ }), [
125
+ bs,
126
+ series,
127
+ lower,
128
+ upper,
129
+ style,
130
+ curveFactory,
131
+ decimate,
132
+ axis,
133
+ index,
134
+ edgeLabel,
135
+ ]);
95
136
  // Stable per-instance slot (see useSlotKey): keeps this band's z-position +
96
137
  // identity across prop updates; the injected index drives the sort.
97
138
  const slot = useSlotKey();
@@ -106,6 +147,24 @@ export function BandChart({ series, lower, upper, as: semantic, axis, curve, ind
106
147
  useEffect(() => {
107
148
  registerTrackerSource(slot, entry.layer);
108
149
  }, [registerTrackerSource, slot, entry.layer]);
150
+ // And a legend row: the series identity (`as`, else the edge columns as a
151
+ // span) + the resolved band fill, so a `<Legend>` swatch can never drift.
152
+ const legendRows = useMemo(() => {
153
+ const name = legendLabelFor(legend, semantic ?? `${lower}–${upper}`);
154
+ return name === null
155
+ ? null
156
+ : [
157
+ {
158
+ label: name,
159
+ swatch: {
160
+ kind: 'band',
161
+ fill: style.fill,
162
+ opacity: style.opacity,
163
+ },
164
+ },
165
+ ];
166
+ }, [legend, semantic, lower, upper, style]);
167
+ useLegendItems(container, slot, index, legendRows);
109
168
  return null;
110
169
  }
111
170
  //# sourceMappingURL=BandChart.js.map
@@ -120,6 +120,17 @@ export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends
120
120
  * would invert collapses to the style's `minWidth`.
121
121
  */
122
122
  gap?: number;
123
+ /**
124
+ * This layer's `<Legend>` row(s): `false` ⇒ none (opt out), a string ⇒ the
125
+ * display name of a **one-row** layer. **Omitted ⇒** the single path (and a
126
+ * one-group stack) registers one row named by the layer identity
127
+ * (`as` ?? `column` ?? `id`); a **multi-group stack registers one row per
128
+ * group** (stack order, each group's resolved fill), where a rename string
129
+ * is ignored — group names come from the data. A per-bin-coloured histogram
130
+ * (`binColors`) is one series: its single row's swatch shows the group's
131
+ * base fill, not the per-bin palette.
132
+ */
133
+ legend?: boolean | string;
123
134
  /**
124
135
  * @internal Declaration position among the `<Layers>` children, injected by
125
136
  * `Layers` so z-order follows JSX order. Do not set.
@@ -169,5 +180,5 @@ export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends
169
180
  * </Layers>
170
181
  * ```
171
182
  */
172
- export declare function BarChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, bins, categories, column, columns, as: semantic, colors, binColors, orientation, ordinal, id, axis, gap, index, }: BarChartProps<S, VS>): null;
183
+ export declare function BarChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, bins, categories, column, columns, as: semantic, colors, binColors, orientation, ordinal, id, axis, gap, legend, index, }: BarChartProps<S, VS>): null;
173
184
  //# sourceMappingURL=BarChart.d.ts.map
package/dist/BarChart.js CHANGED
@@ -3,6 +3,7 @@ import { Interval, ValueSeries } from 'pond-ts';
3
3
  import { barsFromTimeSeries, barsFromValueSeries, categoryStack, stacksFromBins, stacksFromColumns, stacksFromGroups, } from './data.js';
4
4
  import { barAt, barExtent, barIndexAtTime, drawBars, drawStacks, resolveBarBaseline, stackAt, stackBinExtent, stackValueExtent, } from './bars.js';
5
5
  import { ContainerContext, LayersContext, } from './context.js';
6
+ import { legendLabelFor, useLegendItems, } from './swatch.js';
6
7
  import { useSlotKey } from './use-slot-key.js';
7
8
  /**
8
9
  * A bar / histogram draw layer. In its simplest form, one rectangle per event
@@ -47,7 +48,7 @@ import { useSlotKey } from './use-slot-key.js';
47
48
  * </Layers>
48
49
  * ```
49
50
  */
50
- export function BarChart({ series, bins, categories, column, columns, as: semantic, colors, binColors, orientation = 'vertical', ordinal = false, id, axis, gap, index = 0, }) {
51
+ export function BarChart({ series, bins, categories, column, columns, as: semantic, colors, binColors, orientation = 'vertical', ordinal = false, id, axis, gap, legend, index = 0, }) {
51
52
  const container = useContext(ContainerContext);
52
53
  if (container === null) {
53
54
  throw new Error('<BarChart> must be rendered inside a <ChartContainer>');
@@ -364,6 +365,38 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
364
365
  registerSelectable(slot);
365
366
  return () => unregisterSelectable(slot);
366
367
  }, [registerSelectable, unregisterSelectable, slot, id]);
368
+ // Legend rows: a genuinely multi-group layer (a stack) registers **one row
369
+ // per group** in stack order, each with the same per-group fill resolution
370
+ // the canvas draws with; everything else — the single path, a one-group
371
+ // stack (horizontal single, categorical) — registers one row under the
372
+ // layer's identity. A `legend` string renames only a one-row layer (a
373
+ // multi-group layer has no single name to give); `legend={false}` opts all
374
+ // out. The layer's `id` rides every row (selection identity is the layer).
375
+ const legendRows = useMemo(() => {
376
+ if (legend === false)
377
+ return null;
378
+ if (groups !== undefined && groups.length > 1) {
379
+ return groups.map((g, i) => ({
380
+ label: g,
381
+ id,
382
+ swatch: { kind: 'bar', fill: stackStyle.fills[i] },
383
+ }));
384
+ }
385
+ const name = legendLabelFor(legend, label);
386
+ return name === null
387
+ ? null
388
+ : [
389
+ {
390
+ label: name,
391
+ id,
392
+ swatch: {
393
+ kind: 'bar',
394
+ fill: groups !== undefined ? stackStyle.fills[0] : singleStyle.fill,
395
+ },
396
+ },
397
+ ];
398
+ }, [legend, groups, stackStyle, label, id, singleStyle]);
399
+ useLegendItems(container, slot, index, legendRows);
367
400
  return null;
368
401
  }
369
402
  //# sourceMappingURL=BarChart.js.map
package/dist/BoxPlot.d.ts CHANGED
@@ -88,6 +88,23 @@ export interface BoxPlotProps<S extends SeriesSchema = SeriesSchema, VS extends
88
88
  * `'whisker'` shape (`'solid'`/`'none'` have no caps).
89
89
  */
90
90
  capWidth?: number;
91
+ /**
92
+ * Stable series identity — **gates selection + hover**, the same id-gated
93
+ * contract `<BarChart>` / `<ScatterChart>` carry. With an `id`, a click on a
94
+ * box (body or whisker — a range-only bid→ask segment included) selects it
95
+ * (`selected`/`onSelect`) and pointer-over lights it (`hovered`/`onHover`);
96
+ * the box matching the selection's `(id, key)` outlines. **Omitted ⇒
97
+ * display-only** (a click resolves to empty space). `key` is the box's `x`
98
+ * (its `begin`).
99
+ */
100
+ id?: string;
101
+ /**
102
+ * This layer's `<Legend>` row: `false` ⇒ no row (opt out), a string ⇒ the
103
+ * row's display name. **Omitted ⇒ a row named by the layer's readout
104
+ * identity** (`as`, else `"<lower>–<upper>"`). The swatch is the resolved
105
+ * whisker style.
106
+ */
107
+ legend?: boolean | string;
91
108
  /**
92
109
  * @internal Declaration position among the `<Layers>` children, injected by
93
110
  * `Layers` so z-order follows JSX order. Do not set.
@@ -125,5 +142,5 @@ export interface BoxPlotProps<S extends SeriesSchema = SeriesSchema, VS extends
125
142
  * </Layers>
126
143
  * ```
127
144
  */
128
- export declare function BoxPlot<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, lower, q1, median, q3, upper, as: semantic, axis, gap, shape, showMedian, offset, capWidth, index, }: BoxPlotProps<S, VS>): null;
145
+ export declare function BoxPlot<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, lower, q1, median, q3, upper, as: semantic, axis, gap, shape, showMedian, offset, capWidth, id, legend, index, }: BoxPlotProps<S, VS>): null;
129
146
  //# sourceMappingURL=BoxPlot.d.ts.map