@pond-ts/charts 0.53.0 → 0.54.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.
@@ -17,6 +17,14 @@ export interface AreaChartProps<S extends SeriesSchema = SeriesSchema, VS extend
17
17
  series: TimeSeries<S> | ValueSeries<VS>;
18
18
  /** Name of the numeric value column to fill from. */
19
19
  column: string;
20
+ /**
21
+ * Optional column to **read out** at the cursor instead of the plotted
22
+ * `column` — the area still fills `column`, but each tracker sample also
23
+ * carries this column's value as {@link TrackerSample.readout}, so an
24
+ * off-chart readout can show a **source** value while the area draws a derived
25
+ * one. Mirrors `<LineChart readout>`. **Omitted ⇒ no readout channel.**
26
+ */
27
+ readout?: string;
20
28
  /**
21
29
  * The series' semantic identifier — what the data _is_ / how it should read
22
30
  * (e.g. `elevation`, or a signed-traffic role like `in` / `out`). The theme
@@ -108,5 +116,5 @@ export interface AreaChartProps<S extends SeriesSchema = SeriesSchema, VS extend
108
116
  * </Layers>
109
117
  * ```
110
118
  */
111
- export declare function AreaChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, axis, baseline, curve, gaps, decimate, legend, index, }: AreaChartProps<S, VS>): null;
119
+ export declare function AreaChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, readout, as: semantic, axis, baseline, curve, gaps, decimate, legend, index, }: AreaChartProps<S, VS>): null;
112
120
  //# sourceMappingURL=AreaChart.d.ts.map
package/dist/AreaChart.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
2
  import { ValueSeries } from 'pond-ts';
3
- import { fromTimeSeries, fromValueSeries } from './data.js';
3
+ import { assertNumericColumn, fromTimeSeries, fromValueSeries, } from './data.js';
4
4
  import { areaExtent, drawArea } from './area.js';
5
5
  import { resolveCurve } from './curve.js';
6
6
  import { DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
@@ -36,7 +36,7 @@ function domainFloor(yScale) {
36
36
  * </Layers>
37
37
  * ```
38
38
  */
39
- export function AreaChart({ series, column, as: semantic, axis, baseline, curve, gaps = DEFAULT_GAP_MODE, decimate = true, legend, index = 0, }) {
39
+ export function AreaChart({ series, column, readout, as: semantic, axis, baseline, curve, gaps = DEFAULT_GAP_MODE, decimate = true, legend, index = 0, }) {
40
40
  const container = useContext(ContainerContext);
41
41
  if (container === null) {
42
42
  throw new Error('<AreaChart> must be rendered inside a <ChartContainer>');
@@ -48,6 +48,22 @@ export function AreaChart({ series, column, as: semantic, axis, baseline, curve,
48
48
  const cs = useMemo(() => series instanceof ValueSeries
49
49
  ? fromValueSeries(series, column)
50
50
  : fromTimeSeries(series, column), [series, column]);
51
+ // Readout column values for a value-axis series (time path reads it off the
52
+ // event) — the tracker reports it alongside the plotted fill so an off-chart
53
+ // readout can show a source value. See AreaChartProps.readout.
54
+ //
55
+ // The time path buffers nothing (it has an event, not an index), so it
56
+ // validates the name here instead, so a mistyped `readout` fails the same way
57
+ // on both axis kinds rather than throwing on one and silently doing nothing
58
+ // on the other. Mirrors `<LineChart>`.
59
+ const readoutY = useMemo(() => {
60
+ if (readout === undefined)
61
+ return undefined;
62
+ if (series instanceof ValueSeries)
63
+ return fromValueSeries(series, readout).y;
64
+ assertNumericColumn(series, readout);
65
+ return undefined;
66
+ }, [series, readout]);
51
67
  // Styling: semantic identifier → theme area style. The single styling channel.
52
68
  const { area } = container.theme;
53
69
  const style = (semantic !== undefined ? area[semantic] : undefined) ?? area.default;
@@ -77,8 +93,19 @@ export function AreaChart({ series, column, as: semantic, axis, baseline, curve,
77
93
  if (i < 0)
78
94
  return [];
79
95
  const v = cs.y[i];
96
+ const rv = readoutY?.[i];
80
97
  return Number.isFinite(v)
81
- ? [{ x: cs.x[i], value: v, color: style.color, label }]
98
+ ? [
99
+ {
100
+ x: cs.x[i],
101
+ value: v,
102
+ color: style.color,
103
+ label,
104
+ ...(rv !== undefined && Number.isFinite(rv)
105
+ ? { readout: rv }
106
+ : {}),
107
+ },
108
+ ]
82
109
  : [];
83
110
  }
84
111
  const e = series.nearest(x);
@@ -87,11 +114,23 @@ export function AreaChart({ series, column, as: semantic, axis, baseline, curve,
87
114
  // get() wants a literal key; column is a runtime string. Cast the
88
115
  // *event* (not the method — that would detach `this`) to a
89
116
  // string-keyed get; runtime-safe read + guard.
90
- const v = e.get(column);
117
+ const ev = e;
118
+ const v = ev.get(column);
119
+ const rv = readout !== undefined ? ev.get(readout) : undefined;
91
120
  // The readout dot rides the value line (not the baseline), coloured by
92
121
  // the outline stroke. A gap yields no readout (like the fill).
93
122
  return typeof v === 'number' && Number.isFinite(v)
94
- ? [{ x: e.begin(), value: v, color: style.color, label }]
123
+ ? [
124
+ {
125
+ x: e.begin(),
126
+ value: v,
127
+ color: style.color,
128
+ label,
129
+ ...(typeof rv === 'number' && Number.isFinite(rv)
130
+ ? { readout: rv }
131
+ : {}),
132
+ },
133
+ ]
95
134
  : [];
96
135
  },
97
136
  draw: (ctx, xScale, yScale) => drawArea(ctx, cs, xScale, yScale, style,
@@ -106,6 +145,8 @@ export function AreaChart({ series, column, as: semantic, axis, baseline, curve,
106
145
  cs,
107
146
  series,
108
147
  column,
148
+ readout,
149
+ readoutY,
109
150
  style,
110
151
  label,
111
152
  baseline,
@@ -68,14 +68,21 @@ export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends
68
68
  */
69
69
  colors?: Readonly<Record<string, string>>;
70
70
  /**
71
- * **Per-bin** colours for a single-series band chart — `binColors[i]` fills
72
- * bar `i` (aligned to the bins / bands in order), overriding the `as`/theme
73
- * fill. This is the way to colour heart-rate / power **zones** or value bands
74
- * each their own colour (the `colors` map above is per-**group**, for stacks).
75
- * An `undefined`/short entry falls back to the theme fill. Meant for a
76
- * single-series chart (`column` + `bins`, or a horizontal single series); on a
71
+ * **Per-bar** colours for a single-series chart — `binColors[i]` fills bar
72
+ * `i` (aligned to the bars / bins in order), overriding the `as`/theme fill.
73
+ * This is the way to colour heart-rate / power **zones** or value bands each
74
+ * their own colour, and the **direction-coloured financial volume row** a
75
+ * time-axis `series` derives `binColors` from its own data (rising / falling
76
+ * off open vs close) so volume bars read green / red under the candles (the
77
+ * `colors` map above is per-**group**, for stacks). An `undefined`/short
78
+ * entry falls back to the theme fill. Works on any **single-series** shape —
79
+ * a time / value `series` (vertical or horizontal) or `bins`; on a
77
80
  * multi-group stack it would tint every segment of a bin alike, so it's not
78
- * the tool there.
81
+ * the tool there. A per-bar-coloured bar keeps its own colour under hover /
82
+ * selection (the highlight pops opacity instead of swapping the fill), and
83
+ * the hover / click readout reports the bar's own colour. **Disables the
84
+ * dense-bar envelope decimation** (see `decimate`) — an envelope rect can't
85
+ * carry many bars' colours, so every visible bar draws.
79
86
  */
80
87
  binColors?: readonly (string | undefined)[];
81
88
  /**
@@ -130,7 +137,9 @@ export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends
130
137
  * at that density (a perf knob, not a style); interaction still reads the source
131
138
  * bars. Pass `false` to always draw every bar, or `{ threshold }` to tune the
132
139
  * samples-per-pixel factor. **No-op for a stacked / multi-group histogram** (the
133
- * categorical case is low-count; only the single-series path decimates).
140
+ * categorical case is low-count; only the single-series path decimates) — and
141
+ * **no-op when `binColors` is set** (an envelope rect would repaint its bars
142
+ * one flat colour; a per-bar-coloured layer draws every visible bar).
134
143
  */
135
144
  decimate?: DecimateOption;
136
145
  /**
package/dist/BarChart.js CHANGED
@@ -244,7 +244,9 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
244
244
  {
245
245
  x: (bs.begin[i] + bs.end[i]) / 2,
246
246
  value: v,
247
- color: singleStyle.fill,
247
+ // A per-bar colour wins over the flat fill, so the readout
248
+ // pill reads the bar's own colour (as the stacked path does).
249
+ color: binColors?.[i] ?? singleStyle.fill,
248
250
  label,
249
251
  },
250
252
  ];
@@ -257,17 +259,27 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
257
259
  const hit = barAt(bs, px, py, xScale, yScale, baseline, gapPx, singleStyle.minWidth);
258
260
  if (hit === null)
259
261
  return null;
260
- const [, begin, value] = hit;
262
+ const [bi, begin, value] = hit;
263
+ // The bar's stable `mark` (its own axis key) rides the
264
+ // selection, so the highlight match and a controlled echo key
265
+ // on the *sample* rather than on the `begin` edge — which on a
266
+ // point-keyed series is derived geometry, not the sample's key
267
+ // (see BarSeries.marks). `bi` is the exact bar index from the
268
+ // hit, as the stacked path uses it.
269
+ const stableMark = bs.marks?.[bi];
261
270
  return {
262
271
  id,
263
272
  key: begin,
264
273
  value,
265
- color: singleStyle.fill,
274
+ // The bar's own colour when per-bar coloured (stacked-path
275
+ // parity: the readout pill matches the pixels).
276
+ color: binColors?.[bi] ?? singleStyle.fill,
266
277
  label,
278
+ ...(stableMark !== undefined ? { mark: stableMark } : {}),
267
279
  };
268
280
  },
269
281
  }),
270
- draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate),
282
+ draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate, binColors),
271
283
  },
272
284
  axisId: axis,
273
285
  index,
@@ -335,6 +347,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
335
347
  orientation,
336
348
  singleStyle,
337
349
  stackStyle,
350
+ binColors,
338
351
  label,
339
352
  id,
340
353
  gapPx,
@@ -510,7 +510,14 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
510
510
  })
511
511
  : cursorFormat !== undefined
512
512
  ? resolveTimeFormat(e, xTickCount, cursorFormat)
513
- : fine;
513
+ : // A container `timeFormat` shapes the readout too when no
514
+ // `cursorFormat` is set — its documented back-compat behaviour,
515
+ // and the same inversion as the `<XAxis format>` one a rung down:
516
+ // a custom format that owns the labels must own the pill rather
517
+ // than watch the elapsed default overrule it (PR #541 review).
518
+ timeFormat !== undefined
519
+ ? labels
520
+ : fine;
514
521
  return { xScale: e, formatTime: labels, formatReadout: readout };
515
522
  };
516
523
  if (xDiscontinuities !== undefined) {
@@ -688,6 +695,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
688
695
  formatTime,
689
696
  formatReadout,
690
697
  xFormatCustom: timeFormat !== undefined,
698
+ xReadoutCustom: cursorFormat !== undefined,
691
699
  xTickCount,
692
700
  registerTrackerSource,
693
701
  unregisterTrackerSource,
@@ -749,6 +757,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
749
757
  formatTime,
750
758
  formatReadout,
751
759
  timeFormat,
760
+ cursorFormat,
752
761
  xTickCount,
753
762
  registerTrackerSource,
754
763
  unregisterTrackerSource,
@@ -18,6 +18,17 @@ export interface LineChartProps<S extends SeriesSchema = SeriesSchema, VS extend
18
18
  series: TimeSeries<S> | ValueSeries<VS>;
19
19
  /** Name of the numeric value column to plot. */
20
20
  column: string;
21
+ /**
22
+ * Optional column to **read out** at the cursor instead of the plotted
23
+ * `column`. The layer still plots `column`; each tracker sample additionally
24
+ * carries this column's value as {@link TrackerSample.readout}, so an
25
+ * off-chart readout can show the **source** value while the line draws a
26
+ * derived one — a smoothed / transformed / normalized plot with a raw-value
27
+ * readout (estela plots pace-space + Gaussian-smoothed, reads the native m/s).
28
+ * The plotted `value` (hence the in-chart cursor dot) is unchanged.
29
+ * **Omitted ⇒ no readout channel** (`readout` is `undefined` on the sample).
30
+ */
31
+ readout?: string;
21
32
  /**
22
33
  * The series' semantic identifier — what the data _is_ / how it should read
23
34
  * (e.g. `heartrate`, `power`, or a role name like `foam`). The theme maps it
@@ -94,5 +105,5 @@ export interface LineChartProps<S extends SeriesSchema = SeriesSchema, VS extend
94
105
  * (scaling against its `axis`), and renders nothing to the DOM — the row draws
95
106
  * it. The line breaks at gaps rather than spanning them.
96
107
  */
97
- export declare function LineChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, axis, curve, gaps, sessionBreaks, decimate, legend, index, }: LineChartProps<S, VS>): null;
108
+ export declare function LineChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, readout, as: semantic, axis, curve, gaps, sessionBreaks, decimate, legend, index, }: LineChartProps<S, VS>): null;
98
109
  //# sourceMappingURL=LineChart.d.ts.map
package/dist/LineChart.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
2
  import { ValueSeries } from 'pond-ts';
3
- import { fromTimeSeries, fromValueSeries } from './data.js';
3
+ import { assertNumericColumn, fromTimeSeries, fromValueSeries, } from './data.js';
4
4
  import { drawLine, yExtent } from './line.js';
5
5
  import { resolveCurve } from './curve.js';
6
6
  import { DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
@@ -16,7 +16,7 @@ const NO_BREAKS = [];
16
16
  * (scaling against its `axis`), and renders nothing to the DOM — the row draws
17
17
  * it. The line breaks at gaps rather than spanning them.
18
18
  */
19
- export function LineChart({ series, column, as: semantic, axis, curve, gaps = DEFAULT_GAP_MODE, sessionBreaks = false, decimate = true, legend, index = 0, }) {
19
+ export function LineChart({ series, column, readout, as: semantic, axis, curve, gaps = DEFAULT_GAP_MODE, sessionBreaks = false, decimate = true, legend, index = 0, }) {
20
20
  const container = useContext(ContainerContext);
21
21
  if (container === null) {
22
22
  throw new Error('<LineChart> must be rendered inside a <ChartContainer>');
@@ -28,6 +28,22 @@ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DE
28
28
  const cs = useMemo(() => series instanceof ValueSeries
29
29
  ? fromValueSeries(series, column)
30
30
  : fromTimeSeries(series, column), [series, column]);
31
+ // Readout column values for a value-axis series (the time path reads it off
32
+ // the event in `sampleAt`). Built once per (series, readout) so the tracker
33
+ // can report a source value the line doesn't plot — see LineChartProps.readout.
34
+ //
35
+ // The time path buffers nothing (it has an event, not an index), so it
36
+ // validates the name here instead: otherwise a mistyped `readout` throws on a
37
+ // value axis but silently yields no readout on a time axis, and the same typo
38
+ // fails two different ways. Both now throw the reader's errors.
39
+ const readoutY = useMemo(() => {
40
+ if (readout === undefined)
41
+ return undefined;
42
+ if (series instanceof ValueSeries)
43
+ return fromValueSeries(series, readout).y;
44
+ assertNumericColumn(series, readout);
45
+ return undefined;
46
+ }, [series, readout]);
31
47
  // Styling: semantic identifier → theme style. The single styling channel.
32
48
  const { line } = container.theme;
33
49
  const style = (semantic !== undefined ? line[semantic] : undefined) ?? line.default;
@@ -68,8 +84,19 @@ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DE
68
84
  if (i < 0)
69
85
  return [];
70
86
  const v = cs.y[i];
87
+ const rv = readoutY?.[i];
71
88
  return Number.isFinite(v)
72
- ? [{ x: cs.x[i], value: v, color: style.color, label }]
89
+ ? [
90
+ {
91
+ x: cs.x[i],
92
+ value: v,
93
+ color: style.color,
94
+ label,
95
+ ...(rv !== undefined && Number.isFinite(rv)
96
+ ? { readout: rv }
97
+ : {}),
98
+ },
99
+ ]
73
100
  : [];
74
101
  }
75
102
  const e = series.nearest(x);
@@ -78,9 +105,21 @@ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DE
78
105
  // get() wants a literal key; column is a runtime string. Cast the
79
106
  // *event* (not the method — that would detach `this`) to a
80
107
  // string-keyed get; runtime-safe read + guard.
81
- const v = e.get(column);
108
+ const ev = e;
109
+ const v = ev.get(column);
110
+ const rv = readout !== undefined ? ev.get(readout) : undefined;
82
111
  return typeof v === 'number' && Number.isFinite(v)
83
- ? [{ x: e.begin(), value: v, color: style.color, label }]
112
+ ? [
113
+ {
114
+ x: e.begin(),
115
+ value: v,
116
+ color: style.color,
117
+ label,
118
+ ...(typeof rv === 'number' && Number.isFinite(rv)
119
+ ? { readout: rv }
120
+ : {}),
121
+ },
122
+ ]
84
123
  : [];
85
124
  },
86
125
  draw: (ctx, xScale, yScale) => drawLine(ctx, cs, xScale, yScale, style, curveFactory, gaps, gapConnectorOpacity, sessionBreakInstants, decimate),
@@ -91,6 +130,8 @@ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DE
91
130
  cs,
92
131
  series,
93
132
  column,
133
+ readout,
134
+ readoutY,
94
135
  style,
95
136
  label,
96
137
  curveFactory,
package/dist/XAxis.js CHANGED
@@ -165,7 +165,19 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
165
165
  // own channel, so precise-pill-over-terse-ticks works on any axis kind. A
166
166
  // `transform`ed axis is exempt — its pill speaks the derived unit, which a
167
167
  // data-unit `cursorFormat` can't address.
168
- const readoutFmt = transform === undefined ? (container.formatReadout ?? fmt) : fmt;
168
+ //
169
+ // The middle rung is why `xReadoutCustom` exists: `formatReadout` carries
170
+ // either a real `cursorFormat` (which outranks this axis's `format`) or the
171
+ // axis kind's own default readout (which does not — an elapsed container's
172
+ // finer `00:05:12`). Reading the field alone can't tell them apart, so a
173
+ // wall-clock strip declared as `<XAxis format="%H:%M">` under an elapsed
174
+ // container used to label its ticks `11:33` and pill them `00:05:12`
175
+ // (issue #540).
176
+ const readoutFmt = transform !== undefined
177
+ ? fmt
178
+ : format !== undefined && !container.xReadoutCustom
179
+ ? fmt
180
+ : (container.formatReadout ?? fmt);
169
181
  // Marker annotations that opted into an axis indicator (`<Marker indicator>`)
170
182
  // pin their **time** to this shared x-axis — a pill at `at`, in the annotation
171
183
  // colour, reading like a tick. An indicator always shows the axis coordinate
package/dist/affine.d.ts CHANGED
@@ -2,13 +2,25 @@
2
2
  * Affine-scale fast path (charts perf, [PND-AFFINE]). A chart's continuous
3
3
  * scales — `scaleLinear` (value axis, every y axis), `scaleTime`, and the
4
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
5
+ * time axis) — map data→pixels affinely. The per-point draw loops in
6
+ * `drawLine` / `drawArea` can then evaluate the map inline over the typed
7
7
  * arrays instead of paying a d3-scale closure call (deinterpolate → interpolate)
8
8
  * per point — the ~37% of stroke-bound frame self-time the 2026-07 external
9
9
  * bench profile attributed to `scale()` (see
10
10
  * `docs/notes/charts-bench-vs-scichart-suite-2026-07.md`, finding 1).
11
11
  *
12
+ * The map is stored and evaluated in the **rebased** form
13
+ * `px = (v − v0)·k + p0` (v0 = the domain's low endpoint, p0 = scale(v0)) —
14
+ * the same association d3's own deinterpolate → interpolate uses — never
15
+ * expanded to `k·v + b`. The expanded form is catastrophically ill-conditioned
16
+ * on epoch-millisecond domains: with t ≈ 1.8e12 and a deeply zoomed window,
17
+ * `k·t` and `b` are huge near-cancelling terms whose rounding (½ ULP of `k·t`)
18
+ * survives the cancellation — ~0.16 px reconstruction error at a 1 ms window,
19
+ * ~24 px at 1 µs (measured). Under the expanded form the interior probe below
20
+ * caught that drift and *rejected* the scale, so deep-zoomed frames silently
21
+ * lost the fast path; the rebased form evaluates to ≲1e-9 px of the exact
22
+ * d3 path at every zoom depth, so the fast path stays engaged.
23
+ *
12
24
  * The affine coefficients are recovered from the scale's own domain/range
13
25
  * endpoints, then **verified affine** by probing interior points: a scale that
14
26
  * deviates (a `scaleTradingTime` with *collapsed* gaps, or a future
@@ -19,23 +31,32 @@
19
31
  * a straight line.
20
32
  */
21
33
  import type { Scale } from './line.js';
22
- /** Coefficients of an affine pixel map `px = k·value + b`. */
34
+ /**
35
+ * Coefficients of an affine pixel map in rebased form:
36
+ * `px = (value − v0)·k + p0`. `v0` is the domain's low endpoint and `p0` its
37
+ * pixel image, so the multiply sees the O(span) offset `value − v0` (exact for
38
+ * in-domain values, by Sterbenz cancellation) rather than an O(1e12) absolute
39
+ * epoch value — see the module comment for why the expanded `k·value + b`
40
+ * form must not be reintroduced.
41
+ */
23
42
  export interface Affine {
24
43
  readonly k: number;
25
- readonly b: number;
44
+ readonly v0: number;
45
+ readonly p0: number;
26
46
  }
27
47
  /**
28
- * The affine coefficients `{ k, b }` with `scale(v) === 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.
48
+ * The affine coefficients `{ k, v0, p0 }` with `scale(v) === (v v0)·k + p0`
49
+ * for all `v`, or `null` when the scale is not affine over its domain (a
50
+ * real-gap `scaleTradingTime`, a non-linear axis) or exposes no numeric
51
+ * domain/range (a bare `(v) => v` test stub, a `scaleBand` category axis).
52
+ * `null` ⇒ the caller keeps the d3-scale path.
33
53
  *
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.
54
+ * Recovered from the domain/range endpoints (`k` from the two extremes, the
55
+ * `(v0, p0)` base pinning the low end), then verified at
56
+ * {@link PROBE_FRACTIONS}. Every probe must map finite and within
57
+ * {@link PROBE_EPSILON} of the reconstruction so a scale that returns
58
+ * non-numbers for an interior value (a `scaleBand`) or bends away from the
59
+ * endpoint line (trading gaps, log) is rejected.
39
60
  */
40
61
  export declare function affineOf(scale: Scale): Affine | null;
41
62
  //# sourceMappingURL=affine.d.ts.map
package/dist/affine.js CHANGED
@@ -2,13 +2,25 @@
2
2
  * Affine-scale fast path (charts perf, [PND-AFFINE]). A chart's continuous
3
3
  * scales — `scaleLinear` (value axis, every y axis), `scaleTime`, and the
4
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
5
+ * time axis) — map data→pixels affinely. The per-point draw loops in
6
+ * `drawLine` / `drawArea` can then evaluate the map inline over the typed
7
7
  * arrays instead of paying a d3-scale closure call (deinterpolate → interpolate)
8
8
  * per point — the ~37% of stroke-bound frame self-time the 2026-07 external
9
9
  * bench profile attributed to `scale()` (see
10
10
  * `docs/notes/charts-bench-vs-scichart-suite-2026-07.md`, finding 1).
11
11
  *
12
+ * The map is stored and evaluated in the **rebased** form
13
+ * `px = (v − v0)·k + p0` (v0 = the domain's low endpoint, p0 = scale(v0)) —
14
+ * the same association d3's own deinterpolate → interpolate uses — never
15
+ * expanded to `k·v + b`. The expanded form is catastrophically ill-conditioned
16
+ * on epoch-millisecond domains: with t ≈ 1.8e12 and a deeply zoomed window,
17
+ * `k·t` and `b` are huge near-cancelling terms whose rounding (½ ULP of `k·t`)
18
+ * survives the cancellation — ~0.16 px reconstruction error at a 1 ms window,
19
+ * ~24 px at 1 µs (measured). Under the expanded form the interior probe below
20
+ * caught that drift and *rejected* the scale, so deep-zoomed frames silently
21
+ * lost the fast path; the rebased form evaluates to ≲1e-9 px of the exact
22
+ * d3 path at every zoom depth, so the fast path stays engaged.
23
+ *
12
24
  * The affine coefficients are recovered from the scale's own domain/range
13
25
  * endpoints, then **verified affine** by probing interior points: a scale that
14
26
  * deviates (a `scaleTradingTime` with *collapsed* gaps, or a future
@@ -30,22 +42,25 @@ const PROBE_FRACTIONS = [0.1213, 0.2857, 0.4391, 0.6137, 0.7649, 0.8831];
30
42
  /**
31
43
  * Pixel tolerance for the affinity probe. Far below a sub-pixel (so a real
32
44
  * 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.
45
+ * yet far above the float-reconstruction noise of the rebased
46
+ * `(v − v0)·k + p0` against d3's own evaluation (≲1e-9 px at any domain
47
+ * magnitude or zoom depth — both sides subtract the domain origin before
48
+ * multiplying), so an exactly-affine scale is never rejected.
35
49
  */
36
50
  const PROBE_EPSILON = 1e-3;
37
51
  /**
38
- * The affine coefficients `{ k, b }` with `scale(v) === 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.
52
+ * The affine coefficients `{ k, v0, p0 }` with `scale(v) === (v v0)·k + p0`
53
+ * for all `v`, or `null` when the scale is not affine over its domain (a
54
+ * real-gap `scaleTradingTime`, a non-linear axis) or exposes no numeric
55
+ * domain/range (a bare `(v) => v` test stub, a `scaleBand` category axis).
56
+ * `null` ⇒ the caller keeps the d3-scale path.
43
57
  *
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.
58
+ * Recovered from the domain/range endpoints (`k` from the two extremes, the
59
+ * `(v0, p0)` base pinning the low end), then verified at
60
+ * {@link PROBE_FRACTIONS}. Every probe must map finite and within
61
+ * {@link PROBE_EPSILON} of the reconstruction so a scale that returns
62
+ * non-numbers for an interior value (a `scaleBand`) or bends away from the
63
+ * endpoint line (trading gaps, log) is rejected.
49
64
  */
50
65
  export function affineOf(scale) {
51
66
  const s = scale;
@@ -63,15 +78,17 @@ export function affineOf(scale) {
63
78
  if (!Number.isFinite(pLo) || !Number.isFinite(pHi))
64
79
  return null;
65
80
  const k = (pHi - pLo) / (hi - lo);
66
- const b = pLo - k * lo;
67
81
  const span = hi - lo;
68
82
  for (const t of PROBE_FRACTIONS) {
69
83
  const v = lo + t * span;
70
84
  const p = scale(v);
71
- if (!Number.isFinite(p) || Math.abs(p - (k * v + b)) > PROBE_EPSILON) {
85
+ // Probe the exact rebased expression the draw loops evaluate, so what is
86
+ // verified is what runs.
87
+ if (!Number.isFinite(p) ||
88
+ Math.abs(p - ((v - lo) * k + pLo)) > PROBE_EPSILON) {
72
89
  return null;
73
90
  }
74
91
  }
75
- return { k, b };
92
+ return { k, v0: lo, p0: pLo };
76
93
  }
77
94
  //# sourceMappingURL=affine.js.map
package/dist/area.js CHANGED
@@ -65,8 +65,8 @@ export function fillAffineArea(ctx, xs, ys, baselinePx, ax, ay) {
65
65
  for (let j = 0; j <= n; j += 1) {
66
66
  const finite = j < n && Number.isFinite(ys[j]);
67
67
  if (finite) {
68
- const px = ax.k * xs[j] + ax.b;
69
- const py = ay.k * ys[j] + ay.b;
68
+ const px = (xs[j] - ax.v0) * ax.k + ax.p0;
69
+ const py = (ys[j] - ay.v0) * ay.k + ay.p0;
70
70
  if (runStart < 0) {
71
71
  runStart = j;
72
72
  ctx.moveTo(px, py);
@@ -78,8 +78,8 @@ export function fillAffineArea(ctx, xs, ys, baselinePx, ax, ay) {
78
78
  else if (runStart >= 0) {
79
79
  // Close the run: drop to the baseline under the last point, run flat back
80
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);
81
+ ctx.lineTo((xs[j - 1] - ax.v0) * ax.k + ax.p0, baselinePx);
82
+ ctx.lineTo((xs[runStart] - ax.v0) * ax.k + ax.p0, baselinePx);
83
83
  ctx.closePath();
84
84
  runStart = -1;
85
85
  }