@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.
- package/CHANGELOG.md +225 -1
- package/dist/AreaChart.d.ts +18 -1
- package/dist/AreaChart.js +23 -2
- package/dist/BandChart.d.ts +21 -2
- package/dist/BandChart.js +68 -9
- package/dist/BarChart.d.ts +12 -1
- package/dist/BarChart.js +34 -1
- package/dist/BoxPlot.d.ts +29 -1
- package/dist/BoxPlot.js +61 -3
- package/dist/Candlestick.d.ts +21 -1
- package/dist/Candlestick.js +42 -7
- package/dist/ChartContainer.d.ts +23 -13
- package/dist/ChartContainer.js +86 -32
- package/dist/ChartRow.js +22 -3
- package/dist/Layers.js +37 -14
- package/dist/Legend.d.ts +62 -0
- package/dist/Legend.js +169 -0
- package/dist/LineChart.d.ts +20 -1
- package/dist/LineChart.js +23 -2
- package/dist/ScatterChart.d.ts +8 -1
- package/dist/ScatterChart.js +24 -1
- package/dist/XAxis.js +9 -2
- package/dist/YAxis.d.ts +9 -1
- package/dist/YAxis.js +27 -6
- package/dist/annotations.d.ts +21 -3
- package/dist/annotations.js +36 -15
- package/dist/area.d.ts +2 -1
- package/dist/area.js +29 -4
- package/dist/band.d.ts +2 -1
- package/dist/band.js +18 -1
- package/dist/bars.js +8 -1
- package/dist/box.d.ts +15 -1
- package/dist/box.js +71 -2
- package/dist/context.d.ts +51 -4
- package/dist/culling.d.ts +165 -0
- package/dist/culling.js +286 -0
- package/dist/data.d.ts +3 -1
- package/dist/decimate.d.ts +231 -0
- package/dist/decimate.js +478 -0
- package/dist/format.d.ts +20 -11
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/line.d.ts +2 -1
- package/dist/line.js +38 -3
- package/dist/ohlc.d.ts +2 -1
- package/dist/ohlc.js +23 -2
- package/dist/scatter.js +42 -7
- package/dist/swatch.d.ts +104 -0
- package/dist/swatch.js +96 -0
- package/dist/theme.d.ts +27 -0
- package/dist/theme.js +12 -0
- package/dist/useChartLegend.d.ts +106 -0
- package/dist/useChartLegend.js +122 -0
- package/dist/yticks.d.ts +20 -0
- package/dist/yticks.js +28 -0
- package/package.json +3 -3
package/dist/decimate.js
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
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
|
+
/**
|
|
360
|
+
* Decimate an {@link OhlcSeries} to one **aggregate candle per device-pixel
|
|
361
|
+
* column** — `open = first`, `high = max`, `low = min`, `close = last` over the
|
|
362
|
+
* candles that fall in the column. This is exactly a candle re-bucketed to a
|
|
363
|
+
* **coarser timeframe** (the pixel-column's time range): it is never *wrong* —
|
|
364
|
+
* it is the true OHLC of that span — so a dense chart that zooms out reads as
|
|
365
|
+
* fewer, wider aggregate candles, the trading-UI convention (decimator
|
|
366
|
+
* assessment §2.4). Auto-on with an opt-out; a consumer wanting fixed-timeframe
|
|
367
|
+
* candles pre-aggregates upstream and passes `decimate={false}`.
|
|
368
|
+
*
|
|
369
|
+
* Returns the **same object** when decimation doesn't apply (sparse series,
|
|
370
|
+
* domainless / non-invertible scale, no canvas width). The slot of each
|
|
371
|
+
* aggregate candle is its pixel column `[edges[b], edges[b+1]]`; an empty column
|
|
372
|
+
* (no candles) reduces to `NaN` on all channels — `drawCandles` skips it. No
|
|
373
|
+
* session-break union is needed: candles are independent marks (they never
|
|
374
|
+
* connect), and a trading-axis closed period is simply an empty column.
|
|
375
|
+
*/
|
|
376
|
+
export function decimateOhlc(ohlc, xScale, ctx, k = 2, visibleCount = ohlc.length) {
|
|
377
|
+
// Gate on the number of candles *in view*, not the whole series: a candle's
|
|
378
|
+
// width is its pixel-column slot, so re-slotting a handful of deep-zoomed
|
|
379
|
+
// candles to one column each would render them as 1px slivers. Below the
|
|
380
|
+
// visible-density threshold the loop-bound cull draws them at full width.
|
|
381
|
+
if (!shouldDecimateCount(visibleCount, ctx, k))
|
|
382
|
+
return ohlc;
|
|
383
|
+
const dom = scaleDomain(xScale);
|
|
384
|
+
if (dom === null || dom[1] <= dom[0])
|
|
385
|
+
return ohlc;
|
|
386
|
+
const invert = scaleInvert(xScale);
|
|
387
|
+
const plotWidthCss = scaleRangeWidth(xScale);
|
|
388
|
+
if (invert === null || plotWidthCss === null)
|
|
389
|
+
return ohlc;
|
|
390
|
+
const W = deviceBucketCount(ctx);
|
|
391
|
+
const edges = pixelEdges(invert, plotWidthCss, W);
|
|
392
|
+
// Bin each channel over the candles' (monotonic) left-edge key. open/close need
|
|
393
|
+
// the first/last channels (only `'minMaxFirstLast'` carries them); high/low are
|
|
394
|
+
// the scalar max/min. Four O(n) walks — candle counts are modest.
|
|
395
|
+
const key = ohlc.x;
|
|
396
|
+
const openCh = new Float64Column(ohlc.open, ohlc.length).binBy(key, edges, 'minMaxFirstLast').first;
|
|
397
|
+
const closeCh = new Float64Column(ohlc.close, ohlc.length).binBy(key, edges, 'minMaxFirstLast').last;
|
|
398
|
+
const highCh = new Float64Column(ohlc.high, ohlc.length).binBy(key, edges, 'max');
|
|
399
|
+
const lowCh = new Float64Column(ohlc.low, ohlc.length).binBy(key, edges, 'min');
|
|
400
|
+
const x = new Float64Array(W);
|
|
401
|
+
const xEnd = new Float64Array(W);
|
|
402
|
+
for (let b = 0; b < W; b += 1) {
|
|
403
|
+
x[b] = edges[b]; // the aggregate candle's slot IS its pixel column
|
|
404
|
+
xEnd[b] = edges[b + 1];
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
x,
|
|
408
|
+
xEnd,
|
|
409
|
+
open: openCh,
|
|
410
|
+
high: highCh,
|
|
411
|
+
low: lowCh,
|
|
412
|
+
close: closeCh,
|
|
413
|
+
length: W,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Decimate a {@link BoxSeries} to one **aggregate box per device-pixel column** —
|
|
418
|
+
* the interval-mark sibling of {@link decimateOhlc}. Each channel is binned over
|
|
419
|
+
* the column: the whiskers widen to the column's full reach (`lower = min(lower)`,
|
|
420
|
+
* `upper = max(upper)`, exactly {@link decimateBand}'s envelope), the body to the
|
|
421
|
+
* column's **IQR envelope** (`q1 = min(q1)`, `q3 = max(q3)`), and the centre line
|
|
422
|
+
* to the **first** box's `median` in the column (a real median value, not an
|
|
423
|
+
* average — `binBy` carries no mean; it stays within the aggregate body since the
|
|
424
|
+
* first box's `[q1, q3]` ⊆ the envelope). So a dense per-x distribution chart
|
|
425
|
+
* that zooms out reads as fewer, wider boxes summarising each column's spread.
|
|
426
|
+
*
|
|
427
|
+
* Gates on the **visible** box count (a box's width is its slot — decimating a
|
|
428
|
+
* handful of deep-zoomed boxes would render 1px slivers, the same trap the candle
|
|
429
|
+
* path has). Returns the **same object** when decimation doesn't apply (below the
|
|
430
|
+
* visible-density threshold, domainless / non-invertible scale, no canvas width).
|
|
431
|
+
* The `hasBox` / `hasMedian` flags carry through, so a **range-only** box (all-NaN
|
|
432
|
+
* `q1`/`q3`) stays range-only (its binned body is NaN throughout). An empty column
|
|
433
|
+
* reduces to `NaN` on every channel — `drawBox` skips it via `isFiniteBox`.
|
|
434
|
+
*/
|
|
435
|
+
export function decimateBox(box, xScale, ctx, k = 2, visibleCount = box.length) {
|
|
436
|
+
if (!shouldDecimateCount(visibleCount, ctx, k))
|
|
437
|
+
return box;
|
|
438
|
+
const dom = scaleDomain(xScale);
|
|
439
|
+
if (dom === null || dom[1] <= dom[0])
|
|
440
|
+
return box;
|
|
441
|
+
const invert = scaleInvert(xScale);
|
|
442
|
+
const plotWidthCss = scaleRangeWidth(xScale);
|
|
443
|
+
if (invert === null || plotWidthCss === null)
|
|
444
|
+
return box;
|
|
445
|
+
const W = deviceBucketCount(ctx);
|
|
446
|
+
const edges = pixelEdges(invert, plotWidthCss, W);
|
|
447
|
+
const key = box.x;
|
|
448
|
+
const n = box.length;
|
|
449
|
+
// Envelope whiskers + IQR body (scalar min/max); centre line = the first box's
|
|
450
|
+
// median (only `'minMaxFirstLast'` carries `first`). Five O(n) walks — box
|
|
451
|
+
// counts are modest, like candles.
|
|
452
|
+
const lowerCh = new Float64Column(box.lower, n).binBy(key, edges, 'min');
|
|
453
|
+
const upperCh = new Float64Column(box.upper, n).binBy(key, edges, 'max');
|
|
454
|
+
const q1Ch = new Float64Column(box.q1, n).binBy(key, edges, 'min');
|
|
455
|
+
const q3Ch = new Float64Column(box.q3, n).binBy(key, edges, 'max');
|
|
456
|
+
const medianCh = new Float64Column(box.median, n).binBy(key, edges, 'minMaxFirstLast').first;
|
|
457
|
+
const x = new Float64Array(W);
|
|
458
|
+
const xEnd = new Float64Array(W);
|
|
459
|
+
for (let b = 0; b < W; b += 1) {
|
|
460
|
+
x[b] = edges[b]; // the aggregate box's slot IS its pixel column
|
|
461
|
+
xEnd[b] = edges[b + 1];
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
x,
|
|
465
|
+
xEnd,
|
|
466
|
+
lower: lowerCh,
|
|
467
|
+
q1: q1Ch,
|
|
468
|
+
median: medianCh,
|
|
469
|
+
q3: q3Ch,
|
|
470
|
+
upper: upperCh,
|
|
471
|
+
length: W,
|
|
472
|
+
// Carry the flags through so a range-only / no-median box stays that way;
|
|
473
|
+
// omit (not `undefined`) when unset, per `exactOptionalPropertyTypes`.
|
|
474
|
+
...(box.hasBox !== undefined ? { hasBox: box.hasBox } : {}),
|
|
475
|
+
...(box.hasMedian !== undefined ? { hasMedian: box.hasMedian } : {}),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
//# sourceMappingURL=decimate.js.map
|
package/dist/format.d.ts
CHANGED
|
@@ -14,22 +14,31 @@ import type { TimeGrain } from './tickLadder.js';
|
|
|
14
14
|
*/
|
|
15
15
|
export type AxisFormat = string | ((value: number) => string);
|
|
16
16
|
/**
|
|
17
|
-
* How to format the **cursor / marker readout** on
|
|
18
|
-
* ({@link ChartContainerProps.cursorFormat}). Either:
|
|
17
|
+
* How to format the **cursor / marker readout** on the x axis
|
|
18
|
+
* ({@link ChartContainerProps.cursorFormat}) — time or value kind. Either:
|
|
19
19
|
*
|
|
20
|
-
* - a d3
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
20
|
+
* - a d3 specifier **string** applied uniformly: a [time specifier]
|
|
21
|
+
* (https://github.com/d3/d3-time-format#locale_format) on a time axis
|
|
22
|
+
* (e.g. `'%b %-d'`), a [number specifier]
|
|
23
|
+
* (https://github.com/d3/d3-format#locale_format) on a value axis
|
|
24
|
+
* (e.g. `'+.2f'`); or
|
|
25
|
+
* - a **function** `(value, ctx) => string` — `value` is epoch ms on a time
|
|
26
|
+
* axis, the data-unit x value on a value axis. On a **time** axis
|
|
27
|
+
* `ctx.grain` is the axis's resolved coarse {@link TimeGrain}
|
|
28
|
+
* (`year` … `second`) and `ctx.defaultText` is the library's grain-aware
|
|
29
|
+
* default readout for that instant — so a consumer can branch on the zoom
|
|
30
|
+
* level (`grain === 'year' ? … : …`) and pass `defaultText` through for the
|
|
31
|
+
* grains they don't want to override. On a **value** axis there is no time
|
|
32
|
+
* grain — `ctx.grain` is `undefined` and `ctx.defaultText` is the
|
|
33
|
+
* **container's** label-formatter text (`timeFormat`-shaped, else the d3
|
|
34
|
+
* default; an explicit `<XAxis format>` shapes only that axis's own
|
|
35
|
+
* channel, never this default).
|
|
27
36
|
*
|
|
28
37
|
* The library hands you the grain because it already resolved it — you never
|
|
29
38
|
* re-derive it from the range.
|
|
30
39
|
*/
|
|
31
|
-
export type CursorFormat = string | ((
|
|
32
|
-
readonly grain: TimeGrain;
|
|
40
|
+
export type CursorFormat = string | ((value: number, ctx: {
|
|
41
|
+
readonly grain: TimeGrain | undefined;
|
|
33
42
|
readonly defaultText: string;
|
|
34
43
|
}) => string);
|
|
35
44
|
/** The slice of a d3 scale {@link resolveAxisFormat} needs — `tickFormat` with an
|
package/dist/index.d.ts
CHANGED
|
@@ -47,6 +47,11 @@ export type { BarChartProps } from './BarChart.js';
|
|
|
47
47
|
export { Candlestick } from './Candlestick.js';
|
|
48
48
|
export type { CandlestickProps } from './Candlestick.js';
|
|
49
49
|
export type { CandleVariant, ColorBy } from './ohlc.js';
|
|
50
|
+
export { Legend } from './Legend.js';
|
|
51
|
+
export type { LegendProps, LegendPlacement } from './Legend.js';
|
|
52
|
+
export type { SwatchSpec, LegendItemInput } from './swatch.js';
|
|
53
|
+
export { useChartLegend } from './useChartLegend.js';
|
|
54
|
+
export type { ChartLegend, LegendRow, LegendItem } from './useChartLegend.js';
|
|
50
55
|
export { scaleTradingTime } from './tradingTimeScale.js';
|
|
51
56
|
export type { TradingTimeScale, DiscontinuityProvider, TimeGrain, } from './tradingTimeScale.js';
|
|
52
57
|
export { scaleBand } from './bandScale.js';
|
|
@@ -62,6 +67,7 @@ export type { Orientation } from './bars.js';
|
|
|
62
67
|
export type { RadiusEncoding, ColorEncoding } from './encoding.js';
|
|
63
68
|
export type { Curve } from './curve.js';
|
|
64
69
|
export type { GapMode } from './gaps.js';
|
|
70
|
+
export type { DecimateOption } from './decimate.js';
|
|
65
71
|
export { defaultTheme, estelaTheme } from './theme.js';
|
|
66
72
|
export type { ChartTheme, LineStyle, BandStyle, AreaStyle, ScatterStyle, BoxStyle, CandleStyle, BarStyle, } from './theme.js';
|
|
67
73
|
export { cssVarTheme } from './css-theme.js';
|
package/dist/index.js
CHANGED
|
@@ -31,6 +31,12 @@ export { ScatterChart } from './ScatterChart.js';
|
|
|
31
31
|
export { BoxPlot } from './BoxPlot.js';
|
|
32
32
|
export { BarChart } from './BarChart.js';
|
|
33
33
|
export { Candlestick } from './Candlestick.js';
|
|
34
|
+
// The series key: rows enumerate the registered layers' resolved styles.
|
|
35
|
+
export { Legend } from './Legend.js';
|
|
36
|
+
// The headless legend — the same rows + hover/select sync as data, for
|
|
37
|
+
// consumers who design their own key (horizontal strips, ticker-compare,
|
|
38
|
+
// values-in-the-legend).
|
|
39
|
+
export { useChartLegend } from './useChartLegend.js';
|
|
34
40
|
export { scaleTradingTime } from './tradingTimeScale.js';
|
|
35
41
|
// The ordinal category (band) scale — the transpose view's "columns on x" axis.
|
|
36
42
|
export { scaleBand } from './bandScale.js';
|
package/dist/line.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type CurveFactory } from 'd3-shape';
|
|
2
2
|
import type { ChartSeries } from './data.js';
|
|
3
3
|
import type { LineStyle } from './theme.js';
|
|
4
|
+
import { type DecimateOption } from './decimate.js';
|
|
4
5
|
import { type GapMode } from './gaps.js';
|
|
5
6
|
/** Maps a data value to a pixel coordinate (a d3 scale is assignable to this). */
|
|
6
7
|
export type Scale = (value: number) => number;
|
|
@@ -50,7 +51,7 @@ export declare function yExtent(cs: ChartSeries): [number, number] | null;
|
|
|
50
51
|
* the NaN **data** gaps (`gaps`) handled within each run. With no boundaries the
|
|
51
52
|
* output is identical to a single-pass draw.
|
|
52
53
|
*/
|
|
53
|
-
export declare function drawLine(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: LineStyle, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number, boundaries?: readonly number[]): void;
|
|
54
|
+
export declare function drawLine(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: LineStyle, curve?: CurveFactory, gaps?: GapMode, gapConnectorOpacity?: number, boundaries?: readonly number[], decimate?: DecimateOption): void;
|
|
54
55
|
/**
|
|
55
56
|
* Split a sorted columnar x-axis into contiguous index runs `[start, endEx)`,
|
|
56
57
|
* cutting wherever a `boundaries` instant falls in `(x[i-1], x[i]]` — i.e. a
|
package/dist/line.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { line as d3line, curveLinear } from 'd3-shape';
|
|
2
|
+
import { cullChartSeries } from './culling.js';
|
|
3
|
+
import { decimateM4 } from './decimate.js';
|
|
2
4
|
import { bridgeGaps, collectGapEdges, drawGapBridges, drawGapFades, drawGapSteps, DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
|
|
5
|
+
/** Shared empty boundary list — passed to `sessionRuns` when a decimated series
|
|
6
|
+
* already carries its session breaks as baked-in `NaN` points. */
|
|
7
|
+
const EMPTY_BOUNDARIES = [];
|
|
3
8
|
/**
|
|
4
9
|
* The y-scale's domain lower bound (the axis floor) in pixels — where the
|
|
5
10
|
* `step` / `fade` gap bridges drop to. The runtime `yScale` is a d3
|
|
@@ -62,11 +67,41 @@ export function yExtent(cs) {
|
|
|
62
67
|
* the NaN **data** gaps (`gaps`) handled within each run. With no boundaries the
|
|
63
68
|
* output is identical to a single-pass draw.
|
|
64
69
|
*/
|
|
65
|
-
export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY, boundaries = []) {
|
|
70
|
+
export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY, boundaries = [], decimate = true) {
|
|
71
|
+
// Viewport culling (Phase 2): clip to the visible slice (+1 entry/exit point)
|
|
72
|
+
// before any path work, so a pan repaint strokes O(visible), not O(N). A no-op
|
|
73
|
+
// — the same `cs` object back — when the whole series is in view or `xScale`
|
|
74
|
+
// exposes no domain (a bare test stub), keeping the fully-visible hot path
|
|
75
|
+
// byte-identical. Everything below indexes `cs` relatively, so the zero-copy
|
|
76
|
+
// subarray view drops in transparently; `boundaries` are absolute instants that
|
|
77
|
+
// `sessionRuns` bisects by value, so they still cut the slice correctly.
|
|
78
|
+
cs = cullChartSeries(cs, xScale);
|
|
79
|
+
// M4 decimation (Phase 3): once the culled slice is still denser than ~2
|
|
80
|
+
// samples per device pixel, replace it with the pixel-dense M4 polyline
|
|
81
|
+
// ({@link decimateM4}) — O(devicePlotWidth) points that rasterize identically.
|
|
82
|
+
// The edge union makes the decimated series break at exactly the real gaps
|
|
83
|
+
// (gap-mode connectors compose unchanged) **and** aligns a bucket edge to each
|
|
84
|
+
// session break in `boundaries`, so `sessionRuns` below still splits the
|
|
85
|
+
// decimated series into clean per-session subpaths. Only a non-linear **curve**
|
|
86
|
+
// stays gated (a smoothing curve would distort the 4-points-per-column
|
|
87
|
+
// polyline) — that draws full-resolution. Off (`decimate === false`) or a curve
|
|
88
|
+
// set ⇒ the full culled slice draws. `decimateM4` itself no-ops on a sparse
|
|
89
|
+
// slice or a domainless test scale, so this stays byte-identical there.
|
|
90
|
+
let decimated = false;
|
|
91
|
+
if (decimate !== false && curve === curveLinear) {
|
|
92
|
+
const k = typeof decimate === 'object' ? decimate.threshold : undefined;
|
|
93
|
+
const before = cs;
|
|
94
|
+
cs = decimateM4(cs, xScale, ctx, k, boundaries);
|
|
95
|
+
decimated = cs !== before;
|
|
96
|
+
}
|
|
66
97
|
// Split into independent index runs at each boundary; no boundary inside the
|
|
67
98
|
// data ⇒ one run over the whole series (the hot path — no slicing, so the draw
|
|
68
|
-
// is byte-identical to the pre-boundary single pass).
|
|
69
|
-
|
|
99
|
+
// is byte-identical to the pre-boundary single pass). When the series was
|
|
100
|
+
// decimated, `decimateM4` already baked the session breaks in as `NaN` points
|
|
101
|
+
// (aligned to the break instants), so re-cutting here with `boundaries` would
|
|
102
|
+
// mis-attribute the boundary points — pass `[]` and let the baked-in breaks split
|
|
103
|
+
// the sessions.
|
|
104
|
+
const runs = sessionRuns(cs.x, cs.length, decimated ? EMPTY_BOUNDARIES : boundaries);
|
|
70
105
|
const singleRun = runs.length === 1;
|
|
71
106
|
// Solid pass: one path across every run. Each run's generator opens with its
|
|
72
107
|
// own moveTo, so a run boundary is a clean pen-up — the session break.
|
package/dist/ohlc.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OhlcSeries } from './data.js';
|
|
2
2
|
import type { Scale } from './line.js';
|
|
3
3
|
import type { CandleStyle } from './theme.js';
|
|
4
|
+
import { type DecimateOption } from './decimate.js';
|
|
4
5
|
/**
|
|
5
6
|
* How an OHLC mark renders (pjm17971's fork 2 — bundled as one component, like
|
|
6
7
|
* {@link BoxShape}, not split into a separate `<OHLCBar>`):
|
|
@@ -77,5 +78,5 @@ export declare function resolveCandleStyle(style: CandleStyle, open: number, clo
|
|
|
77
78
|
* O(N) over the keys, a fixed number of path ops each — no per-key allocation
|
|
78
79
|
* beyond the `barSpanPx` tuple.
|
|
79
80
|
*/
|
|
80
|
-
export declare function drawCandles(ctx: CanvasRenderingContext2D, ohlc: OhlcSeries, xScale: Scale, yScale: Scale, style: CandleStyle, variant?: CandleVariant, colorBy?: ColorBy, gapPx?: number, minWidthPx?: number): void;
|
|
81
|
+
export declare function drawCandles(ctx: CanvasRenderingContext2D, ohlc: OhlcSeries, xScale: Scale, yScale: Scale, style: CandleStyle, variant?: CandleVariant, colorBy?: ColorBy, gapPx?: number, minWidthPx?: number, decimate?: DecimateOption): void;
|
|
81
82
|
//# sourceMappingURL=ohlc.d.ts.map
|