@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/ohlc.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { barSpanPx } from './range.js';
|
|
2
2
|
import { visibleSpanRange } from './culling.js';
|
|
3
3
|
import { decimateOhlc } from './decimate.js';
|
|
4
|
+
import { spanMatchesAny } from './span.js';
|
|
5
|
+
const NO_KEYS = [];
|
|
6
|
+
const NO_SPANS = [];
|
|
7
|
+
/** Linear scan — a selected/hovered set is a handful, not a collection. */
|
|
8
|
+
function includesKey(keys, key) {
|
|
9
|
+
for (let i = 0; i < keys.length; i += 1) {
|
|
10
|
+
if (keys[i] === key)
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
4
15
|
/** Default body width as a fraction of the candle slot when the style omits one. */
|
|
5
16
|
const DEFAULT_BODY_WIDTH = 0.8;
|
|
6
17
|
/** Minimum body height in px so a doji (open === close) still shows a mark. */
|
|
@@ -90,7 +101,37 @@ export function resolveCandleStyle(style, open, close, colorBy) {
|
|
|
90
101
|
* O(N) over the keys, a fixed number of path ops each — no per-key allocation
|
|
91
102
|
* beyond the `barSpanPx` tuple.
|
|
92
103
|
*/
|
|
93
|
-
|
|
104
|
+
/**
|
|
105
|
+
* The candle whose **slot** contains `(px, py)` — the same rect-containment
|
|
106
|
+
* `boxAt` does, over the candle's full `[x0, x1] × [high, low]` extent.
|
|
107
|
+
*
|
|
108
|
+
* Deliberately the slot and not the ink. A candle's body can be a doji a
|
|
109
|
+
* pixel tall and its wick is a hairline; requiring the pointer to land on
|
|
110
|
+
* drawn pixels would make most candles unclickable. (That the box layer makes
|
|
111
|
+
* the same choice *without* saying so is [PND-BOXHIT].)
|
|
112
|
+
*/
|
|
113
|
+
export function ohlcAt(ohlc, px, py, xScale, yScale, gapPx, minWidthPx) {
|
|
114
|
+
for (let i = 0; i < ohlc.length; i += 1) {
|
|
115
|
+
if (!isFiniteOhlc(ohlc, i))
|
|
116
|
+
continue;
|
|
117
|
+
const [x0, x1] = barSpanPx(ohlc.x[i], ohlc.xEnd[i], xScale, gapPx, minWidthPx);
|
|
118
|
+
if (px < x0 || px > x1)
|
|
119
|
+
continue;
|
|
120
|
+
const yHigh = yScale(ohlc.high[i]);
|
|
121
|
+
const yLow = yScale(ohlc.low[i]);
|
|
122
|
+
const top = Math.min(yHigh, yLow);
|
|
123
|
+
const bottom = Math.max(yHigh, yLow);
|
|
124
|
+
if (py < top || py > bottom)
|
|
125
|
+
continue;
|
|
126
|
+
return [i, ohlc.x[i], ohlc.close[i]];
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
export function drawCandles(ctx, ohlc, xScale, yScale, style, variant = 'candle', colorBy = 'direction', gapPx = 0, minWidthPx = 1, decimate = true,
|
|
131
|
+
/** Candle keys (each candle's `x`) currently selected / hovered, and the
|
|
132
|
+
* selection's span entries — the same three channels the bar and box draws
|
|
133
|
+
* take. Empty ⇒ a display-only candle, byte-identical to before. */
|
|
134
|
+
selectedKeys = NO_KEYS, hoveredKeys = NO_KEYS, spans = NO_SPANS) {
|
|
94
135
|
const bodyFraction = style.bodyWidth ?? DEFAULT_BODY_WIDTH;
|
|
95
136
|
const sourceCount = ohlc.length; // pre-cull, pre-decimation (for draw stats)
|
|
96
137
|
// Viewport cull first (Phase 2): the [vStart, vEnd) candles whose span overlaps
|
|
@@ -128,11 +169,47 @@ export function drawCandles(ctx, ohlc, xScale, yScale, style, variant = 'candle'
|
|
|
128
169
|
const yLow = yScale(ohlc.low[i]);
|
|
129
170
|
const yClose = yScale(close);
|
|
130
171
|
const { body, wick } = resolveCandleStyle(style, open, close, colorBy);
|
|
172
|
+
// **State without hue.** A candle's colour *is* its direction, so a
|
|
173
|
+
// selected candle keeps its own body/wick and takes an outline around the
|
|
174
|
+
// slot instead; the field recedes by opacity, and the wick — the mark's
|
|
175
|
+
// hairline — gains weight. A decimated aggregate candle carries synthetic
|
|
176
|
+
// keys no mark entry can name, so per-candle state is gated off there (as
|
|
177
|
+
// the box draw does) rather than lighting a column the selection never
|
|
178
|
+
// held.
|
|
179
|
+
const key = ohlc.x[i];
|
|
180
|
+
const isSelected = !decimated &&
|
|
181
|
+
(includesKey(selectedKeys, key) ||
|
|
182
|
+
(spans.length > 0 && spanMatchesAny(spans, key, close)));
|
|
183
|
+
const isHovered = !decimated && !isSelected && includesKey(hoveredKeys, key);
|
|
184
|
+
const dimming = style.dimmedOpacity !== undefined &&
|
|
185
|
+
!decimated &&
|
|
186
|
+
(selectedKeys.length > 0 || spans.length > 0);
|
|
187
|
+
const recede = dimming && !isSelected && !isHovered;
|
|
188
|
+
// Live = hovered or selected. Both look the same on the mark itself; what
|
|
189
|
+
// separates them is that a *selection* recedes everything else.
|
|
190
|
+
const live = isSelected || isHovered;
|
|
191
|
+
const wickW = live && style.liveWickWidth !== undefined
|
|
192
|
+
? style.liveWickWidth
|
|
193
|
+
: style.wickWidth;
|
|
194
|
+
// A live candle **grows** rather than gaining anything new: its body is
|
|
195
|
+
// stroked in its own colour, so the mark thickens by the stroke and
|
|
196
|
+
// nothing else changes. An outline around the *slot* was the first attempt
|
|
197
|
+
// and it redraws the mark's whole footprint — far too loud for a hover,
|
|
198
|
+
// and it invents a rectangle the chart otherwise never shows.
|
|
199
|
+
const grow = live && style.liveWickWidth !== undefined;
|
|
200
|
+
// Bracket only when the field recedes, so a chart with no selection emits
|
|
201
|
+
// exactly the op stream it always did.
|
|
202
|
+
const bracketed = recede;
|
|
203
|
+
if (bracketed) {
|
|
204
|
+
ctx.save();
|
|
205
|
+
if (recede)
|
|
206
|
+
ctx.globalAlpha = style.dimmedOpacity;
|
|
207
|
+
}
|
|
131
208
|
if (variant === 'bar') {
|
|
132
209
|
// OHLC bar: a high–low stem, a left tick at open, a right tick at close —
|
|
133
210
|
// all one colour (the `body` role), no filled body.
|
|
134
211
|
ctx.strokeStyle = body;
|
|
135
|
-
ctx.lineWidth =
|
|
212
|
+
ctx.lineWidth = wickW;
|
|
136
213
|
ctx.beginPath();
|
|
137
214
|
ctx.moveTo(mid, yHigh); // stem
|
|
138
215
|
ctx.lineTo(mid, yLow);
|
|
@@ -141,12 +218,16 @@ export function drawCandles(ctx, ohlc, xScale, yScale, style, variant = 'candle'
|
|
|
141
218
|
ctx.moveTo(mid, yClose); // close tick (points right)
|
|
142
219
|
ctx.lineTo(mid + bodyHalf, yClose);
|
|
143
220
|
ctx.stroke();
|
|
221
|
+
// The `bar` variant has no body to grow; its lines already thickened
|
|
222
|
+
// above, which is the whole cue there.
|
|
223
|
+
if (bracketed)
|
|
224
|
+
ctx.restore();
|
|
144
225
|
continue;
|
|
145
226
|
}
|
|
146
227
|
// candle / hollow: the high–low wick first (so the body overlaps it), then
|
|
147
228
|
// the open→close body.
|
|
148
229
|
ctx.strokeStyle = wick;
|
|
149
|
-
ctx.lineWidth =
|
|
230
|
+
ctx.lineWidth = wickW;
|
|
150
231
|
ctx.beginPath();
|
|
151
232
|
ctx.moveTo(mid, yHigh);
|
|
152
233
|
ctx.lineTo(mid, yLow);
|
|
@@ -163,14 +244,22 @@ export function drawCandles(ctx, ohlc, xScale, yScale, style, variant = 'candle'
|
|
|
163
244
|
// so a doji's fill and its colour agree.
|
|
164
245
|
const hollow = variant === 'hollow' && close > open;
|
|
165
246
|
if (hollow) {
|
|
247
|
+
// Already an outline — `wickW` is the growth.
|
|
166
248
|
ctx.strokeStyle = body;
|
|
167
|
-
ctx.lineWidth =
|
|
249
|
+
ctx.lineWidth = wickW;
|
|
168
250
|
ctx.strokeRect(bx0, top, bodyW, h);
|
|
169
251
|
}
|
|
170
252
|
else {
|
|
171
253
|
ctx.fillStyle = body;
|
|
172
254
|
ctx.fillRect(bx0, top, bodyW, h);
|
|
255
|
+
if (grow) {
|
|
256
|
+
ctx.strokeStyle = body;
|
|
257
|
+
ctx.lineWidth = wickW;
|
|
258
|
+
ctx.strokeRect(bx0, top, bodyW, h);
|
|
259
|
+
}
|
|
173
260
|
}
|
|
261
|
+
if (bracketed)
|
|
262
|
+
ctx.restore();
|
|
174
263
|
}
|
|
175
264
|
// `drawnCount` = candle slots iterated (visible span, or the aggregate set when
|
|
176
265
|
// decimation engaged); `sourceCount` = the raw candle count it started from.
|
package/dist/scatter.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ChartSeries } from './data.js';
|
|
|
2
2
|
import type { Scale } from './line.js';
|
|
3
3
|
import type { ScatterStyle } from './theme.js';
|
|
4
4
|
import type { ResolvedEncoding } from './encoding.js';
|
|
5
|
-
import type { SelectInfo, LayerDrawStats } from './context.js';
|
|
5
|
+
import type { SelectInfo, LayerDrawStats, SpanSelection } from './context.js';
|
|
6
6
|
import { type DecimateOption } from './decimate.js';
|
|
7
7
|
/**
|
|
8
8
|
* Index of the point in `cs` **nearest** `time` by `|x − time|`, restricted to
|
|
@@ -29,11 +29,18 @@ export declare function scatterExtent(cs: ChartSeries): [number, number] | null;
|
|
|
29
29
|
* coloured by `encoding` (data-driven radius / colour) over `style`. A gap
|
|
30
30
|
* (non-finite y) draws nothing — points are discrete, there is no path to break.
|
|
31
31
|
*
|
|
32
|
-
*
|
|
33
|
-
* point's `begin` key) is restroked with the style's wider
|
|
34
|
-
* the base pass, so it lifts above its neighbours
|
|
35
|
-
*
|
|
36
|
-
*
|
|
32
|
+
* **Every selected** point (each `selected` member matching this layer's
|
|
33
|
+
* `seriesId` *and* a point's `begin` key) is restroked with the style's wider
|
|
34
|
+
* highlight ring after the base pass, so it lifts above its neighbours
|
|
35
|
+
* regardless of draw order; every **hovered** point takes the same ring at
|
|
36
|
+
* {@link HOVER_RING_ALPHA}. Matching on both key and series id is what keeps two
|
|
37
|
+
* series sharing a timestamp from both lighting up (the container's selection
|
|
38
|
+
* contract). A point in both sets reads as **selected** — the precedence
|
|
39
|
+
* `drawBars` / `drawStacks` / `drawBox` share.
|
|
40
|
+
*
|
|
41
|
+
* Both are **sets**: `selected` has been one since [PND-MULTISEL] and `hovered`
|
|
42
|
+
* since RFC A4.3, so a pinned group of points, or a drag-sweep lighting several
|
|
43
|
+
* at once, rings all of them rather than only the set's first member.
|
|
37
44
|
*
|
|
38
45
|
* Each circle is its own `beginPath`/`arc`/`fill`/`stroke`; `save`/`restore`
|
|
39
46
|
* brackets the whole pass so fill/stroke state doesn't leak into later layers.
|
|
@@ -44,16 +51,17 @@ export declare function scatterExtent(cs: ChartSeries): [number, number] | null;
|
|
|
44
51
|
* usually `(i) => cs.x[i]`.
|
|
45
52
|
* @param labelAt optional per-point text label; `undefined` ⇒ no labels drawn.
|
|
46
53
|
* @param font `theme.font` (family + size) for label text.
|
|
47
|
-
* @param selected the container's current selection (
|
|
54
|
+
* @param selected the container's current selection set (empty ⇒ none).
|
|
55
|
+
* @param hovered the container's current hover set (empty ⇒ none).
|
|
48
56
|
* @param seriesId this layer's stable series identity (its `id` prop, or
|
|
49
57
|
* `undefined` when the layer isn't selectable) — the series half
|
|
50
|
-
* of the selection match. A point lights only when
|
|
58
|
+
* of the selection match. A point lights only when a member's
|
|
51
59
|
* `id` matches, keyed to the sample by its `key`.
|
|
52
60
|
*/
|
|
53
61
|
export declare function drawScatter(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: ScatterStyle, encoding: ResolvedEncoding, keyAt: (i: number) => number, labelAt: ((i: number) => string | undefined) | undefined, font: {
|
|
54
62
|
readonly family: string;
|
|
55
63
|
readonly size: number;
|
|
56
|
-
}, selected: SelectInfo
|
|
64
|
+
}, selected: readonly SelectInfo[], hovered: readonly SelectInfo[], seriesId: string | undefined, offsetPx?: number, decimate?: DecimateOption, spans?: readonly SpanSelection[]): LayerDrawStats;
|
|
57
65
|
/**
|
|
58
66
|
* Hit-test plot-pixel `(qx, qy)` against the scatter's points — the topmost
|
|
59
67
|
* point whose circle contains the click, or `null`. "Topmost" = the
|
package/dist/scatter.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { visiblePointRange } from './culling.js';
|
|
2
|
+
import { NO_SPANS, spanMatchesAny } from './span.js';
|
|
2
3
|
import { shouldDecimateCount, decimateScatter, isOpaqueColor, } from './decimate.js';
|
|
3
4
|
/**
|
|
4
5
|
* Scatter geometry + the canvas draw — pure, like {@link drawLine} /
|
|
@@ -20,6 +21,73 @@ import { shouldDecimateCount, decimateScatter, isOpaqueColor, } from './decimate
|
|
|
20
21
|
function isPoint(cs, i) {
|
|
21
22
|
return Number.isFinite(cs.y[i]);
|
|
22
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* The alpha a **hovered** point's ring draws at, against the selected ring's
|
|
26
|
+
* full strength. Same two-step signal `drawBox` uses, and for the same reason:
|
|
27
|
+
* a {@link ScatterStyle} carries one highlight ring (`selectedOutline` /
|
|
28
|
+
* `selectedWidth`) and no hover token, so hover reads as a fainter version of
|
|
29
|
+
* the same ring rather than inventing a theme value the library would have to
|
|
30
|
+
* pick a colour for.
|
|
31
|
+
*/
|
|
32
|
+
const HOVER_RING_ALPHA = 0.5;
|
|
33
|
+
/**
|
|
34
|
+
* Does **any** member of `set` name this series at all? A cheap gate so a draw
|
|
35
|
+
* with a selection belonging to some other layer — the common case on a
|
|
36
|
+
* multi-layer row — never pays the per-point key lookup.
|
|
37
|
+
*/
|
|
38
|
+
function namesSeries(set, seriesId) {
|
|
39
|
+
if (seriesId === undefined)
|
|
40
|
+
return false; // a no-id layer is never selectable
|
|
41
|
+
for (let i = 0; i < set.length; i += 1) {
|
|
42
|
+
if (set[i].id === seriesId)
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Does any member of `set` identify the point of `seriesId` keyed `key`? The set
|
|
49
|
+
* form of the single `selected.key === keyAt(i)` test this draw used to make
|
|
50
|
+
* ([PND-MULTISEL] / RFC A4.3).
|
|
51
|
+
*
|
|
52
|
+
* A point's identity within its series **is** its key (a scatter reports no
|
|
53
|
+
* `SelectInfo.mark`), so `(id, key)` is the whole match. Linear over the set for
|
|
54
|
+
* the reason `barMatchesAny` records: a selection is a handful of marks a person
|
|
55
|
+
* clicked, and a `Set` per draw would cost more than it saves.
|
|
56
|
+
*/
|
|
57
|
+
function marksPoint(set, seriesId, key) {
|
|
58
|
+
for (let i = 0; i < set.length; i += 1) {
|
|
59
|
+
const m = set[i];
|
|
60
|
+
if (m.id === seriesId && m.key === key)
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Past this many entries a per-draw key index beats the linear scan above.
|
|
67
|
+
* Mirrors `bars.ts`'s threshold, and exists for the same reason: `marksPoint`
|
|
68
|
+
* is written for "a handful of marks a person clicked", and a **sweep
|
|
69
|
+
* preview is not that**. A rect drag lights its whole covered region through
|
|
70
|
+
* the plural `hovered`, so the scan becomes O(V · |hovered|) — the A8.1 shape
|
|
71
|
+
* that measured **4.0 s per frame** here at 100k points with 50k covered,
|
|
72
|
+
* against the 1-D band's 6.2 s that motivated the bar index in the first
|
|
73
|
+
* place. Small enough that a sweep always indexes; big enough that a clicked
|
|
74
|
+
* handful never pays the Set build.
|
|
75
|
+
*/
|
|
76
|
+
const MARK_INDEX_THRESHOLD = 16;
|
|
77
|
+
/**
|
|
78
|
+
* {@link marksPoint} in set form. A point's identity within its series **is**
|
|
79
|
+
* its key (a scatter reports no `SelectInfo.mark`), so unlike the bar index
|
|
80
|
+
* this is one set, not three.
|
|
81
|
+
*/
|
|
82
|
+
function buildKeyIndex(set, seriesId) {
|
|
83
|
+
const keys = new Set();
|
|
84
|
+
for (let i = 0; i < set.length; i += 1) {
|
|
85
|
+
const m = set[i];
|
|
86
|
+
if (m.id === seriesId)
|
|
87
|
+
keys.add(m.key);
|
|
88
|
+
}
|
|
89
|
+
return keys;
|
|
90
|
+
}
|
|
23
91
|
/**
|
|
24
92
|
* Index of the point in `cs` **nearest** `time` by `|x − time|`, restricted to
|
|
25
93
|
* finite points, or `-1` if none. `cs.x` is the sorted time axis, so a binary
|
|
@@ -97,11 +165,18 @@ export function scatterExtent(cs) {
|
|
|
97
165
|
* coloured by `encoding` (data-driven radius / colour) over `style`. A gap
|
|
98
166
|
* (non-finite y) draws nothing — points are discrete, there is no path to break.
|
|
99
167
|
*
|
|
100
|
-
*
|
|
101
|
-
* point's `begin` key) is restroked with the style's wider
|
|
102
|
-
* the base pass, so it lifts above its neighbours
|
|
103
|
-
*
|
|
104
|
-
*
|
|
168
|
+
* **Every selected** point (each `selected` member matching this layer's
|
|
169
|
+
* `seriesId` *and* a point's `begin` key) is restroked with the style's wider
|
|
170
|
+
* highlight ring after the base pass, so it lifts above its neighbours
|
|
171
|
+
* regardless of draw order; every **hovered** point takes the same ring at
|
|
172
|
+
* {@link HOVER_RING_ALPHA}. Matching on both key and series id is what keeps two
|
|
173
|
+
* series sharing a timestamp from both lighting up (the container's selection
|
|
174
|
+
* contract). A point in both sets reads as **selected** — the precedence
|
|
175
|
+
* `drawBars` / `drawStacks` / `drawBox` share.
|
|
176
|
+
*
|
|
177
|
+
* Both are **sets**: `selected` has been one since [PND-MULTISEL] and `hovered`
|
|
178
|
+
* since RFC A4.3, so a pinned group of points, or a drag-sweep lighting several
|
|
179
|
+
* at once, rings all of them rather than only the set's first member.
|
|
105
180
|
*
|
|
106
181
|
* Each circle is its own `beginPath`/`arc`/`fill`/`stroke`; `save`/`restore`
|
|
107
182
|
* brackets the whole pass so fill/stroke state doesn't leak into later layers.
|
|
@@ -112,21 +187,65 @@ export function scatterExtent(cs) {
|
|
|
112
187
|
* usually `(i) => cs.x[i]`.
|
|
113
188
|
* @param labelAt optional per-point text label; `undefined` ⇒ no labels drawn.
|
|
114
189
|
* @param font `theme.font` (family + size) for label text.
|
|
115
|
-
* @param selected the container's current selection (
|
|
190
|
+
* @param selected the container's current selection set (empty ⇒ none).
|
|
191
|
+
* @param hovered the container's current hover set (empty ⇒ none).
|
|
116
192
|
* @param seriesId this layer's stable series identity (its `id` prop, or
|
|
117
193
|
* `undefined` when the layer isn't selectable) — the series half
|
|
118
|
-
* of the selection match. A point lights only when
|
|
194
|
+
* of the selection match. A point lights only when a member's
|
|
119
195
|
* `id` matches, keyed to the sample by its `key`.
|
|
120
196
|
*/
|
|
121
|
-
export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesId, offsetPx = 0, decimate = true
|
|
197
|
+
export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, hovered, seriesId, offsetPx = 0, decimate = true,
|
|
198
|
+
// Span descriptors covering this layer (interaction RFC A5.2), already
|
|
199
|
+
// narrowed to its `id` (and constant-label `rows` resolved) by the component
|
|
200
|
+
// — see `spansForLayer`. A point is selected when a mark entry names it OR a
|
|
201
|
+
// span contains it: the O(1) half-open test of its key against `x` and — the
|
|
202
|
+
// continuous 2-D case, RFC A3.3/A5.3 — its value against `y` when present.
|
|
203
|
+
// Tested at the point's data position, per A7.6: centre-in-rect, not
|
|
204
|
+
// disc-intersects-rect. Ignored on the decimated path, like the mark rings.
|
|
205
|
+
spans = NO_SPANS) {
|
|
122
206
|
ctx.save();
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
207
|
+
// Either set only lights up points of *this* series; settle that once so the
|
|
208
|
+
// point loop can skip the key lookup entirely when neither names us. A no-id
|
|
209
|
+
// (non-selectable) layer passes `undefined` and never matches (`spans` is
|
|
210
|
+
// narrowed to this layer before the call, so its gate is just "any").
|
|
211
|
+
const anySelected = namesSeries(selected, seriesId);
|
|
212
|
+
const anyHovered = namesSeries(hovered, seriesId);
|
|
213
|
+
// Index the big sets once per draw, scan the small ones — see
|
|
214
|
+
// `MARK_INDEX_THRESHOLD`. Only the live-preview path ever reaches the
|
|
215
|
+
// threshold, so a clicked selection still allocates nothing.
|
|
216
|
+
const selIx = anySelected && selected.length > MARK_INDEX_THRESHOLD
|
|
217
|
+
? buildKeyIndex(selected, seriesId)
|
|
218
|
+
: null;
|
|
219
|
+
const hovIx = anyHovered && hovered.length > MARK_INDEX_THRESHOLD
|
|
220
|
+
? buildKeyIndex(hovered, seriesId)
|
|
221
|
+
: null;
|
|
222
|
+
const anySpan = spans.length > 0;
|
|
223
|
+
// Ring geometry, deferred to a pass after the marks so a highlight is never
|
|
224
|
+
// overpainted by a neighbour drawn later. Flat `[px, py, r, …]` triples, and
|
|
225
|
+
// **allocated only on the first hit** — the resting frame (nothing selected,
|
|
226
|
+
// nothing hovered) still allocates nothing per draw, which is the property the
|
|
227
|
+
// old pair of scalars had and the reason this isn't an array of objects.
|
|
228
|
+
let selRings = null;
|
|
229
|
+
let hovRings = null;
|
|
230
|
+
// The state ladder, or `undefined` for the pre-states behaviour — in which
|
|
231
|
+
// case every branch below it is inert and the op stream is unchanged.
|
|
232
|
+
const st = style.states;
|
|
233
|
+
// The radii arrive in px against the base radius and are applied as the
|
|
234
|
+
// RATIO between them, so a data-driven `radius` encoding still grows and
|
|
235
|
+
// shrinks proportionally instead of collapsing to one size when it goes
|
|
236
|
+
// live. `style.radius` is what an unencoded point already draws at, so on
|
|
237
|
+
// the uniform path these land exactly on the token's px value.
|
|
238
|
+
const hoverK = st === undefined ? 1 : st.hoverRadius / style.radius;
|
|
239
|
+
const dimK = st === undefined ? 1 : st.dimmedRadius / style.radius;
|
|
240
|
+
// A **committed** selection is what recedes the rest of the field; a hover
|
|
241
|
+
// is not, or the whole plot would flicker under an ordinary mousemove.
|
|
242
|
+
//
|
|
243
|
+
// Note this is all-or-nothing across the base loop: every point that is not
|
|
244
|
+
// live is dimmed, and live points leave the loop entirely, so the alpha is
|
|
245
|
+
// set ONCE around the loop rather than toggled per point. Bracketed in its
|
|
246
|
+
// own `save`/`restore` (the `drawBox` ladder's discipline) so a draw with
|
|
247
|
+
// nothing selected emits exactly the op stream it always did.
|
|
248
|
+
const dimming = st !== undefined && (anySelected || anySpan);
|
|
130
249
|
// Viewport culling (Phase 2): draw only the marks in the visible x-window
|
|
131
250
|
// (+1 each side). The loop keeps the **original** index `i`, so the index-keyed
|
|
132
251
|
// accessors (`colorAt`/`radiusAt`/`keyAt`/`labelAt`) and the selection match
|
|
@@ -164,9 +283,10 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
|
|
|
164
283
|
// overlapping cluster to one representative per mark-radius cell (2D
|
|
165
284
|
// occupancy — {@link decimateScatter}). Visually lossless at that density, and
|
|
166
285
|
// O(visible). Interaction is unaffected ({@link hitTestScatter} still walks
|
|
167
|
-
// every source point); the selection
|
|
168
|
-
// this path —
|
|
169
|
-
// bar path
|
|
286
|
+
// every source point); the selection / hover rings and per-point labels are
|
|
287
|
+
// dropped on this path — all are illegible under a dense blob — matching the
|
|
288
|
+
// decimated bar path (and `drawHeat`, which suppresses its cell outlines for
|
|
289
|
+
// the same reason). Data-driven size/colour (`!encoding.uniform`) or a translucent
|
|
170
290
|
// fill (density-encoded, where overlap *should* build up) keep the full draw.
|
|
171
291
|
const visibleCount = vEnd - vStart;
|
|
172
292
|
const k = typeof decimate === 'object' && decimate.threshold !== undefined
|
|
@@ -213,6 +333,10 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
|
|
|
213
333
|
}
|
|
214
334
|
}
|
|
215
335
|
let drawn = 0;
|
|
336
|
+
if (dimming) {
|
|
337
|
+
ctx.save();
|
|
338
|
+
ctx.globalAlpha = st.dimmedOpacity;
|
|
339
|
+
}
|
|
216
340
|
for (let i = vStart; i < vEnd; i += 1) {
|
|
217
341
|
if (!isPoint(cs, i))
|
|
218
342
|
continue;
|
|
@@ -222,8 +346,37 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
|
|
|
222
346
|
const px = xScale(cs.x[i]) + offsetPx;
|
|
223
347
|
const py = yScale(cs.y[i]);
|
|
224
348
|
const r = encoding.radiusAt(i);
|
|
349
|
+
// Which state this point is in. Selection (a mark entry naming it, or a
|
|
350
|
+
// span containing it) is tested first and wins outright, so a point that
|
|
351
|
+
// is both never draws twice.
|
|
352
|
+
let live = 0; // 0 = neither, 1 = hovered, 2 = selected
|
|
353
|
+
if (anySelected || anyHovered || anySpan) {
|
|
354
|
+
const key = keyAt(i);
|
|
355
|
+
if ((anySelected &&
|
|
356
|
+
(selIx !== null
|
|
357
|
+
? selIx.has(key)
|
|
358
|
+
: marksPoint(selected, seriesId, key))) ||
|
|
359
|
+
(anySpan && spanMatchesAny(spans, key, cs.y[i]))) {
|
|
360
|
+
live = 2;
|
|
361
|
+
}
|
|
362
|
+
else if (anyHovered &&
|
|
363
|
+
(hovIx !== null ? hovIx.has(key) : marksPoint(hovered, seriesId, key))) {
|
|
364
|
+
live = 1;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// A live point is **deferred whole** under `states`, not just its ring:
|
|
368
|
+
// its fill and radius both change, so drawing the mark here and only its
|
|
369
|
+
// ring later would let a resting neighbour drawn afterwards paint over
|
|
370
|
+
// the grown body it belongs to.
|
|
371
|
+
if (live !== 0) {
|
|
372
|
+
(live === 2 ? (selRings ??= []) : (hovRings ??= [])).push(px, py, st !== undefined && live === 1 ? r * hoverK : r);
|
|
373
|
+
if (st !== undefined)
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
225
376
|
ctx.beginPath();
|
|
226
|
-
|
|
377
|
+
// Outside a non-empty selection a point shrinks as well as fading — see
|
|
378
|
+
// `dimmedRadius`; the alpha itself is set once, around the loop.
|
|
379
|
+
ctx.arc(px, py, dimming ? r * dimK : r, 0, Math.PI * 2);
|
|
227
380
|
ctx.fillStyle = encoding.colorAt(i);
|
|
228
381
|
ctx.fill();
|
|
229
382
|
if (style.outlineWidth > 0) {
|
|
@@ -231,22 +384,32 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
|
|
|
231
384
|
ctx.strokeStyle = style.outline;
|
|
232
385
|
ctx.stroke();
|
|
233
386
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
387
|
+
}
|
|
388
|
+
if (dimming)
|
|
389
|
+
ctx.restore();
|
|
390
|
+
// The live marks, after the base pass — always on top. Hover first, then
|
|
391
|
+
// selection over it, so the fainter state never paints over the committed
|
|
392
|
+
// one where two marks overlap.
|
|
393
|
+
if (hovRings !== null) {
|
|
394
|
+
ctx.save();
|
|
395
|
+
if (st !== undefined)
|
|
396
|
+
fillMarks(ctx, hovRings, st.hover, st.halo, st);
|
|
397
|
+
else {
|
|
398
|
+
ctx.globalAlpha = HOVER_RING_ALPHA;
|
|
399
|
+
ctx.lineWidth = style.selectedWidth;
|
|
400
|
+
ctx.strokeStyle = style.selectedOutline;
|
|
401
|
+
strokeRings(ctx, hovRings);
|
|
241
402
|
}
|
|
403
|
+
ctx.restore();
|
|
242
404
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
405
|
+
if (selRings !== null) {
|
|
406
|
+
if (st !== undefined)
|
|
407
|
+
fillMarks(ctx, selRings, st.selected, st.halo, st);
|
|
408
|
+
else {
|
|
409
|
+
ctx.lineWidth = style.selectedWidth;
|
|
410
|
+
ctx.strokeStyle = style.selectedOutline;
|
|
411
|
+
strokeRings(ctx, selRings);
|
|
412
|
+
}
|
|
250
413
|
}
|
|
251
414
|
// Optional per-point labels, after all marks so text isn't overpainted.
|
|
252
415
|
if (labelAt !== undefined) {
|
|
@@ -275,6 +438,31 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
|
|
|
275
438
|
}
|
|
276
439
|
/** Gap (px) between a point's edge and its label text. */
|
|
277
440
|
const LABEL_GAP = 4;
|
|
441
|
+
/** Stroke one circle per `[px, py, r]` triple of `rings`. The caller owns the
|
|
442
|
+
* stroke state (colour / width / alpha), so both highlight passes set it once
|
|
443
|
+
* rather than per ring. */
|
|
444
|
+
/** Draw one live mark per `[px, py, r]` triple — the `states` path's second
|
|
445
|
+
* pass: a filled disc in the state's colour, ringed by the halo that keeps
|
|
446
|
+
* overlapping same-coloured points countable. */
|
|
447
|
+
function fillMarks(ctx, marks, fill, halo, st) {
|
|
448
|
+
ctx.fillStyle = fill;
|
|
449
|
+
ctx.lineWidth = st.haloWidth;
|
|
450
|
+
ctx.strokeStyle = halo;
|
|
451
|
+
for (let j = 0; j < marks.length; j += 3) {
|
|
452
|
+
ctx.beginPath();
|
|
453
|
+
ctx.arc(marks[j], marks[j + 1], marks[j + 2], 0, Math.PI * 2);
|
|
454
|
+
ctx.fill();
|
|
455
|
+
if (st.haloWidth > 0)
|
|
456
|
+
ctx.stroke();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
function strokeRings(ctx, rings) {
|
|
460
|
+
for (let j = 0; j < rings.length; j += 3) {
|
|
461
|
+
ctx.beginPath();
|
|
462
|
+
ctx.arc(rings[j], rings[j + 1], rings[j + 2], 0, Math.PI * 2);
|
|
463
|
+
ctx.stroke();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
278
466
|
/**
|
|
279
467
|
* Hit-test plot-pixel `(qx, qy)` against the scatter's points — the topmost
|
|
280
468
|
* point whose circle contains the click, or `null`. "Topmost" = the
|
package/dist/select.d.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import type { LayerEntry, SelectInfo } from './context.js';
|
|
2
2
|
/**
|
|
3
|
-
* Resolve a
|
|
4
|
-
* Walks the row's layers **top-down** (reverse z-order — the topmost
|
|
5
|
-
* matching what the user sees) and returns the first `hitTest` hit.
|
|
6
|
-
* no `hitTest` (line / band / area) or no resolvable y-scale is
|
|
3
|
+
* Resolve a pointer position at plot-pixel `(px, py)` to the mark under it, or
|
|
4
|
+
* `null`. Walks the row's layers **top-down** (reverse z-order — the topmost
|
|
5
|
+
* mark wins, matching what the user sees) and returns the first `hitTest` hit.
|
|
6
|
+
* A layer with no `hitTest` (line / band / area) or no resolvable y-scale is
|
|
7
|
+
* skipped.
|
|
8
|
+
*
|
|
9
|
+
* `mode` (default `'hover'`) is forwarded to each layer's `hitTest`: hover may
|
|
10
|
+
* attribute a generous target (a bar's whole slot — the continuous-highlight
|
|
11
|
+
* model), while `'select'` — the click path — must leave genuinely empty plot
|
|
12
|
+
* space resolving to `null`, because that `null` **is** the deselect signal
|
|
13
|
+
* (RFC §7: a click on no mark is the empty-commit path). See
|
|
14
|
+
* {@link RowLayer.hitTest}.
|
|
7
15
|
*
|
|
8
16
|
* Pure, given the row's `xScale` and a per-axis y-scale lookup — so the click
|
|
9
17
|
* dispatch in `Layers` unit-tests without a DOM. (Layers passes its sorted
|
|
10
18
|
* z-stack, the shared `xScale`, and its `axisId → yScale` resolver.)
|
|
11
19
|
*/
|
|
12
|
-
export declare function resolveSelection(entries: readonly LayerEntry[], px: number, py: number, xScale: (value: number) => number, yScaleFor: (axisId: string | undefined) => ((value: number) => number) | undefined): SelectInfo | null;
|
|
20
|
+
export declare function resolveSelection(entries: readonly LayerEntry[], px: number, py: number, xScale: (value: number) => number, yScaleFor: (axisId: string | undefined) => ((value: number) => number) | undefined, mode?: 'hover' | 'select'): SelectInfo | null;
|
|
13
21
|
//# sourceMappingURL=select.d.ts.map
|
package/dist/select.js
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Resolve a
|
|
3
|
-
* Walks the row's layers **top-down** (reverse z-order — the topmost
|
|
4
|
-
* matching what the user sees) and returns the first `hitTest` hit.
|
|
5
|
-
* no `hitTest` (line / band / area) or no resolvable y-scale is
|
|
2
|
+
* Resolve a pointer position at plot-pixel `(px, py)` to the mark under it, or
|
|
3
|
+
* `null`. Walks the row's layers **top-down** (reverse z-order — the topmost
|
|
4
|
+
* mark wins, matching what the user sees) and returns the first `hitTest` hit.
|
|
5
|
+
* A layer with no `hitTest` (line / band / area) or no resolvable y-scale is
|
|
6
|
+
* skipped.
|
|
7
|
+
*
|
|
8
|
+
* `mode` (default `'hover'`) is forwarded to each layer's `hitTest`: hover may
|
|
9
|
+
* attribute a generous target (a bar's whole slot — the continuous-highlight
|
|
10
|
+
* model), while `'select'` — the click path — must leave genuinely empty plot
|
|
11
|
+
* space resolving to `null`, because that `null` **is** the deselect signal
|
|
12
|
+
* (RFC §7: a click on no mark is the empty-commit path). See
|
|
13
|
+
* {@link RowLayer.hitTest}.
|
|
6
14
|
*
|
|
7
15
|
* Pure, given the row's `xScale` and a per-axis y-scale lookup — so the click
|
|
8
16
|
* dispatch in `Layers` unit-tests without a DOM. (Layers passes its sorted
|
|
9
17
|
* z-stack, the shared `xScale`, and its `axisId → yScale` resolver.)
|
|
10
18
|
*/
|
|
11
|
-
export function resolveSelection(entries, px, py, xScale, yScaleFor) {
|
|
19
|
+
export function resolveSelection(entries, px, py, xScale, yScaleFor, mode = 'hover') {
|
|
12
20
|
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
13
21
|
const entry = entries[i];
|
|
14
22
|
const yScale = yScaleFor(entry.axisId);
|
|
15
23
|
if (yScale === undefined)
|
|
16
24
|
continue;
|
|
17
|
-
const hit = entry.layer.hitTest?.(px, py, xScale, yScale);
|
|
25
|
+
const hit = entry.layer.hitTest?.(px, py, xScale, yScale, mode);
|
|
18
26
|
if (hit)
|
|
19
27
|
return hit;
|
|
20
28
|
}
|