@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.
- package/API.md +576 -0
- package/CHANGELOG.md +1213 -1
- package/dist/AreaChart.d.ts +12 -1
- package/dist/AreaChart.js +131 -13
- package/dist/BarChart.d.ts +56 -7
- package/dist/BarChart.js +263 -39
- package/dist/BarList.d.ts +85 -5
- package/dist/BarList.js +25 -4
- package/dist/BoxList.d.ts +70 -3
- package/dist/BoxList.js +21 -7
- package/dist/BoxPlot.d.ts +2 -1
- package/dist/BoxPlot.js +101 -9
- package/dist/Candlestick.d.ts +13 -1
- package/dist/Candlestick.js +89 -3
- package/dist/ChartContainer.d.ts +36 -48
- package/dist/ChartContainer.js +465 -59
- package/dist/ChartRow.d.ts +9 -2
- package/dist/ChartRow.js +176 -14
- package/dist/HeatMap.d.ts +176 -0
- package/dist/HeatMap.js +344 -0
- package/dist/Layers.d.ts +5 -1
- package/dist/Layers.js +1014 -253
- package/dist/Legend.js +8 -4
- package/dist/LineChart.d.ts +18 -1
- package/dist/LineChart.js +165 -4
- package/dist/ListTable.d.ts +30 -3
- package/dist/ListTable.js +381 -23
- package/dist/ScatterChart.d.ts +3 -2
- package/dist/ScatterChart.js +68 -4
- package/dist/XAxis.js +40 -22
- package/dist/YAxis.d.ts +58 -2
- package/dist/YAxis.js +3 -1
- package/dist/area.d.ts +34 -1
- package/dist/area.js +88 -1
- package/dist/bars.d.ts +67 -6
- package/dist/bars.js +250 -35
- package/dist/box.d.ts +2 -2
- package/dist/box.js +158 -40
- package/dist/brush.d.ts +142 -0
- package/dist/brush.js +179 -0
- package/dist/child-index.d.ts +27 -0
- package/dist/child-index.js +57 -0
- package/dist/context.d.ts +870 -39
- package/dist/cursors.d.ts +161 -0
- package/dist/cursors.js +503 -0
- package/dist/data.d.ts +38 -0
- package/dist/data.js +43 -0
- package/dist/decimate.d.ts +78 -1
- package/dist/decimate.js +157 -0
- package/dist/format.d.ts +15 -0
- package/dist/format.js +16 -1
- package/dist/heat.d.ts +163 -0
- package/dist/heat.js +659 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.js +27 -0
- package/dist/line.d.ts +137 -0
- package/dist/line.js +328 -0
- package/dist/ohlc.d.ts +16 -1
- package/dist/ohlc.js +93 -4
- package/dist/range.d.ts +14 -1
- package/dist/range.js +24 -3
- package/dist/scatter.d.ts +17 -9
- package/dist/scatter.js +221 -33
- package/dist/select.d.ts +13 -5
- package/dist/select.js +14 -6
- package/dist/selection-fixtures.d.ts +174 -0
- package/dist/selection-fixtures.js +569 -0
- package/dist/selection-stories.d.ts +73 -0
- package/dist/selection-stories.js +301 -0
- package/dist/selectors.d.ts +316 -0
- package/dist/selectors.js +391 -0
- package/dist/span.d.ts +122 -0
- package/dist/span.js +203 -0
- package/dist/sweep.d.ts +154 -0
- package/dist/sweep.js +282 -0
- package/dist/theme.d.ts +510 -5
- package/dist/theme.js +217 -41
- package/dist/tracker.d.ts +6 -0
- package/dist/tracker.js +6 -0
- package/dist/tradingAxis.fixture.d.ts +78 -0
- package/dist/tradingAxis.fixture.js +215 -0
- package/dist/useChartLegend.js +18 -3
- package/dist/yticks.d.ts +3 -0
- package/dist/yticks.js +104 -0
- 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
|
|
28
|
-
|
|
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 =
|
|
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': '' } : {}),
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
boxShadow:
|
|
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
|
|
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
|
|
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);
|
package/dist/ScatterChart.d.ts
CHANGED
|
@@ -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`**;
|
|
136
|
-
* `key`) gets a highlight ring
|
|
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>
|
package/dist/ScatterChart.js
CHANGED
|
@@ -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 {
|
|
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`**;
|
|
24
|
-
* `key`) gets a highlight ring
|
|
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,
|
|
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,
|