@pond-ts/charts 0.51.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 CHANGED
@@ -8,7 +8,8 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
8
8
  tag, so this file covers them all. Pre-1.0: minor bumps may include new features
9
9
  and type-level changes; patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pond-ts/pond/compare/v0.51.0...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.52.0...HEAD
12
+ [0.52.0]: https://github.com/pond-ts/pond/compare/v0.51.0...v0.52.0
12
13
  [0.51.0]: https://github.com/pond-ts/pond/compare/v0.50.0...v0.51.0
13
14
  [0.50.0]: https://github.com/pond-ts/pond/compare/v0.49.0...v0.50.0
14
15
  [0.49.0]: https://github.com/pond-ts/pond/compare/v0.48.1...v0.49.0
@@ -51,6 +52,79 @@ and type-level changes; patch bumps are strictly additive.
51
52
 
52
53
  ## [Unreleased]
53
54
 
55
+ ## [0.52.0] — 2026-07-23
56
+
57
+ ### Changed
58
+
59
+ - **core / financial:** **Market-scale studies are now typed-array fast**
60
+ (the "SMA/EMA at 1M bars costs hundreds of ms" report). Three cuts along
61
+ the same path, all behaviour-preserving (identical values, warm-ups,
62
+ missing-cell semantics, and rejection errors; every fast path falls back
63
+ to the original sweep when it doesn't apply):
64
+ - **`smooth('ema')` columnar fast path** — on a packed numeric source
65
+ column the EMA recurrence runs straight off the typed buffer into a
66
+ typed result column via trusted construction (key + untouched columns
67
+ pass through zero-copy), replacing the per-row Event/tuple rebuild +
68
+ full-series intake re-pack. 1M rows: **530 ms → 4.4 ms (~120×)**.
69
+ - **`rolling({ count })` numeric fast path** — an all-built-in numeric
70
+ mapping over packed sources feeds the shared incremental reducer states
71
+ directly from the typed buffers and writes snapshots into typed columns
72
+ (no per-row snapshot arrays, no boxed accumulators, no post-pass
73
+ assert/re-pack). 1M rows, `avg`: **135 ms → 32 ms (~4×)**.
74
+ - **financial kernel reads columns, not events** — `rollingColumns` /
75
+ `columnValues` now read study inputs/outputs off the public column API
76
+ instead of materializing `series.events` (an Event + data object per
77
+ row, ~400 ms of pure overhead at 1M rows).
78
+ - End-to-end at 1M bars: `ema()` **603 ms → 2.5 ms (~240×)**, `sma()`
79
+ **569 ms → 56 ms (~10×)**, `bollinger()` **748 ms → 162 ms (~4.6×)**.
80
+ Durable benchmarks: `packages/core/scripts/perf-smooth-ema.mjs`,
81
+ `packages/financial/scripts/perf-studies.mjs`.
82
+
83
+ ### Added
84
+
85
+ - **core:** **`TimeSeries.fromArrow(table, options?)` — ingest a decoded Apache
86
+ Arrow `Table`.** pond stays zero-dependency: bring your own Arrow
87
+ (`tableFromIPC(...)`) and hand the `Table` in; the input is duck-typed against
88
+ a small structural surface (`ArrowTableLike` / `ArrowVectorLike` / …, all
89
+ exported). Ingest is the zero-copy path — every `Float64` column's backing
90
+ `Float64Array` is adopted as-is (`Float32`/int columns convert; int64 value
91
+ columns recombine BigInt-free), and the schema is derived from the Arrow
92
+ fields. The time key is converted **BigInt-free**: Arrow's idiomatic int64
93
+ timestamps are recombined from their two int32 halves rather than
94
+ `Number(bigint)` per row — measured **~11× faster** on the time column (0.6ms
95
+ vs 6.8ms at 500k rows; `scripts/perf-from-arrow.mjs`). Options: `time` (key
96
+ column, default the `'time'` field), `timeUnit` (default read from the Arrow
97
+ Arrow type family — a `Timestamp`'s raw-unit int64 is scaled by its
98
+ `TimeUnit`; `Date32`/`Date64` arrive already normalized to epoch-ms and pass
99
+ through; overridable), `columns` (subset, in order), `name`, `sort`. Numeric
100
+ **and string** columns are supported — string columns (Arrow
101
+ `Utf8`) become dict-encoded `StringColumn`s; any other Arrow type
102
+ (list/struct) throws, naming it. A null time key throws; numeric nulls map to
103
+ `NaN` and string nulls to missing.
104
+ - **core:** **`TimeSeries.fromColumns` / `ValueSeries.fromColumns` now accept
105
+ `string` value columns** (previously numeric-only), packed to dict-encoded
106
+ `StringColumn`s (`null`/`undefined` → missing) — the shared columnar-ingress
107
+ engine now dispatches on the schema kind. Other value kinds (`boolean`,
108
+ arrays) still throw.
109
+ - **charts:** **`<ScatterChart decimate>` — dense scatter plots now decimate**
110
+ (PND-MARKDEC scatter half — the last un-decimated mark type). **Default
111
+ `true`.** When the marks are **uniform** (fixed size + colour, no data-driven
112
+ `radius`/`color`), **opaque**, and denser than the pixel grid, overlapping
113
+ marks collapse to one representative per **mark-radius cell** via a 2D
114
+ pixel-**occupancy** sweep. Scatter has no fill, so a line/bar's per-column
115
+ `[min, max]` envelope would erase interior points — the occupancy grid keeps
116
+ one mark per occupied cell instead, which is **visually lossless** for uniform
117
+ opaque marks at that density (same-cell marks overlap). Interaction (hover /
118
+ click / tracker) still reads **every source point**; the per-point selection
119
+ ring + labels are suppressed only on the decimated (dense) path. A
120
+ **translucent** fill (density-encoded — overlap _should_ build up) or a
121
+ data-driven size/colour keeps the full draw. `decimate={false}` draws every
122
+ mark; `{ threshold }` tunes the trigger. The occupancy sweep uses the affine
123
+ fast path for the per-point pixel mapping. Measured (SciChart-suite
124
+ point-update, real browser): **100k 18 → 73 fps (4×)**, and the ladder now
125
+ runs to **10M** points (previously dead by 1M). `drawScatter` now returns
126
+ `LayerDrawStats` (visible via `onDrawStats`).
127
+
54
128
  ## [0.51.0] — 2026-07-22
55
129
 
56
130
  ### Changed
@@ -1,6 +1,7 @@
1
1
  import { ValueSeries } from 'pond-ts';
2
2
  import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
3
3
  import { type ColorEncoding, type RadiusEncoding } from './encoding.js';
4
+ import type { DecimateOption } from './decimate.js';
4
5
  export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
5
6
  /**
6
7
  * The source series. A `TimeSeries` scatters against the time axis; a
@@ -85,6 +86,16 @@ export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS ext
85
86
  * together, so a nudged point still selects.
86
87
  */
87
88
  offset?: number;
89
+ /**
90
+ * Collapse dense, **uniform** marks to one representative per pixel cell —
91
+ * lossless at that density, so a scatter of 100k+ points stays interactive.
92
+ * **Default `true`.** It engages only when the marks are a fixed size + colour
93
+ * (no data-driven `radius`/`color`), the fill is opaque, and the visible points
94
+ * are denser than the pixel grid; otherwise every point draws. Interaction
95
+ * (hover / click / tracker) always reads the source points. `decimate={false}`
96
+ * draws every mark; `{ threshold }` tunes the samples-per-pixel trigger.
97
+ */
98
+ decimate?: DecimateOption;
88
99
  /**
89
100
  * This layer's `<Legend>` row: `false` ⇒ no row (opt out), a string ⇒ the
90
101
  * row's display name. **Omitted ⇒ a row named by the layer's readout
@@ -126,5 +137,5 @@ export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS ext
126
137
  * </Layers>
127
138
  * ```
128
139
  */
129
- export declare function ScatterChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, offset, legend, index, }: ScatterChartProps<S, VS>): null;
140
+ export declare function ScatterChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, offset, decimate, legend, index, }: ScatterChartProps<S, VS>): null;
130
141
  //# sourceMappingURL=ScatterChart.d.ts.map
@@ -34,7 +34,7 @@ import { useSlotKey } from './use-slot-key.js';
34
34
  * </Layers>
35
35
  * ```
36
36
  */
37
- export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, offset = 0, legend, index = 0, }) {
37
+ export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, offset = 0, decimate = true, legend, index = 0, }) {
38
38
  const container = useContext(ContainerContext);
39
39
  if (container === null) {
40
40
  throw new Error('<ScatterChart> must be rendered inside a <ChartContainer>');
@@ -131,7 +131,7 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
131
131
  : {
132
132
  hitTest: (px, py, xScale, yScale) => hitTestScatter(cs, px, py, xScale, yScale, encoding, keyAt, id, seriesLabel, offset),
133
133
  }),
134
- draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id, offset),
134
+ draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id, offset, decimate),
135
135
  },
136
136
  axisId: axis,
137
137
  index,
@@ -148,6 +148,7 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
148
148
  font,
149
149
  container.selected,
150
150
  offset,
151
+ decimate,
151
152
  axis,
152
153
  index,
153
154
  ]);
@@ -305,4 +305,43 @@ export interface BarColumnEnvelope {
305
305
  * `resolveBarBaseline`), so the union is honest about the zero line.
306
306
  */
307
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 &lt; 1
341
+ * (`rgba(…, a<1)`, `#rrggbbaa` / `#rgba` with alpha &lt; 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;
308
347
  //# sourceMappingURL=decimate.d.ts.map
package/dist/decimate.js CHANGED
@@ -46,6 +46,7 @@
46
46
  * shrinks the point count, not a second renderer.
47
47
  */
48
48
  import { Float64Column } from 'pond-ts';
49
+ import { affineOf } from './affine.js';
49
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
@@ -606,4 +607,112 @@ export function decimateBars(cs, xScale, ctx, baseline, k = 2, visibleCount = cs
606
607
  }
607
608
  return { begin, end, lo, hi, length: W };
608
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 &lt; 1
681
+ * (`rgba(…, a<1)`, `#rrggbbaa` / `#rgba` with alpha &lt; 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
+ }
609
718
  //# sourceMappingURL=decimate.js.map
@@ -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
- return { radiusAt, colorAt };
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/scatter.d.ts CHANGED
@@ -2,7 +2,8 @@ import type { ChartSeries } from './data.js';
2
2
  import type { Scale } from './line.js';
3
3
  import type { ScatterStyle } from './theme.js';
4
4
  import type { ResolvedEncoding } from './encoding.js';
5
- import type { SelectInfo } from './context.js';
5
+ import type { SelectInfo, LayerDrawStats } from './context.js';
6
+ import { type DecimateOption } from './decimate.js';
6
7
  /**
7
8
  * Index of the point in `cs` **nearest** `time` by `|x − time|`, restricted to
8
9
  * finite points, or `-1` if none. `cs.x` is the sorted time axis, so a binary
@@ -52,7 +53,7 @@ export declare function scatterExtent(cs: ChartSeries): [number, number] | null;
52
53
  export declare function drawScatter(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: ScatterStyle, encoding: ResolvedEncoding, keyAt: (i: number) => number, labelAt: ((i: number) => string | undefined) | undefined, font: {
53
54
  readonly family: string;
54
55
  readonly size: number;
55
- }, selected: SelectInfo | null, seriesId: string | undefined, offsetPx?: number): void;
56
+ }, selected: SelectInfo | null, seriesId: string | undefined, offsetPx?: number, decimate?: DecimateOption): LayerDrawStats;
56
57
  /**
57
58
  * Hit-test plot-pixel `(qx, qy)` against the scatter's points — the topmost
58
59
  * point whose circle contains the click, or `null`. "Topmost" = the
package/dist/scatter.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { visiblePointRange } from './culling.js';
2
+ import { shouldDecimateCount, decimateScatter, isOpaqueColor, } from './decimate.js';
2
3
  /**
3
4
  * Scatter geometry + the canvas draw — pure, like {@link drawLine} /
4
5
  * {@link drawBand}, so the recording-mock tests assert the op sequence and the
@@ -117,7 +118,7 @@ export function scatterExtent(cs) {
117
118
  * of the selection match. A point lights only when the selection's
118
119
  * `id` matches, keyed to the sample by its `key`.
119
120
  */
120
- export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesId, offsetPx = 0) {
121
+ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesId, offsetPx = 0, decimate = true) {
121
122
  ctx.save();
122
123
  // The selection only lights up a point of *this* series; resolve the key once.
123
124
  // A no-id (non-selectable) layer passes `undefined` and never matches.
@@ -158,9 +159,64 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
158
159
  const [vStart, vEnd] = pad > 0
159
160
  ? visiblePointRange(cs.x, cs.length, xScale, pad)
160
161
  : [w0Start, w0End];
162
+ // [PND-MARKDEC] scatter decimation. When the marks are **uniform** (fixed
163
+ // size + colour), **opaque**, and denser than the pixel grid, collapse each
164
+ // overlapping cluster to one representative per mark-radius cell (2D
165
+ // occupancy — {@link decimateScatter}). Visually lossless at that density, and
166
+ // O(visible). Interaction is unaffected ({@link hitTestScatter} still walks
167
+ // every source point); the selection ring and per-point labels are dropped on
168
+ // this path — both are illegible under a dense blob — matching the decimated
169
+ // bar path. Data-driven size/colour (`!encoding.uniform`) or a translucent
170
+ // fill (density-encoded, where overlap *should* build up) keep the full draw.
171
+ const visibleCount = vEnd - vStart;
172
+ const k = typeof decimate === 'object' && decimate.threshold !== undefined
173
+ ? decimate.threshold
174
+ : 2;
175
+ if (decimate !== false &&
176
+ encoding.uniform &&
177
+ shouldDecimateCount(visibleCount, ctx, k) &&
178
+ isOpaqueColor(encoding.colorAt(vStart))) {
179
+ const r = encoding.radiusAt(vStart);
180
+ const dec = decimateScatter(cs, xScale, yScale, Math.max(1, r), vStart, vEnd);
181
+ // Only take the decimated pass if it actually shrank the work (a sparse-in-y
182
+ // scatter over the threshold may not overlap — then the full draw is fine,
183
+ // and keeps its selection ring + labels). Compare against the **finite**
184
+ // visible count, not `vEnd - vStart`: `decimateScatter` skips gaps, so the
185
+ // raw span would falsely read as a reduction on any gappy series (and wrongly
186
+ // suppress the ring / labels with zero cell collisions).
187
+ let finite = 0;
188
+ for (let i = vStart; i < vEnd; i += 1)
189
+ if (isPoint(cs, i))
190
+ finite += 1;
191
+ if (dec.length < finite) {
192
+ ctx.fillStyle = encoding.colorAt(vStart);
193
+ const outlined = style.outlineWidth > 0;
194
+ if (outlined) {
195
+ ctx.lineWidth = style.outlineWidth;
196
+ ctx.strokeStyle = style.outline;
197
+ }
198
+ for (let j = 0; j < dec.length; j += 1) {
199
+ const px = xScale(dec.x[j]) + offsetPx;
200
+ const py = yScale(dec.y[j]);
201
+ ctx.beginPath();
202
+ ctx.arc(px, py, r, 0, Math.PI * 2);
203
+ ctx.fill();
204
+ if (outlined)
205
+ ctx.stroke();
206
+ }
207
+ ctx.restore();
208
+ return {
209
+ sourceCount: cs.length,
210
+ drawnCount: dec.length,
211
+ decimated: true,
212
+ };
213
+ }
214
+ }
215
+ let drawn = 0;
161
216
  for (let i = vStart; i < vEnd; i += 1) {
162
217
  if (!isPoint(cs, i))
163
218
  continue;
219
+ drawn += 1;
164
220
  // `offsetPx` nudges the whole scatter in pixel space (zoom-stable) — for
165
221
  // pairing same-key marks (call/put at one strike) beside each other.
166
222
  const px = xScale(cs.x[i]) + offsetPx;
@@ -212,6 +268,10 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
212
268
  }
213
269
  }
214
270
  ctx.restore();
271
+ // Full draw (no decimation): every finite mark in the visible window drew.
272
+ // `drawnCount < sourceCount` here reflects viewport **culling**, not
273
+ // decimation (`decimated: false`).
274
+ return { sourceCount: cs.length, drawnCount: drawn, decimated: false };
215
275
  }
216
276
  /** Gap (px) between a point's edge and its label text. */
217
277
  const LABEL_GAP = 4;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",
@@ -38,8 +38,8 @@
38
38
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
39
39
  },
40
40
  "peerDependencies": {
41
- "@pond-ts/react": "^0.51.0",
42
- "pond-ts": "^0.51.0",
41
+ "@pond-ts/react": "^0.52.0",
42
+ "pond-ts": "^0.52.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {