@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.
- package/CHANGELOG.md +1070 -1
- package/dist/AreaChart.d.ts +12 -1
- package/dist/AreaChart.js +131 -13
- package/dist/BarChart.js +184 -30
- package/dist/BarList.d.ts +85 -5
- package/dist/BarList.js +25 -4
- package/dist/BoxList.d.ts +70 -3
- package/dist/BoxList.js +21 -7
- package/dist/BoxPlot.d.ts +2 -1
- package/dist/BoxPlot.js +101 -9
- package/dist/Candlestick.d.ts +13 -1
- package/dist/Candlestick.js +89 -3
- package/dist/ChartContainer.d.ts +36 -48
- package/dist/ChartContainer.js +465 -59
- package/dist/ChartRow.d.ts +9 -2
- package/dist/ChartRow.js +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/area.d.ts +34 -1
- package/dist/area.js +88 -1
- package/dist/bars.d.ts +57 -3
- package/dist/bars.js +237 -26
- 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 +859 -33
- 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 +11 -2
- package/dist/index.js +22 -0
- package/dist/line.d.ts +137 -0
- package/dist/line.js +328 -0
- package/dist/ohlc.d.ts +16 -1
- package/dist/ohlc.js +93 -4
- package/dist/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 +456 -5
- package/dist/theme.js +217 -41
- package/dist/tracker.d.ts +6 -0
- package/dist/tracker.js +6 -0
- package/dist/tradingAxis.fixture.d.ts +78 -0
- package/dist/tradingAxis.fixture.js +215 -0
- package/dist/useChartLegend.js +18 -3
- package/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
|
package/dist/sweep.d.ts
ADDED
|
@@ -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
|