@pond-ts/charts 0.43.0 → 0.44.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/dist/data.d.ts CHANGED
@@ -30,19 +30,30 @@ export interface BandSeries {
30
30
  readonly length: number;
31
31
  }
32
32
  /**
33
- * A chart-ready view of a box-and-whisker series ({@link BoxPlot}): the
34
- * interval-keyed time axis (`x` = key `begin`, `xEnd` = key `end`, the box's
35
- * horizontal span) plus the five quantile edges per key —
36
- * `lower`/`q1`/`median`/`q3`/`upper`. The quantiles are pre-computed columns
37
- * (a `rolling`/`aggregate` percentile pass upstream); the chart only reads them.
38
- *
39
- * A key is drawn only where **all five** quantiles are finite; any one `NaN` is
40
- * a gap (the box draws nothing same gap contract as {@link BandSeries}).
41
- *
42
- * `x` and `xEnd` are zero-copy views of the key column's `begin`/`end` buffers
43
- * (immutable by contract do not mutate). For a point-in-time key the column's
44
- * `end` coincides with `begin`, so `xEnd === x` and the box collapses to a
45
- * minimum-width mark via `barSpanPx`; an interval key gives the box real width.
33
+ * A chart-ready view of a box-and-whisker series ({@link BoxPlot}): a horizontal
34
+ * span (`x`/`xEnd`) per key plus its quantile edges the required
35
+ * `lower`/`upper` (whisker reach) and the optional `q1`/`median`/`q3` (box body +
36
+ * centre line). The quantiles are pre-computed columns (a `rolling`/`aggregate`
37
+ * percentile pass upstream); the chart only reads them.
38
+ *
39
+ * A key is drawn only where the quantiles it **carries** are all finite (a full
40
+ * box needs all five; a range-only box just `lower`/`upper`); any present one
41
+ * `NaN` is a gap (the box draws nothing — same gap contract as {@link BandSeries}).
42
+ *
43
+ * `x` and `xEnd` are the box's horizontal span. An **interval**-keyed
44
+ * `TimeSeries` uses the key's own `[begin, end)`; a **point**-keyed `TimeSeries`
45
+ * (or a `ValueSeries`, always point-keyed on its value axis) synthesizes the span
46
+ * from **neighbour spacing** (each box centred on its key, reaching halfway to
47
+ * each neighbour — the same rule as bars / candles), so a point series still gets
48
+ * real box width instead of collapsing to the 1px floor.
49
+ *
50
+ * **Range-only boxes.** `q1`/`median`/`q3` are optional at the source (a bid→ask
51
+ * IV segment is a degenerate box — whiskers only, no body). `hasBox` is `false`
52
+ * when `q1`/`q3` were omitted (no box body; the whisker runs the full
53
+ * `lower→upper`), and `hasMedian` is `false` when `median` was omitted (no centre
54
+ * line). Absent quantile buffers are all-`NaN`; the flags — not the buffers —
55
+ * decide what draws, so an absent quantile isn't confused with a per-row gap.
56
+ * Both default to `true` (a full five-number box) when unset.
46
57
  */
47
58
  export interface BoxSeries {
48
59
  readonly x: Float64Array;
@@ -53,6 +64,10 @@ export interface BoxSeries {
53
64
  readonly q3: Float64Array;
54
65
  readonly upper: Float64Array;
55
66
  readonly length: number;
67
+ /** `false` ⇒ `q1`/`q3` absent (range-only box, no body). Default `true`. */
68
+ readonly hasBox?: boolean;
69
+ /** `false` ⇒ `median` absent (no centre line). Default `true`. */
70
+ readonly hasMedian?: boolean;
56
71
  }
57
72
  /**
58
73
  * A chart-ready view of an OHLC series ({@link Candlestick}): the candle's
@@ -131,17 +146,23 @@ export interface StackedBarSeries {
131
146
  */
132
147
  readonly marks?: readonly string[];
133
148
  }
134
- /** The five quantile column names a {@link boxFromTimeSeries} reads, in order. */
149
+ /**
150
+ * The quantile column names a {@link boxFromTimeSeries} / {@link boxFromValueSeries}
151
+ * reads. `lower`/`upper` (the whisker reach) are required; `q1`/`q3` (the box
152
+ * body) and `median` (the centre line) are **optional** — omit them for a
153
+ * range-only box (a bid→ask IV segment: whiskers only, no body). Omitting exactly
154
+ * one of `q1`/`q3` is a data error (a box needs both edges or neither).
155
+ */
135
156
  export interface BoxColumns {
136
- /** Lower whisker end (e.g. `p5` / `min`). */
157
+ /** Lower whisker end (e.g. `p5` / `min`). Required. */
137
158
  readonly lower: string;
138
- /** Box bottom — first quartile (e.g. `p25`). */
139
- readonly q1: string;
140
- /** Median line inside the box (e.g. `p50`). */
141
- readonly median: string;
142
- /** Box top — third quartile (e.g. `p75`). */
143
- readonly q3: string;
144
- /** Upper whisker end (e.g. `p95` / `max`). */
159
+ /** Box bottom — first quartile (e.g. `p25`). Omit with `q3` for a range-only box. */
160
+ readonly q1?: string | undefined;
161
+ /** Median line inside the box (e.g. `p50`). Omit for no centre line. */
162
+ readonly median?: string | undefined;
163
+ /** Box top — third quartile (e.g. `p75`). Omit with `q1` for a range-only box. */
164
+ readonly q3?: string | undefined;
165
+ /** Upper whisker end (e.g. `p95` / `max`). Required. */
145
166
  readonly upper: string;
146
167
  }
147
168
  /** The four OHLC column names {@link ohlcFromTimeSeries} reads. */
@@ -205,16 +226,38 @@ export declare function bandFromTimeSeries<S extends SeriesSchema>(series: TimeS
205
226
  */
206
227
  export declare function bandFromValueSeries<VS extends ValueSeriesSchema>(series: ValueSeries<VS>, lower: string, upper: string): BandSeries;
207
228
  /**
208
- * Build a {@link BoxSeries} from a pond `TimeSeries` five numeric quantile
209
- * columns (`lower`/`q1`/`median`/`q3`/`upper`) sharing the series' interval time
210
- * axis (`begin`/`end`, the box's horizontal span). The quantile columns are
211
- * typically `rolling`/`aggregate` percentiles (e.g. p5/p25/p50/p75/p95); a key
212
- * with any quantile missing reads as a gap (the box draws nothing).
213
- *
214
- * @throws RangeError if any quantile column does not exist.
215
- * @throws TypeError if any quantile column is not a numeric column.
229
+ * Build a {@link BoxSeries} from a pond `TimeSeries`. `lower`/`upper` (the whisker
230
+ * reach) are required; `q1`/`q3` (the box body) and `median` (the centre line)
231
+ * are optional omit them for a **range-only** box (a bid→ask segment). The
232
+ * quantile columns are typically `rolling`/`aggregate` percentiles; a key with
233
+ * any **present** quantile missing reads as a gap (the box draws nothing).
234
+ *
235
+ * **Key-shape aware, like {@link ohlcFromTimeSeries}.** An **interval /
236
+ * timeRange**-keyed series uses the key's own `[begin, end)` as the box span; a
237
+ * **point**-keyed (`time`) series synthesizes the span from neighbour spacing
238
+ * (each box centred on its timestamp, halfway to each neighbour), so a raw
239
+ * percentile-per-timestamp feed renders as contiguous boxes instead of collapsing
240
+ * to the 1px floor.
241
+ *
242
+ * @throws RangeError if any named quantile column does not exist, or if exactly
243
+ * one of `q1`/`q3` is given.
244
+ * @throws TypeError if any named quantile column is not a numeric column.
216
245
  */
217
246
  export declare function boxFromTimeSeries<S extends SeriesSchema>(series: TimeSeries<S>, columns: BoxColumns): BoxSeries;
247
+ /**
248
+ * Build a {@link BoxSeries} from a pond `ValueSeries` — the value-axis sibling of
249
+ * {@link boxFromTimeSeries} (a volatility smile's per-strike bid/ask IV segments,
250
+ * a per-strike intraday IV distribution). A `ValueSeries` is **point-keyed** on
251
+ * its value axis, so the box span comes from **neighbour spacing** on
252
+ * `axisValues()` (each box centred on its axis value, halfway to each neighbour —
253
+ * the same rule as {@link barsFromValueSeries}), instead of collapsing to a point.
254
+ * Same optional-quantile / range-only contract as the time reader.
255
+ *
256
+ * @throws RangeError if any named quantile column does not exist, or if exactly
257
+ * one of `q1`/`q3` is given.
258
+ * @throws TypeError if any named quantile column is not a numeric column.
259
+ */
260
+ export declare function boxFromValueSeries<VS extends ValueSeriesSchema>(series: ValueSeries<VS>, columns: BoxColumns): BoxSeries;
218
261
  /**
219
262
  * Build an {@link OhlcSeries} from a pond `TimeSeries` — four numeric price
220
263
  * columns (`open`/`high`/`low`/`close`) plus the candle's horizontal slot.
package/dist/data.js CHANGED
@@ -65,14 +65,6 @@ function timeAxis(series) {
65
65
  // it lines up with the value arrays.
66
66
  return series.keyColumn().begin.subarray(0, series.length);
67
67
  }
68
- /**
69
- * The key column's `end` buffer aligned to the logical length (zero-copy). For a
70
- * point-in-time key the column sets `end === begin`, so this returns the same
71
- * timestamps as {@link timeAxis} — an interval key gives a distinct span.
72
- */
73
- function timeEndAxis(series) {
74
- return series.keyColumn().end.subarray(0, series.length);
75
- }
76
68
  /**
77
69
  * Build a {@link ChartSeries} from a pond `TimeSeries` by reading its columnar
78
70
  * buffers directly — no per-event materialization. `column` names a numeric
@@ -150,25 +142,91 @@ export function bandFromValueSeries(series, lower, upper) {
150
142
  };
151
143
  }
152
144
  /**
153
- * Build a {@link BoxSeries} from a pond `TimeSeries` five numeric quantile
154
- * columns (`lower`/`q1`/`median`/`q3`/`upper`) sharing the series' interval time
155
- * axis (`begin`/`end`, the box's horizontal span). The quantile columns are
156
- * typically `rolling`/`aggregate` percentiles (e.g. p5/p25/p50/p75/p95); a key
157
- * with any quantile missing reads as a gap (the box draws nothing).
145
+ * Reject a half-specified box body `q1`/`q3` are both-or-neither (a box needs
146
+ * two edges or none). Called by both box readers before they build.
147
+ */
148
+ function validateBoxColumns(columns) {
149
+ if ((columns.q1 === undefined) !== (columns.q3 === undefined)) {
150
+ throw new RangeError(`BoxPlot: 'q1' and 'q3' are both-or-neither — a box body needs both edges ` +
151
+ `(got q1=${columns.q1 ?? 'undefined'}, q3=${columns.q3 ?? 'undefined'}). ` +
152
+ `Omit both for a range-only box (whiskers lower→upper).`);
153
+ }
154
+ }
155
+ /** An all-`NaN` buffer of length `n` — the value channel of an absent quantile. */
156
+ function nanBuffer(n) {
157
+ return new Float64Array(n).fill(NaN);
158
+ }
159
+ /**
160
+ * Build a {@link BoxSeries} from a pond `TimeSeries`. `lower`/`upper` (the whisker
161
+ * reach) are required; `q1`/`q3` (the box body) and `median` (the centre line)
162
+ * are optional — omit them for a **range-only** box (a bid→ask segment). The
163
+ * quantile columns are typically `rolling`/`aggregate` percentiles; a key with
164
+ * any **present** quantile missing reads as a gap (the box draws nothing).
158
165
  *
159
- * @throws RangeError if any quantile column does not exist.
160
- * @throws TypeError if any quantile column is not a numeric column.
166
+ * **Key-shape aware, like {@link ohlcFromTimeSeries}.** An **interval /
167
+ * timeRange**-keyed series uses the key's own `[begin, end)` as the box span; a
168
+ * **point**-keyed (`time`) series synthesizes the span from neighbour spacing
169
+ * (each box centred on its timestamp, halfway to each neighbour), so a raw
170
+ * percentile-per-timestamp feed renders as contiguous boxes instead of collapsing
171
+ * to the 1px floor.
172
+ *
173
+ * @throws RangeError if any named quantile column does not exist, or if exactly
174
+ * one of `q1`/`q3` is given.
175
+ * @throws TypeError if any named quantile column is not a numeric column.
161
176
  */
162
177
  export function boxFromTimeSeries(series, columns) {
178
+ validateBoxColumns(columns);
179
+ const n = series.length;
180
+ const hasBox = columns.q1 !== undefined;
181
+ const hasMedian = columns.median !== undefined;
182
+ const { begin, end } = series.keyColumn().kind !== 'time'
183
+ ? keyBeginEnd(series) // interval / timeRange: the key's own span
184
+ : neighbourSpans(series.keyColumn().begin, n); // point: neighbour spacing
163
185
  return {
164
- x: timeAxis(series),
165
- xEnd: timeEndAxis(series),
186
+ x: begin,
187
+ xEnd: end,
166
188
  lower: readNumericColumn(series, columns.lower),
167
- q1: readNumericColumn(series, columns.q1),
168
- median: readNumericColumn(series, columns.median),
169
- q3: readNumericColumn(series, columns.q3),
189
+ q1: hasBox ? readNumericColumn(series, columns.q1) : nanBuffer(n),
190
+ median: hasMedian
191
+ ? readNumericColumn(series, columns.median)
192
+ : nanBuffer(n),
193
+ q3: hasBox ? readNumericColumn(series, columns.q3) : nanBuffer(n),
170
194
  upper: readNumericColumn(series, columns.upper),
171
- length: series.length,
195
+ length: n,
196
+ hasBox,
197
+ hasMedian,
198
+ };
199
+ }
200
+ /**
201
+ * Build a {@link BoxSeries} from a pond `ValueSeries` — the value-axis sibling of
202
+ * {@link boxFromTimeSeries} (a volatility smile's per-strike bid/ask IV segments,
203
+ * a per-strike intraday IV distribution). A `ValueSeries` is **point-keyed** on
204
+ * its value axis, so the box span comes from **neighbour spacing** on
205
+ * `axisValues()` (each box centred on its axis value, halfway to each neighbour —
206
+ * the same rule as {@link barsFromValueSeries}), instead of collapsing to a point.
207
+ * Same optional-quantile / range-only contract as the time reader.
208
+ *
209
+ * @throws RangeError if any named quantile column does not exist, or if exactly
210
+ * one of `q1`/`q3` is given.
211
+ * @throws TypeError if any named quantile column is not a numeric column.
212
+ */
213
+ export function boxFromValueSeries(series, columns) {
214
+ validateBoxColumns(columns);
215
+ const n = series.length;
216
+ const hasBox = columns.q1 !== undefined;
217
+ const hasMedian = columns.median !== undefined;
218
+ const { begin, end } = neighbourSpans(series.axisValues(), n);
219
+ return {
220
+ x: begin,
221
+ xEnd: end,
222
+ lower: readValueColumn(series, columns.lower),
223
+ q1: hasBox ? readValueColumn(series, columns.q1) : nanBuffer(n),
224
+ median: hasMedian ? readValueColumn(series, columns.median) : nanBuffer(n),
225
+ q3: hasBox ? readValueColumn(series, columns.q3) : nanBuffer(n),
226
+ upper: readValueColumn(series, columns.upper),
227
+ length: n,
228
+ hasBox,
229
+ hasMedian,
172
230
  };
173
231
  }
174
232
  /**
package/dist/scatter.d.ts CHANGED
@@ -52,7 +52,7 @@ export declare function scatterExtent(cs: ChartSeries): [number, number] | null;
52
52
  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
53
  readonly family: string;
54
54
  readonly size: number;
55
- }, selected: SelectInfo | null, seriesId: string | undefined): void;
55
+ }, selected: SelectInfo | null, seriesId: string | undefined, offsetPx?: number): void;
56
56
  /**
57
57
  * Hit-test plot-pixel `(qx, qy)` against the scatter's points — the topmost
58
58
  * point whose circle contains the click, or `null`. "Topmost" = the
@@ -68,5 +68,5 @@ export declare function drawScatter(ctx: CanvasRenderingContext2D, cs: ChartSeri
68
68
  * Pure: takes the same `xScale`/`yScale` the row hands to `draw`, so it
69
69
  * unit-tests without a DOM (mirrors the `sampleAt` / `resolveSelection` split).
70
70
  */
71
- export declare function hitTestScatter(cs: ChartSeries, qx: number, qy: number, xScale: Scale, yScale: Scale, encoding: ResolvedEncoding, keyAt: (i: number) => number, id: string, seriesLabel: string): SelectInfo | null;
71
+ export declare function hitTestScatter(cs: ChartSeries, qx: number, qy: number, xScale: Scale, yScale: Scale, encoding: ResolvedEncoding, keyAt: (i: number) => number, id: string, seriesLabel: string, offsetPx?: number): SelectInfo | null;
72
72
  //# sourceMappingURL=scatter.d.ts.map
package/dist/scatter.js CHANGED
@@ -114,7 +114,7 @@ export function scatterExtent(cs) {
114
114
  * of the selection match. A point lights only when the selection's
115
115
  * `id` matches, keyed to the sample by its `key`.
116
116
  */
117
- export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesId) {
117
+ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesId, offsetPx = 0) {
118
118
  ctx.save();
119
119
  // The selection only lights up a point of *this* series; resolve the key once.
120
120
  // A no-id (non-selectable) layer passes `undefined` and never matches.
@@ -126,7 +126,9 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
126
126
  for (let i = 0; i < cs.length; i += 1) {
127
127
  if (!isPoint(cs, i))
128
128
  continue;
129
- const px = xScale(cs.x[i]);
129
+ // `offsetPx` nudges the whole scatter in pixel space (zoom-stable) — for
130
+ // pairing same-key marks (call/put at one strike) beside each other.
131
+ const px = xScale(cs.x[i]) + offsetPx;
130
132
  const py = yScale(cs.y[i]);
131
133
  const r = encoding.radiusAt(i);
132
134
  ctx.beginPath();
@@ -166,7 +168,7 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
166
168
  const text = labelAt(i);
167
169
  if (text === undefined || text === '')
168
170
  continue;
169
- const px = xScale(cs.x[i]);
171
+ const px = xScale(cs.x[i]) + offsetPx;
170
172
  const py = yScale(cs.y[i]);
171
173
  const r = encoding.radiusAt(i);
172
174
  // Sit the label just right of the point (past its radius), vertically
@@ -193,11 +195,12 @@ const LABEL_GAP = 4;
193
195
  * Pure: takes the same `xScale`/`yScale` the row hands to `draw`, so it
194
196
  * unit-tests without a DOM (mirrors the `sampleAt` / `resolveSelection` split).
195
197
  */
196
- export function hitTestScatter(cs, qx, qy, xScale, yScale, encoding, keyAt, id, seriesLabel) {
198
+ export function hitTestScatter(cs, qx, qy, xScale, yScale, encoding, keyAt, id, seriesLabel, offsetPx = 0) {
197
199
  for (let i = cs.length - 1; i >= 0; i -= 1) {
198
200
  if (!isPoint(cs, i))
199
201
  continue;
200
- const px = xScale(cs.x[i]);
202
+ // Match the drawn position (offset in px space) so the click target aligns.
203
+ const px = xScale(cs.x[i]) + offsetPx;
201
204
  const py = yScale(cs.y[i]);
202
205
  const r = encoding.radiusAt(i);
203
206
  const dx = qx - px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.43.0",
3
+ "version": "0.44.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.43.0",
42
- "pond-ts": "^0.43.0",
41
+ "@pond-ts/react": "^0.44.0",
42
+ "pond-ts": "^0.44.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {