@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 +26 -1
- package/dist/BoxPlot.d.ts +12 -1
- package/dist/BoxPlot.js +3 -2
- package/dist/Candlestick.d.ts +14 -1
- package/dist/Candlestick.js +3 -2
- package/dist/box.d.ts +2 -1
- package/dist/box.js +21 -6
- package/dist/decimate.d.ts +39 -1
- package/dist/decimate.js +119 -0
- package/dist/ohlc.d.ts +2 -1
- package/dist/ohlc.js +21 -5
- package/package.json +3 -3
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.
|
|
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
|
]);
|
package/dist/Candlestick.d.ts
CHANGED
|
@@ -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
|
package/dist/Candlestick.js
CHANGED
|
@@ -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;
|
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 } 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
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
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.
|
|
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.
|
|
42
|
-
"pond-ts": "^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": {
|