@pond-ts/charts 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 (85) hide show
  1. package/API.md +576 -0
  2. package/CHANGELOG.md +1213 -1
  3. package/dist/AreaChart.d.ts +12 -1
  4. package/dist/AreaChart.js +131 -13
  5. package/dist/BarChart.d.ts +56 -7
  6. package/dist/BarChart.js +263 -39
  7. package/dist/BarList.d.ts +85 -5
  8. package/dist/BarList.js +25 -4
  9. package/dist/BoxList.d.ts +70 -3
  10. package/dist/BoxList.js +21 -7
  11. package/dist/BoxPlot.d.ts +2 -1
  12. package/dist/BoxPlot.js +101 -9
  13. package/dist/Candlestick.d.ts +13 -1
  14. package/dist/Candlestick.js +89 -3
  15. package/dist/ChartContainer.d.ts +36 -48
  16. package/dist/ChartContainer.js +465 -59
  17. package/dist/ChartRow.d.ts +9 -2
  18. package/dist/ChartRow.js +176 -14
  19. package/dist/HeatMap.d.ts +176 -0
  20. package/dist/HeatMap.js +344 -0
  21. package/dist/Layers.d.ts +5 -1
  22. package/dist/Layers.js +1014 -253
  23. package/dist/Legend.js +8 -4
  24. package/dist/LineChart.d.ts +18 -1
  25. package/dist/LineChart.js +165 -4
  26. package/dist/ListTable.d.ts +30 -3
  27. package/dist/ListTable.js +381 -23
  28. package/dist/ScatterChart.d.ts +3 -2
  29. package/dist/ScatterChart.js +68 -4
  30. package/dist/XAxis.js +40 -22
  31. package/dist/YAxis.d.ts +58 -2
  32. package/dist/YAxis.js +3 -1
  33. package/dist/area.d.ts +34 -1
  34. package/dist/area.js +88 -1
  35. package/dist/bars.d.ts +67 -6
  36. package/dist/bars.js +250 -35
  37. package/dist/box.d.ts +2 -2
  38. package/dist/box.js +158 -40
  39. package/dist/brush.d.ts +142 -0
  40. package/dist/brush.js +179 -0
  41. package/dist/child-index.d.ts +27 -0
  42. package/dist/child-index.js +57 -0
  43. package/dist/context.d.ts +870 -39
  44. package/dist/cursors.d.ts +161 -0
  45. package/dist/cursors.js +503 -0
  46. package/dist/data.d.ts +38 -0
  47. package/dist/data.js +43 -0
  48. package/dist/decimate.d.ts +78 -1
  49. package/dist/decimate.js +157 -0
  50. package/dist/format.d.ts +15 -0
  51. package/dist/format.js +16 -1
  52. package/dist/heat.d.ts +163 -0
  53. package/dist/heat.js +659 -0
  54. package/dist/index.d.ts +13 -4
  55. package/dist/index.js +27 -0
  56. package/dist/line.d.ts +137 -0
  57. package/dist/line.js +328 -0
  58. package/dist/ohlc.d.ts +16 -1
  59. package/dist/ohlc.js +93 -4
  60. package/dist/range.d.ts +14 -1
  61. package/dist/range.js +24 -3
  62. package/dist/scatter.d.ts +17 -9
  63. package/dist/scatter.js +221 -33
  64. package/dist/select.d.ts +13 -5
  65. package/dist/select.js +14 -6
  66. package/dist/selection-fixtures.d.ts +174 -0
  67. package/dist/selection-fixtures.js +569 -0
  68. package/dist/selection-stories.d.ts +73 -0
  69. package/dist/selection-stories.js +301 -0
  70. package/dist/selectors.d.ts +316 -0
  71. package/dist/selectors.js +391 -0
  72. package/dist/span.d.ts +122 -0
  73. package/dist/span.js +203 -0
  74. package/dist/sweep.d.ts +154 -0
  75. package/dist/sweep.js +282 -0
  76. package/dist/theme.d.ts +510 -5
  77. package/dist/theme.js +217 -41
  78. package/dist/tracker.d.ts +6 -0
  79. package/dist/tracker.js +6 -0
  80. package/dist/tradingAxis.fixture.d.ts +78 -0
  81. package/dist/tradingAxis.fixture.js +215 -0
  82. package/dist/useChartLegend.js +18 -3
  83. package/dist/yticks.d.ts +3 -0
  84. package/dist/yticks.js +104 -0
  85. package/package.json +6 -5
package/dist/ListTable.js CHANGED
@@ -12,20 +12,304 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  * Not exported from the package: the public surface is the two sisters, so the
13
13
  * shared shell can evolve without a compatibility contract.
14
14
  */
15
- import { Fragment, useState } from 'react';
15
+ import { Fragment, useEffect, useMemo, useRef, useState, } from 'react';
16
16
  /** The turquoise the selected-row edge falls back to when the theme has no
17
17
  * annotation register — the same built-in the annotation layer uses. */
18
18
  const FALLBACK_ACCENT = '#0d9488';
19
+ /**
20
+ * Stable identity for "nothing hovered" — the same module-constant trick
21
+ * `ChartContainer` uses for `EMPTY_SELECTION`, so the (overwhelmingly common)
22
+ * no-hover case doesn't mint a fresh set on every render.
23
+ */
24
+ const EMPTY_HOVER = new Set();
25
+ /** The same trick for "nothing selected" — see {@link EMPTY_HOVER}. */
26
+ const EMPTY_SELECTED = new Set();
19
27
  /** The shared text ink: the band-label tone when the theme has one (stronger
20
28
  * than tick labels — these cells are primary content), else the tick ink. */
21
29
  export function listInk(theme) {
22
30
  return theme.axis.band?.label ?? theme.axis.label;
23
31
  }
24
- export function ListTable({ rows, kind, renderGlyphs, before = [], after = [], renderExpanded, defaultExpanded, onExpandToggle, selected, onRowClick, divided = true, baseline = false, markers = [], theme, }) {
32
+ export function ListTable({ rows, kind, renderGlyphs, before = [], after = [], renderExpanded, defaultExpanded, onExpandToggle, selected, onRowClick, onRowSelect, hovered, onHover, divided = true, baseline = false, markers = [], theme, }) {
25
33
  // Uncontrolled expansion, keyed on row identity so it survives a re-sort.
26
34
  const [expanded, setExpanded] = useState(() => new Set(defaultExpanded ?? []));
27
- const [hovered, setHovered] = useState(null);
28
- const interactive = onRowClick !== undefined;
35
+ const interactive = onRowClick !== undefined || onRowSelect !== undefined;
36
+ // The drag-range gesture, armed by the MOUNT (interaction RFC A4.2 rule 1 —
37
+ // the same reason a bare `<MultiSelector />` enables the canvas sweep).
38
+ const ranges = onRowSelect !== undefined;
39
+ // Hover: controlled (`hovered`) or uncontrolled (internal), mirroring the
40
+ // canvas layers' channel on `ChartContainer` (RFC `interaction.md` A3.1 —
41
+ // the list family speaks the same vocabulary, not a parallel one).
42
+ const [internalHovered, setInternalHovered] = useState(null);
43
+ const controlledHover = hovered !== undefined;
44
+ // Rows track + light hover when there's a click affordance (as they always
45
+ // have) or when the consumer wired either half of the hover channel.
46
+ const hoverWired = controlledHover || onHover !== undefined;
47
+ const tracksHover = interactive || hoverWired;
48
+ // Normalize the prop's three accepted shapes — one key, a set, or nothing —
49
+ // into the single shape the render asks its question in ("is this row in the
50
+ // hovered set"), the same normalization `ChartContainer` does for its
51
+ // `SelectInfo` union. `EMPTY_HOVER` keeps the empty case identity-stable.
52
+ const hoveredKeys = useMemo(() => {
53
+ const raw = controlledHover ? hovered : internalHovered;
54
+ if (raw === null || raw === undefined)
55
+ return EMPTY_HOVER;
56
+ return new Set(typeof raw === 'string' ? [raw] : raw);
57
+ }, [controlledHover, hovered, internalHovered]);
58
+ // …and the identical normalization for `selected`, which now takes the same
59
+ // union. It is deliberately the same three shapes and the same question
60
+ // ("is this row in the set"): the two channels of one vocabulary should not
61
+ // differ in how a consumer spells them.
62
+ const selectedKeys = useMemo(() => {
63
+ if (selected === null || selected === undefined)
64
+ return EMPTY_SELECTED;
65
+ return new Set(typeof selected === 'string' ? [selected] : selected);
66
+ }, [selected]);
67
+ const rowByKey = useMemo(() => new Map(rows.map((row) => [row.key, row])), [rows]);
68
+ /**
69
+ * The live drag-range: the row index the press landed on, the one the
70
+ * pointer is over now, and whether it has ever left the first.
71
+ *
72
+ * **Crossing into another row is what makes it a range** — not a pixel slop.
73
+ * A row is tall and discrete, so "did the pointer reach a different row" is
74
+ * the question the gesture actually turns on, and asking it directly means a
75
+ * press-and-release on one row can never accidentally commit a range (nor
76
+ * can a horizontal wobble, which on a stack of rows means nothing at all).
77
+ * It also needs no coordinates: `pointerenter` per row answers it.
78
+ *
79
+ * A ref, not state, for the reason the canvas gesture keeps one: the
80
+ * handlers must never read a state mirror that may not have committed.
81
+ */
82
+ const dragRef = useRef(null);
83
+ /** The run the drag currently covers — the live preview, painted as hover. */
84
+ const [dragRun, setDragRun] = useState(null);
85
+ /**
86
+ * A press is armed — used only to suppress **native text selection** for the
87
+ * gesture's duration.
88
+ *
89
+ * It has to be state rather than the ref above, because the suppression is a
90
+ * style: `user-select` must already be `none` in the DOM before the browser
91
+ * starts extending a selection, which it does on the first move after the
92
+ * press. `pointerdown` is a discrete event, so React flushes this update
93
+ * synchronously — the style lands before any `pointermove` arrives.
94
+ *
95
+ * Scoped to the press rather than to the whole list on purpose: a data list's
96
+ * labels are hostnames and ticker symbols, and people copy them. Mounting a
97
+ * range gesture should not cost the list its selectable text.
98
+ */
99
+ const [armed, setArmed] = useState(false);
100
+ /**
101
+ * The **selection anchor** — the row a range extends *from*, shared by both
102
+ * input methods so they are one model rather than two: click a row, then
103
+ * Shift-Arrow, and the run starts where you clicked.
104
+ *
105
+ * A plain move (arrow, click, Enter) re-anchors; a shift-extend deliberately
106
+ * does not, so repeated Shift-Down grows one run instead of walking a
107
+ * two-row window down the list.
108
+ */
109
+ const anchorRef = useRef(null);
110
+ const tableRef = useRef(null);
111
+ /**
112
+ * This table's own row elements, in display order.
113
+ *
114
+ * Scoped with `:scope >` because an expanded row's detail may contain a
115
+ * whole nested list, whose rows carry the same attribute — a plain
116
+ * descendant query would walk into it and the arrow keys would navigate
117
+ * somebody else's list. (`handlePointerOver` guards the same hazard.)
118
+ */
119
+ const rowEls = () => Array.from(tableRef.current?.querySelectorAll(':scope > tbody > tr[data-list-row]') ?? []);
120
+ /** A ranged drag ends in a `click` too; this swallows that one. */
121
+ const rangedClickRef = useRef(false);
122
+ /**
123
+ * Keyboard parity with the pointer: there is no drag on a keyboard, so the
124
+ * range has to arrive as a **modifier** there.
125
+ *
126
+ * That is not the contradiction with `SelectModifiers`' "an ordinal range is
127
+ * a gesture, not a modifier" that it looks like. The note is about not
128
+ * overloading a *pointer* chord that already means something else (a region
129
+ * drag); a keyboard has no competing gesture and Shift-Arrow is the one
130
+ * range idiom every platform already teaches.
131
+ *
132
+ * - **Arrow Up/Down** — move focus one row; re-anchor.
133
+ * - **Home/End** — move focus to the first/last row; re-anchor.
134
+ * - **Shift** with any of those — move focus and report the run from the
135
+ * anchor, which stays put.
136
+ * - **Enter/Space** — select the focused row (with modifiers, so ⌘/Ctrl-Enter
137
+ * adds); re-anchor.
138
+ *
139
+ * Focus is moved by focusing the row element rather than by tracking an
140
+ * index in state: the browser is already the source of truth for what has
141
+ * focus, and a second copy of that would be one more thing to keep in sync.
142
+ */
143
+ const onRowKeyDown = (e, i) => {
144
+ if (!interactive)
145
+ return;
146
+ // **Only when the ROW itself has focus.** A row may contain its own
147
+ // interactive content — the expander chevron is a real `<button>`, and a
148
+ // consumer's `render` cell could be anything — and `keydown` bubbles. The
149
+ // chevron stops propagation on `click` but a button cannot stop what it
150
+ // does not know about, so without this guard Enter on the chevron reached
151
+ // here, got `preventDefault`ed, and selected the row instead of expanding
152
+ // it (and worse, the cancelled keydown suppresses the button's own Space
153
+ // activation). Arrow keys would likewise yank focus out of the button.
154
+ if (e.target !== e.currentTarget)
155
+ return;
156
+ if (e.key === 'Enter' || e.key === ' ') {
157
+ e.preventDefault();
158
+ anchorRef.current = i;
159
+ onRowClick?.(rows[i]);
160
+ onRowSelect?.([rows[i]], mods(e));
161
+ return;
162
+ }
163
+ const last = rows.length - 1;
164
+ const to = e.key === 'ArrowDown'
165
+ ? Math.min(i + 1, last)
166
+ : e.key === 'ArrowUp'
167
+ ? Math.max(i - 1, 0)
168
+ : e.key === 'Home'
169
+ ? 0
170
+ : e.key === 'End'
171
+ ? last
172
+ : null;
173
+ if (to === null)
174
+ return;
175
+ // Claim the key before anything else can act on it: Arrow and Home/End
176
+ // would otherwise scroll the page out from under the list.
177
+ e.preventDefault();
178
+ rowEls()[to]?.focus();
179
+ if (!e.shiftKey || !ranges) {
180
+ anchorRef.current = to;
181
+ return;
182
+ }
183
+ // Extending: the anchor holds, so Shift-Down repeatedly grows ONE run.
184
+ // With no anchor yet (arrowing in from a Tab, never having selected), the
185
+ // row we left is the honest one to start from.
186
+ const from = anchorRef.current ?? i;
187
+ anchorRef.current = from;
188
+ onRowSelect?.(runOf(from, to), mods(e));
189
+ };
190
+ /** The rows of the inclusive index run `[a, b]`, in display order. */
191
+ const runOf = (a, b) => rows.slice(Math.min(a, b), Math.max(a, b) + 1);
192
+ const mods = (e) => ({
193
+ // `metaKey || ctrlKey`, character-for-character what `Layers` resolves
194
+ // for a canvas select. Deliberately NOT a `navigator.platform` sniff:
195
+ // whatever the better rule might be, a list and a chart in the same app
196
+ // must not disagree about what "add to selection" means — and
197
+ // `navigator.platform` is deprecated and absent in some hosts anyway.
198
+ additive: e.metaKey || e.ctrlKey,
199
+ ctrlKey: e.ctrlKey,
200
+ metaKey: e.metaKey,
201
+ shiftKey: e.shiftKey,
202
+ altKey: e.altKey,
203
+ });
204
+ /** Press: arm a potential range on this row. Nothing commits yet. */
205
+ const beginDrag = (i, e) => {
206
+ if (!ranges)
207
+ return;
208
+ // **Touch is excluded, deliberately.** A vertical drag over a list on a
209
+ // touch device is how you SCROLL, and claiming it for a range would make
210
+ // the list impossible to scroll past. A touch range gesture needs its own
211
+ // affordance (a long-press, or an explicit multi-select mode) rather than
212
+ // stealing the one gesture the platform already spent. Touch keeps
213
+ // click-to-select, which still reports through `onRowSelect`.
214
+ if (e.pointerType === 'touch')
215
+ return;
216
+ // The press is a plain move: it re-anchors, so a later Shift-Arrow
217
+ // extends from the row the user actually grabbed.
218
+ anchorRef.current = i;
219
+ dragRef.current = { anchor: i, current: i, ranged: false };
220
+ setArmed(true);
221
+ setDragRun(null);
222
+ };
223
+ /** The pointer reached row `i` with the button still down. */
224
+ const extendDrag = (i) => {
225
+ const d = dragRef.current;
226
+ if (d === null || i === d.current)
227
+ return;
228
+ d.current = i;
229
+ d.ranged = i !== d.anchor;
230
+ setDragRun(new Set(runOf(d.anchor, i).map((r) => r.key)));
231
+ };
232
+ /** Release: a range commits here; a single row is left to the click. */
233
+ const endDrag = (e) => {
234
+ const d = dragRef.current;
235
+ dragRef.current = null;
236
+ setDragRun(null);
237
+ setArmed(false);
238
+ if (d === null)
239
+ return;
240
+ // Hand the hover channel back, to the row the pointer actually ended on.
241
+ // The press suppressed reporting for its whole duration, so without this
242
+ // the list believes nothing is hovered until the pointer moves again —
243
+ // the row under the cursor would go dark on release and light again on
244
+ // the next twitch.
245
+ reportHover(rows[d.current]?.key ?? null);
246
+ if (!d.ranged)
247
+ return;
248
+ rangedClickRef.current = true;
249
+ onRowSelect?.(runOf(d.anchor, d.current), mods(e));
250
+ };
251
+ // A release **outside** the rows would otherwise leave the drag armed, so a
252
+ // later stray `pointerenter` would resume a gesture the user had finished. It
253
+ // commits rather than cancels: the run the user let go of is the run they
254
+ // meant, and where the pointer happened to be when they did is not the
255
+ // library's business. Registered only while a range gesture is possible — a
256
+ // list with no `onRowSelect` adds no listener at all.
257
+ useEffect(() => {
258
+ if (!ranges)
259
+ return;
260
+ const onUp = (e) => {
261
+ const d = dragRef.current;
262
+ if (d === null)
263
+ return;
264
+ dragRef.current = null;
265
+ setDragRun(null);
266
+ setArmed(false);
267
+ // Released off the rows, so there is no row under the pointer to hand
268
+ // the hover back to — unlike `endDrag`, which knows exactly which.
269
+ reportHover(null);
270
+ if (!d.ranged)
271
+ return;
272
+ rangedClickRef.current = true;
273
+ onRowSelect?.(runOf(d.anchor, d.current), mods(e));
274
+ };
275
+ window.addEventListener('pointerup', onUp);
276
+ return () => window.removeEventListener('pointerup', onUp);
277
+ });
278
+ // The last key we reported, so `onHover` fires on a row transition rather
279
+ // than on every pointer move within a row — the canvas `onHover` dedup rule.
280
+ // A ref (not state): it must not drive a render of its own.
281
+ const lastHoverRef = useRef(null);
282
+ const reportHover = (key) => {
283
+ // **A held press owns the hover channel** — the same rule the canvas
284
+ // follows (`Layers` clears the single-mark hover the moment a sweep arms).
285
+ // Reporting "you are hovering row d" while a run b→d is being previewed
286
+ // would have the two channels contradict each other, with no way for the
287
+ // consumer to tell which was current.
288
+ //
289
+ // Gated on the press being ARMED, not on the run having started: hover is
290
+ // delegated at the table (`handlePointerOver`) while the range extends
291
+ // per row, and React dispatches the ancestor's handler FIRST — so
292
+ // checking `ranged` here would let the crossing that *starts* the run
293
+ // report a hover on its way past, and only suppress the ones after it.
294
+ if (dragRef.current !== null)
295
+ return;
296
+ if (lastHoverRef.current === key)
297
+ return;
298
+ lastHoverRef.current = key;
299
+ if (!controlledHover)
300
+ setInternalHovered(key);
301
+ onHover?.(key === null ? null : (rowByKey.get(key) ?? null));
302
+ };
303
+ // Delegated at the table rather than per-row, so moving from one row to its
304
+ // neighbour reports the new row once — per-row enter/leave would emit a
305
+ // spurious `null` in between, which the canvas channel never does.
306
+ const handlePointerOver = (e) => {
307
+ const rowEl = e.target?.closest('[data-list-row]');
308
+ // A row of a NESTED list (an expanded detail may hold one) is not ours to
309
+ // report — only rows belonging to this table's own `data-list` count.
310
+ const own = rowEl != null && rowEl.closest('[data-list]') === e.currentTarget;
311
+ reportHover(own ? rowEl.getAttribute('data-list-row') : null);
312
+ };
29
313
  const toggle = (key) => {
30
314
  const open = !expanded.has(key);
31
315
  setExpanded((prev) => {
@@ -40,6 +324,11 @@ export function ListTable({ rows, kind, renderGlyphs, before = [], after = [], r
40
324
  };
41
325
  const ink = listInk(theme);
42
326
  const accent = theme.annotation?.color ?? FALLBACK_ACCENT;
327
+ // The row-chart register. Absent ⇒ the pre-token look exactly: a hover band
328
+ // borrowed from `legend.border`, a selection rail from the annotation
329
+ // register, and no dimmed state — so a hand-built theme's rows do not
330
+ // shift under it (the same back-compatibility `theme.brush` was given).
331
+ const reg = theme.list;
43
332
  const divider = divided ? `1px solid ${theme.axis.grid}` : undefined;
44
333
  // Label + before + glyph + after (+ expander) — the detail row spans them all.
45
334
  const span = 2 + before.length + after.length + (renderExpanded ? 1 : 0);
@@ -60,9 +349,15 @@ export function ListTable({ rows, kind, renderGlyphs, before = [], after = [], r
60
349
  borderLeft: baseline ? `1px solid ${theme.axis.grid}` : undefined,
61
350
  });
62
351
  const drawnMarkers = markers.filter((m) => m.frac !== null);
63
- return (_jsx("table", { "data-list": kind, style: {
352
+ return (_jsx("table", { ref: tableRef, "data-list": kind, onPointerOver: tracksHover ? handlePointerOver : undefined, onPointerLeave: tracksHover ? () => reportHover(null) : undefined, style: {
64
353
  width: '100%',
65
354
  borderCollapse: 'collapse',
355
+ // Suppress native text selection **only while a press is armed**. A
356
+ // drag across rows would otherwise sweep up the label text along the
357
+ // way — the run gets picked out in the browser's own selection colour,
358
+ // fighting the band and the rail for the same meaning. Released, the
359
+ // labels are selectable again (see `armed`).
360
+ ...(armed ? { userSelect: 'none' } : {}),
66
361
  font: `${theme.font.size}px/${1.5} ${theme.font.family}`,
67
362
  color: ink,
68
363
  background: theme.background,
@@ -81,28 +376,87 @@ export function ListTable({ rows, kind, renderGlyphs, before = [], after = [], r
81
376
  whiteSpace: 'nowrap',
82
377
  color: accent,
83
378
  }, children: m.label }, mi))) }) }), after.map((cell) => (_jsx("td", { style: textCell(cell.align) }, cell.key))), renderExpanded !== undefined && _jsx("td", {})] })), rows.map((row, i) => {
84
- const isSelected = selected != null && selected === row.key;
379
+ const isSelected = selectedKeys.has(row.key);
380
+ // **A live drag owns the surface**, exactly as the canvas sweep does
381
+ // (`Layers` clears the single-mark hover the moment a sweep arms):
382
+ // while a run is being drawn it IS what "would be selected if you
383
+ // released now", so it replaces the pointer's own hover rather than
384
+ // being unioned with it. `hovered` and `onHover` are untouched — the
385
+ // consumer's channel is not hijacked, it is simply out-ranked for
386
+ // the duration.
387
+ const isHovered = dragRun !== null ? dragRun.has(row.key) : hoveredKeys.has(row.key);
388
+ // Dimmed means *something else* is selected — nothing recedes while
389
+ // the selection is empty. Only meaningful once a register exists;
390
+ // without one there is no dimmed state at all.
391
+ const isDimmed = reg !== undefined && selectedKeys.size > 0 && !isSelected;
392
+ // **Selection outranks hover on the band**, because selection is
393
+ // committed and hover is transient: a hovered selected row must not
394
+ // read as merely hovered. The rail follows the band so the two
395
+ // never disagree about which state the row is in.
396
+ const band = isSelected
397
+ ? reg?.selectedBand
398
+ : isHovered
399
+ ? (reg?.hoverBand ?? theme.legend?.border ?? theme.axis.grid)
400
+ : undefined;
401
+ const rail = isSelected
402
+ ? (reg?.selectedRail ?? accent)
403
+ : isHovered
404
+ ? reg?.hoverRail
405
+ : undefined;
85
406
  const isOpen = renderExpanded !== undefined && expanded.has(row.key);
86
- return (_jsxs(Fragment, { children: [_jsxs("tr", { "data-list-row": row.key, ...(isSelected ? { 'data-selected': '' } : {}), onClick: onRowClick === undefined ? undefined : () => onRowClick(row),
87
- // A clickable row is keyboard-reachable too: focusable, and
88
- // Enter / Space activate it (Space's default scroll is eaten).
89
- tabIndex: interactive ? 0 : undefined, onKeyDown: onRowClick === undefined
407
+ return (_jsxs(Fragment, { children: [_jsxs("tr", { "data-list-row": row.key, ...(isSelected ? { 'data-selected': '' } : {}), ...(isHovered ? { 'data-hovered': '' } : {}), onClick: !interactive
90
408
  ? undefined
91
409
  : (e) => {
92
- if (e.key === 'Enter' || e.key === ' ') {
93
- e.preventDefault();
94
- onRowClick(row);
410
+ // A ranged drag also fires a click; swallow that one,
411
+ // or the release would both commit the run and then
412
+ // immediately report the single row under the pointer.
413
+ if (rangedClickRef.current) {
414
+ rangedClickRef.current = false;
415
+ return;
95
416
  }
96
- }, onPointerEnter: interactive ? () => setHovered(row.key) : undefined, onPointerLeave: interactive ? () => setHovered(null) : undefined, style: {
417
+ anchorRef.current = i;
418
+ onRowClick?.(row);
419
+ // `onRowSelect` is a strict SUPERSET of `onRowClick`,
420
+ // the way `<MultiSelector>` is of `<Selector>`: below
421
+ // the range gesture a click is still a click, and it
422
+ // reports one row plus its modifiers. Both fire when
423
+ // both are mounted — each consumer does its own job.
424
+ onRowSelect?.([row], mods(e));
425
+ }, ...(ranges
426
+ ? {
427
+ onPointerDown: (e) => beginDrag(i, e),
428
+ // Per-row `pointerenter` rather than pointer capture:
429
+ // capture would route every later event to the pressed
430
+ // row and the other rows would never hear the pointer
431
+ // arrive. The cost is that a release outside the table
432
+ // is not seen here — the window listener below is what
433
+ // covers that.
434
+ onPointerEnter: (e) => {
435
+ if (e.buttons !== 0)
436
+ extendDrag(i);
437
+ },
438
+ onPointerUp: endDrag,
439
+ }
440
+ : {}),
441
+ // A clickable row is keyboard-reachable too: focusable, and
442
+ // Enter / Space activate it (Space's default scroll is eaten).
443
+ tabIndex: interactive ? 0 : undefined, onKeyDown: interactive ? (e) => onRowKeyDown(e, i) : undefined, style: {
97
444
  borderTop: i > 0 ? divider : undefined,
98
445
  cursor: interactive ? 'pointer' : undefined,
99
- background: interactive && hovered === row.key
100
- ? (theme.legend?.border ?? theme.axis.grid)
101
- : undefined,
102
- // The selection accent: an inset edge in the annotation
103
- // register (a *user's* mark, so it takes the marks colour,
104
- // not a data hue) — reads on any ground, moves no layout.
105
- boxShadow: isSelected ? `inset 3px 0 0 ${accent}` : undefined,
446
+ background: band,
447
+ // The rail: a 3px inset edge — reads on any ground and
448
+ // moves no layout. It is chrome for the whole ROW, so it
449
+ // resolves from the list register rather than from
450
+ // `bar[as]`; a row may carry several metrics and there is
451
+ // only ever one rail.
452
+ boxShadow: rail === undefined ? undefined : `inset 3px 0 0 ${rail}`,
453
+ // The row is the target, not the bar (see `ChartTheme.list`):
454
+ // a 4% row would otherwise be a sliver to aim at, so the
455
+ // whole band is one hit area of at least 44px.
456
+ // (`height`, not `minHeight`: a table row ignores the
457
+ // latter, while the former is treated as a MINIMUM — the
458
+ // used height is max(specified, content).)
459
+ ...(interactive || hoverWired ? { height: 44 } : {}),
106
460
  }, children: [_jsx("td", { "data-list-cell": "label", style: textCell(), children: row.label ?? row.key }), before.map((cell) => (_jsx("td", { "data-list-cell": cell.key, style: textCell(cell.align), children: cell.render(row) }, cell.key))), _jsx("td", { "data-list-cell": "glyphs",
107
461
  // 100% absorbs the table's free width; every text cell
108
462
  // shrinks to its content, staying aligned down the list.
@@ -111,7 +465,11 @@ export function ListTable({ rows, kind, renderGlyphs, before = [], after = [], r
111
465
  // rule, and border-collapse joins the rows' rules into one
112
466
  // continuous vertical — the same thin `axis.grid` ink as
113
467
  // the row dividers, so the two read as one quiet grid.
114
- style: glyphCellStyle('6px'), children: _jsxs("div", { style: { position: 'relative' }, children: [renderGlyphs(row), drawnMarkers.map((m, mi) => (
468
+ style: glyphCellStyle('6px'), children: _jsxs("div", { style: { position: 'relative' }, children: [renderGlyphs(row, {
469
+ selected: isSelected,
470
+ hovered: isHovered,
471
+ dimmed: isDimmed,
472
+ }), drawnMarkers.map((m, mi) => (
115
473
  // One dotted segment per row, bleeding through the
116
474
  // row's vertical padding (+ divider) so adjacent rows'
117
475
  // segments join into one continuous rule. Annotation
@@ -124,7 +482,7 @@ export function ListTable({ rows, kind, renderGlyphs, before = [], after = [], r
124
482
  width: 0,
125
483
  borderLeft: `1px dotted ${accent}`,
126
484
  pointerEvents: 'none',
127
- } }, mi)))] }) }), after.map((cell) => (_jsx("td", { "data-list-cell": cell.key, style: textCell(cell.align), children: cell.render(row) }, cell.key))), renderExpanded !== undefined && (_jsx("td", { style: { padding: '0 4px', verticalAlign: 'middle' }, children: _jsx("button", { type: "button", "data-list-expander": "", "aria-expanded": isOpen, "aria-label": isOpen ? 'Collapse row' : 'Expand row', onClick: (e) => {
485
+ } }, mi)))] }) }), after.map((cell) => (_jsx("td", { "data-list-cell": cell.key, style: textCell(cell.align), children: cell.render(row) }, cell.key))), renderExpanded !== undefined && (_jsx("td", { style: { padding: '0 4px', verticalAlign: 'middle' }, children: _jsx("button", { type: "button", "data-list-expander": "", "aria-expanded": isOpen, "aria-label": `${isOpen ? 'Collapse' : 'Expand'} ${row.label ?? row.key}`, onClick: (e) => {
128
486
  // The chevron toggles; it must not double as a row click.
129
487
  e.stopPropagation();
130
488
  toggle(row.key);
@@ -132,8 +132,9 @@ export type ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS extends
132
132
  * the nearest-point readout. Scatter reuses the shared tracker rather than
133
133
  * adding a separate `onNearest` channel, so a scatter reads out exactly like a
134
134
  * line. Click selection hit-tests each point's disc (`hitTest`) — **opt-in via
135
- * `id`**; the selected point (matching the selection's series `id` and the sample
136
- * `key`) gets a highlight ring. Without an `id` the scatter is display-only.
135
+ * `id`**; every selected point (matching a selection member's series `id` and
136
+ * the sample `key`) gets a highlight ring, and every hovered one a fainter
137
+ * version of it. Without an `id` the scatter is display-only.
137
138
  *
138
139
  * ```tsx
139
140
  * <Layers>
@@ -3,9 +3,11 @@ import { ValueSeries } from 'pond-ts';
3
3
  import { fromTimeSeries, fromValueSeries } from './data.js';
4
4
  import { drawScatter, hitTestScatter, nearestIndex, scatterExtent, } from './scatter.js';
5
5
  import { resolveEncoding, } from './encoding.js';
6
- import { ContainerContext, LayersContext } from './context.js';
6
+ import { spansForLayer } from './span.js';
7
+ import { ContainerContext, LayersContext, } from './context.js';
7
8
  import { legendLabelFor, useLegendItems, } from './swatch.js';
8
9
  import { useSlotKey } from './use-slot-key.js';
10
+ import { sweep2D } from './sweep.js';
9
11
  /**
10
12
  * A scatter draw layer: one mark per finite point at `(x, column-value)`
11
13
  * — x from the series' key / axis column (time or value axis) —
@@ -20,8 +22,9 @@ import { useSlotKey } from './use-slot-key.js';
20
22
  * the nearest-point readout. Scatter reuses the shared tracker rather than
21
23
  * adding a separate `onNearest` channel, so a scatter reads out exactly like a
22
24
  * line. Click selection hit-tests each point's disc (`hitTest`) — **opt-in via
23
- * `id`**; the selected point (matching the selection's series `id` and the sample
24
- * `key`) gets a highlight ring. Without an `id` the scatter is display-only.
25
+ * `id`**; every selected point (matching a selection member's series `id` and
26
+ * the sample `key`) gets a highlight ring, and every hovered one a fainter
27
+ * version of it. Without an `id` the scatter is display-only.
25
28
  *
26
29
  * ```tsx
27
30
  * <Layers>
@@ -93,6 +96,12 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
93
96
  // axis, the axis value on a value axis; either way it's cs.x[i], the key
94
97
  // column's begin buffer. Used for selection identity.
95
98
  const keyAt = useMemo(() => (i) => cs.x[i], [cs]);
99
+ // The selection's span entries, narrowed to this layer (interaction RFC
100
+ // A5.2). Every point shares one label (the series label), so a span's `rows`
101
+ // channel resolves here once; the `x`/`y` intervals ride through for the
102
+ // draw's per-point test. Reference-stable when empty, so other layers' spans
103
+ // never re-register (or repaint) this one.
104
+ const layerSpans = useMemo(() => spansForLayer(container.selectedSpans, id, seriesLabel), [container.selectedSpans, id, seriesLabel]);
96
105
  const entry = useMemo(() => ({
97
106
  layer: {
98
107
  as: semantic,
@@ -130,8 +139,61 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
130
139
  ? {}
131
140
  : {
132
141
  hitTest: (px, py, xScale, yScale) => hitTestScatter(cs, px, py, xScale, yScale, encoding, keyAt, id, seriesLabel, offset),
142
+ /**
143
+ * The **free rect** ([PND-INTERACT2D]). A point is a position,
144
+ * not a column, so `sweep2D` gets `begin === end` and its x cut
145
+ * means "keys inside the window"; the y half is a plain scan of
146
+ * that run, because a scatter's second dimension is continuous
147
+ * and has nothing to snap to.
148
+ *
149
+ * Each materialised hit is exactly what `hitTestScatter` reports
150
+ * for that point, so a swept point and a clicked one are the
151
+ * same currency.
152
+ */
153
+ // A point/cell owns a position, not a column: the sweep is a rect, and
154
+ // the resting cursor is the small crosshair rather than a band.
155
+ sweepsRect: true,
156
+ beginSweep: () => cs.length === 0
157
+ ? null
158
+ : sweep2D({
159
+ id,
160
+ // A point layer: no span either side of the key.
161
+ begin: cs.x,
162
+ end: cs.x,
163
+ length: cs.length,
164
+ spanFrom: 'drag',
165
+ materialize: (lo, hi, y0, y1) => {
166
+ const out = [];
167
+ for (let i = lo; i < hi; i += 1) {
168
+ const v = cs.y[i];
169
+ // Half-open in y, matching `SpanSelection.y`'s own
170
+ // containment test — so replaying the committed span
171
+ // reproduces exactly this set.
172
+ if (!Number.isFinite(v) || v < y0 || v >= y1)
173
+ continue;
174
+ out.push({
175
+ id,
176
+ key: keyAt(i),
177
+ value: v,
178
+ color: encoding.colorAt(i),
179
+ label: seriesLabel,
180
+ });
181
+ }
182
+ return out;
183
+ },
184
+ // The committed window is the drag's own, not the
185
+ // points' tight bounds: the rect is what the user drew,
186
+ // and a tightened box would quietly exclude a point that
187
+ // arrives later inside the same region.
188
+ channels: (_hits, y0, y1) => ({ y: [y0, y1] }),
189
+ }),
133
190
  }),
134
- draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id, offset, decimate),
191
+ draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font,
192
+ // Both sets whole. `selected` has been plural since [PND-MULTISEL]
193
+ // and `hovered` since RFC A4.3, and the draw rings every member
194
+ // naming this layer — reading `[0]` here quietly showed one ring
195
+ // for a three-mark selection.
196
+ container.selected, container.hovered, id, offset, decimate, layerSpans),
135
197
  },
136
198
  axisId: axis,
137
199
  index,
@@ -147,6 +209,8 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
147
209
  labelAt,
148
210
  font,
149
211
  container.selected,
212
+ container.hovered,
213
+ layerSpans,
150
214
  offset,
151
215
  decimate,
152
216
  axis,