@pond-ts/charts 0.43.0 → 0.44.1

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/box.js CHANGED
@@ -3,9 +3,10 @@ import { barSpanPx } from './range.js';
3
3
  const WHISKER_CAP_FRACTION = 0.5;
4
4
  /**
5
5
  * The `[min, max]` vertical extent of the **drawn** boxes — the lowest `lower`
6
- * whisker and highest `upper` whisker over keys where **all five** quantiles are
7
- * finite or `null` if none are. Gap keys (any quantile `NaN`) are excluded,
8
- * matching what {@link drawBox} draws, so they don't drag the y-domain.
6
+ * whisker and highest `upper` whisker over the keys {@link isFiniteBox} draws
7
+ * (a full box needs all five quantiles; a range-only box just `lower`/`upper`)
8
+ * or `null` if none are. Gap keys are excluded, matching what {@link drawBox}
9
+ * draws, so they don't drag the y-domain.
9
10
  *
10
11
  * Only `lower`/`upper` bound the extent: they are the outermost reach of a key
11
12
  * (the whisker ends), so `q1`/`median`/`q3` lie within `[lower, upper]` for any
@@ -56,70 +57,101 @@ export function boxIndexAtTime(box, time) {
56
57
  * reads darker on a light ground, brighter on a dark one), no stems/outline.
57
58
  * - **`none`** — the `q1→q3` box fill + outline only, no spread marks.
58
59
  *
59
- * Then, if `showMedian`, the median line across the box on top. Fills are
60
- * bracketed by `save`/`restore` so their `globalAlpha` doesn't leak.
60
+ * **Range-only** (`box.hasBox === false` no `q1`/`q3`): there's no body, so
61
+ * `whisker` draws **one** full `lower→upper` stem with caps, `solid` draws just
62
+ * the outer bar, and `none` draws **nothing** (no body + no spread ⇒ empty — pick
63
+ * `whisker`/`solid` for a range-only box). `showMedian` is a no-op when the box
64
+ * carries no `median` (`hasMedian === false`).
61
65
  *
62
- * **Gap-aware**: a key with any quantile non-finite is skipped entirely (no
63
- * partial box) the same contract as a band gap.
66
+ * Then, if `showMedian` (and a median is present), the median line on top. Fills
67
+ * are bracketed by `save`/`restore` so their `globalAlpha` doesn't leak.
68
+ * `offsetPx` shifts every mark in pixel space (for pairing same-key marks);
69
+ * `capWidthPx` sets a fixed whisker-cap width (else half the box width — a small
70
+ * fixed cap keeps paired offset marks' T-bars from overlapping), clamped to the
71
+ * box width.
72
+ *
73
+ * **Gap-aware**: a key whose present quantiles aren't all finite is skipped
74
+ * entirely (no partial box) — the same contract as a band gap.
64
75
  *
65
76
  * O(N) over the keys, a fixed number of path ops each — no per-key allocation
66
77
  * beyond the `barSpanPx` tuple.
67
78
  */
68
- export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx = 1, shape = 'whisker', showMedian = true) {
79
+ export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx = 1, shape = 'whisker', showMedian = true, offsetPx = 0, capWidthPx) {
80
+ // A range-only box (bid→ask segment) has no body / median; the whisker (or the
81
+ // solid bar) runs the full lower→upper. Flags default true (a full box).
82
+ const hasBox = box.hasBox !== false;
83
+ const drawMedian = showMedian && box.hasMedian !== false;
69
84
  for (let i = 0; i < box.length; i += 1) {
70
85
  if (!isFiniteBox(box, i))
71
86
  continue;
72
- const [x0, x1] = barSpanPx(box.x[i], box.xEnd[i], xScale, gapPx, minWidthPx);
87
+ const [span0, span1] = barSpanPx(box.x[i], box.xEnd[i], xScale, gapPx, minWidthPx);
88
+ // `offsetPx` nudges the whole mark in pixel space (zoom-stable) — for pairing
89
+ // same-key marks (call/put at one strike) side by side without overlap.
90
+ const x0 = span0 + offsetPx;
91
+ const x1 = span1 + offsetPx;
73
92
  const mid = (x0 + x1) / 2;
74
93
  const yLower = yScale(box.lower[i]);
75
- const yQ1 = yScale(box.q1[i]);
76
- const yMedian = yScale(box.median[i]);
77
- const yQ3 = yScale(box.q3[i]);
78
94
  const yUpper = yScale(box.upper[i]);
95
+ // q1/q3 are NaN on a range-only box — read them only when there's a body.
96
+ const yQ1 = hasBox ? yScale(box.q1[i]) : 0;
97
+ const yQ3 = hasBox ? yScale(box.q3[i]) : 0;
79
98
  if (shape === 'solid') {
80
- // Candlestick: a light outer bar over the full lower→upper spread, then a
81
- // more-prominent inner q1→q3 box on top (same fill at rising opacity, so the
82
- // inner reads darker on a light ground / brighter on a dark one). No stems,
83
- // no outline.
99
+ // Candlestick: a light outer bar over the full lower→upper spread, then
100
+ // when there's a body — a more-prominent inner q1→q3 box on top (same fill
101
+ // at rising opacity). No stems, no outline.
84
102
  ctx.save();
85
103
  ctx.fillStyle = style.fill;
86
104
  ctx.globalAlpha = style.fillOpacity;
87
105
  ctx.fillRect(x0, yUpper, x1 - x0, yLower - yUpper);
88
- ctx.globalAlpha = Math.min(1, style.fillOpacity * 2);
89
- ctx.fillRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
106
+ if (hasBox) {
107
+ ctx.globalAlpha = Math.min(1, style.fillOpacity * 2);
108
+ ctx.fillRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
109
+ }
90
110
  ctx.restore();
91
111
  }
92
112
  else {
93
- // `whisker` / `none`: the graded q1→q3 box fill + outline.
94
- ctx.save();
95
- ctx.fillStyle = style.fill;
96
- ctx.globalAlpha = style.fillOpacity;
97
- ctx.fillRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
98
- ctx.restore();
99
- ctx.strokeStyle = style.stroke;
100
- ctx.lineWidth = style.strokeWidth;
101
- ctx.strokeRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
113
+ // `whisker` / `none`: the graded q1→q3 box fill + outline (body only).
114
+ if (hasBox) {
115
+ ctx.save();
116
+ ctx.fillStyle = style.fill;
117
+ ctx.globalAlpha = style.fillOpacity;
118
+ ctx.fillRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
119
+ ctx.restore();
120
+ ctx.strokeStyle = style.stroke;
121
+ ctx.lineWidth = style.strokeWidth;
122
+ ctx.strokeRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
123
+ }
102
124
  if (shape === 'whisker') {
103
- // Whiskers: a stem from each box edge to the whisker end, with a cap.
104
- const capHalf = ((x1 - x0) * WHISKER_CAP_FRACTION) / 2;
125
+ // Whiskers with end-caps. With a body: two stems (q3→upper, q1→lower).
126
+ // Range-only (no body): one stem spanning the full lower→upper.
127
+ // Cap half-width: an explicit `capWidthPx` (a fixed pixel cap — for
128
+ // pairing offset marks without their T-bars overlapping) else a fraction
129
+ // of the box width (responsive default). Never wider than the box.
130
+ const capHalf = capWidthPx !== undefined
131
+ ? Math.min(capWidthPx, x1 - x0) / 2
132
+ : ((x1 - x0) * WHISKER_CAP_FRACTION) / 2;
105
133
  ctx.strokeStyle = style.whisker;
106
134
  ctx.lineWidth = style.whiskerWidth;
107
135
  ctx.beginPath();
108
- // Upper: stem q3 upper, cap at upper.
109
- ctx.moveTo(mid, yQ3);
136
+ // Upper stem: from the box top (q3) or, range-only, from lower.
137
+ ctx.moveTo(mid, hasBox ? yQ3 : yLower);
110
138
  ctx.lineTo(mid, yUpper);
111
139
  ctx.moveTo(mid - capHalf, yUpper);
112
140
  ctx.lineTo(mid + capHalf, yUpper);
113
- // Lower: stem q1 lower, cap at lower.
114
- ctx.moveTo(mid, yQ1);
115
- ctx.lineTo(mid, yLower);
141
+ // Lower cap (and, with a body, the lower stem q1→lower).
142
+ if (hasBox) {
143
+ ctx.moveTo(mid, yQ1);
144
+ ctx.lineTo(mid, yLower);
145
+ }
116
146
  ctx.moveTo(mid - capHalf, yLower);
117
147
  ctx.lineTo(mid + capHalf, yLower);
118
148
  ctx.stroke();
119
149
  }
120
150
  }
121
- // The median line across the box, on top — always optional.
122
- if (showMedian) {
151
+ // The median line across the box, on top — drawn only when the box carries a
152
+ // median column and `showMedian` is on.
153
+ if (drawMedian) {
154
+ const yMedian = yScale(box.median[i]);
123
155
  ctx.strokeStyle = style.median;
124
156
  ctx.lineWidth = style.medianWidth;
125
157
  ctx.beginPath();
@@ -129,12 +161,24 @@ export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx =
129
161
  }
130
162
  }
131
163
  }
132
- /** All five quantiles finite at `i` — i.e. this key is drawn. */
164
+ /**
165
+ * This key is drawable — the quantiles it actually carries are all finite at `i`.
166
+ * `lower`/`upper` (the whisker reach) are always required; `q1`/`q3` only when the
167
+ * box has a body (`hasBox !== false`), `median` only when it has a centre line
168
+ * (`hasMedian !== false`). So a **range-only** box (bid→ask, no body/median) draws
169
+ * wherever `lower`/`upper` are finite, and a full box still needs all five.
170
+ */
133
171
  export function isFiniteBox(box, i) {
134
- return (Number.isFinite(box.lower[i]) &&
135
- Number.isFinite(box.q1[i]) &&
136
- Number.isFinite(box.median[i]) &&
137
- Number.isFinite(box.q3[i]) &&
138
- Number.isFinite(box.upper[i]));
172
+ if (!Number.isFinite(box.lower[i]) || !Number.isFinite(box.upper[i])) {
173
+ return false;
174
+ }
175
+ if (box.hasBox !== false &&
176
+ (!Number.isFinite(box.q1[i]) || !Number.isFinite(box.q3[i]))) {
177
+ return false;
178
+ }
179
+ if (box.hasMedian !== false && !Number.isFinite(box.median[i])) {
180
+ return false;
181
+ }
182
+ return true;
139
183
  }
140
184
  //# sourceMappingURL=box.js.map
package/dist/context.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ScaleLinear, ScaleTime } from 'd3-scale';
2
2
  import type { ChartTheme } from './theme.js';
3
3
  import type { AxisFormat } from './format.js';
4
- import type { Interval, TimeRange } from 'pond-ts';
4
+ import type { Interval } from 'pond-ts';
5
5
  import type { TradingTimeScale, DiscontinuityProvider } from './tradingTimeScale.js';
6
6
  import type { ScaleBand } from './bandScale.js';
7
7
  /**
@@ -75,22 +75,28 @@ export interface ContainerFrame {
75
75
  */
76
76
  readonly cursorBuckets: readonly Interval[] | undefined;
77
77
  /**
78
- * The `region`-cursor **drag anchor** (epoch ms), or `null` when not dragging.
79
- * A drag on a region cursor (only when {@link onRegionSelect} is set) records
80
- * the press time here; the band then spans from the anchor's bucket to the
81
- * pointer's bucket (extending bucket by bucket). Cleared on release.
78
+ * The `region`-cursor **drag anchor** in axis units (epoch ms on a time axis,
79
+ * the axis value on a value axis), or `null` when not dragging. A drag on a
80
+ * region cursor (only when {@link onRegionSelect} is set) records the press
81
+ * position here; the band then spans from the anchor's bucket to the pointer's
82
+ * bucket (extending bucket by bucket), or freeform when there are no buckets.
83
+ * Cleared on release.
82
84
  */
83
85
  readonly regionAnchor: number | null;
84
86
  /** Set / clear the region-drag anchor (see {@link regionAnchor}). */
85
- setRegionAnchor(time: number | null): void;
87
+ setRegionAnchor(value: number | null): void;
86
88
  /**
87
89
  * One-shot callback fired when a `region`-cursor **drag** is released, with the
88
- * selected `[start, end)` `TimeRange` (snapped to the `cursorSequence` buckets).
90
+ * selected `[lo, hi]` span in **axis units** — epoch ms on a time axis, the axis
91
+ * value on a value axis (snapped to the `cursorSequence` buckets when present,
92
+ * else the raw drag span). The neutral numeric pair mirrors the container's
93
+ * polymorphic `range` input (which never takes the axis *kind* from its value);
94
+ * a time-axis consumer who wants a `TimeRange` constructs one from the pair.
89
95
  * Providing it is what makes the region cursor **draggable**; the cursor does
90
96
  * not keep the range (it reverts to the single-bucket highlight). Typical use:
91
- * zoom the view to the returned range.
97
+ * zoom the view, or map the span onto a subscription's range params.
92
98
  */
93
- readonly onRegionSelect: ((range: TimeRange) => void) | undefined;
99
+ readonly onRegionSelect: ((range: readonly [number, number]) => void) | undefined;
94
100
  /**
95
101
  * Require a modifier key held to start a region-drag — set to `'shift'` to make
96
102
  * plain drag **pan** and **shift**-drag select, when `panZoom` is on. Only
@@ -145,6 +151,17 @@ export interface ContainerFrame {
145
151
  /** Format an epoch-ms instant the same way the time axis labels its ticks —
146
152
  * shared by `<TimeAxis>` and the cursor-time readout. */
147
153
  readonly formatTime: (epochMs: number) => string;
154
+ /**
155
+ * The shared **x-side tick count** — the `count` every x-side `ticks()` /
156
+ * `tickFormat()` call passes (`<XAxis>` labels, the canvas x gridlines and
157
+ * session dividers, {@link formatTime}), so labels, grid, and dividers all
158
+ * derive from the same instants. A fixed default on a continuous axis;
159
+ * **width-derived on a trading-time axis**, where the count caps how many
160
+ * calendar buckets `coarsenCalendar` may keep — a fixed small count would
161
+ * coarsen any long daily view to year grain (2 ticks) no matter how wide
162
+ * the plot is.
163
+ */
164
+ readonly xTickCount: number;
148
165
  /**
149
166
  * Register a draw layer as a tracker source so the container can fan in every
150
167
  * series' value at the cursor for `onTrackerChanged`. Keyed by the layer's
@@ -369,6 +386,17 @@ export interface RowLayer {
369
386
  * must agree on this list (a mix is an error), the same way {@link xKind} must.
370
387
  */
371
388
  xCategories?(): readonly string[] | null;
389
+ /**
390
+ * A bar/histogram layer's bar `[begin, end)` spans, as pond `Interval`s — the
391
+ * **region cursor's snap buckets**. When present (and no `cursorSequence` is
392
+ * set), a region drag snaps bar by bar and a hover highlights the bar under the
393
+ * pointer, so a histogram gets bin-aligned selection for free. Only a
394
+ * **vertical** bar layer on a **continuous** (time / value) x axis publishes
395
+ * them — a horizontal chart puts the value on x (snapping counts is meaningless)
396
+ * and a **category** (ordinal-slot) axis is excluded from the region cursor.
397
+ * `null` / absent otherwise.
398
+ */
399
+ binIntervals?(): readonly Interval[] | null;
372
400
  /**
373
401
  * The layer's value(s) at `time` — the nearest sample — for the scrub tracker:
374
402
  * one for a line, two (lower/upper) for a band, empty at a gap. Each carries
@@ -442,6 +470,8 @@ export interface TrackerSource {
442
470
  xExtent(): readonly [number, number] | null;
443
471
  /** A `'category'` source's ordered category names (see {@link RowLayer.xCategories}). */
444
472
  xCategories?(): readonly string[] | null;
473
+ /** A bar/histogram source's bar `[begin, end)` spans (see {@link RowLayer.binIntervals}). */
474
+ binIntervals?(): readonly Interval[] | null;
445
475
  }
446
476
  /**
447
477
  * One selection — what {@link RowLayer.hitTest} returns and `onSelect` reports.
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/format.d.ts CHANGED
@@ -42,8 +42,10 @@ interface TimeTickable {
42
42
  * - a **function** → used as-is (called with epoch ms);
43
43
  * - a **specifier string** → `scale.tickFormat(count, specifier)` (one format for
44
44
  * every value), wrapped to take epoch ms;
45
- * - **`undefined`** → `scale.tickFormat()` — d3's **multi-scale** time format (the
46
- * time axis's default; no `count` so it matches `<TimeAxis>` exactly).
45
+ * - **`undefined`** → `scale.tickFormat(count)` — the scale's default. On a d3
46
+ * `scaleTime` that is the **multi-scale** time format (which ignores `count`);
47
+ * a trading-time scale picks its **anchor grain** from `count`, so passing the
48
+ * axis's count here is what keeps the labels on the same grain as the ticks.
47
49
  *
48
50
  * The cursor time is epoch ms, so the resolved formatter wraps the d3 `Date`
49
51
  * formatter in `new Date(ms)`.
package/dist/format.js CHANGED
@@ -32,8 +32,10 @@ export function resolveAxisFormat(scale, count, format) {
32
32
  * - a **function** → used as-is (called with epoch ms);
33
33
  * - a **specifier string** → `scale.tickFormat(count, specifier)` (one format for
34
34
  * every value), wrapped to take epoch ms;
35
- * - **`undefined`** → `scale.tickFormat()` — d3's **multi-scale** time format (the
36
- * time axis's default; no `count` so it matches `<TimeAxis>` exactly).
35
+ * - **`undefined`** → `scale.tickFormat(count)` — the scale's default. On a d3
36
+ * `scaleTime` that is the **multi-scale** time format (which ignores `count`);
37
+ * a trading-time scale picks its **anchor grain** from `count`, so passing the
38
+ * axis's count here is what keeps the labels on the same grain as the ticks.
37
39
  *
38
40
  * The cursor time is epoch ms, so the resolved formatter wraps the d3 `Date`
39
41
  * formatter in `new Date(ms)`.
@@ -41,7 +43,9 @@ export function resolveAxisFormat(scale, count, format) {
41
43
  export function resolveTimeFormat(scale, count, format) {
42
44
  if (typeof format === 'function')
43
45
  return format;
44
- const tf = format !== undefined ? scale.tickFormat(count, format) : scale.tickFormat();
46
+ const tf = format !== undefined
47
+ ? scale.tickFormat(count, format)
48
+ : scale.tickFormat(count);
45
49
  return (ms) => tf(new Date(ms));
46
50
  }
47
51
  //# sourceMappingURL=format.js.map
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;