@pond-ts/charts 0.31.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 (79) hide show
  1. package/CHANGELOG.md +3254 -0
  2. package/LICENSE +21 -0
  3. package/README.md +229 -0
  4. package/dist/AreaChart.d.ts +85 -0
  5. package/dist/AreaChart.js +119 -0
  6. package/dist/BandChart.d.ts +55 -0
  7. package/dist/BandChart.js +93 -0
  8. package/dist/BarChart.d.ts +72 -0
  9. package/dist/BarChart.js +137 -0
  10. package/dist/BoxPlot.d.ts +77 -0
  11. package/dist/BoxPlot.js +137 -0
  12. package/dist/Canvas.d.ts +37 -0
  13. package/dist/Canvas.js +39 -0
  14. package/dist/ChartContainer.d.ts +106 -0
  15. package/dist/ChartContainer.js +306 -0
  16. package/dist/ChartRow.d.ts +29 -0
  17. package/dist/ChartRow.js +215 -0
  18. package/dist/Layers.d.ts +22 -0
  19. package/dist/Layers.js +399 -0
  20. package/dist/LineChart.d.ts +60 -0
  21. package/dist/LineChart.js +105 -0
  22. package/dist/ScatterChart.d.ts +84 -0
  23. package/dist/ScatterChart.js +139 -0
  24. package/dist/TimeAxis.d.ts +9 -0
  25. package/dist/TimeAxis.js +12 -0
  26. package/dist/XAxis.d.ts +39 -0
  27. package/dist/XAxis.js +84 -0
  28. package/dist/YAxis.d.ts +42 -0
  29. package/dist/YAxis.js +86 -0
  30. package/dist/annotations.d.ts +110 -0
  31. package/dist/annotations.js +459 -0
  32. package/dist/area.d.ts +54 -0
  33. package/dist/area.js +186 -0
  34. package/dist/band.d.ts +31 -0
  35. package/dist/band.js +57 -0
  36. package/dist/bars.d.ts +96 -0
  37. package/dist/bars.js +171 -0
  38. package/dist/box.d.ts +59 -0
  39. package/dist/box.js +140 -0
  40. package/dist/chip.d.ts +23 -0
  41. package/dist/chip.js +43 -0
  42. package/dist/cjs-fallback.cjs +16 -0
  43. package/dist/context.d.ts +362 -0
  44. package/dist/context.js +5 -0
  45. package/dist/curve.d.ts +22 -0
  46. package/dist/curve.js +13 -0
  47. package/dist/data.d.ts +154 -0
  48. package/dist/data.js +197 -0
  49. package/dist/domain.d.ts +19 -0
  50. package/dist/domain.js +61 -0
  51. package/dist/encoding.d.ts +89 -0
  52. package/dist/encoding.js +144 -0
  53. package/dist/format.d.ts +53 -0
  54. package/dist/format.js +47 -0
  55. package/dist/gaps.d.ts +146 -0
  56. package/dist/gaps.js +209 -0
  57. package/dist/grid.d.ts +11 -0
  58. package/dist/grid.js +29 -0
  59. package/dist/index.d.ts +53 -0
  60. package/dist/index.js +34 -0
  61. package/dist/line.d.ts +46 -0
  62. package/dist/line.js +88 -0
  63. package/dist/range.d.ts +15 -0
  64. package/dist/range.js +27 -0
  65. package/dist/scatter.d.ts +70 -0
  66. package/dist/scatter.js +213 -0
  67. package/dist/select.d.ts +13 -0
  68. package/dist/select.js +23 -0
  69. package/dist/slots.d.ts +48 -0
  70. package/dist/slots.js +64 -0
  71. package/dist/theme.d.ts +224 -0
  72. package/dist/theme.js +232 -0
  73. package/dist/tracker.d.ts +30 -0
  74. package/dist/tracker.js +47 -0
  75. package/dist/use-slot-key.d.ts +21 -0
  76. package/dist/use-slot-key.js +25 -0
  77. package/dist/viewport.d.ts +20 -0
  78. package/dist/viewport.js +30 -0
  79. package/package.json +67 -0
package/dist/line.js ADDED
@@ -0,0 +1,88 @@
1
+ import { line as d3line, curveLinear } from 'd3-shape';
2
+ import { bridgeGaps, collectGapEdges, drawGapBridges, drawGapFades, drawGapSteps, DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
3
+ /**
4
+ * The y-scale's domain lower bound (the axis floor) in pixels — where the
5
+ * `step` / `fade` gap bridges drop to. The runtime `yScale` is a d3
6
+ * `ScaleLinear` (it carries `.domain()`); read the bound through a localized,
7
+ * documented shape rather than widening the draw contract to d3-scale. Falls
8
+ * back to `0` if the scale exposes no domain.
9
+ */
10
+ export function baselinePxFromScale(yScale) {
11
+ const d = yScale.domain?.();
12
+ return yScale(d && d.length > 0 ? d[0] : 0);
13
+ }
14
+ /**
15
+ * The `[min, max]` of the **finite** values in `cs.y`, or `null` if none are
16
+ * finite. NaN (the gap signal) is ignored, so a coast doesn't drag the domain.
17
+ */
18
+ export function yExtent(cs) {
19
+ let min = Infinity;
20
+ let max = -Infinity;
21
+ for (let i = 0; i < cs.length; i += 1) {
22
+ const v = cs.y[i];
23
+ if (Number.isFinite(v)) {
24
+ if (v < min)
25
+ min = v;
26
+ if (v > max)
27
+ max = v;
28
+ }
29
+ }
30
+ return min === Infinity ? null : [min, max];
31
+ }
32
+ /**
33
+ * Stroke a line for `cs`, mapping data→pixels through `xScale`/`yScale` and
34
+ * connecting points with `curve` (d3-shape; default linear).
35
+ *
36
+ * Built on d3-shape's `line()`. **Gap handling is driven by `gaps`** (a
37
+ * {@link GapMode}, default `'empty'`):
38
+ *
39
+ * - `'empty'` (default) — `.defined(Number.isFinite)`: a non-finite value ends
40
+ * the current subpath and the next finite point starts a fresh one (`moveTo`,
41
+ * not `lineTo`), so a coast reads as a break, not a `lineTo(NaN, …)` bridge
42
+ * (`docs/rfcs/charts.md` trap #2).
43
+ * - `'none'` — interior gaps are linearly interpolated ({@link bridgeGaps}) so
44
+ * the line bridges straight across (real `lineTo`s, robust to leading /
45
+ * trailing gaps, which stay a break). The one non-honest mode.
46
+ * - `'dashed'` / `'step'` / `'fade'` — the **solid** segments break exactly as
47
+ * in `'empty'`, then a second pass draws the inferred bridge across each
48
+ * interior gap: a dashed straight line, a flat dashed line at the average of
49
+ * the edge values, or estela's fade-to-baseline (the axis floor). `dashed` /
50
+ * `step` are drawn faint (`gapConnectorOpacity`); the gap edges are collected
51
+ * by one O(N) walk ({@link collectGapEdges}).
52
+ *
53
+ * The generator writes path ops to `ctx`; we bracket with `beginPath`/`stroke`.
54
+ * `cs.y` (a `Float64Array`) is the datum iterable — `y` reads the value, `x`
55
+ * reads `cs.x[i]` by index, so there's no per-point object allocation.
56
+ */
57
+ export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY) {
58
+ // `none` interpolates interior gaps so the line bridges them; every other mode
59
+ // keeps NaN so d3 breaks the solid path (the inferred bridge, if any, is a
60
+ // separate overlay pass below).
61
+ const ys = gaps === 'none' ? bridgeGaps(cs.y, cs.length) : cs.y;
62
+ const gen = d3line()
63
+ .defined((v) => Number.isFinite(v))
64
+ .x((_, i) => xScale(cs.x[i]))
65
+ .y((v) => yScale(v))
66
+ .curve(curve)
67
+ .context(ctx);
68
+ ctx.beginPath();
69
+ gen(ys);
70
+ ctx.strokeStyle = style.color;
71
+ ctx.lineWidth = style.width;
72
+ ctx.stroke();
73
+ // Overlay bridges for the inferred-gap modes. `dashed` / `step` are faint
74
+ // dashed connectors (gapConnectorOpacity); only `fade` drops to the axis floor.
75
+ if (gaps === 'dashed' || gaps === 'step' || gaps === 'fade') {
76
+ const edges = collectGapEdges(cs.length, cs.x, (i) => cs.y[i], xScale, (i) => yScale(cs.y[i]));
77
+ if (gaps === 'dashed') {
78
+ drawGapBridges(ctx, edges, style.color, style.width, gapConnectorOpacity);
79
+ }
80
+ else if (gaps === 'step') {
81
+ drawGapSteps(ctx, edges, style.color, style.width, gapConnectorOpacity);
82
+ }
83
+ else {
84
+ drawGapFades(ctx, edges, baselinePxFromScale(yScale), style.color, style.width);
85
+ }
86
+ }
87
+ }
88
+ //# sourceMappingURL=line.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Pixel x-span for a range/interval-keyed mark (a bar or a box) spanning
3
+ * `[beginMs, endMs]`, inset by `gapPx` total (half each side) so adjacent marks
4
+ * breathe. Returns `[x0, x1]` with `x0 <= x1`.
5
+ *
6
+ * The chart supplies the range — the key's `begin`/`end` for an interval series,
7
+ * or a derived width for a point-keyed one — plus the gap; this is just the math,
8
+ * so it unit-tests without a canvas. Shared by `BarChart` + `BoxPlot`.
9
+ *
10
+ * A span that the gap would invert (narrower than `minWidthPx` after the inset)
11
+ * collapses to a `minWidthPx` mark centred in the slot, so a too-thin bucket
12
+ * stays visible and the bar never flips inside-out.
13
+ */
14
+ export declare function barSpanPx(beginMs: number, endMs: number, xScale: (value: number) => number, gapPx?: number, minWidthPx?: number): [number, number];
15
+ //# sourceMappingURL=range.d.ts.map
package/dist/range.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Pixel x-span for a range/interval-keyed mark (a bar or a box) spanning
3
+ * `[beginMs, endMs]`, inset by `gapPx` total (half each side) so adjacent marks
4
+ * breathe. Returns `[x0, x1]` with `x0 <= x1`.
5
+ *
6
+ * The chart supplies the range — the key's `begin`/`end` for an interval series,
7
+ * or a derived width for a point-keyed one — plus the gap; this is just the math,
8
+ * so it unit-tests without a canvas. Shared by `BarChart` + `BoxPlot`.
9
+ *
10
+ * A span that the gap would invert (narrower than `minWidthPx` after the inset)
11
+ * collapses to a `minWidthPx` mark centred in the slot, so a too-thin bucket
12
+ * stays visible and the bar never flips inside-out.
13
+ */
14
+ export function barSpanPx(beginMs, endMs, xScale, gapPx = 0, minWidthPx = 1) {
15
+ const a = xScale(beginMs);
16
+ const b = xScale(endMs);
17
+ const lo = Math.min(a, b);
18
+ const hi = Math.max(a, b);
19
+ const inset = gapPx / 2;
20
+ const x0 = lo + inset;
21
+ const x1 = hi - inset;
22
+ if (x1 - x0 >= minWidthPx)
23
+ return [x0, x1];
24
+ const mid = (lo + hi) / 2;
25
+ return [mid - minWidthPx / 2, mid + minWidthPx / 2];
26
+ }
27
+ //# sourceMappingURL=range.js.map
@@ -0,0 +1,70 @@
1
+ import type { ChartSeries } from './data.js';
2
+ import type { Scale } from './line.js';
3
+ import type { ScatterStyle } from './theme.js';
4
+ import type { ResolvedEncoding } from './encoding.js';
5
+ import type { SelectInfo } from './context.js';
6
+ /**
7
+ * Index of the point in `cs` **nearest** `time` by `|x − time|`, restricted to
8
+ * finite points, or `-1` if none. `cs.x` is the sorted time axis, so a binary
9
+ * search finds the insertion point in O(log N); the two straddling rows are then
10
+ * the only nearest candidates — but either may be a gap (non-finite y), so we
11
+ * step outward from each until we find a real point and keep the closer.
12
+ *
13
+ * Ties go to the **earlier** point (strict `<` on the distance comparison),
14
+ * matching core's `nearest(select:'nearest')`. Used by the tracker's `sampleAt`
15
+ * so the readout snaps to a drawn mark (not a gap) — and reads everything by
16
+ * index, so the encoded colour at that row comes along for free.
17
+ */
18
+ export declare function nearestIndex(cs: ChartSeries, time: number): number;
19
+ /**
20
+ * The `[min, max]` of the **finite** plotted values — identical to a line's
21
+ * vertical extent (the marks sit at their values; radius is a pixel-space
22
+ * concern that doesn't widen the data domain). `null` if no point is finite.
23
+ * Mirrors `yExtent` so a scatter auto-fits its axis the same way a line does.
24
+ */
25
+ export declare function scatterExtent(cs: ChartSeries): [number, number] | null;
26
+ /**
27
+ * Draw the scatter: one filled, outlined circle per finite point, sized +
28
+ * coloured by `encoding` (data-driven radius / colour) over `style`. A gap
29
+ * (non-finite y) draws nothing — points are discrete, there is no path to break.
30
+ *
31
+ * The **selected** point (when `selected` matches this layer's `label` *and* a
32
+ * point's `begin` key) is restroked with the style's wider highlight ring after
33
+ * the base pass, so it lifts above its neighbours regardless of draw order.
34
+ * Matching on both key and label is what keeps two series sharing a timestamp
35
+ * from both lighting up (the container's selection contract).
36
+ *
37
+ * Each circle is its own `beginPath`/`arc`/`fill`/`stroke`; `save`/`restore`
38
+ * brackets the whole pass so fill/stroke state doesn't leak into later layers.
39
+ * Reads `cs.x`/`cs.y` by index — no per-point object allocation in the hot loop.
40
+ *
41
+ * @param keyAt maps a row index to the point's stable key (its event `begin`),
42
+ * for selection matching — the row's `x` is epoch ms, so this is
43
+ * usually `(i) => cs.x[i]`.
44
+ * @param labelAt optional per-point text label; `undefined` ⇒ no labels drawn.
45
+ * @param font `theme.font` (family + size) for label text.
46
+ * @param selected the container's current selection (or `null`).
47
+ * @param seriesLabel this layer's series identity (`as` ?? column) — the
48
+ * `label` half of the selection match.
49
+ */
50
+ 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: {
51
+ readonly family: string;
52
+ readonly size: number;
53
+ }, selected: SelectInfo | null, seriesLabel: string): void;
54
+ /**
55
+ * Hit-test plot-pixel `(qx, qy)` against the scatter's points — the topmost
56
+ * point whose circle contains the click, or `null`. "Topmost" = the
57
+ * last-drawn at that spot, so a later point drawn over an earlier one wins; we
58
+ * walk **backwards** and return the first containing point.
59
+ *
60
+ * A point's hit radius is its drawn radius (data-driven or base) — clicking the
61
+ * visible disc selects it. Distance is compared squared (no `sqrt` in the loop).
62
+ * Returns the point's {@link SelectInfo} with `key = keyAt(i)` (its event
63
+ * `begin`), the encoded fill colour (so the readout swatch matches the mark),
64
+ * and the series `label`.
65
+ *
66
+ * Pure: takes the same `xScale`/`yScale` the row hands to `draw`, so it
67
+ * unit-tests without a DOM (mirrors the `sampleAt` / `resolveSelection` split).
68
+ */
69
+ export declare function hitTestScatter(cs: ChartSeries, qx: number, qy: number, xScale: Scale, yScale: Scale, encoding: ResolvedEncoding, keyAt: (i: number) => number, seriesLabel: string): SelectInfo | null;
70
+ //# sourceMappingURL=scatter.d.ts.map
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Scatter geometry + the canvas draw — pure, like {@link drawLine} /
3
+ * {@link drawBand}, so the recording-mock tests assert the op sequence and the
4
+ * hit math without a browser.
5
+ *
6
+ * A scatter plots one mark per finite point at `(xScale(x), yScale(y))`, sized
7
+ * + coloured by the resolved {@link ResolvedEncoding} (data-driven radius /
8
+ * colour) over the style's base. All three of `drawScatter`, `scatterExtent`,
9
+ * and {@link hitTestScatter} are **O(N)** in the point count (a single pass; no
10
+ * spatial index — a chart row holds far fewer points than a dense line, and a
11
+ * click happens at human cadence). If a scatter ever needs 100k+ points this is
12
+ * the place to add a coarse x-bucket index; today the linear walk is the right
13
+ * tradeoff.
14
+ */
15
+ /** A non-finite y (the gap signal) means "no point here" — skip it everywhere. */
16
+ function isPoint(cs, i) {
17
+ return Number.isFinite(cs.y[i]);
18
+ }
19
+ /**
20
+ * Index of the point in `cs` **nearest** `time` by `|x − time|`, restricted to
21
+ * finite points, or `-1` if none. `cs.x` is the sorted time axis, so a binary
22
+ * search finds the insertion point in O(log N); the two straddling rows are then
23
+ * the only nearest candidates — but either may be a gap (non-finite y), so we
24
+ * step outward from each until we find a real point and keep the closer.
25
+ *
26
+ * Ties go to the **earlier** point (strict `<` on the distance comparison),
27
+ * matching core's `nearest(select:'nearest')`. Used by the tracker's `sampleAt`
28
+ * so the readout snaps to a drawn mark (not a gap) — and reads everything by
29
+ * index, so the encoded colour at that row comes along for free.
30
+ */
31
+ export function nearestIndex(cs, time) {
32
+ const n = cs.length;
33
+ if (n === 0)
34
+ return -1;
35
+ // Binary search for the first index with x >= time.
36
+ let lo = 0;
37
+ let hi = n;
38
+ while (lo < hi) {
39
+ const mid = (lo + hi) >>> 1;
40
+ if (cs.x[mid] < time)
41
+ lo = mid + 1;
42
+ else
43
+ hi = mid;
44
+ }
45
+ // Candidates straddle `lo`: the row at `lo` (>= time) and `lo - 1` (< time).
46
+ // Either side may be a run of gaps, so scan outward for the nearest real point.
47
+ let best = -1;
48
+ let bestDist = Infinity;
49
+ // Walk right from lo for the first finite point.
50
+ for (let i = lo; i < n; i += 1) {
51
+ if (Number.isFinite(cs.y[i])) {
52
+ best = i;
53
+ bestDist = Math.abs(cs.x[i] - time);
54
+ break;
55
+ }
56
+ }
57
+ // Walk left from lo-1; take it only if strictly closer (ties → earlier index,
58
+ // which on the left side means this earlier point wins an equal distance).
59
+ for (let i = lo - 1; i >= 0; i -= 1) {
60
+ if (Number.isFinite(cs.y[i])) {
61
+ const d = Math.abs(cs.x[i] - time);
62
+ if (d <= bestDist) {
63
+ best = i;
64
+ bestDist = d;
65
+ }
66
+ break;
67
+ }
68
+ }
69
+ return best;
70
+ }
71
+ /**
72
+ * The `[min, max]` of the **finite** plotted values — identical to a line's
73
+ * vertical extent (the marks sit at their values; radius is a pixel-space
74
+ * concern that doesn't widen the data domain). `null` if no point is finite.
75
+ * Mirrors `yExtent` so a scatter auto-fits its axis the same way a line does.
76
+ */
77
+ export function scatterExtent(cs) {
78
+ let min = Infinity;
79
+ let max = -Infinity;
80
+ for (let i = 0; i < cs.length; i += 1) {
81
+ const v = cs.y[i];
82
+ if (Number.isFinite(v)) {
83
+ if (v < min)
84
+ min = v;
85
+ if (v > max)
86
+ max = v;
87
+ }
88
+ }
89
+ return min === Infinity ? null : [min, max];
90
+ }
91
+ /**
92
+ * Draw the scatter: one filled, outlined circle per finite point, sized +
93
+ * coloured by `encoding` (data-driven radius / colour) over `style`. A gap
94
+ * (non-finite y) draws nothing — points are discrete, there is no path to break.
95
+ *
96
+ * The **selected** point (when `selected` matches this layer's `label` *and* a
97
+ * point's `begin` key) is restroked with the style's wider highlight ring after
98
+ * the base pass, so it lifts above its neighbours regardless of draw order.
99
+ * Matching on both key and label is what keeps two series sharing a timestamp
100
+ * from both lighting up (the container's selection contract).
101
+ *
102
+ * Each circle is its own `beginPath`/`arc`/`fill`/`stroke`; `save`/`restore`
103
+ * brackets the whole pass so fill/stroke state doesn't leak into later layers.
104
+ * Reads `cs.x`/`cs.y` by index — no per-point object allocation in the hot loop.
105
+ *
106
+ * @param keyAt maps a row index to the point's stable key (its event `begin`),
107
+ * for selection matching — the row's `x` is epoch ms, so this is
108
+ * usually `(i) => cs.x[i]`.
109
+ * @param labelAt optional per-point text label; `undefined` ⇒ no labels drawn.
110
+ * @param font `theme.font` (family + size) for label text.
111
+ * @param selected the container's current selection (or `null`).
112
+ * @param seriesLabel this layer's series identity (`as` ?? column) — the
113
+ * `label` half of the selection match.
114
+ */
115
+ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesLabel) {
116
+ ctx.save();
117
+ // The selection only lights up a point of *this* series; resolve the key once.
118
+ const selectedKey = selected !== null && selected.label === seriesLabel ? selected.key : null;
119
+ let selPx = 0;
120
+ let selPy = 0;
121
+ let selR = 0;
122
+ let selHit = false;
123
+ for (let i = 0; i < cs.length; i += 1) {
124
+ if (!isPoint(cs, i))
125
+ continue;
126
+ const px = xScale(cs.x[i]);
127
+ const py = yScale(cs.y[i]);
128
+ const r = encoding.radiusAt(i);
129
+ ctx.beginPath();
130
+ ctx.arc(px, py, r, 0, Math.PI * 2);
131
+ ctx.fillStyle = encoding.colorAt(i);
132
+ ctx.fill();
133
+ if (style.outlineWidth > 0) {
134
+ ctx.lineWidth = style.outlineWidth;
135
+ ctx.strokeStyle = style.outline;
136
+ ctx.stroke();
137
+ }
138
+ // Defer the selected point's highlight ring to a second pass so it sits on
139
+ // top of any neighbour drawn after it.
140
+ if (selectedKey !== null && keyAt(i) === selectedKey) {
141
+ selPx = px;
142
+ selPy = py;
143
+ selR = r;
144
+ selHit = true;
145
+ }
146
+ }
147
+ // Highlight ring for the selected point (after the base pass — always on top).
148
+ if (selHit) {
149
+ ctx.beginPath();
150
+ ctx.arc(selPx, selPy, selR, 0, Math.PI * 2);
151
+ ctx.lineWidth = style.selectedWidth;
152
+ ctx.strokeStyle = style.selectedOutline;
153
+ ctx.stroke();
154
+ }
155
+ // Optional per-point labels, after all marks so text isn't overpainted.
156
+ if (labelAt !== undefined) {
157
+ ctx.fillStyle = style.label;
158
+ ctx.font = `${font.size}px ${font.family}`;
159
+ ctx.textBaseline = 'middle';
160
+ for (let i = 0; i < cs.length; i += 1) {
161
+ if (!isPoint(cs, i))
162
+ continue;
163
+ const text = labelAt(i);
164
+ if (text === undefined || text === '')
165
+ continue;
166
+ const px = xScale(cs.x[i]);
167
+ const py = yScale(cs.y[i]);
168
+ const r = encoding.radiusAt(i);
169
+ // Sit the label just right of the point (past its radius), vertically
170
+ // centred — simple, theme-styled placement.
171
+ ctx.fillText(text, px + r + LABEL_GAP, py);
172
+ }
173
+ }
174
+ ctx.restore();
175
+ }
176
+ /** Gap (px) between a point's edge and its label text. */
177
+ const LABEL_GAP = 4;
178
+ /**
179
+ * Hit-test plot-pixel `(qx, qy)` against the scatter's points — the topmost
180
+ * point whose circle contains the click, or `null`. "Topmost" = the
181
+ * last-drawn at that spot, so a later point drawn over an earlier one wins; we
182
+ * walk **backwards** and return the first containing point.
183
+ *
184
+ * A point's hit radius is its drawn radius (data-driven or base) — clicking the
185
+ * visible disc selects it. Distance is compared squared (no `sqrt` in the loop).
186
+ * Returns the point's {@link SelectInfo} with `key = keyAt(i)` (its event
187
+ * `begin`), the encoded fill colour (so the readout swatch matches the mark),
188
+ * and the series `label`.
189
+ *
190
+ * Pure: takes the same `xScale`/`yScale` the row hands to `draw`, so it
191
+ * unit-tests without a DOM (mirrors the `sampleAt` / `resolveSelection` split).
192
+ */
193
+ export function hitTestScatter(cs, qx, qy, xScale, yScale, encoding, keyAt, seriesLabel) {
194
+ for (let i = cs.length - 1; i >= 0; i -= 1) {
195
+ if (!isPoint(cs, i))
196
+ continue;
197
+ const px = xScale(cs.x[i]);
198
+ const py = yScale(cs.y[i]);
199
+ const r = encoding.radiusAt(i);
200
+ const dx = qx - px;
201
+ const dy = qy - py;
202
+ if (dx * dx + dy * dy <= r * r) {
203
+ return {
204
+ key: keyAt(i),
205
+ value: cs.y[i],
206
+ color: encoding.colorAt(i),
207
+ label: seriesLabel,
208
+ };
209
+ }
210
+ }
211
+ return null;
212
+ }
213
+ //# sourceMappingURL=scatter.js.map
@@ -0,0 +1,13 @@
1
+ import type { LayerEntry, SelectInfo } from './context.js';
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.
7
+ *
8
+ * Pure, given the row's `xScale` and a per-axis y-scale lookup — so the click
9
+ * dispatch in `Layers` unit-tests without a DOM. (Layers passes its sorted
10
+ * z-stack, the shared `xScale`, and its `axisId → yScale` resolver.)
11
+ */
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;
13
+ //# sourceMappingURL=select.d.ts.map
package/dist/select.js ADDED
@@ -0,0 +1,23 @@
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.
6
+ *
7
+ * Pure, given the row's `xScale` and a per-axis y-scale lookup — so the click
8
+ * dispatch in `Layers` unit-tests without a DOM. (Layers passes its sorted
9
+ * z-stack, the shared `xScale`, and its `axisId → yScale` resolver.)
10
+ */
11
+ export function resolveSelection(entries, px, py, xScale, yScaleFor) {
12
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
13
+ const entry = entries[i];
14
+ const yScale = yScaleFor(entry.axisId);
15
+ if (yScale === undefined)
16
+ continue;
17
+ const hit = entry.layer.hitTest?.(px, py, xScale, yScale);
18
+ if (hit)
19
+ return hit;
20
+ }
21
+ return null;
22
+ }
23
+ //# sourceMappingURL=select.js.map
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Per-slot gutter math, shared by {@link ChartContainer} (reserve) and
3
+ * {@link ChartRow} (place). A "slot" is one axis column on a side; slots are
4
+ * indexed **from the plot outward** — slot 0 is the axis nearest the plot. Each
5
+ * row contributes a list of its axis widths for one side in slot order (slot 0
6
+ * first). Kept pure + free of React so the column-alignment rule is unit-tested
7
+ * directly.
8
+ */
9
+ /**
10
+ * The reserved width of each slot: the max any row needs in that slot. A row
11
+ * with fewer axes simply has no entry for the outer slots, contributing nothing
12
+ * there — so `leftSlots[i]` is `max` over the rows that *have* a slot `i`.
13
+ *
14
+ * The sum is the side's total gutter; the plot starts after the left sum and
15
+ * ends before the right sum, identically on every row.
16
+ */
17
+ export declare function maxSlotWidths(rows: readonly (readonly number[])[]): number[];
18
+ /** Sum of an array of widths (a side's total gutter). */
19
+ export declare function sum(widths: readonly number[]): number;
20
+ /** A row axis as the layout sees it: its per-instance slot **key** + width. */
21
+ export interface SlotAxis {
22
+ /** Stable per-instance key (the `useSlotKey` symbol), NOT the data id. */
23
+ readonly key: symbol;
24
+ readonly width: number;
25
+ }
26
+ /** What {@link placeAxisSlots} computes for one row. */
27
+ export interface AxisSlotPlacement {
28
+ /** Axis instance key → its reserved slot width (that column's max across rows). */
29
+ readonly axisSlots: ReadonlyMap<symbol, number>;
30
+ /** Width of the outer slots this row lacks — padded so its plot stays aligned. */
31
+ readonly leftPad: number;
32
+ readonly rightPad: number;
33
+ }
34
+ /**
35
+ * Place a row's real axes into the container's reserved slots (slot 0 nearest the
36
+ * plot). `leftAxes` are in author order (outer→inner), so the i-th sits in slot
37
+ * `len-1-i`; `rightAxes` are author order (inner→outer), so the j-th sits in slot
38
+ * `j`. Each axis's reserved width is its slot's max (falling back to its own
39
+ * width until the container has reserved); a row with fewer axes than the widest
40
+ * pads the outer slots it lacks — so a no-axis row pads the whole gutter and its
41
+ * plot still left-aligns.
42
+ *
43
+ * Keyed by **instance** (the `key` symbol), not the data `id`: two axes can share
44
+ * an id (a left/right mirror of one scale, or a duplicate) yet must keep distinct
45
+ * slots, or the rendered gutter would not match what the container reserved.
46
+ */
47
+ export declare function placeAxisSlots(leftAxes: readonly SlotAxis[], rightAxes: readonly SlotAxis[], leftSlots: readonly number[], rightSlots: readonly number[]): AxisSlotPlacement;
48
+ //# sourceMappingURL=slots.d.ts.map
package/dist/slots.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Per-slot gutter math, shared by {@link ChartContainer} (reserve) and
3
+ * {@link ChartRow} (place). A "slot" is one axis column on a side; slots are
4
+ * indexed **from the plot outward** — slot 0 is the axis nearest the plot. Each
5
+ * row contributes a list of its axis widths for one side in slot order (slot 0
6
+ * first). Kept pure + free of React so the column-alignment rule is unit-tested
7
+ * directly.
8
+ */
9
+ /**
10
+ * The reserved width of each slot: the max any row needs in that slot. A row
11
+ * with fewer axes simply has no entry for the outer slots, contributing nothing
12
+ * there — so `leftSlots[i]` is `max` over the rows that *have* a slot `i`.
13
+ *
14
+ * The sum is the side's total gutter; the plot starts after the left sum and
15
+ * ends before the right sum, identically on every row.
16
+ */
17
+ export function maxSlotWidths(rows) {
18
+ const len = rows.reduce((m, r) => Math.max(m, r.length), 0);
19
+ const slots = new Array(len).fill(0);
20
+ for (const row of rows) {
21
+ for (let i = 0; i < row.length; i++) {
22
+ slots[i] = Math.max(slots[i], row[i]);
23
+ }
24
+ }
25
+ return slots;
26
+ }
27
+ /** Sum of an array of widths (a side's total gutter). */
28
+ export function sum(widths) {
29
+ let total = 0;
30
+ for (const w of widths)
31
+ total += w;
32
+ return total;
33
+ }
34
+ /**
35
+ * Place a row's real axes into the container's reserved slots (slot 0 nearest the
36
+ * plot). `leftAxes` are in author order (outer→inner), so the i-th sits in slot
37
+ * `len-1-i`; `rightAxes` are author order (inner→outer), so the j-th sits in slot
38
+ * `j`. Each axis's reserved width is its slot's max (falling back to its own
39
+ * width until the container has reserved); a row with fewer axes than the widest
40
+ * pads the outer slots it lacks — so a no-axis row pads the whole gutter and its
41
+ * plot still left-aligns.
42
+ *
43
+ * Keyed by **instance** (the `key` symbol), not the data `id`: two axes can share
44
+ * an id (a left/right mirror of one scale, or a duplicate) yet must keep distinct
45
+ * slots, or the rendered gutter would not match what the container reserved.
46
+ */
47
+ export function placeAxisSlots(leftAxes, rightAxes, leftSlots, rightSlots) {
48
+ const axisSlots = new Map();
49
+ leftAxes.forEach((ax, i) => {
50
+ axisSlots.set(ax.key, leftSlots[leftAxes.length - 1 - i] ?? ax.width);
51
+ });
52
+ rightAxes.forEach((ax, j) => {
53
+ axisSlots.set(ax.key, rightSlots[j] ?? ax.width);
54
+ });
55
+ let leftPad = 0;
56
+ for (let i = leftAxes.length; i < leftSlots.length; i++)
57
+ leftPad += leftSlots[i] ?? 0;
58
+ let rightPad = 0;
59
+ for (let j = rightAxes.length; j < rightSlots.length; j++) {
60
+ rightPad += rightSlots[j] ?? 0;
61
+ }
62
+ return { axisSlots, leftPad, rightPad };
63
+ }
64
+ //# sourceMappingURL=slots.js.map