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