@pond-ts/charts 0.49.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 +138 -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.d.ts +12 -1
- package/dist/BoxPlot.js +4 -2
- package/dist/Candlestick.d.ts +14 -1
- package/dist/Candlestick.js +4 -2
- 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 +3 -1
- package/dist/box.js +26 -6
- 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 +116 -1
- package/dist/decimate.js +251 -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 +3 -1
- package/dist/ohlc.js +26 -5
- 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/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
|
package/dist/band.js
CHANGED
|
@@ -41,6 +41,7 @@ export function bandExtent(band) {
|
|
|
41
41
|
* opacity and is restored so it doesn't leak into later layers.
|
|
42
42
|
*/
|
|
43
43
|
export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear, decimate = true) {
|
|
44
|
+
const sourceCount = band.length; // pre-cull, pre-decimation (for draw stats)
|
|
44
45
|
// Viewport culling (Phase 2): clip the envelope to the visible slice (+1 each
|
|
45
46
|
// side) before filling, so a pan strokes O(visible). The solid fill has no
|
|
46
47
|
// cross-point state, so a zero-copy subarray view is exact; a no-op (same
|
|
@@ -52,9 +53,12 @@ export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear,
|
|
|
52
53
|
// pixels. Gated off a smoothing `curve` (which would distort the per-column
|
|
53
54
|
// envelope) and `decimate === false`; `decimateBand` itself no-ops on a sparse
|
|
54
55
|
// envelope or a domainless test scale, so this stays byte-identical there.
|
|
56
|
+
let decimated = false;
|
|
55
57
|
if (decimate !== false && curve === curveLinear) {
|
|
56
58
|
const k = typeof decimate === 'object' ? decimate.threshold : undefined;
|
|
59
|
+
const before = band;
|
|
57
60
|
band = decimateBand(band, xScale, ctx, k);
|
|
61
|
+
decimated = band !== before;
|
|
58
62
|
}
|
|
59
63
|
const gen = d3area()
|
|
60
64
|
.defined((_, i) => Number.isFinite(band.lower[i]) && Number.isFinite(band.upper[i]))
|
|
@@ -70,5 +74,6 @@ export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear,
|
|
|
70
74
|
gen(band.lower);
|
|
71
75
|
ctx.fill();
|
|
72
76
|
ctx.restore();
|
|
77
|
+
return { sourceCount, drawnCount: band.length, decimated };
|
|
73
78
|
}
|
|
74
79
|
//# sourceMappingURL=band.js.map
|
package/dist/bars.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { BarSeries, StackedBarSeries } from './data.js';
|
|
2
2
|
import type { Scale } from './line.js';
|
|
3
3
|
import type { BarStyle } from './theme.js';
|
|
4
|
+
import type { LayerDrawStats } from './context.js';
|
|
5
|
+
import { type DecimateOption } from './decimate.js';
|
|
4
6
|
/**
|
|
5
7
|
* Bar growth direction — the histogram orientation. `'vertical'` bars grow **up**
|
|
6
8
|
* from a value baseline, bins on the x axis (the column / time-bucket look);
|
|
@@ -67,6 +69,17 @@ export declare function barRect(cs: BarSeries, i: number, xScale: Scale, yScale:
|
|
|
67
69
|
*
|
|
68
70
|
* O(N) over the events, one fill (+ optional stroke) per bar, no per-bar
|
|
69
71
|
* allocation beyond the rect tuple.
|
|
72
|
+
*
|
|
73
|
+
* **M4 column decimation ([PND-MARKDEC]):** once the *visible* bars are denser
|
|
74
|
+
* than ~2 per device pixel, they overplot into a solid silhouette, so
|
|
75
|
+
* `decimate !== false` replaces them with one **envelope rect per pixel column**
|
|
76
|
+
* ({@link decimateBars} — `[min(value, baseline), max(value, baseline)]`), from
|
|
77
|
+
* O(W) rects instead of O(visible). Gated on the *visible* count (a bar's width
|
|
78
|
+
* is its slot). The decimated pass draws the flat `fill` only — the aggregate
|
|
79
|
+
* columns aren't individually selectable, so per-bar selection/hover highlight is
|
|
80
|
+
* suppressed (a <1px bar's ring wouldn't be visible anyway); interaction still
|
|
81
|
+
* reads the **source** bars via {@link barAt} (§2.3). Pass `decimate={false}` to
|
|
82
|
+
* always draw every bar. Returns {@link LayerDrawStats} for `onDrawStats`.
|
|
70
83
|
*/
|
|
71
84
|
export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, xScale: Scale, yScale: Scale, style: BarStyle, baseline: number, gapPx: number, seriesId: string | undefined, selection: {
|
|
72
85
|
key: number;
|
|
@@ -74,7 +87,7 @@ export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, x
|
|
|
74
87
|
} | null, hovered: {
|
|
75
88
|
key: number;
|
|
76
89
|
id: string;
|
|
77
|
-
} | null):
|
|
90
|
+
} | null, decimate?: DecimateOption): LayerDrawStats;
|
|
78
91
|
/**
|
|
79
92
|
* The index of the bar whose key span `[begin, end]` contains `time` — the bar
|
|
80
93
|
* **under the cursor** — or `-1` if `time` falls in no bar's span. This is the
|
package/dist/bars.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { barSpanPx } from './range.js';
|
|
2
2
|
import { visibleSpanRange } from './culling.js';
|
|
3
|
+
import { decimateBars } from './decimate.js';
|
|
3
4
|
/**
|
|
4
5
|
* The `[min, max]` vertical extent the bars occupy — the finite values of `cs.y`
|
|
5
6
|
* **widened to include `0`**, since a bar spans from its value to the baseline
|
|
@@ -93,16 +94,54 @@ export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx) {
|
|
|
93
94
|
*
|
|
94
95
|
* O(N) over the events, one fill (+ optional stroke) per bar, no per-bar
|
|
95
96
|
* allocation beyond the rect tuple.
|
|
97
|
+
*
|
|
98
|
+
* **M4 column decimation ([PND-MARKDEC]):** once the *visible* bars are denser
|
|
99
|
+
* than ~2 per device pixel, they overplot into a solid silhouette, so
|
|
100
|
+
* `decimate !== false` replaces them with one **envelope rect per pixel column**
|
|
101
|
+
* ({@link decimateBars} — `[min(value, baseline), max(value, baseline)]`), from
|
|
102
|
+
* O(W) rects instead of O(visible). Gated on the *visible* count (a bar's width
|
|
103
|
+
* is its slot). The decimated pass draws the flat `fill` only — the aggregate
|
|
104
|
+
* columns aren't individually selectable, so per-bar selection/hover highlight is
|
|
105
|
+
* suppressed (a <1px bar's ring wouldn't be visible anyway); interaction still
|
|
106
|
+
* reads the **source** bars via {@link barAt} (§2.3). Pass `decimate={false}` to
|
|
107
|
+
* always draw every bar. Returns {@link LayerDrawStats} for `onDrawStats`.
|
|
96
108
|
*/
|
|
97
|
-
export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered) {
|
|
109
|
+
export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered, decimate = true) {
|
|
98
110
|
ctx.save();
|
|
99
111
|
ctx.globalAlpha = style.opacity;
|
|
112
|
+
const sourceCount = cs.length; // pre-cull, pre-decimation (for draw stats)
|
|
100
113
|
// Viewport culling (Phase 2): draw only the bars whose span overlaps the
|
|
101
114
|
// visible x-window (+1 each side). The loop keeps the original index `i`, so
|
|
102
115
|
// the `begin[i]` selection/hover match stays correct; full range when `xScale`
|
|
103
116
|
// has no domain (a test stub). A selected/hovered bar off-screen isn't drawn
|
|
104
117
|
// (its highlight would be off-screen anyway).
|
|
105
118
|
const [vStart, vEnd] = visibleSpanRange(cs.begin, cs.end, cs.length, xScale);
|
|
119
|
+
// Decimate the visible bars to per-column envelope rects once dense (see the
|
|
120
|
+
// header). `null` below the visible-density threshold ⇒ the full per-bar loop.
|
|
121
|
+
// `{ threshold }` tunes the samples-per-pixel factor `k` (as line/area/band do);
|
|
122
|
+
// `undefined` ⇒ decimateBars' default (2).
|
|
123
|
+
const k = typeof decimate === 'object' ? decimate.threshold : undefined;
|
|
124
|
+
const envelope = decimate !== false
|
|
125
|
+
? decimateBars(cs, xScale, ctx, baseline, k, vEnd - vStart)
|
|
126
|
+
: null;
|
|
127
|
+
if (envelope !== null) {
|
|
128
|
+
ctx.fillStyle = style.fill;
|
|
129
|
+
let drawn = 0;
|
|
130
|
+
for (let b = 0; b < envelope.length; b += 1) {
|
|
131
|
+
const lo = envelope.lo[b];
|
|
132
|
+
if (!Number.isFinite(lo))
|
|
133
|
+
continue; // empty column
|
|
134
|
+
const [x0, x1] = barSpanPx(envelope.begin[b], envelope.end[b], xScale, 0, // tile the column — a per-bar gapPx is invisible at <1px bars
|
|
135
|
+
style.minWidth);
|
|
136
|
+
const yTop = yScale(envelope.hi[b]);
|
|
137
|
+
const yBottom = yScale(lo);
|
|
138
|
+
ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
139
|
+
drawn += 1;
|
|
140
|
+
}
|
|
141
|
+
ctx.restore();
|
|
142
|
+
return { sourceCount, drawnCount: drawn, decimated: true };
|
|
143
|
+
}
|
|
144
|
+
let drawn = 0;
|
|
106
145
|
for (let i = vStart; i < vEnd; i += 1) {
|
|
107
146
|
const rect = barRect(cs, i, xScale, yScale, baseline, gapPx, style.minWidth);
|
|
108
147
|
if (rect === null)
|
|
@@ -122,6 +161,7 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
|
|
|
122
161
|
hovered.key === cs.begin[i];
|
|
123
162
|
ctx.fillStyle = selected || isHovered ? style.highlight : style.fill;
|
|
124
163
|
ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
164
|
+
drawn += 1;
|
|
125
165
|
if (selected) {
|
|
126
166
|
// The selected bar gets an outline so it reads at full strength over the
|
|
127
167
|
// (alpha'd) fills. Stroke at full opacity (reset within the save bracket).
|
|
@@ -133,6 +173,7 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
|
|
|
133
173
|
}
|
|
134
174
|
}
|
|
135
175
|
ctx.restore();
|
|
176
|
+
return { sourceCount, drawnCount: drawn, decimated: false };
|
|
136
177
|
}
|
|
137
178
|
/**
|
|
138
179
|
* The index of the bar whose key span `[begin, end]` contains `time` — the bar
|
package/dist/box.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { BoxSeries } from './data.js';
|
|
2
2
|
import type { Scale } from './line.js';
|
|
3
3
|
import type { BoxStyle } from './theme.js';
|
|
4
|
+
import type { LayerDrawStats } from './context.js';
|
|
5
|
+
import { type DecimateOption } from './decimate.js';
|
|
4
6
|
/**
|
|
5
7
|
* The `[min, max]` vertical extent of the **drawn** boxes — the lowest `lower`
|
|
6
8
|
* whisker and highest `upper` whisker over the keys {@link isFiniteBox} draws
|
|
@@ -77,7 +79,7 @@ export type BoxShape = 'whisker' | 'solid' | 'none';
|
|
|
77
79
|
* O(N) over the keys, a fixed number of path ops each — no per-key allocation
|
|
78
80
|
* beyond the `barSpanPx` tuple.
|
|
79
81
|
*/
|
|
80
|
-
export declare function drawBox(ctx: CanvasRenderingContext2D, box: BoxSeries, xScale: Scale, yScale: Scale, style: BoxStyle, gapPx?: number, minWidthPx?: number, shape?: BoxShape, showMedian?: boolean, offsetPx?: number, capWidthPx?: number, selectedKey?: number | null, hoveredKey?: number | null):
|
|
82
|
+
export declare function drawBox(ctx: CanvasRenderingContext2D, box: BoxSeries, xScale: Scale, yScale: Scale, style: BoxStyle, gapPx?: number, minWidthPx?: number, shape?: BoxShape, showMedian?: boolean, offsetPx?: number, capWidthPx?: number, selectedKey?: number | null, hoveredKey?: number | null, decimate?: DecimateOption): LayerDrawStats;
|
|
81
83
|
/**
|
|
82
84
|
* This key is drawable — the quantiles it actually carries are all finite at `i`.
|
|
83
85
|
* `lower`/`upper` (the whisker reach) are always required; `q1`/`q3` only when the
|
package/dist/box.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { barSpanPx } from './range.js';
|
|
2
2
|
import { visibleSpanRange } from './culling.js';
|
|
3
|
+
import { decimateBox } from './decimate.js';
|
|
3
4
|
/** Fraction of the box width the whisker end-caps span (centred on the stem). */
|
|
4
5
|
const WHISKER_CAP_FRACTION = 0.5;
|
|
5
6
|
/**
|
|
@@ -112,16 +113,32 @@ export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx =
|
|
|
112
113
|
// the container selection's `key` by the caller). `null` ⇒ none. A selected
|
|
113
114
|
// box gets a full-strength bounding outline; a hovered one a fainter one —
|
|
114
115
|
// the box analog of the bar highlight, drawn without a new theme token.
|
|
115
|
-
selectedKey = null, hoveredKey = null) {
|
|
116
|
+
selectedKey = null, hoveredKey = null, decimate = true) {
|
|
117
|
+
const sourceCount = box.length; // pre-cull, pre-decimation (for draw stats)
|
|
118
|
+
// Viewport cull first (Phase 2): the [vStart, vEnd) boxes whose span overlaps
|
|
119
|
+
// the window (+1 each side). Full range when `xScale` has no domain (a stub);
|
|
120
|
+
// `offsetPx` is a small pixel nudge the ±1 margin absorbs.
|
|
121
|
+
let [vStart, vEnd] = visibleSpanRange(box.x, box.xEnd, box.length, xScale);
|
|
122
|
+
// M4 box decimation (Phase 5): once the *visible* boxes are denser than ~2 per
|
|
123
|
+
// device pixel, replace them with per-column **aggregate boxes** ({@link
|
|
124
|
+
// decimateBox}). Gate on the visible count, NOT `box.length`: a box's width is
|
|
125
|
+
// its slot, so decimating when only a handful are on screen (deep zoom) would
|
|
126
|
+
// re-slot each to a 1px sliver. `decimateBox` no-ops (returns the same object)
|
|
127
|
+
// below the visible-density threshold or on a domainless scale, leaving the
|
|
128
|
+
// loop-bound cull above. A selection/hover highlight keyed by the source box's
|
|
129
|
+
// `x` won't match an aggregate column edge — but per-box highlight is
|
|
130
|
+
// meaningless at decimation density, and hit-testing still reads the source.
|
|
131
|
+
const decimatedBox = decimate !== false ? decimateBox(box, xScale, ctx, 2, vEnd - vStart) : box;
|
|
132
|
+
const decimated = decimatedBox !== box;
|
|
133
|
+
if (decimated) {
|
|
134
|
+
box = decimatedBox; // aggregate boxes are already the visible set
|
|
135
|
+
vStart = 0;
|
|
136
|
+
vEnd = box.length;
|
|
137
|
+
}
|
|
116
138
|
// A range-only box (bid→ask segment) has no body / median; the whisker (or the
|
|
117
139
|
// solid bar) runs the full lower→upper. Flags default true (a full box).
|
|
118
140
|
const hasBox = box.hasBox !== false;
|
|
119
141
|
const drawMedian = showMedian && box.hasMedian !== false;
|
|
120
|
-
// Viewport culling (Phase 2): draw only the boxes whose span overlaps the
|
|
121
|
-
// visible x-window (+1 each side); the loop keeps the original index `i`. Full
|
|
122
|
-
// range when `xScale` has no domain (a test stub). `offsetPx` is a small pixel
|
|
123
|
-
// nudge the ±1 margin absorbs.
|
|
124
|
-
const [vStart, vEnd] = visibleSpanRange(box.x, box.xEnd, box.length, xScale);
|
|
125
142
|
for (let i = vStart; i < vEnd; i += 1) {
|
|
126
143
|
if (!isFiniteBox(box, i))
|
|
127
144
|
continue;
|
|
@@ -214,6 +231,9 @@ selectedKey = null, hoveredKey = null) {
|
|
|
214
231
|
ctx.restore();
|
|
215
232
|
}
|
|
216
233
|
}
|
|
234
|
+
// `drawnCount` = box slots iterated (visible span, or the aggregate set when
|
|
235
|
+
// decimation engaged); `sourceCount` = the raw box count it started from.
|
|
236
|
+
return { sourceCount, drawnCount: vEnd - vStart, decimated };
|
|
217
237
|
}
|
|
218
238
|
/**
|
|
219
239
|
* This key is drawable — the quantiles it actually carries are all finite at `i`.
|