@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/CHANGELOG.md +113 -1
- package/dist/AreaChart.js +1 -0
- package/dist/BandChart.js +1 -0
- package/dist/BarChart.d.ts +14 -1
- package/dist/BarChart.js +5 -2
- package/dist/BoxPlot.js +1 -0
- package/dist/Candlestick.js +1 -0
- package/dist/ChartContainer.d.ts +61 -11
- package/dist/ChartContainer.js +70 -23
- package/dist/Layers.js +53 -14
- package/dist/Legend.js +5 -2
- package/dist/LineChart.js +1 -0
- package/dist/ScatterChart.js +1 -0
- package/dist/XAxis.js +3 -2
- package/dist/affine.d.ts +41 -0
- package/dist/affine.js +77 -0
- package/dist/area.d.ts +20 -2
- package/dist/area.js +151 -45
- package/dist/band.d.ts +2 -1
- package/dist/band.js +5 -0
- package/dist/bars.d.ts +14 -1
- package/dist/bars.js +42 -1
- package/dist/box.d.ts +2 -1
- package/dist/box.js +8 -3
- package/dist/context.d.ts +121 -22
- package/dist/context.js +8 -0
- package/dist/data.d.ts +8 -0
- package/dist/decimate.d.ts +78 -1
- package/dist/decimate.js +132 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +5 -3
- package/dist/line.d.ts +15 -1
- package/dist/line.js +84 -30
- package/dist/ohlc.d.ts +2 -1
- package/dist/ohlc.js +8 -3
- package/dist/tracker.d.ts +17 -4
- package/dist/tracker.js +19 -6
- package/dist/useChartLegend.d.ts +2 -2
- package/dist/useChartLegend.js +8 -7
- package/dist/viewport.d.ts +19 -0
- package/dist/viewport.js +32 -0
- 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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
728
|
-
const cy =
|
|
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
|
-
:
|
|
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.
|
|
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
|
|
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.
|
package/dist/ScatterChart.js
CHANGED
|
@@ -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 =
|
|
72
|
+
const cursorX = cursor.cursorX;
|
|
72
73
|
const showCursorTag = container.cursor === 'crosshair' &&
|
|
73
74
|
cursorX !== null &&
|
|
74
75
|
cursorX >= 0 &&
|
package/dist/affine.d.ts
ADDED
|
@@ -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
|
|
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):
|
|
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 {
|
|
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.
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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 (
|
|
123
|
-
//
|
|
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
|
|
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,
|
|
168
|
-
|
|
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):
|
|
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
|