@pond-ts/charts 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Layers.js CHANGED
@@ -6,7 +6,7 @@ import { cursorParts, bandRect, regionSpan } from './tracker.js';
6
6
  import { resolveSelection } from './select.js';
7
7
  import { panRange, zoomRange, panRangeTrading, zoomRangeTrading, } from './viewport.js';
8
8
  import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
9
- import { ContainerContext, LayersContext, RowContext, } from './context.js';
9
+ import { ContainerContext, CursorContext, LayersContext, RowContext, } from './context.js';
10
10
  /** Fallback **y**-gridline tick count, used only before the row publishes its
11
11
  * resolved `tickCounts` (pre-registration). Normally the gridlines read the
12
12
  * default axis's resolved count from `row.tickCounts` — the same value the
@@ -69,6 +69,9 @@ export function Layers({ children }) {
69
69
  if (container === null) {
70
70
  throw new Error('<Layers> must be rendered inside a <ChartContainer>');
71
71
  }
72
+ // Per-move cursor state (own context, so this overlay re-renders on hover
73
+ // without re-identifying the container frame — [PND-HOVCTX]).
74
+ const cursor = useContext(CursorContext);
72
75
  const row = useContext(RowContext);
73
76
  if (row === null) {
74
77
  throw new Error('<Layers> must be rendered inside a <ChartRow>');
@@ -204,11 +207,40 @@ export function Layers({ children }) {
204
207
  drawDividers(ctx, thinPixels(bx, MIN_DIVIDER_PX), h, dividerColor);
205
208
  }
206
209
  }
207
- for (const entry of layers) {
208
- const yScale = yScales.get(entry.axisId ?? defaultAxisId);
209
- if (yScale === undefined)
210
- continue;
211
- entry.layer.draw(ctx, xScale, yScale);
210
+ // Draw the layers. When a draw-stats consumer is subscribed
211
+ // (`reportDrawStats` defined), time each layer and collect its reported
212
+ // {@link LayerDrawStats}; otherwise the plain loop with zero timing
213
+ // overhead. Fires one {@link DrawStatsFrame} per repaint of THIS row.
214
+ const report = container.reportDrawStats;
215
+ if (report === undefined) {
216
+ for (const entry of layers) {
217
+ const yScale = yScales.get(entry.axisId ?? defaultAxisId);
218
+ if (yScale === undefined)
219
+ continue;
220
+ entry.layer.draw(ctx, xScale, yScale);
221
+ }
222
+ }
223
+ else {
224
+ const infos = [];
225
+ let totalDrawMs = 0;
226
+ for (const entry of layers) {
227
+ const yScale = yScales.get(entry.axisId ?? defaultAxisId);
228
+ if (yScale === undefined)
229
+ continue;
230
+ const t0 = performance.now();
231
+ const stats = entry.layer.draw(ctx, xScale, yScale);
232
+ const drawMs = performance.now() - t0;
233
+ totalDrawMs += drawMs;
234
+ infos.push({
235
+ as: entry.layer.as,
236
+ index: entry.index,
237
+ drawMs,
238
+ sourceCount: stats?.sourceCount,
239
+ drawnCount: stats?.drawnCount,
240
+ decimated: stats?.decimated,
241
+ });
242
+ }
243
+ report({ rowKey: row.rowKey, layers: infos, totalDrawMs });
212
244
  }
213
245
  }, [
214
246
  layers,
@@ -222,7 +254,13 @@ export function Layers({ children }) {
222
254
  gridColor,
223
255
  gridDash,
224
256
  container.discontinuities,
257
+ // Identity-stable across cursor moves (memoized tuple in ChartContainer;
258
+ // e2e `hover sweep never repaints the data canvas` pins this). A fresh
259
+ // array per frame rebuild here would re-fire the Canvas draw effect —
260
+ // a full replot per mousemove.
225
261
  container.timeRange,
262
+ container.reportDrawStats,
263
+ row.rowKey,
226
264
  ]);
227
265
  // Interaction overlay: the cursor marks live on a DOM/SVG overlay above the
228
266
  // data, so hovering never repaints the data canvas (whose `draw` doesn't depend
@@ -230,7 +268,8 @@ export function Layers({ children }) {
230
268
  // pointer is over — syncs the cursor across every row for free. cursorX is a
231
269
  // *pixel*, so it stays put while a live window slides; the time + values under
232
270
  // it derive from the current xScale.
233
- const { cursorX, cursorTime: showCursorTime, formatTime } = container;
271
+ const { cursorTime: showCursorTime, formatTime } = container;
272
+ const { cursorX } = cursor;
234
273
  // Cursor mode: the row's override, else the container default. One mode per
235
274
  // row (the synced vertical line is shared across rows); each layer renders the
236
275
  // mode in its own way. `parts` decomposes it into {line, dots, chip}.
@@ -392,7 +431,7 @@ export function Layers({ children }) {
392
431
  if (c.cursor === 'region' &&
393
432
  c.onRegionSelect &&
394
433
  (c.xKind === 'time' || c.xKind === 'value')) {
395
- const needsShift = c.regionSelectModifier === 'shift' && c.panZoom;
434
+ const needsShift = c.regionSelectModifier === 'shift' && c.panEnabled;
396
435
  if (!needsShift || e.shiftKey) {
397
436
  const px = Math.max(0, Math.min(c.plotWidth, e.clientX - e.currentTarget.getBoundingClientRect().left));
398
437
  regionAnchorRef.current = +c.xScale.invert(px);
@@ -408,7 +447,7 @@ export function Layers({ children }) {
408
447
  }
409
448
  // Modifier required but not held → fall through to pan.
410
449
  }
411
- if (!c.panZoom || c.xKind === 'category')
450
+ if (!c.panEnabled || c.xKind === 'category')
412
451
  return;
413
452
  const r = c.timeRange;
414
453
  // Arm a potential pan: record the anchor, but DON'T capture the pointer or
@@ -663,7 +702,7 @@ export function Layers({ children }) {
663
702
  return;
664
703
  const onWheel = (e) => {
665
704
  const c = containerRef.current;
666
- if (!c.panZoom || c.xKind === 'category')
705
+ if (!c.zoomEnabled || c.xKind === 'category')
667
706
  return;
668
707
  e.preventDefault();
669
708
  const rect = el.getBoundingClientRect();
@@ -724,14 +763,14 @@ export function Layers({ children }) {
724
763
  const reticle = (() => {
725
764
  if (parts.chip !== 'axis' || !cursorInBounds)
726
765
  return null;
727
- const hoveredRow = container.cursorRowKey === row.rowKey;
728
- const cy = container.cursorY;
766
+ const hoveredRow = cursor.cursorRowKey === row.rowKey;
767
+ const cy = cursor.cursorY;
729
768
  if (container.crosshairSnap) {
730
769
  if (trackerSamples.length === 0)
731
770
  return null;
732
771
  const pick = hoveredRow && cy !== null
733
772
  ? trackerSamples.reduce((a, b) => Math.abs(b.py - cy) < Math.abs(a.py - cy) ? b : a)
734
- : container.cursorRowKey === null
773
+ : cursor.cursorRowKey === null
735
774
  ? trackerSamples[0]
736
775
  : null;
737
776
  return pick
@@ -824,7 +863,7 @@ export function Layers({ children }) {
824
863
  ? `inset 0 0 0 1px ${guideColor}`
825
864
  : undefined,
826
865
  // Let pan/zoom own touch gestures (no native scroll) when enabled.
827
- touchAction: container.panZoom ? 'none' : 'auto',
866
+ touchAction: container.panEnabled || container.zoomEnabled ? 'none' : 'auto',
828
867
  }, onPointerMove: handlePointerMove, onPointerDown: handlePointerDown, onPointerUp: handlePointerUp, onPointerCancel: handlePointerUp, onPointerLeave: handlePointerLeave, onClick: handleClick, children: [_jsx(Canvas, { width: plotWidth, height: row.height, draw: draw }), guideXs.length > 0 && (_jsx("svg", { width: plotWidth, height: row.height, style: {
829
868
  position: 'absolute',
830
869
  top: 0,
package/dist/Legend.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useContext } from 'react';
3
- import { ContainerContext, RowContext } from './context.js';
3
+ import { ContainerContext, CursorContext, RowContext } from './context.js';
4
4
  import { buildChartLegend } from './useChartLegend.js';
5
5
  import { defaultTheme } from './theme.js';
6
6
  /** One 20×12 swatch glyph, drawn from the layer's resolved style. */
@@ -83,6 +83,7 @@ function placementStyle(placement, leftGutter, rightGutter) {
83
83
  */
84
84
  export function Legend({ placement = 'top-right', items, onRowClick, onRowHover, theme: themeProp, }) {
85
85
  const container = useContext(ContainerContext);
86
+ const cursor = useContext(CursorContext);
86
87
  if (container === null && items === undefined) {
87
88
  throw new Error('<Legend> must be inside a <ChartContainer> (or be given explicit `items`)');
88
89
  }
@@ -99,7 +100,9 @@ export function Legend({ placement = 'top-right', items, onRowClick, onRowHover,
99
100
  // The shared headless core (also `useChartLegend`'s) — rows + the id-gated
100
101
  // hover/select verbs — so the built-in card and a custom-rendered legend
101
102
  // can never disagree. `null` in standalone `items` mode (no chart to sync).
102
- const legend = container !== null ? buildChartLegend(container, row?.rowKey) : null;
103
+ const legend = container !== null
104
+ ? buildChartLegend(container, cursor, row?.rowKey)
105
+ : null;
103
106
  // The card renders a flat item list — `rows` is grouped by chart row, so
104
107
  // flatten it (a scoped legend has one group anyway).
105
108
  const entries = items ?? legend.rows.flatMap((r) => r.items);
package/dist/LineChart.js CHANGED
@@ -50,6 +50,7 @@ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DE
50
50
  }, [sessionBreaks, container.discontinuities, cs]);
51
51
  const entry = useMemo(() => ({
52
52
  layer: {
53
+ as: semantic,
53
54
  yExtent: () => yExtent(cs),
54
55
  // The container infers the shared x scale's kind + auto-fit domain from
55
56
  // its layers: a ValueSeries plots on a value axis, a TimeSeries on time.
@@ -95,6 +95,7 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
95
95
  const keyAt = useMemo(() => (i) => cs.x[i], [cs]);
96
96
  const entry = useMemo(() => ({
97
97
  layer: {
98
+ as: semantic,
98
99
  yExtent: () => scatterExtent(cs),
99
100
  // The container infers the shared x scale's kind + auto-fit domain from
100
101
  // its layers: a ValueSeries scatters on a value axis, a TimeSeries on time.
package/dist/XAxis.js CHANGED
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Fragment, useContext } from 'react';
3
3
  import { scaleLinear } from 'd3-scale';
4
4
  import { derivedTicks } from './derivedTicks.js';
5
- import { ContainerContext } from './context.js';
5
+ import { ContainerContext, CursorContext } from './context.js';
6
6
  import { axisPillStyle } from './chip.js';
7
7
  import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
8
8
  /** Tick strip height (mark + value label) in CSS px. */
@@ -60,6 +60,7 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
60
60
  if (container === null) {
61
61
  throw new Error('<XAxis> must be rendered inside a <ChartContainer>');
62
62
  }
63
+ const cursor = useContext(CursorContext);
63
64
  // `xTickCount` is the container's shared x-side count — the same value the x
64
65
  // gridlines and `formatTime` use, so labels and grid stay on the same instants
65
66
  // (width-derived on a trading-time axis).
@@ -68,7 +69,7 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
68
69
  // cursor is live in-bounds, pin the hovered time to this axis (covering the
69
70
  // tick behind it), matching the on-axis y value pills the rows draw. Gated on
70
71
  // the container default, so a per-row `cursor` override doesn't reach here.
71
- const cursorX = container.cursorX;
72
+ const cursorX = cursor.cursorX;
72
73
  const showCursorTag = container.cursor === 'crosshair' &&
73
74
  cursorX !== null &&
74
75
  cursorX >= 0 &&
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Affine-scale fast path (charts perf, [PND-AFFINE]). A chart's continuous
3
+ * scales — `scaleLinear` (value axis, every y axis), `scaleTime`, and the
4
+ * **gap-free** `scaleTradingTime(identityProvider())` (the default continuous
5
+ * time axis) — map data→pixels by a single `px = k·v + b`. The per-point draw
6
+ * loops in `drawLine` / `drawArea` can then multiply-add inline over the typed
7
+ * arrays instead of paying a d3-scale closure call (deinterpolate → interpolate)
8
+ * per point — the ~37% of stroke-bound frame self-time the 2026-07 external
9
+ * bench profile attributed to `scale()` (see
10
+ * `docs/notes/charts-bench-vs-scichart-suite-2026-07.md`, finding 1).
11
+ *
12
+ * The affine coefficients are recovered from the scale's own domain/range
13
+ * endpoints, then **verified affine** by probing interior points: a scale that
14
+ * deviates (a `scaleTradingTime` with *collapsed* gaps, or a future
15
+ * log/pow/sqrt axis) is rejected — the caller falls back to the exact d3-scale
16
+ * path — while a genuinely affine scale (including a gap-free trading axis) is
17
+ * accepted and reproduced to floating-point precision. The verification is what
18
+ * keeps the fast path a pure optimization: it never draws a non-affine scale as
19
+ * a straight line.
20
+ */
21
+ import type { Scale } from './line.js';
22
+ /** Coefficients of an affine pixel map `px = k·value + b`. */
23
+ export interface Affine {
24
+ readonly k: number;
25
+ readonly b: number;
26
+ }
27
+ /**
28
+ * The affine coefficients `{ k, b }` with `scale(v) === k·v + b` for all `v`, or
29
+ * `null` when the scale is not affine over its domain (a real-gap
30
+ * `scaleTradingTime`, a non-linear axis) or exposes no numeric domain/range (a
31
+ * bare `(v) => v` test stub, a `scaleBand` category axis). `null` ⇒ the caller
32
+ * keeps the d3-scale path.
33
+ *
34
+ * Recovered from the domain/range endpoints (`k` from the two extremes, `b`
35
+ * pinning the low end), then verified at {@link PROBE_FRACTIONS}. Every probe
36
+ * must map finite and within {@link PROBE_EPSILON} of the reconstruction — so a
37
+ * scale that returns non-numbers for an interior value (a `scaleBand`) or bends
38
+ * away from the endpoint line (trading gaps, log) is rejected.
39
+ */
40
+ export declare function affineOf(scale: Scale): Affine | null;
41
+ //# sourceMappingURL=affine.d.ts.map
package/dist/affine.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Affine-scale fast path (charts perf, [PND-AFFINE]). A chart's continuous
3
+ * scales — `scaleLinear` (value axis, every y axis), `scaleTime`, and the
4
+ * **gap-free** `scaleTradingTime(identityProvider())` (the default continuous
5
+ * time axis) — map data→pixels by a single `px = k·v + b`. The per-point draw
6
+ * loops in `drawLine` / `drawArea` can then multiply-add inline over the typed
7
+ * arrays instead of paying a d3-scale closure call (deinterpolate → interpolate)
8
+ * per point — the ~37% of stroke-bound frame self-time the 2026-07 external
9
+ * bench profile attributed to `scale()` (see
10
+ * `docs/notes/charts-bench-vs-scichart-suite-2026-07.md`, finding 1).
11
+ *
12
+ * The affine coefficients are recovered from the scale's own domain/range
13
+ * endpoints, then **verified affine** by probing interior points: a scale that
14
+ * deviates (a `scaleTradingTime` with *collapsed* gaps, or a future
15
+ * log/pow/sqrt axis) is rejected — the caller falls back to the exact d3-scale
16
+ * path — while a genuinely affine scale (including a gap-free trading axis) is
17
+ * accepted and reproduced to floating-point precision. The verification is what
18
+ * keeps the fast path a pure optimization: it never draws a non-affine scale as
19
+ * a straight line.
20
+ */
21
+ /**
22
+ * Irregular interior sample fractions for the affinity probe. Deliberately not
23
+ * `[0.25, 0.5, 0.75]` — a piecewise-linear scale (trading time) can have
24
+ * breakpoints that a symmetric, round-fraction probe set slips between; the
25
+ * jittered spread makes a false "affine" verdict on a real-gap scale
26
+ * astronomically unlikely (and the e2e visual-regression layer is the backstop,
27
+ * the same net that guards M4).
28
+ */
29
+ const PROBE_FRACTIONS = [0.1213, 0.2857, 0.4391, 0.6137, 0.7649, 0.8831];
30
+ /**
31
+ * Pixel tolerance for the affinity probe. Far below a sub-pixel (so a real
32
+ * non-affine deviation — a collapsed trading gap or a log curve is many pixels)
33
+ * yet far above the float-reconstruction noise of `k·v + b` on wide domains
34
+ * (~1e-9 px), so an exactly-affine scale is never rejected.
35
+ */
36
+ const PROBE_EPSILON = 1e-3;
37
+ /**
38
+ * The affine coefficients `{ k, b }` with `scale(v) === k·v + b` for all `v`, or
39
+ * `null` when the scale is not affine over its domain (a real-gap
40
+ * `scaleTradingTime`, a non-linear axis) or exposes no numeric domain/range (a
41
+ * bare `(v) => v` test stub, a `scaleBand` category axis). `null` ⇒ the caller
42
+ * keeps the d3-scale path.
43
+ *
44
+ * Recovered from the domain/range endpoints (`k` from the two extremes, `b`
45
+ * pinning the low end), then verified at {@link PROBE_FRACTIONS}. Every probe
46
+ * must map finite and within {@link PROBE_EPSILON} of the reconstruction — so a
47
+ * scale that returns non-numbers for an interior value (a `scaleBand`) or bends
48
+ * away from the endpoint line (trading gaps, log) is rejected.
49
+ */
50
+ export function affineOf(scale) {
51
+ const s = scale;
52
+ const d = s.domain?.();
53
+ const r = s.range?.();
54
+ if (d === undefined || r === undefined || d.length < 2 || r.length < 2) {
55
+ return null;
56
+ }
57
+ const lo = +d[0];
58
+ const hi = +d[d.length - 1];
59
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo === hi)
60
+ return null;
61
+ const pLo = scale(lo);
62
+ const pHi = scale(hi);
63
+ if (!Number.isFinite(pLo) || !Number.isFinite(pHi))
64
+ return null;
65
+ const k = (pHi - pLo) / (hi - lo);
66
+ const b = pLo - k * lo;
67
+ const span = hi - lo;
68
+ for (const t of PROBE_FRACTIONS) {
69
+ const v = lo + t * span;
70
+ const p = scale(v);
71
+ if (!Number.isFinite(p) || Math.abs(p - (k * v + b)) > PROBE_EPSILON) {
72
+ return null;
73
+ }
74
+ }
75
+ return { k, b };
76
+ }
77
+ //# sourceMappingURL=affine.js.map
package/dist/area.d.ts CHANGED
@@ -1,9 +1,27 @@
1
1
  import { type CurveFactory } from 'd3-shape';
2
2
  import type { ChartSeries } from './data.js';
3
- import type { Scale } from './line.js';
3
+ import { type Scale } from './line.js';
4
4
  import type { AreaStyle } from './theme.js';
5
+ import type { LayerDrawStats } from './context.js';
5
6
  import { type GapMode } from './gaps.js';
6
7
  import { type DecimateOption } from './decimate.js';
8
+ import { type Affine } from './affine.js';
9
+ export declare function columnFiniteExtent(y: Float64Array, length: number): readonly [number, number] | null;
10
+ /**
11
+ * Fill the area between an affine-mapped value polyline and a constant baseline
12
+ * pixel — the [PND-AFFINE] fast path for {@link drawArea}'s fill, the counterpart
13
+ * to {@link strokeAffinePolyline} for its outline. Emits one **independent closed
14
+ * polygon per finite run** (matching `d3.area`'s `.defined(Number.isFinite)`
15
+ * segmentation for a linear curve + constant `y0`): per run `[a, b)`,
16
+ * `moveTo(top_a)` → `lineTo(top…)` along the value edge → `lineTo(x_{b-1}, base)`
17
+ * → `lineTo(x_a, base)` → `closePath`. That is the same filled region `d3.area`
18
+ * draws — its flat backward baseline edge only adds collinear interior vertices,
19
+ * which don't change the fill — without the per-point `scale()` / d3-shape
20
+ * closures. A signed value edge crossing the baseline stays one polygon (no NaN),
21
+ * filled correctly on both sides. The caller brackets `beginPath`/`fill`;
22
+ * `xs`/`ys` are aligned index-for-index.
23
+ */
24
+ export declare function fillAffineArea(ctx: CanvasRenderingContext2D, xs: Float64Array, ys: Float64Array, baselinePx: number, ax: Affine, ay: Affine): void;
7
25
  /**
8
26
  * The `[min, max]` vertical extent an area occupies — the finite values of
9
27
  * `cs.y` widened to include `baseline`, since the fill spans from each value to
@@ -51,5 +69,5 @@ export declare function areaExtent(cs: ChartSeries, baseline: number | undefined
51
69
  * bracketed by `save`/`restore` so they don't leak into later layers. Gap edges
52
70
  * are collected by one O(N) walk ({@link collectGapEdges}).
53
71
  */
54
- export declare function drawArea(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: AreaStyle, baselineValue: number, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number, decimate?: DecimateOption): void;
72
+ export declare function drawArea(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: AreaStyle, baselineValue: number, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number, decimate?: DecimateOption): LayerDrawStats;
55
73
  //# sourceMappingURL=area.d.ts.map
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