@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/span.js ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Span-selection membership — the one place the containment rule lives
3
+ * (interaction RFC A5.2/A5.3, edge rule A7.6).
4
+ *
5
+ * `selectionContains` is the **public** predicate, and it is deliberately the
6
+ * same code every layer's draw runs per mark ({@link spanContainsPoint} for
7
+ * span entries): a consumer asking "is this hit already selected?" must get
8
+ * exactly the answer the canvas paints, or the boundary marks disagree with
9
+ * the span that swept them — the inverse-band-scale friction the marks
10
+ * currency was built to kill (`selection.md` A4.2) re-imported through the
11
+ * back door.
12
+ *
13
+ * Pure, DOM-free, theme-free — unit-tests like `select.ts` does.
14
+ */
15
+ /** Stable identity for "no spans" — the resting case, shared by the container
16
+ * normalization and every layer's narrowing, so a spanless render never hands
17
+ * a draw (or a memo dep) a fresh empty array. */
18
+ export const NO_SPANS = [];
19
+ /**
20
+ * Is this `selected` entry a span descriptor rather than a single mark? The
21
+ * discriminant is the `kind` field, which {@link SelectInfo} does not have —
22
+ * useful to a consumer editing a mixed selection (RFC A5.2's demote-on-edit:
23
+ * filter the span out, splice in the marks it stashed at commit time).
24
+ */
25
+ export function isSpanSelection(entry) {
26
+ return entry.kind === 'span';
27
+ }
28
+ /**
29
+ * Does `span` contain the mark with these channels? The containment rule of
30
+ * {@link SpanSelection}, minus the layer-`id` gate (the caller has already
31
+ * matched it — a layer narrows the set to its own `id` once per render, not
32
+ * once per mark):
33
+ *
34
+ * - `key` in the **half-open** `x` interval — `x[0] <= key < x[1]`;
35
+ * - `value` in the half-open `y` interval, when the span has one;
36
+ * - `label` a member of `rows`, when the span has one.
37
+ *
38
+ * Each channel is the mark's own {@link SelectInfo} field, so the test is
39
+ * answerable from a hit alone *and* from a draw loop's per-mark scalars — the
40
+ * property RFC A5.3 requires (nothing here reads a slot index or a pixel).
41
+ * `NaN` in any tested channel fails its comparison, so a series-scoped entry
42
+ * (`key: NaN`) or a gap value is never inside any span. A span carrying `rows`
43
+ * tested against a caller with no label channel (`label === undefined`)
44
+ * matches nothing — a row-set has to be checked, not skipped.
45
+ *
46
+ * O(1) per mark (plus O(|rows|) for the label set, which is a handful of row
47
+ * names) — the whole point of the descriptor: a span covering ten thousand
48
+ * marks costs each of them one interval test, not a ten-thousand-entry scan.
49
+ */
50
+ export function spanContainsPoint(span, key, value, label) {
51
+ const x = span.x;
52
+ if (!(key >= x[0] && key < x[1]))
53
+ return false;
54
+ const y = span.y;
55
+ if (y !== undefined && !(value >= y[0] && value < y[1]))
56
+ return false;
57
+ const rows = span.rows;
58
+ if (rows !== undefined) {
59
+ if (label === undefined)
60
+ return false;
61
+ let found = false;
62
+ for (let i = 0; i < rows.length; i += 1) {
63
+ if (rows[i] === label) {
64
+ found = true;
65
+ break;
66
+ }
67
+ }
68
+ if (!found)
69
+ return false;
70
+ }
71
+ return true;
72
+ }
73
+ /**
74
+ * Does **any** of `spans` contain the mark with these channels? The set form of
75
+ * {@link spanContainsPoint}, for the draw loops — `spans` is the layer's
76
+ * already-`id`-narrowed list (see {@link spansForLayer}), so this is pure
77
+ * channel tests. Linear over the spans for the reason `barMatchesAny` records
78
+ * about mark sets: a selection is a handful of entries, and the common cases
79
+ * (0 or 1) short-circuit.
80
+ */
81
+ export function spanMatchesAny(spans, key, value, label) {
82
+ for (let i = 0; i < spans.length; i += 1) {
83
+ if (spanContainsPoint(spans[i], key, value, label))
84
+ return true;
85
+ }
86
+ return false;
87
+ }
88
+ /**
89
+ * Narrow the container's span set to one layer — the span analog of the
90
+ * per-layer mark narrowing every chart component already does (`keysOf`,
91
+ * `marksOf`): drop the spans naming other layers, and — when the layer's marks
92
+ * all share **one** label (`label` given: a single-series bar, scatter or box,
93
+ * whose `SelectInfo.label` is the series label) — resolve the `rows` channel
94
+ * here, once, instead of per mark: a row set that excludes the constant label
95
+ * can never match and is dropped; one that includes it always matches and is
96
+ * stripped. Layers whose label varies per mark (a stack's groups, a heat map's
97
+ * rows) pass no `label` and keep `rows` for the draw to test per mark.
98
+ *
99
+ * Returns {@link NO_SPANS} when nothing survives, so the resting case keeps a
100
+ * stable identity (no re-registered layer, no repaint, when some *other*
101
+ * layer's spans change).
102
+ */
103
+ export function spansForLayer(spans, id, label) {
104
+ if (id === undefined || spans.length === 0)
105
+ return NO_SPANS;
106
+ let out = null;
107
+ for (let i = 0; i < spans.length; i += 1) {
108
+ const s = spans[i];
109
+ if (s.id !== id)
110
+ continue;
111
+ let keep = s;
112
+ if (label !== undefined && s.rows !== undefined) {
113
+ let found = false;
114
+ for (let r = 0; r < s.rows.length; r += 1) {
115
+ if (s.rows[r] === label) {
116
+ found = true;
117
+ break;
118
+ }
119
+ }
120
+ if (!found)
121
+ continue; // can never match this layer's constant label
122
+ // Always satisfied — strip it so the draw never re-tests a constant.
123
+ keep =
124
+ s.y !== undefined
125
+ ? { kind: 'span', id: s.id, x: s.x, y: s.y }
126
+ : { kind: 'span', id: s.id, x: s.x };
127
+ }
128
+ (out ??= []).push(keep);
129
+ }
130
+ return out ?? NO_SPANS;
131
+ }
132
+ /**
133
+ * **Are these the same mark?** The full mark identity, matching the
134
+ * container's own hover dedup: same layer `id`, same per-mark handle — the
135
+ * stable `mark` when **both** sides carry one, else the sample `key` (the
136
+ * `barMatches` fallback rule) — and same `label`, which on a grouped layer
137
+ * (stack segment, heat cell) is the half of the identity that separates two
138
+ * marks sharing a bin. `NaN` keys never match (`NaN !== NaN`), so a
139
+ * series-scoped legend entry names no mark, deliberately.
140
+ *
141
+ * Exported as the companion to {@link selectionContains}, whose doc has
142
+ * always told a consumer to write `remove(cur, hit)` without giving them
143
+ * anything to write it with. **`key` alone is not identity**, and reaching
144
+ * for it is the natural mistake: it is right for a bar, a box and a candle,
145
+ * and on a stack or a heat map it silently takes out every mark in the bin.
146
+ * `<MultiSelector>`'s own demote-on-edit stories made exactly that error, in
147
+ * three places, in the file that is supposed to be the worked example —
148
+ * which is why this is a library export and not a docs note.
149
+ */
150
+ export function sameMark(a, b) {
151
+ if (a.id !== b.id)
152
+ return false;
153
+ if (a.mark !== undefined && b.mark !== undefined) {
154
+ if (a.mark !== b.mark)
155
+ return false;
156
+ }
157
+ else if (a.key !== b.key) {
158
+ return false;
159
+ }
160
+ return a.label === b.label;
161
+ }
162
+ /**
163
+ * Is `hit` inside the selection — named by a mark entry, or covered by a span
164
+ * (interaction RFC A5.2)? **The same predicate the layers run**, exported so a
165
+ * consumer implementing click-policy over a mixed selection (toggle a mark out,
166
+ * ⌘-click-add next to a swept span) never re-implements the interval test in
167
+ * axis units — the exact friction `selection.md` A4.2's marks currency exists
168
+ * to eliminate.
169
+ *
170
+ * ```tsx
171
+ * onSelect={(hit, mods) =>
172
+ * setSelected((cur) =>
173
+ * hit === null ? []
174
+ * : mods?.additive
175
+ * ? selectionContains(cur, hit)
176
+ * ? cur.filter((e) => isSpanSelection(e) || !sameMark(e, hit))
177
+ * : [...cur, hit]
178
+ * : [hit],
179
+ * )
180
+ * }
181
+ * ```
182
+ *
183
+ * Span entries use {@link SpanSelection}'s containment rule (half-open `x`/`y`
184
+ * intervals on the hit's `key`/`value`, `rows` membership on its `label`);
185
+ * mark entries use the full mark identity (`id`, `mark`-or-`key`, `label`).
186
+ * Entries naming another layer's `id` never match. O(|sel|) with O(1) per
187
+ * entry, spans included.
188
+ */
189
+ export function selectionContains(sel, hit) {
190
+ for (let i = 0; i < sel.length; i += 1) {
191
+ const entry = sel[i];
192
+ if (isSpanSelection(entry)) {
193
+ if (entry.id === hit.id &&
194
+ spanContainsPoint(entry, hit.key, hit.value, hit.label))
195
+ return true;
196
+ }
197
+ else if (sameMark(entry, hit)) {
198
+ return true;
199
+ }
200
+ }
201
+ return false;
202
+ }
203
+ //# sourceMappingURL=span.js.map
@@ -0,0 +1,154 @@
1
+ import type { SelectInfo, SweepSession } from './context.js';
2
+ /**
3
+ * The 1-D **sweep session** — `<MultiSelector>`'s per-drag range query over a
4
+ * layer's interval marks (interaction RFC A7.6/A7.7).
5
+ *
6
+ * A layer's marks are sorted and non-overlapping on the key axis by
7
+ * construction (bars/bins are bucketed series), so the marks covered by a swept
8
+ * window are one **contiguous index run `[lo, hi)`** — the 1-D analog of the
9
+ * heat map's "four integers" session state. Each {@link SweepSession.update} is
10
+ * two binary searches (O(log N)); the covered run is compared against the last
11
+ * one, so an unchanged frame costs nothing further and the materialised
12
+ * preview refreshes **only when the covered set changed** (A1.4's
13
+ * frame-coalesced, delta-gated preview). {@link SweepSession.hits} caches that
14
+ * materialisation, which is what makes A5.2's "the hits are free at commit
15
+ * time" true here: release reads the same array the preview lit, it never runs
16
+ * a fresh range query.
17
+ *
18
+ * **Capture is by intersection, the span is snapped outward** (the A7.6 edge
19
+ * rule): a mark `[begin, end)` is covered when it intersects the half-open
20
+ * window `[x0, x1)` — `begin < x1 && end > x0` — and
21
+ * {@link SweepSession.extent} reports `[begin(first), end(last))` of the
22
+ * covered run, so `selectionContains`' half-open key test reproduces exactly
23
+ * the captured set (contiguous neighbours fall out on the open side).
24
+ *
25
+ * **Nothing persists** (A7.7): a session is allocated on the pointer-down that
26
+ * survives `DRAG_SLOP` under a mounted `<MultiSelector>` and dropped at
27
+ * release. It snapshots the layer's arrays at press — a mid-drag data update
28
+ * lands on the next gesture, not this one. Consumers who never sweep pay
29
+ * literally zero: no index, no memory, no draw-path branch.
30
+ *
31
+ * Pure and DOM-free, so it unit-tests like `select.ts` does.
32
+ */
33
+ /** First index in sorted `xs[0..length)` with `xs[i] > v` (upper bound). */
34
+ export declare function firstAbove(xs: ArrayLike<number>, length: number, v: number): number;
35
+ /** First index in sorted `xs[0..length)` with `xs[i] >= v` (lower bound). */
36
+ export declare function firstAtOrAbove(xs: ArrayLike<number>, length: number, v: number): number;
37
+ /**
38
+ * Build a {@link SweepSession} over one layer's sorted, non-overlapping
39
+ * interval marks. The layer supplies its identity and two closures:
40
+ *
41
+ * - `selectable(i)` — whether mark `i` can own membership at all (a gap bar, an
42
+ * all-gap bin). The covered run's **edges** are trimmed to selectable marks so
43
+ * the committed span never claims territory whose end marks hold nothing;
44
+ * interior gaps stay inside the extent and simply own no membership (A7.6's
45
+ * "holes own no membership").
46
+ * - `materialize(lo, hi)` — the covered marks as the same {@link SelectInfo}s
47
+ * the layer's `hitTest` would report (skipping unselectable marks itself), so
48
+ * a swept mark and a clicked mark are indistinguishable downstream.
49
+ */
50
+ export declare function sweep1D(opts: {
51
+ readonly id: string;
52
+ /** Mark key-axis begins, ascending. */
53
+ readonly begin: ArrayLike<number>;
54
+ /** Mark key-axis ends, ascending (marks are non-overlapping). */
55
+ readonly end: ArrayLike<number>;
56
+ readonly length: number;
57
+ selectable(i: number): boolean;
58
+ materialize(lo: number, hi: number): readonly SelectInfo[];
59
+ }): SweepSession;
60
+ /**
61
+ * A **2-D** sweep session — the rect gesture, for layers whose marks do not
62
+ * reduce to a run of columns ([PND-INTERACT2D], RFC A7.6/A7.7).
63
+ *
64
+ * The x half is {@link sweep1D}'s exactly: two binary-search probes over the
65
+ * sorted, non-overlapping key spans. A **point** layer passes `begin === end`
66
+ * (a position has no span either side of it), which makes the same cut mean
67
+ * "keys within the window". The y half is the layer's own business — it
68
+ * arrives as a window and the layer's `materialize` applies it, because a
69
+ * scatter filters a continuous value while a heat map picks whole row slots.
70
+ *
71
+ * **No spatial index, deliberately** (Q14): the x cut is `O(log N)` and the y
72
+ * filter is a scan of that run, so nothing persists outside a drag. The
73
+ * delta-gate below is what keeps that affordable — a pointer move that changes
74
+ * neither the run nor the y window re-materialises nothing, which is the
75
+ * lesson A8.1 cost 6.2 s/frame to learn on the 1-D preview.
76
+ */
77
+ export declare function sweep2D(opts: {
78
+ readonly id: string;
79
+ /** Key-axis begins, ascending. Equal to `end` for a point layer. */
80
+ readonly begin: ArrayLike<number>;
81
+ /** Key-axis ends, ascending. Equal to `begin` for a point layer. */
82
+ readonly end: ArrayLike<number>;
83
+ readonly length: number;
84
+ /**
85
+ * Where the committed span's `x` interval comes from. (Not to be confused
86
+ * with `RowLayer.xExtent`, which is the layer's whole key range.)
87
+ *
88
+ * - `'bins'` — the marks **tile** the key axis, so the span snaps outward to
89
+ * the covered bins' own edges, exactly as 1-D (a heat map).
90
+ * - `'drag'` — the marks are isolated positions with no interval either side
91
+ * to snap to, so the drag's own half-open window is the span (a scatter).
92
+ * Deriving `[first.key, last.key]` from the hits looks tighter and is
93
+ * wrong: `SpanSelection`'s `x` test is half-open, so that span excludes
94
+ * the very last point the drag captured. The drag window is also what the
95
+ * `y` channel reports, so a point layer describes both of its dimensions
96
+ * the same way.
97
+ */
98
+ readonly spanFrom: 'bins' | 'drag';
99
+ /** The marks in key-run `[lo, hi)` whose y falls in the **half-open**
100
+ * `[y0, y1)` — the same rule `SpanSelection.y` tests, so the capture and
101
+ * the committed descriptor agree on the boundary. */
102
+ materialize(lo: number, hi: number, y0: number, y1: number): readonly SelectInfo[];
103
+ /**
104
+ * The cut's **snapped rect in axis units** — `x` in key units, `y` in the
105
+ * layer's own y-axis units — or omitted when the layer's cut is free.
106
+ *
107
+ * This is for the BRUSH, not for the commit: a layer that snaps should draw
108
+ * the rect it is actually going to take, not the raw pointer rectangle,
109
+ * or the preview promises a different set than the release delivers. A
110
+ * scatter omits it and keeps the pointer rect, which is honest there
111
+ * because a free cut takes exactly what the pointer enclosed.
112
+ */
113
+ snap?(lo: number, hi: number, y0: number, y1: number): {
114
+ readonly x: readonly [number, number];
115
+ readonly y: readonly [number, number];
116
+ } | null;
117
+ /** The captured set's second-dimension channels — see
118
+ * {@link SweepSession.extent2D}. */
119
+ channels(hits: readonly SelectInfo[], y0: number, y1: number): {
120
+ readonly y?: readonly [number, number];
121
+ readonly rows?: readonly string[];
122
+ } | null;
123
+ }): SweepSession;
124
+ /**
125
+ * A **span-only** sweep session — for a layer that has a range but **no marks**
126
+ * ([PND-TRACESEL]): a continuous trace.
127
+ *
128
+ * The third shape beside {@link sweep1D} and {@link sweep2D}, and the smallest:
129
+ * `hits()` is always empty and `extent()` is the drag's own window. That is not
130
+ * a stub, it is the answer. A `<LineChart>`'s samples are **not marks** — they
131
+ * are usually undrawn, and at any real density there are several per pixel — so
132
+ * "the samples you swept" is a set the user never expressed. Reporting the
133
+ * range they *did* express, and nothing else, is the honest cut.
134
+ *
135
+ * It is also the cheap one, which is the same decision viewed from the other
136
+ * side. Materialising the covered samples would mint an array per changed frame
137
+ * over a series that may hold a million points — the **A8.1** shape this whole
138
+ * family of sessions is built to avoid. Here there is nothing to materialise at
139
+ * all: no index, no scan, no allocation, and `NO_HITS` is a shared constant so
140
+ * even the empty case never mints an array. A consumer who wants the samples
141
+ * has the span and their own series, and slicing it is one call in pond.
142
+ *
143
+ * **The window is not snapped**, because there is nothing to snap to: a trace
144
+ * tiles no bins. `Layers` still bucket-snaps the drag through the shared
145
+ * `cursorBuckets` when a sibling layer publishes `binIntervals`, which is
146
+ * deliberate — a line over a histogram should cut on the histogram's edges.
147
+ */
148
+ export declare function sweepSpan(opts: {
149
+ readonly id: string;
150
+ /** The layer's whole key range, so a drag past the data does not claim
151
+ * territory the trace never covered. Omit for an unbounded cut. */
152
+ readonly bounds?: readonly [number, number];
153
+ }): SweepSession;
154
+ //# sourceMappingURL=sweep.d.ts.map
package/dist/sweep.js ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * The 1-D **sweep session** — `<MultiSelector>`'s per-drag range query over a
3
+ * layer's interval marks (interaction RFC A7.6/A7.7).
4
+ *
5
+ * A layer's marks are sorted and non-overlapping on the key axis by
6
+ * construction (bars/bins are bucketed series), so the marks covered by a swept
7
+ * window are one **contiguous index run `[lo, hi)`** — the 1-D analog of the
8
+ * heat map's "four integers" session state. Each {@link SweepSession.update} is
9
+ * two binary searches (O(log N)); the covered run is compared against the last
10
+ * one, so an unchanged frame costs nothing further and the materialised
11
+ * preview refreshes **only when the covered set changed** (A1.4's
12
+ * frame-coalesced, delta-gated preview). {@link SweepSession.hits} caches that
13
+ * materialisation, which is what makes A5.2's "the hits are free at commit
14
+ * time" true here: release reads the same array the preview lit, it never runs
15
+ * a fresh range query.
16
+ *
17
+ * **Capture is by intersection, the span is snapped outward** (the A7.6 edge
18
+ * rule): a mark `[begin, end)` is covered when it intersects the half-open
19
+ * window `[x0, x1)` — `begin < x1 && end > x0` — and
20
+ * {@link SweepSession.extent} reports `[begin(first), end(last))` of the
21
+ * covered run, so `selectionContains`' half-open key test reproduces exactly
22
+ * the captured set (contiguous neighbours fall out on the open side).
23
+ *
24
+ * **Nothing persists** (A7.7): a session is allocated on the pointer-down that
25
+ * survives `DRAG_SLOP` under a mounted `<MultiSelector>` and dropped at
26
+ * release. It snapshots the layer's arrays at press — a mid-drag data update
27
+ * lands on the next gesture, not this one. Consumers who never sweep pay
28
+ * literally zero: no index, no memory, no draw-path branch.
29
+ *
30
+ * Pure and DOM-free, so it unit-tests like `select.ts` does.
31
+ */
32
+ /** First index in sorted `xs[0..length)` with `xs[i] > v` (upper bound). */
33
+ export function firstAbove(xs, length, v) {
34
+ let lo = 0;
35
+ let hi = length;
36
+ while (lo < hi) {
37
+ const mid = (lo + hi) >> 1;
38
+ if (xs[mid] > v)
39
+ hi = mid;
40
+ else
41
+ lo = mid + 1;
42
+ }
43
+ return lo;
44
+ }
45
+ /** First index in sorted `xs[0..length)` with `xs[i] >= v` (lower bound). */
46
+ export function firstAtOrAbove(xs, length, v) {
47
+ let lo = 0;
48
+ let hi = length;
49
+ while (lo < hi) {
50
+ const mid = (lo + hi) >> 1;
51
+ if (xs[mid] >= v)
52
+ hi = mid;
53
+ else
54
+ lo = mid + 1;
55
+ }
56
+ return lo;
57
+ }
58
+ /** Stable "nothing covered" identity, so an empty preview never mints arrays. */
59
+ const NO_HITS = [];
60
+ /**
61
+ * Build a {@link SweepSession} over one layer's sorted, non-overlapping
62
+ * interval marks. The layer supplies its identity and two closures:
63
+ *
64
+ * - `selectable(i)` — whether mark `i` can own membership at all (a gap bar, an
65
+ * all-gap bin). The covered run's **edges** are trimmed to selectable marks so
66
+ * the committed span never claims territory whose end marks hold nothing;
67
+ * interior gaps stay inside the extent and simply own no membership (A7.6's
68
+ * "holes own no membership").
69
+ * - `materialize(lo, hi)` — the covered marks as the same {@link SelectInfo}s
70
+ * the layer's `hitTest` would report (skipping unselectable marks itself), so
71
+ * a swept mark and a clicked mark are indistinguishable downstream.
72
+ */
73
+ export function sweep1D(opts) {
74
+ const { id, begin, end, length, selectable, materialize } = opts;
75
+ // The covered run [curLo, curHi) — empty to start (a press is not a sweep).
76
+ let curLo = 0;
77
+ let curHi = 0;
78
+ let cache = NO_HITS;
79
+ let dirty = false;
80
+ return {
81
+ id,
82
+ update(x0, x1) {
83
+ // Intersection cut: first mark ending past x0, first mark beginning at or
84
+ // past x1. Two O(log N) probes; empty/reversed windows yield an empty run.
85
+ let lo = firstAbove(end, length, x0);
86
+ let hi = firstAtOrAbove(begin, length, x1);
87
+ if (hi < lo)
88
+ hi = lo;
89
+ // Trim the run's edges to selectable marks (the span must snap outward
90
+ // to marks that own membership, not to gaps).
91
+ while (lo < hi && !selectable(lo))
92
+ lo += 1;
93
+ while (hi > lo && !selectable(hi - 1))
94
+ hi -= 1;
95
+ if (lo === curLo && hi === curHi)
96
+ return false;
97
+ curLo = lo;
98
+ curHi = hi;
99
+ dirty = true;
100
+ return true;
101
+ },
102
+ hits() {
103
+ if (dirty) {
104
+ cache = curHi > curLo ? materialize(curLo, curHi) : NO_HITS;
105
+ dirty = false;
106
+ }
107
+ return cache;
108
+ },
109
+ extent() {
110
+ return curHi > curLo ? [begin[curLo], end[curHi - 1]] : null;
111
+ },
112
+ };
113
+ }
114
+ /**
115
+ * A **2-D** sweep session — the rect gesture, for layers whose marks do not
116
+ * reduce to a run of columns ([PND-INTERACT2D], RFC A7.6/A7.7).
117
+ *
118
+ * The x half is {@link sweep1D}'s exactly: two binary-search probes over the
119
+ * sorted, non-overlapping key spans. A **point** layer passes `begin === end`
120
+ * (a position has no span either side of it), which makes the same cut mean
121
+ * "keys within the window". The y half is the layer's own business — it
122
+ * arrives as a window and the layer's `materialize` applies it, because a
123
+ * scatter filters a continuous value while a heat map picks whole row slots.
124
+ *
125
+ * **No spatial index, deliberately** (Q14): the x cut is `O(log N)` and the y
126
+ * filter is a scan of that run, so nothing persists outside a drag. The
127
+ * delta-gate below is what keeps that affordable — a pointer move that changes
128
+ * neither the run nor the y window re-materialises nothing, which is the
129
+ * lesson A8.1 cost 6.2 s/frame to learn on the 1-D preview.
130
+ */
131
+ export function sweep2D(opts) {
132
+ const { id, begin, end, length, spanFrom, snap, materialize, channels } = opts;
133
+ let curLo = 0;
134
+ let curHi = 0;
135
+ // The y window is part of the gate: moving the pointer vertically changes
136
+ // the covered set without moving the x run at all.
137
+ let curY0 = 0;
138
+ let curY1 = 0;
139
+ // The drag's raw x window, kept for `spanFrom: 'drag'`.
140
+ let curX0 = 0;
141
+ let curX1 = 0;
142
+ let cache = NO_HITS;
143
+ let dirty = false;
144
+ return {
145
+ id,
146
+ twoD: true,
147
+ update(x0, x1, y0 = 0, y1 = 0) {
148
+ let lo = firstAbove(end, length, x0);
149
+ let hi = firstAtOrAbove(begin, length, x1);
150
+ if (hi < lo)
151
+ hi = lo;
152
+ // A point layer's `end === begin`, so a mark sitting exactly on `x0` is
153
+ // excluded by `firstAbove`. Pull it back in: the press pixel is inside
154
+ // the rect the user is drawing, not outside it.
155
+ while (lo > 0 && begin[lo - 1] === x0)
156
+ lo -= 1;
157
+ curX0 = x0;
158
+ curX1 = x1;
159
+ let [ylo, yhi] = y0 <= y1 ? [y0, y1] : [y1, y0];
160
+ // **Gate on the SNAPPED window.** A snapping layer's covered set only
161
+ // changes when the pointer crosses a row edge, so comparing raw axis
162
+ // units re-materialises the whole set on every sub-cell pixel of
163
+ // vertical movement — the delta gate defeating itself, and precisely
164
+ // the A8.1 shape this gate exists to prevent. Idempotent for the
165
+ // layers that snap (a row run snapped twice is the same run), and
166
+ // inert for the ones that do not.
167
+ const snapped = snap?.(lo, hi, ylo, yhi) ?? null;
168
+ if (snapped !== null) {
169
+ ylo = snapped.y[0];
170
+ yhi = snapped.y[1];
171
+ }
172
+ if (lo === curLo && hi === curHi && ylo === curY0 && yhi === curY1) {
173
+ return false;
174
+ }
175
+ curLo = lo;
176
+ curHi = hi;
177
+ curY0 = ylo;
178
+ curY1 = yhi;
179
+ dirty = true;
180
+ return true;
181
+ },
182
+ hits() {
183
+ if (dirty) {
184
+ cache =
185
+ curHi > curLo ? materialize(curLo, curHi, curY0, curY1) : NO_HITS;
186
+ dirty = false;
187
+ }
188
+ return cache;
189
+ },
190
+ extent() {
191
+ const hs = this.hits();
192
+ if (hs.length === 0)
193
+ return null;
194
+ // A point layer reports the drag's own window — see `spanFrom`.
195
+ // Snapping to the hits would give `[first.key, last.key]`, which the
196
+ // half-open test then reads as excluding the last point captured.
197
+ if (spanFrom === 'drag')
198
+ return [curX0, curX1];
199
+ // Snapped outward exactly as 1-D — but over the marks the **y filter
200
+ // kept**, not the whole x run. A column whose cells all fell outside the
201
+ // rect must not widen the span, or replaying that span would re-select
202
+ // marks the drag never covered.
203
+ //
204
+ // Every mark's `key` IS its `begin` on both 2-D layers, and `materialize`
205
+ // walks the run in index order, so the surviving edges are the first and
206
+ // last hit keys — the right edge found with one `O(log N)` probe rather
207
+ // than a scan.
208
+ const lo = hs[0].key;
209
+ const last = hs[hs.length - 1].key;
210
+ const i = firstAtOrAbove(begin, length, last);
211
+ return [lo, i < length && begin[i] === last ? end[i] : last];
212
+ },
213
+ snappedRect() {
214
+ return curHi > curLo
215
+ ? (snap?.(curLo, curHi, curY0, curY1) ?? null)
216
+ : null;
217
+ },
218
+ extent2D() {
219
+ const hs = this.hits();
220
+ return hs.length === 0 ? null : channels(hs, curY0, curY1);
221
+ },
222
+ };
223
+ }
224
+ /**
225
+ * A **span-only** sweep session — for a layer that has a range but **no marks**
226
+ * ([PND-TRACESEL]): a continuous trace.
227
+ *
228
+ * The third shape beside {@link sweep1D} and {@link sweep2D}, and the smallest:
229
+ * `hits()` is always empty and `extent()` is the drag's own window. That is not
230
+ * a stub, it is the answer. A `<LineChart>`'s samples are **not marks** — they
231
+ * are usually undrawn, and at any real density there are several per pixel — so
232
+ * "the samples you swept" is a set the user never expressed. Reporting the
233
+ * range they *did* express, and nothing else, is the honest cut.
234
+ *
235
+ * It is also the cheap one, which is the same decision viewed from the other
236
+ * side. Materialising the covered samples would mint an array per changed frame
237
+ * over a series that may hold a million points — the **A8.1** shape this whole
238
+ * family of sessions is built to avoid. Here there is nothing to materialise at
239
+ * all: no index, no scan, no allocation, and `NO_HITS` is a shared constant so
240
+ * even the empty case never mints an array. A consumer who wants the samples
241
+ * has the span and their own series, and slicing it is one call in pond.
242
+ *
243
+ * **The window is not snapped**, because there is nothing to snap to: a trace
244
+ * tiles no bins. `Layers` still bucket-snaps the drag through the shared
245
+ * `cursorBuckets` when a sibling layer publishes `binIntervals`, which is
246
+ * deliberate — a line over a histogram should cut on the histogram's edges.
247
+ */
248
+ export function sweepSpan(opts) {
249
+ const { id, bounds } = opts;
250
+ let lo = 0;
251
+ let hi = 0;
252
+ let live = false;
253
+ return {
254
+ id,
255
+ spanOnly: true,
256
+ update(x0, x1) {
257
+ let a = Math.min(x0, x1);
258
+ let b = Math.max(x0, x1);
259
+ if (bounds !== undefined) {
260
+ a = Math.max(a, bounds[0]);
261
+ b = Math.min(b, bounds[1]);
262
+ }
263
+ // An empty or fully-clipped window covers nothing — reported as "no
264
+ // extent" rather than as a zero-width span, so a release outside the
265
+ // data reads as the swept-empty deselect every other layer gives.
266
+ const next = b > a;
267
+ if (next === live && a === lo && b === hi)
268
+ return false;
269
+ lo = a;
270
+ hi = b;
271
+ live = next;
272
+ return true;
273
+ },
274
+ hits() {
275
+ return NO_HITS;
276
+ },
277
+ extent() {
278
+ return live ? [lo, hi] : null;
279
+ },
280
+ };
281
+ }
282
+ //# sourceMappingURL=sweep.js.map