@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/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.
@@ -1,6 +1,7 @@
1
1
  import { ValueSeries } from 'pond-ts';
2
2
  import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
3
3
  import { type ColorEncoding, type RadiusEncoding } from './encoding.js';
4
+ import type { DecimateOption } from './decimate.js';
4
5
  export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
5
6
  /**
6
7
  * The source series. A `TimeSeries` scatters against the time axis; a
@@ -85,6 +86,16 @@ export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS ext
85
86
  * together, so a nudged point still selects.
86
87
  */
87
88
  offset?: number;
89
+ /**
90
+ * Collapse dense, **uniform** marks to one representative per pixel cell —
91
+ * lossless at that density, so a scatter of 100k+ points stays interactive.
92
+ * **Default `true`.** It engages only when the marks are a fixed size + colour
93
+ * (no data-driven `radius`/`color`), the fill is opaque, and the visible points
94
+ * are denser than the pixel grid; otherwise every point draws. Interaction
95
+ * (hover / click / tracker) always reads the source points. `decimate={false}`
96
+ * draws every mark; `{ threshold }` tunes the samples-per-pixel trigger.
97
+ */
98
+ decimate?: DecimateOption;
88
99
  /**
89
100
  * This layer's `<Legend>` row: `false` ⇒ no row (opt out), a string ⇒ the
90
101
  * row's display name. **Omitted ⇒ a row named by the layer's readout
@@ -126,5 +137,5 @@ export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS ext
126
137
  * </Layers>
127
138
  * ```
128
139
  */
129
- export declare function ScatterChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, offset, legend, index, }: ScatterChartProps<S, VS>): null;
140
+ export declare function ScatterChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, offset, decimate, legend, index, }: ScatterChartProps<S, VS>): null;
130
141
  //# sourceMappingURL=ScatterChart.d.ts.map
@@ -34,7 +34,7 @@ import { useSlotKey } from './use-slot-key.js';
34
34
  * </Layers>
35
35
  * ```
36
36
  */
37
- export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, offset = 0, legend, index = 0, }) {
37
+ export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, offset = 0, decimate = true, legend, index = 0, }) {
38
38
  const container = useContext(ContainerContext);
39
39
  if (container === null) {
40
40
  throw new Error('<ScatterChart> must be rendered inside a <ChartContainer>');
@@ -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.
@@ -130,7 +131,7 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
130
131
  : {
131
132
  hitTest: (px, py, xScale, yScale) => hitTestScatter(cs, px, py, xScale, yScale, encoding, keyAt, id, seriesLabel, offset),
132
133
  }),
133
- draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id, offset),
134
+ draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id, offset, decimate),
134
135
  },
135
136
  axisId: axis,
136
137
  index,
@@ -147,6 +148,7 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
147
148
  font,
148
149
  container.selected,
149
150
  offset,
151
+ decimate,
150
152
  axis,
151
153
  index,
152
154
  ]);
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