@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +215 -1
  2. package/dist/AreaChart.d.ts +18 -1
  3. package/dist/AreaChart.js +23 -2
  4. package/dist/BandChart.d.ts +21 -2
  5. package/dist/BandChart.js +68 -9
  6. package/dist/BarChart.d.ts +12 -1
  7. package/dist/BarChart.js +34 -1
  8. package/dist/BoxPlot.d.ts +18 -1
  9. package/dist/BoxPlot.js +60 -3
  10. package/dist/Candlestick.d.ts +8 -1
  11. package/dist/Candlestick.js +40 -6
  12. package/dist/ChartContainer.d.ts +23 -13
  13. package/dist/ChartContainer.js +86 -32
  14. package/dist/ChartRow.js +22 -3
  15. package/dist/Layers.js +37 -14
  16. package/dist/Legend.d.ts +62 -0
  17. package/dist/Legend.js +169 -0
  18. package/dist/LineChart.d.ts +20 -1
  19. package/dist/LineChart.js +23 -2
  20. package/dist/ScatterChart.d.ts +8 -1
  21. package/dist/ScatterChart.js +24 -1
  22. package/dist/XAxis.js +9 -2
  23. package/dist/YAxis.d.ts +9 -1
  24. package/dist/YAxis.js +27 -6
  25. package/dist/annotations.d.ts +21 -3
  26. package/dist/annotations.js +36 -15
  27. package/dist/area.d.ts +2 -1
  28. package/dist/area.js +29 -4
  29. package/dist/band.d.ts +2 -1
  30. package/dist/band.js +18 -1
  31. package/dist/bars.js +8 -1
  32. package/dist/box.d.ts +14 -1
  33. package/dist/box.js +56 -2
  34. package/dist/context.d.ts +51 -4
  35. package/dist/culling.d.ts +165 -0
  36. package/dist/culling.js +286 -0
  37. package/dist/data.d.ts +3 -1
  38. package/dist/decimate.d.ts +193 -0
  39. package/dist/decimate.js +359 -0
  40. package/dist/format.d.ts +20 -11
  41. package/dist/index.d.ts +6 -0
  42. package/dist/index.js +6 -0
  43. package/dist/line.d.ts +2 -1
  44. package/dist/line.js +38 -3
  45. package/dist/ohlc.js +6 -1
  46. package/dist/scatter.js +42 -7
  47. package/dist/swatch.d.ts +104 -0
  48. package/dist/swatch.js +96 -0
  49. package/dist/theme.d.ts +27 -0
  50. package/dist/theme.js +12 -0
  51. package/dist/useChartLegend.d.ts +106 -0
  52. package/dist/useChartLegend.js +122 -0
  53. package/dist/yticks.d.ts +20 -0
  54. package/dist/yticks.js +28 -0
  55. package/package.json +3 -3
@@ -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 a time axis
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 time specifier **string** (e.g. `'%b %-d'`) applied uniformly at every
21
- * zoom; or
22
- * - a **function** `(epochMs, ctx) => string`, where `ctx.grain` is the axis's
23
- * resolved coarse {@link TimeGrain} (`year` `second`) and `ctx.defaultText`
24
- * is the library's grain-aware default readout for that instant — so a
25
- * consumer can branch on the zoom level (`grain === 'year' ? : …`) and
26
- * pass `defaultText` through for the grains they don't want to override.
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 | ((epochMs: number, ctx: {
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
- const runs = sessionRuns(cs.x, cs.length, boundaries);
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
- for (let i = 0; i < ohlc.length; i += 1) {
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. All three of `drawScatter`, `scatterExtent`,
9
- * and {@link hitTestScatter} are **O(N)** in the point count (a single pass; no
10
- * spatial index a chart row holds far fewer points than a dense line, and a
11
- * click happens at human cadence). If a scatter ever needs 100k+ points this is
12
- * the place to add a coarse x-bucket index; today the linear walk is the right
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
- for (let i = 0; i < cs.length; i += 1) {
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 = 0; i < cs.length; i += 1) {
200
+ for (let i = vStart; i < vEnd; i += 1) {
166
201
  if (!isPoint(cs, i))
167
202
  continue;
168
203
  const text = labelAt(i);