@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
package/dist/Layers.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Children, cloneElement, isValidElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
2
+ import { Children, Fragment, isValidElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
3
3
  import { Canvas } from './Canvas.js';
4
4
  import { drawGrid, drawDividers, dividerAlphas, thinPixels } from './grid.js';
5
- import { cursorParts, bandRect, regionSpan } from './tracker.js';
5
+ import { bandRect, regionSpan } from './tracker.js';
6
+ import { effectiveCursorEntries, gestureOwner } from './cursors.js';
7
+ import { renderBrushBand, renderBrushRect, resolveBrushClaim, resolveRangeDrag, warnSweepShadowsRangeDrag, } from './brush.js';
6
8
  import { resolveSelection } from './select.js';
9
+ import { isDev } from './dev.js';
10
+ import { useIndexedChildren } from './child-index.js';
7
11
  import { panRange, zoomRange, panRangeTrading, zoomRangeTrading, } from './viewport.js';
8
12
  import { yTickValues } from './yticks.js';
9
- import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
10
13
  import { ContainerContext, CursorContext, LayersContext, RowContext, } from './context.js';
11
14
  /** Fallback **y**-gridline tick count, used only before the row publishes its
12
15
  * resolved `tickCounts` (pre-registration). Normally the gridlines read the
@@ -46,9 +49,125 @@ const ZOOM_SENSITIVITY = 0.0015;
46
49
  * it still selects. One threshold for both so a click never also nudges the pan
47
50
  * (and never hit-tests against a shifted scale). */
48
51
  const DRAG_SLOP = 4;
49
- /** Past this fraction of the plot, a readout label flips left of its dot so it
50
- * doesn't overflow the right edge. */
51
- const LABEL_FLIP_FRACTION = 0.85;
52
+ /** Stable "no sweep in flight" identity for the span preview channel. */
53
+ const EMPTY_SPANS = [];
54
+ /**
55
+ * The topmost sweep-capable layer's fresh {@link SweepSession} (RFC §8's
56
+ * z-order rule — the same rule a click follows), or `null` when the row has
57
+ * none. **The one resolution** behind both the pointer-down sweep claim and
58
+ * the resting block preview, so what hover previews and what a drag captures
59
+ * cannot come from different layers.
60
+ */
61
+ function beginTopmostSweep(c, r) {
62
+ for (let i = r.layers.length - 1; i >= 0; i -= 1) {
63
+ const entry = r.layers[i];
64
+ const ys = r.yScales.get(entry.axisId ?? r.defaultAxisId);
65
+ if (ys === undefined)
66
+ continue;
67
+ const s = entry.layer.beginSweep?.((v) => c.xScale(v), (v) => ys(v)) ?? null;
68
+ // The scale rides along because a `twoD` session's `update` takes its y
69
+ // window in the LAYER'S axis units, and only this loop knows which of the
70
+ // row's axes that is. Handing back the session alone would leave the
71
+ // gesture inverting pointer pixels through the default axis, which is a
72
+ // silent mis-cut exactly on a dual-axis row. The declared `sweepAxis`
73
+ // rides along for the same reason and from the same layer, so the gesture
74
+ // never has to re-derive which layer won the z-order race.
75
+ if (s !== null)
76
+ return { session: s, yScale: ys, axis: entry.layer.sweepAxis ?? 'x' };
77
+ }
78
+ return null;
79
+ }
80
+ /**
81
+ * Every **span-only** layer's session in the row ([PND-TRACESEL]) — a trace has
82
+ * a range but no marks, and every trace shares the same x window, so they are
83
+ * swept together rather than by z-order.
84
+ *
85
+ * Mark layers are untouched by this: topmost-wins still decides which of them
86
+ * claims a drag, because there you were pointing at marks and the topmost is
87
+ * the one you meant. A trace sweep points at nothing, so singling one out
88
+ * would be arbitrary to the reader.
89
+ *
90
+ * Built once per gesture, at the press, for the reason every session is: they
91
+ * snapshot the layer's arrays and nothing persists outside the drag.
92
+ */
93
+ function beginSpanOnlySweeps(c, r) {
94
+ // **Topmost first**, matching `beginTopmostSweep`'s descending scan, so
95
+ // `spans[0]` is the same layer `span` reports (reviewer finding: ascending
96
+ // order made the documented `span === spans[0]` false on a two-trace row).
97
+ const out = [];
98
+ for (let i = r.layers.length - 1; i >= 0; i -= 1) {
99
+ const entry = r.layers[i];
100
+ const ys = r.yScales.get(entry.axisId ?? r.defaultAxisId);
101
+ if (ys === undefined)
102
+ continue;
103
+ const s = entry.layer.beginSweep?.((v) => c.xScale(v), (v) => ys(v)) ?? null;
104
+ if (s !== null && s.spanOnly === true)
105
+ out.push(s);
106
+ }
107
+ return out;
108
+ }
109
+ /**
110
+ * Does the topmost sweep-capable layer cut a **rect**? The render-time,
111
+ * session-free counterpart of {@link beginTopmostSweep} — same z-order rule,
112
+ * reading each layer's `sweepsRect` declaration, so the resting cursor and
113
+ * the gesture cannot disagree about which shape this row is.
114
+ */
115
+ function topmostSweepsRect(layers) {
116
+ for (let i = layers.length - 1; i >= 0; i -= 1) {
117
+ const l = layers[i].layer;
118
+ if (l.beginSweep !== undefined)
119
+ return l.sweepsRect === true;
120
+ }
121
+ return false;
122
+ }
123
+ /**
124
+ * Does the topmost sweep-capable layer have a range but **no marks**? The
125
+ * render-time counterpart of {@link SweepSession.spanOnly}, read where
126
+ * {@link topmostSweepsRect} is and for the same reason: the resting state needs
127
+ * the fact and there is no session at rest.
128
+ */
129
+ function topmostSweepSpanOnly(layers) {
130
+ for (let i = layers.length - 1; i >= 0; i -= 1) {
131
+ const l = layers[i];
132
+ if (l.layer.beginSweep !== undefined)
133
+ return l.layer.sweepSpanOnly === true;
134
+ }
135
+ return false;
136
+ }
137
+ /**
138
+ * Which screen axis the topmost sweep-capable layer cuts — the `sweepAxis`
139
+ * counterpart of {@link topmostSweepsRect}, same z-order rule and the same
140
+ * reason for existing: the resting state has to answer it with no session in
141
+ * hand. `'x'` for a row with nothing sweepable, which is what every caller
142
+ * wants for "not a transposed row".
143
+ */
144
+ function topmostSweepAxis(layers) {
145
+ for (let i = layers.length - 1; i >= 0; i -= 1) {
146
+ const l = layers[i].layer;
147
+ if (l.beginSweep !== undefined)
148
+ return l.sweepAxis ?? 'x';
149
+ }
150
+ return 'x';
151
+ }
152
+ /** Same marks, by full identity — so a recomputed resting block can keep the
153
+ * CACHED array's reference when nothing actually changed. The layer registry
154
+ * re-identifies on every hover commit (the entries close over the hovered
155
+ * set), so an identity-keyed cache alone would re-mint the block each move
156
+ * and defeat the container's identity-based block dedup. O(block). */
157
+ function sameHits(a, b) {
158
+ if (a.length !== b.length)
159
+ return false;
160
+ for (let i = 0; i < a.length; i += 1) {
161
+ const x = a[i];
162
+ const y = b[i];
163
+ if (x.id !== y.id ||
164
+ x.key !== y.key ||
165
+ x.label !== y.label ||
166
+ x.mark !== y.mark)
167
+ return false;
168
+ }
169
+ return true;
170
+ }
52
171
  /**
53
172
  * The plot area of a {@link ChartRow}: a single `<canvas>` plus the draw-layer
54
173
  * registry. It is the boundary where the row's horizontal layout flips to
@@ -63,7 +182,11 @@ const LABEL_FLIP_FRACTION = 0.85;
63
182
  * others slots into place, not onto the top), and each layer keeps a stable,
64
183
  * id-keyed slot so a series/style update holds its position (no jump to the
65
184
  * front — the trap that bites live charts). Draw layers must be **direct
66
- * children** of `<Layers>` for the index to reach them.
185
+ * children** of `<Layers>` for the index to reach them — to group them
186
+ * conditionally, return a **keyed array**, not a `<>…</>`: a fragment takes no
187
+ * props, so the index stops there and the layers inside it all register at 0
188
+ * (dev warns about this, because a stable sort makes the resulting tie look
189
+ * correct until mount order and declaration order disagree).
67
190
  */
68
191
  export function Layers({ children }) {
69
192
  const container = useContext(ContainerContext);
@@ -288,19 +411,79 @@ export function Layers({ children }) {
288
411
  // pointer is over — syncs the cursor across every row for free. cursorX is a
289
412
  // *pixel*, so it stays put while a live window slides; the time + values under
290
413
  // it derive from the current xScale.
291
- const { cursorTime: showCursorTime, formatTime } = container;
414
+ const { formatTime } = container;
292
415
  const { cursorX } = cursor;
293
- // Cursor mode: the row's override, else the container default. One mode per
294
- // row (the synced vertical line is shared across rows); each layer renders the
295
- // mode in its own way. `parts` decomposes it into {line, dots, chip}.
416
+ // The cursors in effect for THIS row: its own mounts (the per-row override —
417
+ // a component mounted in the row, or the `<ChartRow cursor>` shim), else the
418
+ // container's. Each registered a spec whose slots this overlay renders; what
419
+ // the container must resolve per move is the union of their declared needs.
296
420
  // Editing suppresses the data cursor — the marks get the surface (hover/drag),
297
421
  // and a crosshair would just be noise. True in global edit mode *and* while a
298
422
  // single annotation is being edited (the double-click target).
299
423
  const editingActive = container.editAnnotations || container.annotations.some((a) => a.editing);
300
- const parts = editingActive
301
- ? cursorParts('none')
302
- : cursorParts(row.cursor ?? container.cursor);
303
- const cursorColor = container.theme.cursor ?? container.theme.axis.label;
424
+ // Paint-only mirror of "a <MultiSelector> sweep is live in THIS row"
425
+ // (declared here, ahead of the gesture machinery below, because the cursor
426
+ // resolution needs it). A live sweep suppresses the row's cursor slots the
427
+ // same way editing does: the gesture owns the surface, and the default
428
+ // line preset otherwise keeps painting its solid vertical rule at the raw
429
+ // pointer OVER the brush band — which reads as "a line", not as the region
430
+ // being swept (the §8.1 identical-pixels promise, broken by an overlay).
431
+ // The shared band still renders while suppressed — `sweeping && !wantsBand`
432
+ // below — from the same resolved frame, so the sweep looks exactly like a
433
+ // <RangeCursor> drag.
434
+ const [sweeping, setSweeping] = useState(false);
435
+ // The **resting block preview**: a `<MultiSelector>` in scope over a
436
+ // sweep-capable row changes the RESTING state, not just the drag — the grey
437
+ // band and the block-scoped hover are a preview of exactly what a drag
438
+ // begun here would select. Two halves, resolved here:
439
+ //
440
+ // - `blockPreview` — the fact ("this row previews blocks"). It also scopes
441
+ // the resting hover to the snap block (handlePointerMove).
442
+ // - `restingBand` — the brush band is this row's resting CURSOR, replacing
443
+ // the shim's un-asked-for `'line'` default. Any *explicitly chosen*
444
+ // cursor still wins: a mounted component, or a legacy `cursor` string the
445
+ // consumer actually set — both register non-`implicit` entries and keep
446
+ // their own slots (a mounted `<RangeCursor>` already draws this same
447
+ // band; a `<CrosshairCursor>` keeps its crosshair).
448
+ //
449
+ // Which of the two the row gets is the TOPMOST sweep-capable layer's
450
+ // business (§8's z-order rule again, and the same rule `beginTopmostSweep`
451
+ // follows) — but answered from the layer's `sweepsRect` declaration rather
452
+ // than by building a session, because at rest there is no drag to build one
453
+ // for.
454
+ const sweeps = container.hasMultiSelector(row.rowKey);
455
+ const rectPreview = sweeps && topmostSweepsRect(layers);
456
+ const sweepAxis = topmostSweepAxis(layers);
457
+ // A span-only row gets no resting block band either, and for the rect's
458
+ // reason: a trace has no blocks, so the band would preview a set the drag
459
+ // never takes. It would also collide with the committed span's own edge
460
+ // rules, putting two vertical marks a pixel apart on each boundary.
461
+ const blockPreview = sweeps &&
462
+ !rectPreview &&
463
+ !topmostSweepSpanOnly(layers) &&
464
+ layers.some((e) => e.layer.beginSweep !== undefined);
465
+ // The resting brush replaces the implicit cursor either way; only its SHAPE
466
+ // differs. A 2-D row gets no band: its snap block is a whole x column while
467
+ // a drag there captures a rect, so a band would advertise a set the gesture
468
+ // never selects (the same reason the block hover opts out).
469
+ const restingBrush = (blockPreview || rectPreview) &&
470
+ !editingActive &&
471
+ effectiveCursorEntries(container.cursors, row.rowKey).every((e) => e.implicit === true);
472
+ // …and a **transposed** row gets none yet: the resting block preview is
473
+ // resolved from the shared x buckets, so a y-cutting row would draw a band
474
+ // over a column its drag can never select. Suppressed rather than
475
+ // transposed — [PND-HSWEEP] carries the follow-up, and a missing preview is
476
+ // honest where a wrong one is not.
477
+ const restingBand = restingBrush && !rectPreview && sweepAxis === 'x';
478
+ const restingCross = restingBrush && rectPreview;
479
+ const cursorEntries = useMemo(() => editingActive || sweeping || restingBrush
480
+ ? []
481
+ : effectiveCursorEntries(container.cursors, row.rowKey), [editingActive, sweeping, restingBrush, container.cursors, row.rowKey]);
482
+ const wantsSamples = cursorEntries.some((e) => e.wants.samples);
483
+ const wantsFlags = cursorEntries.some((e) => e.wants.flags);
484
+ const wantsBand = cursorEntries.some((e) => e.wants.band);
485
+ const wantsPointer = cursorEntries.some((e) => e.wants.pointer);
486
+ const wantsTime = cursorEntries.some((e) => e.wants.time);
304
487
  // Only read a time when the cursor is within the plot. An out-of-bounds
305
488
  // controlled trackerPosition hides the cursor, so the dots + chips hide too —
306
489
  // gating cursorTime makes trackerSamples empty, which drives both the SVG marks
@@ -308,14 +491,15 @@ export function Layers({ children }) {
308
491
  const cursorTime = cursorX !== null && cursorX >= 0 && cursorX <= plotWidth
309
492
  ? +xScale.invert(cursorX)
310
493
  : null;
311
- // Per-layer readout samples at the cursor time (nearest data point) — pixel
312
- // position + value + colour. Drives the overlay dots and the DOM value labels;
313
- // recomputes as the cursor moves or the window slides under it. Empty when not
314
- // hovering, so the data canvas is never touched.
494
+ // Per-layer readout samples at the cursor time (nearest data point) —
495
+ // **finished measurements** (RFC A2.3): pixel position, axis id + side, and
496
+ // the value already formatted by that axis's formatter. Drives the cursor
497
+ // slots' dots and value labels; recomputes as the cursor moves or the window
498
+ // slides under it. Empty when not hovering — or when no effective cursor
499
+ // declared a need — so the data canvas is never touched and a line-only
500
+ // cursor never pays the per-layer walk.
315
501
  const trackerSamples = useMemo(() => {
316
- // Only needed for the in-chart dots / chips; skip the per-layer walk when the
317
- // mode shows neither (the off-chart readout fans in separately on the container).
318
- if (cursorTime === null || (!parts.dots && parts.chip === 'none'))
502
+ if (cursorTime === null || !wantsSamples)
319
503
  return [];
320
504
  const out = [];
321
505
  for (const entry of layers) {
@@ -337,31 +521,30 @@ export function Layers({ children }) {
337
521
  out.push({
338
522
  px: xScale(s.x),
339
523
  py: yScale(s.value),
340
- value: s.value,
341
- color: s.color,
342
- format: fmt,
524
+ axisId,
343
525
  side,
526
+ formatted: fmt(s.value),
527
+ color: s.color,
528
+ label: s.label,
344
529
  });
345
530
  }
346
531
  }
347
532
  return out;
348
533
  }, [
349
534
  cursorTime,
535
+ wantsSamples,
350
536
  layers,
351
537
  yScales,
352
538
  formats,
353
539
  axisSides,
354
540
  xScale,
355
541
  defaultAxisId,
356
- parts.dots,
357
- parts.chip,
358
542
  ]);
359
- // Consolidated multi-value flags (BoxPlot) — one flag per such layer, only in
360
- // `flag` mode: all the box's values on one chip, anchored at its top-centre
361
- // (`px`, `topPy`). Rendered as one staff + one multi-line chip (vs the
362
- // per-sample dots/chips above), the values each coloured to their box piece.
543
+ // Consolidated multi-value flags (BoxPlot) — one flag per such layer, wanted
544
+ // by the flag cursor only: all the box's values on one chip, anchored at its
545
+ // top-centre (`px`, `topPy`), each line formatted + coloured to its piece.
363
546
  const trackerFlags = useMemo(() => {
364
- if (cursorTime === null || parts.chip !== 'flag')
547
+ if (cursorTime === null || !wantsFlags)
365
548
  return [];
366
549
  const out = [];
367
550
  for (const entry of layers) {
@@ -385,7 +568,7 @@ export function Layers({ children }) {
385
568
  });
386
569
  }
387
570
  return out;
388
- }, [cursorTime, layers, yScales, formats, xScale, defaultAxisId, parts.chip]);
571
+ }, [cursorTime, wantsFlags, layers, yScales, formats, xScale, defaultAxisId]);
389
572
  // Pan/zoom + tracker share the plot's event surface. Container fields are read
390
573
  // through a ref so the handlers + the (once-attached) wheel listener always see
391
574
  // the latest frame without re-subscribing. Written after commit (not in render)
@@ -396,6 +579,20 @@ export function Layers({ children }) {
396
579
  containerRef.current = container;
397
580
  });
398
581
  const plotRef = useRef(null);
582
+ /**
583
+ * Clamp a 2-D pan offset so the zoomed content still covers the plot.
584
+ *
585
+ * The y counterpart of `bounds` on x. With `k ≥ 1` the transformed band
586
+ * `[k·0 + ty, k·height + ty]` is at least as tall as the plot, so it can cover
587
+ * it — but nothing stopped `ty` sliding until the data left the viewport, which
588
+ * showed up as an axis reading past the end of the record with blank canvas
589
+ * under it. Requiring both edges to stay outside the plot pins it.
590
+ */
591
+ function clampPanY(k, ty, height) {
592
+ const lo = height * (1 - k); // bottom edge at or below the plot bottom
593
+ const hi = 0; // top edge at or above the plot top
594
+ return Math.min(hi, Math.max(lo, ty));
595
+ }
399
596
  const dragRef = useRef(null);
400
597
  // Row read through a ref so the click handler hit-tests the latest layers +
401
598
  // y-scales without re-subscribing (same after-commit discipline as containerRef).
@@ -411,20 +608,266 @@ export function Layers({ children }) {
411
608
  const [createPt, setCreatePt] = useState(null);
412
609
  const [drawFrom, setDrawFrom] = useState(null);
413
610
  const drawFromRef = useRef(null);
414
- // The region-select drag anchor, mirrored for the gesture handlers (the same
415
- // ref+state discipline as `drawFromRef`): the container's `regionAnchor`
416
- // STATE is only how the rows paint the band; the gesture logic must never
417
- // read it back, because a batched pointer stream (automation, jsdom, a very
418
- // fast flick under load) delivers down→up before the down's setState commits
419
- // the up would see `regionAnchor === null`, silently drop the select, and
420
- // the late-committing anchor would then stick (#508 item 7). Trusted
421
- // human-paced input hides this (React flushes trusted discrete events
422
- // synchronously); the ref is correct under both.
423
- const regionAnchorRef = useRef(null);
611
+ // The live range-drag session the anchor (axis units) plus the release
612
+ // sink the brush claim resolved at press — mirrored for the gesture
613
+ // handlers (the same ref+state discipline as `drawFromRef`): the
614
+ // container's `regionAnchor` STATE is only how the rows paint the band; the
615
+ // gesture logic must never read it back, because a batched pointer stream
616
+ // (automation, jsdom, a very fast flick under load) delivers down→up before
617
+ // the down's setState commits the up would see `regionAnchor === null`,
618
+ // silently drop the select, and the late-committing anchor would then stick
619
+ // (#508 item 7). Trusted human-paced input hides this (React flushes
620
+ // trusted discrete events synchronously); the ref is correct under both.
621
+ // The release sink rides the ref too, so what fires is what the press
622
+ // resolved — a `<RangeCursor onDragRelease>` or the legacy `onRegionSelect`.
623
+ const rangeDragRef = useRef(null);
624
+ // The live sweep session (a mounted <MultiSelector> — RFC §8 / A7.7): the
625
+ // anchor + the topmost capable layer's per-drag session + the container's
626
+ // preview/commit sinks, resolved at press. Same ref-not-state discipline as
627
+ // `rangeDragRef` (the gesture must never read a state mirror that may not
628
+ // have committed — #508 item 7). `committed` arms past DRAG_SLOP, mirroring
629
+ // pan's deferred capture, so a click stays a click and selects one mark
630
+ // (§8.1: movement separates the two, not a modifier). The live preview is
631
+ // **coalesced to animation frames** (RFC A1.4): pointermove only records
632
+ // `pendingT` and schedules `raf`; the frame re-cuts the session and, only
633
+ // when the covered set changed, lights the hits through plural `hovered`.
634
+ //
635
+ // A `twoD` session (a scatter, a heat map) tracks the pointer's **y**
636
+ // alongside its x — `anchorPy` / `pendingPy` in plot pixels, inverted
637
+ // through the sweeping layer's own axis at cut time. Pixels rather than
638
+ // axis units because the pixel is what both consumers want: the session
639
+ // wants it inverted, the rect wants it drawn, and carrying axis units would
640
+ // mean mapping back through the scale to paint.
641
+ const sweepRef = useRef(null);
642
+ // The live 2-D brush rect, in plot pixels — row-local because a rect's y
643
+ // only means anything against the axis it was measured on (see
644
+ // `ResolvedCursorFrame.rect`). The 1-D band stays container state.
645
+ const [sweepRect, setSweepRect] = useState(null);
646
+ // The live **transposed** band, in this row's plot pixels — a `sweepAxis:
647
+ // 'y'` cut (a horizontal bar). Row-local for the rect's reason: a y interval
648
+ // only means anything against the axis that measured it, so the container's
649
+ // shared `regionAnchor` (how every row draws the x band) is the wrong home.
650
+ const [sweepBandY, setSweepBandY] = useState(null);
651
+ // (`sweeping` — the paint-only mirror of "a sweep is live" — is declared up
652
+ // with the cursor resolution, which it suppresses. It also renders the
653
+ // shared brush band from this row: §8.1 — one renderer either way, so the
654
+ // two visuals cannot drift.)
655
+ // The resting block preview's per-block cache: the materialised hits of the
656
+ // snap block the pointer is in, keyed on the block extent AND the row's
657
+ // layer registry identity (a data / selection change re-registers layers,
658
+ // which must invalidate the cached marks). One small session per block
659
+ // TRANSITION, nothing per move — and the stable `hits` array is what makes
660
+ // the container's identity-based block dedup work.
661
+ const restingBlockRef = useRef(null);
662
+ // A1.5's arbitration, surfaced: warn once when a press found both claimants.
663
+ const warnedSweepShadowRef = useRef(false);
664
+ /**
665
+ * One re-cut of the live sweep at the recorded pointer: the x window
666
+ * bucket-snapped through the shared `cursorBuckets` (freeform without),
667
+ * and — for a `twoD` layer — the y window inverted through the sweeping
668
+ * layer's own axis, plus the brush rect that goes with it. Returns whether
669
+ * the covered set changed (the session's delta gate).
670
+ *
671
+ * Shared by the frame flush and the release, so the final cut is the same
672
+ * cut, and the release cannot capture a different set than the preview the
673
+ * user let go of.
674
+ */
675
+ const cutSweep = useCallback((sw, c) => {
676
+ if (sw.pendingT === null)
677
+ return false;
678
+ // ── The transposed cut (`sweepAxis: 'y'`) — a horizontal bar. ──
679
+ // The window comes from the pointer's y through the sweeping layer's own
680
+ // axis, and NOT through `cursorBuckets`: the shared bin channel carries
681
+ // the *value* axis here (`binIntervals` is published vertical-only), so
682
+ // snapping the window against it would snap bins to value edges.
683
+ //
684
+ // The band is then read back off `session.extent()` — the
685
+ // snapped-outward run the session already computed — rather than agreed
686
+ // with separately. Same move `snappedRect()` made for the rect, and the
687
+ // stronger version of it: the band is *derived* from the cut, so it
688
+ // cannot promise a different set than release delivers. With nothing
689
+ // covered there is no extent, and the raw drag is what to show.
690
+ // The **live span preview** for span-only layers ([PND-TRACESEL]): a
691
+ // trace has no marks, so plural `hovered` carries nothing for it and the
692
+ // thing that wants lighting is the portion inside the window. Published
693
+ // every cut, from the same sessions the release will read, so the
694
+ // preview cannot promise a different picture than the commit delivers.
695
+ const publishPreview = () => {
696
+ // **Bail out when nothing needs it.** Reviewer finding: this ran every
697
+ // raf frame of every sweep and minted a fresh array even with zero
698
+ // span-only layers, and `previewSpans` sits in the container frame
699
+ // memos — so a plain MARK-layer sweep re-rendered per frame where the
700
+ // session's `changed` gate used to suppress it. That is an A8.1-class
701
+ // regression on a path this PR was not supposed to touch.
702
+ if (sw.spanOnly.length === 0)
703
+ return;
704
+ const spans = [];
705
+ for (const s of sw.spanOnly) {
706
+ const ext = s.extent();
707
+ if (ext !== null)
708
+ spans.push({ kind: 'span', id: s.id, x: ext });
709
+ }
710
+ c.setPreviewSpans(spans);
711
+ };
712
+ if (sw.axis === 'y') {
713
+ const a = +sw.yScale.invert(sw.anchorPy);
714
+ const b = +sw.yScale.invert(sw.pendingPy);
715
+ const changed = sw.session.update(Math.min(a, b), Math.max(a, b));
716
+ for (const s of sw.spanOnly)
717
+ s.update(Math.min(a, b), Math.max(a, b));
718
+ publishPreview();
719
+ const ext = sw.session.extent();
720
+ setSweepBandY(ext === null
721
+ ? { y0: sw.anchorPy, y1: sw.pendingPy }
722
+ : { y0: sw.yScale(ext[0]), y1: sw.yScale(ext[1]) });
723
+ return changed;
724
+ }
725
+ const span = regionSpan(c.cursorBuckets ?? [], sw.anchor, sw.pendingT);
726
+ if (span === null)
727
+ return false;
728
+ if (sw.session.twoD !== true) {
729
+ const changed = sw.session.update(span.start, span.end);
730
+ // Every span-only layer takes the same window. These are sessions of
731
+ // their own — including one for the claimant when it is a trace — so
732
+ // there is nothing to skip.
733
+ for (const s of sw.spanOnly)
734
+ s.update(span.start, span.end);
735
+ publishPreview();
736
+ return changed;
737
+ }
738
+ const changed = sw.session.update(span.start, span.end, +sw.yScale.invert(sw.anchorPy), +sw.yScale.invert(sw.pendingPy));
739
+ // The rect tracks the pointer every frame, gate or no gate: the covered
740
+ // set is unchanged for a move within one heat-map cell, and a brush that
741
+ // only redrew when the set changed would visibly stick between cells.
742
+ //
743
+ // **A snapping layer draws the rect it is going to take**, not the one
744
+ // the pointer traced — otherwise the preview promises a different set
745
+ // than the release delivers. The corners keep the drag's own diagonal
746
+ // either way, so the two crosshairs stay on the ends the user is
747
+ // holding rather than jumping to a fixed pair of corners.
748
+ const snapped = sw.session.snappedRect?.() ?? null;
749
+ const clampX = (v) => Math.max(0, Math.min(c.plotWidth, v));
750
+ const forwardX = sw.anchor <= sw.pendingT;
751
+ const forwardY = sw.anchorPy <= sw.pendingPy;
752
+ if (snapped !== null) {
753
+ const xa = clampX(c.xScale(snapped.x[0]));
754
+ const xb = clampX(c.xScale(snapped.x[1]));
755
+ const ya = sw.yScale(snapped.y[0]);
756
+ const yb = sw.yScale(snapped.y[1]);
757
+ const yLo = Math.min(ya, yb);
758
+ const yHi = Math.max(ya, yb);
759
+ setSweepRect({
760
+ x0: forwardX ? Math.min(xa, xb) : Math.max(xa, xb),
761
+ x1: forwardX ? Math.max(xa, xb) : Math.min(xa, xb),
762
+ y0: forwardY ? yLo : yHi,
763
+ y1: forwardY ? yHi : yLo,
764
+ });
765
+ return changed;
766
+ }
767
+ const xa = clampX(c.xScale(span.start));
768
+ const xb = clampX(c.xScale(span.end));
769
+ setSweepRect({
770
+ x0: forwardX ? xa : xb,
771
+ x1: forwardX ? xb : xa,
772
+ y0: sw.anchorPy,
773
+ y1: sw.pendingPy,
774
+ });
775
+ return changed;
776
+ }, []);
777
+ /** Re-cut the sweep to the latest pointer and light the changed preview. */
778
+ const flushSweep = useCallback(() => {
779
+ const sw = sweepRef.current;
780
+ if (sw === null || !sw.committed)
781
+ return;
782
+ if (cutSweep(sw, containerRef.current))
783
+ // A brush that is drawing the SNAPPED region already outlines exactly
784
+ // what will be taken, so lighting each covered mark on top of it is
785
+ // redundant — and on a heat map actively worse (see `preview`). Tested
786
+ // on the answer, not on whether the method exists: `sweep2D` always
787
+ // defines it and returns `null` for a layer that does not snap.
788
+ sw.gesture.preview(sw.session.hits(), (sw.session.snappedRect?.() ?? null) === null);
789
+ }, [cutSweep]);
790
+ const scheduleSweepFrame = useCallback(() => {
791
+ const sw = sweepRef.current;
792
+ if (sw === null || sw.raf !== 0)
793
+ return;
794
+ sw.raf = requestAnimationFrame(() => {
795
+ sw.raf = 0;
796
+ flushSweep();
797
+ });
798
+ }, [flushSweep]);
799
+ /** Drop a live sweep without committing (leave / lost buttons / unmount). */
800
+ const cancelSweep = useCallback(() => {
801
+ const sw = sweepRef.current;
802
+ if (sw === null)
803
+ return;
804
+ sweepRef.current = null;
805
+ if (sw.raf !== 0)
806
+ cancelAnimationFrame(sw.raf);
807
+ if (sw.committed) {
808
+ setSweeping(false);
809
+ setSweepRect(null);
810
+ setSweepBandY(null);
811
+ containerRef.current.setRegionAnchor(null);
812
+ containerRef.current.setPreviewSpans(EMPTY_SPANS);
813
+ sw.gesture.preview([]); // un-light the preview; nothing commits
814
+ }
815
+ }, []);
816
+ // A sweep interrupted by unmount must not leave the preview lit.
817
+ useEffect(() => cancelSweep, [cancelSweep]);
424
818
  const handlePointerDown = useCallback((e) => {
425
819
  clickStartRef.current = { x: e.clientX, y: e.clientY };
426
820
  const c = containerRef.current;
427
- if (c.creating !== null) {
821
+ const r = rowRef.current;
822
+ // The sweep's two resolved facts (RFC §8): a <MultiSelector> in scope
823
+ // (the container arbitrates, registry stays private), and a
824
+ // sweep-capable layer in THIS row — topmost wins, the z-order rule a
825
+ // click already follows. Both must hold for the sweep to claim; a
826
+ // selector over a row of untagged/lines-only layers deliberately claims
827
+ // nothing (Q8's identity-gates-interactivity, range form).
828
+ const sweepGesture = c.resolveSweep(r.rowKey);
829
+ const swept = sweepGesture !== null ? beginTopmostSweep(c, r) : null;
830
+ const drag = resolveRangeDrag(c, gestureOwner(effectiveCursorEntries(c.cursors, r.rowKey)));
831
+ // ONE brush recognizer arbitrates every drag claim — annotation-create,
832
+ // the sweep, the range drag (component or legacy), pan — in a
833
+ // documented order (RFC A1.5 / A2.7; see brush.tsx). This handler only
834
+ // routes.
835
+ const claim = resolveBrushClaim({
836
+ creating: c.creating !== null,
837
+ sweep: swept !== null,
838
+ drag,
839
+ shiftKey: e.shiftKey,
840
+ panEnabled: c.panEnabled,
841
+ // As with the wheel: a category x has nothing to pan, but a y-panning
842
+ // mode still has work to do.
843
+ canPan: (c.panX && c.xKind !== 'category') || c.panY,
844
+ });
845
+ // Sweep (a mounted <MultiSelector> over a sweepable row): record the
846
+ // press, but arm NOTHING yet — no capture, no anchor, no preview. The
847
+ // gesture commits on the first move past DRAG_SLOP (handlePointerMove);
848
+ // a press that stays put remains a click and selects one mark (§8.1).
849
+ if (claim.kind === 'sweep') {
850
+ if (isDev && drag !== null)
851
+ warnSweepShadowsRangeDrag(warnedSweepShadowRef);
852
+ const box = e.currentTarget.getBoundingClientRect();
853
+ const px = Math.max(0, Math.min(c.plotWidth, e.clientX - box.left));
854
+ const py = Math.max(0, Math.min(r.height, e.clientY - box.top));
855
+ sweepRef.current = {
856
+ anchor: +c.xScale.invert(px),
857
+ anchorPy: py,
858
+ session: swept.session,
859
+ yScale: swept.yScale,
860
+ axis: swept.axis,
861
+ spanOnly: beginSpanOnlySweeps(c, r),
862
+ gesture: sweepGesture,
863
+ committed: false,
864
+ raf: 0,
865
+ pendingT: null,
866
+ pendingPy: py,
867
+ };
868
+ return;
869
+ }
870
+ if (claim.kind === 'create') {
428
871
  // Armed: a region presses to fix its start edge; a line just tracks until
429
872
  // release. Capture so the draw can continue outside the plot.
430
873
  if (c.creating === 'region') {
@@ -440,36 +883,29 @@ export function Layers({ children }) {
440
883
  }
441
884
  return;
442
885
  }
443
- // Region-cursor drag-select (opt-in via `onRegionSelect`): anchor the
444
- // selection at the press; the band then extends as the pointer moves (bucket
445
- // by bucket with a sequence, freeform without), and release commits the span.
446
- // Works on a continuous x axis time **or** value (a category axis is
447
- // excluded; its ordinal-slot select is a different gesture). A
448
- // `regionSelectModifier` (only while `panZoom` is on) gates it behind the key
449
- // so plain drag can still pan; otherwise it preempts pan (returns before the
450
- // pan is armed below).
451
- if (c.cursor === 'region' &&
452
- c.onRegionSelect &&
453
- (c.xKind === 'time' || c.xKind === 'value')) {
454
- const needsShift = c.regionSelectModifier === 'shift' && c.panEnabled;
455
- if (!needsShift || e.shiftKey) {
456
- const px = Math.max(0, Math.min(c.plotWidth, e.clientX - e.currentTarget.getBoundingClientRect().left));
457
- regionAnchorRef.current = +c.xScale.invert(px);
458
- c.setRegionAnchor(regionAnchorRef.current); // paint-only mirror
459
- c.setHoverX(px);
460
- try {
461
- e.currentTarget.setPointerCapture(e.pointerId);
462
- }
463
- catch {
464
- /* ignore */
465
- }
466
- return;
886
+ // Range drag (a drag-enabled <RangeCursor>, or the legacy
887
+ // `cursor="region"` + `onRegionSelect`): anchor the selection at the
888
+ // press; the band then extends as the pointer moves (bucket by bucket
889
+ // with a sequence, freeform without), and release commits the span to
890
+ // whichever sink the claim resolved. Continuous x only, and gated
891
+ // behind the drag modifier while pan is on all decided in the claim.
892
+ if (claim.kind === 'range') {
893
+ const px = Math.max(0, Math.min(c.plotWidth, e.clientX - e.currentTarget.getBoundingClientRect().left));
894
+ const anchor = +c.xScale.invert(px);
895
+ rangeDragRef.current = { anchor, release: claim.drag.release };
896
+ c.setRegionAnchor(anchor); // paint-only mirror
897
+ c.setHoverX(px);
898
+ try {
899
+ e.currentTarget.setPointerCapture(e.pointerId);
900
+ }
901
+ catch {
902
+ /* ignore */
467
903
  }
468
- // Modifier required but not held → fall through to pan.
904
+ return;
469
905
  }
470
- if (!c.panEnabled || c.xKind === 'category')
906
+ if (claim.kind === 'none')
471
907
  return;
472
- const r = c.timeRange;
908
+ const tr = c.timeRange;
473
909
  // Arm a potential pan: record the anchor, but DON'T capture the pointer or
474
910
  // hide the tracker yet. Capturing on press retargets the eventual `click`
475
911
  // to the plot (Pointer Events spec: a captured pointer's compatibility
@@ -484,7 +920,9 @@ export function Layers({ children }) {
484
920
  // captures then.
485
921
  dragRef.current = {
486
922
  startX: e.clientX,
487
- startRange: [r[0], r[1]],
923
+ startY: e.clientY,
924
+ startRange: [tr[0], tr[1]],
925
+ startTy: c.yTransform.ty,
488
926
  captured: false,
489
927
  };
490
928
  }, []);
@@ -497,14 +935,88 @@ export function Layers({ children }) {
497
935
  c.setHoverX(px); // share the preview x so other rows draw a guide there
498
936
  return;
499
937
  }
500
- // Region drag in progress: just track the pointer x (the band spans from the
938
+ // Range drag in progress: just track the pointer x (the band spans from the
501
939
  // anchor bucket to here); no pan, no hover hit-test. Gesture truth is the
502
- // ref — the state mirror may not have committed yet (see regionAnchorRef).
503
- if (regionAnchorRef.current !== null) {
940
+ // ref — the state mirror may not have committed yet (see rangeDragRef).
941
+ if (rangeDragRef.current !== null) {
504
942
  const rect = e.currentTarget.getBoundingClientRect();
505
943
  c.setHoverX(Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left)));
506
944
  return;
507
945
  }
946
+ // A pressed <MultiSelector> sweep. Below the slop it is still a
947
+ // potential click (same deferral as pan — see the dragRef branch); past
948
+ // it the sweep commits: capture, anchor the band, and from then on each
949
+ // move records the pointer and schedules one animation frame — the
950
+ // session re-cut + preview run there, not per event (RFC A1.4).
951
+ const sw = sweepRef.current;
952
+ if (sw !== null) {
953
+ // Lost buttons ⇒ the press ended off-plot without a pointerup here
954
+ // (the sub-slop path never captured) — drop it, as dragRef does.
955
+ if (e.buttons === 0) {
956
+ cancelSweep();
957
+ }
958
+ else {
959
+ const rect = e.currentTarget.getBoundingClientRect();
960
+ const px = Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left));
961
+ const py = Math.max(0, Math.min(rowRef.current.height, e.clientY - rect.top));
962
+ const twoD = sw.session.twoD === true;
963
+ let justCommitted = false;
964
+ if (!sw.committed) {
965
+ const start = clickStartRef.current;
966
+ if (start === null)
967
+ return;
968
+ // The slop lives on whatever the gesture actually cuts, so the
969
+ // wobble it forgives is the one that carries no meaning:
970
+ // - an x band — |dx|, so a vertical wobble under a still x is a
971
+ // click;
972
+ // - a **y** band (`sweepAxis: 'y'`, a horizontal bar) — |dy|, the
973
+ // mirror image, and measuring |dx| there would make the whole
974
+ // gesture unstartable;
975
+ // - a `twoD` rect — the DISTANCE, since both axes carry meaning
976
+ // and a straight-down drag is a legitimate rect.
977
+ const dx = e.clientX - start.x;
978
+ const dy = e.clientY - start.y;
979
+ const moved = twoD
980
+ ? Math.hypot(dx, dy)
981
+ : Math.abs(sw.axis === 'y' ? dy : dx);
982
+ if (moved <= DRAG_SLOP)
983
+ return;
984
+ sw.committed = true;
985
+ justCommitted = true;
986
+ setSweeping(true);
987
+ // A `twoD` sweep paints its own row-local rect instead, and a y
988
+ // band its own row-local band, for the same reason: the
989
+ // container's anchor is how EVERY row draws the x band, and
990
+ // anything measured against this row's y axis means nothing in
991
+ // the others.
992
+ if (!twoD && sw.axis === 'x')
993
+ c.setRegionAnchor(sw.anchor); // paint-only mirror
994
+ c.setHovered(null, rowRef.current.rowKey); // plural preview owns hover now
995
+ try {
996
+ e.currentTarget.setPointerCapture(e.pointerId);
997
+ }
998
+ catch {
999
+ /* ignore */
1000
+ }
1001
+ }
1002
+ c.setHoverX(px);
1003
+ if (twoD)
1004
+ c.setHoverY(py, rowRef.current.rowKey);
1005
+ sw.pendingT = +c.xScale.invert(px);
1006
+ sw.pendingPy = py;
1007
+ // The move that COMMITS the sweep cuts synchronously: the band and
1008
+ // the covered marks appear in the same event turn, so a
1009
+ // bucket-snapped sweep lights its whole first bucket from the
1010
+ // moment the drag starts — not an animation frame later, with the
1011
+ // single-mark hover already cleared and nothing lit in between.
1012
+ // Every later move stays frame-coalesced (RFC A1.4).
1013
+ if (justCommitted)
1014
+ flushSweep();
1015
+ else
1016
+ scheduleSweepFrame();
1017
+ return;
1018
+ }
1019
+ }
508
1020
  // A pan is only live while a button is held. A move with no buttons means
509
1021
  // the press already ended without us seeing the pointerup — which the
510
1022
  // deferred-capture path allows: an uncommitted (sub-slop) potential-pan
@@ -519,9 +1031,14 @@ export function Layers({ children }) {
519
1031
  if (drag) {
520
1032
  // Pan from the start range by the total drag — right → earlier (−dt).
521
1033
  const dx = e.clientX - drag.startX;
1034
+ const dy = e.clientY - drag.startY;
522
1035
  // Don't pan until past the slop, so a click's 1–4px jitter neither moves
523
- // the view nor shifts the scale the click then hit-tests against.
524
- if (Math.abs(dx) <= DRAG_SLOP)
1036
+ // the view nor shifts the scale the click then hit-tests against. In 2-D
1037
+ // the slop is on the *distance*, so a purely vertical drag arms it too.
1038
+ // With y in play the slop is on the DISTANCE, so a purely vertical drag
1039
+ // arms it; x-only keeps the horizontal-only test it always had.
1040
+ const moved = c.panY ? Math.hypot(dx, dy) : Math.abs(dx);
1041
+ if (moved <= DRAG_SLOP)
525
1042
  return;
526
1043
  // First move past the slop ⇒ this is a real pan, not a click. Commit it
527
1044
  // now (deferred from press, see handlePointerDown): hide the tracker and
@@ -532,7 +1049,7 @@ export function Layers({ children }) {
532
1049
  drag.captured = true;
533
1050
  c.setHoverX(null); // hide the tracker while panning
534
1051
  c.setHoverY(null, null);
535
- c.setHovered(null); // and drop any hover-highlight
1052
+ c.setHovered(null, rowRef.current.rowKey); // drop any hover-highlight
536
1053
  try {
537
1054
  e.currentTarget.setPointerCapture(e.pointerId);
538
1055
  }
@@ -540,6 +1057,18 @@ export function Layers({ children }) {
540
1057
  /* ignore (synthetic / already-released pointer) */
541
1058
  }
542
1059
  }
1060
+ // 2-D pan is a straight pixel shift of the y transform — no domain
1061
+ // maths, because the transform is already in pixel space.
1062
+ if (c.panY)
1063
+ c.applyYTransform({
1064
+ k: c.yTransform.k,
1065
+ ty: clampPanY(c.yTransform.k, drag.startTy + dy, rowRef.current.height),
1066
+ });
1067
+ // A category x has no continuous domain to pan; the y half above is the
1068
+ // whole gesture for a horizontal heat map. (Recomputed rather than
1069
+ // carried on the drag: this is a different closure from the press.)
1070
+ if (!c.panX || c.xKind === 'category')
1071
+ return;
543
1072
  if (c.discontinuities) {
544
1073
  // Trading-time axis: pan by an equal amount of *trading* time so the
545
1074
  // drag feels uniform across collapsed gaps (a raw-ms shift jumps).
@@ -557,18 +1086,21 @@ export function Layers({ children }) {
557
1086
  const r = rowRef.current;
558
1087
  const rawX = Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left));
559
1088
  const py = Math.max(0, Math.min(r.height, e.clientY - rect.top));
560
- // Crosshair with `crosshairSnap` (default): snap the shared vertical line to
561
- // the nearest sample's x, so the reticle centres on a real data point (and
562
- // stays aligned across rows on a shared grid). Free mode keeps the raw x.
563
- // Crosshair always snaps the shared vertical line (and so the x-time pill)
564
- // to the nearest sample's x the reticle rides the data grid on x, giving a
565
- // clean time readout (a raw pointer time formats to unreadable sub-second
566
- // precision). `crosshairSnap` only governs the *y* (snap to the value vs a
567
- // free horizontal line at the pointer). This is ChartIQ's model.
1089
+ // The declared x-snap, resolved BY the container (RFC A2.3): the hovered
1090
+ // row's gesture-owning cursor (its innermost mount A2.5) declares
1091
+ // `snapX`, and `'sample'` snaps the shared vertical line to the nearest
1092
+ // sample's x so the reticle centres on a real data point (and stays
1093
+ // aligned across rows on a shared grid). The crosshair declares it
1094
+ // unconditionally x always rides the data grid for a clean time
1095
+ // readout; its `snap` prop only governs the *y* (ChartIQ's model). A
1096
+ // cursor component could never do this itself: it has neither the
1097
+ // layers nor the right to write the shared cursorX.
568
1098
  let px = rawX;
569
- if ((r.cursor ?? c.cursor) === 'crosshair') {
1099
+ const owner = gestureOwner(effectiveCursorEntries(c.cursors, r.rowKey));
1100
+ if (owner?.spec.snapX === 'sample') {
570
1101
  const t = +c.xScale.invert(rawX);
571
- for (const entry of r.layers) {
1102
+ for (let i = r.layers.length - 1; i >= 0; i -= 1) {
1103
+ const entry = r.layers[i];
572
1104
  if (entry.layer.cursorFlag)
573
1105
  continue;
574
1106
  const s = entry.layer.sampleAt(t)[0];
@@ -586,18 +1118,148 @@ export function Layers({ children }) {
586
1118
  // slides the SVG cursor). A row with no selectable layer (line/area/band)
587
1119
  // resolves to null → a no-op. Uses the raw pointer, not the snapped x.
588
1120
  const hit = resolveSelection(r.layers, rawX, py, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId));
589
- c.setHovered(hit);
1121
+ // The resting BLOCK preview (a mounted <MultiSelector>): hover lights
1122
+ // every mark in the snap block under the pointer — exactly the set a
1123
+ // drag begun and released here would select, from exactly the sweep's
1124
+ // own machinery (the shared snap buckets through `regionSpan`, the
1125
+ // topmost layer's session), so the preview and a drag cannot disagree.
1126
+ // Cached per block (and per layer registry), so within-block moves
1127
+ // re-materialise nothing and hand the container the SAME array back —
1128
+ // its identity is the block-level hover dedup.
1129
+ let block;
1130
+ if (c.hasMultiSelector(r.rowKey)) {
1131
+ const span = regionSpan(c.cursorBuckets ?? [], +c.xScale.invert(rawX));
1132
+ if (span !== null) {
1133
+ const cached = restingBlockRef.current;
1134
+ if (cached !== null &&
1135
+ cached.layers === r.layers &&
1136
+ cached.start === span.start &&
1137
+ cached.end === span.end) {
1138
+ block = cached.hits;
1139
+ }
1140
+ else {
1141
+ const session = beginTopmostSweep(c, r)?.session ?? null;
1142
+ // A `twoD` layer has no resting block. Its snap block is a whole
1143
+ // x column, and a drag there captures a RECT — so lighting the
1144
+ // column at rest would preview a set the gesture never selects,
1145
+ // which is the exact lie the resting preview exists to avoid.
1146
+ // Rest falls back to the single-mark hover (the `hit` below).
1147
+ if (session !== null && session.twoD !== true) {
1148
+ session.update(span.start, span.end);
1149
+ let hits = session.hits();
1150
+ // Same block, same marks after a registry re-identification
1151
+ // (every hover commit re-registers the layers): keep the CACHED
1152
+ // array's reference, or the identity-based block dedup would
1153
+ // re-fire on every within-block move.
1154
+ if (cached !== null &&
1155
+ cached.start === span.start &&
1156
+ cached.end === span.end &&
1157
+ sameHits(cached.hits, hits))
1158
+ hits = cached.hits;
1159
+ restingBlockRef.current = {
1160
+ layers: r.layers,
1161
+ start: span.start,
1162
+ end: span.end,
1163
+ hits,
1164
+ };
1165
+ block = hits;
1166
+ }
1167
+ }
1168
+ // An all-gap block owns no membership (A7.6's "holes own no
1169
+ // membership") — fall back to the plain single-mark hover.
1170
+ if (block !== undefined && block.length === 0)
1171
+ block = undefined;
1172
+ }
1173
+ }
1174
+ // The row key scopes which `<Selector>`s hear it (a row's own mounts, else
1175
+ // the container's) — the hover *highlight* is unscoped container state.
1176
+ c.setHovered(hit, r.rowKey, block);
590
1177
  }, []);
591
1178
  const handlePointerUp = useCallback((e) => {
592
1179
  const c = containerRef.current;
593
- // End a region drag: commit the anchor→pointer span as a one-shot range,
594
- // then clear the anchor (the cursor reverts to the single-bucket highlight).
595
- // The anchor is read from the ref, never the state mirror — under a batched
596
- // pointer stream the state hasn't committed yet and the select would be
597
- // silently dropped (and the anchor stuck). See regionAnchorRef.
598
- if (regionAnchorRef.current !== null) {
599
- const anchor = regionAnchorRef.current;
600
- regionAnchorRef.current = null;
1180
+ // End a sweep: one final synchronous re-cut at the release pointer (the
1181
+ // last move's animation frame may not have run), then commit
1182
+ // `(hits, modifiers, spans)` to the <MultiSelector>s the press resolved
1183
+ // (RFC A5.2). The hits ARE the materialised preview `session.hits()`
1184
+ // reads the same cached array the last preview lit, never a fresh range
1185
+ // query (A7.7) and the span is the covered marks' snapped-outward
1186
+ // extent (A7.6's edge rule), `null` when the sweep covered nothing (the
1187
+ // swept-empty analog of a deselect click). A sub-slop press never
1188
+ // committed: it stays a click, and the click handler selects one mark.
1189
+ const sw = sweepRef.current;
1190
+ if (sw !== null) {
1191
+ sweepRef.current = null;
1192
+ if (sw.raf !== 0)
1193
+ cancelAnimationFrame(sw.raf);
1194
+ if (!sw.committed)
1195
+ return; // a click — handleClick owns it
1196
+ const box = e.currentTarget.getBoundingClientRect();
1197
+ sw.pendingT = +c.xScale.invert(Math.max(0, Math.min(c.plotWidth, e.clientX - box.left)));
1198
+ sw.pendingPy = Math.max(0, Math.min(rowRef.current.height, e.clientY - box.top));
1199
+ cutSweep(sw, c);
1200
+ setSweeping(false);
1201
+ setSweepRect(null);
1202
+ setSweepBandY(null);
1203
+ // The preview hands over to the committed selection here. A layer that
1204
+ // was previewed and is now selected sees no visual change, which is the
1205
+ // point: release must not repaint what it already promised.
1206
+ c.setPreviewSpans(EMPTY_SPANS);
1207
+ c.setRegionAnchor(null); // the band reverts, as the range drag's does
1208
+ try {
1209
+ e.currentTarget.releasePointerCapture(e.pointerId);
1210
+ }
1211
+ catch {
1212
+ /* ignore */
1213
+ }
1214
+ const extent = sw.session.extent();
1215
+ const modifiers = {
1216
+ additive: e.metaKey || e.ctrlKey,
1217
+ ctrlKey: e.ctrlKey,
1218
+ metaKey: e.metaKey,
1219
+ shiftKey: e.shiftKey,
1220
+ altKey: e.altKey,
1221
+ };
1222
+ // A `twoD` layer's span carries the second dimension too — a scatter's
1223
+ // continuous `y` window, a heat map's ordinal `rows` — so replaying
1224
+ // the descriptor through `spanMatchesAny` reproduces the rect, not
1225
+ // the whole column under it. The session names its own channel; the
1226
+ // gesture never guesses which one a layer uses.
1227
+ const channels = sw.session.twoD === true ? (sw.session.extent2D?.() ?? null) : null;
1228
+ // Every span this gesture produced, and the claimant's comes first —
1229
+ // on a mark-layer row it is the only one. A trace sweep adds one per span-only layer, because they
1230
+ // all share the x window and z-order means nothing to the reader there
1231
+ // ([PND-TRACESEL]).
1232
+ const claimed = extent === null
1233
+ ? null
1234
+ : { kind: 'span', id: sw.session.id, x: extent, ...channels };
1235
+ // `sw.spanOnly` is the authoritative set for span-only layers — and it
1236
+ // holds a session for EVERY one of them, including the claimant when
1237
+ // the claimant is a trace. Those are freshly built, so they are never
1238
+ // the same object as `sw.session`: prepending the claimant
1239
+ // unconditionally reported the topmost trace twice. Prepend it only
1240
+ // when it is a mark layer, which `spanOnly` does not cover.
1241
+ const all = [];
1242
+ if (claimed !== null && sw.session.spanOnly !== true)
1243
+ all.push(claimed);
1244
+ for (const s of sw.spanOnly) {
1245
+ const ext = s.extent();
1246
+ if (ext !== null)
1247
+ all.push({ kind: 'span', id: s.id, x: ext });
1248
+ }
1249
+ sw.gesture.commit(sw.session.hits(), modifiers, all);
1250
+ return;
1251
+ }
1252
+ // End a range drag: commit the anchor→pointer span as a one-shot range —
1253
+ // to the sink the press resolved (`<RangeCursor onDragRelease>`'s
1254
+ // `{ x: [lo, hi] }`, or the legacy `onRegionSelect` bare pair) — then
1255
+ // clear the anchor: the cursor **reverts** to the single-bucket
1256
+ // highlight (it does not keep the range). The anchor is read from the
1257
+ // ref, never the state mirror — under a batched pointer stream the
1258
+ // state hasn't committed yet and the select would be silently dropped
1259
+ // (and the anchor stuck). See rangeDragRef.
1260
+ if (rangeDragRef.current !== null) {
1261
+ const { anchor, release } = rangeDragRef.current;
1262
+ rangeDragRef.current = null;
601
1263
  const px = Math.max(0, Math.min(c.plotWidth, e.clientX - e.currentTarget.getBoundingClientRect().left));
602
1264
  const span = regionSpan(c.cursorBuckets ?? [], anchor, +c.xScale.invert(px));
603
1265
  c.setRegionAnchor(null);
@@ -608,7 +1270,7 @@ export function Layers({ children }) {
608
1270
  /* ignore */
609
1271
  }
610
1272
  if (span)
611
- c.onRegionSelect?.([span.start, span.end]);
1273
+ release(span.start, span.end);
612
1274
  return;
613
1275
  }
614
1276
  if (c.creating !== null) {
@@ -678,15 +1340,17 @@ export function Layers({ children }) {
678
1340
  c.setHoverY(null, null);
679
1341
  return;
680
1342
  }
681
- // Cancel a region-drag on leave (no commit) — a safety net for the rare case
1343
+ // Cancel a range-drag on leave (no commit) — a safety net for the rare case
682
1344
  // where the pointer capture didn't take, so the anchor can't get stuck.
683
- regionAnchorRef.current = null;
1345
+ rangeDragRef.current = null;
1346
+ // Same net for a sweep: no commit, preview un-lit, band cleared.
1347
+ cancelSweep();
684
1348
  if (c.regionAnchor !== null)
685
1349
  c.setRegionAnchor(null);
686
1350
  c.setHoverX(null);
687
1351
  c.setHoverY(null, null);
688
- c.setHovered(null);
689
- }, []);
1352
+ c.setHovered(null, rowRef.current.rowKey);
1353
+ }, [cancelSweep]);
690
1354
  // Click selection: ignore the click that ends a drag/pan (moved past a few px),
691
1355
  // else hit-test the row's layers top-down and select — or clear on a miss.
692
1356
  const handleClick = useCallback((e) => {
@@ -710,8 +1374,70 @@ export function Layers({ children }) {
710
1374
  }
711
1375
  const r = rowRef.current;
712
1376
  const rect = e.currentTarget.getBoundingClientRect();
713
- const hit = resolveSelection(r.layers, e.clientX - rect.left, e.clientY - rect.top, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId));
714
- c.select(hit);
1377
+ // 'select', not 'hover': a click must be able to resolve to NO mark
1378
+ // that null is the deselect signal (the empty commit) — so layers whose
1379
+ // hover target is generous (a bar's full-height slot) narrow it here.
1380
+ const hit = resolveSelection(r.layers, e.clientX - rect.left, e.clientY - rect.top, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId), 'select');
1381
+ const modifiers = {
1382
+ additive: e.metaKey || e.ctrlKey,
1383
+ ctrlKey: e.ctrlKey,
1384
+ metaKey: e.metaKey,
1385
+ shiftKey: e.shiftKey,
1386
+ altKey: e.altKey,
1387
+ };
1388
+ // **A click commits the block it previewed.** Under a mounted
1389
+ // `<MultiSelector>` the resting preview lights the whole snap block a
1390
+ // gesture begun here would select (the band + every covered mark); a click
1391
+ // that then selected only the mark under the pointer would make that
1392
+ // preview a lie for the gesture most people try first. So the click
1393
+ // commits the same block, through the same session the drag uses — one
1394
+ // code path, so rest, click and sweep cannot disagree.
1395
+ //
1396
+ // **Only when a `sequence` was declared** (`gesture.snapped`). With none,
1397
+ // the block is the single bin under the pointer and this falls through to
1398
+ // the one-mark `select` below unchanged — which is what keeps a click
1399
+ // distinguishable from a sweep by its `null` span (RFC §8: a click is a
1400
+ // click). A `sequence` is an explicit declaration that selection happens
1401
+ // in bucket units, and *that* is what earns the wider commit.
1402
+ //
1403
+ // The test has to be the declaration rather than "the block covers more
1404
+ // than one mark", which is what it was first written as. A **stack**'s bin
1405
+ // holds one mark per group, so the mark-count test fired on an ordinary
1406
+ // unsnapped click and swallowed the whole bin instead of the clicked
1407
+ // segment — found walking `MultiSelector/Stacked/ClickStillSelectsOne`.
1408
+ if (hit !== null && c.hasMultiSelector(r.rowKey)) {
1409
+ const px = e.clientX - rect.left;
1410
+ const span = regionSpan(c.cursorBuckets ?? [], +c.xScale.invert(px));
1411
+ const gesture = span === null ? null : c.resolveSweep(r.rowKey);
1412
+ if (span !== null && gesture !== null && gesture.snapped) {
1413
+ const session = beginTopmostSweep(c, r)?.session ?? null;
1414
+ // …and for the same reason, a `twoD` layer's click stays a click on
1415
+ // the mark under the pointer. The block it would otherwise commit is
1416
+ // the column, which is not what the rect gesture next to it captures.
1417
+ if (session !== null && session.twoD !== true) {
1418
+ session.update(span.start, span.end);
1419
+ const hits = session.hits();
1420
+ const extent = session.extent();
1421
+ if (hits.length > 0 && extent !== null) {
1422
+ const one = {
1423
+ kind: 'span',
1424
+ id: session.id,
1425
+ x: extent,
1426
+ };
1427
+ gesture.commit(hits, modifiers, [one]);
1428
+ return;
1429
+ }
1430
+ }
1431
+ }
1432
+ }
1433
+ // Report the modifiers the click carried ([PND-MULTISEL]). The library
1434
+ // applies no policy to them; a consumer implements ⌘/Ctrl-adds itself,
1435
+ // which it could not do at all while the click arrived as a bare hit.
1436
+ //
1437
+ // Passing the row key marks this as a **plot gesture**, which the container
1438
+ // gates on a mounted `<Selector>` (interaction RFC §7.1): with none in
1439
+ // scope this whole click is inert, deliberately.
1440
+ c.select(hit, modifiers, r.rowKey);
715
1441
  }, []);
716
1442
  // Wheel-zoom — a native non-passive listener so `preventDefault` works (React's
717
1443
  // onWheel is passive). Attached once; no-ops (and lets the page scroll) when
@@ -722,116 +1448,173 @@ export function Layers({ children }) {
722
1448
  return;
723
1449
  const onWheel = (e) => {
724
1450
  const c = containerRef.current;
725
- if (!c.zoomEnabled || c.xKind === 'category')
1451
+ if (!c.zoomEnabled)
1452
+ return;
1453
+ // A category x axis has no continuous domain to zoom. That rules out the x
1454
+ // half, but not the y half — which is why `panZoomY` is what a horizontal
1455
+ // heat map (categories on x, bins on y) wants, and why `panZoomXY` would
1456
+ // be a lie there.
1457
+ const doX = c.zoomX && c.xKind !== 'category';
1458
+ if (!doX && !c.zoomY)
726
1459
  return;
727
1460
  e.preventDefault();
728
1461
  const rect = el.getBoundingClientRect();
729
1462
  const localX = Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left));
730
1463
  const pivot = +c.xScale.invert(localX);
731
- const factor = Math.exp(e.deltaY * ZOOM_SENSITIVITY);
732
- c.applyRange(c.discontinuities
1464
+ let factor = Math.exp(e.deltaY * ZOOM_SENSITIVITY);
1465
+ const nextRange = (f) => c.discontinuities
733
1466
  ? // minDuration is the zoom-in floor; on a trading-time axis it caps
734
- // the minimum visible *trading* time (ms of open-market time) rather
735
- // than wall-clock ms — the sensible meaning for this axis.
736
- zoomRangeTrading(c.timeRange, pivot, factor, c.discontinuities, c.minDuration)
737
- : zoomRange(c.timeRange, pivot, factor, c.minDuration));
1467
+ // the minimum visible *trading* time (ms of open-market time)
1468
+ // rather than wall-clock ms — the sensible meaning for this axis.
1469
+ zoomRangeTrading(c.timeRange, pivot, f, c.discontinuities, c.minDuration)
1470
+ : zoomRange(c.timeRange, pivot, f, c.minDuration);
1471
+ // ── The aspect lock has to be NEGOTIATED, not asserted ────────────────
1472
+ // Both axes zooming by "the same factor" only holds the ratio while both
1473
+ // can actually take that factor. Each has its own limit — y cannot zoom
1474
+ // out past its natural fit (`k >= 1`), x cannot zoom in past
1475
+ // `minDuration` — and if either clamps on its own, the other carries on
1476
+ // and the picture shears. That is visible as soon as you zoom out to an
1477
+ // edge: y stops at `k = 1` and x keeps widening.
1478
+ //
1479
+ // So agree one factor first: cap it at what y can take, then ask x what
1480
+ // it would actually do with that and adopt the answer.
1481
+ const both = doX && c.zoomY;
1482
+ let range = null;
1483
+ if (doX) {
1484
+ if (both)
1485
+ factor = Math.min(factor, c.yTransform.k);
1486
+ range = nextRange(factor);
1487
+ const span = c.timeRange[1] - c.timeRange[0];
1488
+ if (both && span > 0)
1489
+ factor = (range[1] - range[0]) / span;
1490
+ }
1491
+ if (c.zoomY) {
1492
+ // `factor` scales the DOMAIN span, so factor > 1 is zoom *out*; the
1493
+ // pixel-space zoom is its reciprocal. One factor for both axes is what
1494
+ // fixes the aspect ratio.
1495
+ const z = 1 / factor;
1496
+ const localY = e.clientY - rect.top;
1497
+ const { k, ty } = c.yTransform;
1498
+ // Zoom about the cursor: p' = localY + (p − localY)·z, expanded through
1499
+ // the existing transform p = ty + k·base.
1500
+ const nk = Math.max(1, k * z);
1501
+ c.applyYTransform({
1502
+ k: nk,
1503
+ ty: clampPanY(nk, localY * (1 - z) + ty * z, rowRef.current.height),
1504
+ });
1505
+ }
1506
+ if (range !== null)
1507
+ c.applyRange(range);
738
1508
  };
739
1509
  el.addEventListener('wheel', onWheel, { passive: false });
740
1510
  return () => el.removeEventListener('wheel', onWheel);
741
1511
  }, []);
742
- // Cursor presentation is a DOM/SVG overlay (no cursor canvas): an SVG holds the
743
- // line / dots / flag staffs; these value chips (DOM, crisp text) sit beside each
744
- // dot ('inline', clamped within the row) or stack at the top of the flag staff
745
- // ('flag'). line / point / none draw no chips surface values off-chart.
746
- const flagLineHeight = container.theme.font.size + 5;
747
- // Cursor chips share the annotation label look (filled, no outline) — one
748
- // source of truth so a flag and a placed label read as the same object.
749
- const chipStyle = flagChipStyle(container.theme);
750
- // Show the cursor's time atop the readout (opt-in via `cursorTime`), whenever
751
- // the cursor is active (any mode that draws marks). A single chip at the cursor
752
- // x, top of the row; for `flag` it sits above the value chips (which shift down).
753
- // The time is shared across rows (one cursor, one time), so it shows **once**,
754
- // atop the first row — not repeated per row. (Gating it here also drops the
755
- // top-of-stack space reservation on the other rows, see `flagBase`.)
756
- // Crosshair (`chip: 'axis'`) is excluded: it pins the time to the shared x-axis
757
- // pill (`<XAxis>`), so a per-row chip here would double it (and land wrong on a
758
- // stacked row).
759
- const showTime = showCursorTime &&
760
- cursorTime !== null &&
761
- (parts.line || parts.dots) &&
762
- parts.chip !== 'axis' &&
763
- row.isFirstRow;
764
- // Flag geometry: each value flies as a flag from the top of its own staff — the
765
- // chip's top sits at `flagBase` (just below the time chip when shown) and the
766
- // staff drops from there to the dot. (Chips share that top and spread by x, so
767
- // near-coincident flags can overlap — a de-overlap heuristic is a follow-up.)
768
- const flagTop = 2;
769
- const flagBase = flagTop + (showTime ? flagLineHeight : 0);
770
- // The cursor-time chip caps the readout. In `flag` mode it tops the flag stack,
771
- // so anchor it to the stack's x (the nearest sample's point) so time + flag +
772
- // staff + dot read as one column; otherwise it labels the cursor line at cursorX.
773
- const timeX = parts.chip === 'flag' && trackerSamples.length > 0
774
- ? trackerSamples[0].px
775
- : cursorX;
776
- // Crosshair reticle (`chip: 'axis'`): a single centre for THIS row — the
777
- // horizontal line + centre dot + value pill anchor to `center.py` (the vertical
778
- // line is the shared `cursorX`, drawn in every row). Snap: the sample nearest
779
- // the pointer y in the hovered row (or the first sample when nothing's hovered,
780
- // e.g. a pinned demo — so every row shows a reticle then). Free: the raw pointer
781
- // y in the hovered row, its value via `yScale.invert`. `null` ⇒ vertical only.
782
- const cursorInBounds = cursorX !== null && cursorX >= 0 && cursorX <= plotWidth;
783
- const reticle = (() => {
784
- if (parts.chip !== 'axis' || !cursorInBounds)
1512
+ // ── The resolved cursor frame (RFC A2.3) finished measurements for the
1513
+ // effective cursors' render slots. Everything below is *resolution*: the
1514
+ // slots (in cursors.tsx, or eventually user-authored) only draw.
1515
+ // The raw pointer's y resolved against the row's default axisthe free
1516
+ // (non-snapping) crosshair's centre. Only in the hovered row, and only when
1517
+ // an effective cursor declared the need; a slot has no `yScale.invert` of
1518
+ // its own, which is exactly why this is resolved here.
1519
+ const pointer = useMemo(() => {
1520
+ if (!wantsPointer)
785
1521
  return null;
786
- const hoveredRow = cursor.cursorRowKey === row.rowKey;
787
- const cy = cursor.cursorY;
788
- if (container.crosshairSnap) {
789
- if (trackerSamples.length === 0)
790
- return null;
791
- const pick = hoveredRow && cy !== null
792
- ? trackerSamples.reduce((a, b) => Math.abs(b.py - cy) < Math.abs(a.py - cy) ? b : a)
793
- : cursor.cursorRowKey === null
794
- ? trackerSamples[0]
795
- : null;
796
- return pick
797
- ? {
798
- py: pick.py,
799
- value: pick.value,
800
- format: pick.format,
801
- side: pick.side,
802
- }
803
- : null;
804
- }
805
- // Free reticle — the raw pointer y in the hovered row.
806
- if (!hoveredRow || cy === null)
1522
+ if (cursor.cursorRowKey !== row.rowKey || cursor.cursorY === null)
807
1523
  return null;
808
1524
  const ys = yScales.get(defaultAxisId);
809
1525
  if (ys === undefined)
810
1526
  return null;
1527
+ const fmt = formats.get(defaultAxisId) ?? String;
811
1528
  return {
812
- py: cy,
813
- value: ys.invert(cy),
814
- format: formats.get(defaultAxisId) ?? String,
1529
+ py: cursor.cursorY,
1530
+ formatted: fmt(ys.invert(cursor.cursorY)),
815
1531
  side: axisSides.get(defaultAxisId) ?? 'left',
816
1532
  };
817
- })();
818
- // `region` cursor (continuous x axis — time or value): shade the span under the
819
- // pointer. With a `cursorSequence` (time axis only) the band snaps to the bucket
820
- // (and extends bucket by bucket under a drag); with none — always the case on a
821
- // value axis — it's the **freeform** case: a bare hover draws a plain line
822
- // (`regionLine`), a drag shades the raw `[anchor, pointer]`. Edges map through
823
- // `xScale`, so on a trading-time axis the band crops to live time.
824
- const regionActive = parts.band && (container.xKind === 'time' || container.xKind === 'value');
825
- const band = regionActive && cursorTime !== null
1533
+ }, [
1534
+ wantsPointer,
1535
+ cursor.cursorRowKey,
1536
+ cursor.cursorY,
1537
+ row.rowKey,
1538
+ yScales,
1539
+ formats,
1540
+ axisSides,
1541
+ defaultAxisId,
1542
+ ]);
1543
+ // The in-plot cursor time, readout-formatted — the `showTime` presets' chip
1544
+ // text. The time is shared across rows (one cursor, one time), so the chip
1545
+ // itself shows once, atop the first row; formatting is skipped entirely when
1546
+ // no effective cursor wants it.
1547
+ const formattedTime = wantsTime && cursorTime !== null
1548
+ ? (container.formatReadout ?? formatTime)(cursorTime)
1549
+ : null;
1550
+ // The range cursor's band (continuous x axis — time or value): shade the
1551
+ // span under the pointer. With snap buckets (a sequence / a histogram's
1552
+ // bins) the band snaps to the bucket (and extends bucket by bucket under a
1553
+ // legacy drag); with none it's the **freeform** case — a bare hover draws a
1554
+ // plain line (`bandLine`), a drag shades the raw `[anchor, pointer]`. Edges
1555
+ // map through `xScale`, so on a trading-time axis the band crops to live time.
1556
+ // A live <MultiSelector> sweep shades the same band — and so does its
1557
+ // RESTING state (`restingBand`): the band over the snap block under the
1558
+ // pointer is the row's resting cursor, previewing the block a drag would
1559
+ // select. Neither is gated to a continuous axis: the marks currency is what
1560
+ // folds the category axis into the gesture (RFC §8 / A4.2 — nobody sees a
1561
+ // numeric range), and the band maps slot units through the shared band
1562
+ // scale like any other span.
1563
+ // A 2-D sweep paints `sweepRect` instead, so it must not ALSO shade a band:
1564
+ // `sweeping` alone would resolve one from the pointer's bucket, and the row
1565
+ // would carry a full-height column under the rect the drag is drawing.
1566
+ // A y sweep paints `sweepBandY`, so — exactly as with the rect — it must
1567
+ // not ALSO shade an x band: `sweeping` alone would resolve one from the
1568
+ // pointer's bucket and lay a full-height column across the horizontal band
1569
+ // the drag is actually drawing.
1570
+ const bandActive = (wantsBand &&
1571
+ (container.xKind === 'time' || container.xKind === 'value')) ||
1572
+ (sweeping && sweepRect === null && sweepBandY === null) ||
1573
+ restingBand;
1574
+ const band = bandActive && cursorTime !== null
826
1575
  ? bandRect(container.cursorBuckets ?? [], cursorTime, (v) => xScale(v), plotWidth, container.regionAnchor ?? undefined)
827
1576
  : null;
828
- // Degenerate region cursor (no sequence, not mid-drag): a plain vertical line.
829
- const regionLine = regionActive &&
1577
+ // Degenerate range cursor (no buckets, not mid-drag): a plain vertical
1578
+ // line. Deliberately NOT extended to `restingBand` — the resting preview is
1579
+ // "a region-like cursor, not a line", so with no snap block under the
1580
+ // pointer it shows nothing rather than degenerating to the rule it exists
1581
+ // to replace.
1582
+ // A drag owns the band's extent exactly while an anchor is set — the range
1583
+ // drag sets it on pointerdown, the sweep when it crosses `DRAG_SLOP` (so a
1584
+ // click never flashes the edges on its way to committing a block).
1585
+ // A y band's anchor is row-local, so the container's `regionAnchor` says
1586
+ // nothing about it — its own presence is the drag (it is set only past the
1587
+ // slop, and cleared on release).
1588
+ const bandDragging = container.regionAnchor !== null || sweepBandY !== null;
1589
+ const bandLine = wantsBand &&
1590
+ (container.xKind === 'time' || container.xKind === 'value') &&
1591
+ !sweeping &&
830
1592
  container.cursorBuckets === undefined &&
831
1593
  container.regionAnchor === null &&
832
1594
  cursorX !== null &&
833
1595
  cursorX >= 0 &&
834
1596
  cursorX <= plotWidth;
1597
+ const cursorRenderFrame = {
1598
+ cursorX,
1599
+ cursorY: cursor.cursorY,
1600
+ rowKey: row.rowKey,
1601
+ hoveredRowKey: cursor.cursorRowKey,
1602
+ samples: trackerSamples,
1603
+ flags: trackerFlags,
1604
+ pointer,
1605
+ band,
1606
+ bandY: sweepBandY,
1607
+ bandLine,
1608
+ bandDragging,
1609
+ rect: sweepRect,
1610
+ restingCross,
1611
+ formattedTime,
1612
+ plotWidth,
1613
+ rowHeight: row.height,
1614
+ isFirstRow: row.isFirstRow,
1615
+ theme: container.theme,
1616
+ xAxis: null,
1617
+ };
835
1618
  // Cross-row guide lines: the x-positions of annotations on the OTHER rows
836
1619
  // (markers + region edges), so a mark on one row reads against this row's data +
837
1620
  // the shared x axis. A mark's own row skips itself; baselines cast no vertical
@@ -866,9 +1649,45 @@ export function Layers({ children }) {
866
1649
  }
867
1650
  // Inject each draw layer's JSX position so it registers its declaration order
868
1651
  // (z-stack: lower index at the back), independent of mount timing.
869
- const indexedChildren = Children.map(children, (child, index) => isValidElement(child)
870
- ? cloneElement(child, { index })
871
- : child);
1652
+ const indexedChildren = useIndexedChildren(children, '<Layers>', 'the draw layers inside it all register at 0 and their z-stacking falls ' +
1653
+ 'back to mount order');
1654
+ // The non-fragment half of the same trap, and the mirror of `<ChartRow>`'s
1655
+ // axis check: a draw layer nested inside ANY element child of `<Layers>`
1656
+ // never receives the injected index, so the z-stack silently falls back to
1657
+ // mount order. `<Selector>`/`<MultiSelector>` made this reachable when they
1658
+ // gained `children` (RFC A10.1) — they belong *outside* `<Layers>`, wrapping
1659
+ // it — and being real elements they slip past the fragment warning. No draw
1660
+ // layer takes element children of its own, so "a child that has element
1661
+ // children" is a sound signal here. (Codex finding on #638.)
1662
+ let wrapperChild = false;
1663
+ if (isDev) {
1664
+ for (const child of Children.toArray(children)) {
1665
+ if (!isValidElement(child) || child.type === Fragment)
1666
+ continue;
1667
+ const nested = child.props.children;
1668
+ if (nested === undefined)
1669
+ continue;
1670
+ for (const g of Children.toArray(nested)) {
1671
+ if (isValidElement(g)) {
1672
+ wrapperChild = true;
1673
+ break;
1674
+ }
1675
+ }
1676
+ if (wrapperChild)
1677
+ break;
1678
+ }
1679
+ }
1680
+ const warnedWrapperRef = useRef(false);
1681
+ useEffect(() => {
1682
+ if (!isDev || !wrapperChild || warnedWrapperRef.current)
1683
+ return;
1684
+ warnedWrapperRef.current = true;
1685
+ console.warn('[pond-charts] a child of <Layers> wraps other elements, so any draw ' +
1686
+ 'layer inside it never receives the injected z-order index and the ' +
1687
+ 'stack falls back to mount order. Draw layers must be direct children ' +
1688
+ 'of <Layers>; a <Selector>/<MultiSelector> belongs outside it, ' +
1689
+ 'wrapping the <Layers> (or the whole <ChartRow>).');
1690
+ }, [wrapperChild]);
872
1691
  return (_jsx(LayersContext.Provider, { value: registry, children: _jsxs("div", { ref: plotRef, style: {
873
1692
  position: 'relative',
874
1693
  width: `${plotWidth}px`,
@@ -899,66 +1718,8 @@ export function Layers({ children }) {
899
1718
  top: 0,
900
1719
  left: 0,
901
1720
  pointerEvents: 'none',
902
- }, children: [band !== null && (_jsx("rect", { x: band.x0, y: 0, width: band.x1 - band.x0, height: row.height, fill: cursorColor, opacity: 0.12 })), regionLine && cursorX !== null && (_jsx("line", { x1: Math.round(cursorX), y1: 0, x2: Math.round(cursorX), y2: row.height, stroke: cursorColor, strokeWidth: 1, shapeRendering: "crispEdges" })), parts.line &&
903
- cursorX !== null &&
904
- cursorX >= 0 &&
905
- cursorX <= plotWidth && (_jsx("line", { x1: Math.round(cursorX), y1: 0, x2: Math.round(cursorX), y2: row.height, stroke: cursorColor, strokeWidth: 1, shapeRendering: "crispEdges" })), parts.chip === 'flag' &&
906
- trackerSamples.map((s, i) => s.py > flagBase ? (_jsx("line", { x1: s.px, y1: flagBase, x2: s.px, y2: s.py, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `staff-${i}`)) : null), parts.chip === 'flag' &&
907
- trackerFlags.map((f, i) => f.topPy > flagBase ? (_jsx("line", { x1: f.px, y1: flagBase, x2: f.px, y2: f.topPy, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `boxstaff-${i}`)) : null), parts.chip === 'axis' &&
908
- cursorX !== null &&
909
- cursorX >= 0 &&
910
- cursorX <= plotWidth && (_jsxs(_Fragment, { children: [_jsx("line", { x1: Math.round(cursorX), y1: 0, x2: Math.round(cursorX), y2: row.height, stroke: cursorColor, strokeWidth: 1, strokeDasharray: "3 3", shapeRendering: "crispEdges" }), reticle && (_jsxs(_Fragment, { children: [_jsx("line", { x1: 0, y1: Math.round(reticle.py), x2: plotWidth, y2: Math.round(reticle.py), stroke: cursorColor, strokeWidth: 1, strokeDasharray: "3 3", shapeRendering: "crispEdges" }), _jsx("circle", { cx: cursorX, cy: reticle.py, r: 3, fill: cursorColor, stroke: background, strokeWidth: background ? 1 : 0 })] }))] })), parts.dots &&
911
- trackerSamples.map((s, i) => (_jsx("circle", { cx: s.px, cy: s.py, r: 3, fill: s.color, stroke: background, strokeWidth: background ? 1 : 0 }, `dot-${i}`)))] }), showTime && timeX !== null && cursorTime !== null && (_jsx("div", { style: {
912
- ...chipStyle,
913
- background: 'transparent',
914
- padding: 0,
915
- top: `${flagTop}px`,
916
- left: timeX > plotWidth * LABEL_FLIP_FRACTION
917
- ? undefined
918
- : `${timeX + 4}px`,
919
- right: timeX > plotWidth * LABEL_FLIP_FRACTION
920
- ? `${plotWidth - timeX + 4}px`
921
- : undefined,
922
- color: cursorColor,
923
- }, children: (container.formatReadout ?? formatTime)(cursorTime) })), parts.chip === 'inline' &&
924
- trackerSamples.map((s, i) => {
925
- // Flip the chip left of its dot near the right edge so it stays in-plot.
926
- const flip = s.px > plotWidth * LABEL_FLIP_FRACTION;
927
- // Clamp within the row so a chip near the top/bottom isn't clipped by
928
- // (or spilling into) the neighbouring row. Chip-vs-chip de-overlap is
929
- // a later refinement; this keeps each chip inside its own row.
930
- const top = Math.max(flagLineHeight / 2, Math.min(row.height - flagLineHeight / 2, s.py));
931
- return (_jsx("div", { style: {
932
- ...chipStyle,
933
- top: `${top}px`,
934
- transform: 'translateY(-50%)',
935
- left: flip ? undefined : `${s.px + 8}px`,
936
- right: flip ? `${plotWidth - s.px + 8}px` : undefined,
937
- color: s.color,
938
- }, children: s.format(s.value) }, i));
939
- }), reticle && (_jsx("div", { style: {
940
- ...axisPillStyle(container.theme, cursorColor),
941
- top: `${Math.max(flagLineHeight / 2, Math.min(row.height - flagLineHeight / 2, reticle.py))}px`,
942
- transform: 'translateY(-50%)',
943
- ...axisPillX(reticle.side, plotWidth),
944
- }, children: reticle.format(reticle.value) })), parts.chip === 'flag' &&
945
- cursorX !== null &&
946
- trackerSamples.map((s, i) => (
947
- // The flag flies from the top of its staff — chip top at the staff top
948
- // (`flagBase`), beside the pole at the point's x (shared `flagChipX`).
949
- _jsx("div", { style: {
950
- ...chipStyle,
951
- top: `${flagBase}px`,
952
- ...flagChipX(s.px, plotWidth),
953
- color: s.color,
954
- }, children: s.format(s.value) }, i))), parts.chip === 'flag' &&
955
- trackerFlags.map((f, i) => (_jsx("div", { style: {
956
- ...chipStyle,
957
- top: `${flagBase}px`,
958
- ...flagChipX(f.px, plotWidth),
959
- display: 'flex',
960
- flexDirection: 'row',
961
- gap: '6px',
962
- }, children: f.lines.map((l, j) => (_jsx("span", { style: { color: l.color }, children: l.text }, j))) }, `boxflag-${i}`)))] }) }));
1721
+ }, children: [cursorEntries.map((e, i) => e.spec.renderPlot ? (_jsx(Fragment, { children: e.spec.renderPlot(cursorRenderFrame) }, `cursor-plot-${i}`)) : null), (sweeping || restingBand) &&
1722
+ !wantsBand &&
1723
+ renderBrushBand(cursorRenderFrame), renderBrushRect(cursorRenderFrame)] }), cursorEntries.map((e, i) => e.spec.renderPlotHtml ? (_jsx(Fragment, { children: e.spec.renderPlotHtml(cursorRenderFrame) }, `cursor-html-${i}`)) : null), cursorEntries.map((e, i) => e.spec.renderYGutter ? (_jsx(Fragment, { children: e.spec.renderYGutter(cursorRenderFrame) }, `cursor-gutter-${i}`)) : null)] }) }));
963
1724
  }
964
1725
  //# sourceMappingURL=Layers.js.map