@pond-ts/charts 0.56.2 → 0.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1218 -1
- package/dist/AreaChart.d.ts +12 -1
- package/dist/AreaChart.js +131 -13
- package/dist/BarChart.d.ts +84 -9
- package/dist/BarChart.js +295 -40
- package/dist/BarList.d.ts +85 -5
- package/dist/BarList.js +25 -4
- package/dist/BoxList.d.ts +70 -3
- package/dist/BoxList.js +21 -7
- package/dist/BoxPlot.d.ts +2 -1
- package/dist/BoxPlot.js +101 -9
- package/dist/Candlestick.d.ts +13 -1
- package/dist/Candlestick.js +89 -3
- package/dist/ChartContainer.d.ts +79 -48
- package/dist/ChartContainer.js +482 -60
- package/dist/ChartRow.d.ts +9 -2
- package/dist/ChartRow.js +86 -12
- package/dist/HeatMap.d.ts +176 -0
- package/dist/HeatMap.js +344 -0
- package/dist/Layers.d.ts +5 -1
- package/dist/Layers.js +1014 -253
- package/dist/Legend.js +8 -4
- package/dist/LineChart.d.ts +18 -1
- package/dist/LineChart.js +165 -4
- package/dist/ListTable.d.ts +30 -3
- package/dist/ListTable.js +381 -23
- package/dist/ScatterChart.d.ts +3 -2
- package/dist/ScatterChart.js +68 -4
- package/dist/XAxis.js +40 -22
- package/dist/YAxis.d.ts +28 -1
- package/dist/YAxis.js +24 -2
- package/dist/annotations.d.ts +74 -0
- package/dist/annotations.js +97 -7
- package/dist/area.d.ts +34 -1
- package/dist/area.js +88 -1
- package/dist/bars.d.ts +178 -5
- package/dist/bars.js +504 -46
- package/dist/box.d.ts +2 -2
- package/dist/box.js +158 -40
- package/dist/brush.d.ts +142 -0
- package/dist/brush.js +179 -0
- package/dist/child-index.d.ts +27 -0
- package/dist/child-index.js +57 -0
- package/dist/context.d.ts +871 -36
- package/dist/cursors.d.ts +161 -0
- package/dist/cursors.js +503 -0
- package/dist/decimate.d.ts +78 -1
- package/dist/decimate.js +157 -0
- package/dist/heat.d.ts +163 -0
- package/dist/heat.js +659 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.js +25 -2
- package/dist/line.d.ts +137 -0
- package/dist/line.js +328 -0
- package/dist/ohlc.d.ts +16 -1
- package/dist/ohlc.js +93 -4
- package/dist/scatter.d.ts +17 -9
- package/dist/scatter.js +221 -33
- package/dist/select.d.ts +13 -5
- package/dist/select.js +14 -6
- package/dist/selection-fixtures.d.ts +174 -0
- package/dist/selection-fixtures.js +569 -0
- package/dist/selection-stories.d.ts +73 -0
- package/dist/selection-stories.js +301 -0
- package/dist/selectors.d.ts +316 -0
- package/dist/selectors.js +391 -0
- package/dist/span.d.ts +122 -0
- package/dist/span.js +203 -0
- package/dist/sweep.d.ts +154 -0
- package/dist/sweep.js +282 -0
- package/dist/theme.d.ts +517 -11
- package/dist/theme.js +220 -39
- package/dist/tracker.d.ts +6 -0
- package/dist/tracker.js +6 -0
- package/dist/tradingAxis.fixture.d.ts +78 -0
- package/dist/tradingAxis.fixture.js +215 -0
- package/dist/useChartLegend.js +18 -3
- package/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,
|
|
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 {
|
|
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
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
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 {
|
|
414
|
+
const { formatTime } = container;
|
|
292
415
|
const { cursorX } = cursor;
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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) —
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
360
|
-
//
|
|
361
|
-
// (`px`, `topPy`)
|
|
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 ||
|
|
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
|
|
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
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
// the
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
904
|
+
return;
|
|
469
905
|
}
|
|
470
|
-
if (
|
|
906
|
+
if (claim.kind === 'none')
|
|
471
907
|
return;
|
|
472
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
503
|
-
if (
|
|
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
|
-
|
|
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); //
|
|
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
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
714
|
-
|
|
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
|
|
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
|
-
|
|
732
|
-
|
|
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)
|
|
735
|
-
// than wall-clock ms — the sensible meaning for this axis.
|
|
736
|
-
zoomRangeTrading(c.timeRange, pivot,
|
|
737
|
-
: zoomRange(c.timeRange, pivot,
|
|
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
|
-
//
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
const
|
|
750
|
-
|
|
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 axis — the 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
|
-
|
|
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:
|
|
813
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
|
829
|
-
|
|
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 =
|
|
870
|
-
|
|
871
|
-
|
|
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: [
|
|
903
|
-
|
|
904
|
-
|
|
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
|