@pond-ts/charts 0.56.2 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +1218 -1
  2. package/dist/AreaChart.d.ts +12 -1
  3. package/dist/AreaChart.js +131 -13
  4. package/dist/BarChart.d.ts +84 -9
  5. package/dist/BarChart.js +295 -40
  6. package/dist/BarList.d.ts +85 -5
  7. package/dist/BarList.js +25 -4
  8. package/dist/BoxList.d.ts +70 -3
  9. package/dist/BoxList.js +21 -7
  10. package/dist/BoxPlot.d.ts +2 -1
  11. package/dist/BoxPlot.js +101 -9
  12. package/dist/Candlestick.d.ts +13 -1
  13. package/dist/Candlestick.js +89 -3
  14. package/dist/ChartContainer.d.ts +79 -48
  15. package/dist/ChartContainer.js +482 -60
  16. package/dist/ChartRow.d.ts +9 -2
  17. package/dist/ChartRow.js +86 -12
  18. package/dist/HeatMap.d.ts +176 -0
  19. package/dist/HeatMap.js +344 -0
  20. package/dist/Layers.d.ts +5 -1
  21. package/dist/Layers.js +1014 -253
  22. package/dist/Legend.js +8 -4
  23. package/dist/LineChart.d.ts +18 -1
  24. package/dist/LineChart.js +165 -4
  25. package/dist/ListTable.d.ts +30 -3
  26. package/dist/ListTable.js +381 -23
  27. package/dist/ScatterChart.d.ts +3 -2
  28. package/dist/ScatterChart.js +68 -4
  29. package/dist/XAxis.js +40 -22
  30. package/dist/YAxis.d.ts +28 -1
  31. package/dist/YAxis.js +24 -2
  32. package/dist/annotations.d.ts +74 -0
  33. package/dist/annotations.js +97 -7
  34. package/dist/area.d.ts +34 -1
  35. package/dist/area.js +88 -1
  36. package/dist/bars.d.ts +178 -5
  37. package/dist/bars.js +504 -46
  38. package/dist/box.d.ts +2 -2
  39. package/dist/box.js +158 -40
  40. package/dist/brush.d.ts +142 -0
  41. package/dist/brush.js +179 -0
  42. package/dist/child-index.d.ts +27 -0
  43. package/dist/child-index.js +57 -0
  44. package/dist/context.d.ts +871 -36
  45. package/dist/cursors.d.ts +161 -0
  46. package/dist/cursors.js +503 -0
  47. package/dist/decimate.d.ts +78 -1
  48. package/dist/decimate.js +157 -0
  49. package/dist/heat.d.ts +163 -0
  50. package/dist/heat.js +659 -0
  51. package/dist/index.d.ts +13 -4
  52. package/dist/index.js +25 -2
  53. package/dist/line.d.ts +137 -0
  54. package/dist/line.js +328 -0
  55. package/dist/ohlc.d.ts +16 -1
  56. package/dist/ohlc.js +93 -4
  57. package/dist/scatter.d.ts +17 -9
  58. package/dist/scatter.js +221 -33
  59. package/dist/select.d.ts +13 -5
  60. package/dist/select.js +14 -6
  61. package/dist/selection-fixtures.d.ts +174 -0
  62. package/dist/selection-fixtures.js +569 -0
  63. package/dist/selection-stories.d.ts +73 -0
  64. package/dist/selection-stories.js +301 -0
  65. package/dist/selectors.d.ts +316 -0
  66. package/dist/selectors.js +391 -0
  67. package/dist/span.d.ts +122 -0
  68. package/dist/span.js +203 -0
  69. package/dist/sweep.d.ts +154 -0
  70. package/dist/sweep.js +282 -0
  71. package/dist/theme.d.ts +517 -11
  72. package/dist/theme.js +220 -39
  73. package/dist/tracker.d.ts +6 -0
  74. package/dist/tracker.js +6 -0
  75. package/dist/tradingAxis.fixture.d.ts +78 -0
  76. package/dist/tradingAxis.fixture.js +215 -0
  77. package/dist/useChartLegend.js +18 -3
  78. 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
- export function drawCandles(ctx, ohlc, xScale, yScale, style, variant = 'candle', colorBy = 'direction', gapPx = 0, minWidthPx = 1, decimate = true) {
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 = style.wickWidth;
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 = style.wickWidth;
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 = style.wickWidth;
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
- * The **selected** point (when `selected` matches this layer's `label` *and* a
33
- * point's `begin` key) is restroked with the style's wider highlight ring after
34
- * the base pass, so it lifts above its neighbours regardless of draw order.
35
- * Matching on both key and label is what keeps two series sharing a timestamp
36
- * from both lighting up (the container's selection contract).
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 (or `null`).
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 the selection's
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 | null, seriesId: string | undefined, offsetPx?: number, decimate?: DecimateOption): LayerDrawStats;
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
- * The **selected** point (when `selected` matches this layer's `label` *and* a
101
- * point's `begin` key) is restroked with the style's wider highlight ring after
102
- * the base pass, so it lifts above its neighbours regardless of draw order.
103
- * Matching on both key and label is what keeps two series sharing a timestamp
104
- * from both lighting up (the container's selection contract).
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 (or `null`).
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 the selection's
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
- // The selection only lights up a point of *this* series; resolve the key once.
124
- // A no-id (non-selectable) layer passes `undefined` and never matches.
125
- const selectedKey = selected !== null && selected.id === seriesId ? selected.key : null;
126
- let selPx = 0;
127
- let selPy = 0;
128
- let selR = 0;
129
- let selHit = false;
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 ring and per-point labels are dropped on
168
- // this path — both are illegible under a dense blob — matching the decimated
169
- // bar path. Data-driven size/colour (`!encoding.uniform`) or a translucent
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
- ctx.arc(px, py, r, 0, Math.PI * 2);
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
- // Defer the selected point's highlight ring to a second pass so it sits on
235
- // top of any neighbour drawn after it.
236
- if (selectedKey !== null && keyAt(i) === selectedKey) {
237
- selPx = px;
238
- selPy = py;
239
- selR = r;
240
- selHit = true;
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
- // Highlight ring for the selected point (after the base pass — always on top).
244
- if (selHit) {
245
- ctx.beginPath();
246
- ctx.arc(selPx, selPy, selR, 0, Math.PI * 2);
247
- ctx.lineWidth = style.selectedWidth;
248
- ctx.strokeStyle = style.selectedOutline;
249
- ctx.stroke();
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 click at plot-pixel `(px, py)` to the selected mark, or `null`.
4
- * Walks the row's layers **top-down** (reverse z-order — the topmost mark wins,
5
- * matching what the user sees) and returns the first `hitTest` hit. A layer with
6
- * no `hitTest` (line / band / area) or no resolvable y-scale is skipped.
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 click at plot-pixel `(px, py)` to the selected mark, or `null`.
3
- * Walks the row's layers **top-down** (reverse z-order — the topmost mark wins,
4
- * matching what the user sees) and returns the first `hitTest` hit. A layer with
5
- * no `hitTest` (line / band / area) or no resolvable y-scale is skipped.
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
  }