@pond-ts/charts 0.50.0 → 0.52.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 +187 -1
- package/dist/AreaChart.js +1 -0
- package/dist/BandChart.js +1 -0
- package/dist/BarChart.d.ts +14 -1
- package/dist/BarChart.js +5 -2
- package/dist/BoxPlot.js +1 -0
- package/dist/Candlestick.js +1 -0
- package/dist/ChartContainer.d.ts +61 -11
- package/dist/ChartContainer.js +70 -23
- package/dist/Layers.js +53 -14
- package/dist/Legend.js +5 -2
- package/dist/LineChart.js +1 -0
- package/dist/ScatterChart.d.ts +12 -1
- package/dist/ScatterChart.js +4 -2
- package/dist/XAxis.js +3 -2
- package/dist/affine.d.ts +41 -0
- package/dist/affine.js +77 -0
- package/dist/area.d.ts +20 -2
- package/dist/area.js +151 -45
- package/dist/band.d.ts +2 -1
- package/dist/band.js +5 -0
- package/dist/bars.d.ts +14 -1
- package/dist/bars.js +42 -1
- package/dist/box.d.ts +2 -1
- package/dist/box.js +8 -3
- package/dist/context.d.ts +121 -22
- package/dist/context.js +8 -0
- package/dist/data.d.ts +8 -0
- package/dist/decimate.d.ts +117 -1
- package/dist/decimate.js +241 -1
- package/dist/encoding.d.ts +9 -0
- package/dist/encoding.js +5 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +5 -3
- package/dist/line.d.ts +15 -1
- package/dist/line.js +84 -30
- package/dist/ohlc.d.ts +2 -1
- package/dist/ohlc.js +8 -3
- package/dist/scatter.d.ts +3 -2
- package/dist/scatter.js +61 -1
- package/dist/tracker.d.ts +17 -4
- package/dist/tracker.js +19 -6
- package/dist/useChartLegend.d.ts +2 -2
- package/dist/useChartLegend.js +8 -7
- package/dist/viewport.d.ts +19 -0
- package/dist/viewport.js +32 -0
- package/package.json +3 -3
package/dist/context.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { createContext } from 'react';
|
|
2
2
|
export const ContainerContext = createContext(null);
|
|
3
|
+
/** No-cursor default, so a consumer outside a provider reads "not hovering"
|
|
4
|
+
* rather than needing a null guard (the container always provides a real one). */
|
|
5
|
+
const NO_CURSOR = {
|
|
6
|
+
cursorX: null,
|
|
7
|
+
cursorY: null,
|
|
8
|
+
cursorRowKey: null,
|
|
9
|
+
};
|
|
10
|
+
export const CursorContext = createContext(NO_CURSOR);
|
|
3
11
|
export const RowContext = createContext(null);
|
|
4
12
|
export const LayersContext = createContext(null);
|
|
5
13
|
//# sourceMappingURL=context.js.map
|
package/dist/data.d.ts
CHANGED
|
@@ -13,6 +13,14 @@ import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
|
|
|
13
13
|
* column materialized to a `Float64Array`. `x` is **monotonically ascending**
|
|
14
14
|
* (a series' key column is sorted) — the draw layers and the viewport bisect
|
|
15
15
|
* (`culling.ts`) rely on it, as `sessionRuns` already does.
|
|
16
|
+
*
|
|
17
|
+
* **Neither buffer may be mutated in place** — not just `x`. The area fill
|
|
18
|
+
* gradient's value extent is memoized on the `y` buffer's identity
|
|
19
|
+
* (`columnFiniteExtent`, [PND-GRADX]), so a consumer that mutated a live `y`
|
|
20
|
+
* buffer's contents across frames instead of materializing a fresh column would
|
|
21
|
+
* read a stale gradient span (no crash, just a wrong shade). The built-in
|
|
22
|
+
* readers always allocate a fresh exactly-sized buffer per materialization, so
|
|
23
|
+
* buffer identity tracks data identity — hold to that.
|
|
16
24
|
*/
|
|
17
25
|
export interface ChartSeries {
|
|
18
26
|
readonly x: Float64Array;
|
package/dist/decimate.d.ts
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
* `xScale` and breaks its subpath on `NaN`) — decimation is a pre-pass that
|
|
46
46
|
* shrinks the point count, not a second renderer.
|
|
47
47
|
*/
|
|
48
|
-
import type { ChartSeries, BandSeries, OhlcSeries, BoxSeries } from './data.js';
|
|
48
|
+
import type { ChartSeries, BandSeries, OhlcSeries, BoxSeries, BarSeries } from './data.js';
|
|
49
49
|
import type { Scale } from './line.js';
|
|
50
50
|
/**
|
|
51
51
|
* A line layer's M4-decimation control (`<LineChart decimate>`). **Default
|
|
@@ -152,6 +152,42 @@ export declare function mergeGapEdges(edges: Float64Array, gaps: number[], lo: n
|
|
|
152
152
|
* series into per-session subpaths at exactly those instants.
|
|
153
153
|
*/
|
|
154
154
|
export declare function decimateM4(cs: ChartSeries, xScale: Scale, ctx: CanvasRenderingContext2D, k?: number, boundaries?: readonly number[]): ChartSeries;
|
|
155
|
+
/**
|
|
156
|
+
* Cull `source` to the visible window and M4-decimate it, **memoized per source
|
|
157
|
+
* series** so a y-only repaint reuses the prior frame's polyline instead of
|
|
158
|
+
* re-binning O(N) points. The decimation output is a pure function of the source
|
|
159
|
+
* data, the x mapping, the device width, the threshold, and the session breaks —
|
|
160
|
+
* it **never reads the y-scale** — so it is byte-identical across every y-zoom /
|
|
161
|
+
* y-autorange frame (the ~19% mountain@1M recompute the 2026-07 bench profile
|
|
162
|
+
* flagged, finding 3: an x-only computation re-run under y-only invalidation).
|
|
163
|
+
*
|
|
164
|
+
* The cache holds **one** entry per source: a y-only frame matches the stored
|
|
165
|
+
* key → hit; a pan / x-zoom mints a fresh `xScale` (`ChartContainer` keys the
|
|
166
|
+
* scale on the x-domain, not the y-domain — so the scale object is stable under
|
|
167
|
+
* y-zoom and fresh under pan) → miss, and the single entry is overwritten. So it
|
|
168
|
+
* **wins on y-only frames and is a no-op under pan** — bounded, never growing,
|
|
169
|
+
* the same reasoning that made Path2D caching not help pan ([PND-DECIM]).
|
|
170
|
+
*
|
|
171
|
+
* Correctness rests on the {@link ChartSeries} immutability contract: a data
|
|
172
|
+
* change mints a **new** source object (the layer re-materializes its column),
|
|
173
|
+
* so a stale entry can't be read. `boundaries` is compared by **identity** — the
|
|
174
|
+
* `<LineChart>` session-break instants are `useMemo`-stable across frames; a
|
|
175
|
+
* caller passing a fresh array each frame simply misses (safe, no benefit). `W`
|
|
176
|
+
* is `deviceBucketCount(ctx)` so a DPR / resize change re-keys. Keying on the
|
|
177
|
+
* `xScale` object (not just its `[domain, W]`) is what keeps the hit correct on
|
|
178
|
+
* a **non-affine** trading-time scale too — same scale object ⇒ identical
|
|
179
|
+
* pixel-column edges.
|
|
180
|
+
*
|
|
181
|
+
* Returns `{ series, decimated }`: `series` is the M4 polyline, or the plain
|
|
182
|
+
* culled slice when the window is too sparse to decimate ({@link decimateM4}
|
|
183
|
+
* no-ops); `decimated` says which (for the caller's draw-stats + session-run
|
|
184
|
+
* split). Callers pass the **pre-cull** source and skip their own cull — this
|
|
185
|
+
* function does it, so a cache hit skips the cull too.
|
|
186
|
+
*/
|
|
187
|
+
export declare function decimateM4Cached(source: ChartSeries, xScale: Scale, ctx: CanvasRenderingContext2D, k?: number, boundaries?: readonly number[]): {
|
|
188
|
+
series: ChartSeries;
|
|
189
|
+
decimated: boolean;
|
|
190
|
+
};
|
|
155
191
|
/**
|
|
156
192
|
* Assemble the M4 polyline {@link ChartSeries} from the four binned channels.
|
|
157
193
|
* Split out (pure, no canvas / pond deps) so the point emission is unit-tested
|
|
@@ -228,4 +264,84 @@ export declare function decimateOhlc(ohlc: OhlcSeries, xScale: Scale, ctx: Canva
|
|
|
228
264
|
* reduces to `NaN` on every channel — `drawBox` skips it via `isFiniteBox`.
|
|
229
265
|
*/
|
|
230
266
|
export declare function decimateBox(box: BoxSeries, xScale: Scale, ctx: CanvasRenderingContext2D, k?: number, visibleCount?: number): BoxSeries;
|
|
267
|
+
/**
|
|
268
|
+
* One filled **envelope rect per device-pixel column** — the decimated form of a
|
|
269
|
+
* {@link BarSeries} ({@link decimateBars}). Each column `b` spans `[begin[b],
|
|
270
|
+
* end[b]]` in key space and fills the value range `[lo[b], hi[b]]`, i.e. the union
|
|
271
|
+
* of every bar in that column (their tops range over `[min, max]`, and each bar
|
|
272
|
+
* also reaches the baseline — so the union is `[min(minValue, baseline),
|
|
273
|
+
* max(maxValue, baseline)]`). An empty column carries `NaN` on `lo`/`hi` and draws
|
|
274
|
+
* nothing.
|
|
275
|
+
*/
|
|
276
|
+
export interface BarColumnEnvelope {
|
|
277
|
+
readonly begin: Float64Array;
|
|
278
|
+
readonly end: Float64Array;
|
|
279
|
+
readonly lo: Float64Array;
|
|
280
|
+
readonly hi: Float64Array;
|
|
281
|
+
readonly length: number;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Decimate a {@link BarSeries} to **one envelope rect per device-pixel column**
|
|
285
|
+
* ([PND-MARKDEC]) — the interval-mark analog of {@link decimateBand}, for the
|
|
286
|
+
* "column chart, dense in x" case (SciChart-suite finding 4: a bar column has no
|
|
287
|
+
* decimation path, so it falls off where line/area/candle don't). Once each bar's
|
|
288
|
+
* slot is narrower than ~1px (the visible bars exceed `k ×` the device-pixel
|
|
289
|
+
* column count), the individual rects overplot into a solid silhouette; this
|
|
290
|
+
* replaces them with the exact painted union: per column, `lo = min(minValue,
|
|
291
|
+
* baseline)` and `hi = max(maxValue, baseline)` — the bars' value range widened
|
|
292
|
+
* to include the baseline every bar reaches. Drawing one rect `[begin, end] ×
|
|
293
|
+
* [lo, hi]` per column reproduces that silhouette from O(W) rects instead of
|
|
294
|
+
* O(visible).
|
|
295
|
+
*
|
|
296
|
+
* Gates on the **visible** bar count (`visibleCount`, a bar's width is its slot —
|
|
297
|
+
* decimating a handful of deep-zoomed bars would render 1px slivers, the same
|
|
298
|
+
* trap the candle / box paths gate against). Returns `null` when decimation
|
|
299
|
+
* doesn't apply (below the visible-density threshold, domainless / non-invertible
|
|
300
|
+
* scale, no canvas width) — the caller then draws every visible bar. Bars are
|
|
301
|
+
* binned by their **`begin`** key (at this density `begin`/`end` sit in the same
|
|
302
|
+
* column); the envelope ignores per-bar `gapPx` and tiles the column (a few-px
|
|
303
|
+
* gap is invisible at <1px bars anyway — the standard decimation tradeoff).
|
|
304
|
+
* `baseline` is the resolved bar baseline in **value** units (from
|
|
305
|
+
* `resolveBarBaseline`), so the union is honest about the zero line.
|
|
306
|
+
*/
|
|
307
|
+
export declare function decimateBars(cs: BarSeries, xScale: Scale, ctx: CanvasRenderingContext2D, baseline: number, k?: number, visibleCount?: number): BarColumnEnvelope | null;
|
|
308
|
+
/**
|
|
309
|
+
* Decimate a **uniform** scatter to one representative mark per occupied
|
|
310
|
+
* **pixel cell** ([PND-MARKDEC] scatter half) — the marks analog of
|
|
311
|
+
* {@link decimateBars}, but **2D**: scatter has no fill, so a line/bar's
|
|
312
|
+
* `[min, max]`-per-column envelope would erase interior points. Instead this
|
|
313
|
+
* bins the plane into `cellPx × cellPx` cells and keeps the **first** point in
|
|
314
|
+
* each occupied cell.
|
|
315
|
+
*
|
|
316
|
+
* **Why it's visually lossless (for uniform, opaque marks):** with `cellPx` set
|
|
317
|
+
* to the mark **radius**, any two points sharing a cell are at most `cellPx·√2 ≈
|
|
318
|
+
* 1.4·r` apart — less than `2·r` — so their discs overlap; drawing one paints
|
|
319
|
+
* (essentially) the pixels of both. Points in different cells are kept, so a
|
|
320
|
+
* sparse-in-y scatter reduces little (and stays exact); a dense blob collapses to
|
|
321
|
+
* ~`plotArea / cellPx²` marks regardless of N. The reduction is therefore an
|
|
322
|
+
* **unconditional** identity for overlapping uniform marks — the caller's job is
|
|
323
|
+
* only to decide when the O(visible) sweep is worth it (density gate) and that
|
|
324
|
+
* the marks are in fact uniform + opaque (data-driven size/colour or translucent
|
|
325
|
+
* fill must **not** decimate — see {@link isOpaqueColor}).
|
|
326
|
+
*
|
|
327
|
+
* `cs.x` is sorted, so the sweep visits columns left-to-right and only the
|
|
328
|
+
* **current** column's occupied rows need tracking (a `Set` cleared on each
|
|
329
|
+
* column change — bounded memory, O(visible) total). Positions are in CSS px
|
|
330
|
+
* (`xScale`/`yScale` output), matching the mark radius' units. `[vStart, vEnd)`
|
|
331
|
+
* is the pre-culled visible window (defaults to the whole series). Gaps
|
|
332
|
+
* (non-finite y) are skipped, like {@link drawScatter}.
|
|
333
|
+
*/
|
|
334
|
+
export declare function decimateScatter(cs: ChartSeries, xScale: Scale, yScale: Scale, cellPx: number, vStart?: number, vEnd?: number): ChartSeries;
|
|
335
|
+
/**
|
|
336
|
+
* Whether `color` is fully opaque — the second precondition (beside
|
|
337
|
+
* {@link ResolvedEncoding.uniform}) for scatter decimation. A **translucent**
|
|
338
|
+
* fill accumulates opacity where marks overlap (that build-up _is_ the density
|
|
339
|
+
* signal), so collapsing overlaps would lighten the plot — decimation must not
|
|
340
|
+
* engage. Returns `false` only when it can **prove** alpha < 1
|
|
341
|
+
* (`rgba(…, a<1)`, `#rrggbbaa` / `#rgba` with alpha < full, or `transparent`);
|
|
342
|
+
* every other form (`#rgb` / `#rrggbb`, `rgb()`, a named colour, `hsl()`) is
|
|
343
|
+
* treated as opaque. Conservative in the safe direction: a colour it can't prove
|
|
344
|
+
* translucent still decimates, but the realistic translucent cases are caught.
|
|
345
|
+
*/
|
|
346
|
+
export declare function isOpaqueColor(color: string): boolean;
|
|
231
347
|
//# sourceMappingURL=decimate.d.ts.map
|
package/dist/decimate.js
CHANGED
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
* shrinks the point count, not a second renderer.
|
|
47
47
|
*/
|
|
48
48
|
import { Float64Column } from 'pond-ts';
|
|
49
|
-
import {
|
|
49
|
+
import { affineOf } from './affine.js';
|
|
50
|
+
import { scaleDomain, cullChartSeries } from './culling.js';
|
|
50
51
|
/** The device-pixel bucket count for `ctx` — the backing buffer width, i.e.
|
|
51
52
|
* `plotWidthCss × DPR` (so buckets land at device-pixel resolution). Falls back
|
|
52
53
|
* to `0` when there is no sized canvas (a headless test ctx), which the caller
|
|
@@ -247,6 +248,74 @@ export function decimateM4(cs, xScale, ctx, k = 2, boundaries = []) {
|
|
|
247
248
|
const { lo: mn, hi: mx, first, last, } = col.binBy(cs.x, edges, 'minMaxFirstLast');
|
|
248
249
|
return m4Polyline(edges, mn, mx, first, last, buckets, breaks.length > 0 ? new Set(breaks) : undefined);
|
|
249
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* Stable empty boundary list for the (common) no-session-break decimation cache
|
|
253
|
+
* key — so an area / no-break line draw compares equal frame-to-frame instead of
|
|
254
|
+
* missing on a fresh default `[]`.
|
|
255
|
+
*/
|
|
256
|
+
const NO_BOUNDARIES = [];
|
|
257
|
+
/**
|
|
258
|
+
* One-entry-per-source cache of the cull+M4-decimate result ([PND-DECKEY]),
|
|
259
|
+
* keyed on the source series (`WeakMap`) → the last `(xScale, W, k, boundaries)`
|
|
260
|
+
* it was drawn for. Evicts with the series (no leak); one entry per source keeps
|
|
261
|
+
* it bounded under pan (see {@link decimateM4Cached}).
|
|
262
|
+
*/
|
|
263
|
+
const m4Cache = new WeakMap();
|
|
264
|
+
/**
|
|
265
|
+
* Cull `source` to the visible window and M4-decimate it, **memoized per source
|
|
266
|
+
* series** so a y-only repaint reuses the prior frame's polyline instead of
|
|
267
|
+
* re-binning O(N) points. The decimation output is a pure function of the source
|
|
268
|
+
* data, the x mapping, the device width, the threshold, and the session breaks —
|
|
269
|
+
* it **never reads the y-scale** — so it is byte-identical across every y-zoom /
|
|
270
|
+
* y-autorange frame (the ~19% mountain@1M recompute the 2026-07 bench profile
|
|
271
|
+
* flagged, finding 3: an x-only computation re-run under y-only invalidation).
|
|
272
|
+
*
|
|
273
|
+
* The cache holds **one** entry per source: a y-only frame matches the stored
|
|
274
|
+
* key → hit; a pan / x-zoom mints a fresh `xScale` (`ChartContainer` keys the
|
|
275
|
+
* scale on the x-domain, not the y-domain — so the scale object is stable under
|
|
276
|
+
* y-zoom and fresh under pan) → miss, and the single entry is overwritten. So it
|
|
277
|
+
* **wins on y-only frames and is a no-op under pan** — bounded, never growing,
|
|
278
|
+
* the same reasoning that made Path2D caching not help pan ([PND-DECIM]).
|
|
279
|
+
*
|
|
280
|
+
* Correctness rests on the {@link ChartSeries} immutability contract: a data
|
|
281
|
+
* change mints a **new** source object (the layer re-materializes its column),
|
|
282
|
+
* so a stale entry can't be read. `boundaries` is compared by **identity** — the
|
|
283
|
+
* `<LineChart>` session-break instants are `useMemo`-stable across frames; a
|
|
284
|
+
* caller passing a fresh array each frame simply misses (safe, no benefit). `W`
|
|
285
|
+
* is `deviceBucketCount(ctx)` so a DPR / resize change re-keys. Keying on the
|
|
286
|
+
* `xScale` object (not just its `[domain, W]`) is what keeps the hit correct on
|
|
287
|
+
* a **non-affine** trading-time scale too — same scale object ⇒ identical
|
|
288
|
+
* pixel-column edges.
|
|
289
|
+
*
|
|
290
|
+
* Returns `{ series, decimated }`: `series` is the M4 polyline, or the plain
|
|
291
|
+
* culled slice when the window is too sparse to decimate ({@link decimateM4}
|
|
292
|
+
* no-ops); `decimated` says which (for the caller's draw-stats + session-run
|
|
293
|
+
* split). Callers pass the **pre-cull** source and skip their own cull — this
|
|
294
|
+
* function does it, so a cache hit skips the cull too.
|
|
295
|
+
*/
|
|
296
|
+
export function decimateM4Cached(source, xScale, ctx, k, boundaries = NO_BOUNDARIES) {
|
|
297
|
+
const W = deviceBucketCount(ctx);
|
|
298
|
+
const cached = m4Cache.get(source);
|
|
299
|
+
if (cached !== undefined &&
|
|
300
|
+
cached.xScale === xScale &&
|
|
301
|
+
cached.W === W &&
|
|
302
|
+
cached.k === k &&
|
|
303
|
+
cached.boundaries === boundaries) {
|
|
304
|
+
return cached;
|
|
305
|
+
}
|
|
306
|
+
const culled = cullChartSeries(source, xScale);
|
|
307
|
+
const series = decimateM4(culled, xScale, ctx, k, boundaries);
|
|
308
|
+
const entry = {
|
|
309
|
+
xScale,
|
|
310
|
+
W,
|
|
311
|
+
k,
|
|
312
|
+
boundaries,
|
|
313
|
+
series,
|
|
314
|
+
decimated: series !== culled,
|
|
315
|
+
};
|
|
316
|
+
m4Cache.set(source, entry);
|
|
317
|
+
return entry;
|
|
318
|
+
}
|
|
250
319
|
/** Shared empty break-set for the common (no session-break) case. */
|
|
251
320
|
const NO_BREAKS = new Set();
|
|
252
321
|
/**
|
|
@@ -475,4 +544,175 @@ export function decimateBox(box, xScale, ctx, k = 2, visibleCount = box.length)
|
|
|
475
544
|
...(box.hasMedian !== undefined ? { hasMedian: box.hasMedian } : {}),
|
|
476
545
|
};
|
|
477
546
|
}
|
|
547
|
+
/**
|
|
548
|
+
* Decimate a {@link BarSeries} to **one envelope rect per device-pixel column**
|
|
549
|
+
* ([PND-MARKDEC]) — the interval-mark analog of {@link decimateBand}, for the
|
|
550
|
+
* "column chart, dense in x" case (SciChart-suite finding 4: a bar column has no
|
|
551
|
+
* decimation path, so it falls off where line/area/candle don't). Once each bar's
|
|
552
|
+
* slot is narrower than ~1px (the visible bars exceed `k ×` the device-pixel
|
|
553
|
+
* column count), the individual rects overplot into a solid silhouette; this
|
|
554
|
+
* replaces them with the exact painted union: per column, `lo = min(minValue,
|
|
555
|
+
* baseline)` and `hi = max(maxValue, baseline)` — the bars' value range widened
|
|
556
|
+
* to include the baseline every bar reaches. Drawing one rect `[begin, end] ×
|
|
557
|
+
* [lo, hi]` per column reproduces that silhouette from O(W) rects instead of
|
|
558
|
+
* O(visible).
|
|
559
|
+
*
|
|
560
|
+
* Gates on the **visible** bar count (`visibleCount`, a bar's width is its slot —
|
|
561
|
+
* decimating a handful of deep-zoomed bars would render 1px slivers, the same
|
|
562
|
+
* trap the candle / box paths gate against). Returns `null` when decimation
|
|
563
|
+
* doesn't apply (below the visible-density threshold, domainless / non-invertible
|
|
564
|
+
* scale, no canvas width) — the caller then draws every visible bar. Bars are
|
|
565
|
+
* binned by their **`begin`** key (at this density `begin`/`end` sit in the same
|
|
566
|
+
* column); the envelope ignores per-bar `gapPx` and tiles the column (a few-px
|
|
567
|
+
* gap is invisible at <1px bars anyway — the standard decimation tradeoff).
|
|
568
|
+
* `baseline` is the resolved bar baseline in **value** units (from
|
|
569
|
+
* `resolveBarBaseline`), so the union is honest about the zero line.
|
|
570
|
+
*/
|
|
571
|
+
export function decimateBars(cs, xScale, ctx, baseline, k = 2, visibleCount = cs.length) {
|
|
572
|
+
if (!shouldDecimateCount(visibleCount, ctx, k))
|
|
573
|
+
return null;
|
|
574
|
+
const dom = scaleDomain(xScale);
|
|
575
|
+
if (dom === null || dom[1] <= dom[0])
|
|
576
|
+
return null;
|
|
577
|
+
const invert = scaleInvert(xScale);
|
|
578
|
+
const plotWidthCss = scaleRangeWidth(xScale);
|
|
579
|
+
if (invert === null || plotWidthCss === null)
|
|
580
|
+
return null;
|
|
581
|
+
const W = deviceBucketCount(ctx);
|
|
582
|
+
const edges = pixelEdges(invert, plotWidthCss, W);
|
|
583
|
+
// Per-column min/max of the bar values, binned by the bar's begin key — two O(n)
|
|
584
|
+
// walks, like decimateBand. An empty column reduces to NaN on both.
|
|
585
|
+
const col = new Float64Column(cs.y, cs.length);
|
|
586
|
+
const vMin = col.binBy(cs.begin, edges, 'min');
|
|
587
|
+
const vMax = col.binBy(cs.begin, edges, 'max');
|
|
588
|
+
const begin = new Float64Array(W);
|
|
589
|
+
const end = new Float64Array(W);
|
|
590
|
+
const lo = new Float64Array(W);
|
|
591
|
+
const hi = new Float64Array(W);
|
|
592
|
+
for (let b = 0; b < W; b += 1) {
|
|
593
|
+
begin[b] = edges[b];
|
|
594
|
+
end[b] = edges[b + 1];
|
|
595
|
+
const mn = vMin[b];
|
|
596
|
+
if (Number.isFinite(mn)) {
|
|
597
|
+
// Widen to the baseline so the rect spans exactly the painted union (each
|
|
598
|
+
// bar reaches the baseline). One-signed data ⇒ one edge is the baseline ⇒
|
|
599
|
+
// the rect is the tallest bar, unchanged.
|
|
600
|
+
lo[b] = Math.min(mn, baseline);
|
|
601
|
+
hi[b] = Math.max(vMax[b], baseline);
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
lo[b] = NaN; // empty column — drawBars skips it
|
|
605
|
+
hi[b] = NaN;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return { begin, end, lo, hi, length: W };
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Decimate a **uniform** scatter to one representative mark per occupied
|
|
612
|
+
* **pixel cell** ([PND-MARKDEC] scatter half) — the marks analog of
|
|
613
|
+
* {@link decimateBars}, but **2D**: scatter has no fill, so a line/bar's
|
|
614
|
+
* `[min, max]`-per-column envelope would erase interior points. Instead this
|
|
615
|
+
* bins the plane into `cellPx × cellPx` cells and keeps the **first** point in
|
|
616
|
+
* each occupied cell.
|
|
617
|
+
*
|
|
618
|
+
* **Why it's visually lossless (for uniform, opaque marks):** with `cellPx` set
|
|
619
|
+
* to the mark **radius**, any two points sharing a cell are at most `cellPx·√2 ≈
|
|
620
|
+
* 1.4·r` apart — less than `2·r` — so their discs overlap; drawing one paints
|
|
621
|
+
* (essentially) the pixels of both. Points in different cells are kept, so a
|
|
622
|
+
* sparse-in-y scatter reduces little (and stays exact); a dense blob collapses to
|
|
623
|
+
* ~`plotArea / cellPx²` marks regardless of N. The reduction is therefore an
|
|
624
|
+
* **unconditional** identity for overlapping uniform marks — the caller's job is
|
|
625
|
+
* only to decide when the O(visible) sweep is worth it (density gate) and that
|
|
626
|
+
* the marks are in fact uniform + opaque (data-driven size/colour or translucent
|
|
627
|
+
* fill must **not** decimate — see {@link isOpaqueColor}).
|
|
628
|
+
*
|
|
629
|
+
* `cs.x` is sorted, so the sweep visits columns left-to-right and only the
|
|
630
|
+
* **current** column's occupied rows need tracking (a `Set` cleared on each
|
|
631
|
+
* column change — bounded memory, O(visible) total). Positions are in CSS px
|
|
632
|
+
* (`xScale`/`yScale` output), matching the mark radius' units. `[vStart, vEnd)`
|
|
633
|
+
* is the pre-culled visible window (defaults to the whole series). Gaps
|
|
634
|
+
* (non-finite y) are skipped, like {@link drawScatter}.
|
|
635
|
+
*/
|
|
636
|
+
export function decimateScatter(cs, xScale, yScale, cellPx, vStart = 0, vEnd = cs.length) {
|
|
637
|
+
const cell = cellPx > 0 ? cellPx : 1;
|
|
638
|
+
// Affine fast path ([PND-AFFINE]) for the per-point pixel mapping the sweep
|
|
639
|
+
// needs — an inline `k·v + b` over the typed arrays instead of a d3-scale
|
|
640
|
+
// closure per point (each axis independently; a non-affine axis, e.g. a
|
|
641
|
+
// real-gap trading x, falls back to the exact scale call). Without this the
|
|
642
|
+
// sweep would re-introduce the per-point d3-scale cost the line/area paths
|
|
643
|
+
// shed, making the decimation's own cost dominate.
|
|
644
|
+
const ax = affineOf(xScale);
|
|
645
|
+
const ay = affineOf(yScale);
|
|
646
|
+
const outX = [];
|
|
647
|
+
const outY = [];
|
|
648
|
+
let curCol = Number.NaN;
|
|
649
|
+
const rows = new Set();
|
|
650
|
+
for (let i = vStart; i < vEnd; i += 1) {
|
|
651
|
+
const y = cs.y[i];
|
|
652
|
+
if (!Number.isFinite(y))
|
|
653
|
+
continue; // gap — no mark
|
|
654
|
+
const xv = cs.x[i];
|
|
655
|
+
const px = ax !== null ? ax.k * xv + ax.b : xScale(xv);
|
|
656
|
+
const col = Math.floor(px / cell);
|
|
657
|
+
if (col !== curCol) {
|
|
658
|
+
rows.clear();
|
|
659
|
+
curCol = col;
|
|
660
|
+
}
|
|
661
|
+
const py = ay !== null ? ay.k * y + ay.b : yScale(y);
|
|
662
|
+
const row = Math.floor(py / cell);
|
|
663
|
+
if (!rows.has(row)) {
|
|
664
|
+
rows.add(row);
|
|
665
|
+
outX.push(xv);
|
|
666
|
+
outY.push(y);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return {
|
|
670
|
+
x: Float64Array.from(outX),
|
|
671
|
+
y: Float64Array.from(outY),
|
|
672
|
+
length: outX.length,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Whether `color` is fully opaque — the second precondition (beside
|
|
677
|
+
* {@link ResolvedEncoding.uniform}) for scatter decimation. A **translucent**
|
|
678
|
+
* fill accumulates opacity where marks overlap (that build-up _is_ the density
|
|
679
|
+
* signal), so collapsing overlaps would lighten the plot — decimation must not
|
|
680
|
+
* engage. Returns `false` only when it can **prove** alpha < 1
|
|
681
|
+
* (`rgba(…, a<1)`, `#rrggbbaa` / `#rgba` with alpha < full, or `transparent`);
|
|
682
|
+
* every other form (`#rgb` / `#rrggbb`, `rgb()`, a named colour, `hsl()`) is
|
|
683
|
+
* treated as opaque. Conservative in the safe direction: a colour it can't prove
|
|
684
|
+
* translucent still decimates, but the realistic translucent cases are caught.
|
|
685
|
+
*/
|
|
686
|
+
export function isOpaqueColor(color) {
|
|
687
|
+
const c = color.trim().toLowerCase();
|
|
688
|
+
if (c === 'transparent')
|
|
689
|
+
return false;
|
|
690
|
+
// rgb/rgba/hsl/hsla, both syntaxes: legacy comma (`rgba(r,g,b,a)`) and modern
|
|
691
|
+
// slash (`rgb(r g b / a)` / `hsl(h s l / a)`). Extract the alpha either way.
|
|
692
|
+
const fn = /^(?:rgba?|hsla?)\(([^)]+)\)$/.exec(c);
|
|
693
|
+
if (fn) {
|
|
694
|
+
const body = fn[1];
|
|
695
|
+
const slash = body.split('/');
|
|
696
|
+
if (slash.length === 2) {
|
|
697
|
+
const a = parseFloat(slash[1].trim()); // modern: `… / <alpha>`
|
|
698
|
+
return !(Number.isFinite(a) && a < 1);
|
|
699
|
+
}
|
|
700
|
+
const parts = body.split(',').map((p) => p.trim());
|
|
701
|
+
if (parts.length >= 4) {
|
|
702
|
+
const a = parseFloat(parts[3]); // legacy: 4th component is alpha
|
|
703
|
+
return !(Number.isFinite(a) && a < 1);
|
|
704
|
+
}
|
|
705
|
+
return true; // 3-component rgb/hsl → opaque
|
|
706
|
+
}
|
|
707
|
+
const hex = /^#([0-9a-f]{3,8})$/.exec(c);
|
|
708
|
+
if (hex) {
|
|
709
|
+
const h = hex[1];
|
|
710
|
+
if (h.length === 8)
|
|
711
|
+
return parseInt(h.slice(6, 8), 16) === 255;
|
|
712
|
+
if (h.length === 4)
|
|
713
|
+
return parseInt(h[3] + h[3], 16) === 255;
|
|
714
|
+
return true; // #rgb / #rrggbb
|
|
715
|
+
}
|
|
716
|
+
return true; // named / unknown → opaque
|
|
717
|
+
}
|
|
478
718
|
//# sourceMappingURL=decimate.js.map
|
package/dist/encoding.d.ts
CHANGED
|
@@ -62,6 +62,15 @@ export interface ResolvedEncoding {
|
|
|
62
62
|
radiusAt(i: number): number;
|
|
63
63
|
/** This point's fill colour (base colour if unencoded / non-finite). */
|
|
64
64
|
colorAt(i: number): string;
|
|
65
|
+
/**
|
|
66
|
+
* `true` when **neither** radius nor colour is data-driven — every mark is the
|
|
67
|
+
* same fixed size and colour (`radiusAt`/`colorAt` ignore their index). This is
|
|
68
|
+
* the precondition for lossless occupancy **decimation** (PND-MARKDEC scatter
|
|
69
|
+
* half): only same-size, same-colour marks can be collapsed where they overlap
|
|
70
|
+
* without changing the picture. A `{column, range}` on either channel makes it
|
|
71
|
+
* `false`, and the scatter draws every point.
|
|
72
|
+
*/
|
|
73
|
+
readonly uniform: boolean;
|
|
65
74
|
}
|
|
66
75
|
/** A numeric column read into a `Float64Array` (gaps as NaN), by name. */
|
|
67
76
|
export type ColumnReader = (column: string) => Float64Array;
|
package/dist/encoding.js
CHANGED
|
@@ -139,6 +139,10 @@ export function resolveEncoding(cs, baseRadius, baseColor, radius, color, readCo
|
|
|
139
139
|
};
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
-
|
|
142
|
+
// Uniform iff both channels are fixed: radius a number/omitted and no colour
|
|
143
|
+
// encoding. Data-driven either side (`{column, range}`) makes every mark
|
|
144
|
+
// potentially distinct, so decimation must not collapse them.
|
|
145
|
+
const uniform = (radius === undefined || typeof radius === 'number') && color === undefined;
|
|
146
|
+
return { radiusAt, colorAt, uniform };
|
|
143
147
|
}
|
|
144
148
|
//# sourceMappingURL=encoding.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Canvas-rendered, streaming-first time-series charts with a
|
|
5
5
|
* react-timeseries-charts-style declarative layout. The architecture (hard
|
|
6
|
-
* layers: adapter → typed-array store → decimator →
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* layers: adapter → typed-array store → decimator → canvas renderer → React
|
|
7
|
+
* shell) is documented in the charts RFC at `docs/rfcs/charts.md`; the
|
|
8
|
+
* milestone plan lives in `PLAN.md`. (A Path2D path cache was explored and
|
|
9
|
+
* deferred — it doesn't help the pan case, which re-decimates every frame; see
|
|
10
|
+
* the [PND-DECIM] floor decision.)
|
|
9
11
|
*
|
|
10
12
|
* **M1 — rendering spine.** The layout shell + the first draw layer:
|
|
11
13
|
* `<ChartContainer>` (time axis) → `<ChartRow>` (y-axis + canvas) →
|
|
@@ -75,4 +77,5 @@ export type { ChartThemeOverrides, VarReader } from './css-theme.js';
|
|
|
75
77
|
export { useChartTheme } from './useChartTheme.js';
|
|
76
78
|
export type { UseChartThemeOptions } from './useChartTheme.js';
|
|
77
79
|
export type { CursorMode, TrackerInfo, TrackerSample, SelectInfo, } from './context.js';
|
|
80
|
+
export type { DrawStatsFrame, LayerDrawInfo } from './context.js';
|
|
78
81
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Canvas-rendered, streaming-first time-series charts with a
|
|
5
5
|
* react-timeseries-charts-style declarative layout. The architecture (hard
|
|
6
|
-
* layers: adapter → typed-array store → decimator →
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* layers: adapter → typed-array store → decimator → canvas renderer → React
|
|
7
|
+
* shell) is documented in the charts RFC at `docs/rfcs/charts.md`; the
|
|
8
|
+
* milestone plan lives in `PLAN.md`. (A Path2D path cache was explored and
|
|
9
|
+
* deferred — it doesn't help the pan case, which re-decimates every frame; see
|
|
10
|
+
* the [PND-DECIM] floor decision.)
|
|
9
11
|
*
|
|
10
12
|
* **M1 — rendering spine.** The layout shell + the first draw layer:
|
|
11
13
|
* `<ChartContainer>` (time axis) → `<ChartRow>` (y-axis + canvas) →
|
package/dist/line.d.ts
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
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 { LayerDrawStats } from './context.js';
|
|
4
5
|
import { type DecimateOption } from './decimate.js';
|
|
6
|
+
import { type Affine } from './affine.js';
|
|
5
7
|
import { type GapMode } from './gaps.js';
|
|
6
8
|
/** Maps a data value to a pixel coordinate (a d3 scale is assignable to this). */
|
|
7
9
|
export type Scale = (value: number) => number;
|
|
10
|
+
/**
|
|
11
|
+
* Stroke one run of `ys` (aligned index-for-index with `xs`) through the affine
|
|
12
|
+
* pixel maps `ax` / `ay` — the [PND-AFFINE] fast path. Replicates d3-shape's
|
|
13
|
+
* `curveLinear` + `.defined(Number.isFinite)` behaviour exactly: a non-finite
|
|
14
|
+
* value lifts the pen (the next finite point `moveTo`s a fresh subpath), a
|
|
15
|
+
* finite value `lineTo`s (or `moveTo`s when the pen is up), so a gap breaks and
|
|
16
|
+
* a lone point draws nothing — the same op sequence the generator emits, minus
|
|
17
|
+
* the per-point `scale()` + d3-shape closures. The caller brackets
|
|
18
|
+
* `beginPath`/`stroke`; `xs`/`ys` are the run (a `subarray` view, so index 0 is
|
|
19
|
+
* the run start). Used for lines and for the area outline.
|
|
20
|
+
*/
|
|
21
|
+
export declare function strokeAffinePolyline(ctx: CanvasRenderingContext2D, xs: Float64Array, ys: Float64Array, ax: Affine, ay: Affine): void;
|
|
8
22
|
/**
|
|
9
23
|
* The y-scale's domain lower bound (the axis floor) in pixels — where the
|
|
10
24
|
* `step` / `fade` gap bridges drop to. The runtime `yScale` is a d3
|
|
@@ -51,7 +65,7 @@ export declare function yExtent(cs: ChartSeries): [number, number] | null;
|
|
|
51
65
|
* the NaN **data** gaps (`gaps`) handled within each run. With no boundaries the
|
|
52
66
|
* output is identical to a single-pass draw.
|
|
53
67
|
*/
|
|
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):
|
|
68
|
+
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): LayerDrawStats;
|
|
55
69
|
/**
|
|
56
70
|
* Split a sorted columnar x-axis into contiguous index runs `[start, endEx)`,
|
|
57
71
|
* cutting wherever a `boundaries` instant falls in `(x[i-1], x[i]]` — i.e. a
|