@pond-ts/charts 0.48.0 → 0.49.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 +215 -1
- package/dist/AreaChart.d.ts +18 -1
- package/dist/AreaChart.js +23 -2
- package/dist/BandChart.d.ts +21 -2
- package/dist/BandChart.js +68 -9
- package/dist/BarChart.d.ts +12 -1
- package/dist/BarChart.js +34 -1
- package/dist/BoxPlot.d.ts +18 -1
- package/dist/BoxPlot.js +60 -3
- package/dist/Candlestick.d.ts +8 -1
- package/dist/Candlestick.js +40 -6
- package/dist/ChartContainer.d.ts +23 -13
- package/dist/ChartContainer.js +86 -32
- package/dist/ChartRow.js +22 -3
- package/dist/Layers.js +37 -14
- package/dist/Legend.d.ts +62 -0
- package/dist/Legend.js +169 -0
- package/dist/LineChart.d.ts +20 -1
- package/dist/LineChart.js +23 -2
- package/dist/ScatterChart.d.ts +8 -1
- package/dist/ScatterChart.js +24 -1
- package/dist/XAxis.js +9 -2
- package/dist/YAxis.d.ts +9 -1
- package/dist/YAxis.js +27 -6
- package/dist/annotations.d.ts +21 -3
- package/dist/annotations.js +36 -15
- package/dist/area.d.ts +2 -1
- package/dist/area.js +29 -4
- package/dist/band.d.ts +2 -1
- package/dist/band.js +18 -1
- package/dist/bars.js +8 -1
- package/dist/box.d.ts +14 -1
- package/dist/box.js +56 -2
- package/dist/context.d.ts +51 -4
- package/dist/culling.d.ts +165 -0
- package/dist/culling.js +286 -0
- package/dist/data.d.ts +3 -1
- package/dist/decimate.d.ts +193 -0
- package/dist/decimate.js +359 -0
- package/dist/format.d.ts +20 -11
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/line.d.ts +2 -1
- package/dist/line.js +38 -3
- package/dist/ohlc.js +6 -1
- package/dist/scatter.js +42 -7
- package/dist/swatch.d.ts +104 -0
- package/dist/swatch.js +96 -0
- package/dist/theme.d.ts +27 -0
- package/dist/theme.js +12 -0
- package/dist/useChartLegend.d.ts +106 -0
- package/dist/useChartLegend.js +122 -0
- package/dist/yticks.d.ts +20 -0
- package/dist/yticks.js +28 -0
- package/package.json +3 -3
package/dist/decimate.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M4 line decimation (charts decimator wave, Phase 3). Reduces an
|
|
3
|
+
* already-viewport-culled visible slice to a **pixel-dense** polyline that is
|
|
4
|
+
* **visually lossless** vs the full line at the current plot width + DPR, from
|
|
5
|
+
* O(devicePlotWidth) points instead of O(visible) — the win that lifts the
|
|
6
|
+
* *fully-visible* draw ceiling Phase 2 culling deliberately left in place (a
|
|
7
|
+
* dense series that fills the plot still strokes every point).
|
|
8
|
+
*
|
|
9
|
+
* **Algorithm — M4** (Jugel et al., VLDB 2014). Split the visible key range into
|
|
10
|
+
* one bucket per device pixel column; per column keep the **min**, **max**,
|
|
11
|
+
* **first**, and **last** value (the four channels of `Float64Column.binBy(…,
|
|
12
|
+
* 'minMaxFirstLast')` — the pond-side reducer math from PR #362/#363). Drawing
|
|
13
|
+
* first → min → max → last per column reproduces what the full line rasterizes
|
|
14
|
+
* there: the vertical extent (min→max) is the exact band of pixels the dense
|
|
15
|
+
* samples cover, and first/last carry the slope to the neighbouring columns. It
|
|
16
|
+
* is lossless to within a **sub-pixel AA seam** along the envelope edges — the
|
|
17
|
+
* min/max are placed at the column *centre* (their true sub-pixel x isn't carried
|
|
18
|
+
* by the value-only reducer), so the edge antialiases a fraction of a pixel
|
|
19
|
+
* differently than the full line (the e2e bounds the whole-plot difference at a
|
|
20
|
+
* low single-digit %; a broken M4 diffs a large area). An empty column (a gap
|
|
21
|
+
* with no samples) reduces to `NaN` on all four channels — the canvas
|
|
22
|
+
* sub-path-break sentinel — so a gap becomes a break for free.
|
|
23
|
+
*
|
|
24
|
+
* **Gaps (§2.2 gap-edge union).** A `binBy` bucket straddling a gap *edge* is
|
|
25
|
+
* validity-blind (min/max/first/last see only the finite samples), so it would
|
|
26
|
+
* silently bridge a gap `'empty'` must break and rob the dashed/step/fade
|
|
27
|
+
* connectors of exact edge values. {@link gapKeyEdges} folds every ≥1-column
|
|
28
|
+
* interior gap's boundaries into the bucket-edge list, so each gap reduces to its
|
|
29
|
+
* own empty (NaN) bucket and the bordering buckets carry the exact pre/post-gap
|
|
30
|
+
* values — the decimated series then feeds the *unchanged* gap-mode machinery in
|
|
31
|
+
* `drawLine` (`'none'` bridges the breaks, dashed/step/fade draw their inferred
|
|
32
|
+
* connectors from `collectGapEdges`).
|
|
33
|
+
*
|
|
34
|
+
* **Reads the frame geometry off the canvas + scale**, not the layer signature:
|
|
35
|
+
* the bucket count `W` is the backing buffer width `ctx.canvas.width` (already
|
|
36
|
+
* `plotWidthCss × DPR` — see `Canvas`), so the grid is at **device-pixel**
|
|
37
|
+
* resolution — twice the columns at 2× DPR, which keeps extremes from
|
|
38
|
+
* flat-topping (decimator assessment §2.6). The bucket **edges** are the scale's
|
|
39
|
+
* CSS-pixel range (`xScale.range()`) inverted back to key space at those `W`
|
|
40
|
+
* positions (see {@link pixelEdges}) — so each bucket is exactly one column on
|
|
41
|
+
* **any** scale, including a non-affine `TradingTimeScale`.
|
|
42
|
+
*
|
|
43
|
+
* The output is a plain {@link ChartSeries} in **key space**, so it feeds
|
|
44
|
+
* straight back into the existing `drawLine` path (which maps x through the same
|
|
45
|
+
* `xScale` and breaks its subpath on `NaN`) — decimation is a pre-pass that
|
|
46
|
+
* shrinks the point count, not a second renderer.
|
|
47
|
+
*/
|
|
48
|
+
import { Float64Column } from 'pond-ts';
|
|
49
|
+
import { scaleDomain } from './culling.js';
|
|
50
|
+
/** The device-pixel bucket count for `ctx` — the backing buffer width, i.e.
|
|
51
|
+
* `plotWidthCss × DPR` (so buckets land at device-pixel resolution). Falls back
|
|
52
|
+
* to `0` when there is no sized canvas (a headless test ctx), which the caller
|
|
53
|
+
* reads as "can't decimate". */
|
|
54
|
+
export function deviceBucketCount(ctx) {
|
|
55
|
+
const w = ctx.canvas?.width;
|
|
56
|
+
return typeof w === 'number' && w > 0 ? Math.floor(w) : 0;
|
|
57
|
+
}
|
|
58
|
+
/** The scale's CSS-pixel range span (`range()[last]`), or `null` when the scale
|
|
59
|
+
* exposes no numeric range. This is the pixel width the {@link pixelEdges}
|
|
60
|
+
* columns are inverted across — read through a localized cast, like
|
|
61
|
+
* {@link scaleDomain}. */
|
|
62
|
+
function scaleRangeWidth(xScale) {
|
|
63
|
+
const r = xScale.range?.();
|
|
64
|
+
if (r === undefined || r.length < 2)
|
|
65
|
+
return null;
|
|
66
|
+
const w = +r[r.length - 1];
|
|
67
|
+
return Number.isFinite(w) && w > 0 ? w : null;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Whether decimating a series of `length` samples would pay off at the current
|
|
71
|
+
* frame width: `true` once `length` exceeds `k ×` the device-pixel column count
|
|
72
|
+
* (default `k = 2` — below ~2 samples per pixel the min/max buckets barely shrink
|
|
73
|
+
* the point set, so plain drawing is cheaper than the bin walk). Returns `false`
|
|
74
|
+
* when the canvas has no measurable width (a test ctx) so those draws stay
|
|
75
|
+
* full-resolution and byte-identical. Shared by the line ({@link shouldDecimate})
|
|
76
|
+
* and band decimators.
|
|
77
|
+
*/
|
|
78
|
+
export function shouldDecimateCount(length, ctx, k = 2) {
|
|
79
|
+
const W = deviceBucketCount(ctx);
|
|
80
|
+
return W > 0 && length > k * W;
|
|
81
|
+
}
|
|
82
|
+
/** {@link shouldDecimateCount} for a {@link ChartSeries} (the line / area case). */
|
|
83
|
+
export function shouldDecimate(cs, ctx, k = 2) {
|
|
84
|
+
return shouldDecimateCount(cs.length, ctx, k);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The `key`-space (`(px) => value`) inverse of a chart scale, or `null` when the
|
|
88
|
+
* scale exposes none. Every continuous chart x scale (`scaleLinear`, `scaleTime`,
|
|
89
|
+
* `TradingTimeScale`) carries `.invert`; a category `ScaleBand` doesn't (and a
|
|
90
|
+
* line never sits on one). Read through a localized cast, like {@link scaleDomain}.
|
|
91
|
+
*/
|
|
92
|
+
function scaleInvert(xScale) {
|
|
93
|
+
const inv = xScale.invert;
|
|
94
|
+
return typeof inv === 'function'
|
|
95
|
+
? (px) => +inv.call(xScale, px)
|
|
96
|
+
: null;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The `W + 1` pixel-column **edges** in key space — built by **inverting uniform
|
|
100
|
+
* pixel positions** through the scale (`edges[b] = invert(b/W · plotWidthCss)`),
|
|
101
|
+
* NOT by partitioning the key domain uniformly. The distinction is load-bearing:
|
|
102
|
+
* "one bucket per pixel column" means uniform in *pixel* space, which equals a
|
|
103
|
+
* uniform *key* partition only when the scale is **affine** (`scaleLinear` /
|
|
104
|
+
* `scaleTime`). A `TradingTimeScale` compresses closed-market gaps — its key→px
|
|
105
|
+
* map is piecewise-linear — so inverting pixel positions is what keeps each
|
|
106
|
+
* bucket exactly one column wide there too (else the min/max envelope would thin
|
|
107
|
+
* within a session). `invert` is monotonic, so the edges ascend; the last is the
|
|
108
|
+
* domain max (`invert(plotWidthCss)`), inclusive in `binBy`.
|
|
109
|
+
*
|
|
110
|
+
* `plotWidthCss` is the scale's CSS-pixel range width (`xScale.range()` max);
|
|
111
|
+
* `W` counts *device* columns (`plotWidthCss × DPR`), so the `W` inverted
|
|
112
|
+
* positions land at device-pixel resolution across the CSS range.
|
|
113
|
+
*/
|
|
114
|
+
export function pixelEdges(invert, plotWidthCss, W) {
|
|
115
|
+
const edges = new Float64Array(W + 1);
|
|
116
|
+
for (let b = 0; b <= W; b += 1)
|
|
117
|
+
edges[b] = invert((plotWidthCss * b) / W);
|
|
118
|
+
return edges;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Key-space bucket boundaries that isolate each **interior gap** — a `NaN` run in
|
|
122
|
+
* `y` with a finite sample on both sides — that spans at least one pixel column
|
|
123
|
+
* (`minSpan`). This is the §2.2 gap-edge union: without it a `binBy` bucket
|
|
124
|
+
* straddling a gap edge is *validity-blind* (min/max/first/last see only the
|
|
125
|
+
* finite samples), so it silently bridges a gap `'empty'` mode must break and the
|
|
126
|
+
* `dashed`/`step`/`fade` connectors lose their exact edge values. For a gap
|
|
127
|
+
* bounded by finite `x[a]` (last before) and `x[c]` (first after), with the first
|
|
128
|
+
* `NaN` at `x[a+1]`, two edges are emitted:
|
|
129
|
+
*
|
|
130
|
+
* - `x[a+1]` — so `x[a]` stays the **last** finite sample of the prior bucket
|
|
131
|
+
* (its `last` channel = the exact pre-gap edge value); and
|
|
132
|
+
* - `x[c]` — so `x[c]` **starts** the next bucket (its `first` = the exact
|
|
133
|
+
* post-gap edge value).
|
|
134
|
+
*
|
|
135
|
+
* The `[x[a+1], x[c])` bucket between them is then all-`NaN` → an empty bucket →
|
|
136
|
+
* the `NaN` break. Only gaps at least one pixel column wide (`x[c] − x[a] ≥
|
|
137
|
+
* minSpan`) are emitted — a sub-pixel dropout is invisible and left to the
|
|
138
|
+
* plain empty-bucket convention, which also **bounds the edge count** (disjoint
|
|
139
|
+
* gaps each ≥ `minSpan` ⇒ ≤ `W` of them ⇒ ≤ `3W` total edges). Emitted ascending
|
|
140
|
+
* (`x` is). Leading / trailing `NaN` runs are skipped (no bridge to preserve —
|
|
141
|
+
* the first/last live bucket handles the end).
|
|
142
|
+
*
|
|
143
|
+
* `minSpan` is the caller's mean per-column key width (`domainSpan / W`) — exact
|
|
144
|
+
* on an affine scale, an **approximation** on a `TradingTimeScale` (where a
|
|
145
|
+
* column's key width varies across compressed gaps). A misfire there is benign:
|
|
146
|
+
* a real ≥1px gap it skips still breaks in its fully-empty interior columns; only
|
|
147
|
+
* the ~1px gap *edges* bridge (and session-break charts gate decimation off
|
|
148
|
+
* entirely). A per-gap pixel-width measure is the follow-up if a consumer hits it.
|
|
149
|
+
*/
|
|
150
|
+
export function gapKeyEdges(cs, minSpan) {
|
|
151
|
+
const { x, y, length } = cs;
|
|
152
|
+
const out = [];
|
|
153
|
+
let prevFinite = -1;
|
|
154
|
+
for (let i = 0; i < length; i += 1) {
|
|
155
|
+
if (!Number.isFinite(y[i]))
|
|
156
|
+
continue;
|
|
157
|
+
if (prevFinite >= 0 &&
|
|
158
|
+
i - prevFinite > 1 &&
|
|
159
|
+
x[i] - x[prevFinite] >= minSpan) {
|
|
160
|
+
out.push(x[prevFinite + 1]); // first NaN key
|
|
161
|
+
out.push(x[i]); // first finite key after the gap
|
|
162
|
+
}
|
|
163
|
+
prevFinite = i;
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Merge the pixel-column `edges` with the interior-gap boundaries `gaps` (both
|
|
169
|
+
* ascending) into one ascending, duplicate-free edge list, keeping only gap
|
|
170
|
+
* boundaries strictly inside the domain `(lo, hi)` so the pixel span isn't
|
|
171
|
+
* extended. Returns the **same** `edges` array (identity — no allocation) when
|
|
172
|
+
* `gaps` is empty, so the gapless hot path is untouched.
|
|
173
|
+
*/
|
|
174
|
+
export function mergeGapEdges(edges, gaps, lo, hi) {
|
|
175
|
+
if (gaps.length === 0)
|
|
176
|
+
return edges; // gapless hot path — identity, no alloc
|
|
177
|
+
const inRange = gaps.filter((g) => g > lo && g < hi);
|
|
178
|
+
if (inRange.length === 0)
|
|
179
|
+
return edges;
|
|
180
|
+
const all = [...edges, ...inRange].sort((a, b) => a - b);
|
|
181
|
+
const out = [];
|
|
182
|
+
for (const e of all)
|
|
183
|
+
if (out.length === 0 || e > out[out.length - 1])
|
|
184
|
+
out.push(e);
|
|
185
|
+
return Float64Array.from(out);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Decimate `cs` (a viewport-culled visible slice, ascending `x`) to an M4
|
|
189
|
+
* polyline for `ctx`'s current width + DPR. Returns the **same object** when
|
|
190
|
+
* decimation doesn't apply — the scale has no domain (a test stub), the canvas
|
|
191
|
+
* has no width, or the series is already sparse enough ({@link shouldDecimate})
|
|
192
|
+
* — so those frames draw full-resolution unchanged.
|
|
193
|
+
*
|
|
194
|
+
* Otherwise returns a fresh {@link ChartSeries} of up to `4·W` points: per
|
|
195
|
+
* non-empty column, four points at `[first, min, max, last]` placed at the
|
|
196
|
+
* column's left / centre / centre / right key positions (sub-pixel within the
|
|
197
|
+
* 1px column), and a single `NaN` break per empty column. The classic M4 render
|
|
198
|
+
* — the min→max vertical is the exact pixel band the dense samples cover, and
|
|
199
|
+
* first/last carry the inter-column slope.
|
|
200
|
+
*
|
|
201
|
+
* `boundaries` are trading-axis session-break instants: their keys are unioned
|
|
202
|
+
* into the bucket edges so no bucket straddles a break (which would merge two
|
|
203
|
+
* sessions' extremes). The caller's `sessionRuns` then splits the returned
|
|
204
|
+
* series into per-session subpaths at exactly those instants.
|
|
205
|
+
*/
|
|
206
|
+
export function decimateM4(cs, xScale, ctx, k = 2, boundaries = []) {
|
|
207
|
+
if (!shouldDecimate(cs, ctx, k))
|
|
208
|
+
return cs;
|
|
209
|
+
const dom = scaleDomain(xScale);
|
|
210
|
+
if (dom === null)
|
|
211
|
+
return cs;
|
|
212
|
+
if (dom[1] <= dom[0])
|
|
213
|
+
return cs;
|
|
214
|
+
const invert = scaleInvert(xScale);
|
|
215
|
+
const plotWidthCss = scaleRangeWidth(xScale);
|
|
216
|
+
// No inverse / range ⇒ can't align buckets to pixel columns; draw full-res.
|
|
217
|
+
if (invert === null || plotWidthCss === null)
|
|
218
|
+
return cs;
|
|
219
|
+
const W = deviceBucketCount(ctx);
|
|
220
|
+
// `W` device columns inverted across the scale's CSS-pixel range → key-space
|
|
221
|
+
// edges, so each bucket is exactly one pixel column on **any** scale (affine
|
|
222
|
+
// or trading-time — see {@link pixelEdges}).
|
|
223
|
+
const pixels = pixelEdges(invert, plotWidthCss, W);
|
|
224
|
+
// Edge union — fold two families of boundaries into the bucket edges so no
|
|
225
|
+
// bucket ever straddles one:
|
|
226
|
+
// - §2.2 gap edges: every ≥1-column interior gap → its own empty (NaN) bucket
|
|
227
|
+
// with exact pre/post-gap values (so `'empty'` breaks precisely and the
|
|
228
|
+
// dashed/step/fade connectors land right).
|
|
229
|
+
// - session-break instants (`boundaries`, a trading-time close→open): a bucket
|
|
230
|
+
// that spanned a break would merge the two sessions' min/max across the
|
|
231
|
+
// discontinuity. Aligning a bucket edge to each break keeps the sessions
|
|
232
|
+
// separate, so `sessionRuns` in `drawLine` cuts the decimated series cleanly.
|
|
233
|
+
// A gapless, boundary-free slice returns `pixels` unchanged (no allocation).
|
|
234
|
+
const extra = gapKeyEdges(cs, (dom[1] - dom[0]) / W);
|
|
235
|
+
// Session-break instants inside the visible domain — unioned into the edges AND
|
|
236
|
+
// marked as explicit break points so the decimated series breaks (not connects)
|
|
237
|
+
// there. `mergeGapEdges` keeps their exact values, so the set matches the edges.
|
|
238
|
+
const breaks = boundaries.length > 0
|
|
239
|
+
? boundaries.filter((b) => b > dom[0] && b < dom[1])
|
|
240
|
+
: [];
|
|
241
|
+
const edges = mergeGapEdges(pixels, breaks.length > 0 ? [...extra, ...breaks] : extra, dom[0], dom[1]);
|
|
242
|
+
const buckets = edges.length - 1;
|
|
243
|
+
// Bin the value channel against the pixel-column edges over the key axis. A
|
|
244
|
+
// fresh Float64Column wraps the already-materialized `cs.y` (zero-copy — it
|
|
245
|
+
// reads, never mutates); `cs.x` is the monotonic key.
|
|
246
|
+
const col = new Float64Column(cs.y, cs.length);
|
|
247
|
+
const { lo: mn, hi: mx, first, last, } = col.binBy(cs.x, edges, 'minMaxFirstLast');
|
|
248
|
+
return m4Polyline(edges, mn, mx, first, last, buckets, breaks.length > 0 ? new Set(breaks) : undefined);
|
|
249
|
+
}
|
|
250
|
+
/** Shared empty break-set for the common (no session-break) case. */
|
|
251
|
+
const NO_BREAKS = new Set();
|
|
252
|
+
/**
|
|
253
|
+
* Assemble the M4 polyline {@link ChartSeries} from the four binned channels.
|
|
254
|
+
* Split out (pure, no canvas / pond deps) so the point emission is unit-tested
|
|
255
|
+
* directly. Per column `b`: an empty bucket (`first[b]` non-finite ⇒ all four
|
|
256
|
+
* are) emits one `NaN` break; a live bucket emits
|
|
257
|
+
* `(left, first) (mid, min) (mid, max) (right, last)`.
|
|
258
|
+
*
|
|
259
|
+
* `breakAt` holds bucket-edge keys (session-break instants, already unioned into
|
|
260
|
+
* `edges`) at which the line must **break** rather than connect: a bucket whose
|
|
261
|
+
* left edge is in `breakAt` emits a `NaN` **before** its points. This makes a
|
|
262
|
+
* session split explicit in the geometry — clean regardless of whether the break
|
|
263
|
+
* fell exactly on a pixel edge (where otherwise the closing bucket's `last` and
|
|
264
|
+
* the opening bucket's `first` would sit at the same x and connect with a
|
|
265
|
+
* spurious vertical stub).
|
|
266
|
+
*/
|
|
267
|
+
export function m4Polyline(edges, mn, mx, first, last, W, breakAt = NO_BREAKS) {
|
|
268
|
+
// Upper bound: 4 points/column + a break slot each (empty buckets and each
|
|
269
|
+
// session break); trimmed to the real count.
|
|
270
|
+
const cap = W * 4 + breakAt.size;
|
|
271
|
+
const x = new Float64Array(cap);
|
|
272
|
+
const y = new Float64Array(cap);
|
|
273
|
+
let n = 0;
|
|
274
|
+
let brokenLast = false; // avoid emitting consecutive NaN breaks
|
|
275
|
+
for (let b = 0; b < W; b += 1) {
|
|
276
|
+
// Explicit session break: this bucket opens a new session → pen up first.
|
|
277
|
+
if (b > 0 && !brokenLast && n > 0 && breakAt.has(edges[b])) {
|
|
278
|
+
x[n] = edges[b];
|
|
279
|
+
y[n] = NaN;
|
|
280
|
+
n += 1;
|
|
281
|
+
brokenLast = true;
|
|
282
|
+
}
|
|
283
|
+
if (!Number.isFinite(first[b])) {
|
|
284
|
+
if (!brokenLast && n > 0) {
|
|
285
|
+
x[n] = edges[b];
|
|
286
|
+
y[n] = NaN;
|
|
287
|
+
n += 1;
|
|
288
|
+
brokenLast = true;
|
|
289
|
+
}
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
brokenLast = false;
|
|
293
|
+
const left = edges[b];
|
|
294
|
+
const right = edges[b + 1];
|
|
295
|
+
const mid = (left + right) / 2;
|
|
296
|
+
// first (left) → min (mid) → max (mid) → last (right): the min→max vertical
|
|
297
|
+
// plus the entry/exit stubs that connect to the neighbouring columns.
|
|
298
|
+
x[n] = left;
|
|
299
|
+
y[n] = first[b];
|
|
300
|
+
x[n + 1] = mid;
|
|
301
|
+
y[n + 1] = mn[b];
|
|
302
|
+
x[n + 2] = mid;
|
|
303
|
+
y[n + 2] = mx[b];
|
|
304
|
+
x[n + 3] = right;
|
|
305
|
+
y[n + 3] = last[b];
|
|
306
|
+
n += 4;
|
|
307
|
+
}
|
|
308
|
+
// A trailing break (empty columns after the last live one) is a no-op for the
|
|
309
|
+
// draw — drop it so the point count is exact.
|
|
310
|
+
if (n > 0 && Number.isNaN(y[n - 1]))
|
|
311
|
+
n -= 1;
|
|
312
|
+
return { x: x.subarray(0, n), y: y.subarray(0, n), length: n };
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Decimate a {@link BandSeries} (a filled variance envelope) to one sample per
|
|
316
|
+
* device-pixel column: per column the **min of `lower`** and the **max of
|
|
317
|
+
* `upper`** — the *widest* envelope the dense samples span, so a decimated band
|
|
318
|
+
* covers exactly the pixels the full band's silhouette would (decimator
|
|
319
|
+
* assessment §2.5: paired min-lower / max-upper, so the envelope can never
|
|
320
|
+
* invert — `max(upper) ≥ min(lower)` for any valid band). Returns the **same
|
|
321
|
+
* object** when decimation doesn't apply (sparse band, domainless / non-invertible
|
|
322
|
+
* scale, no canvas width).
|
|
323
|
+
*
|
|
324
|
+
* Uses the same pixel-aligned edges as the line decimator ({@link pixelEdges} —
|
|
325
|
+
* correct on non-affine scales too), binning `lower` with `'min'` and `upper`
|
|
326
|
+
* with `'max'`. An empty column (no samples) reduces to `NaN` on both edges — the
|
|
327
|
+
* `drawBand` `.defined` break. Unlike the line path this needs **no gap-edge
|
|
328
|
+
* union**: a band has no inferred-connector modes (`drawBand` always breaks the
|
|
329
|
+
* fill at a gap, never bridges), so a sub-pixel gap edge folding into a boundary
|
|
330
|
+
* bucket is invisible — there is no connector to misplace. Assumes `lower` /
|
|
331
|
+
* `upper` are finite **together** per sample (the paired-percentile shape bands
|
|
332
|
+
* are built from); a column where only one edge has finite samples would bin a
|
|
333
|
+
* band segment that no single sample carried.
|
|
334
|
+
*/
|
|
335
|
+
export function decimateBand(band, xScale, ctx, k = 2) {
|
|
336
|
+
if (!shouldDecimateCount(band.length, ctx, k))
|
|
337
|
+
return band;
|
|
338
|
+
const dom = scaleDomain(xScale);
|
|
339
|
+
if (dom === null || dom[1] <= dom[0])
|
|
340
|
+
return band;
|
|
341
|
+
const invert = scaleInvert(xScale);
|
|
342
|
+
const plotWidthCss = scaleRangeWidth(xScale);
|
|
343
|
+
if (invert === null || plotWidthCss === null)
|
|
344
|
+
return band;
|
|
345
|
+
const W = deviceBucketCount(ctx);
|
|
346
|
+
const edges = pixelEdges(invert, plotWidthCss, W);
|
|
347
|
+
const lowerMin = new Float64Column(band.lower, band.length).binBy(band.x, edges, 'min');
|
|
348
|
+
const upperMax = new Float64Column(band.upper, band.length).binBy(band.x, edges, 'max');
|
|
349
|
+
const x = new Float64Array(W);
|
|
350
|
+
const lower = new Float64Array(W);
|
|
351
|
+
const upper = new Float64Array(W);
|
|
352
|
+
for (let b = 0; b < W; b += 1) {
|
|
353
|
+
x[b] = (edges[b] + edges[b + 1]) / 2; // column centre
|
|
354
|
+
lower[b] = lowerMin[b]; // NaN on an empty column → the fill break
|
|
355
|
+
upper[b] = upperMax[b];
|
|
356
|
+
}
|
|
357
|
+
return { x, lower, upper, length: W };
|
|
358
|
+
}
|
|
359
|
+
//# sourceMappingURL=decimate.js.map
|
package/dist/format.d.ts
CHANGED
|
@@ -14,22 +14,31 @@ import type { TimeGrain } from './tickLadder.js';
|
|
|
14
14
|
*/
|
|
15
15
|
export type AxisFormat = string | ((value: number) => string);
|
|
16
16
|
/**
|
|
17
|
-
* How to format the **cursor / marker readout** on
|
|
18
|
-
* ({@link ChartContainerProps.cursorFormat}). Either:
|
|
17
|
+
* How to format the **cursor / marker readout** on the x axis
|
|
18
|
+
* ({@link ChartContainerProps.cursorFormat}) — time or value kind. Either:
|
|
19
19
|
*
|
|
20
|
-
* - a d3
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
20
|
+
* - a d3 specifier **string** applied uniformly: a [time specifier]
|
|
21
|
+
* (https://github.com/d3/d3-time-format#locale_format) on a time axis
|
|
22
|
+
* (e.g. `'%b %-d'`), a [number specifier]
|
|
23
|
+
* (https://github.com/d3/d3-format#locale_format) on a value axis
|
|
24
|
+
* (e.g. `'+.2f'`); or
|
|
25
|
+
* - a **function** `(value, ctx) => string` — `value` is epoch ms on a time
|
|
26
|
+
* axis, the data-unit x value on a value axis. On a **time** axis
|
|
27
|
+
* `ctx.grain` is the axis's resolved coarse {@link TimeGrain}
|
|
28
|
+
* (`year` … `second`) and `ctx.defaultText` is the library's grain-aware
|
|
29
|
+
* default readout for that instant — so a consumer can branch on the zoom
|
|
30
|
+
* level (`grain === 'year' ? … : …`) and pass `defaultText` through for the
|
|
31
|
+
* grains they don't want to override. On a **value** axis there is no time
|
|
32
|
+
* grain — `ctx.grain` is `undefined` and `ctx.defaultText` is the
|
|
33
|
+
* **container's** label-formatter text (`timeFormat`-shaped, else the d3
|
|
34
|
+
* default; an explicit `<XAxis format>` shapes only that axis's own
|
|
35
|
+
* channel, never this default).
|
|
27
36
|
*
|
|
28
37
|
* The library hands you the grain because it already resolved it — you never
|
|
29
38
|
* re-derive it from the range.
|
|
30
39
|
*/
|
|
31
|
-
export type CursorFormat = string | ((
|
|
32
|
-
readonly grain: TimeGrain;
|
|
40
|
+
export type CursorFormat = string | ((value: number, ctx: {
|
|
41
|
+
readonly grain: TimeGrain | undefined;
|
|
33
42
|
readonly defaultText: string;
|
|
34
43
|
}) => string);
|
|
35
44
|
/** The slice of a d3 scale {@link resolveAxisFormat} needs — `tickFormat` with an
|
package/dist/index.d.ts
CHANGED
|
@@ -47,6 +47,11 @@ export type { BarChartProps } from './BarChart.js';
|
|
|
47
47
|
export { Candlestick } from './Candlestick.js';
|
|
48
48
|
export type { CandlestickProps } from './Candlestick.js';
|
|
49
49
|
export type { CandleVariant, ColorBy } from './ohlc.js';
|
|
50
|
+
export { Legend } from './Legend.js';
|
|
51
|
+
export type { LegendProps, LegendPlacement } from './Legend.js';
|
|
52
|
+
export type { SwatchSpec, LegendItemInput } from './swatch.js';
|
|
53
|
+
export { useChartLegend } from './useChartLegend.js';
|
|
54
|
+
export type { ChartLegend, LegendRow, LegendItem } from './useChartLegend.js';
|
|
50
55
|
export { scaleTradingTime } from './tradingTimeScale.js';
|
|
51
56
|
export type { TradingTimeScale, DiscontinuityProvider, TimeGrain, } from './tradingTimeScale.js';
|
|
52
57
|
export { scaleBand } from './bandScale.js';
|
|
@@ -62,6 +67,7 @@ export type { Orientation } from './bars.js';
|
|
|
62
67
|
export type { RadiusEncoding, ColorEncoding } from './encoding.js';
|
|
63
68
|
export type { Curve } from './curve.js';
|
|
64
69
|
export type { GapMode } from './gaps.js';
|
|
70
|
+
export type { DecimateOption } from './decimate.js';
|
|
65
71
|
export { defaultTheme, estelaTheme } from './theme.js';
|
|
66
72
|
export type { ChartTheme, LineStyle, BandStyle, AreaStyle, ScatterStyle, BoxStyle, CandleStyle, BarStyle, } from './theme.js';
|
|
67
73
|
export { cssVarTheme } from './css-theme.js';
|
package/dist/index.js
CHANGED
|
@@ -31,6 +31,12 @@ export { ScatterChart } from './ScatterChart.js';
|
|
|
31
31
|
export { BoxPlot } from './BoxPlot.js';
|
|
32
32
|
export { BarChart } from './BarChart.js';
|
|
33
33
|
export { Candlestick } from './Candlestick.js';
|
|
34
|
+
// The series key: rows enumerate the registered layers' resolved styles.
|
|
35
|
+
export { Legend } from './Legend.js';
|
|
36
|
+
// The headless legend — the same rows + hover/select sync as data, for
|
|
37
|
+
// consumers who design their own key (horizontal strips, ticker-compare,
|
|
38
|
+
// values-in-the-legend).
|
|
39
|
+
export { useChartLegend } from './useChartLegend.js';
|
|
34
40
|
export { scaleTradingTime } from './tradingTimeScale.js';
|
|
35
41
|
// The ordinal category (band) scale — the transpose view's "columns on x" axis.
|
|
36
42
|
export { scaleBand } from './bandScale.js';
|
package/dist/line.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type CurveFactory } from 'd3-shape';
|
|
2
2
|
import type { ChartSeries } from './data.js';
|
|
3
3
|
import type { LineStyle } from './theme.js';
|
|
4
|
+
import { type DecimateOption } from './decimate.js';
|
|
4
5
|
import { type GapMode } from './gaps.js';
|
|
5
6
|
/** Maps a data value to a pixel coordinate (a d3 scale is assignable to this). */
|
|
6
7
|
export type Scale = (value: number) => number;
|
|
@@ -50,7 +51,7 @@ export declare function yExtent(cs: ChartSeries): [number, number] | null;
|
|
|
50
51
|
* the NaN **data** gaps (`gaps`) handled within each run. With no boundaries the
|
|
51
52
|
* output is identical to a single-pass draw.
|
|
52
53
|
*/
|
|
53
|
-
export declare function drawLine(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: LineStyle, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number, boundaries?: readonly number[]): void;
|
|
54
|
+
export declare function drawLine(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: LineStyle, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number, boundaries?: readonly number[], decimate?: DecimateOption): void;
|
|
54
55
|
/**
|
|
55
56
|
* Split a sorted columnar x-axis into contiguous index runs `[start, endEx)`,
|
|
56
57
|
* cutting wherever a `boundaries` instant falls in `(x[i-1], x[i]]` — i.e. a
|
package/dist/line.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { line as d3line, curveLinear } from 'd3-shape';
|
|
2
|
+
import { cullChartSeries } from './culling.js';
|
|
3
|
+
import { decimateM4 } from './decimate.js';
|
|
2
4
|
import { bridgeGaps, collectGapEdges, drawGapBridges, drawGapFades, drawGapSteps, DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
|
|
5
|
+
/** Shared empty boundary list — passed to `sessionRuns` when a decimated series
|
|
6
|
+
* already carries its session breaks as baked-in `NaN` points. */
|
|
7
|
+
const EMPTY_BOUNDARIES = [];
|
|
3
8
|
/**
|
|
4
9
|
* The y-scale's domain lower bound (the axis floor) in pixels — where the
|
|
5
10
|
* `step` / `fade` gap bridges drop to. The runtime `yScale` is a d3
|
|
@@ -62,11 +67,41 @@ export function yExtent(cs) {
|
|
|
62
67
|
* the NaN **data** gaps (`gaps`) handled within each run. With no boundaries the
|
|
63
68
|
* output is identical to a single-pass draw.
|
|
64
69
|
*/
|
|
65
|
-
export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY, boundaries = []) {
|
|
70
|
+
export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY, boundaries = [], decimate = true) {
|
|
71
|
+
// Viewport culling (Phase 2): clip to the visible slice (+1 entry/exit point)
|
|
72
|
+
// before any path work, so a pan repaint strokes O(visible), not O(N). A no-op
|
|
73
|
+
// — the same `cs` object back — when the whole series is in view or `xScale`
|
|
74
|
+
// exposes no domain (a bare test stub), keeping the fully-visible hot path
|
|
75
|
+
// byte-identical. Everything below indexes `cs` relatively, so the zero-copy
|
|
76
|
+
// subarray view drops in transparently; `boundaries` are absolute instants that
|
|
77
|
+
// `sessionRuns` bisects by value, so they still cut the slice correctly.
|
|
78
|
+
cs = cullChartSeries(cs, xScale);
|
|
79
|
+
// M4 decimation (Phase 3): once the culled slice is still denser than ~2
|
|
80
|
+
// samples per device pixel, replace it with the pixel-dense M4 polyline
|
|
81
|
+
// ({@link decimateM4}) — O(devicePlotWidth) points that rasterize identically.
|
|
82
|
+
// The edge union makes the decimated series break at exactly the real gaps
|
|
83
|
+
// (gap-mode connectors compose unchanged) **and** aligns a bucket edge to each
|
|
84
|
+
// session break in `boundaries`, so `sessionRuns` below still splits the
|
|
85
|
+
// decimated series into clean per-session subpaths. Only a non-linear **curve**
|
|
86
|
+
// stays gated (a smoothing curve would distort the 4-points-per-column
|
|
87
|
+
// polyline) — that draws full-resolution. Off (`decimate === false`) or a curve
|
|
88
|
+
// set ⇒ the full culled slice draws. `decimateM4` itself no-ops on a sparse
|
|
89
|
+
// slice or a domainless test scale, so this stays byte-identical there.
|
|
90
|
+
let decimated = false;
|
|
91
|
+
if (decimate !== false && curve === curveLinear) {
|
|
92
|
+
const k = typeof decimate === 'object' ? decimate.threshold : undefined;
|
|
93
|
+
const before = cs;
|
|
94
|
+
cs = decimateM4(cs, xScale, ctx, k, boundaries);
|
|
95
|
+
decimated = cs !== before;
|
|
96
|
+
}
|
|
66
97
|
// Split into independent index runs at each boundary; no boundary inside the
|
|
67
98
|
// data ⇒ one run over the whole series (the hot path — no slicing, so the draw
|
|
68
|
-
// is byte-identical to the pre-boundary single pass).
|
|
69
|
-
|
|
99
|
+
// is byte-identical to the pre-boundary single pass). When the series was
|
|
100
|
+
// decimated, `decimateM4` already baked the session breaks in as `NaN` points
|
|
101
|
+
// (aligned to the break instants), so re-cutting here with `boundaries` would
|
|
102
|
+
// mis-attribute the boundary points — pass `[]` and let the baked-in breaks split
|
|
103
|
+
// the sessions.
|
|
104
|
+
const runs = sessionRuns(cs.x, cs.length, decimated ? EMPTY_BOUNDARIES : boundaries);
|
|
70
105
|
const singleRun = runs.length === 1;
|
|
71
106
|
// Solid pass: one path across every run. Each run's generator opens with its
|
|
72
107
|
// own moveTo, so a run boundary is a clean pen-up — the session break.
|
package/dist/ohlc.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { barSpanPx } from './range.js';
|
|
2
|
+
import { visibleSpanRange } from './culling.js';
|
|
2
3
|
/** Default body width as a fraction of the candle slot when the style omits one. */
|
|
3
4
|
const DEFAULT_BODY_WIDTH = 0.8;
|
|
4
5
|
/** Minimum body height in px so a doji (open === close) still shows a mark. */
|
|
@@ -90,7 +91,11 @@ export function resolveCandleStyle(style, open, close, colorBy) {
|
|
|
90
91
|
*/
|
|
91
92
|
export function drawCandles(ctx, ohlc, xScale, yScale, style, variant = 'candle', colorBy = 'direction', gapPx = 0, minWidthPx = 1) {
|
|
92
93
|
const bodyFraction = style.bodyWidth ?? DEFAULT_BODY_WIDTH;
|
|
93
|
-
|
|
94
|
+
// Viewport culling (Phase 2): draw only the candles whose span overlaps the
|
|
95
|
+
// visible x-window (+1 each side); the loop keeps the original index `i`. Full
|
|
96
|
+
// range when `xScale` has no domain (a test stub).
|
|
97
|
+
const [vStart, vEnd] = visibleSpanRange(ohlc.x, ohlc.xEnd, ohlc.length, xScale);
|
|
98
|
+
for (let i = vStart; i < vEnd; i += 1) {
|
|
94
99
|
if (!isFiniteOhlc(ohlc, i))
|
|
95
100
|
continue;
|
|
96
101
|
const open = ohlc.open[i];
|
package/dist/scatter.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { visiblePointRange } from './culling.js';
|
|
1
2
|
/**
|
|
2
3
|
* Scatter geometry + the canvas draw — pure, like {@link drawLine} /
|
|
3
4
|
* {@link drawBand}, so the recording-mock tests assert the op sequence and the
|
|
@@ -5,11 +6,13 @@
|
|
|
5
6
|
*
|
|
6
7
|
* A scatter plots one mark per finite point at `(xScale(x), yScale(y))`, sized
|
|
7
8
|
* + coloured by the resolved {@link ResolvedEncoding} (data-driven radius /
|
|
8
|
-
* colour) over the style's base.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* colour) over the style's base. `drawScatter` culls to the visible x-window
|
|
10
|
+
* first (Phase 2 — see the draw loop), so a pan/zoom repaint is O(visible), not
|
|
11
|
+
* O(N); `scatterExtent` and {@link hitTestScatter} still walk the full series
|
|
12
|
+
* (**O(N)** — the y-extent must see every point and a click happens at human
|
|
13
|
+
* cadence). No spatial index — a chart row holds far fewer points than a dense
|
|
14
|
+
* line. If a scatter ever needs 100k+ points *and* a hot hit-test this is the
|
|
15
|
+
* place to add a coarse x-bucket index; today the linear walk is the right
|
|
13
16
|
* tradeoff.
|
|
14
17
|
*/
|
|
15
18
|
/** A non-finite y (the gap signal) means "no point here" — skip it everywhere. */
|
|
@@ -123,7 +126,39 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
|
|
|
123
126
|
let selPy = 0;
|
|
124
127
|
let selR = 0;
|
|
125
128
|
let selHit = false;
|
|
126
|
-
|
|
129
|
+
// Viewport culling (Phase 2): draw only the marks in the visible x-window
|
|
130
|
+
// (+1 each side). The loop keeps the **original** index `i`, so the index-keyed
|
|
131
|
+
// accessors (`colorAt`/`radiusAt`/`keyAt`/`labelAt`) and the selection match
|
|
132
|
+
// stay correct — a subarray would renumber them. Full range when `xScale` has
|
|
133
|
+
// no domain (a test stub). A selected point outside the window isn't drawn (its
|
|
134
|
+
// ring would be off-screen anyway).
|
|
135
|
+
//
|
|
136
|
+
// Radius-aware pad (the follow-up #499 flagged): the ±1 margin is in *index*
|
|
137
|
+
// space, but a mark's **radius** can reach the plot from further out — a dense
|
|
138
|
+
// scatter of visible-size bubbles would otherwise drop an edge bubble whose
|
|
139
|
+
// centre is >1 sample off-screen while its disc overlaps the edge (a flicker
|
|
140
|
+
// under pan). So we make two window calls: pass 1 is the plain window; scan it
|
|
141
|
+
// for the max drawn radius; pass 2 re-expands by that radius plus `|offsetPx|`
|
|
142
|
+
// (the pixel nudge shifts marks in px space, so it widens the reach too). The
|
|
143
|
+
// common small-radius frame re-expands by a few pixels — usually the same
|
|
144
|
+
// window. Interval marks (bars/candles/boxes) don't need this — their width
|
|
145
|
+
// *is* their x-span (`visibleSpanRange` captures it exactly); only sub-pixel
|
|
146
|
+
// `minWidth`/`gapPx` rounding can poke past the edge, which the ±1 margin
|
|
147
|
+
// absorbs and which only bites when zoomed out (where culling barely narrows).
|
|
148
|
+
const [w0Start, w0End] = visiblePointRange(cs.x, cs.length, xScale);
|
|
149
|
+
let maxR = 0;
|
|
150
|
+
for (let i = w0Start; i < w0End; i += 1) {
|
|
151
|
+
if (isPoint(cs, i)) {
|
|
152
|
+
const r = encoding.radiusAt(i);
|
|
153
|
+
if (r > maxR)
|
|
154
|
+
maxR = r;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const pad = maxR + Math.abs(offsetPx);
|
|
158
|
+
const [vStart, vEnd] = pad > 0
|
|
159
|
+
? visiblePointRange(cs.x, cs.length, xScale, pad)
|
|
160
|
+
: [w0Start, w0End];
|
|
161
|
+
for (let i = vStart; i < vEnd; i += 1) {
|
|
127
162
|
if (!isPoint(cs, i))
|
|
128
163
|
continue;
|
|
129
164
|
// `offsetPx` nudges the whole scatter in pixel space (zoom-stable) — for
|
|
@@ -162,7 +197,7 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
|
|
|
162
197
|
ctx.fillStyle = style.label;
|
|
163
198
|
ctx.font = `${font.size}px ${font.family}`;
|
|
164
199
|
ctx.textBaseline = 'middle';
|
|
165
|
-
for (let i =
|
|
200
|
+
for (let i = vStart; i < vEnd; i += 1) {
|
|
166
201
|
if (!isPoint(cs, i))
|
|
167
202
|
continue;
|
|
168
203
|
const text = labelAt(i);
|