@pond-ts/charts 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +1070 -1
  2. package/dist/AreaChart.d.ts +12 -1
  3. package/dist/AreaChart.js +131 -13
  4. package/dist/BarChart.js +184 -30
  5. package/dist/BarList.d.ts +85 -5
  6. package/dist/BarList.js +25 -4
  7. package/dist/BoxList.d.ts +70 -3
  8. package/dist/BoxList.js +21 -7
  9. package/dist/BoxPlot.d.ts +2 -1
  10. package/dist/BoxPlot.js +101 -9
  11. package/dist/Candlestick.d.ts +13 -1
  12. package/dist/Candlestick.js +89 -3
  13. package/dist/ChartContainer.d.ts +36 -48
  14. package/dist/ChartContainer.js +465 -59
  15. package/dist/ChartRow.d.ts +9 -2
  16. package/dist/ChartRow.js +86 -12
  17. package/dist/HeatMap.d.ts +176 -0
  18. package/dist/HeatMap.js +344 -0
  19. package/dist/Layers.d.ts +5 -1
  20. package/dist/Layers.js +1014 -253
  21. package/dist/Legend.js +8 -4
  22. package/dist/LineChart.d.ts +18 -1
  23. package/dist/LineChart.js +165 -4
  24. package/dist/ListTable.d.ts +30 -3
  25. package/dist/ListTable.js +381 -23
  26. package/dist/ScatterChart.d.ts +3 -2
  27. package/dist/ScatterChart.js +68 -4
  28. package/dist/XAxis.js +40 -22
  29. package/dist/area.d.ts +34 -1
  30. package/dist/area.js +88 -1
  31. package/dist/bars.d.ts +57 -3
  32. package/dist/bars.js +237 -26
  33. package/dist/box.d.ts +2 -2
  34. package/dist/box.js +158 -40
  35. package/dist/brush.d.ts +142 -0
  36. package/dist/brush.js +179 -0
  37. package/dist/child-index.d.ts +27 -0
  38. package/dist/child-index.js +57 -0
  39. package/dist/context.d.ts +859 -33
  40. package/dist/cursors.d.ts +161 -0
  41. package/dist/cursors.js +503 -0
  42. package/dist/decimate.d.ts +78 -1
  43. package/dist/decimate.js +157 -0
  44. package/dist/heat.d.ts +163 -0
  45. package/dist/heat.js +659 -0
  46. package/dist/index.d.ts +11 -2
  47. package/dist/index.js +22 -0
  48. package/dist/line.d.ts +137 -0
  49. package/dist/line.js +328 -0
  50. package/dist/ohlc.d.ts +16 -1
  51. package/dist/ohlc.js +93 -4
  52. package/dist/scatter.d.ts +17 -9
  53. package/dist/scatter.js +221 -33
  54. package/dist/select.d.ts +13 -5
  55. package/dist/select.js +14 -6
  56. package/dist/selection-fixtures.d.ts +174 -0
  57. package/dist/selection-fixtures.js +569 -0
  58. package/dist/selection-stories.d.ts +73 -0
  59. package/dist/selection-stories.js +301 -0
  60. package/dist/selectors.d.ts +316 -0
  61. package/dist/selectors.js +391 -0
  62. package/dist/span.d.ts +122 -0
  63. package/dist/span.js +203 -0
  64. package/dist/sweep.d.ts +154 -0
  65. package/dist/sweep.js +282 -0
  66. package/dist/theme.d.ts +456 -5
  67. package/dist/theme.js +217 -41
  68. package/dist/tracker.d.ts +6 -0
  69. package/dist/tracker.js +6 -0
  70. package/dist/tradingAxis.fixture.d.ts +78 -0
  71. package/dist/tradingAxis.fixture.js +215 -0
  72. package/dist/useChartLegend.js +18 -3
  73. package/package.json +3 -3
@@ -6,6 +6,9 @@ import { scaleBand } from './bandScale.js';
6
6
  import { scaleElapsed } from './elapsed.js';
7
7
  import { Sequence } from 'pond-ts';
8
8
  import { ContainerContext, CursorContext, } from './context.js';
9
+ import { LegacyCursor, legacyCursorWarning, presetNameFor, warnOnDuplicateGestureOwners, } from './cursors.js';
10
+ import { effectiveSelectorEntries, resolveControlledHovered, resolveControlledSelected, selectorEntryEqual, warnInertClick, } from './selectors.js';
11
+ import { isDev } from './dev.js';
9
12
  import { maxSlotWidths, sum } from './slots.js';
10
13
  import { computeLabelLanes } from './annotations.js';
11
14
  import { resolveCursorX, DEFAULT_CURSOR_MODE } from './tracker.js';
@@ -13,6 +16,11 @@ import { clampToBounds } from './viewport.js';
13
16
  import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
14
17
  import { TimeAxis } from './TimeAxis.js';
15
18
  import { defaultTheme } from './theme.js';
19
+ import { isSpanSelection, NO_SPANS } from './span.js';
20
+ /** Stable identity for "nothing selected" — see the normalization below. */
21
+ const EMPTY_SELECTION = [];
22
+ /** Stable "no sweep in flight" identity for the span preview channel. */
23
+ const EMPTY_PREVIEW_SPANS = [];
16
24
  /** Tick count for a **continuous** (non-trading) x axis — the `ticks(count)`
17
25
  * request `<TimeAxis>`, the x gridlines, and the cursor-time formatter share
18
26
  * (as the frame's `xTickCount`). */
@@ -45,14 +53,117 @@ function normalizeRange(range) {
45
53
  * {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
46
54
  * (`<YAxis>`).
47
55
  */
48
- export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat, origin, theme, discontinuities, calendar, spacing, grid = true, sessionDividers = 'none', children, }) {
49
- // Normalize the `panZoom` mode (boolean shorthand or the three-way string)
50
- // into the two gesture flags the event surface reads. `true` both; `'pan'`
51
- // drag only; `false`/`'none'` neither. Zoom implies pan (there is no
52
- // zoom-without-pan mode), so `interactive` (holds an internal view) tracks
53
- // whichever is on.
54
- const panEnabled = panZoom === true || panZoom === 'pan' || panZoom === 'panZoom';
55
- const zoomEnabled = panZoom === true || panZoom === 'panZoom';
56
+ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, panZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor: cursorProp, cursorSequence: cursorSequenceProp, onRegionSelect, regionSelectModifier, cursorTime: cursorTimeProp, crosshairSnap: crosshairSnapProp, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat: cursorFormatProp, origin, theme, discontinuities, calendar, spacing, grid = true, sessionDividers = 'none', children, }) {
57
+ // ── Legacy cursor props (deprecated) ───────────────────────────────────────
58
+ // The string surface keeps working for one minor: the resolved mode is
59
+ // synthesized into the equivalent mounted preset below (`<LegacyCursor>`),
60
+ // and a dev warning names the replacement whenever any of the props is
61
+ // *explicitly* set (never on the defaults). Mounted cursor components in the
62
+ // same scope override the shim. See docs/rfcs/interaction.md §9 / A4.4.
63
+ const cursor = cursorProp ?? DEFAULT_CURSOR_MODE;
64
+ const cursorTime = cursorTimeProp ?? false;
65
+ const crosshairSnap = crosshairSnapProp ?? true;
66
+ const warnedLegacyRef = useRef(false);
67
+ useEffect(() => {
68
+ if (!isDev || warnedLegacyRef.current)
69
+ return;
70
+ const legacy = [];
71
+ if (cursorProp !== undefined)
72
+ legacy.push(`cursor="${cursorProp}" → mount ${presetNameFor(cursorProp)}`);
73
+ if (crosshairSnapProp !== undefined)
74
+ legacy.push('crosshairSnap → <CrosshairCursor snap>');
75
+ if (cursorTimeProp !== undefined)
76
+ legacy.push('cursorTime → showTime on the mounted cursor');
77
+ if (cursorFormatProp !== undefined)
78
+ legacy.push('cursorFormat → format on <CrosshairCursor>');
79
+ if (cursorSequenceProp !== undefined)
80
+ legacy.push('cursorSequence → <RangeCursor sequence>');
81
+ if (onRegionSelect !== undefined)
82
+ legacy.push('onRegionSelect → <RangeCursor onDragRelease> (the payload becomes ' +
83
+ '{ x: [lo, hi] })');
84
+ if (regionSelectModifier !== undefined)
85
+ legacy.push('regionSelectModifier → <RangeCursor dragModifier>');
86
+ if (legacy.length === 0)
87
+ return;
88
+ warnedLegacyRef.current = true;
89
+ console.warn(legacyCursorWarning(legacy));
90
+ }, [
91
+ cursorProp,
92
+ crosshairSnapProp,
93
+ cursorTimeProp,
94
+ cursorFormatProp,
95
+ cursorSequenceProp,
96
+ onRegionSelect,
97
+ regionSelectModifier,
98
+ ]);
99
+ // Mounted-cursor registry ({@link ContainerFrame.registerCursor}): the
100
+ // presets (and the legacy shim) register their specs here; rows and
101
+ // `<XAxis>` render the effective set. Same per-instance-slot discipline as
102
+ // the tracker sources; register is idempotent under reference equality (the
103
+ // presets memoize their entries on props).
104
+ const [cursorMap, setCursorMap] = useState(() => new Map());
105
+ const registerCursor = useCallback((key, entry) => {
106
+ setCursorMap((m) => m.get(key) === entry ? m : new Map(m).set(key, entry));
107
+ }, []);
108
+ const unregisterCursor = useCallback((key) => {
109
+ setCursorMap((m) => {
110
+ if (!m.has(key))
111
+ return m;
112
+ const next = new Map(m);
113
+ next.delete(key);
114
+ return next;
115
+ });
116
+ }, []);
117
+ const cursors = useMemo(() => Array.from(cursorMap.values()), [cursorMap]);
118
+ // RFC A2.5: one snap/gesture owner per scope — warn (dev, once) on two.
119
+ const warnedGestureRef = useRef(false);
120
+ useEffect(() => {
121
+ warnOnDuplicateGestureOwners(cursors, warnedGestureRef);
122
+ }, [cursors]);
123
+ // The registered cursors' resolution inputs, folded into the legacy
124
+ // channels: a mounted `<CrosshairCursor format>` feeds the shared readout
125
+ // channel exactly where `cursorFormat` fed it. First **component-mounted**
126
+ // entry wins (the shim registers before the children mount, so a bare
127
+ // first-wins would let the legacy synthesis shadow a real mount); the
128
+ // legacy prop is the fallback during the window. (`sequence` resolves the
129
+ // same way, below the selector registry — a `<MultiSelector sequence>`
130
+ // feeds the same channel.)
131
+ const cursorFormat = useMemo(() => cursors.find((e) => !e.legacy && e.format !== undefined)?.format ??
132
+ cursorFormatProp, [cursors, cursorFormatProp]);
133
+ // Which axes the gestures own. **Pan follows zoom's degrees of freedom**: an
134
+ // axis that can be zoomed can be panned, because a zoomed axis shows less than
135
+ // all of itself and the reader needs to reach the rest. `'pan'` is the one
136
+ // exception — pan with no zoom — and it stays x-only, which is what it has
137
+ // always meant. `'panZoom'` and `true` are `'panZoomX'`: the original
138
+ // behaviour, now named for the axis it acts on.
139
+ const zoomX = panZoom === true ||
140
+ panZoom === 'panZoom' ||
141
+ panZoom === 'panZoomX' ||
142
+ panZoom === 'panZoomXY';
143
+ const zoomY = panZoom === 'panZoomY' || panZoom === 'panZoomXY';
144
+ const panX = zoomX || panZoom === 'pan';
145
+ const panY = zoomY;
146
+ const panEnabled = panX || panY;
147
+ const zoomEnabled = zoomX || zoomY;
148
+ // **The aspect ratio is fixed only when BOTH axes zoom** — one factor about
149
+ // the cursor, so a feature that looked square stays square. A single-axis zoom
150
+ // necessarily changes the ratio, which is the whole point of asking for one.
151
+ //
152
+ // This is why the modes name their axes. An earlier cut had a single
153
+ // `panZoom2D` that claimed both and then silently fell back to y-only wherever
154
+ // x was a category axis: the ratio changed and nothing said so. Spelling out
155
+ // the axes makes that the caller's choice instead of a hidden one.
156
+ const aspectLocked = zoomX && zoomY;
157
+ const [yTransform, setYTransform] = useState({
158
+ k: 1,
159
+ ty: 0,
160
+ });
161
+ const applyYTransform = useCallback((next) => {
162
+ // `k < 1` would zoom out past the axis' natural fit, leaving blank bands the
163
+ // reader cannot interpret; clamping at 1 makes the un-zoomed view the floor.
164
+ const k = Math.max(1, next.k);
165
+ setYTransform((prev) => prev.k === k && prev.ty === next.ty ? prev : { k, ty: next.ty });
166
+ }, []);
56
167
  const interactive = panEnabled || zoomEnabled;
57
168
  // The explicit base domain from `range` (a tuple or a TimeRange). `undefined`
58
169
  // ⇒ auto-fit (resolved from the layers below). Pan/zoom seeds from it; `seed`
@@ -109,6 +220,11 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
109
220
  const [hoverX, setHoverX] = useState(null);
110
221
  // The region-cursor drag anchor (epoch ms) — set on press, cleared on release.
111
222
  const [regionAnchor, setRegionAnchor] = useState(null);
223
+ // The live sweep preview for span-only layers ([PND-TRACESEL]) — a paint
224
+ // mirror beside `regionAnchor`, never part of the committed selection.
225
+ // `EMPTY_PREVIEW_SPANS` keeps the at-rest case reference-stable so a
226
+ // pointer move over an unswept plot re-identifies no layer entry.
227
+ const [previewSpans, setPreviewSpans] = useState(EMPTY_PREVIEW_SPANS);
112
228
  // The free-form crosshair also needs the pointer's y + which row (row-specific,
113
229
  // unlike the shared x). One state object so a move updates both atomically.
114
230
  const [hoverPoint, setHoverPoint] = useState(null);
@@ -154,6 +270,48 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
154
270
  selectableRef.current = next;
155
271
  setSelectableKeys(next);
156
272
  }, []);
273
+ // Mounted-selector registry ({@link ContainerFrame.registerSelector}):
274
+ // `<Selector>` — and the legacy shim below — register here, and **the
275
+ // registration is what enables a plot click** (interaction RFC §7.1). Same
276
+ // per-instance-slot discipline as the cursors/tracker sources. Mirrored to a
277
+ // ref because `select` / `setHovered` are `[]`-stable callbacks that must not
278
+ // read a stale closure (the `onSelectRef` discipline, one level up).
279
+ const [selectorMap, setSelectorMap] = useState(() => new Map());
280
+ const registerSelector = useCallback((key, entry) => {
281
+ setSelectorMap((m) => {
282
+ // **Value-equal, not reference-equal.** Since A10.3 the entry carries the
283
+ // controlled `selected`/`hovered`, so a consumer's inline array mints a
284
+ // fresh entry every render; a reference-only guard then updates the
285
+ // registry every time, and any descendant that reads the container
286
+ // context (`useChartLegend()`) re-renders, rebuilds the array, and
287
+ // re-registers — an unbounded loop. Same guard, same reason, as
288
+ // `registerAxis`/`axisSpecEqual` in `ChartRow.tsx`.
289
+ const prev = m.get(key);
290
+ if (prev !== undefined && selectorEntryEqual(prev, entry))
291
+ return m;
292
+ return new Map(m).set(key, entry);
293
+ });
294
+ }, []);
295
+ const unregisterSelector = useCallback((key) => {
296
+ setSelectorMap((m) => {
297
+ if (!m.has(key))
298
+ return m;
299
+ const next = new Map(m);
300
+ next.delete(key);
301
+ return next;
302
+ });
303
+ }, []);
304
+ const selectors = useMemo(() => Array.from(selectorMap.values()), [selectorMap]);
305
+ const selectorsRef = useRef(selectors);
306
+ // The shared snap-bucket sequence, folded into the legacy channel exactly as
307
+ // `cursorFormat` is above: a component-mounted `<RangeCursor sequence>`
308
+ // wins, then a mounted `<MultiSelector sequence>` (its sweep extends bucket
309
+ // by bucket over the same realized buckets — one channel, so the band and
310
+ // the sweep can never snap differently), then the legacy shim / prop.
311
+ const cursorSequence = useMemo(() => cursors.find((e) => !e.legacy && e.sequence !== undefined)?.sequence ??
312
+ selectors.find((e) => e.sequence !== undefined)?.sequence ??
313
+ cursors.find((e) => e.sequence !== undefined)?.sequence ??
314
+ cursorSequenceProp, [cursors, selectors, cursorSequenceProp]);
157
315
  // Annotations register here so the container can do what a mark can't in
158
316
  // isolation: draw its guide line across other rows, order regions, serve snap
159
317
  // targets. Keyed by per-instance slot key (same discipline as the sources).
@@ -252,43 +410,147 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
252
410
  const reportDrawStats = useMemo(() => hasDrawStats
253
411
  ? (frame) => onDrawStatsRef.current?.(frame)
254
412
  : undefined, [hasDrawStats]);
255
- // Selection: controlled (`selected` prop) or uncontrolled (internal). A click
256
- // on a selectable layer calls `select()` after hit-testing; `onSelect` notifies
257
- // in both modes, the internal state is managed only when uncontrolled. The full
413
+ // Selection: controlled (a mounted selector's `selected`) or uncontrolled
414
+ // (internal). A click on a selectable layer calls `select()` after
415
+ // hit-testing; the mounted `<Selector>`s in scope are notified in both
416
+ // modes, and the internal state is managed only when uncontrolled — whether
417
+ // *this* container's derived state is controlled now depends on the
418
+ // registry, not a container prop (interaction RFC A10.3). The full
258
419
  // SelectInfo is the identity (key + series), so multi-series marks at one
259
420
  // timestamp stay distinct. Refs written after commit (not in render) so the
260
421
  // click handler never reads a callback / mode from a frame abandoned under
261
422
  // concurrent rendering.
423
+ // Widened past a single mark for the sweep (RFC A5.2): an uncontrolled
424
+ // `<MultiSelector>` release commits its compact span descriptor here, so the
425
+ // swept bars stay lit with no controlled prop — the sweep analog of the
426
+ // uncontrolled click highlight. Clicks still store the single hit.
262
427
  const [internalSelected, setInternalSelected] = useState(null);
263
- const controlledSelection = selected !== undefined;
264
- const selectedValue = controlledSelection
265
- ? (selected ?? null)
266
- : internalSelected;
267
- const onSelectRef = useRef(onSelect);
428
+ // Controlled selection now comes from the registry (interaction RFC A10.3):
429
+ // whichever mounted `<Selector>`/`<MultiSelector>` declared `selected` owns
430
+ // it chart-wide, not row-scoped, and independent of `gestureEnabled`
431
+ // (`<Selector enabled={false} selected={…}>` is a legitimate owner). Warned
432
+ // once if more than one mount declares it.
433
+ const warnedAmbiguousSelectedRef = useRef(false);
434
+ const controlledSelected = useMemo(() => resolveControlledSelected(selectors, warnedAmbiguousSelectedRef), [selectors]);
435
+ const controlledSelection = controlledSelected.present;
436
+ // Normalize the three accepted shapes — a single mark, a set, or nothing —
437
+ // into the shapes the frame carries ([PND-MULTISEL] / RFC A5.2). A mixed
438
+ // `SelectionEntry` array is split ONCE here into its mark entries
439
+ // (`selected`, the exact field every pre-span reader keeps consuming
440
+ // unchanged) and its span descriptors (`selectedSpans`, which only the
441
+ // span-aware layers read) — so a consumer who never passes a span pays
442
+ // nothing anywhere: the marks array is the prop's own array (no copy), the
443
+ // spans field is the module constant, and every downstream `length === 0`
444
+ // gate short-circuits as before. `EMPTY_SELECTION` / `NO_SPANS` are module
445
+ // constants so the common cases keep stable identities and don't
446
+ // re-identify the frame on every render.
447
+ const { selectedValue, selectedSpans } = useMemo(() => {
448
+ const raw = controlledSelection
449
+ ? controlledSelected.value
450
+ : internalSelected;
451
+ if (raw === null || raw === undefined)
452
+ return { selectedValue: EMPTY_SELECTION, selectedSpans: NO_SPANS };
453
+ if (!Array.isArray(raw))
454
+ return {
455
+ selectedValue: [raw],
456
+ selectedSpans: NO_SPANS,
457
+ };
458
+ const entries = raw;
459
+ // The overwhelmingly common case — no span entries — returns the caller's
460
+ // array as-is rather than partitioning into fresh ones.
461
+ let spanCount = 0;
462
+ for (let i = 0; i < entries.length; i += 1) {
463
+ if (isSpanSelection(entries[i]))
464
+ spanCount += 1;
465
+ }
466
+ if (spanCount === 0)
467
+ return {
468
+ selectedValue: entries,
469
+ selectedSpans: NO_SPANS,
470
+ };
471
+ const marks = [];
472
+ const spans = [];
473
+ for (let i = 0; i < entries.length; i += 1) {
474
+ const e = entries[i];
475
+ if (isSpanSelection(e))
476
+ spans.push(e);
477
+ else
478
+ marks.push(e);
479
+ }
480
+ return {
481
+ selectedValue: marks.length === 0 ? EMPTY_SELECTION : marks,
482
+ selectedSpans: spans,
483
+ };
484
+ }, [controlledSelection, controlledSelected.value, internalSelected]);
268
485
  const controlledSelectionRef = useRef(controlledSelection);
269
486
  useLayoutEffect(() => {
270
- onSelectRef.current = onSelect;
487
+ selectorsRef.current = selectors;
271
488
  controlledSelectionRef.current = controlledSelection;
272
489
  });
273
- const select = useCallback((hit) => {
274
- onSelectRef.current?.(hit);
490
+ // The library applies no set arithmetic: it reports the hit plus the
491
+ // modifiers and, when uncontrolled, keeps the single-mark behaviour it always
492
+ // had. A consumer wanting add/toggle reads `modifiers.additive` and drives
493
+ // the controlled `selected` set — see `SelectModifiers`.
494
+ //
495
+ // `rowKey` present ⇒ a **plot gesture**, and RFC §7.1's gate applies: with no
496
+ // `<Selector>` in scope the click does nothing at all — it does not even
497
+ // commit the uncontrolled selection, which is the whole point (a chart that
498
+ // silently highlighted on click now doesn't). Absent ⇒ a programmatic select
499
+ // (a `<Legend>` chip), which is intentional by construction and stays
500
+ // ungated.
501
+ const warnedInertClickRef = useRef(false);
502
+ const select = useCallback((hit, modifiers, rowKey) => {
503
+ const entries = effectiveSelectorEntries(selectorsRef.current, rowKey ?? null);
504
+ if (rowKey !== undefined && entries.length === 0) {
505
+ // A2.6: suppress when controlled `selected` is in effect — that is
506
+ // the signature of the *endorsed* controlled-highlight setup
507
+ // (`<Selector enabled={false} selected={…}>`), not of a consumer who
508
+ // lost their click.
509
+ if (isDev && hit !== null && !controlledSelectionRef.current)
510
+ warnInertClick(warnedInertClickRef);
511
+ return;
512
+ }
513
+ // Pass the second argument only when there is one. Calling
514
+ // `onSelect(hit, undefined)` unconditionally would change the observed
515
+ // arity for every existing consumer — enough to break a
516
+ // `toHaveBeenCalledWith(hit)` assertion, which is a silly thing to break
517
+ // for a purely additive feature.
518
+ for (const e of entries) {
519
+ if (modifiers === undefined)
520
+ e.onSelect?.(hit);
521
+ else
522
+ e.onSelect?.(hit, modifiers);
523
+ // A mounted <MultiSelector> hears the same click in its own currency
524
+ // (RFC §8: everything <Selector> does): 0/1 hits, no span — a click
525
+ // produces marks, only a sweep produces a span (A5.2).
526
+ e.onSelectMany?.(hit === null ? EMPTY_SELECTION : [hit], modifiers, EMPTY_PREVIEW_SPANS);
527
+ }
275
528
  if (!controlledSelectionRef.current)
276
529
  setInternalSelected(hit);
277
530
  }, []);
278
- // Dev-warn: selection is wired (`selected` and/or `onSelect`) but no layer
279
- // carries an `id`, so nothing is selectable — `id` gates interactivity, so a
280
- // consumer who forgot it gets a silent no-op click without this nudge. Fires
281
- // once per wired-but-empty transition (guarded by a ref); child layers
282
- // register before this parent effect runs, so the set is settled here.
283
- const selectionWired = controlledSelection || onSelect !== undefined;
531
+ // Dev-warn: selection is wired (a controlled `selected`, or a mounted
532
+ // `<Selector>`/`<MultiSelector>` at all mounting is itself wiring, even
533
+ // with no callbacks) but no layer carries an `id`, so nothing is
534
+ // selectable `id` gates interactivity, so a consumer who forgot it gets a
535
+ // silent no-op click without this nudge. Fires once per wired-but-empty
536
+ // transition (guarded by a ref); child layers register before this parent
537
+ // effect runs, so the set is settled here.
538
+ // "Wired" means a gesture is armed, or state is being driven. A selector
539
+ // whose gesture is off AND which declares no state is wired to nothing — it
540
+ // would be a strange thing to mount, but accusing it of a missing `id` is a
541
+ // false positive, so it doesn't count (reviewer finding on #638).
542
+ const selectionWired = controlledSelection ||
543
+ selectors.some((e) => e.gestureEnabled || e.declaresSelected || e.declaresHovered);
284
544
  const warnedNoSelectableRef = useRef(false);
285
545
  useEffect(() => {
286
546
  if (selectionWired && selectableRef.current.size === 0) {
287
547
  if (!warnedNoSelectableRef.current) {
288
548
  warnedNoSelectableRef.current = true;
289
- console.warn('[pond-charts] `selected`/`onSelect` is set but no layer has an `id` — ' +
290
- 'nothing is selectable. Give a <BarChart>/<ScatterChart>/<BoxPlot> an ' +
291
- '`id` to make it interactive (an `id` gates selection + hover).');
549
+ console.warn('[pond-charts] a <Selector>/<MultiSelector> is mounted (or declares ' +
550
+ '`selected`) but no layer has an `id` — nothing is selectable. Give ' +
551
+ 'a <BarChart>/<ScatterChart>/<BoxPlot>/<HeatMap>/<LineChart>/' +
552
+ '<AreaChart> an `id` to make it interactive (an `id` gates ' +
553
+ 'selection + hover).');
292
554
  }
293
555
  }
294
556
  else {
@@ -296,37 +558,140 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
296
558
  }
297
559
  }, [selectionWired, selectableKeys]);
298
560
  // Hover-highlight: the transient mark under the pointer (distinct from the
299
- // committed selection). Controlled (`hovered` prop) or uncontrolled (internal),
300
- // mirroring selection; `onHover` notifies in both modes. Deduped by key+label
301
- // so it fires and the data canvas repaintsonly when the hovered mark
302
- // changes, not on every pointer move (the move itself just slides the SVG
303
- // cursor, which never touches the data canvas).
561
+ // committed selection). Controlled (a mounted selector's `hovered`) or
562
+ // uncontrolled (internal), mirroring selection; `onHover` notifies in both
563
+ // modes. Deduped by the mark's full identity so it fires and the data
564
+ // canvas repaints only when the hovered mark changes, not on every pointer
565
+ // move (the move itself just slides the SVG cursor, which never touches the
566
+ // data canvas).
567
+ //
568
+ // "Full identity" means `label` and `mark` as well as `id` and `key`, and that
569
+ // is load-bearing rather than belt-and-braces. `key` is the mark's position on
570
+ // the **bin axis**, so any layer that stacks more than one mark in a bin — a
571
+ // stacked bar, a `<HeatMap>` column — has several marks sharing a key, and
572
+ // deduping on `id + key` alone silently swallows every move *within* a bin.
573
+ // On a heat map that reads as the hover being stuck: dragging straight down a
574
+ // column never changes the reported cell.
575
+ // Widened past a single mark for the sweep's live preview (RFC A1.4/A3.4):
576
+ // a drag under a mounted <MultiSelector> lights every covered mark at once
577
+ // through this same field — the reason `hovered` became a set.
304
578
  const [internalHovered, setInternalHovered] = useState(null);
305
- const controlledHover = hovered !== undefined;
306
- const hoveredValue = controlledHover ? (hovered ?? null) : internalHovered;
307
- const onHoverRef = useRef(onHover);
579
+ const warnedAmbiguousHoveredRef = useRef(false);
580
+ const controlledHovered = useMemo(() => resolveControlledHovered(selectors, warnedAmbiguousHoveredRef), [selectors]);
581
+ const controlledHover = controlledHovered.present;
582
+ // Same three-shape normalization `selected` does — a single mark, a set, or
583
+ // nothing — so a pointer-driven hover (always one mark) and a sweep-driven
584
+ // one (several) reach the draw paths in the same shape (RFC A4.2).
585
+ const hoveredValue = useMemo(() => {
586
+ const raw = controlledHover ? controlledHovered.value : internalHovered;
587
+ if (raw === null || raw === undefined)
588
+ return EMPTY_SELECTION;
589
+ return Array.isArray(raw) ? raw : [raw];
590
+ }, [controlledHover, controlledHovered.value, internalHovered]);
308
591
  const controlledHoverRef = useRef(controlledHover);
309
592
  // The last mark we reported — so the callback dedups across pointer moves even
310
593
  // in controlled mode, where there's no internal state to compare against.
311
594
  const lastHoverRef = useRef(null);
312
595
  useLayoutEffect(() => {
313
- onHoverRef.current = onHover;
314
596
  controlledHoverRef.current = controlledHover;
315
597
  });
316
- const setHovered = useCallback((hit) => {
598
+ // The last resting BLOCK we reported (identity the row keeps the array
599
+ // reference-stable per block), so <MultiSelector onHover> hears one call per
600
+ // block transition and within-block mark transitions re-render nothing.
601
+ const lastHoverBlockRef = useRef(null);
602
+ // Unlike `select`, this is **not** gated on a mounted `<Selector>`: the
603
+ // hover state moves regardless, uncontrolled with none mounted; with none
604
+ // mounted there is simply no `onHover` to fire.
605
+ const setHovered = useCallback((hit, rowKey, block) => {
317
606
  const prev = lastHoverRef.current;
318
- const same = prev === hit ||
607
+ const sameMark = prev === hit ||
319
608
  (prev !== null &&
320
609
  hit !== null &&
321
610
  prev.id === hit.id &&
322
- prev.key === hit.key);
323
- if (same)
611
+ prev.key === hit.key &&
612
+ prev.label === hit.label &&
613
+ prev.mark === hit.mark);
614
+ const blockNow = block ?? null;
615
+ const sameBlock = lastHoverBlockRef.current === blockNow;
616
+ if (sameMark && sameBlock)
324
617
  return;
325
618
  lastHoverRef.current = hit;
326
- onHoverRef.current?.(hit);
619
+ lastHoverBlockRef.current = blockNow;
620
+ for (const e of effectiveSelectorEntries(selectorsRef.current, rowKey ?? null)) {
621
+ // <Selector onHover> keeps its per-mark currency regardless of any
622
+ // block — the mark under the pointer, per mark transition.
623
+ if (!sameMark)
624
+ e.onHover?.(hit);
625
+ // A mounted <MultiSelector> hears hover in its own plural currency:
626
+ // the resting BLOCK preview when there is one (per block transition —
627
+ // the marks a drag begun and released here would select), else 0/1
628
+ // hits per mark transition. A live sweep reports through
629
+ // `resolveSweep`'s preview instead, several at once.
630
+ if (blockNow !== null) {
631
+ if (!sameBlock)
632
+ e.onHoverMany?.(blockNow);
633
+ }
634
+ else if (!sameMark) {
635
+ e.onHoverMany?.(hit === null ? EMPTY_SELECTION : [hit]);
636
+ }
637
+ }
638
+ // The block (reference-stable per block) is the hovered STATE when
639
+ // present, so every mark in it lights — and a within-block mark
640
+ // transition hands React the same value back (no re-render, no
641
+ // repaint), which is the block-level analog of the single-mark dedup.
327
642
  if (!controlledHoverRef.current)
328
- setInternalHovered(hit);
643
+ setInternalHovered(blockNow ?? hit);
644
+ }, []);
645
+ // The sweep gesture's container half (interaction RFC §8 / A5.2): resolve a
646
+ // row's press against the mounted <MultiSelector>s in scope and hand the
647
+ // gesture engine its two sinks. Presence is the arm switch — `null` means no
648
+ // sweep can claim the drag (§7.1's mounting-is-enablement, extended). The
649
+ // entries are captured at the press and live for that one drag, matching the
650
+ // ref-not-state discipline of the range drag (#508 item 7).
651
+ const resolveSweep = useCallback((rowKey) => {
652
+ const entries = effectiveSelectorEntries(selectorsRef.current, rowKey).filter((e) => e.multi);
653
+ if (entries.length === 0)
654
+ return null;
655
+ return {
656
+ // The *declaration*, not a measurement of the block — see the field doc.
657
+ snapped: entries.some((e) => e.sequence !== undefined),
658
+ preview: (hits, light = true) => {
659
+ // The single-hit / resting-block dedup state is meaningless mid-sweep;
660
+ // reset both so the first post-sweep pointer hover always reports.
661
+ lastHoverRef.current = null;
662
+ lastHoverBlockRef.current = null;
663
+ // Reporting and lighting are separate on purpose — see the field doc.
664
+ for (const e of entries)
665
+ e.onHoverMany?.(hits);
666
+ if (!controlledHoverRef.current)
667
+ setInternalHovered(light ? hits : null);
668
+ },
669
+ commit: (hits, modifiers, spans) => {
670
+ for (const e of entries)
671
+ e.onSelectMany?.(hits, modifiers, spans);
672
+ // Uncontrolled: the compact span descriptors ARE the selection (A5.2's
673
+ // second currency) — the swept marks stay lit via the same membership
674
+ // test a controlled span would use. The preview clears; the committed
675
+ // highlight takes over.
676
+ //
677
+ // **All of them**, not just the claimant's: a trace sweep produces one
678
+ // span per trace ([PND-TRACESEL]), and keeping only the topmost would
679
+ // leave the uncontrolled path showing less than the preview promised.
680
+ if (!controlledSelectionRef.current)
681
+ setInternalSelected(spans.length === 0 ? null : [...spans]);
682
+ lastHoverRef.current = null;
683
+ lastHoverBlockRef.current = null;
684
+ if (!controlledHoverRef.current)
685
+ setInternalHovered(null);
686
+ },
687
+ };
329
688
  }, []);
689
+ // The render-time "is a <MultiSelector> in scope for this row" fact, for the
690
+ // resting block preview (frame doc in context.ts). Unlike `resolveSweep` —
691
+ // a []-stable callback over the selectors REF, correct at pointer-down — a
692
+ // row reads this during render to pick its resting cursor, so it derives
693
+ // from the selectors STATE and re-identifies when the registry changes.
694
+ const hasMultiSelector = useCallback((rowKey) => effectiveSelectorEntries(selectors, rowKey).some((e) => e.multi), [selectors]);
330
695
  // Rows report their per-slot gutter widths; we reserve each slot's max.
331
696
  const [gutters, setGutters] = useState([]);
332
697
  const registerGutter = useCallback((req) => {
@@ -613,20 +978,23 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
613
978
  }
614
979
  // No sequence → snap to a bar/histogram layer's bins, if any (a value axis,
615
980
  // or a time-axis histogram with no explicit sequence). `binIntervals` is only
616
- // published by a vertical bar layer on a continuous axis, so this is a no-op
617
- // for line/area/scatter rows and for a category axis.
981
+ // published by a vertical bar layer, so this is a no-op for
982
+ // line/area/scatter rows. On a **category** axis the bins are the unit
983
+ // slots `[i, i+1)`: the region cursor never reads them (its band gates on
984
+ // a continuous axis), but the `<MultiSelector>` sweep's band snaps over
985
+ // them so it runs slot-edge to slot-edge — the band scale's `invert`
986
+ // returns slot *centres*, and a centre-to-centre band disagreed with the
987
+ // snapped-outward span the release commits (RFC A7.6's edge rule).
618
988
  //
619
989
  // **First bar layer wins** — deliberately non-fatal, unlike `xCategories`
620
990
  // (which *throws* when category rows disagree, because a mismatched slot order
621
991
  // corrupts the shared band scale). Two overlaid histograms with different bins
622
992
  // is a degenerate layout the region cursor just snaps to whichever registered
623
993
  // first; a wrong snap grid is harmless where a wrong axis is not.
624
- if (resolvedKind === 'time' || resolvedKind === 'value') {
625
- for (const s of sources.values()) {
626
- const bins = s.binIntervals?.() ?? null;
627
- if (bins && bins.length > 0)
628
- return bins;
629
- }
994
+ for (const s of sources.values()) {
995
+ const bins = s.binIntervals?.() ?? null;
996
+ if (bins && bins.length > 0)
997
+ return bins;
630
998
  }
631
999
  return undefined;
632
1000
  }, [cursorSequence, d0, d1, resolvedKind, sources]);
@@ -689,6 +1057,8 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
689
1057
  crosshairSnap,
690
1058
  cursorBuckets,
691
1059
  regionAnchor,
1060
+ previewSpans,
1061
+ setPreviewSpans,
692
1062
  setRegionAnchor,
693
1063
  onRegionSelect,
694
1064
  reportDrawStats,
@@ -696,6 +1066,7 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
696
1066
  draggingKey,
697
1067
  setDragging,
698
1068
  selected: selectedValue,
1069
+ selectedSpans,
699
1070
  select,
700
1071
  hovered: hoveredValue,
701
1072
  setHovered,
@@ -717,6 +1088,13 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
717
1088
  unregisterTrackerSource,
718
1089
  registerSelectable,
719
1090
  unregisterSelectable,
1091
+ registerCursor,
1092
+ unregisterCursor,
1093
+ cursors,
1094
+ registerSelector,
1095
+ unregisterSelector,
1096
+ resolveSweep,
1097
+ hasMultiSelector,
720
1098
  registerAnnotation,
721
1099
  unregisterAnnotation,
722
1100
  registerLegendItem,
@@ -734,6 +1112,13 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
734
1112
  zoomEnabled,
735
1113
  minDuration,
736
1114
  applyRange,
1115
+ zoomX,
1116
+ zoomY,
1117
+ panX,
1118
+ panY,
1119
+ aspectLocked,
1120
+ yTransform,
1121
+ applyYTransform,
737
1122
  registerGutter,
738
1123
  registerRow,
739
1124
  firstRowKey,
@@ -751,6 +1136,8 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
751
1136
  crosshairSnap,
752
1137
  cursorBuckets,
753
1138
  regionAnchor,
1139
+ previewSpans,
1140
+ setPreviewSpans,
754
1141
  setRegionAnchor,
755
1142
  onRegionSelect,
756
1143
  reportDrawStats,
@@ -758,6 +1145,7 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
758
1145
  draggingKey,
759
1146
  setDragging,
760
1147
  selectedValue,
1148
+ selectedSpans,
761
1149
  select,
762
1150
  hoveredValue,
763
1151
  setHovered,
@@ -779,6 +1167,13 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
779
1167
  unregisterTrackerSource,
780
1168
  registerSelectable,
781
1169
  unregisterSelectable,
1170
+ registerCursor,
1171
+ unregisterCursor,
1172
+ cursors,
1173
+ registerSelector,
1174
+ unregisterSelector,
1175
+ resolveSweep,
1176
+ hasMultiSelector,
782
1177
  registerAnnotation,
783
1178
  unregisterAnnotation,
784
1179
  registerLegendItem,
@@ -796,17 +1191,28 @@ export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width
796
1191
  zoomEnabled,
797
1192
  minDuration,
798
1193
  applyRange,
1194
+ zoomX,
1195
+ zoomY,
1196
+ panX,
1197
+ panY,
1198
+ aspectLocked,
1199
+ yTransform,
1200
+ applyYTransform,
799
1201
  registerGutter,
800
1202
  registerRow,
801
1203
  firstRowKey,
802
1204
  ]);
803
- return (_jsx(ContainerContext.Provider, { value: frame, children: _jsx(CursorContext.Provider, { value: cursorFrame, children: _jsxs("div", { style: { width: `${width}px` }, children: [_jsx("div", { style: {
804
- display: 'flex',
805
- flexDirection: 'column',
806
- gap: `${rowGap}px`,
807
- // The positioned ancestor for overlay chrome (`<Legend>`): the
808
- // card anchors to the rows block, never the axis strip below.
809
- position: 'relative',
810
- }, children: children }), showAxis && _jsx(TimeAxis, {})] }) }) }));
1205
+ return (_jsx(ContainerContext.Provider, { value: frame, children: _jsxs(CursorContext.Provider, { value: cursorFrame, children: [_jsx(LegacyCursor, { mode: cursor, showTime: cursorTime, snap: crosshairSnap, sequence: cursorSequenceProp,
1206
+ // The 'line' default nobody asked for is IMPLICIT — the one cursor a
1207
+ // <MultiSelector>'s resting block preview may replace with the
1208
+ // brush band. An explicit `cursor` prop (any mode) still wins.
1209
+ implicit: cursorProp === undefined }), _jsxs("div", { style: { width: `${width}px` }, children: [_jsx("div", { style: {
1210
+ display: 'flex',
1211
+ flexDirection: 'column',
1212
+ gap: `${rowGap}px`,
1213
+ // The positioned ancestor for overlay chrome (`<Legend>`): the
1214
+ // card anchors to the rows block, never the axis strip below.
1215
+ position: 'relative',
1216
+ }, children: children }), showAxis && _jsx(TimeAxis, {})] })] }) }));
811
1217
  }
812
1218
  //# sourceMappingURL=ChartContainer.js.map