@pond-ts/charts 0.50.0 → 0.52.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 (47) hide show
  1. package/CHANGELOG.md +187 -1
  2. package/dist/AreaChart.js +1 -0
  3. package/dist/BandChart.js +1 -0
  4. package/dist/BarChart.d.ts +14 -1
  5. package/dist/BarChart.js +5 -2
  6. package/dist/BoxPlot.js +1 -0
  7. package/dist/Candlestick.js +1 -0
  8. package/dist/ChartContainer.d.ts +61 -11
  9. package/dist/ChartContainer.js +70 -23
  10. package/dist/Layers.js +53 -14
  11. package/dist/Legend.js +5 -2
  12. package/dist/LineChart.js +1 -0
  13. package/dist/ScatterChart.d.ts +12 -1
  14. package/dist/ScatterChart.js +4 -2
  15. package/dist/XAxis.js +3 -2
  16. package/dist/affine.d.ts +41 -0
  17. package/dist/affine.js +77 -0
  18. package/dist/area.d.ts +20 -2
  19. package/dist/area.js +151 -45
  20. package/dist/band.d.ts +2 -1
  21. package/dist/band.js +5 -0
  22. package/dist/bars.d.ts +14 -1
  23. package/dist/bars.js +42 -1
  24. package/dist/box.d.ts +2 -1
  25. package/dist/box.js +8 -3
  26. package/dist/context.d.ts +121 -22
  27. package/dist/context.js +8 -0
  28. package/dist/data.d.ts +8 -0
  29. package/dist/decimate.d.ts +117 -1
  30. package/dist/decimate.js +241 -1
  31. package/dist/encoding.d.ts +9 -0
  32. package/dist/encoding.js +5 -1
  33. package/dist/index.d.ts +6 -3
  34. package/dist/index.js +5 -3
  35. package/dist/line.d.ts +15 -1
  36. package/dist/line.js +84 -30
  37. package/dist/ohlc.d.ts +2 -1
  38. package/dist/ohlc.js +8 -3
  39. package/dist/scatter.d.ts +3 -2
  40. package/dist/scatter.js +61 -1
  41. package/dist/tracker.d.ts +17 -4
  42. package/dist/tracker.js +19 -6
  43. package/dist/useChartLegend.d.ts +2 -2
  44. package/dist/useChartLegend.js +8 -7
  45. package/dist/viewport.d.ts +19 -0
  46. package/dist/viewport.js +32 -0
  47. package/package.json +3 -3
package/dist/area.js CHANGED
@@ -1,7 +1,90 @@
1
1
  import { area as d3area, curveLinear } from 'd3-shape';
2
+ import { strokeAffinePolyline } from './line.js';
2
3
  import { bridgeGaps, collectGapEdges, drawGapBridges, drawGapFades, drawGapSteps, withAlpha, DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
3
4
  import { cullChartSeries } from './culling.js';
4
- import { decimateM4 } from './decimate.js';
5
+ import { decimateM4Cached } from './decimate.js';
6
+ import { affineOf } from './affine.js';
7
+ /**
8
+ * Per-buffer cache of a column's finite `[min, max]` value extent ([PND-GRADX]).
9
+ * The area fill gradient spans the **full** series' vertical pixel extent (so a
10
+ * culled/zoomed view still shades identically — see {@link buildGradient}), which
11
+ * previously meant an O(N) min/max walk on **every** repaint, including each
12
+ * y-zoom / y-autorange frame where the data hasn't changed (the 2026-07 bench
13
+ * profile's mountain@1M ceiling; see
14
+ * `docs/notes/charts-bench-vs-scichart-suite-2026-07.md`, finding 2).
15
+ *
16
+ * The extent is a pure function of the value buffer, so it is memoized on the
17
+ * `y` `Float64Array` (immutable by the {@link ChartSeries} contract): a y-zoom /
18
+ * pan reuses the same buffer → cache hit (no walk); a live re-materialization
19
+ * mints a new buffer → recompute once. The `WeakMap` evicts with the buffer, so
20
+ * there is no leak. Callers pass the full-series `length` (the buffer's logical
21
+ * length); a `subarray` view is never the cache key here (the gradient reads the
22
+ * pre-cull full series).
23
+ *
24
+ * NaN (the gap signal) is ignored — matching {@link areaExtent} / `yExtent` — so
25
+ * a coast doesn't drag the span. `null` when nothing is finite (the caller then
26
+ * falls back to a flat fill).
27
+ */
28
+ const columnExtentCache = new WeakMap();
29
+ export function columnFiniteExtent(y, length) {
30
+ const cached = columnExtentCache.get(y);
31
+ if (cached !== undefined)
32
+ return cached;
33
+ let min = Infinity;
34
+ let max = -Infinity;
35
+ for (let i = 0; i < length; i += 1) {
36
+ const v = y[i];
37
+ if (Number.isFinite(v)) {
38
+ if (v < min)
39
+ min = v;
40
+ if (v > max)
41
+ max = v;
42
+ }
43
+ }
44
+ const extent = min === Infinity ? null : [min, max];
45
+ columnExtentCache.set(y, extent);
46
+ return extent;
47
+ }
48
+ /**
49
+ * Fill the area between an affine-mapped value polyline and a constant baseline
50
+ * pixel — the [PND-AFFINE] fast path for {@link drawArea}'s fill, the counterpart
51
+ * to {@link strokeAffinePolyline} for its outline. Emits one **independent closed
52
+ * polygon per finite run** (matching `d3.area`'s `.defined(Number.isFinite)`
53
+ * segmentation for a linear curve + constant `y0`): per run `[a, b)`,
54
+ * `moveTo(top_a)` → `lineTo(top…)` along the value edge → `lineTo(x_{b-1}, base)`
55
+ * → `lineTo(x_a, base)` → `closePath`. That is the same filled region `d3.area`
56
+ * draws — its flat backward baseline edge only adds collinear interior vertices,
57
+ * which don't change the fill — without the per-point `scale()` / d3-shape
58
+ * closures. A signed value edge crossing the baseline stays one polygon (no NaN),
59
+ * filled correctly on both sides. The caller brackets `beginPath`/`fill`;
60
+ * `xs`/`ys` are aligned index-for-index.
61
+ */
62
+ export function fillAffineArea(ctx, xs, ys, baselinePx, ax, ay) {
63
+ const n = ys.length;
64
+ let runStart = -1; // index of the current finite run's first point, or -1
65
+ for (let j = 0; j <= n; j += 1) {
66
+ const finite = j < n && Number.isFinite(ys[j]);
67
+ if (finite) {
68
+ const px = ax.k * xs[j] + ax.b;
69
+ const py = ay.k * ys[j] + ay.b;
70
+ if (runStart < 0) {
71
+ runStart = j;
72
+ ctx.moveTo(px, py);
73
+ }
74
+ else {
75
+ ctx.lineTo(px, py);
76
+ }
77
+ }
78
+ else if (runStart >= 0) {
79
+ // Close the run: drop to the baseline under the last point, run flat back
80
+ // to the first point's x, close. (j-1 is the run's last finite index.)
81
+ ctx.lineTo(ax.k * xs[j - 1] + ax.b, baselinePx);
82
+ ctx.lineTo(ax.k * xs[runStart] + ax.b, baselinePx);
83
+ ctx.closePath();
84
+ runStart = -1;
85
+ }
86
+ }
87
+ }
5
88
  /**
6
89
  * The `[min, max]` vertical extent an area occupies — the finite values of
7
90
  * `cs.y` widened to include `baseline`, since the fill spans from each value to
@@ -73,41 +156,51 @@ export function areaExtent(cs, baseline) {
73
156
  * are collected by one O(N) walk ({@link collectGapEdges}).
74
157
  */
75
158
  export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY, decimate = true) {
159
+ const sourceCount = cs.length; // pre-cull, pre-decimation (for draw stats)
76
160
  const baselinePx = yScale(baselineValue);
77
161
  // The fill gradient's vertical extent is computed from the **full** series (a
78
162
  // vertical, position-anchored gradient spanning the data's whole pixel extent)
79
163
  // so viewport culling stays behavior-neutral: the culled path below paints the
80
- // exact same visible pixels under the same gradient. This is a cheap O(N)
81
- // min/max scan; the expensive per-point path work (fill + outline) culls. (Cull
82
- // the region too and the shade would drift under pan as off-screen extrema
83
- // enter/leave a visible change culling must not make.)
84
- const fullYs = gaps === 'none' ? bridgeGaps(cs.y, cs.length) : cs.y;
85
- const fill = buildGradient(ctx, fullYs, cs.length, yScale, baselinePx, style);
86
- // Viewport culling (Phase 2): the path, outline, and gap bridges walk the
87
- // visible slice (+1 entry/exit point) only. A no-op — the same `cs` back when
88
- // fully in view or `xScale` has no domain (a test stub), keeping that hot path
89
- // byte-identical.
90
- cs = cullChartSeries(cs, xScale);
91
- // M4 decimation (Phase 3): the outline is a line, so the same {@link decimateM4}
92
- // pre-pass shrinks the fill + outline + gap-bridge work to O(plot width) once
93
- // dense with the §2.2 gap-edge union so every gap mode composes. The gradient
94
- // above is over the FULL series, so the decimated fill paints identical pixels
95
- // under it. Gated off a smoothing `curve`; a no-op on a sparse slice / test scale.
164
+ // exact same visible pixels under the same gradient. (Cull the region too and
165
+ // the shade would drift under pan as off-screen extrema enter/leave a visible
166
+ // change culling must not make.) [PND-GRADX]: the value extent is memoized per
167
+ // column buffer ({@link columnFiniteExtent}), so a y-zoom / pan frame reuses it
168
+ // instead of re-walking O(N) the mountain@1M ceiling the bench profile
169
+ // flagged. A `'none'` bridge only fills interior gaps with interpolated values
170
+ // that stay within the finite extent, so the plain extent is exact for it too.
171
+ const fill = buildGradient(ctx, columnFiniteExtent(cs.y, cs.length), yScale, baselinePx, style);
172
+ // Clip `cs` to what draws. **Decimated** (linear curve, `decimate !== false`):
173
+ // cull to the visible slice, then the same {@link decimateM4} pre-pass shrinks
174
+ // the fill + outline + gap-bridge work to O(plot width) once dense (the §2.2
175
+ // gap-edge union so every gap mode composes; the FULL-series gradient above
176
+ // paints identical pixels under the decimated fill). Cull + decimate are
177
+ // **memoized per source** ({@link decimateM4Cached}) so a y-zoom / y-autorange
178
+ // frame reuses the prior polyline instead of re-binning O(N) the decimation
179
+ // output never reads the y-scale (finding 3). **Full-resolution** (a smoothing
180
+ // curve, or `decimate === false`): just cull the visible slice (+1 entry/exit
181
+ // point); a no-op — the same `cs` back — when fully in view or `xScale` has no
182
+ // domain (a test stub), keeping that hot path byte-identical.
183
+ const source = cs; // pre-cull source — the decimation cache key ([PND-DECKEY])
184
+ let decimated = false;
96
185
  if (decimate !== false && curve === curveLinear) {
97
186
  const k = typeof decimate === 'object' ? decimate.threshold : undefined;
98
- cs = decimateM4(cs, xScale, ctx, k);
187
+ const r = decimateM4Cached(source, xScale, ctx, k);
188
+ cs = r.series;
189
+ decimated = r.decimated;
190
+ }
191
+ else {
192
+ cs = cullChartSeries(source, xScale);
99
193
  }
100
194
  // `none` interpolates interior gaps so the fill + outline bridge them; every
101
195
  // other mode keeps NaN so d3 breaks both (the inferred line bridge, if any, is
102
196
  // a separate overlay pass below).
103
197
  const ys = gaps === 'none' ? bridgeGaps(cs.y, cs.length) : cs.y;
104
- const gen = d3area()
105
- .defined((v) => Number.isFinite(v))
106
- .x((_, i) => xScale(cs.x[i]))
107
- .y0(() => baselinePx)
108
- .y1((v) => yScale(v))
109
- .curve(curve)
110
- .context(ctx);
198
+ // [PND-AFFINE] fast path: with a linear curve and both scales affine, draw the
199
+ // fill polygon + outline with inline multiply-add over the typed arrays, past
200
+ // the per-point d3-scale + d3-shape closures (finding 1/2). A smoothing curve
201
+ // or a non-affine (real-gap trading) x scale keeps the exact d3-area path.
202
+ const ax = curve === curveLinear ? affineOf(xScale) : null;
203
+ const ay = ax !== null ? affineOf(yScale) : null;
111
204
  ctx.save();
112
205
  // The fill: a vertical gradient anchored at the baseline pixel, opaque at the
113
206
  // line and transparent at the baseline (see buildGradient — handles both the
@@ -116,16 +209,32 @@ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve =
116
209
  ctx.fillStyle = fill;
117
210
  ctx.globalAlpha = style.fillOpacity;
118
211
  ctx.beginPath();
119
- gen(ys);
212
+ // The d3-area generator (slow path only) — also the source of the outline line.
213
+ let outline = null;
214
+ if (ax !== null && ay !== null) {
215
+ fillAffineArea(ctx, cs.x, ys, baselinePx, ax, ay);
216
+ }
217
+ else {
218
+ const gen = d3area()
219
+ .defined((v) => Number.isFinite(v))
220
+ .x((_, i) => xScale(cs.x[i]))
221
+ .y0(() => baselinePx)
222
+ .y1((v) => yScale(v))
223
+ .curve(curve)
224
+ .context(ctx);
225
+ gen(ys);
226
+ outline = gen.lineY1();
227
+ }
120
228
  ctx.fill();
121
229
  ctx.restore();
122
- // The outline on top: the area's top edge as a line (`lineY1` inherits the
123
- // area's `defined`, `curve`, and `context`, so it breaks at the same gaps),
124
- // at full opacity over the graded fill.
125
- const outline = gen.lineY1();
230
+ // The outline on top: the area's top edge as a line (breaks at the same gaps
231
+ // as the fill), at full opacity over the graded fill.
126
232
  ctx.save();
127
233
  ctx.beginPath();
128
- outline(ys);
234
+ if (outline !== null)
235
+ outline(ys);
236
+ else
237
+ strokeAffinePolyline(ctx, cs.x, ys, ax, ay);
129
238
  ctx.strokeStyle = style.color;
130
239
  ctx.lineWidth = style.width;
131
240
  ctx.stroke();
@@ -145,6 +254,7 @@ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve =
145
254
  drawGapFades(ctx, edges, baselinePx, style.color, style.width);
146
255
  }
147
256
  }
257
+ return { sourceCount, drawnCount: cs.length, decimated };
148
258
  }
149
259
  /**
150
260
  * A vertical `CanvasGradient` for the fill, spanning the drawn region's pixel
@@ -164,21 +274,17 @@ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve =
164
274
  * finite point, or values exactly on the baseline) — a zero-height gradient
165
275
  * would paint nothing.
166
276
  */
167
- function buildGradient(ctx, ys, length, yScale, baselinePx, style) {
168
- let topPx = Infinity; // smallest pixel y (highest on screen)
169
- let bottomPx = -Infinity; // largest pixel y (lowest on screen)
170
- for (let i = 0; i < length; i += 1) {
171
- const v = ys[i];
172
- if (!Number.isFinite(v))
173
- continue;
174
- const py = yScale(v);
175
- if (py < topPx)
176
- topPx = py;
177
- if (py > bottomPx)
178
- bottomPx = py;
179
- }
180
- if (topPx === Infinity)
277
+ function buildGradient(ctx, valueExtent, yScale, baselinePx, style) {
278
+ if (valueExtent === null)
181
279
  return style.fill; // no finite values (caller no-ops)
280
+ // The pixel extent is the two value extremes mapped through the (monotonic,
281
+ // always-`scaleLinear`) y scale; min/max them so the result is flip-agnostic,
282
+ // exactly as the former per-point pixel scan produced. [PND-GRADX] moved the
283
+ // O(N) walk into the memoized {@link columnFiniteExtent}.
284
+ const pa = yScale(valueExtent[0]);
285
+ const pb = yScale(valueExtent[1]);
286
+ const topPx = Math.min(pa, pb); // smallest pixel y (highest on screen)
287
+ const bottomPx = Math.max(pa, pb); // largest pixel y (lowest on screen)
182
288
  // The drawn region runs from the topmost of {values, baseline} to the
183
289
  // bottommost — the fill reaches the baseline, so include it.
184
290
  const regionTop = Math.min(topPx, baselinePx);
package/dist/band.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type CurveFactory } from 'd3-shape';
2
2
  import type { BandSeries } from './data.js';
3
3
  import type { Scale } from './line.js';
4
4
  import type { BandStyle } from './theme.js';
5
+ import type { LayerDrawStats } from './context.js';
5
6
  import { type DecimateOption } from './decimate.js';
6
7
  /**
7
8
  * The `[min, max]` vertical extent of the **drawn** band — the lowest `lower`
@@ -28,5 +29,5 @@ export declare function bandExtent(band: BandSeries): [number, number] | null;
28
29
  * index, so there's no per-point object allocation. `globalAlpha` carries the
29
30
  * opacity and is restored so it doesn't leak into later layers.
30
31
  */
31
- export declare function drawBand(ctx: CanvasRenderingContext2D, band: BandSeries, xScale: Scale, yScale: Scale, style: BandStyle, curve?: CurveFactory, decimate?: DecimateOption): void;
32
+ export declare function drawBand(ctx: CanvasRenderingContext2D, band: BandSeries, xScale: Scale, yScale: Scale, style: BandStyle, curve?: CurveFactory, decimate?: DecimateOption): LayerDrawStats;
32
33
  //# sourceMappingURL=band.d.ts.map
package/dist/band.js CHANGED
@@ -41,6 +41,7 @@ export function bandExtent(band) {
41
41
  * opacity and is restored so it doesn't leak into later layers.
42
42
  */
43
43
  export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear, decimate = true) {
44
+ const sourceCount = band.length; // pre-cull, pre-decimation (for draw stats)
44
45
  // Viewport culling (Phase 2): clip the envelope to the visible slice (+1 each
45
46
  // side) before filling, so a pan strokes O(visible). The solid fill has no
46
47
  // cross-point state, so a zero-copy subarray view is exact; a no-op (same
@@ -52,9 +53,12 @@ export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear,
52
53
  // pixels. Gated off a smoothing `curve` (which would distort the per-column
53
54
  // envelope) and `decimate === false`; `decimateBand` itself no-ops on a sparse
54
55
  // envelope or a domainless test scale, so this stays byte-identical there.
56
+ let decimated = false;
55
57
  if (decimate !== false && curve === curveLinear) {
56
58
  const k = typeof decimate === 'object' ? decimate.threshold : undefined;
59
+ const before = band;
57
60
  band = decimateBand(band, xScale, ctx, k);
61
+ decimated = band !== before;
58
62
  }
59
63
  const gen = d3area()
60
64
  .defined((_, i) => Number.isFinite(band.lower[i]) && Number.isFinite(band.upper[i]))
@@ -70,5 +74,6 @@ export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear,
70
74
  gen(band.lower);
71
75
  ctx.fill();
72
76
  ctx.restore();
77
+ return { sourceCount, drawnCount: band.length, decimated };
73
78
  }
74
79
  //# sourceMappingURL=band.js.map
package/dist/bars.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { BarSeries, StackedBarSeries } from './data.js';
2
2
  import type { Scale } from './line.js';
3
3
  import type { BarStyle } from './theme.js';
4
+ import type { LayerDrawStats } from './context.js';
5
+ import { type DecimateOption } from './decimate.js';
4
6
  /**
5
7
  * Bar growth direction — the histogram orientation. `'vertical'` bars grow **up**
6
8
  * from a value baseline, bins on the x axis (the column / time-bucket look);
@@ -67,6 +69,17 @@ export declare function barRect(cs: BarSeries, i: number, xScale: Scale, yScale:
67
69
  *
68
70
  * O(N) over the events, one fill (+ optional stroke) per bar, no per-bar
69
71
  * allocation beyond the rect tuple.
72
+ *
73
+ * **M4 column decimation ([PND-MARKDEC]):** once the *visible* bars are denser
74
+ * than ~2 per device pixel, they overplot into a solid silhouette, so
75
+ * `decimate !== false` replaces them with one **envelope rect per pixel column**
76
+ * ({@link decimateBars} — `[min(value, baseline), max(value, baseline)]`), from
77
+ * O(W) rects instead of O(visible). Gated on the *visible* count (a bar's width
78
+ * is its slot). The decimated pass draws the flat `fill` only — the aggregate
79
+ * columns aren't individually selectable, so per-bar selection/hover highlight is
80
+ * suppressed (a <1px bar's ring wouldn't be visible anyway); interaction still
81
+ * reads the **source** bars via {@link barAt} (§2.3). Pass `decimate={false}` to
82
+ * always draw every bar. Returns {@link LayerDrawStats} for `onDrawStats`.
70
83
  */
71
84
  export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, xScale: Scale, yScale: Scale, style: BarStyle, baseline: number, gapPx: number, seriesId: string | undefined, selection: {
72
85
  key: number;
@@ -74,7 +87,7 @@ export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, x
74
87
  } | null, hovered: {
75
88
  key: number;
76
89
  id: string;
77
- } | null): void;
90
+ } | null, decimate?: DecimateOption): LayerDrawStats;
78
91
  /**
79
92
  * The index of the bar whose key span `[begin, end]` contains `time` — the bar
80
93
  * **under the cursor** — or `-1` if `time` falls in no bar's span. This is the
package/dist/bars.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { barSpanPx } from './range.js';
2
2
  import { visibleSpanRange } from './culling.js';
3
+ import { decimateBars } from './decimate.js';
3
4
  /**
4
5
  * The `[min, max]` vertical extent the bars occupy — the finite values of `cs.y`
5
6
  * **widened to include `0`**, since a bar spans from its value to the baseline
@@ -93,16 +94,54 @@ export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx) {
93
94
  *
94
95
  * O(N) over the events, one fill (+ optional stroke) per bar, no per-bar
95
96
  * allocation beyond the rect tuple.
97
+ *
98
+ * **M4 column decimation ([PND-MARKDEC]):** once the *visible* bars are denser
99
+ * than ~2 per device pixel, they overplot into a solid silhouette, so
100
+ * `decimate !== false` replaces them with one **envelope rect per pixel column**
101
+ * ({@link decimateBars} — `[min(value, baseline), max(value, baseline)]`), from
102
+ * O(W) rects instead of O(visible). Gated on the *visible* count (a bar's width
103
+ * is its slot). The decimated pass draws the flat `fill` only — the aggregate
104
+ * columns aren't individually selectable, so per-bar selection/hover highlight is
105
+ * suppressed (a <1px bar's ring wouldn't be visible anyway); interaction still
106
+ * reads the **source** bars via {@link barAt} (§2.3). Pass `decimate={false}` to
107
+ * always draw every bar. Returns {@link LayerDrawStats} for `onDrawStats`.
96
108
  */
97
- export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered) {
109
+ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered, decimate = true) {
98
110
  ctx.save();
99
111
  ctx.globalAlpha = style.opacity;
112
+ const sourceCount = cs.length; // pre-cull, pre-decimation (for draw stats)
100
113
  // Viewport culling (Phase 2): draw only the bars whose span overlaps the
101
114
  // visible x-window (+1 each side). The loop keeps the original index `i`, so
102
115
  // the `begin[i]` selection/hover match stays correct; full range when `xScale`
103
116
  // has no domain (a test stub). A selected/hovered bar off-screen isn't drawn
104
117
  // (its highlight would be off-screen anyway).
105
118
  const [vStart, vEnd] = visibleSpanRange(cs.begin, cs.end, cs.length, xScale);
119
+ // Decimate the visible bars to per-column envelope rects once dense (see the
120
+ // header). `null` below the visible-density threshold ⇒ the full per-bar loop.
121
+ // `{ threshold }` tunes the samples-per-pixel factor `k` (as line/area/band do);
122
+ // `undefined` ⇒ decimateBars' default (2).
123
+ const k = typeof decimate === 'object' ? decimate.threshold : undefined;
124
+ const envelope = decimate !== false
125
+ ? decimateBars(cs, xScale, ctx, baseline, k, vEnd - vStart)
126
+ : null;
127
+ if (envelope !== null) {
128
+ ctx.fillStyle = style.fill;
129
+ let drawn = 0;
130
+ for (let b = 0; b < envelope.length; b += 1) {
131
+ const lo = envelope.lo[b];
132
+ if (!Number.isFinite(lo))
133
+ continue; // empty column
134
+ const [x0, x1] = barSpanPx(envelope.begin[b], envelope.end[b], xScale, 0, // tile the column — a per-bar gapPx is invisible at <1px bars
135
+ style.minWidth);
136
+ const yTop = yScale(envelope.hi[b]);
137
+ const yBottom = yScale(lo);
138
+ ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
139
+ drawn += 1;
140
+ }
141
+ ctx.restore();
142
+ return { sourceCount, drawnCount: drawn, decimated: true };
143
+ }
144
+ let drawn = 0;
106
145
  for (let i = vStart; i < vEnd; i += 1) {
107
146
  const rect = barRect(cs, i, xScale, yScale, baseline, gapPx, style.minWidth);
108
147
  if (rect === null)
@@ -122,6 +161,7 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
122
161
  hovered.key === cs.begin[i];
123
162
  ctx.fillStyle = selected || isHovered ? style.highlight : style.fill;
124
163
  ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
164
+ drawn += 1;
125
165
  if (selected) {
126
166
  // The selected bar gets an outline so it reads at full strength over the
127
167
  // (alpha'd) fills. Stroke at full opacity (reset within the save bracket).
@@ -133,6 +173,7 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
133
173
  }
134
174
  }
135
175
  ctx.restore();
176
+ return { sourceCount, drawnCount: drawn, decimated: false };
136
177
  }
137
178
  /**
138
179
  * The index of the bar whose key span `[begin, end]` contains `time` — the bar
package/dist/box.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { BoxSeries } from './data.js';
2
2
  import type { Scale } from './line.js';
3
3
  import type { BoxStyle } from './theme.js';
4
+ import type { LayerDrawStats } from './context.js';
4
5
  import { type DecimateOption } from './decimate.js';
5
6
  /**
6
7
  * The `[min, max]` vertical extent of the **drawn** boxes — the lowest `lower`
@@ -78,7 +79,7 @@ export type BoxShape = 'whisker' | 'solid' | 'none';
78
79
  * O(N) over the keys, a fixed number of path ops each — no per-key allocation
79
80
  * beyond the `barSpanPx` tuple.
80
81
  */
81
- export declare function drawBox(ctx: CanvasRenderingContext2D, box: BoxSeries, xScale: Scale, yScale: Scale, style: BoxStyle, gapPx?: number, minWidthPx?: number, shape?: BoxShape, showMedian?: boolean, offsetPx?: number, capWidthPx?: number, selectedKey?: number | null, hoveredKey?: number | null, decimate?: DecimateOption): void;
82
+ export declare function drawBox(ctx: CanvasRenderingContext2D, box: BoxSeries, xScale: Scale, yScale: Scale, style: BoxStyle, gapPx?: number, minWidthPx?: number, shape?: BoxShape, showMedian?: boolean, offsetPx?: number, capWidthPx?: number, selectedKey?: number | null, hoveredKey?: number | null, decimate?: DecimateOption): LayerDrawStats;
82
83
  /**
83
84
  * This key is drawable — the quantiles it actually carries are all finite at `i`.
84
85
  * `lower`/`upper` (the whisker reach) are always required; `q1`/`q3` only when the
package/dist/box.js CHANGED
@@ -114,6 +114,7 @@ export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx =
114
114
  // box gets a full-strength bounding outline; a hovered one a fainter one —
115
115
  // the box analog of the bar highlight, drawn without a new theme token.
116
116
  selectedKey = null, hoveredKey = null, decimate = true) {
117
+ const sourceCount = box.length; // pre-cull, pre-decimation (for draw stats)
117
118
  // Viewport cull first (Phase 2): the [vStart, vEnd) boxes whose span overlaps
118
119
  // the window (+1 each side). Full range when `xScale` has no domain (a stub);
119
120
  // `offsetPx` is a small pixel nudge the ±1 margin absorbs.
@@ -127,9 +128,10 @@ selectedKey = null, hoveredKey = null, decimate = true) {
127
128
  // loop-bound cull above. A selection/hover highlight keyed by the source box's
128
129
  // `x` won't match an aggregate column edge — but per-box highlight is
129
130
  // meaningless at decimation density, and hit-testing still reads the source.
130
- const decimated = decimate !== false ? decimateBox(box, xScale, ctx, 2, vEnd - vStart) : box;
131
- if (decimated !== box) {
132
- box = decimated; // aggregate boxes are already the visible set
131
+ const decimatedBox = decimate !== false ? decimateBox(box, xScale, ctx, 2, vEnd - vStart) : box;
132
+ const decimated = decimatedBox !== box;
133
+ if (decimated) {
134
+ box = decimatedBox; // aggregate boxes are already the visible set
133
135
  vStart = 0;
134
136
  vEnd = box.length;
135
137
  }
@@ -229,6 +231,9 @@ selectedKey = null, hoveredKey = null, decimate = true) {
229
231
  ctx.restore();
230
232
  }
231
233
  }
234
+ // `drawnCount` = box slots iterated (visible span, or the aggregate set when
235
+ // decimation engaged); `sourceCount` = the raw box count it started from.
236
+ return { sourceCount, drawnCount: vEnd - vStart, decimated };
232
237
  }
233
238
  /**
234
239
  * This key is drawable — the quantiles it actually carries are all finite at `i`.
package/dist/context.d.ts CHANGED
@@ -41,25 +41,12 @@ export interface ContainerFrame {
41
41
  readonly rightGutter: number;
42
42
  /** Vertical space between rows in px (not under the time axis). */
43
43
  readonly rowGap: number;
44
- /**
45
- * The crosshair's **plot-pixel x** (`0..plotWidth`), shared across rows so the
46
- * tracker syncs, or `null` when not hovering. A *pixel*, not a timestamp — so a
47
- * still cursor stays put while a live window slides under it (a stored
48
- * timestamp would drift sideways as `xScale` changes). A controlled
49
- * `trackerPosition` (a timestamp) resolves to a pixel here.
50
- */
51
- readonly cursorX: number | null;
52
- /** Set the hovered plot-pixel x; a row's event surface calls this on pointer move. */
44
+ /** Set the hovered plot-pixel x; a row's event surface calls this on pointer move.
45
+ * The value itself is on {@link CursorFrame.cursorX} ({@link CursorContext})
46
+ * split out so a hover doesn't re-identify this frame (see [PND-HOVCTX]). */
53
47
  setHoverX(x: number | null): void;
54
- /**
55
- * The hovered plot-pixel **y** and the row it's in — for the free-form
56
- * crosshair's horizontal line + value readout (which are row-specific, unlike
57
- * the shared vertical `cursorX`). `null` when not hovering a plot. Hover-driven
58
- * only (no controlled equivalent).
59
- */
60
- readonly cursorY: number | null;
61
- readonly cursorRowKey: symbol | null;
62
- /** Set the hovered plot-pixel y + its row; the event surface calls this on move. */
48
+ /** Set the hovered plot-pixel y + its row; the event surface calls this on move.
49
+ * The values are on {@link CursorFrame} ({@link CursorContext}). */
63
50
  setHoverY(y: number | null, rowKey: symbol | null): void;
64
51
  /**
65
52
  * `cursor="crosshair"` **y** snapping. **Default `true`** — the reticle centres
@@ -98,6 +85,14 @@ export interface ContainerFrame {
98
85
  * zoom the view, or map the span onto a subscription's range params.
99
86
  */
100
87
  readonly onRegionSelect: ((range: readonly [number, number]) => void) | undefined;
88
+ /**
89
+ * A stable sink for per-repaint {@link DrawStatsFrame}s, or `undefined` when no
90
+ * `onDrawStats` consumer is subscribed — the `undefined` is the signal for
91
+ * `Layers` to skip per-layer timing entirely (zero overhead when unused). The
92
+ * identity is stable while subscribed (it reads a ref), so an inline
93
+ * `onDrawStats` arrow doesn't thrash the draw memo.
94
+ */
95
+ readonly reportDrawStats: ((frame: DrawStatsFrame) => void) | undefined;
101
96
  /**
102
97
  * Require a modifier key held to start a region-drag — set to `'shift'` to make
103
98
  * plain drag **pan** and **shift**-drag select, when `panZoom` is on. Only
@@ -250,8 +245,10 @@ export interface ContainerFrame {
250
245
  * category label), and the cursor readout to format the x position.
251
246
  */
252
247
  readonly xKind: 'time' | 'value' | 'category';
253
- /** Pan/zoom enabled the plot drag-pans and wheel-zooms the shared time range. */
254
- readonly panZoom: boolean;
248
+ /** Drag-pan enabled (the `'pan'` and `'panZoom'` container modes). */
249
+ readonly panEnabled: boolean;
250
+ /** Wheel-zoom enabled (the `'panZoom'` container mode only). */
251
+ readonly zoomEnabled: boolean;
255
252
  /** Minimum visible duration (ms) — the zoom-in floor. */
256
253
  readonly minDuration: number;
257
254
  /**
@@ -398,6 +395,97 @@ export interface GutterReq {
398
395
  readonly right: readonly number[];
399
396
  }
400
397
  export declare const ContainerContext: import("react").Context<ContainerFrame | null>;
398
+ /**
399
+ * The **per-move** cursor state — split out of {@link ContainerFrame} so a
400
+ * mousemove re-identifies only this (small) context, not the whole frame.
401
+ * `ContainerFrame` carries ~50 mostly-static fields; when the cursor lived
402
+ * there, every pointer move rebuilt it and re-rendered **all** its consumers
403
+ * (both `YAxis`, `Legend`, `Bar`/`Box`) even though only the SVG overlay moved.
404
+ * Config consumers now read the stable frame and skip hover re-renders; the
405
+ * genuine cursor consumers (`Layers` overlay, `XAxis` crosshair pill,
406
+ * `useChartLegend` values) read this. See [PND-HOVCTX] and the note it links.
407
+ *
408
+ * The cursor *time* is **not** here — each consumer derives it locally from
409
+ * `cursorX` + its own `xScale` (an in-bounds `xScale.invert`), as before.
410
+ * ({@link ContainerFrame} still carries a `cursorTime` **boolean** — the
411
+ * unrelated "show time in the readout" config flag.)
412
+ */
413
+ export interface CursorFrame {
414
+ /**
415
+ * The crosshair's **plot-pixel x** (`0..plotWidth`), shared across rows so the
416
+ * tracker syncs, or `null` when not hovering. A *pixel*, not a timestamp — so a
417
+ * still cursor stays put while a live window slides under it (a stored
418
+ * timestamp would drift sideways as `xScale` changes). A controlled
419
+ * `trackerPosition` (a timestamp) resolves to a pixel here.
420
+ */
421
+ readonly cursorX: number | null;
422
+ /**
423
+ * The hovered plot-pixel **y** and the row it's in — for the free-form
424
+ * crosshair's horizontal line + value readout (which are row-specific, unlike
425
+ * the shared vertical `cursorX`). `null` when not hovering a plot. Hover-driven
426
+ * only (no controlled equivalent).
427
+ */
428
+ readonly cursorY: number | null;
429
+ readonly cursorRowKey: symbol | null;
430
+ }
431
+ export declare const CursorContext: import("react").Context<CursorFrame>;
432
+ /**
433
+ * What a {@link RowLayer.draw} may return so the container can report render
434
+ * cost + whether M4 decimation engaged this frame ({@link
435
+ * ContainerProps.onDrawStats}). Optional — a layer that returns `void` reports
436
+ * only its `drawMs` (the render loop times every layer regardless).
437
+ */
438
+ export interface LayerDrawStats {
439
+ /** Source series length the draw received (pre-cull, pre-decimation). */
440
+ readonly sourceCount: number;
441
+ /** Points / marks actually drawn this frame — after viewport culling and, if
442
+ * it engaged, M4 decimation. `drawnCount < sourceCount` can come from **either**
443
+ * culling (a zoomed-in view drops off-screen points) **or** decimation — read
444
+ * `decimated` to tell which; `drawnCount === sourceCount` means everything in
445
+ * view was drawn full-resolution. */
446
+ readonly drawnCount: number;
447
+ /** Whether M4 decimation engaged (vs. drew the culled slice full-resolution).
448
+ * Distinguishes a decimated draw from a merely culled one — both shrink
449
+ * `drawnCount`. */
450
+ readonly decimated: boolean;
451
+ }
452
+ /**
453
+ * One layer's line in a {@link DrawStatsFrame}: its identity + measured draw
454
+ * time, plus the {@link LayerDrawStats} the layer reported (`undefined` counts
455
+ * for a layer that returns none — e.g. a non-decimating scatter/bar, which
456
+ * still contributes its `drawMs`).
457
+ */
458
+ export interface LayerDrawInfo {
459
+ /** The layer's `as` role, or `undefined` if it set none. */
460
+ readonly as: string | undefined;
461
+ /** Z-order index within the row (the `<Layers>` declaration position). */
462
+ readonly index: number;
463
+ /** Wall-clock ms spent in this layer's `draw` this frame. */
464
+ readonly drawMs: number;
465
+ readonly sourceCount: number | undefined;
466
+ readonly drawnCount: number | undefined;
467
+ readonly decimated: boolean | undefined;
468
+ }
469
+ /**
470
+ * The per-repaint draw-stats frame handed to {@link ContainerProps.onDrawStats}.
471
+ * Fires **once per row-canvas repaint** (rows repaint independently, so a
472
+ * multi-row container fires one frame per row that painted), carrying that row's
473
+ * layers newest-drawn. The seam the dashboard A/B asked for (2026-07-21): read
474
+ * `drawnCount` vs `sourceCount` to see whether M4 engaged, and `drawMs` for the
475
+ * per-layer render cost the packaged layer otherwise hides.
476
+ */
477
+ export interface DrawStatsFrame {
478
+ /**
479
+ * Opaque, stable identity of the **row** this frame is for — rows repaint
480
+ * independently, so a multi-row container fires one frame per row and this is
481
+ * how a consumer attributes each (group frames by `rowKey`, e.g. as a `Map`
482
+ * key). Not human-readable; `layers[].as` labels the series within a row.
483
+ */
484
+ readonly rowKey: symbol;
485
+ readonly layers: readonly LayerDrawInfo[];
486
+ /** Total ms across this row's layer draws this frame. */
487
+ readonly totalDrawMs: number;
488
+ }
401
489
  /**
402
490
  * A draw layer ({@link LineChart}, …) registered into a {@link Layers}, paired
403
491
  * with the id of the axis it scales against. The row computes a y-scale per
@@ -405,6 +493,12 @@ export declare const ContainerContext: import("react").Context<ContainerFrame |
405
493
  * domain); each layer draws with its own axis's scale.
406
494
  */
407
495
  export interface RowLayer {
496
+ /**
497
+ * The layer's `as` role (the series identity), surfaced in {@link
498
+ * DrawStatsFrame} so a draw-stats consumer can label each line. `undefined`
499
+ * when the layer was given no `as`.
500
+ */
501
+ readonly as?: string | undefined;
408
502
  /** This layer's finite-value `[min, max]`, or `null` if it has none. */
409
503
  yExtent(): [number, number] | null;
410
504
  /**
@@ -468,8 +562,13 @@ export interface RowLayer {
468
562
  * resolves the layer's axis scale, as for `draw`).
469
563
  */
470
564
  hitTest?(px: number, py: number, xScale: (value: number) => number, yScale: (value: number) => number): SelectInfo | null;
471
- /** Draw into the plot canvas. `xScale`/`yScale` map data→pixels. */
472
- draw(ctx: CanvasRenderingContext2D, xScale: (value: number) => number, yScale: (value: number) => number): void;
565
+ /**
566
+ * Draw into the plot canvas. `xScale`/`yScale` map data→pixels. May return
567
+ * {@link LayerDrawStats} (source/drawn counts + whether decimation engaged) so
568
+ * the container can surface them via {@link ContainerProps.onDrawStats}; a
569
+ * layer that returns `void` still contributes its measured `drawMs`.
570
+ */
571
+ draw(ctx: CanvasRenderingContext2D, xScale: (value: number) => number, yScale: (value: number) => number): LayerDrawStats | void;
473
572
  }
474
573
  /** One tracker readout point — a dot + value the overlay draws at the cursor. */
475
574
  export interface TrackerSample {