@pond-ts/charts 0.49.0 → 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 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.49.0...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.50.0...HEAD
12
+ [0.50.0]: https://github.com/pond-ts/pond/compare/v0.49.0...v0.50.0
12
13
  [0.49.0]: https://github.com/pond-ts/pond/compare/v0.48.1...v0.49.0
13
14
  [0.48.1]: https://github.com/pond-ts/pond/compare/v0.48.0...v0.48.1
14
15
  [0.48.0]: https://github.com/pond-ts/pond/compare/v0.47.0...v0.48.0
@@ -49,6 +50,30 @@ and type-level changes; patch bumps are strictly additive.
49
50
 
50
51
  ## [Unreleased]
51
52
 
53
+ ## [0.50.0] — 2026-07-21
54
+
55
+ ### Added
56
+
57
+ - **charts:** M4 decimation extended to **`<Candlestick>`** (same auto-on
58
+ `decimate` prop). Dense candles are drawn as per-pixel-column **aggregate
59
+ candles** — `open=first`, `high=max`, `low=min`, `close=last` over the column —
60
+ i.e. re-bucketed to the pixel-column timeframe, the way a trading chart shows
61
+ fewer, wider candles as you zoom out (decimator §2.4). It is a faithful OHLC of
62
+ each column's span, never a distortion; the hover readout still reads the
63
+ **source** candle at the cursor (§2.3). Decimation gates on the **visible**
64
+ candle count, so a deep zoom into a large series still draws full-width
65
+ candles. Pass `decimate={false}` (and pre-aggregate upstream) for
66
+ fixed-timeframe candles.
67
+ - **charts:** M4 decimation extended to **`<BoxPlot>`** (same auto-on `decimate`
68
+ prop) — the interval-mark sibling of the candle. Dense boxes are drawn as
69
+ per-pixel-column **aggregate boxes**: the whiskers widen to the column's reach
70
+ (`min(lower)`/`max(upper)`), the body to its IQR envelope
71
+ (`min(q1)`/`max(q3)`), the centre line to the first box's median. Gates on the
72
+ **visible** box count (a deep zoom still draws full-width boxes); the
73
+ `hasBox`/`hasMedian` flags carry through, so a range-only box stays range-only.
74
+ Interaction is unaffected — hit-testing reads the source boxes (§2.3). Pass
75
+ `decimate={false}` to draw every box at its own slot.
76
+
52
77
  ## [0.49.0] — 2026-07-21
53
78
 
54
79
  ### Added
package/dist/BoxPlot.d.ts 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 BoxShape } from './box.js';
4
+ import type { DecimateOption } from './decimate.js';
4
5
  export interface BoxPlotProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
5
6
  /**
6
7
  * The source series. A `TimeSeries` plots against the time axis; a `ValueSeries`
@@ -98,6 +99,16 @@ export interface BoxPlotProps<S extends SeriesSchema = SeriesSchema, VS extends
98
99
  * (its `begin`).
99
100
  */
100
101
  id?: string;
102
+ /**
103
+ * **M4 viewport decimation** (charts decimator wave). **Omitted ⇒ `true`**:
104
+ * once the visible boxes are denser than ~2 per device pixel, they are drawn as
105
+ * per-pixel-column **aggregate boxes** — whiskers widen to the column's reach
106
+ * (`min(lower)`/`max(upper)`), the body to its IQR envelope
107
+ * (`min(q1)`/`max(q3)`), the centre line to the first box's median. Pass `false`
108
+ * to draw every box at its own slot. Interaction is unaffected (hit-testing
109
+ * reads the source boxes). Shares {@link LineChart}'s `DecimateOption`.
110
+ */
111
+ decimate?: DecimateOption;
101
112
  /**
102
113
  * This layer's `<Legend>` row: `false` ⇒ no row (opt out), a string ⇒ the
103
114
  * row's display name. **Omitted ⇒ a row named by the layer's readout
@@ -142,5 +153,5 @@ export interface BoxPlotProps<S extends SeriesSchema = SeriesSchema, VS extends
142
153
  * </Layers>
143
154
  * ```
144
155
  */
145
- export declare function BoxPlot<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, lower, q1, median, q3, upper, as: semantic, axis, gap, shape, showMedian, offset, capWidth, id, legend, index, }: BoxPlotProps<S, VS>): null;
156
+ export declare function BoxPlot<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, lower, q1, median, q3, upper, as: semantic, axis, gap, shape, showMedian, offset, capWidth, id, decimate, legend, index, }: BoxPlotProps<S, VS>): null;
146
157
  //# sourceMappingURL=BoxPlot.d.ts.map
package/dist/BoxPlot.js CHANGED
@@ -38,7 +38,7 @@ const MIN_BOX_WIDTH_PX = 1;
38
38
  * </Layers>
39
39
  * ```
40
40
  */
41
- export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, axis, gap = 0, shape = 'whisker', showMedian = true, offset = 0, capWidth, id, legend, index = 0, }) {
41
+ export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, axis, gap = 0, shape = 'whisker', showMedian = true, offset = 0, capWidth, id, decimate = true, legend, index = 0, }) {
42
42
  const container = useContext(ContainerContext);
43
43
  if (container === null) {
44
44
  throw new Error('<BoxPlot> must be rendered inside a <ChartContainer>');
@@ -142,7 +142,7 @@ export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, ax
142
142
  return { id, key: begin, value, color: style.whisker, label };
143
143
  },
144
144
  }),
145
- draw: (ctx, xScale, yScale) => drawBox(ctx, bx, xScale, yScale, style, gap, MIN_BOX_WIDTH_PX, shape, showMedian, offset, capWidth, selectedKey, hoveredKey),
145
+ draw: (ctx, xScale, yScale) => drawBox(ctx, bx, xScale, yScale, style, gap, MIN_BOX_WIDTH_PX, shape, showMedian, offset, capWidth, selectedKey, hoveredKey, decimate),
146
146
  },
147
147
  axisId: axis,
148
148
  index,
@@ -166,6 +166,7 @@ export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, ax
166
166
  label,
167
167
  selectedKey,
168
168
  hoveredKey,
169
+ decimate,
169
170
  axis,
170
171
  index,
171
172
  ]);
@@ -1,4 +1,5 @@
1
1
  import type { SeriesSchema, TimeSeries } from 'pond-ts';
2
+ import type { DecimateOption } from './decimate.js';
2
3
  import { type CandleVariant, type ColorBy } from './ohlc.js';
3
4
  export interface CandlestickProps<S extends SeriesSchema> {
4
5
  /**
@@ -56,6 +57,18 @@ export interface CandlestickProps<S extends SeriesSchema> {
56
57
  * for a compact legend; the full quote is opt-in for a dense hover readout.
57
58
  */
58
59
  showOHLC?: boolean;
60
+ /**
61
+ * **M4 viewport decimation** (charts decimator wave). **Omitted ⇒ `true`**:
62
+ * once the visible candles are denser than ~2 per device pixel, they are drawn
63
+ * as per-pixel-column **aggregate candles** (`open=first`, `high=max`,
64
+ * `low=min`, `close=last`) — i.e. re-bucketed to the pixel-column timeframe, the
65
+ * way a trading chart shows fewer, wider candles as you zoom out. It is a
66
+ * faithful OHLC of each column's span (never a distortion), just at a coarser
67
+ * timeframe. Pass `false` to draw every candle at its own slot (and
68
+ * pre-aggregate upstream if you need a fixed timeframe). Shares
69
+ * {@link LineChart}'s `DecimateOption`.
70
+ */
71
+ decimate?: DecimateOption;
59
72
  /**
60
73
  * This layer's `<Legend>` row: `false` ⇒ no row (opt out), a string ⇒ the
61
74
  * row's display name. **Omitted ⇒ a row named by the layer's readout
@@ -96,5 +109,5 @@ export interface CandlestickProps<S extends SeriesSchema> {
96
109
  * </Layers>
97
110
  * ```
98
111
  */
99
- export declare function Candlestick<S extends SeriesSchema>({ series, open, high, low, close, as: semantic, axis, variant, colorBy, gap, showOHLC, legend, index, }: CandlestickProps<S>): null;
112
+ export declare function Candlestick<S extends SeriesSchema>({ series, open, high, low, close, as: semantic, axis, variant, colorBy, gap, showOHLC, decimate, legend, index, }: CandlestickProps<S>): null;
100
113
  //# sourceMappingURL=Candlestick.d.ts.map
@@ -31,7 +31,7 @@ import { useSlotKey } from './use-slot-key.js';
31
31
  * </Layers>
32
32
  * ```
33
33
  */
34
- export function Candlestick({ series, open = 'open', high = 'high', low = 'low', close = 'close', as: semantic, axis, variant = 'candle', colorBy = 'direction', gap = 0, showOHLC = false, legend, index = 0, }) {
34
+ export function Candlestick({ series, open = 'open', high = 'high', low = 'low', close = 'close', as: semantic, axis, variant = 'candle', colorBy = 'direction', gap = 0, showOHLC = false, decimate = true, legend, index = 0, }) {
35
35
  const container = useContext(ContainerContext);
36
36
  if (container === null) {
37
37
  throw new Error('<Candlestick> must be rendered inside a <ChartContainer>');
@@ -83,7 +83,7 @@ export function Candlestick({ series, open = 'open', high = 'high', low = 'low',
83
83
  ];
84
84
  return samples;
85
85
  },
86
- draw: (ctx, xScale, yScale) => drawCandles(ctx, ohlc, xScale, yScale, style, variant, colorBy, gap),
86
+ draw: (ctx, xScale, yScale) => drawCandles(ctx, ohlc, xScale, yScale, style, variant, colorBy, gap, undefined, decimate),
87
87
  },
88
88
  axisId: axis,
89
89
  index,
@@ -96,6 +96,7 @@ export function Candlestick({ series, open = 'open', high = 'high', low = 'low',
96
96
  colorBy,
97
97
  gap,
98
98
  showOHLC,
99
+ decimate,
99
100
  axis,
100
101
  index,
101
102
  ]);
package/dist/box.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { BoxSeries } from './data.js';
2
2
  import type { Scale } from './line.js';
3
3
  import type { BoxStyle } from './theme.js';
4
+ import { type DecimateOption } from './decimate.js';
4
5
  /**
5
6
  * The `[min, max]` vertical extent of the **drawn** boxes — the lowest `lower`
6
7
  * whisker and highest `upper` whisker over the keys {@link isFiniteBox} draws
@@ -77,7 +78,7 @@ export type BoxShape = 'whisker' | 'solid' | 'none';
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 drawBox(ctx: CanvasRenderingContext2D, box: BoxSeries, xScale: Scale, yScale: Scale, style: BoxStyle, gapPx?: number, minWidthPx?: number, shape?: BoxShape, showMedian?: boolean, offsetPx?: number, capWidthPx?: number, selectedKey?: number | null, hoveredKey?: number | null): void;
81
+ export declare function drawBox(ctx: CanvasRenderingContext2D, box: BoxSeries, xScale: Scale, yScale: Scale, style: BoxStyle, gapPx?: number, minWidthPx?: number, shape?: BoxShape, showMedian?: boolean, offsetPx?: number, capWidthPx?: number, selectedKey?: number | null, hoveredKey?: number | null, decimate?: DecimateOption): void;
81
82
  /**
82
83
  * This key is drawable — the quantiles it actually carries are all finite at `i`.
83
84
  * `lower`/`upper` (the whisker reach) are always required; `q1`/`q3` only when the
package/dist/box.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { barSpanPx } from './range.js';
2
2
  import { visibleSpanRange } from './culling.js';
3
+ import { decimateBox } from './decimate.js';
3
4
  /** Fraction of the box width the whisker end-caps span (centred on the stem). */
4
5
  const WHISKER_CAP_FRACTION = 0.5;
5
6
  /**
@@ -112,16 +113,30 @@ export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx =
112
113
  // the container selection's `key` by the caller). `null` ⇒ none. A selected
113
114
  // box gets a full-strength bounding outline; a hovered one a fainter one —
114
115
  // the box analog of the bar highlight, drawn without a new theme token.
115
- selectedKey = null, hoveredKey = null) {
116
+ selectedKey = null, hoveredKey = null, decimate = true) {
117
+ // Viewport cull first (Phase 2): the [vStart, vEnd) boxes whose span overlaps
118
+ // the window (+1 each side). Full range when `xScale` has no domain (a stub);
119
+ // `offsetPx` is a small pixel nudge the ±1 margin absorbs.
120
+ let [vStart, vEnd] = visibleSpanRange(box.x, box.xEnd, box.length, xScale);
121
+ // M4 box decimation (Phase 5): once the *visible* boxes are denser than ~2 per
122
+ // device pixel, replace them with per-column **aggregate boxes** ({@link
123
+ // decimateBox}). Gate on the visible count, NOT `box.length`: a box's width is
124
+ // its slot, so decimating when only a handful are on screen (deep zoom) would
125
+ // re-slot each to a 1px sliver. `decimateBox` no-ops (returns the same object)
126
+ // below the visible-density threshold or on a domainless scale, leaving the
127
+ // loop-bound cull above. A selection/hover highlight keyed by the source box's
128
+ // `x` won't match an aggregate column edge — but per-box highlight is
129
+ // meaningless at decimation density, and hit-testing still reads the source.
130
+ const decimated = decimate !== false ? decimateBox(box, xScale, ctx, 2, vEnd - vStart) : box;
131
+ if (decimated !== box) {
132
+ box = decimated; // aggregate boxes are already the visible set
133
+ vStart = 0;
134
+ vEnd = box.length;
135
+ }
116
136
  // A range-only box (bid→ask segment) has no body / median; the whisker (or the
117
137
  // solid bar) runs the full lower→upper. Flags default true (a full box).
118
138
  const hasBox = box.hasBox !== false;
119
139
  const drawMedian = showMedian && box.hasMedian !== false;
120
- // Viewport culling (Phase 2): draw only the boxes whose span overlaps the
121
- // visible x-window (+1 each side); the loop keeps the original index `i`. Full
122
- // range when `xScale` has no domain (a test stub). `offsetPx` is a small pixel
123
- // nudge the ±1 margin absorbs.
124
- const [vStart, vEnd] = visibleSpanRange(box.x, box.xEnd, box.length, xScale);
125
140
  for (let i = vStart; i < vEnd; i += 1) {
126
141
  if (!isFiniteBox(box, i))
127
142
  continue;
@@ -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 } from './data.js';
48
+ import type { ChartSeries, BandSeries, OhlcSeries, BoxSeries } 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
@@ -190,4 +190,42 @@ export declare function m4Polyline(edges: Float64Array, mn: Float64Array, mx: Fl
190
190
  * band segment that no single sample carried.
191
191
  */
192
192
  export declare function decimateBand(band: BandSeries, xScale: Scale, ctx: CanvasRenderingContext2D, k?: number): BandSeries;
193
+ /**
194
+ * Decimate an {@link OhlcSeries} to one **aggregate candle per device-pixel
195
+ * column** — `open = first`, `high = max`, `low = min`, `close = last` over the
196
+ * candles that fall in the column. This is exactly a candle re-bucketed to a
197
+ * **coarser timeframe** (the pixel-column's time range): it is never *wrong* —
198
+ * it is the true OHLC of that span — so a dense chart that zooms out reads as
199
+ * fewer, wider aggregate candles, the trading-UI convention (decimator
200
+ * assessment §2.4). Auto-on with an opt-out; a consumer wanting fixed-timeframe
201
+ * candles pre-aggregates upstream and passes `decimate={false}`.
202
+ *
203
+ * Returns the **same object** when decimation doesn't apply (sparse series,
204
+ * domainless / non-invertible scale, no canvas width). The slot of each
205
+ * aggregate candle is its pixel column `[edges[b], edges[b+1]]`; an empty column
206
+ * (no candles) reduces to `NaN` on all channels — `drawCandles` skips it. No
207
+ * session-break union is needed: candles are independent marks (they never
208
+ * connect), and a trading-axis closed period is simply an empty column.
209
+ */
210
+ export declare function decimateOhlc(ohlc: OhlcSeries, xScale: Scale, ctx: CanvasRenderingContext2D, k?: number, visibleCount?: number): OhlcSeries;
211
+ /**
212
+ * Decimate a {@link BoxSeries} to one **aggregate box per device-pixel column** —
213
+ * the interval-mark sibling of {@link decimateOhlc}. Each channel is binned over
214
+ * the column: the whiskers widen to the column's full reach (`lower = min(lower)`,
215
+ * `upper = max(upper)`, exactly {@link decimateBand}'s envelope), the body to the
216
+ * column's **IQR envelope** (`q1 = min(q1)`, `q3 = max(q3)`), and the centre line
217
+ * to the **first** box's `median` in the column (a real median value, not an
218
+ * average — `binBy` carries no mean; it stays within the aggregate body since the
219
+ * first box's `[q1, q3]` ⊆ the envelope). So a dense per-x distribution chart
220
+ * that zooms out reads as fewer, wider boxes summarising each column's spread.
221
+ *
222
+ * Gates on the **visible** box count (a box's width is its slot — decimating a
223
+ * handful of deep-zoomed boxes would render 1px slivers, the same trap the candle
224
+ * path has). Returns the **same object** when decimation doesn't apply (below the
225
+ * visible-density threshold, domainless / non-invertible scale, no canvas width).
226
+ * The `hasBox` / `hasMedian` flags carry through, so a **range-only** box (all-NaN
227
+ * `q1`/`q3`) stays range-only (its binned body is NaN throughout). An empty column
228
+ * reduces to `NaN` on every channel — `drawBox` skips it via `isFiniteBox`.
229
+ */
230
+ export declare function decimateBox(box: BoxSeries, xScale: Scale, ctx: CanvasRenderingContext2D, k?: number, visibleCount?: number): BoxSeries;
193
231
  //# sourceMappingURL=decimate.d.ts.map
package/dist/decimate.js CHANGED
@@ -356,4 +356,123 @@ export function decimateBand(band, xScale, ctx, k = 2) {
356
356
  }
357
357
  return { x, lower, upper, length: W };
358
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
+ }
359
478
  //# sourceMappingURL=decimate.js.map
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
package/dist/ohlc.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { barSpanPx } from './range.js';
2
2
  import { visibleSpanRange } from './culling.js';
3
+ import { decimateOhlc } from './decimate.js';
3
4
  /** Default body width as a fraction of the candle slot when the style omits one. */
4
5
  const DEFAULT_BODY_WIDTH = 0.8;
5
6
  /** Minimum body height in px so a doji (open === close) still shows a mark. */
@@ -89,12 +90,27 @@ export function resolveCandleStyle(style, open, close, colorBy) {
89
90
  * O(N) over the keys, a fixed number of path ops each — no per-key allocation
90
91
  * beyond the `barSpanPx` tuple.
91
92
  */
92
- export function drawCandles(ctx, ohlc, xScale, yScale, style, variant = 'candle', colorBy = 'direction', gapPx = 0, minWidthPx = 1) {
93
+ export function drawCandles(ctx, ohlc, xScale, yScale, style, variant = 'candle', colorBy = 'direction', gapPx = 0, minWidthPx = 1, decimate = true) {
93
94
  const bodyFraction = style.bodyWidth ?? DEFAULT_BODY_WIDTH;
94
- // Viewport culling (Phase 2): draw only the candles whose span overlaps the
95
- // visible x-window (+1 each side); the loop keeps the original index `i`. Full
96
- // range when `xScale` has no domain (a test stub).
97
- const [vStart, vEnd] = visibleSpanRange(ohlc.x, ohlc.xEnd, ohlc.length, xScale);
95
+ // Viewport cull first (Phase 2): the [vStart, vEnd) candles whose span overlaps
96
+ // the window (+1 each side). Full range when `xScale` has no domain (a stub).
97
+ let [vStart, vEnd] = visibleSpanRange(ohlc.x, ohlc.xEnd, ohlc.length, xScale);
98
+ // M4 candle decimation (Phase 5): once the *visible* candles are denser than ~2
99
+ // per device pixel, replace them with per-column **aggregate candles**
100
+ // (open=first, high=max, low=min, close=last — a coarser-timeframe candle;
101
+ // {@link decimateOhlc}). Gate on the visible count, NOT `ohlc.length`: a candle's
102
+ // width is its slot, so decimating when only a handful are on screen (deep zoom
103
+ // into a large series) would re-slot each to a 1px sliver. `decimateOhlc` no-ops
104
+ // (returns the same object) below the visible-density threshold or on a
105
+ // domainless scale, leaving the loop-bound cull above.
106
+ const decimated = decimate !== false
107
+ ? decimateOhlc(ohlc, xScale, ctx, 2, vEnd - vStart)
108
+ : ohlc;
109
+ if (decimated !== ohlc) {
110
+ ohlc = decimated; // aggregate candles are already the visible set
111
+ vStart = 0;
112
+ vEnd = ohlc.length;
113
+ }
98
114
  for (let i = vStart; i < vEnd; i += 1) {
99
115
  if (!isFiniteOhlc(ohlc, i))
100
116
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.49.0",
3
+ "version": "0.50.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.49.0",
42
- "pond-ts": "^0.49.0",
41
+ "@pond-ts/react": "^0.50.0",
42
+ "pond-ts": "^0.50.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {