@pond-ts/charts 0.48.1 → 0.50.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 (56) hide show
  1. package/CHANGELOG.md +225 -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 +29 -1
  9. package/dist/BoxPlot.js +61 -3
  10. package/dist/Candlestick.d.ts +21 -1
  11. package/dist/Candlestick.js +42 -7
  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 +15 -1
  33. package/dist/box.js +71 -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 +231 -0
  39. package/dist/decimate.js +478 -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.d.ts +2 -1
  46. package/dist/ohlc.js +23 -2
  47. package/dist/scatter.js +42 -7
  48. package/dist/swatch.d.ts +104 -0
  49. package/dist/swatch.js +96 -0
  50. package/dist/theme.d.ts +27 -0
  51. package/dist/theme.js +12 -0
  52. package/dist/useChartLegend.d.ts +106 -0
  53. package/dist/useChartLegend.js +122 -0
  54. package/dist/yticks.d.ts +20 -0
  55. package/dist/yticks.js +28 -0
  56. 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;