@pond-ts/charts 0.48.1 → 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 +200 -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,165 @@
1
+ /**
2
+ * Viewport culling (charts decimator wave, Phase 2). Before a layer strokes its
3
+ * data it clips to the **visible** slice of its key column, so a pan/zoom repaint
4
+ * costs O(visible), not O(N): a 1M-point line panned to show 800px of data draws
5
+ * the ~1k points under the plot, not a million.
6
+ *
7
+ * This is the "slice plumbing" the RFC pipeline (store → viewport/decimator →
8
+ * renderer) puts *before* the M4 decimator (Phase 3): culling narrows the input
9
+ * to the visible window; the decimator later collapses that window to
10
+ * ~plot-width buckets. Culling alone is the win that hits the failing pan metric
11
+ * (#256: 100k line pan 120 → 8 fps), and it lands independently of any
12
+ * decimation semantics — it never changes *which* pixels are drawn, only how
13
+ * many points are walked to draw them.
14
+ *
15
+ * **The §2.3 invariant holds by construction:** culling lives on the *draw* path
16
+ * only. `sampleAt` / `hitTest` / `yExtent` read the full source series (they
17
+ * capture `cs` directly), so a hover readout or selection never shifts when the
18
+ * window resizes — nothing user-facing depends on the visible slice.
19
+ */
20
+ import type { ChartSeries, BandSeries } from './data.js';
21
+ import type { Scale } from './line.js';
22
+ /**
23
+ * The visible x-domain of a chart scale as an ascending `[lo, hi]` pair (epoch
24
+ * ms on a time / trading axis, the axis value on a value axis), or `null` when
25
+ * the scale exposes no numeric domain.
26
+ *
27
+ * The draw contract types `xScale` as a bare `(value) => px` function, but the
28
+ * runtime object is always a real d3 `scaleTime` / `scaleLinear` or a
29
+ * `TradingTimeScale` — all three carry `.domain()`, and the domain **is** the
30
+ * visible range (the container sets it to the current view). Read it through a
31
+ * localized, documented cast rather than widening the draw signature — the same
32
+ * trick {@link baselinePxFromScale} uses for the y-axis floor.
33
+ *
34
+ * Returns `null` (⇒ callers skip culling, drawing the whole series) when:
35
+ * - the scale has no `.domain()` — a bare `(v) => v` test stub; or
36
+ * - the domain isn't a numeric pair — a category {@link ScaleBand}, whose domain
37
+ * is ordinal category strings (`+string` is `NaN`).
38
+ *
39
+ * A `scaleTime` domain is `[Date, Date]`; `+date` coerces to ms. The pair is
40
+ * returned ascending (sorted defensively) so the bisect bounds are well-ordered
41
+ * even under an unusual reversed domain.
42
+ */
43
+ export declare function scaleDomain(xScale: Scale): [number, number] | null;
44
+ /**
45
+ * The index window `[start, end)` of a **monotonically ascending** key column
46
+ * `x` (logical length `length`) that covers the visible range `[lo, hi]` plus
47
+ * `margin` points on **each** side. Pure, O(log length) — two binary searches,
48
+ * no allocation.
49
+ *
50
+ * The margin points are the **entry / exit** samples: the last point left of the
51
+ * viewport and the first point right of it, so the line segment that *crosses*
52
+ * each plot edge is still drawn (drop them and the line would stop at the first
53
+ * in-view point, leaving a visible notch at each edge under a pan). `margin = 1`
54
+ * is exact for a straight (linear) segment — the crossing segment's two
55
+ * endpoints are both present. A smoothing `curve` (monotone) computes an
56
+ * interior point's tangent from a wider neighbourhood, so the *entry segment*
57
+ * itself can differ by a sub-pixel from the un-culled render at the very edge;
58
+ * the visible boundary point's own tangent stays exact (its neighbours are both
59
+ * in the slice). Pixel-identity across the whole edge is an M4 (Phase 3)
60
+ * concern, not culling's.
61
+ *
62
+ * Degenerate cases fall out of the two bounds:
63
+ * - **Whole series visible** — `[0, length]` (the caller then skips the slice).
64
+ * - **Series entirely left of the view** (`hi < x[0]`) — `[length-1, length]`,
65
+ * a one-point off-screen slice that strokes nothing.
66
+ * - **Series entirely right of the view** (`lo > x[last]`) — `[0, 1]`, likewise.
67
+ * - **Empty series** — `[0, 0]`.
68
+ */
69
+ export declare function visiblePointWindow(x: Float64Array, length: number, lo: number, hi: number, margin?: number): [number, number];
70
+ /**
71
+ * A {@link ChartSeries} clipped to the visible window of `xScale` (+`margin`
72
+ * points each side). Returns the **same object** untouched when the whole series
73
+ * is in view or the scale exposes no domain (a test stub / category axis) — so
74
+ * the common "everything fits" frame allocates nothing and the draw stays
75
+ * byte-identical to the pre-culling pass. Otherwise the returned view is a
76
+ * zero-copy `subarray` of the source buffers (the source is immutable by
77
+ * contract, so aliasing is safe).
78
+ *
79
+ * **Gap-mode neutrality.** After the pixel bisect, each boundary is walked
80
+ * outward past any non-finite (`NaN` gap) run until the slice's first and last
81
+ * samples are **finite** (or the buffer end is hit). Without this, a gap wider
82
+ * than `margin` straddling a plot edge would drop the finite anchor sitting
83
+ * >`margin` points off-screen, turning an *interior* gap into a *leading /
84
+ * trailing* one inside the slice — which `bridgeGaps` and `collectGapEdges` both
85
+ * leave broken (they only bridge gaps with a finite sample on *both* sides). The
86
+ * `none` / `dashed` / `step` / `fade` connector that crossed the edge would then
87
+ * vanish (a notch under pan). Re-including the anchor keeps the boundary gap
88
+ * *interior*, so every mode draws exactly as it does un-culled. Cost is one
89
+ * `isFinite` check per side in the common (finite-boundary) case; the walk only
90
+ * runs for an edge-straddling gap and is bounded by that gap's width. (The
91
+ * default `empty` mode breaks at gaps regardless, so it is unaffected either
92
+ * way — this makes the guarantee hold for *all* modes.)
93
+ */
94
+ export declare function cullChartSeries(cs: ChartSeries, xScale: Scale, margin?: number): ChartSeries;
95
+ /**
96
+ * A {@link BandSeries} clipped to the visible window of `xScale` — the paired
97
+ * `lower`/`upper` edges culled in lockstep with the shared `x` axis, so the
98
+ * envelope stays aligned. Same identity-preserving fast path and zero-copy
99
+ * `subarray` view as {@link cullChartSeries}.
100
+ *
101
+ * Unlike {@link cullChartSeries} this needs **no** finite-anchor boundary walk:
102
+ * a band has no gap-bridge mode (`drawBand` always breaks the fill at a gap, it
103
+ * never interpolates one), so a gap straddling a plot edge is a hole on both
104
+ * sides of the cut — there is no crossing fill to lose. The `margin` entry/exit
105
+ * sample is enough for a gap-free envelope that spans the edge.
106
+ */
107
+ export declare function cullBandSeries(band: BandSeries, xScale: Scale, margin?: number): BandSeries;
108
+ /**
109
+ * The index range `[start, end)` of **interval marks** — each spanning
110
+ * `[begin[i], end[i]]` on a **monotonically ascending** `begin` axis — whose span
111
+ * overlaps the visible `[lo, hi]`, plus `margin` marks on each side. A mark is
112
+ * visible iff `end[i] >= lo && begin[i] <= hi`.
113
+ *
114
+ * - **Right:** `begin[i] <= hi` ⇒ everything below `upperBound(begin, hi)`; a
115
+ * mark starting past the right edge is off-screen. Exact — no bisect on `end`
116
+ * needed.
117
+ * - **Left:** a mark with `begin[i] < lo` is still visible if its span reaches
118
+ * `lo` (`end[i] >= lo`) — a wide bar crossing the left edge. `begin` bisects
119
+ * the first in-range mark; from there the scan walks back while the previous
120
+ * mark's `end` still reaches `lo`. For sorted non-overlapping marks (the bar /
121
+ * candle / box contract) `end` is ascending, so the walk stops at the first
122
+ * mark clear of the edge — typically one step.
123
+ *
124
+ * Pure, O(log length + crossing marks). `margin` (default 1) pads each side for
125
+ * a mark whose drawn rect is nudged by `gapPx` / `minWidth` / a pixel `offsetPx`
126
+ * the data-space window can't see.
127
+ */
128
+ export declare function visibleSpanWindow(begin: Float64Array, end: Float64Array, length: number, lo: number, hi: number, margin?: number): [number, number];
129
+ /**
130
+ * The visible `[start, end)` index range of a **point** layer (scatter) against
131
+ * `xScale` — a thin wrapper over {@link visiblePointWindow} that reads the scale's
132
+ * domain. Returns the **full** range `[0, length]` when the scale exposes no
133
+ * numeric domain (a bare test stub / category axis) or the series is empty, so a
134
+ * caller loops over everything and the draw is unchanged there.
135
+ *
136
+ * **Radius-aware widening (`padPx`).** The `margin` is in *index* space, but a
137
+ * point mark's **disc** has a pixel radius independent of sample spacing — so a
138
+ * dense scatter of fat marks can put an edge bubble's *centre* several samples
139
+ * off-screen while its disc still overlaps the plot edge, which a bare index
140
+ * margin would drop (a subtle flicker under pan — the sharp edge #499 flagged as
141
+ * a follow-up). Passing `padPx` widens the data window by that many **pixels** on
142
+ * each side — converted px→data through `xScale.invert` — before the bisect, so
143
+ * every mark whose disc can paint into the plot is kept. Scatter passes its max
144
+ * drawn radius (plus any pixel offset); interval marks ({@link visibleSpanRange})
145
+ * don't need it — their width *is* their x-span.
146
+ *
147
+ * The pad is skipped (the plain domain window still applies) when `padPx <= 0` or
148
+ * the scale carries no `invert` (a real domain-bearing runtime scale always has
149
+ * one; only a partial stub lacks it, and it degrades to the index window — a
150
+ * slightly tighter cull, never a dropped mark, since over-padding only *adds*
151
+ * marks). `padPx` converts as `|invert(padPx) − invert(0)|`, the data span of
152
+ * `padPx` pixels: exact for the linear `scaleTime`/`scaleLinear` regardless of
153
+ * range offset, a local estimate for a non-linear axis, and the `Math.abs` keeps
154
+ * it a *widening* even under a reversed scale.
155
+ */
156
+ export declare function visiblePointRange(x: Float64Array, length: number, xScale: Scale, padPx?: number, margin?: number): [number, number];
157
+ /**
158
+ * The visible `[start, end)` index range of an **interval** layer (bars,
159
+ * candles, boxes) against `xScale` — a thin wrapper over
160
+ * {@link visibleSpanWindow} that reads the scale's domain. Returns the **full**
161
+ * range `[0, length]` when the scale exposes no numeric domain or the series is
162
+ * empty (the draw is unchanged there — a bare stub / category axis draws all).
163
+ */
164
+ export declare function visibleSpanRange(begin: Float64Array, end: Float64Array, length: number, xScale: Scale, margin?: number): [number, number];
165
+ //# sourceMappingURL=culling.d.ts.map
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Viewport culling (charts decimator wave, Phase 2). Before a layer strokes its
3
+ * data it clips to the **visible** slice of its key column, so a pan/zoom repaint
4
+ * costs O(visible), not O(N): a 1M-point line panned to show 800px of data draws
5
+ * the ~1k points under the plot, not a million.
6
+ *
7
+ * This is the "slice plumbing" the RFC pipeline (store → viewport/decimator →
8
+ * renderer) puts *before* the M4 decimator (Phase 3): culling narrows the input
9
+ * to the visible window; the decimator later collapses that window to
10
+ * ~plot-width buckets. Culling alone is the win that hits the failing pan metric
11
+ * (#256: 100k line pan 120 → 8 fps), and it lands independently of any
12
+ * decimation semantics — it never changes *which* pixels are drawn, only how
13
+ * many points are walked to draw them.
14
+ *
15
+ * **The §2.3 invariant holds by construction:** culling lives on the *draw* path
16
+ * only. `sampleAt` / `hitTest` / `yExtent` read the full source series (they
17
+ * capture `cs` directly), so a hover readout or selection never shifts when the
18
+ * window resizes — nothing user-facing depends on the visible slice.
19
+ */
20
+ /**
21
+ * The visible x-domain of a chart scale as an ascending `[lo, hi]` pair (epoch
22
+ * ms on a time / trading axis, the axis value on a value axis), or `null` when
23
+ * the scale exposes no numeric domain.
24
+ *
25
+ * The draw contract types `xScale` as a bare `(value) => px` function, but the
26
+ * runtime object is always a real d3 `scaleTime` / `scaleLinear` or a
27
+ * `TradingTimeScale` — all three carry `.domain()`, and the domain **is** the
28
+ * visible range (the container sets it to the current view). Read it through a
29
+ * localized, documented cast rather than widening the draw signature — the same
30
+ * trick {@link baselinePxFromScale} uses for the y-axis floor.
31
+ *
32
+ * Returns `null` (⇒ callers skip culling, drawing the whole series) when:
33
+ * - the scale has no `.domain()` — a bare `(v) => v` test stub; or
34
+ * - the domain isn't a numeric pair — a category {@link ScaleBand}, whose domain
35
+ * is ordinal category strings (`+string` is `NaN`).
36
+ *
37
+ * A `scaleTime` domain is `[Date, Date]`; `+date` coerces to ms. The pair is
38
+ * returned ascending (sorted defensively) so the bisect bounds are well-ordered
39
+ * even under an unusual reversed domain.
40
+ */
41
+ export function scaleDomain(xScale) {
42
+ const d = xScale.domain?.();
43
+ if (d === undefined || d.length < 2)
44
+ return null;
45
+ const lo = +d[0];
46
+ const hi = +d[d.length - 1];
47
+ if (!Number.isFinite(lo) || !Number.isFinite(hi))
48
+ return null;
49
+ return lo <= hi ? [lo, hi] : [hi, lo];
50
+ }
51
+ /** First index `i` in `x[0..n)` with `x[i] >= v` (`n` if none) — lower bound. */
52
+ function lowerBound(x, n, v) {
53
+ let lo = 0;
54
+ let hi = n;
55
+ while (lo < hi) {
56
+ const mid = (lo + hi) >>> 1;
57
+ if (x[mid] < v)
58
+ lo = mid + 1;
59
+ else
60
+ hi = mid;
61
+ }
62
+ return lo;
63
+ }
64
+ /** First index `i` in `x[0..n)` with `x[i] > v` (`n` if none) — upper bound. */
65
+ function upperBound(x, n, v) {
66
+ let lo = 0;
67
+ let hi = n;
68
+ while (lo < hi) {
69
+ const mid = (lo + hi) >>> 1;
70
+ if (x[mid] <= v)
71
+ lo = mid + 1;
72
+ else
73
+ hi = mid;
74
+ }
75
+ return lo;
76
+ }
77
+ /**
78
+ * The index window `[start, end)` of a **monotonically ascending** key column
79
+ * `x` (logical length `length`) that covers the visible range `[lo, hi]` plus
80
+ * `margin` points on **each** side. Pure, O(log length) — two binary searches,
81
+ * no allocation.
82
+ *
83
+ * The margin points are the **entry / exit** samples: the last point left of the
84
+ * viewport and the first point right of it, so the line segment that *crosses*
85
+ * each plot edge is still drawn (drop them and the line would stop at the first
86
+ * in-view point, leaving a visible notch at each edge under a pan). `margin = 1`
87
+ * is exact for a straight (linear) segment — the crossing segment's two
88
+ * endpoints are both present. A smoothing `curve` (monotone) computes an
89
+ * interior point's tangent from a wider neighbourhood, so the *entry segment*
90
+ * itself can differ by a sub-pixel from the un-culled render at the very edge;
91
+ * the visible boundary point's own tangent stays exact (its neighbours are both
92
+ * in the slice). Pixel-identity across the whole edge is an M4 (Phase 3)
93
+ * concern, not culling's.
94
+ *
95
+ * Degenerate cases fall out of the two bounds:
96
+ * - **Whole series visible** — `[0, length]` (the caller then skips the slice).
97
+ * - **Series entirely left of the view** (`hi < x[0]`) — `[length-1, length]`,
98
+ * a one-point off-screen slice that strokes nothing.
99
+ * - **Series entirely right of the view** (`lo > x[last]`) — `[0, 1]`, likewise.
100
+ * - **Empty series** — `[0, 0]`.
101
+ */
102
+ export function visiblePointWindow(x, length, lo, hi, margin = 1) {
103
+ if (length === 0)
104
+ return [0, 0];
105
+ const left = lowerBound(x, length, lo); // first index with x[i] >= lo
106
+ const right = upperBound(x, length, hi); // first index with x[i] > hi
107
+ const start = Math.max(0, left - margin);
108
+ const end = Math.min(length, right + margin);
109
+ return [start, end];
110
+ }
111
+ /**
112
+ * A {@link ChartSeries} clipped to the visible window of `xScale` (+`margin`
113
+ * points each side). Returns the **same object** untouched when the whole series
114
+ * is in view or the scale exposes no domain (a test stub / category axis) — so
115
+ * the common "everything fits" frame allocates nothing and the draw stays
116
+ * byte-identical to the pre-culling pass. Otherwise the returned view is a
117
+ * zero-copy `subarray` of the source buffers (the source is immutable by
118
+ * contract, so aliasing is safe).
119
+ *
120
+ * **Gap-mode neutrality.** After the pixel bisect, each boundary is walked
121
+ * outward past any non-finite (`NaN` gap) run until the slice's first and last
122
+ * samples are **finite** (or the buffer end is hit). Without this, a gap wider
123
+ * than `margin` straddling a plot edge would drop the finite anchor sitting
124
+ * >`margin` points off-screen, turning an *interior* gap into a *leading /
125
+ * trailing* one inside the slice — which `bridgeGaps` and `collectGapEdges` both
126
+ * leave broken (they only bridge gaps with a finite sample on *both* sides). The
127
+ * `none` / `dashed` / `step` / `fade` connector that crossed the edge would then
128
+ * vanish (a notch under pan). Re-including the anchor keeps the boundary gap
129
+ * *interior*, so every mode draws exactly as it does un-culled. Cost is one
130
+ * `isFinite` check per side in the common (finite-boundary) case; the walk only
131
+ * runs for an edge-straddling gap and is bounded by that gap's width. (The
132
+ * default `empty` mode breaks at gaps regardless, so it is unaffected either
133
+ * way — this makes the guarantee hold for *all* modes.)
134
+ */
135
+ export function cullChartSeries(cs, xScale, margin = 1) {
136
+ if (cs.length === 0)
137
+ return cs;
138
+ const dom = scaleDomain(xScale);
139
+ if (dom === null)
140
+ return cs;
141
+ let [start, end] = visiblePointWindow(cs.x, cs.length, dom[0], dom[1], margin);
142
+ // Extend each boundary to the nearest finite y-anchor so a gap straddling the
143
+ // edge stays interior (see "Gap-mode neutrality" above).
144
+ while (start > 0 && !Number.isFinite(cs.y[start]))
145
+ start -= 1;
146
+ while (end < cs.length && !Number.isFinite(cs.y[end - 1]))
147
+ end += 1;
148
+ if (start === 0 && end === cs.length)
149
+ return cs; // whole series in view
150
+ return {
151
+ x: cs.x.subarray(start, end),
152
+ y: cs.y.subarray(start, end),
153
+ length: end - start,
154
+ };
155
+ }
156
+ /**
157
+ * A {@link BandSeries} clipped to the visible window of `xScale` — the paired
158
+ * `lower`/`upper` edges culled in lockstep with the shared `x` axis, so the
159
+ * envelope stays aligned. Same identity-preserving fast path and zero-copy
160
+ * `subarray` view as {@link cullChartSeries}.
161
+ *
162
+ * Unlike {@link cullChartSeries} this needs **no** finite-anchor boundary walk:
163
+ * a band has no gap-bridge mode (`drawBand` always breaks the fill at a gap, it
164
+ * never interpolates one), so a gap straddling a plot edge is a hole on both
165
+ * sides of the cut — there is no crossing fill to lose. The `margin` entry/exit
166
+ * sample is enough for a gap-free envelope that spans the edge.
167
+ */
168
+ export function cullBandSeries(band, xScale, margin = 1) {
169
+ if (band.length === 0)
170
+ return band;
171
+ const dom = scaleDomain(xScale);
172
+ if (dom === null)
173
+ return band;
174
+ const [start, end] = visiblePointWindow(band.x, band.length, dom[0], dom[1], margin);
175
+ if (start === 0 && end === band.length)
176
+ return band; // whole band in view
177
+ return {
178
+ x: band.x.subarray(start, end),
179
+ lower: band.lower.subarray(start, end),
180
+ upper: band.upper.subarray(start, end),
181
+ length: end - start,
182
+ };
183
+ }
184
+ // ─────────────────────────────────────────────────────────────────────────────
185
+ // Index-range culling for **per-mark** layers (scatter, bars, candles, boxes).
186
+ //
187
+ // Unlike the line/area/band draws — which stroke one continuous path and take a
188
+ // zero-copy `subarray` view — these layers loop over *independent* marks with
189
+ // **index-keyed accessors** (a scatter's `colorAt(i)` / `keyAt(i)`, a bar's
190
+ // `begin[i]` selection match). A subarray would renumber `i` and break those, so
191
+ // the fit is instead a visible `[start, end)` **index range** the draw loop runs
192
+ // over (`for (i = start; i < end; …)`), leaving every accessor's `i` intact and
193
+ // the source arrays untouched (the §2.3 interaction-reads-source invariant holds
194
+ // the same way — hit-tests still scan the full arrays).
195
+ // ─────────────────────────────────────────────────────────────────────────────
196
+ /**
197
+ * The index range `[start, end)` of **interval marks** — each spanning
198
+ * `[begin[i], end[i]]` on a **monotonically ascending** `begin` axis — whose span
199
+ * overlaps the visible `[lo, hi]`, plus `margin` marks on each side. A mark is
200
+ * visible iff `end[i] >= lo && begin[i] <= hi`.
201
+ *
202
+ * - **Right:** `begin[i] <= hi` ⇒ everything below `upperBound(begin, hi)`; a
203
+ * mark starting past the right edge is off-screen. Exact — no bisect on `end`
204
+ * needed.
205
+ * - **Left:** a mark with `begin[i] < lo` is still visible if its span reaches
206
+ * `lo` (`end[i] >= lo`) — a wide bar crossing the left edge. `begin` bisects
207
+ * the first in-range mark; from there the scan walks back while the previous
208
+ * mark's `end` still reaches `lo`. For sorted non-overlapping marks (the bar /
209
+ * candle / box contract) `end` is ascending, so the walk stops at the first
210
+ * mark clear of the edge — typically one step.
211
+ *
212
+ * Pure, O(log length + crossing marks). `margin` (default 1) pads each side for
213
+ * a mark whose drawn rect is nudged by `gapPx` / `minWidth` / a pixel `offsetPx`
214
+ * the data-space window can't see.
215
+ */
216
+ export function visibleSpanWindow(begin, end, length, lo, hi, margin = 1) {
217
+ if (length === 0)
218
+ return [0, 0];
219
+ const right = upperBound(begin, length, hi); // first begin > hi
220
+ let start = lowerBound(begin, length, lo); // first begin >= lo
221
+ // Walk back to include earlier marks whose span still crosses into [lo, …].
222
+ while (start > 0 && end[start - 1] >= lo)
223
+ start -= 1;
224
+ return [Math.max(0, start - margin), Math.min(length, right + margin)];
225
+ }
226
+ /**
227
+ * The visible `[start, end)` index range of a **point** layer (scatter) against
228
+ * `xScale` — a thin wrapper over {@link visiblePointWindow} that reads the scale's
229
+ * domain. Returns the **full** range `[0, length]` when the scale exposes no
230
+ * numeric domain (a bare test stub / category axis) or the series is empty, so a
231
+ * caller loops over everything and the draw is unchanged there.
232
+ *
233
+ * **Radius-aware widening (`padPx`).** The `margin` is in *index* space, but a
234
+ * point mark's **disc** has a pixel radius independent of sample spacing — so a
235
+ * dense scatter of fat marks can put an edge bubble's *centre* several samples
236
+ * off-screen while its disc still overlaps the plot edge, which a bare index
237
+ * margin would drop (a subtle flicker under pan — the sharp edge #499 flagged as
238
+ * a follow-up). Passing `padPx` widens the data window by that many **pixels** on
239
+ * each side — converted px→data through `xScale.invert` — before the bisect, so
240
+ * every mark whose disc can paint into the plot is kept. Scatter passes its max
241
+ * drawn radius (plus any pixel offset); interval marks ({@link visibleSpanRange})
242
+ * don't need it — their width *is* their x-span.
243
+ *
244
+ * The pad is skipped (the plain domain window still applies) when `padPx <= 0` or
245
+ * the scale carries no `invert` (a real domain-bearing runtime scale always has
246
+ * one; only a partial stub lacks it, and it degrades to the index window — a
247
+ * slightly tighter cull, never a dropped mark, since over-padding only *adds*
248
+ * marks). `padPx` converts as `|invert(padPx) − invert(0)|`, the data span of
249
+ * `padPx` pixels: exact for the linear `scaleTime`/`scaleLinear` regardless of
250
+ * range offset, a local estimate for a non-linear axis, and the `Math.abs` keeps
251
+ * it a *widening* even under a reversed scale.
252
+ */
253
+ export function visiblePointRange(x, length, xScale, padPx = 0, margin = 1) {
254
+ if (length === 0)
255
+ return [0, 0];
256
+ const dom = scaleDomain(xScale);
257
+ if (dom === null)
258
+ return [0, length];
259
+ let [lo, hi] = dom;
260
+ if (padPx > 0) {
261
+ const invert = xScale
262
+ .invert;
263
+ if (typeof invert === 'function') {
264
+ const dataPad = Math.abs(invert(padPx) - invert(0));
265
+ lo -= dataPad;
266
+ hi += dataPad;
267
+ }
268
+ }
269
+ return visiblePointWindow(x, length, lo, hi, margin);
270
+ }
271
+ /**
272
+ * The visible `[start, end)` index range of an **interval** layer (bars,
273
+ * candles, boxes) against `xScale` — a thin wrapper over
274
+ * {@link visibleSpanWindow} that reads the scale's domain. Returns the **full**
275
+ * range `[0, length]` when the scale exposes no numeric domain or the series is
276
+ * empty (the draw is unchanged there — a bare stub / category axis draws all).
277
+ */
278
+ export function visibleSpanRange(begin, end, length, xScale, margin = 1) {
279
+ if (length === 0)
280
+ return [0, 0];
281
+ const dom = scaleDomain(xScale);
282
+ if (dom === null)
283
+ return [0, length];
284
+ return visibleSpanWindow(begin, end, length, dom[0], dom[1], margin);
285
+ }
286
+ //# sourceMappingURL=culling.js.map
package/dist/data.d.ts CHANGED
@@ -10,7 +10,9 @@ import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
10
10
  *
11
11
  * Both arrays are length `length`. `x` is a zero-copy view of the key column's
12
12
  * `begin` buffer (immutable by contract — do not mutate); `y` is the value
13
- * column materialized to a `Float64Array`.
13
+ * column materialized to a `Float64Array`. `x` is **monotonically ascending**
14
+ * (a series' key column is sorted) — the draw layers and the viewport bisect
15
+ * (`culling.ts`) rely on it, as `sessionRuns` already does.
14
16
  */
15
17
  export interface ChartSeries {
16
18
  readonly x: Float64Array;
@@ -0,0 +1,193 @@
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 type { ChartSeries, BandSeries } from './data.js';
49
+ import type { Scale } from './line.js';
50
+ /**
51
+ * A line layer's M4-decimation control (`<LineChart decimate>`). **Default
52
+ * `true`** — auto-decimate once the visible slice exceeds `2 ×` the device-pixel
53
+ * column count. `false` disables it (always draw every visible point).
54
+ * `{ threshold }` overrides the samples-per-pixel factor `k` (higher ⇒
55
+ * decimate later). Only the honest default draw path decimates (see
56
+ * `drawLine`); a decimated line is visually identical, so this is a perf knob,
57
+ * not a rendering-style one.
58
+ */
59
+ export type DecimateOption = boolean | {
60
+ readonly threshold?: number;
61
+ };
62
+ /** The device-pixel bucket count for `ctx` — the backing buffer width, i.e.
63
+ * `plotWidthCss × DPR` (so buckets land at device-pixel resolution). Falls back
64
+ * to `0` when there is no sized canvas (a headless test ctx), which the caller
65
+ * reads as "can't decimate". */
66
+ export declare function deviceBucketCount(ctx: CanvasRenderingContext2D): number;
67
+ /**
68
+ * Whether decimating a series of `length` samples would pay off at the current
69
+ * frame width: `true` once `length` exceeds `k ×` the device-pixel column count
70
+ * (default `k = 2` — below ~2 samples per pixel the min/max buckets barely shrink
71
+ * the point set, so plain drawing is cheaper than the bin walk). Returns `false`
72
+ * when the canvas has no measurable width (a test ctx) so those draws stay
73
+ * full-resolution and byte-identical. Shared by the line ({@link shouldDecimate})
74
+ * and band decimators.
75
+ */
76
+ export declare function shouldDecimateCount(length: number, ctx: CanvasRenderingContext2D, k?: number): boolean;
77
+ /** {@link shouldDecimateCount} for a {@link ChartSeries} (the line / area case). */
78
+ export declare function shouldDecimate(cs: ChartSeries, ctx: CanvasRenderingContext2D, k?: number): boolean;
79
+ /**
80
+ * The `W + 1` pixel-column **edges** in key space — built by **inverting uniform
81
+ * pixel positions** through the scale (`edges[b] = invert(b/W · plotWidthCss)`),
82
+ * NOT by partitioning the key domain uniformly. The distinction is load-bearing:
83
+ * "one bucket per pixel column" means uniform in *pixel* space, which equals a
84
+ * uniform *key* partition only when the scale is **affine** (`scaleLinear` /
85
+ * `scaleTime`). A `TradingTimeScale` compresses closed-market gaps — its key→px
86
+ * map is piecewise-linear — so inverting pixel positions is what keeps each
87
+ * bucket exactly one column wide there too (else the min/max envelope would thin
88
+ * within a session). `invert` is monotonic, so the edges ascend; the last is the
89
+ * domain max (`invert(plotWidthCss)`), inclusive in `binBy`.
90
+ *
91
+ * `plotWidthCss` is the scale's CSS-pixel range width (`xScale.range()` max);
92
+ * `W` counts *device* columns (`plotWidthCss × DPR`), so the `W` inverted
93
+ * positions land at device-pixel resolution across the CSS range.
94
+ */
95
+ export declare function pixelEdges(invert: (px: number) => number, plotWidthCss: number, W: number): Float64Array;
96
+ /**
97
+ * Key-space bucket boundaries that isolate each **interior gap** — a `NaN` run in
98
+ * `y` with a finite sample on both sides — that spans at least one pixel column
99
+ * (`minSpan`). This is the §2.2 gap-edge union: without it a `binBy` bucket
100
+ * straddling a gap edge is *validity-blind* (min/max/first/last see only the
101
+ * finite samples), so it silently bridges a gap `'empty'` mode must break and the
102
+ * `dashed`/`step`/`fade` connectors lose their exact edge values. For a gap
103
+ * bounded by finite `x[a]` (last before) and `x[c]` (first after), with the first
104
+ * `NaN` at `x[a+1]`, two edges are emitted:
105
+ *
106
+ * - `x[a+1]` — so `x[a]` stays the **last** finite sample of the prior bucket
107
+ * (its `last` channel = the exact pre-gap edge value); and
108
+ * - `x[c]` — so `x[c]` **starts** the next bucket (its `first` = the exact
109
+ * post-gap edge value).
110
+ *
111
+ * The `[x[a+1], x[c])` bucket between them is then all-`NaN` → an empty bucket →
112
+ * the `NaN` break. Only gaps at least one pixel column wide (`x[c] − x[a] ≥
113
+ * minSpan`) are emitted — a sub-pixel dropout is invisible and left to the
114
+ * plain empty-bucket convention, which also **bounds the edge count** (disjoint
115
+ * gaps each ≥ `minSpan` ⇒ ≤ `W` of them ⇒ ≤ `3W` total edges). Emitted ascending
116
+ * (`x` is). Leading / trailing `NaN` runs are skipped (no bridge to preserve —
117
+ * the first/last live bucket handles the end).
118
+ *
119
+ * `minSpan` is the caller's mean per-column key width (`domainSpan / W`) — exact
120
+ * on an affine scale, an **approximation** on a `TradingTimeScale` (where a
121
+ * column's key width varies across compressed gaps). A misfire there is benign:
122
+ * a real ≥1px gap it skips still breaks in its fully-empty interior columns; only
123
+ * the ~1px gap *edges* bridge (and session-break charts gate decimation off
124
+ * entirely). A per-gap pixel-width measure is the follow-up if a consumer hits it.
125
+ */
126
+ export declare function gapKeyEdges(cs: ChartSeries, minSpan: number): number[];
127
+ /**
128
+ * Merge the pixel-column `edges` with the interior-gap boundaries `gaps` (both
129
+ * ascending) into one ascending, duplicate-free edge list, keeping only gap
130
+ * boundaries strictly inside the domain `(lo, hi)` so the pixel span isn't
131
+ * extended. Returns the **same** `edges` array (identity — no allocation) when
132
+ * `gaps` is empty, so the gapless hot path is untouched.
133
+ */
134
+ export declare function mergeGapEdges(edges: Float64Array, gaps: number[], lo: number, hi: number): Float64Array;
135
+ /**
136
+ * Decimate `cs` (a viewport-culled visible slice, ascending `x`) to an M4
137
+ * polyline for `ctx`'s current width + DPR. Returns the **same object** when
138
+ * decimation doesn't apply — the scale has no domain (a test stub), the canvas
139
+ * has no width, or the series is already sparse enough ({@link shouldDecimate})
140
+ * — so those frames draw full-resolution unchanged.
141
+ *
142
+ * Otherwise returns a fresh {@link ChartSeries} of up to `4·W` points: per
143
+ * non-empty column, four points at `[first, min, max, last]` placed at the
144
+ * column's left / centre / centre / right key positions (sub-pixel within the
145
+ * 1px column), and a single `NaN` break per empty column. The classic M4 render
146
+ * — the min→max vertical is the exact pixel band the dense samples cover, and
147
+ * first/last carry the inter-column slope.
148
+ *
149
+ * `boundaries` are trading-axis session-break instants: their keys are unioned
150
+ * into the bucket edges so no bucket straddles a break (which would merge two
151
+ * sessions' extremes). The caller's `sessionRuns` then splits the returned
152
+ * series into per-session subpaths at exactly those instants.
153
+ */
154
+ export declare function decimateM4(cs: ChartSeries, xScale: Scale, ctx: CanvasRenderingContext2D, k?: number, boundaries?: readonly number[]): ChartSeries;
155
+ /**
156
+ * Assemble the M4 polyline {@link ChartSeries} from the four binned channels.
157
+ * Split out (pure, no canvas / pond deps) so the point emission is unit-tested
158
+ * directly. Per column `b`: an empty bucket (`first[b]` non-finite ⇒ all four
159
+ * are) emits one `NaN` break; a live bucket emits
160
+ * `(left, first) (mid, min) (mid, max) (right, last)`.
161
+ *
162
+ * `breakAt` holds bucket-edge keys (session-break instants, already unioned into
163
+ * `edges`) at which the line must **break** rather than connect: a bucket whose
164
+ * left edge is in `breakAt` emits a `NaN` **before** its points. This makes a
165
+ * session split explicit in the geometry — clean regardless of whether the break
166
+ * fell exactly on a pixel edge (where otherwise the closing bucket's `last` and
167
+ * the opening bucket's `first` would sit at the same x and connect with a
168
+ * spurious vertical stub).
169
+ */
170
+ export declare function m4Polyline(edges: Float64Array, mn: Float64Array, mx: Float64Array, first: Float64Array, last: Float64Array, W: number, breakAt?: ReadonlySet<number>): ChartSeries;
171
+ /**
172
+ * Decimate a {@link BandSeries} (a filled variance envelope) to one sample per
173
+ * device-pixel column: per column the **min of `lower`** and the **max of
174
+ * `upper`** — the *widest* envelope the dense samples span, so a decimated band
175
+ * covers exactly the pixels the full band's silhouette would (decimator
176
+ * assessment §2.5: paired min-lower / max-upper, so the envelope can never
177
+ * invert — `max(upper) ≥ min(lower)` for any valid band). Returns the **same
178
+ * object** when decimation doesn't apply (sparse band, domainless / non-invertible
179
+ * scale, no canvas width).
180
+ *
181
+ * Uses the same pixel-aligned edges as the line decimator ({@link pixelEdges} —
182
+ * correct on non-affine scales too), binning `lower` with `'min'` and `upper`
183
+ * with `'max'`. An empty column (no samples) reduces to `NaN` on both edges — the
184
+ * `drawBand` `.defined` break. Unlike the line path this needs **no gap-edge
185
+ * union**: a band has no inferred-connector modes (`drawBand` always breaks the
186
+ * fill at a gap, never bridges), so a sub-pixel gap edge folding into a boundary
187
+ * bucket is invisible — there is no connector to misplace. Assumes `lower` /
188
+ * `upper` are finite **together** per sample (the paired-percentile shape bands
189
+ * are built from); a column where only one edge has finite samples would bin a
190
+ * band segment that no single sample carried.
191
+ */
192
+ export declare function decimateBand(band: BandSeries, xScale: Scale, ctx: CanvasRenderingContext2D, k?: number): BandSeries;
193
+ //# sourceMappingURL=decimate.d.ts.map