@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.
- package/CHANGELOG.md +1218 -1
- 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.
|
|
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.
|
|
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.
|
|
36
|
+
"pond-ts": "^0.58.0",
|
|
37
37
|
"react": "^18.0.0 || ^19.0.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|