@pond-ts/react 0.56.2 → 0.58.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 (2) hide show
  1. package/CHANGELOG.md +1218 -1
  2. package/package.json +2 -2
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.56.2...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.58.0...HEAD
12
+ [0.58.0]: https://github.com/pond-ts/pond/compare/v0.57.0...v0.58.0
13
+ [0.57.0]: https://github.com/pond-ts/pond/compare/v0.56.2...v0.57.0
12
14
  [0.56.2]: https://github.com/pond-ts/pond/compare/v0.56.1...v0.56.2
13
15
  [0.56.1]: https://github.com/pond-ts/pond/compare/v0.56.0...v0.56.1
14
16
  [0.56.0]: https://github.com/pond-ts/pond/compare/v0.55.0...v0.56.0
@@ -59,6 +61,1221 @@ include new features and type-level changes; patch bumps are strictly additive.
59
61
 
60
62
  ## [Unreleased]
61
63
 
64
+ ## [0.58.0] — 2026-08-10
65
+
66
+ ### Added
67
+
68
+ - **charts: `<LineChart id>` / `<AreaChart id>` — selection on a continuous
69
+ trace** ([PND-TRACESEL]), the last two columns of the selection matrix. A
70
+ trace has **no marks**, so the currency differs from every other layer and
71
+ does so deliberately: a **sweep** commits a `SpanSelection` with **no hits**
72
+ (the span _is_ the selection — a trace's samples are usually undrawn and
73
+ several fall per pixel, so take the span and slice your own series with it),
74
+ and a **click** commits a **series-scoped** `SelectInfo` — `key`/`value`
75
+ `NaN`, plus a stable `mark` so re-clicking deselects. The brush band is the
76
+ whole live preview, because there are no marks to light. An area is hit
77
+ through its **fill** (the whole shape is the target); a line within 6px of
78
+ its **stroke**. New `sweepSpan` session in the kernel; `sweepsRect: false`,
79
+ `sweepAxis: 'x'`.
80
+ - **charts: trace interaction states.** A selected series **thickens and keeps
81
+ its colour** — state is weight, never hue, because a line's colour is how a
82
+ reader tells one series from another (the same rule `<Candlestick>` follows).
83
+ Other series recede to `0.32` with their hue intact; hover echoes the
84
+ selected weight. A **swept window** partitions the trace: the covered portion
85
+ is emphasised and may take a hue (`spanColor`, defaulting to the selection
86
+ blue) because inside one series identity is not in question, while the rest
87
+ recedes. New `LineStyle` tokens `selectedWidth` / `hoverWidth` /
88
+ `dimmedOpacity` / `spanColor`, and `AreaStyle` gains those plus
89
+ `selectedFillOpacity` — an area's mark is its fill, so its state channels are
90
+ fill strength and edge weight rather than weight alone. The partition is
91
+ **live during the drag**, and a sweep covers **every trace in the row** rather
92
+ than the topmost one: they all share the same x window, so singling one out by
93
+ z-order would be arbitrary. Mark layers keep topmost-wins unchanged.
94
+ `spanColor` applies only when a single trace is swept — with two, both would
95
+ go blue and identity would be in question again inside the window. A line's
96
+ emphasised segment has **round ends**: it strokes an interpolated slice of the
97
+ path rather than the whole trace clipped, so its endpoints are real path ends
98
+ and take a cap. An area still clips, because a fill's boundary is a vertical
99
+ wall by construction.
100
+ - **charts: keyboard parity on the list family.** Rows now navigate and select
101
+ from the keyboard: **↑/↓** move focus, **Home/End** jump to the ends,
102
+ **Enter/Space** select the focused row (carrying modifiers, so ⌘/Ctrl-Enter
103
+ adds), and **Shift** with any movement key extends the run. The anchor is
104
+ shared with the pointer, so a click can be finished with the keyboard; it
105
+ holds across repeats, so Shift-↓ grows one run rather than sliding a two-row
106
+ window. Navigation never selects on its own, movement keys are
107
+ `preventDefault`ed so the page cannot scroll out from under the list, and
108
+ arrows stay inside their own table rather than walking into a nested list in
109
+ an expanded row.
110
+ - **charts: `<BarList onRowSelect>` / `<BoxList onRowSelect>`** — the range
111
+ gesture, and how a user actually produces a multi-row `selected`. The list's
112
+ answer to `<MultiSelector>`: **mounting it enables the drag** (a list with
113
+ only `onRowClick` is unchanged), a click reports `[row]` — so it is a strict
114
+ superset — and a drag across rows reports the whole inclusive run in display
115
+ order, plus `SelectModifiers`. **Crossing into another row is what makes it a
116
+ range**, not a pixel slop, so a press-and-release is always a click and a
117
+ horizontal wobble commits nothing; wander to another row and back and it is a
118
+ click again. The covered rows light as _hovered_ while the drag runs — the
119
+ live preview out-ranks `hovered` for the duration without touching it, and
120
+ `onHover` stays quiet so the two channels never contradict. The library holds
121
+ no state and applies no set arithmetic: you get the run and the modifiers.
122
+ `shiftKey` is reported but given no built-in meaning. Native text selection
123
+ is suppressed for the press's duration only, so a drag does not sweep up the
124
+ label text while the labels stay selectable at rest; **touch is excluded** —
125
+ a vertical drag over a list is how a touch device scrolls, so touch keeps
126
+ click-to-select.
127
+ - **charts: the row-chart state ladder** — `<BarList>` / `<BoxList>` now carry
128
+ rest / hover / selected / dimmed states, via a new optional
129
+ **`ChartTheme.list`** register (`hoverBand`, `hoverRail`, `selectedBand`,
130
+ `selectedRail`, `markerInk`). A row's **band** (the whole stripe, gutter to
131
+ value) and **rail** (a 3px inset edge) carry state, because a row's mark is
132
+ as short as its value and cannot be its own target. **Back-compatible when
133
+ omitted:** a theme with no `list` keeps the pre-token look exactly — the
134
+ borrowed hover band, the annotation rail, and no dimmed state. An
135
+ **interactive** row reserves a 44px hit area (a static list is left alone). A
136
+ `<BoxList>` recedes its body, median and tick but never its range band, and a
137
+ multi-metric `<BarList>` row keeps every metric's own hue when selected — the
138
+ fill is the metric's identity, so state lives on the chrome.
139
+ - **charts: a horizontal `<BarChart>` can be swept** ([PND-HSWEEP]). `<MultiSelector>`
140
+ now works on a transposed chart, cut with a **vertical** drag: same component,
141
+ same 1-D cut, the axis moved. The value axis stays inert exactly as it does on
142
+ a vertical chart. Layers declare which axis a sweep cuts through the new
143
+ `RowLayer.sweepAxis: 'x' | 'y'` (default `'x'`, so nothing else changes), and
144
+ the brush band is drawn transposed and snapped to bin edges. Covers a
145
+ **categorical** bin axis too (the funnel / ranking shape): the span reads as
146
+ a slot run and the band lands on whole slots. The horizontal
147
+ **heat map** still cannot sweep — it cannot hit-test either — and a y-cutting
148
+ row draws no resting block preview yet.
149
+
150
+ - **charts: new `sameMark(a, b)` export** — the full mark identity (`id`,
151
+ `mark`-or-`key`, `label`), companion to `selectionContains`, whose doc has
152
+ always told a consumer to write `remove(cur, hit)` without giving them
153
+ anything to write it with.
154
+
155
+ - **charts: a rect-sweeping row rests as a small crosshair** — the last piece
156
+ of the 2-D brush. Under a mounted `<MultiSelector>`, a row whose topmost
157
+ sweepable layer cuts a rect (a scatter, a heat map) shows a compact grey `+`
158
+ at the pointer; a drag pins that same `+` at the anchor, adds a second at
159
+ the pointer, and spans the blue rect between them.
160
+
161
+ It is deliberately _not_ a full-plot crosshair. That is a value-reading
162
+ instrument — it exists to project the pointer onto both axes — whereas this
163
+ marks a corner a rect would start from, and the rect draws its own edges out
164
+ to those axes the moment a drag begins. Plot-spanning rules would add two
165
+ more lines to a picture that is about to have them anyway.
166
+
167
+ It replaces the resting _band_, which a 2-D row should never have shown: a
168
+ band previews the snap block, and the block is a whole x column while the
169
+ drag beside it captures a rect. New `RowLayer.sweepsRect` declares which
170
+ shape a layer sweeps, so the resting cursor can ask without building a
171
+ per-drag session.
172
+
173
+ - **charts: a heat map's selection is one outline around the region, not one
174
+ per cell** — new optional `theme.heat` slot (`HeatStates`: `veil`,
175
+ `hoverRing`, `ringWidth`, `perimeter`, `perimeterWidth`). Absent, the
176
+ pre-states treatment is unchanged. The _geometry_ still comes from
177
+ `theme.bar` — a cell is a bar's slot with colour instead of height — and
178
+ only the state styling is new, because a bar's fill is free while a cell's
179
+ fill **is the datum**.
180
+ - **The live drag shows the SNAPPED rect it is about to take** (new
181
+ `SweepSession.snap`), not the rectangle the pointer traced. A heat map
182
+ snaps to whole bins and whole rows, so a raw pointer rect promises a
183
+ different set than the release delivers — and the two disagree exactly
184
+ while the reader is deciding where to let go. A scatter's cut is free, so
185
+ its brush stays on the pointer, which is honest there.
186
+ - **A committed selection is one region however it was assembled.** On
187
+ release the new rect joins what is already selected and the outline
188
+ merges: one perimeter around the union, drawn by suppressing each cell
189
+ edge whose neighbour is also selected. No connectivity pass falls out of
190
+ that — disconnected pieces get one outline each and a hole gets its own,
191
+ which is what keeps a demoted region readable instead of the "mostly
192
+ border" grid a per-cell outline gives. No false edge where a selection
193
+ runs off-screen.
194
+ - **A snapping layer's sweep reports its covered marks without lighting
195
+ them** (`SweepGesture.preview`'s new `light` argument). The heat map's
196
+ hover treatment is a ring around _the cell under the pointer_, and during
197
+ a sweep there is no such cell: a ring on every covered cell turned the
198
+ region into a grid of borders inside a rect already saying the same
199
+ thing. Consumers still hear the whole set through `<MultiSelector
200
+ onHover>`.
201
+ - **An unselected cell recedes under a flat overlay, not `globalAlpha`.**
202
+ Alpha and value are the same channel on a ramp, so fading a cell slides it
203
+ along the scale; an overlay is uniform and monotonic, so the ramp's order
204
+ survives inside the receded set. It is a colour rather than a number
205
+ because opacity composites with whatever is _behind_ the cell, which would
206
+ veil the same value to different colours in different charts.
207
+ - **The hover ring is a pair** — a light ring outside and a dark one inside,
208
+ both within the cell. A single ring cannot work against a ramp: the light
209
+ one vanishes at the pale end and the dark one at the dark end.
210
+
211
+ - **charts: a scatter point carries its state in colour AND size** — new
212
+ optional `theme.scatter.*.states` (`hover` / `hoverRadius` / `selected` /
213
+ `halo` / `haloWidth` / `dimmedRadius` / `dimmedOpacity`). Unset ⇒ the
214
+ previous behaviour exactly: a live point keeps its fill and is merely
215
+ re-ringed in `selectedOutline`.
216
+
217
+ A point is the one mark with a spare channel. A candle's hue _is_ its
218
+ meaning and a heat cell's colour _is_ its value, so those carry state in
219
+ weight and chrome; a point's colour encodes nothing by default and it also
220
+ has **size**. So the two live states split the channels rather than sharing
221
+ one: **hover grows and brightens** (keeping its own hue — a preview that
222
+ borrowed the committed colour would read as committed), while **selection
223
+ recolours and keeps its size**, so committing a sweep does not reflow the
224
+ cloud under the pointer. Both take a halo, which is what keeps overlapping
225
+ points countable once a whole swept region shares one fill.
226
+
227
+ A point outside a non-empty selection **shrinks as well as fading** — alpha
228
+ alone thins a cloud to nearly nothing, and the shape of the unselected field
229
+ is what a scatter's background is for. A hover does not recede the field;
230
+ only a committed selection does.
231
+
232
+ The radii are px against the base `radius` and applied as the **ratio**
233
+ between them, so a data-driven `radius` encoding still grows and shrinks
234
+ proportionally instead of flattening to one size when a point goes live.
235
+
236
+ - **charts: `<MultiSelector>` sweeps a 2-D rect on `<ScatterChart>` and
237
+ `<HeatMap>`** ([PND-INTERACT2D]). Every other mark owns a column of the key
238
+ axis, so a drag over it is a range of keys; a scatter point owns a
239
+ _position_ and a heat cell owns a `(bin, row)`, so the same gesture becomes
240
+ a **rectangle**. Nothing new is mounted and no prop is added — the layer
241
+ declares its own dimensionality, so the markup for a scatter is the markup
242
+ for a bar.
243
+ - **Scatter cuts a free rect** and commits `SpanSelection.y` (the drag's
244
+ continuous window) alongside `x`.
245
+ - **The heat map snaps both dimensions** — bins on x, whole rows on y — and
246
+ commits `SpanSelection.rows` by row **name**, so a re-ordered `columns`
247
+ list cannot silently repoint a selection at different data. Snapping both
248
+ axes is also why a capture is always a contiguous rectangle of cells.
249
+ - The drag paints a **rect brush** (`theme.brush`'s existing fill/edge
250
+ tokens) with a small `+` on each end of the drag diagonal, in place of the
251
+ 1-D band.
252
+ - A 2-D drag arms on pointer **distance**, not `|dx|`, so a straight-down
253
+ drag is a gesture rather than a click.
254
+ - A 2-D layer publishes **no resting block preview**, and a click on one
255
+ selects the mark under the pointer: the block would be a whole column,
256
+ which is not what the rect gesture beside it captures.
257
+
258
+ - **charts: `<Candlestick>` gains the id-gated interaction surface** — `id`,
259
+ `hitTest`, `beginSweep` and `binIntervals`, so a candle selects, hovers and
260
+ sweeps like every other column mark. A candle is an aggregation owning one
261
+ `[x, xEnd)` slot; hits are rect containment over that slot (a doji body is a
262
+ pixel tall and a wick is a hairline, so requiring drawn ink would make most
263
+ candles unclickable), `key` is its `x` and `value` its `close`.
264
+
265
+ - **charts: a candle carries its state in weight and alpha — never in hue.**
266
+ New optional `theme.candle.*` tokens `liveWickWidth` and `dimmedOpacity`.
267
+ Where a bar swaps its fill and a box rotates its tint ladder, **a candle's
268
+ hue is its meaning** — rising vs falling is the first thing read off it — so
269
+ a candle introduces no state colour at all:
270
+ - **Live** (hovered _or_ selected): an outline around the slot in the
271
+ candle's **own body colour**, and lines thickened to `liveWickWidth`.
272
+ - **Selected**: the same, plus the rest of the field receding to
273
+ `dimmedOpacity`.
274
+
275
+ So hover and selection look identical _on the mark_ and differ in what
276
+ happens to the others — a deliberate consequence of hue being unavailable.
277
+ It also means the weight bump fires on hover here, unlike
278
+ `BoxStyle.selectedStrokeWidth`, which only selection triggers because a box
279
+ announces hover by moving its ladder.
280
+
281
+ Both tokens optional; unset ⇒ a display-only candle exactly as before.
282
+
283
+ - **charts: `<BoxPlot>` joins the sweep — it publishes `binIntervals` and
284
+ `beginSweep`, so a `<MultiSelector>` selects boxes by column.** A box is an
285
+ **aggregation**: it owns one `[begin, end)` interval of the key axis, and the
286
+ fact that its ink floats between two quantiles rather than rising from the
287
+ baseline says nothing about which column the mark occupies. So it sweeps
288
+ exactly as a bar does — a bar that simply isn't grounded to the axis — over
289
+ the same `sweep1D` cut, with each materialised hit identical to what
290
+ `hitTest` reports for that box. A gap box (its present quantiles not all
291
+ finite) owns no membership, the rule `hitTest` and the flag already applied.
292
+
293
+ `binIntervals` is the other half: the region cursor and the sweep band now
294
+ snap to box edges instead of running centre-to-centre, which is what keeps
295
+ the drawn band and the committed span agreeing (RFC A7.6's edge rule).
296
+
297
+ - **charts: `<BoxPlot>` gets a tint ladder — `theme.box.*.states`, one
298
+ four-step ladder per interaction state.** Every mark of a box reads its step
299
+ from the same ladder (0 body/outer · 1 the solid shape's inner bar · 2 stroke
300
+ - whiskers · 3 the median rule), so a state change is a **single palette
301
+ swap** and the quantile read survives it intact. The ladder carries its
302
+ meaning in _lightness_, which is what lets a box move wholesale — brighter
303
+ teal on hover, blue when committed — where a multi-hue stacked bar cannot.
304
+
305
+ Three rules it encodes, each the opposite of what the `bar` palette needs:
306
+ - **Shift the ladder, not one step.** Recolouring only the median, or only
307
+ the body, breaks the read; all four steps move together and keep their
308
+ relative spacing.
309
+ - **Dim without desaturating.** A single-hue ladder has nothing to muddy
310
+ into, so the receded state is the rest ladder at `dimmedOpacity` (`0.32`) —
311
+ no desaturated companion of the kind `bar.groupsDimmed` is.
312
+ - **Hairlines need weight, not just hue.** At 1px a colour change is nearly
313
+ invisible, so a selected box's body stroke and whiskers take
314
+ `selectedStrokeWidth` (1 → 1.5 on `defaultTheme`) alongside the swap.
315
+
316
+ It follows the bar palette's rule exactly — hover brightens within teal, blue
317
+ means committed — and step 2 of each ladder **is** the matching bar token
318
+ (`bar.hover` / `bar.highlight`), so a live box beside a live bar reads as one
319
+ act.
320
+
321
+ With a ladder in force the old bounding outline is superseded and no longer
322
+ drawn: the ladder has already moved every mark, and a rect around a whisker
323
+ claims the empty slot either side of it as part of the mark. `states` is
324
+ optional — unset ⇒ the flat `fill`/`stroke`/`median`/`whisker` tokens and the
325
+ outline cue, exactly as before.
326
+
327
+ **`defaultTheme.box.default.strokeWidth` moves `1.5` → `1`**, so the selected
328
+ weight has somewhere to go.
329
+
330
+ - **charts: `defaultTheme` gains a stack group ramp — `bar.default.groups`,
331
+ `bar.default.groupsHover` and `bar.default.groupsDimmed`.** A multi-group stack resolved every group to
332
+ `bar.default.fill`, because each group looks up a theme role named after
333
+ itself and an unthemed stack has none — so an unthemed stacked chart painted
334
+ every segment one teal, its structure visible only as hairline seams. The
335
+ ramp is four muted hues at similar lightness (`#4c9e8f` `#5379be` `#e2a54a`
336
+ `#b5604e`, first group first, cycling), so it says "different group" rather
337
+ than "more important".
338
+
339
+ `groupsDimmed` is the receded counterpart, **per group** rather than the flat
340
+ `dimmed`: a stack dimmed to one colour stops being a stack, and the
341
+ unselected bins a selection wants you to compare against become solid blocks.
342
+ Each entry is its ramp colour desaturated and lightened, keeping hue and
343
+ relative lightness.
344
+
345
+ `groupsHover` is per-group for the reason the flat `hover` cannot be: one
346
+ hover colour repaints the pointed-at segment in a hue belonging to a
347
+ different group, so hovering _erased the ramp_ exactly where the reader was
348
+ looking — and under a `<MultiSelector>`, where hover is block-scoped, it
349
+ erased the whole bin at once. Each entry is its ramp colour brightened by
350
+ the same move `fill` → `hover` makes (hue held, lightness `+0.11`).
351
+
352
+ Two deliberate boundaries. **Multi-group only** — `categories` and every
353
+ horizontal bar run the same stacked draw path with `G === 1`, and a ramp
354
+ there would repaint charts that have nothing to do with stacks, so a
355
+ single-group stack keeps `fill`. And a **`<BarChart colors>` override yields
356
+ the whole ramp**, dimmed entries included, since pairing the call site's hue
357
+ with the ramp's receded counterpart would dim a colour to one belonging to a
358
+ different colour entirely.
359
+
360
+ Resolution per group is unchanged apart from the new fallback:
361
+ `colors` → a role named after the group → the ramp → `fill`.
362
+
363
+ - **charts: `ChartTheme.brush` — the drag band is now themeable.** The live
364
+ region a brush drag paints (`<RangeCursor>`'s band and `<MultiSelector>`'s
365
+ sweep, which share one renderer) takes `{ fill, edge? }` — a wash plus 1px
366
+ start/end hairlines, so a band has a readable extent while the gesture is
367
+ still live. `defaultTheme` sets it to the bar palette's _selection_ blue at
368
+ 7% (`rgba(63,91,224,0.07)`, edged `rgba(63,91,224,0.45)`): a live sweep is a
369
+ selection being made, so it reads in the hue it is about to commit to.
370
+ **Optional and back-compatible** — a theme that sets no `brush` draws the
371
+ cursor ink at `0.12` with no edges, byte-for-byte what every theme drew
372
+ before.
373
+
374
+ - **charts: `<Selector>` — click-select is now something you mount**
375
+ (interaction RFC §7, step 4 of the wave). Mount it as a child of
376
+ `<ChartContainer>` (every row) or inside a `<ChartRow>` (that row only):
377
+
378
+ ```tsx
379
+ <ChartContainer selected={sel} hovered={hov}>
380
+ <Selector onSelect={(hit, mods) => setSel(next(sel, hit, mods))} />
381
+ <ChartRow>…</ChartRow>
382
+ </ChartContainer>
383
+ ```
384
+
385
+ It reports and holds nothing. `modifiers` carries `additive` (⌘ on macOS,
386
+ Ctrl elsewhere) plus the raw keys; pond applies **no** policy to them and
387
+ holds no set — the consumer computes the next selection and feeds it back.
388
+
389
+ - **charts: `<MultiSelector>` — sweep-select** (interaction RFC §8 / A5.2,
390
+ step 5 of the wave), the superset of `<Selector>`: a click still selects one
391
+ mark, and a drag past the slop **sweeps** — the shared brush band extends
392
+ (bucket by bucket with a `sequence`, freeform without), every covered mark
393
+ lights live through the plural `hovered` (frame-coalesced, delta-gated), and
394
+ release fires once with **both currencies**:
395
+
396
+ ```tsx
397
+ <MultiSelector
398
+ sequence={daily}
399
+ onHover={(hits) => …} // live, as the sweep moves
400
+ onSelect={(hits, modifiers, span) => …} // marks + the span they demote to
401
+ />
402
+ ```
403
+
404
+ `hits` is the materialised live preview (release runs no fresh range query);
405
+ `span` is a `SpanSelection` with the covered marks' snapped-outward extent,
406
+ so `selectionContains([span], hit)` agrees with `hits` exactly — feed the
407
+ span back as `selected` and stash the hits for A5.2's demote-on-edit. Works
408
+ on **category axes too** (the `[PND-CATRANGE]` fold-in): ordinal and
409
+ continuous are the same gesture when the payload is marks. 1-D layers in
410
+ this release (bars — single, stacked, categorical); the 2-D rect for
411
+ scatter / heat map is `[PND-INTERACT2D]`. The sweep preempts pan and a
412
+ drag-enabled `<RangeCursor>` in the same scope (dev-warned — one drag owner
413
+ per scope).
414
+
415
+ - **charts: large selection / hover sets no longer scan linearly in the bar
416
+ draws.** A sweep preview (or a demoted sweep echoed back as marks) can put
417
+ thousands of entries in `hovered` / `selected`, where the per-bar linear
418
+ match measured 6.2 s/frame at 100k undecimated bars; past 16 entries the
419
+ draw now builds a set index once (exact same match rule) — 18 ms on that
420
+ case, unchanged at click sizes.
421
+
422
+ - **charts: the list family gets the canvas's hover channel.**
423
+ `<BarList>` / `<BoxList>` now take a controlled **`hovered`** prop —
424
+ one row key or a **set** of them (`string | readonly string[] | null`),
425
+ mirroring the plural shape `<ChartContainer hovered>` carries — plus an
426
+ **`onHover(row | null)`** callback. Together they make hover bidirectional
427
+ the way selection already was, which is what a hover-linked list ↔ chart ↔
428
+ map view needs: hovering a row lights the matching bar, hovering the bar
429
+ lights the row.
430
+
431
+ Before this, `ListTable` held hover in internal component state that a
432
+ consumer could neither read nor drive, so the list family was the
433
+ less-expressive sibling for the same two-state interaction (#608). RFC
434
+ `interaction.md` A3.1 rules that a conformance item, not a standalone
435
+ feature: the lists speak the canvas's vocabulary, not a parallel one.
436
+
437
+ **Strictly additive** — omit both and the list behaves exactly as it did
438
+ (a clickable list tracks its own pointer). `onHover` is notification-only,
439
+ fires in controlled and uncontrolled mode alike, and is deduped by row key,
440
+ so moving from one row straight to the next reports the new row with no
441
+ `null` in between. The library holds no set and applies no arithmetic: it
442
+ reports what the pointer is over and renders what it's handed. Hovered rows
443
+ also carry a `data-hovered` attribute, alongside the existing
444
+ `data-selected`.
445
+
446
+ - **charts: cursor components.** The `cursor` string modes are now mounted
447
+ presets — `<LineCursor>`, `<PointCursor>`, `<InlineCursor>`, `<FlagCursor>`,
448
+ `<CrosshairCursor snap showTime format>`, `<RangeCursor sequence>` — mounted
449
+ as a child of `<ChartContainer>` (the default for all rows) or inside a
450
+ `<ChartRow>` (the per-row override). Each mode-conditional prop moved onto
451
+ the component that uses it, where it is always meaningful (interaction RFC
452
+ §4/A4.1). Render-only presets stack; one gesture-owning cursor
453
+ (crosshair/range) per scope, dev-warned otherwise. Internally each preset
454
+ registers a `CursorSpec` — declared snap, render slots taking resolved
455
+ geometry — with the container resolving and the slots drawing (RFC A2.3);
456
+ the spec type stays unpublished until the presets have proven it (Q3).
457
+
458
+ **Deprecated** (working for one more minor via an internal shim, with a dev
459
+ warning naming the replacement): `ChartContainer`'s `cursor`, `cursorTime`,
460
+ `crosshairSnap`, `cursorFormat`, `cursorSequence`, and `<ChartRow cursor>`.
461
+ `<RangeCursor>` ships the hover band only — its drag (and `onRegionSelect`'s
462
+ successor) is the next step.
463
+
464
+ **Bug fixed by the seam deletion:** a row-level crosshair now gets its
465
+ x-axis time pill. The pill was gated on the _container_ `cursor` string,
466
+ which a per-row override never reached (the code comment admitted it);
467
+ `<XAxis>` now asks whether the **hovered row's** mounted cursor registered
468
+ an x-axis slot, so a crosshair mounted in (or set on) one row of a multi-row
469
+ chart shows its pill when that row is hovered — and only then.
470
+
471
+ - **charts: `<HeatMap scale="log">` and `noData="hatch"`.** Both come from the
472
+ same chart — US measles incidence by state and year — and both are about a
473
+ cell meaning what it looks like.
474
+
475
+ **`scale="log"`** bands the ramp by equal _ratio_ rather than equal width.
476
+ Measles incidence runs from ~2,900 per 100k before the vaccine to under 1
477
+ after it; linear banding over eight colours puts everything below ~360 into a
478
+ single band — the entire post-1965 record, which is the half of the chart that
479
+ carries the finding. Bands on `log1p` of the offset from the domain floor, so
480
+ a value **at** the floor is a real band rather than `-Infinity`; zero is the
481
+ case that needs it, since an eliminated disease's grid is mostly zeros and
482
+ those cells are the point.
483
+
484
+ **`noData="hatch"`** draws diagonal lines in the theme's grid colour where a
485
+ cell has no value. The default stays `'blank'`, which is right when a hole
486
+ just means "outside the record" — but on a pale ramp, painting nothing reads
487
+ as the _bottom of the scale_, so a state with no surveillance yet looks
488
+ exactly like a state reporting zero cases. That distinction is the whole late
489
+ period of the measles chart. Hatching is the convention because no ramp colour
490
+ can be mistaken for it. Suppressed while decimated: an aggregated cell is not
491
+ a hole.
492
+
493
+ - **charts: `panZoom` names its axes — `panZoomX` / `panZoomY` / `panZoomXY`.**
494
+ The mode was x-only, so a chart wanting to zoom any other axis had nothing.
495
+
496
+ ```tsx
497
+ <ChartContainer panZoom="panZoomY"> // wheel zooms y, drag pans y
498
+ ```
499
+
500
+ **Pan follows zoom's degrees of freedom**: an axis that can be zoomed can be
501
+ panned, because a zoomed axis shows less than all of itself and the reader
502
+ needs to reach the rest. `'pan'` stays the one exception — pan with no zoom,
503
+ x-only, unchanged. `'panZoom'` and `true` are `'panZoomX'`, so nothing that
504
+ exists moves.
505
+
506
+ **`panZoomXY` zooms both axes by one factor about the cursor, holding the
507
+ aspect ratio** — a feature that looked square stays square. A single-axis zoom
508
+ changes the ratio, which is the point of asking for one.
509
+
510
+ Naming the axes is load-bearing rather than tidiness. A first cut had a single
511
+ `panZoom2D` that claimed both axes and then silently fell back to y-only
512
+ wherever x was a **category** axis: the ratio changed and nothing said so.
513
+ Spelling the axes out makes that the caller's choice. It also means a
514
+ horizontal `<HeatMap>` — categories on x, bins on y — can pan and zoom at all,
515
+ where both gesture handlers previously bailed on `xKind === 'category'`.
516
+
517
+ Carried as a pixel-space transform on the container, applied by narrowing each
518
+ y axis' **domain** to the window it makes visible. Pixels because they are
519
+ axis-independent, so one gesture serves a row whatever its axes' units;
520
+ narrowing the domain rather than stretching the range because the tick
521
+ generator must see the visible window (stretching it piled clamped ticks on
522
+ the plot edge). Panning is clamped so the content always covers the plot.
523
+
524
+ - **charts: multi-selection** ([PND-MULTISEL]) — three pieces that let a
525
+ consumer own a multi-valued selection. The library still applies **no set
526
+ arithmetic**: it reports what happened and renders what it is given, and the
527
+ policy stays with the consumer.
528
+ - **`onSelect(hit, modifiers)`** — the second argument carries the keyboard
529
+ modifiers the click held (`{ additive, ctrlKey, metaKey, shiftKey,
530
+ altKey }`). `additive` is the platform-idiomatic add-to-selection chord
531
+ (⌘ on macOS, Ctrl elsewhere) resolved once, so every consumer doesn't
532
+ re-derive it and get one platform wrong. Without this a consumer could not
533
+ implement ⌘-click-adds **at all** — the click had already been reduced to
534
+ a hit, forcing every click to be a replace. `undefined` for a selection
535
+ with no pointer event behind it (a `<Legend>` row, a programmatic select),
536
+ and the argument is omitted rather than passed as `undefined`, so existing
537
+ `onSelect` assertions keep their arity.
538
+ - **`<ChartContainer selected>` accepts a set** —
539
+ `SelectInfo | readonly SelectInfo[] | null`. A **union, not a
540
+ replacement**: a single `SelectInfo` still means what it always did, so no
541
+ existing caller changes. (`docs/rfcs/selection.md` A1.4 proposed replacing
542
+ the type outright and flagged it as a breaking widen needing the human gate
543
+ plus a one-release shim; accepting both costs one `Array.isArray`.)
544
+ - **`BarStyle.dimmed`** — the fill for a bar outside a non-empty selection.
545
+ Opt-in by construction: a theme that sets no `dimmed` dims nothing, and
546
+ nothing dims while the set is empty (RFC A2.3 — the library never
547
+ auto-dims; the theme carries selection-state styling and the library
548
+ references it by state). It exists because "not in the selection" was
549
+ otherwise re-invented per component and drifted immediately — one consumer
550
+ had three charts using `color-mix` at 22%, 28% and 30% for the same
551
+ concept, in the same week.
552
+
553
+ **Scope limit, stated rather than implied:** the selection _set_ renders on
554
+ **bars**. `ScatterChart` and `HeatMap` draw the first member only
555
+ (`selected[0]`) — their draw paths match a single mark, and widening them is
556
+ separate work. (Since done for `HeatMap` — see _Fixed_ above.) `dimmed` is likewise bars-only, so a mixed chart dims its bars
557
+ and nothing else. `selectionMode` (RFC A1.1) stays unbuilt: it would be sugar
558
+ over what this ships, and adding it later is additive.
559
+
560
+ - **charts: `<HeatMap>` — a grid of colour-coded cells** ([PND-HEATMAP]). Bins
561
+ along x, the series' **columns** down y, colour carrying the value. A single
562
+ column is a stripe (climate stripes, a load band); many columns are a grid.
563
+
564
+ ```tsx
565
+ <HeatMap series={byMonth} columns={ROWS} colors={ramp} axis="yr" id="sst" />
566
+ ```
567
+
568
+ It adds **no reader of its own** — it builds on `stacksFromColumns`, whose
569
+ output is already a heat map's shape, so all four series shapes pond can
570
+ express today (`TimeSeries` or `ValueSeries` × one column or many) work
571
+ unchanged. That also means it has **no opinion about x**: cell spans are
572
+ ordinary bin spans, so `aggregate` over a trading calendar, `Sequence.calendar`
573
+ buckets and `byColumn` value bands all apply, and changing resolution is a
574
+ re-bin of the series rather than a chart prop.
575
+
576
+ The y dimension **must be columns** — a month-of-year grid means a column per
577
+ month. That keeps the second dimension in the data model, where pond's own
578
+ reshaping operators (`pivotByGroup`, `partitionBy`) can produce it, instead of
579
+ inventing a chart-level pivot.
580
+
581
+ A cell **carries its value**, so hover and click report it and the readout
582
+ takes the cell's own colour — the bar-based workaround this replaces cannot,
583
+ since its bars are a constant-height column carrying no value. Note that a
584
+ `TrackerSample`'s `value` is where the cursor _draws_, so the cell's number
585
+ rides `readout`; read it as `readout ?? value`. For a grid, prefer
586
+ `onHover`/`onSelect`, which resolve **both** axes — the tracker knows only x.
587
+
588
+ Colour is data and comes from `colors`, not the theme; geometry and the
589
+ live-cell treatment borrow `theme.bar[as] ?? theme.bar.default`. A live cell
590
+ keeps its own colour and gains an **outline** (hover `outlineWidth`, selection
591
+ twice that) rather than the bar layers' alpha pop, which on a colour scale is
592
+ both usually invisible and misleading.
593
+
594
+ **Viewport decimation, on by default.** Once the visible cells are denser than
595
+ ~2 per device pixel they overlap and overpaint each other, so the picture is
596
+ already one cell per column picked by draw order. `<HeatMap>` replaces that
597
+ with the **mean** per pixel column — what the overdrawn version resolves to at
598
+ that size — from `O(W·G)` rects instead of `O(V·G)`. A 20,000-bin × 45-row
599
+ grid over an 800px plot goes from **48ms to 5.7ms**. Below the gate nothing
600
+ changes at all. `decimate={false}` draws every visible cell; `{ threshold }`
601
+ moves the gate. While decimated, per-cell outlines are suppressed and
602
+ interaction still reads the source grid.
603
+
604
+ Note this is a move a per-bar-coloured `<BarChart>` deliberately does _not_
605
+ make: its reduction is a geometric `[min, max]` envelope, which has no honest
606
+ colour when it spans differently-coloured bars. A heat map's cells composite
607
+ rather than forming a silhouette, so reducing the **value** and letting the
608
+ existing ramp colour it is a resampling answer, not a statistic imposed on the
609
+ reader.
610
+
611
+ **`orientation="horizontal"`** transposes the grid: bins run down **y** and
612
+ the columns become the categories across **x**. Cheaper than the same move on
613
+ `<BarChart>`, because a heat map has two _position_ axes and no value axis —
614
+ nothing changes which scale it is measured against, only which one is
615
+ horizontal on the canvas.
616
+
617
+ This is the orientation gene-expression heat maps are drawn in, and it is what
618
+ makes the layer's y-must-be-columns constraint work rather than fight. Put the
619
+ long dimension on the **key** axis — one row per gene — and pond's ordinary
620
+ binning buckets it: `series.byColumn('rank', { width: 20 }, avg)` is "10,000
621
+ sorted genes into 500 buckets of 20", and `stacksFromBins` hands the result
622
+ straight to the layer. The few samples become the categories across the top.
623
+
624
+ Not built: a grouped two-level x axis, cell value labels, and 2-D region
625
+ selection / pan-zoom.
626
+
627
+ ### Changed
628
+
629
+ - **charts (BREAKING): `<ChartContainer selected>` / `hovered` / `onSelect` /
630
+ `onHover` are REMOVED. Move them onto `<Selector>` / `<MultiSelector>`,
631
+ which now also WRAP the content they apply to.** No deprecation shim — this
632
+ is a straight deletion, and the fix is mechanical. Two changes at once:
633
+
634
+ **1. State moved from the container onto the selector.** `selected` /
635
+ `hovered` are no longer container props; they're `<Selector>` /
636
+ `<MultiSelector>` props, alongside `onSelect` / `onHover`. One component now
637
+ owns both the gesture and the state it produces — a `value`/`onChange` pair,
638
+ the same shape as a controlled `<input>`.
639
+
640
+ **2. The selector wraps its scope instead of sitting beside it.** Previously
641
+ `<Selector>` was a bare sibling mount; it now takes `children` and wraps
642
+ whatever it applies to — every `<ChartRow>` (mounted as a direct child of
643
+ `<ChartContainer>`) or just one row (mounted inside that `<ChartRow>`
644
+ instead).
645
+
646
+ **Migration — before:**
647
+
648
+ ```tsx
649
+ <ChartContainer selected={sel} onSelect={setSel}>
650
+ <ChartRow>…</ChartRow>
651
+ </ChartContainer>
652
+ ```
653
+
654
+ **after:**
655
+
656
+ ```tsx
657
+ <ChartContainer>
658
+ <Selector selected={sel} onSelect={setSel}>
659
+ <ChartRow>…</ChartRow>
660
+ </Selector>
661
+ </ChartContainer>
662
+ ```
663
+
664
+ Same rule for `<MultiSelector>` — `selected` / `hovered` move onto it, and
665
+ it wraps its `<ChartRow>`(s) the same way.
666
+
667
+ **Had `selected` with no `<Selector>` mounted at all** (controlled
668
+ highlighting driven from outside the chart — a legend chip, an external
669
+ filter list — with the plot deliberately inert on click)? Mount
670
+ `<Selector enabled={false} selected={sel}>` wrapping the same content.
671
+ `enabled` is new, defaults to `true`, and `false` disables the **gesture
672
+ only** — no hit-testing, no `onSelect`/`onHover` firing — while `selected` /
673
+ `hovered` stay in effect. This is the one case that needs a new prop rather
674
+ than just moving existing ones; every other case is a pure relocation.
675
+
676
+ **Why, having shipped the opposite design two days ago** (`docs/rfcs/
677
+ interaction.md` A1.2: "the selector reports; the container holds the
678
+ state"): the plot gesture already requires mounting `<Selector>` (§7.1,
679
+ decided earlier still), so a chart that both selects on click and
680
+ highlights a controlled set — most of them — was wiring one concept in two
681
+ places with no structural link between them beyond care. Full write-up in
682
+ `docs/rfcs/interaction.md` Amendment 10. `<Selector>`/`<MultiSelector>` had
683
+ never appeared in a published release, so this is a same-cycle correction,
684
+ not a second breaking change to migrate through.
685
+
686
+ - **charts (breaking, pre-release): `<MultiSelector onSelect>`'s span argument
687
+ is plural.** The signature is now
688
+ `(hits, modifiers, spans: readonly SpanSelection[])` — one argument, not the
689
+ `span` + `spans` pair #634 briefly shipped. **An empty array now carries what
690
+ `null` used to**: a click (clicks produce marks, only sweeps produce spans) or
691
+ a sweep that covered nothing. Spans arrive **topmost layer first**; compare
692
+ them by `id`, since each span-only layer clamps the window to its own key
693
+ range. Not a compatibility concern in practice — `<MultiSelector>` has never
694
+ appeared in a published version.
695
+
696
+ - **charts: `<BarList selected>` / `<BoxList selected>` accept a set**
697
+ ([PND-INTERACTCONF]). Widened from `string | null` to
698
+ `string | readonly string[] | null` — the same union `hovered` already took.
699
+ **Additive:** every existing caller passes a `string` or `null`, both still
700
+ valid and unchanged. The lists were given the _receiving_ half of the sweep
701
+ vocabulary (`hovered` went plural because a sweep lights several marks at
702
+ once) and not the committing half, which made a list multi-select
703
+ inexpressible whatever gesture drives it. `onRowSelect` below is the gesture
704
+ that spends it.
705
+
706
+ - **charts: `defaultTheme`'s resting scatter point moves off cerulean
707
+ (`#0284c7`) to the shared teal `#2A9D8F`, at 9px.** Blue has to mean
708
+ _committed_, and a cerulean point going to selection blue is barely a
709
+ change — the rule the bar palette reached, now for the third time. The
710
+ `primary` / `secondary` roles keep the **line** roles' hues (that identity
711
+ is why they exist) and take the same states with their own hue brightened
712
+ for hover.
713
+
714
+ - **charts: mounting `<MultiSelector>` now changes the row's RESTING state —
715
+ the band and the hover become a live preview of the block a drag would
716
+ select.** Two halves, one requirement ("the grey cursor and the hover
717
+ highlighting are a preview for the block a drag will select"):
718
+ - **The brush band is the resting cursor.** With a `<MultiSelector>` in
719
+ scope over a sweep-capable row, the shared brush band (`<RangeCursor>`'s
720
+ renderer — one function, so the visuals cannot drift) spans the **snap
721
+ block under the pointer**: the `sequence` bucket where one is set, else
722
+ the layer's own bin/slot. It replaces the container's **implicit**
723
+ `'line'` default; an explicitly chosen cursor — a mounted component or a
724
+ legacy `cursor` string the consumer actually set — still wins the
725
+ surface.
726
+ - **Hover is block-scoped.** Pointing at any one mark of a block lights
727
+ (and reports through `onHover`) **every mark in the block** — e.g. all
728
+ four six-hour bars of a day under `sequence={Sequence.calendar('day')}`
729
+ — reported once per block transition, with within-block moves
730
+ re-rendering nothing. A co-mounted `<Selector onHover>` keeps its
731
+ per-mark currency.
732
+
733
+ Rest and drag share one code path (the same snap buckets, the same layer
734
+ sweep session), so what the rest previews and what a drag commits cannot
735
+ disagree — the drag just grows the same band, and release selects exactly
736
+ what was lit. **This is new semantics, not a bug fix**: the interaction RFC
737
+ had hover meaning "the mark under the pointer" (A4.2) and the sweep preview
738
+ meaning "what would be selected"; a mounted snapping `<MultiSelector>` now
739
+ unifies them at rest.
740
+
741
+ - **charts: under a snapping `<MultiSelector>`, a click commits the whole
742
+ block it previewed** — not the single mark under the pointer. This is the
743
+ third state falling in behind the two above: with a `sequence` set, the
744
+ band spans the block, hover lights the block, and a click now selects the
745
+ block, so the three no longer disagree about what "the thing under the
746
+ pointer" means. The commit carries the same `{ kind: 'span', … }`
747
+ descriptor a sweep of that block would produce, so a consumer needs no
748
+ click-versus-drag branch.
749
+
750
+ **Without a `sequence` nothing changes**: a click still reports one mark
751
+ with a null span and stays distinguishable from a sweep (RFC §8). The test
752
+ is the **declaration** — an in-scope `<MultiSelector sequence={…}>` — and
753
+ deliberately not "the block covers more than one mark", which is a different
754
+ question on a stacked chart, where one bin holds one mark per group and an
755
+ ordinary unsnapped click would otherwise swallow the whole column.
756
+
757
+ On a stack a snapped click therefore commits `bins × groups` marks under one
758
+ span — 21 for a seven-day bucket over a three-group stack — while an
759
+ unsnapped one still selects the single segment under the pointer.
760
+
761
+ - **charts: the brush band's edge rules now draw only while a drag is in
762
+ flight.** The band renders in two states — previewing the block a drag
763
+ _would_ select, and tracking a drag actually in progress — and since the
764
+ resting preview landed, the wash alone could not tell them apart. The edges
765
+ mark the boundary the gesture has **grabbed**, so at rest there is nothing
766
+ for them to mark: edging a preview asserts a range the user hasn't made yet,
767
+ and makes the two states read alike.
768
+
769
+ Applies to both band-drawing surfaces, `<MultiSelector>`'s sweep and a
770
+ snapping `<RangeCursor>`, from the one shared renderer. The wash is
771
+ unchanged in both states, and a _click_ never flashes the edges on its way
772
+ to committing a block — the sweep only anchors once it crosses `DRAG_SLOP`.
773
+ Themes that set no `theme.brush` drew no edges to begin with and are
774
+ unaffected.
775
+
776
+ - **charts: `defaultTheme` bars look different. This is a visible change to
777
+ every chart that uses the default theme** — a new **bar interaction-state
778
+ palette** in which state is a _hue_ difference rather than a shade of one
779
+ colour. Previously `fill: '#2563eb'` and `highlight: '#1d4ed8'` were two
780
+ shades of one blue, so a selected bar read as "the same bar, slightly
781
+ darker". Now rest is teal and a committed selection is blue.
782
+
783
+ | `defaultTheme.bar.default` | old | new |
784
+ | -------------------------- | --------- | ----------------------- |
785
+ | `fill` (rest) | `#2563eb` | `#2A9D8F` |
786
+ | `opacity` (rest) | `0.85` | `1` |
787
+ | `highlight` (selected) | `#1d4ed8` | `#3F5BE0` |
788
+ | `hover` | _(unset)_ | `#3FBFAE` |
789
+ | `dimmed` | _(unset)_ | `rgba(42,157,143,0.32)` |
790
+ | `bands[0]` (in-range) | `#2563eb` | `#2A9D8F` |
791
+
792
+ `hover` is a _brighter teal_ and deliberately **not** blue: blue stays
793
+ reserved for committed selection, so a passing pointer and a real selection
794
+ never read as the same act. `bands[0]` follows `fill` because it is the
795
+ in-range band — a bar under its first threshold must not change colour.
796
+
797
+ Two behaviour notes beyond the colours. `defaultTheme` now ships a `dimmed`
798
+ value, so on the default theme **bars outside a non-empty selection now
799
+ recede** where before nothing did (opting in was previously the consumer's
800
+ job). And the resting `opacity` of `1` means gridlines no longer show
801
+ faintly through a bar. `emphasisOpacity` is unchanged — still unset, still
802
+ defaulting to `1`.
803
+
804
+ Nothing about this is forced on a custom theme: every value above lives in
805
+ `defaultTheme`, and `estelaTheme` plus any hand-built theme are untouched.
806
+ To keep the old look, override `bar.default` with the old-column values.
807
+
808
+ - **charts: `defaultTheme`'s data blue and annotation register re-hued — the
809
+ two colour collisions the new bar palette created are fixed.** The old
810
+ annotation turquoise (`#0d9488`) sat ~ΔE 4 from the bar palette's resting
811
+ teal (`#2A9D8F`), so a placed mark over default-theme bars read as data; the
812
+ old royal data blue (`#2563eb`) sat ~ΔE 5 from the new _selection_ blue
813
+ (`#3F5BE0`), so a line drawn over bars read as nearly the selection colour.
814
+
815
+ | `defaultTheme` | old | new |
816
+ | -------------------------------------------------------------- | --------- | --------- |
817
+ | data blue (`line`/`band`/`area`/`scatter`/`box`/candle rising) | `#2563eb` | `#0284c7` |
818
+ | dark accents (box `median`, candle rising `wick`) | `#1e3a8a` | `#075985` |
819
+ | box `whisker` | `#aabee9` | `#a3cde5` |
820
+ | `annotation.color` | `#0d9488` | `#b45309` |
821
+
822
+ The whole `#2563eb` family moves together so the theme keeps one data blue
823
+ (a line and its variance band must stay one hue). The register's new burnt
824
+ amber is a deliberately warm outlier — nearest data hue ΔE2000 ≈ 18, most
825
+ 40+ — restoring "a placed mark never reads as data" for the whole palette.
826
+ Both rules are now pinned by `test/default-theme-collisions.test.ts`, so a
827
+ future palette edit that re-introduces a collision fails CI. The teal
828
+ fallbacks a theme _without_ an annotation register gets (`#14b8a6` /
829
+ `#0d9488` built-ins) are unchanged.
830
+
831
+ - **charts (dev): Storybook unified on the shipped `defaultTheme`.** All ~280
832
+ `theme={docsTheme}` / `theme={estelaTheme}` props are gone from the stories,
833
+ so every story now exercises the `theme ?? defaultTheme` fallback a
834
+ themeless consumer hits — previously nothing in the repo rendered the
835
+ default theme at all, which is how both collisions above went unseen.
836
+ Theming-as-a-feature lives in a curated set: the new `Theming/Showcase`
837
+ group (docs light/dark, estela dark), `Theming/CssVars`, and the handful of
838
+ stories whose subject is the theme channel itself (custom line themes, list
839
+ estela restyles, per-role bar/box/annotation maps — now based on
840
+ `defaultTheme`). Story-local `dimmed`/`highlight` overrides that predated
841
+ the default palette's own interaction states are dropped.
842
+
843
+ - **charts: BREAKING — a plot click does nothing unless a `<Selector>` is
844
+ mounted.** Click-select used to be _implicit_: any layer with an `id` was
845
+ selectable, and the container maintained an uncontrolled `selected` whether or
846
+ not anything was wired. Every chart that highlighted on click goes inert on
847
+ upgrade until a `<Selector>` is mounted — a regression no type error catches,
848
+ accepted deliberately (RFC §7.1) because selection turned out to be a whole
849
+ subsystem (modifiers, a set, a de-emphasis slot, a sweep gesture, precedence
850
+ against hover) and a subsystem that large should not switch itself on because
851
+ a layer happened to be given an `id`.
852
+
853
+ **What is _not_ affected:** `selected` / `hovered` stay on `<ChartContainer>`
854
+ (RFC A1.2), so **controlled highlighting keeps working with no `<Selector>`
855
+ mounted** — a legend chip or an external filter list driving the chart is
856
+ untouched. So does the hover _highlight_, and so does a programmatic
857
+ `select()` from `useChartLegend`. The break is precisely the plot gesture.
858
+
859
+ Softening the landing: a dev build warns **once** when a click resolves to a
860
+ mark and no `<Selector>` is mounted — and **suppresses that warning when
861
+ `selected` is supplied** (RFC A2.6), because after A1.2 that is the signature
862
+ of the _endorsed_ controlled-highlight setup rather than of a lost click. The
863
+ warning is scoped to the deprecation window: an `id` without a `<Selector>` is
864
+ a legitimate configuration afterwards (identity without enablement).
865
+
866
+ - **charts: `<ChartContainer onSelect>` / `onHover` are deprecated** in favour
867
+ of `<Selector onSelect>` / `onHover`. They keep working for one more minor —
868
+ the container synthesizes an equivalent registration internally, so an
869
+ existing chart keeps its click _and_ stays out of the warning above — and
870
+ dev-warn once naming the replacement. A mounted `<Selector>` in the same scope
871
+ overrides them.
872
+ - **charts: `<RangeCursor>` gets its drag — `onDragRelease`, `enableDrag`,
873
+ `dragModifier` — on a single brush recognizer** (interaction RFC §6 / A4.2,
874
+ step 3 of A4.4). Wiring `onDragRelease` makes the cursor draggable: the band
875
+ extends bucket by bucket over the cursor's `sequence` (or a histogram's
876
+ bins; freeform without either), fires **once** on release, and the cursor
877
+ **reverts** to the single-bucket highlight — it does not keep the range.
878
+ The payload is a new exported **`RangeSpan`** — `{ x: [lo, hi], y? }` in
879
+ axis units, not the legacy bare pair: one uniform shape whose optional `y`
880
+ the 2-D drag (scatter / heat map, RFC A3.3) will populate additively.
881
+ `span.x` is exactly what `ChartContainer.range` accepts, so drag-to-zoom is
882
+ `onDragRelease={(s) => setRange(s.x)}`. `enableDrag` defaults to
883
+ `!!onDragRelease` and is the **OFF** switch — set it `false` to freeze the
884
+ gesture without unwiring the callback; `dragModifier="shift"` shares the
885
+ surface with pan (plain drag pans, shift-drag selects) and is only enforced
886
+ while pan is enabled.
887
+
888
+ Structurally, **one brush recognizer now arbitrates every drag claim on the
889
+ plot** — annotation-create capture, the range drag (component or legacy),
890
+ and pan — in a documented precedence order (`packages/charts/src/brush.tsx`,
891
+ RFC A1.5 / A2.7), replacing the ad-hoc ordering inside
892
+ `Layers.handlePointerDown`; the band visual is one shared renderer
893
+ (`renderBrushBand`) so `<RangeCursor>` and the future `<MultiSelector>`
894
+ cannot drift apart. Observable behaviour is unchanged — every existing
895
+ interaction test passes as-is. The legacy `cursor="region"` +
896
+ `onRegionSelect` + `regionSelectModifier` surface keeps working for one more
897
+ minor (now with the dev deprecation notice naming the replacements); a
898
+ mounted `<RangeCursor onDragRelease>` takes the gesture over it.
899
+
900
+ - **charts: span selections — `SpanSelection` entries in `selected`, and
901
+ `selectionContains`** (interaction RFC A5.2/A5.3/A7.6). The controlled
902
+ `selected` prop's array form now accepts `SelectionEntry =
903
+ SelectInfo | SpanSelection`: a span names **every** mark of one layer inside
904
+ a range — `{ kind: 'span', id, x: [lo, hi) }` plus `y` (a scatter's
905
+ continuous second dimension) or `rows` (a heat map's ordinal row names) — so
906
+ a swept session of ten thousand bars is one entry, not ten thousand, at an
907
+ O(1)-per-mark membership test in every selection-aware layer (`BarChart`
908
+ single + stacked, `ScatterChart`, `BoxPlot`, `HeatMap`). Containment is
909
+ **half-open** on `x`/`y` (the pond bucket convention, and what makes a
910
+ sweep's snapped-outward edges agree exactly with the marks it captured —
911
+ RFC A7.6's edge rule); `rows` is label-set membership, stable under a row
912
+ reorder. The exported `selectionContains(sel, hit)` runs the **same**
913
+ predicate the layers do, so consumers implementing click policy over a mixed
914
+ selection never re-implement the interval test; `isSpanSelection` narrows an
915
+ entry for A5.2's demote-on-edit flow. The union widens **non-breaking**: a
916
+ bare `SelectInfo` or plain `SelectInfo[]` means exactly what it did, and a
917
+ spanless render costs nothing new. The sweep gesture itself
918
+ (`<MultiSelector>`) is a later step; spans are fully exercisable through the
919
+ controlled prop today.
920
+
921
+ - **charts: `hovered` is a set.** `<ChartContainer hovered>` accepts
922
+ `SelectInfo | readonly SelectInfo[] | null` (a union, so nothing existing
923
+ changes), and the frame carries the normalized array. Several marks can now
924
+ be lit at once.
925
+
926
+ RFC `selection.md` A1.4 argued hover "is inherently one mark under the
927
+ pointer" — true while hover _means_ pointer position, and false under a drag
928
+ sweep, where it means "would be selected if you released now" and several
929
+ marks light together. This is step 1 of A4.3's sequencing and the
930
+ prerequisite for `<Select>` drag-to-select; plain pointer-over is unaffected
931
+ and simply carries 0 or 1 members.
932
+
933
+ `BoxPlot` reads the first hovered member only, matching the limit its
934
+ selection reader already carries. (`HeatMap` did too; see _Fixed_ above.)
935
+
936
+ ### Fixed
937
+
938
+ - **charts: `panZoomXY` lost its aspect ratio at the zoom edge.** The two axes
939
+ clamped independently — y cannot zoom out past its natural fit, x cannot zoom
940
+ in past `minDuration` — so whichever hit its limit first stopped while the
941
+ other kept going, and the picture sheared from that point on. An aspect lock
942
+ means a limit on **either** axis stops **both**: the factor is now agreed
943
+ before either axis moves (capped at what y can take, then x is asked what it
944
+ would do with that, and x's answer is adopted for both). Caught by two new
945
+ stories — `HeatMap/PanZoomXY`, deliberately diagonally banded because a
946
+ diagonal exposes shear the instant the ratio slips where a blocky grid hides
947
+ it, and `ScatterChart/PanZoomXY` — since the only prior `panZoom` story used a
948
+ category x axis and could not exercise `XY` at all.
949
+
950
+ - **charts: a `<LineChart id>` / `<AreaChart id>` no longer draws the
951
+ "nothing is selectable" warning.** Traces became selectable in
952
+ [PND-TRACESEL] but never joined the container's selectable registry, so the
953
+ guard that catches a genuinely-forgotten `id` fired on every correctly-wired
954
+ trace chart — and its remedy told you to add an `id` to a
955
+ `<BarChart>`/`<ScatterChart>`/`<BoxPlot>`, none of which you had mounted.
956
+ Both trace layers now register on `id` like every other selectable layer, and
957
+ the message names all six. The guard still fires for a trace with **no** `id`,
958
+ which is the footgun it exists for.
959
+
960
+ - **charts: `<Layers>` and `<ChartRow>` now warn when a `<Fragment>` child
961
+ swallows the injected declaration index.** A fragment accepts no props, so
962
+ the index that makes ordering follow the markup died on it and every element
963
+ inside fell back to the `index = 0` default. The sort is stable, so the tie
964
+ resolved to **mount** order — which matches declaration order on a
965
+ synchronous tree, and that is what made it silent: the stack looked right
966
+ until the two disagreed, at which point a layer toggled on between two others
967
+ landed on top instead of slotting into place. Direct children were already
968
+ documented; a dev-only warning now names the consequence, and the fragment is
969
+ no longer cloned, so React's less specific "invalid prop supplied to
970
+ React.Fragment" stops firing alongside it.
971
+
972
+ **A fragment costs more in `<ChartRow>`**, which is why both sites warn: the
973
+ axes inside it lose their order _and_ the `side` sort can't see a `<YAxis>`
974
+ through a fragment, so they render in the plot column instead of a gutter.
975
+
976
+ Four places had already tripped it, three of which **demonstrate** ordering:
977
+ the `LineSweep` story, the `spans[0]`-is-topmost test, and two perf stories —
978
+ one of them a band-behind-line stack that held only by mount luck. All now
979
+ use keyed arrays, and the site-traffic gallery page no longer teaches the
980
+ pattern in prose (its runnable example never used it).
981
+
982
+ - **charts: `<MultiSelector>`'s demote-on-edit stories removed a whole bin
983
+ instead of the clicked mark.** They filtered on `m.key !== hit.key`, which
984
+ is a bar's identity but only half of a stack segment's or a heat cell's —
985
+ so ⌘-clicking one cell inside a swept heat-map region knocked out every row
986
+ of its column. Visible as a column-shaped hole in the selection's outline;
987
+ the outline was faithfully drawing a wrong selection. All three
988
+ hand-rolled identity tests in `selection-stories.tsx` now call `sameMark`.
989
+
990
+ - **charts: `<MultiSelector>`'s demote story could only knock out one mark.**
991
+ It rewrote the span entries and passed everything else through, so once the
992
+ span had demoted there was nothing left for a second ⌘-click to act on and
993
+ it silently did nothing. It now implements the ⌘-click policy
994
+ `selectionContains`' doc describes in full — a mark in the selection comes
995
+ out, one that isn't goes in — with the demote as the extra step a span
996
+ needs before it can lose a member.
997
+
998
+ - **charts: `<ScatterChart>` and `<HeatMap>` index a large selection set
999
+ instead of scanning it** — the live-preview repaint on both goes from
1000
+ seconds to milliseconds. `bars.ts` grew a per-draw set index at 16 entries
1001
+ because a sweep preview puts the whole covered run in `hovered` and the
1002
+ linear scan measured 6.2 s/frame at 100k; neither 2-D layer had the fix, and
1003
+ a rect fills that set faster than a band can. Measured by the new
1004
+ `scripts/perf-interact2d.mjs`:
1005
+
1006
+ | scenario | before | after |
1007
+ | ------------------------------------- | ------- | ------- |
1008
+ | scatter, 100k points, 10k covered | 1424 ms | 13.2 ms |
1009
+ | scatter, 100k points, 50k covered | 4042 ms | 14.7 ms |
1010
+ | heat map, 365×45, 2,783 cells covered | 2.64 ms | 1.01 ms |
1011
+
1012
+ Below the threshold nothing changes: a clicked handful still scans, and
1013
+ still allocates nothing.
1014
+
1015
+ - **charts: a selected segment of a group-ramped stack keeps its own colour.**
1016
+ The outline and the receded neighbours are the cue — the same exclusion
1017
+ `binFills` already gets, and for the same reason: replacing a
1018
+ meaning-carrying colour with the flat `highlight` erases _which group_ is
1019
+ selected, which is the thing the selection is about.
1020
+
1021
+ - **charts: six `<MultiSelector>` polish bugs found walking the Storybook
1022
+ stories.**
1023
+ - **A live sweep no longer draws the data cursor over its band.** The
1024
+ default `line` preset (and any mounted cursor) kept painting its solid
1025
+ vertical rule at the raw pointer for the whole drag, so the sweep read as
1026
+ "a line" while the band (7% wash) sat underneath. A live sweep now
1027
+ suppresses the row's cursor slots exactly as annotation editing does; the
1028
+ shared brush band still renders, so a sweep looks like a `<RangeCursor>`
1029
+ drag (RFC §8.1's identical pixels).
1030
+ - **Adjacent selected bars keep their gap.** The selection outline was a
1031
+ centred `strokeRect`, painting `outlineWidth / 2` outside each bar — with
1032
+ the default `gap: 1` that bridged the whole gap from both sides, fusing a
1033
+ swept run into one solid block. The outline now strokes _inside_ the ink
1034
+ (both draw paths, `drawBars` and `drawStacks`); a mark too thin to contain
1035
+ its outline skips it.
1036
+ - **Clicking the empty space above the bars deselects.** Bar slots tile the
1037
+ full plot height (the continuous hover model of #582), so a click could
1038
+ never resolve to "no mark" — RFC §7's empty-commit deselect path was
1039
+ unreachable on a full-range bar chart. The click hit-test now requires
1040
+ the pointer within the bar's drawn ink vertically (the slot keeps its
1041
+ full interval width, so the gap between columns still selects). Hover is
1042
+ deliberately unchanged: the highlight keeps tracking the full slot like
1043
+ the readout. No new callback needed — the existing
1044
+ `([], modifiers, null)` empty commit is the deselect signal.
1045
+ - **A bucket-snapped sweep lights its whole first bucket from the moment
1046
+ the drag starts.** The move that commits the sweep now runs the covered
1047
+ cut synchronously (later moves stay frame-coalesced per RFC A1.4), and
1048
+ the preview is pinned to agree with the commit exactly under a
1049
+ `sequence`.
1050
+ - **The `SweepAdditive` story's policy handled only spans** — any click
1051
+ (⌘ held or not) cleared the whole selection, so the A5.2 headline flow
1052
+ ("sweep, then ⌘-click to extend") deleted it instead. The library
1053
+ reported `(hits, modifiers, null)` correctly all along; the story-side
1054
+ set arithmetic was wrong and is fixed (a story-driven test now pins it).
1055
+ - **A category sweep's band snaps to the slots' outer edges.** The band
1056
+ scale's `invert` returns slot centres, so the freeform band ran
1057
+ centre-to-centre while capture and the committed span snapped outward
1058
+ (RFC A7.6's edge rule) — the drawn band disagreed with the selection by
1059
+ half a slot at each end. The vertical categorical bar layer now publishes
1060
+ its unit slots as snap buckets and the band extends slot-edge to
1061
+ slot-edge.
1062
+
1063
+ - **charts: `<ScatterChart>` and `<BoxPlot>` light every selected / hovered
1064
+ mark, not just the first.** `ContainerFrame.selected` has been a set since
1065
+ [PND-MULTISEL] and `hovered` since RFC A4.3, but both layers narrowed it back
1066
+ to one mark on the way into their draw — the scatter with `selected[0]`, the
1067
+ box with a `find` on its own series id. A consumer pinning three marks got one
1068
+ ring (or one outline), no warning, and no error.
1069
+
1070
+ Both now match **any** member of either set, the way the bar paths already do
1071
+ (`barMatchesAny`), keeping the linear scan those document: a selection is a
1072
+ handful of marks a person clicked, so a `Set` per draw would cost more than it
1073
+ saves. Precedence is the shared one — **selected > hovered > rest** — so a
1074
+ mark in both sets draws its selected cue only, never two.
1075
+
1076
+ `<ScatterChart>` gains a **hover cue** in the process: it had a hover
1077
+ hit-test but never rendered one. A hovered point takes the style's highlight
1078
+ ring at half strength, the same two-step signal `<BoxPlot>` uses, so no new
1079
+ theme token is invented. Both layers still suppress every highlight while
1080
+ **decimated**, unchanged — a sub-pixel ring isn't visible and interaction
1081
+ still reads the source marks.
1082
+
1083
+ This supersedes the "`BoxPlot` and `HeatMap` read the first hovered member
1084
+ only" note under _Changed_ below, for `BoxPlot`. `<HeatMap>` had the identical
1085
+ bug and is fixed in the entry below — it was scoped out of this change on a
1086
+ mistaken reading of its source (it maps both sets into plural memos, then
1087
+ passed `selection[0]` into a single-mark `drawHeat` a hundred lines later).
1088
+
1089
+ - **charts: `<HeatMap>` lights every selected / hovered cell, not just the
1090
+ first.** `ContainerFrame.selected` has been a set since [PND-MULTISEL] and
1091
+ `hovered` since RFC A4.3. `<HeatMap>` mapped **both** into plural memoized
1092
+ arrays — which is exactly what made the layer read as already-correct — and
1093
+ then handed `drawHeat` `selection[0] ?? null` / `hover[0] ?? null`, whose
1094
+ parameters were `StackMark | null`. A consumer pinning three cells got one
1095
+ outline, with no warning and no error.
1096
+
1097
+ `drawHeat` now takes both sets (`readonly StackMark[]`, defaulting to empty)
1098
+ and outlines **any** cell a member names, keeping the linear scan
1099
+ `barMatchesAny` documents: a selection is a handful of cells a person clicked,
1100
+ so a `Set` per draw would cost more than it saves. Precedence is the shared one
1101
+ — **selected > hovered > rest** — so a cell in both sets takes the
1102
+ full-strength outline once rather than stacking a hover stroke under it. The
1103
+ per-cell scan is gated on either set naming this layer, so a resting draw and a
1104
+ draw whose selection belongs to another layer cost exactly what they did
1105
+ before, and nothing extra is allocated per frame.
1106
+
1107
+ `<HeatMap>` still suppresses every per-cell outline while **decimated**,
1108
+ unchanged — an aggregated column has no per-cell identity to match and a
1109
+ sub-pixel ring isn't visible — now pinned by tests under a plural set.
1110
+
1111
+ This supersedes the "`ScatterChart` and `HeatMap` draw the first member only"
1112
+ scope limit and the "`BoxPlot` and `HeatMap` read the first hovered member
1113
+ only" note below, for `HeatMap`.
1114
+
1115
+ - **charts: the `<HeatMap>` `ValueAxisStripe` story threw on mount.** It called
1116
+ `sf.byValue('high')` on the temperature record in _date_ order, and a value
1117
+ axis must be non-decreasing — it is the key — so the story had never rendered.
1118
+ It now sorts by `high` before re-keying. Found by the story render smoke test
1119
+ added alongside the fix above; `<HeatMap>` had no such net, which is how a
1120
+ broken story went unnoticed.
1121
+
1122
+ - **charts: hover was stuck on the y axis inside a stacked column.**
1123
+ `<ChartContainer>` deduped the hovered mark on `id + key` so the data canvas
1124
+ repaints only when the mark changes — but `key` is the mark's position on the
1125
+ **bin axis**, unique only for a layer with one mark per bin. A stacked
1126
+ `<BarChart>` column or a `<HeatMap>` column stacks several, so every pointer
1127
+ move _within_ a bin was swallowed: dragging straight down a heat-map column
1128
+ never changed the reported cell, and `onHover` never fired. Now compares the
1129
+ mark's full identity (`id`, `key`, `label`, `mark`). Genuine repeats are still
1130
+ deduped, so this costs no extra repaints.
1131
+
1132
+ ## [0.57.0] — 2026-08-07
1133
+
1134
+ ### Added
1135
+
1136
+ - **charts: `<Zone>` — a shaded y-span annotation.** The fourth annotation
1137
+ mark, and the value-axis counterpart of `<Region>`: a band between two y
1138
+ values, spanning the full plot width. The mark for a **classification of the
1139
+ value axis** — US EPA AQI categories, heart-rate / power zones, SLO bands,
1140
+ control-chart spec limits — where a reading only means something read against
1141
+ a scale.
1142
+
1143
+ ```tsx
1144
+ {
1145
+ AQI_CATEGORIES.map((c) => (
1146
+ <Zone key={c.role} from={c.from} to={c.to} axis="aqi" role={c.role} />
1147
+ ));
1148
+ }
1149
+ ```
1150
+
1151
+ Colour comes from `theme.annotation.roles[role]`, so a zone _set_ styles as
1152
+ one palette in the theme rather than N colours at the call site. Bounds are
1153
+ order-free, clamped to the plot (a band past the axis domain is cut, one
1154
+ fully outside culls), and accept `±Infinity` for genuinely open-ended bands
1155
+ (`to={Infinity}` — AQI's "Hazardous", a `ZoneTime.openEnded` zone), so a
1156
+ whole category table can be rendered and the axis decides what shows.
1157
+
1158
+ Three defaults deliberately **invert** the rest of the annotation family,
1159
+ because a zone spans the full width and a set tiles the row:
1160
+ `selectable={false}` (else every mousemove lights a band and its hit area
1161
+ eats the plot's clicks), `edges={false}` (contiguous sets share every
1162
+ interior boundary — edges-on draws each twice), and no auto-label (the bounds
1163
+ are already legible on the y axis; the useful label is a name). `<Zone>` has
1164
+ no `onChange` — drag-to-edit zones await a consumer.
1165
+
1166
+ Guide: [From a CSV to a banded chart](https://pond-ts.org/docs/how-to-guides/air-quality-bands).
1167
+
1168
+ - **charts: `annotation.dash` — an optional dash pattern for the annotation
1169
+ register**, per-register or per-role (`{ color, fillOpacity?, dash? }`), same
1170
+ shape as `LineStyle.dash`. Applies to marker / baseline lines and region /
1171
+ zone boundaries; fills are never dashed. A dashed reference line reads as
1172
+ _placed_ rather than _measured_ — the job the annotation register exists to
1173
+ do, and one colour alone can't always carry.
1174
+
1175
+ - **charts: threshold-banded bars** — `<BarChart thresholds={[1, 2]}>` colours
1176
+ one bar **along its length** against a ladder (neutral → warning → alarm), so
1177
+ a long bar shows how far through the ladder it travelled rather than only
1178
+ which band it ended in. Band fills come from the new
1179
+ **`BarStyle.bands`** on the resolved theme role, overridable per chart with
1180
+ **`<BarChart bandColors>`** — breakpoints are data, colour stays in the theme,
1181
+ the same split `colors` already applies to a stack's group fills.
1182
+
1183
+ The shape was previously expressible as N `<BarChart>` layers drawn
1184
+ outermost-first, each clipped to a band, compositing the gradient by
1185
+ overpainting. That produces the same pixels and loses what matters: N layers
1186
+ means N hit targets, N `SelectInfo.mark` identities and N legend rows for
1187
+ something the reader sees as one bar. Banding is **draw-only** — the hit rect
1188
+ is untouched, so a banded bar stays one bar. It also measures **27–49%
1189
+ cheaper** than the layered workaround at 8–400 categories
1190
+ (`scripts/perf-bandbar.mjs`, which interleaves the arms and prints the
1191
+ ratios), and costs nothing when unused.
1192
+
1193
+ Applies to any single-value bar — `series`, `bins`, `categories`, both
1194
+ orientations. Negatives band symmetrically on the magnitude, so a ± diverging
1195
+ scale needs no negative breakpoints. Ignored (with a dev warning) on a
1196
+ multi-group stack, and yields to `binColors` when both are set. Suppresses
1197
+ envelope decimation for the same reason `binColors` does.
1198
+
1199
+ - **charts: `<YAxis hide>`** — keep the scale, draw no gutter, reserve no
1200
+ width. A `<YAxis>` does two jobs — it _holds the scale_ (`min`/`max`/`scale`/
1201
+ `pad`) and it _renders a gutter_ — and there was no way to ask for the first
1202
+ without the second. A caller could express "auto domain, no gutter" (omit the
1203
+ axis) or "explicit domain, with a gutter", but not the pairing a fixed-domain
1204
+ chart needs. Omitting the axis is not equivalent: the row supplies an implicit
1205
+ auto-domain axis, which is exactly what must not be given up. `width={0}`
1206
+ isn't either — the labels still draw, over the plot.
1207
+
1208
+ Gridlines are unaffected: they belong to the plot, not the gutter, and
1209
+ `<ChartContainer grid>` already governs them.
1210
+
1211
+ - **charts: `<ChartContainer maxBandWidth>` + `bandAlign`** — cap the **slot
1212
+ pitch** on a category x axis and place the resulting block
1213
+ (`'start'` (default) / `'center'` / `'end'`). A band scale otherwise spreads
1214
+ its categories across the full plot width, so three categories in a 900px
1215
+ panel become three 300px bars and thirty become thirty 30px ones — the same
1216
+ chart in the same panel reading as two different charts depending on how many
1217
+ categories the data returned. Fine for a fixed domain; wrong for a **live**
1218
+ one, where bar width becomes a variable that moves on its own and a reader
1219
+ can't compare the chart to itself a minute ago.
1220
+
1221
+ `maxBandWidth` caps the **slot**; `<BarChart gap>` still insets the bar
1222
+ within it — one knob for pitch, one for ink. Omitting `maxBandWidth` is the
1223
+ previous fill behaviour exactly, and a cap too loose to bind degrades back to
1224
+ it rather than clipping. There is deliberately no `bandAlign: 'fill'`: "fill"
1225
+ is what omitting the cap means, and a `fill` alongside a pitch cap would be a
1226
+ contradiction rather than a choice.
1227
+
1228
+ **Vertical / x-axis categories only** — a `orientation="horizontal"`
1229
+ categorical chart puts its categories on the y axis as unit slots, a
1230
+ different mechanism this does not cap.
1231
+
1232
+ - **charts: themed emphasis on the category path** — `BarStyle.hover` /
1233
+ `.highlight` now apply to `categories` and horizontal bars, which routed
1234
+ through the transposed stacked draw path and read neither. New
1235
+ `BarStyle.selectedOutline` (the selected bar's stroke, where the default is
1236
+ its own fill) and `BarStyle.emphasisOpacity` (the alpha a live bar pops to,
1237
+ previously hard-coded `1`) make the emphasis tunable rather than fixed.
1238
+
1239
+ The _behaviour_ was defensible; the problem was that the theme accepted
1240
+ values it would not use. `bar.hover` / `.highlight` were typed, settable and
1241
+ documented as the emphasis channel, and silently did nothing on the most
1242
+ common categorical chart, so a theme author set them, saw no change, and
1243
+ could not tell whether they were wrong about the colour or the mechanism.
1244
+ The one genuine exclusion stays and is now the only one: a `binColors` bar
1245
+ keeps its own colour under hover/selection, because swapping a
1246
+ zone-coloured or direction-coloured bar to a single highlight hue erases
1247
+ what the colour encodes.
1248
+
1249
+ ### Fixed
1250
+
1251
+ - **charts: a negative segment in a multi-group stack is no longer silently
1252
+ dropped** ([PND-SIGNSTACK]). Positives stack **up** from the baseline and
1253
+ negatives stack **down** from it — two running totals per bin — so the
1254
+ **signed stacked histogram** (net flow by category, inflow/outflow, buy/sell
1255
+ pressure by venue) renders correctly. `stackValueExtent` grew the matching
1256
+ negative half; both had to move together, since an extent stopping at `0`
1257
+ would clip the segments the draw path now emits.
1258
+
1259
+ **This is a visible behaviour change** for any existing chart feeding
1260
+ negative values into a multi-group stack — but such a chart was previously
1261
+ rendering _wrongly_: the dropped segments did not clamp, warn or throw, every
1262
+ remaining segment stacked up as though they had never been in the data, and a
1263
+ mixed-sign series came out as a confident, wrong, all-positive chart. An
1264
+ all-positive stack is bit-identical to before. Splitting into two layers was
1265
+ not a workaround either — the negative layer was still `G > 1`, so it was
1266
+ dropped too.
1267
+
1268
+ ### Changed
1269
+
1270
+ - **charts (docs): `<BarChart bins>` now states that it selects a _value_
1271
+ axis** ([PND-TICKUNIT]), so a time-bucketed histogram fed through `bins` gets
1272
+ the decimal 1-2-5 tick ladder rather than the duration ladder a clock
1273
+ subdivides by — labelling e.g. 11:40 and 13:20 at a ~100-minute step. The
1274
+ natural reading ("I have pre-binned buckets, so I'll pass `bins`") is exactly
1275
+ what forecloses the time axis, and nothing at the call site said so. The prop
1276
+ docs now point a time-keyed caller at `<BarChart series columns>`, where the
1277
+ clock ticks are native. No behaviour change.
1278
+
62
1279
  ## [0.56.2] — 2026-08-05
63
1280
 
64
1281
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/react",
3
- "version": "0.56.2",
3
+ "version": "0.58.0",
4
4
  "description": "React hooks for pond-ts live time series",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,7 +33,7 @@
33
33
  "test:runtime": "vitest run"
34
34
  },
35
35
  "peerDependencies": {
36
- "pond-ts": "^0.56.2",
36
+ "pond-ts": "^0.58.0",
37
37
  "react": "^18.0.0 || ^19.0.0"
38
38
  },
39
39
  "devDependencies": {