@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/CHANGELOG.md CHANGED
@@ -8,7 +8,9 @@ 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/pjm17971/pond-ts/compare/v0.43.0...HEAD
11
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.44.1...HEAD
12
+ [0.44.1]: https://github.com/pjm17971/pond-ts/compare/v0.44.0...v0.44.1
13
+ [0.44.0]: https://github.com/pjm17971/pond-ts/compare/v0.43.0...v0.44.0
12
14
  [0.43.0]: https://github.com/pjm17971/pond-ts/compare/v0.42.0...v0.43.0
13
15
  [0.42.0]: https://github.com/pjm17971/pond-ts/compare/v0.41.0...v0.42.0
14
16
  [0.41.0]: https://github.com/pjm17971/pond-ts/compare/v0.40.0...v0.41.0
@@ -41,6 +43,108 @@ and type-level changes; patch bumps are strictly additive.
41
43
 
42
44
  ## [Unreleased]
43
45
 
46
+ ## [0.44.1] — 2026-07-13
47
+
48
+ A `@pond-ts/charts` patch: fixes trading-time axis tick density. `pond-ts`,
49
+ `@pond-ts/react`, `@pond-ts/fit`, and `@pond-ts/financial` carry no code
50
+ changes — republished in lock-step; their `^0.44.0` peer ranges already
51
+ admit this patch.
52
+
53
+ ### Fixed
54
+
55
+ - **charts:** trading-time axis tick density now derives from plot width
56
+ instead of a fixed count of 5. The trading scale's tick `count` caps how many
57
+ calendar buckets `coarsenCalendar` may keep, so the fixed count coarsened any
58
+ ≳6-month daily view to quarter or year grain — a 1-year daily chart on a
59
+ ~900px plot showed 2 ticks; it now lands on month grain (~12). Continuous
60
+ (non-trading) time and value axes keep the fixed default, unchanged. The
61
+ count is shared through the container frame (`xTickCount`), so axis labels,
62
+ x gridlines, session dividers, and the cursor-time formatter all derive from
63
+ the same instants — previously the label formatter anchored at the scale's
64
+ internal default (10) while ticks used 5, which is why sparse year-grain
65
+ ticks were labelled with dates (`Jun 22`) instead of years. (Tidal friction
66
+ report, charts 0.44.)
67
+
68
+ ## [0.44.0] — 2026-07-11
69
+
70
+ The **value-axis charts** release: cross-sectional data (a volatility smile keyed
71
+ by strike) becomes a first-class charting surface. `ValueSeries.fromColumns` is
72
+ the direct columnar door; `<ScatterChart>` and `<BoxPlot>` join `<LineChart>` on
73
+ the value axis; `<BoxPlot>` gains range-only (bid→ask) marks, `offset` pairing,
74
+ and `capWidth`; and the region cursor works on value axes and snaps to histogram
75
+ bins.
76
+
77
+ ### Added
78
+
79
+ - **`<BoxPlot>` finished for the value axis + range-only marks** (`@pond-ts/charts`).
80
+ Four coordinated changes, driven by the volatility smile's per-strike bid/ask IV
81
+ segments (`docs/notes/vol-smile-followups-2026-07.md` §1):
82
+ - **Accepts a `ValueSeries`** (`series.byValue('strike')` /
83
+ `ValueSeries.fromColumns`) — boxes on a value axis, the same instanceof branch
84
+ as `<LineChart>` / `<ScatterChart>`. The box **width** now comes from neighbour
85
+ spacing for a **point** key (a `ValueSeries`, or a point-keyed `TimeSeries`) —
86
+ like bars/candles — instead of collapsing to the 1px floor; an interval-keyed
87
+ `TimeSeries` still uses its `[begin, end)`.
88
+ - **Optional `q1`/`median`/`q3`** — omit `q1`+`q3` for a **range-only** box: a
89
+ whisker-only `lower→upper` segment, no body (a bid→ask IV mark honestly named,
90
+ not a candlestick abuse). Omitting exactly one of `q1`/`q3` throws.
91
+ - **`offset` prop** (`<BoxPlot>` and `<ScatterChart>`) — a **pixel** shift for
92
+ pairing same-key marks (call/put at one strike) side by side, zoom-stable. On
93
+ the scatter it moves the draw **and** the click hit-test together; on the box
94
+ the readout hit-tests in un-shifted data space (keep the offset small).
95
+ - **`capWidth` prop** (`<BoxPlot>`) — the whisker end-cap width in **pixels**
96
+ (else half the box width). A small fixed cap keeps two `offset`-paired marks'
97
+ T-bars from overlapping when the value-axis slot is wide; clamped to the box
98
+ width, `'whisker'` shape only.
99
+ - **Readout labels** carry the series' `as` identity (`iv upper`, `iv median`)
100
+ when set, instead of bare column names — the `as ?? column` convention
101
+ Line/Scatter already use.
102
+ - **`<ScatterChart>` accepts a `ValueSeries`** (`@pond-ts/charts`) — scatter
103
+ marks on the value axis, the same instanceof-branched adapter as
104
+ `<LineChart>` (the container infers the x kind from the data). The
105
+ data-driven `radius` / `color` encodings work unchanged on a value axis;
106
+ the per-point `label` reads through a new columnar branch (a `ValueSeries`
107
+ has no per-row events) — IV marks keyed by strike with open-interest
108
+ radius is the driving composition (vol smile). New value-axis Storybook
109
+ fan-out (`ValueAxis` / `ValueAxisEncoded` / `ValueAxisSmile` /
110
+ `ValueAxisFlag`) + Linux visual baselines.
111
+
112
+ - **`ValueSeries.fromColumns({ name, schema, columns, sort? })`** (`pond-ts`) —
113
+ the direct columnar door into value-land, for data that is _natively_
114
+ value-keyed (cross-sectional): an options chain keyed by strike, a spectrum
115
+ keyed by frequency. Exact `TimeSeries.fromColumns` contract with the axis in
116
+ place of time — same polymorphic `number[]` / `Float64Array` inputs, same
117
+ zero-copy adoption, same stable opt-in `sort`, same gap rule — the two doors
118
+ share one ingest engine. Previously cross-sectional callers had to launder
119
+ the axis through a fake `time` column (`TimeSeries.fromColumns` +
120
+ `byValue`); that detour is no longer needed.
121
+
122
+ - **The region cursor snaps to a histogram's bins** (`@pond-ts/charts`). On a
123
+ `<BarChart>` histogram, `cursor="region"` now snaps **bar by bar** with no
124
+ `cursorSequence`: hovering highlights the bar under the pointer, a drag extends
125
+ across whole bars, and `onRegionSelect` reports the selected bin range
126
+ `[lo, hi]` at the bar edges. The bar layer publishes its `[begin, end)` spans
127
+ (a new internal `binIntervals` channel) as the region cursor's snap buckets —
128
+ the same machinery a `cursorSequence` drives on a time axis, so it also covers a
129
+ time-axis histogram. Only a **vertical** bar layer on a continuous (time /
130
+ value) x axis publishes bins; a horizontal chart (value on x) and a categorical
131
+ axis stay freeform / excluded. An explicit `cursorSequence` still takes
132
+ precedence. New `HistogramBins` region-cursor story.
133
+
134
+ ### Changed
135
+
136
+ - **BREAKING (`@pond-ts/charts`): the region cursor works on a value x-axis, and
137
+ `onRegionSelect` reports a neutral `[lo, hi]` pair.** The drag-select callback
138
+ fired a `TimeRange`; it now fires `readonly [number, number]` in **axis units** —
139
+ epoch ms on a time axis, the axis value (strike, distance, …) on a value axis —
140
+ mirroring the container's polymorphic `range` input (which never takes the axis
141
+ _kind_ from its value). A time-axis consumer that wants a `TimeRange` builds one
142
+ from the pair (`new TimeRange({ start: lo, end: hi })`). The cursor itself is
143
+ ungated from time-only to any **continuous** x-axis (time **or** value; a
144
+ **category** axis stays excluded — an ordinal-slot select is a different gesture).
145
+ Bucket **snapping** stays time-only (a `cursorSequence` bucket is a time
146
+ interval), so a value axis is always **freeform** (hover line + raw-span drag).
147
+
44
148
  ## [0.43.0] — 2026-07-11
45
149
 
46
150
  The **categorical x-axis** release: a first-class ordinal band scale (ticker /
package/dist/BarChart.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
- import { ValueSeries } from 'pond-ts';
2
+ import { Interval, ValueSeries } from 'pond-ts';
3
3
  import { barsFromTimeSeries, barsFromValueSeries, categoryStack, stacksFromBins, stacksFromColumns, stacksFromGroups, } from './data.js';
4
4
  import { barAt, barExtent, barIndexAtTime, drawBars, drawStacks, resolveBarBaseline, stackAt, stackBinExtent, stackValueExtent, } from './bars.js';
5
5
  import { ContainerContext, LayersContext, } from './context.js';
@@ -155,6 +155,26 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
155
155
  : series instanceof ValueSeries
156
156
  ? 'value'
157
157
  : 'time';
158
+ // The bars' `[begin, end)` spans as pond `Interval`s — the region cursor's snap
159
+ // buckets (a region drag snaps bar by bar; a hover highlights the bar under the
160
+ // pointer). Published only for a **vertical** bar layer on a **continuous**
161
+ // (time / value) x axis: a horizontal chart puts the value/count on x (snapping
162
+ // it is meaningless) and a categorical (ordinal-slot) axis is out of the region
163
+ // cursor's scope. Memoized off the shape alone, so a hover / selection change
164
+ // (which rebuilds the layer entry) doesn't re-allocate the intervals.
165
+ const binBuckets = useMemo(() => {
166
+ if (orientation !== 'vertical' || binAxisKind === 'category')
167
+ return null;
168
+ const { begin, end, length } = shape.kind === 'single' ? shape.bs : shape.ss;
169
+ if (length === 0)
170
+ return null;
171
+ const out = new Array(length);
172
+ for (let i = 0; i < length; i += 1) {
173
+ const b = begin[i];
174
+ out[i] = new Interval({ value: b, start: b, end: end[i] });
175
+ }
176
+ return out;
177
+ }, [shape, orientation, binAxisKind]);
158
178
  const { bar } = container.theme;
159
179
  // Single-series style: the `as` role → theme bar style (the single channel).
160
180
  const singleStyle = (semantic !== undefined ? bar[semantic] : undefined) ?? bar.default;
@@ -208,6 +228,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
208
228
  yExtent: () => barExtent(bs),
209
229
  xKind: binAxisKind,
210
230
  xExtent: () => bs.length === 0 ? null : [bs.begin[0], bs.end[bs.length - 1]],
231
+ ...(binBuckets !== null ? { binIntervals: () => binBuckets } : {}),
211
232
  sampleAt: (time) => {
212
233
  if (bs.length === 0)
213
234
  return [];
@@ -263,6 +284,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
263
284
  xKind: vertical ? binAxisKind : 'value',
264
285
  xExtent: vertical ? binExtent : valueExtent,
265
286
  yExtent: vertical ? valueExtent : binExtent,
287
+ ...(binBuckets !== null ? { binIntervals: () => binBuckets } : {}),
266
288
  // A categorical chart hands the container its ordered category names — the
267
289
  // ordinal axis domain the shared band scale + label formatter build on.
268
290
  ...(categoryLabels !== null
@@ -305,6 +327,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
305
327
  }, [
306
328
  shape,
307
329
  binAxisKind,
330
+ binBuckets,
308
331
  categoryLabels,
309
332
  orientation,
310
333
  singleStyle,
package/dist/BoxPlot.d.ts CHANGED
@@ -1,18 +1,35 @@
1
- import type { SeriesSchema, TimeSeries } from 'pond-ts';
1
+ import { ValueSeries } from 'pond-ts';
2
+ import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
2
3
  import { type BoxShape } from './box.js';
3
- export interface BoxPlotProps<S extends SeriesSchema> {
4
- /** The source series. Its interval key column supplies the time axis (the box
5
- * x-span is the key's `[begin, end)`). */
6
- series: TimeSeries<S>;
7
- /** Name of the numeric column for the lower whisker end (e.g. `p5` / `min`). */
4
+ export interface BoxPlotProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
5
+ /**
6
+ * The source series. A `TimeSeries` plots against the time axis; a `ValueSeries`
7
+ * (`series.byValue('strike')`, or `ValueSeries.fromColumns` for natively
8
+ * value-keyed data a per-strike IV distribution) against its value axis the
9
+ * container infers which from the data, no axis-type prop (mirrors `<LineChart>`
10
+ * / `<ScatterChart>`). The box x-span is the key's `[begin, end)` for an
11
+ * interval-keyed `TimeSeries`, else synthesized from neighbour spacing (a
12
+ * point-keyed `TimeSeries`, or a `ValueSeries`) so the box keeps real width.
13
+ */
14
+ series: TimeSeries<S> | ValueSeries<VS>;
15
+ /** Name of the numeric column for the lower whisker end (e.g. `p5` / `min`).
16
+ * **Required** — with `upper` it's the whisker reach. */
8
17
  lower: string;
9
- /** Name of the numeric column for the box bottom — first quartile (e.g. `p25`). */
10
- q1: string;
11
- /** Name of the numeric column for the median line (e.g. `p50`). */
12
- median: string;
13
- /** Name of the numeric column for the box top — third quartile (e.g. `p75`). */
14
- q3: string;
15
- /** Name of the numeric column for the upper whisker end (e.g. `p95` / `max`). */
18
+ /**
19
+ * Name of the numeric column for the box bottom — first quartile (e.g. `p25`).
20
+ * **Optional:** omit `q1` **and** `q3` together for a **range-only** box a
21
+ * whisker-only `lower→upper` segment, no body (a bid→ask IV mark). Giving just
22
+ * one of `q1`/`q3` throws.
23
+ */
24
+ q1?: string;
25
+ /** Name of the numeric column for the median line (e.g. `p50`). **Optional** —
26
+ * omit for no centre line (independent of the box body). */
27
+ median?: string;
28
+ /** Name of the numeric column for the box top — third quartile (e.g. `p75`).
29
+ * **Optional** — omit with `q1` for a range-only box (see `q1`). */
30
+ q3?: string;
31
+ /** Name of the numeric column for the upper whisker end (e.g. `p95` / `max`).
32
+ * **Required** — with `lower` it's the whisker reach. */
16
33
  upper: string;
17
34
  /**
18
35
  * The box series' semantic identifier — what the spread _is_ (e.g. `latency`).
@@ -38,12 +55,39 @@ export interface BoxPlotProps<S extends SeriesSchema> {
38
55
  * How each box renders its spread — `'whisker'` (default; thin stems + caps),
39
56
  * `'solid'` (the candlestick look: a light outer bar over the full range with a
40
57
  * darker inner q1→q3 box, no stems), or `'none'` (the q1→q3 box only, no spread
41
- * marks). See {@link BoxShape}.
58
+ * marks). See {@link BoxShape}. On a **range-only** box (no `q1`/`q3`),
59
+ * `'whisker'` is one full `lower→upper` stem and `'solid'` the outer bar; `'none'`
60
+ * would draw **nothing** (no body + no spread), so use `'whisker'`/`'solid'` there.
42
61
  */
43
62
  shape?: BoxShape;
44
- /** Draw the median (centre) line across each box. Always optional; default
45
- * `true`. (The `median` prop above names the *column*; this toggles the line.) */
63
+ /** Draw the median (centre) line across each box. Default `true`, but a no-op
64
+ * when the `median` column is omitted (nothing to draw). The `median` prop
65
+ * names the *column*; this toggles the line. */
46
66
  showMedian?: boolean;
67
+ /**
68
+ * A **pixel** shift applied to every box's x — zoom-stable (unlike a data-space
69
+ * nudge). **Default `0`.** For pairing marks that share a key side by side: e.g.
70
+ * a call and a put box at the same strike, `offset={-4}` / `offset={+4}` (the
71
+ * react-timeseries-charts side-by-side-bars precedent). Pairs with
72
+ * `<ScatterChart offset>`.
73
+ *
74
+ * Only the **draw** is shifted. The box's **readouts** stay in un-shifted data
75
+ * space — the off-chart hover finds a box by span containment (`xScale.invert`),
76
+ * and the in-chart `flag` staff anchors at the box's data centre — so both can
77
+ * sit up to `offset` px from the pixel-shifted box. Keep the offset small (a
78
+ * pairing nudge, not a layout tool) and it's imperceptible. (`<ScatterChart
79
+ * offset>` has no such gap — its hit-test is pixel-space and shifts too.)
80
+ */
81
+ offset?: number;
82
+ /**
83
+ * Whisker end-cap **total width in pixels** (the top/bottom bars of the `T`).
84
+ * **Omitted ⇒ half the box width** (responsive — scales with the slot). Set a
85
+ * small fixed value (e.g. `6`) to keep the caps narrow so two `offset`-paired
86
+ * marks (call/put at one strike) don't overlap their T-bars — the caps no longer
87
+ * grow with the wide value-axis slot. Clamped to the box width. Only affects the
88
+ * `'whisker'` shape (`'solid'`/`'none'` have no caps).
89
+ */
90
+ capWidth?: number;
47
91
  /**
48
92
  * @internal Declaration position among the `<Layers>` children, injected by
49
93
  * `Layers` so z-order follows JSX order. Do not set.
@@ -52,26 +96,34 @@ export interface BoxPlotProps<S extends SeriesSchema> {
52
96
  }
53
97
  /**
54
98
  * A discrete box-and-whisker draw layer — the bar-chart analog of the variance
55
- * band. Reads five **pre-computed quantile columns** of `series` (typically a
99
+ * band. Reads **pre-computed quantile columns** of `series` (typically a
56
100
  * `rolling`/`aggregate` percentile pass — the chart does **not** compute them)
57
- * into a {@link BoxSeries} and draws one box per key: the q1→q3 box, the median
58
- * line, and whiskers out to lower/upper, over the key's interval x-span. Gap-aware
59
- * (a key missing any quantile draws nothing) and registers itself into the
60
- * enclosing {@link Layers}. Renders nothing to the DOM — the row draws it.
101
+ * into a {@link BoxSeries} and draws one box per key: the q1→q3 body, the median
102
+ * line, and whiskers out to lower/upper. Registers itself into the enclosing
103
+ * {@link Layers}; renders nothing to the DOM the row draws it.
104
+ *
105
+ * - **Any axis.** A `TimeSeries` plots on time, a `ValueSeries`
106
+ * (`series.byValue('strike')` or `ValueSeries.fromColumns`) on its value axis —
107
+ * a vol smile's per-strike IV. The box width is the interval key's `[begin, end)`
108
+ * or, for a point key (a `ValueSeries`, or a point-keyed `TimeSeries`),
109
+ * neighbour spacing — so it never collapses to the 1px floor.
110
+ * - **Range-only.** `q1`/`q3` (the body) and `median` (the centre line) are
111
+ * optional: omit `q1`+`q3` for a whisker-only `lower→upper` segment — a bid→ask
112
+ * IV mark. Gap-aware: a key missing any **present** quantile draws nothing.
113
+ * - **`offset`** nudges the whole layer in pixel space, for pairing same-key marks
114
+ * (call/put at one strike) side by side.
61
115
  *
62
116
  * There's no baseline — a box is a spread, not a bar to a floor; the y-domain
63
117
  * auto-fits the whisker reach (lower→upper).
64
118
  *
65
119
  * ```tsx
66
120
  * <Layers>
67
- * <BoxPlot
68
- * series={q}
69
- * lower="p5" q1="p25" median="p50" q3="p75" upper="p95"
70
- * as="latency"
71
- * gap={6}
72
- * />
121
+ * <BoxPlot series={q} lower="p5" q1="p25" median="p50" q3="p75" upper="p95"
122
+ * as="latency" gap={6} />
123
+ * // range-only bid→ask on a value axis (a vol smile):
124
+ * <BoxPlot series={smile} lower="bid" upper="ask" />
73
125
  * </Layers>
74
126
  * ```
75
127
  */
76
- export declare function BoxPlot<S extends SeriesSchema>({ series, lower, q1, median, q3, upper, as: semantic, axis, gap, shape, showMedian, index, }: BoxPlotProps<S>): null;
128
+ 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, index, }: BoxPlotProps<S, VS>): null;
77
129
  //# sourceMappingURL=BoxPlot.d.ts.map
package/dist/BoxPlot.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
- import { boxFromTimeSeries } from './data.js';
2
+ import { ValueSeries } from 'pond-ts';
3
+ import { boxFromTimeSeries, boxFromValueSeries } from './data.js';
3
4
  import { boxExtent, boxIndexAtTime, drawBox, isFiniteBox, } from './box.js';
4
5
  import { ContainerContext, LayersContext, } from './context.js';
5
6
  import { useSlotKey } from './use-slot-key.js';
@@ -7,28 +8,36 @@ import { useSlotKey } from './use-slot-key.js';
7
8
  const MIN_BOX_WIDTH_PX = 1;
8
9
  /**
9
10
  * A discrete box-and-whisker draw layer — the bar-chart analog of the variance
10
- * band. Reads five **pre-computed quantile columns** of `series` (typically a
11
+ * band. Reads **pre-computed quantile columns** of `series` (typically a
11
12
  * `rolling`/`aggregate` percentile pass — the chart does **not** compute them)
12
- * into a {@link BoxSeries} and draws one box per key: the q1→q3 box, the median
13
- * line, and whiskers out to lower/upper, over the key's interval x-span. Gap-aware
14
- * (a key missing any quantile draws nothing) and registers itself into the
15
- * enclosing {@link Layers}. Renders nothing to the DOM — the row draws it.
13
+ * into a {@link BoxSeries} and draws one box per key: the q1→q3 body, the median
14
+ * line, and whiskers out to lower/upper. Registers itself into the enclosing
15
+ * {@link Layers}; renders nothing to the DOM the row draws it.
16
+ *
17
+ * - **Any axis.** A `TimeSeries` plots on time, a `ValueSeries`
18
+ * (`series.byValue('strike')` or `ValueSeries.fromColumns`) on its value axis —
19
+ * a vol smile's per-strike IV. The box width is the interval key's `[begin, end)`
20
+ * or, for a point key (a `ValueSeries`, or a point-keyed `TimeSeries`),
21
+ * neighbour spacing — so it never collapses to the 1px floor.
22
+ * - **Range-only.** `q1`/`q3` (the body) and `median` (the centre line) are
23
+ * optional: omit `q1`+`q3` for a whisker-only `lower→upper` segment — a bid→ask
24
+ * IV mark. Gap-aware: a key missing any **present** quantile draws nothing.
25
+ * - **`offset`** nudges the whole layer in pixel space, for pairing same-key marks
26
+ * (call/put at one strike) side by side.
16
27
  *
17
28
  * There's no baseline — a box is a spread, not a bar to a floor; the y-domain
18
29
  * auto-fits the whisker reach (lower→upper).
19
30
  *
20
31
  * ```tsx
21
32
  * <Layers>
22
- * <BoxPlot
23
- * series={q}
24
- * lower="p5" q1="p25" median="p50" q3="p75" upper="p95"
25
- * as="latency"
26
- * gap={6}
27
- * />
33
+ * <BoxPlot series={q} lower="p5" q1="p25" median="p50" q3="p75" upper="p95"
34
+ * as="latency" gap={6} />
35
+ * // range-only bid→ask on a value axis (a vol smile):
36
+ * <BoxPlot series={smile} lower="bid" upper="ask" />
28
37
  * </Layers>
29
38
  * ```
30
39
  */
31
- export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, axis, gap = 0, shape = 'whisker', showMedian = true, index = 0, }) {
40
+ export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, axis, gap = 0, shape = 'whisker', showMedian = true, offset = 0, capWidth, index = 0, }) {
32
41
  const container = useContext(ContainerContext);
33
42
  if (container === null) {
34
43
  throw new Error('<BoxPlot> must be rendered inside a <ChartContainer>');
@@ -37,78 +46,96 @@ export function BoxPlot({ series, lower, q1, median, q3, upper, as: semantic, ax
37
46
  if (layers === null) {
38
47
  throw new Error('<BoxPlot> must be rendered inside a <Layers>');
39
48
  }
40
- const bx = useMemo(() => boxFromTimeSeries(series, { lower, q1, median, q3, upper }), [series, lower, q1, median, q3, upper]);
49
+ const isValue = series instanceof ValueSeries;
50
+ const bx = useMemo(() => series instanceof ValueSeries
51
+ ? boxFromValueSeries(series, { lower, q1, median, q3, upper })
52
+ : boxFromTimeSeries(series, { lower, q1, median, q3, upper }), [series, lower, q1, median, q3, upper]);
41
53
  // Styling: semantic identifier → theme box style. The single styling channel.
42
54
  const { box } = container.theme;
43
55
  const style = (semantic !== undefined ? box[semantic] : undefined) ?? box.default;
56
+ // Readout label per quantile: when a semantic `as` is set, label reads under the
57
+ // series name + role (`iv upper`, `iv median`) — the `as ?? column` convention
58
+ // Line/Scatter use, so a box no longer reads out as bare column names (e.g.
59
+ // `bidIv`); with no `as`, fall back to the column name (its role is self-evident).
60
+ const qLabel = useMemo(() => {
61
+ return (col, role) => semantic !== undefined ? `${semantic} ${role}` : (col ?? role);
62
+ }, [semantic]);
44
63
  const entry = useMemo(() => ({
45
64
  layer: {
46
65
  yExtent: () => boxExtent(bx),
47
- xKind: 'time',
66
+ // A ValueSeries plots on a value axis, a TimeSeries on time; the container
67
+ // infers the shared x kind from its layers.
68
+ xKind: isValue ? 'value' : 'time',
48
69
  xExtent: () => bx.length === 0 ? null : [bx.x[0], bx.xEnd[bx.length - 1]],
49
- sampleAt: (time) => {
70
+ sampleAt: (x) => {
50
71
  // The readout reads the box **under the cursor** (boxIndexAtTime — span
51
72
  // containment, not nearest-by-begin which flips past a wide box's
52
73
  // midpoint), anchored at the box **centre** `(x + xEnd) / 2`. Outside
53
74
  // every box → no readout. Off-chart fan-in only; the in-chart flag is
54
- // `cursorFlag`.
75
+ // `cursorFlag`. `push` skips a non-finite quantile, so an absent
76
+ // (range-only) q1/q3/median simply doesn't read out.
55
77
  if (bx.length === 0)
56
78
  return [];
57
- const i = boxIndexAtTime(bx, time);
79
+ const i = boxIndexAtTime(bx, x);
58
80
  if (i < 0)
59
81
  return [];
60
82
  const at = (bx.x[i] + bx.xEnd[i]) / 2;
61
83
  const samples = [];
62
- // The median is the primary readout (median colour); the four quantile
63
- // edges ride the whisker colour, each labelled by its own column. A
64
- // single non-finite quantile is omitted (a gap key yields nothing — all
65
- // five missing but a malformed partial set still reads what it has).
66
- push(samples, at, bx.upper[i], style.whisker, upper);
67
- push(samples, at, bx.q3[i], style.whisker, q3);
68
- push(samples, at, bx.median[i], style.median, median);
69
- push(samples, at, bx.q1[i], style.whisker, q1);
70
- push(samples, at, bx.lower[i], style.whisker, lower);
84
+ push(samples, at, bx.upper[i], style.whisker, qLabel(upper, 'upper'));
85
+ push(samples, at, bx.q3[i], style.whisker, qLabel(q3, 'q3'));
86
+ push(samples, at, bx.median[i], style.median, qLabel(median, 'median'));
87
+ push(samples, at, bx.q1[i], style.whisker, qLabel(q1, 'q1'));
88
+ push(samples, at, bx.lower[i], style.whisker, qLabel(lower, 'lower'));
71
89
  return samples;
72
90
  },
73
- cursorFlag: (time) => {
74
- // The in-chart `flag`: all five values on **one** flag at the box's
91
+ cursorFlag: (x) => {
92
+ // The in-chart `flag`: the box's values on **one** flag at its
75
93
  // top-centre. The staff rises from `upper` (the mark's top); the values
76
94
  // run high→low across one horizontal row (Layers renders them
77
- // left→right), each coloured to its box piece. All-or-nothing a gap
78
- // box (any quantile non-finite, not drawn) shows no flag.
95
+ // left→right), each coloured to its box piece. A gap box (its present
96
+ // quantiles not all finite) shows no flag; an absent (range-only)
97
+ // quantile is simply skipped.
79
98
  if (bx.length === 0)
80
99
  return null;
81
- const i = boxIndexAtTime(bx, time);
100
+ const i = boxIndexAtTime(bx, x);
82
101
  if (i < 0 || !isFiniteBox(bx, i))
83
102
  return null;
103
+ const lines = [];
104
+ const line = (value, color, label) => {
105
+ if (Number.isFinite(value))
106
+ lines.push({ value, color, label });
107
+ };
108
+ line(bx.upper[i], style.whisker, qLabel(upper, 'upper'));
109
+ line(bx.q3[i], style.whisker, qLabel(q3, 'q3'));
110
+ line(bx.median[i], style.median, qLabel(median, 'median'));
111
+ line(bx.q1[i], style.whisker, qLabel(q1, 'q1'));
112
+ line(bx.lower[i], style.whisker, qLabel(lower, 'lower'));
84
113
  return {
85
114
  x: (bx.x[i] + bx.xEnd[i]) / 2,
86
115
  topValue: bx.upper[i],
87
- lines: [
88
- { value: bx.upper[i], color: style.whisker, label: upper },
89
- { value: bx.q3[i], color: style.whisker, label: q3 },
90
- { value: bx.median[i], color: style.median, label: median },
91
- { value: bx.q1[i], color: style.whisker, label: q1 },
92
- { value: bx.lower[i], color: style.whisker, label: lower },
93
- ],
116
+ lines,
94
117
  };
95
118
  },
96
- draw: (ctx, xScale, yScale) => drawBox(ctx, bx, xScale, yScale, style, gap, MIN_BOX_WIDTH_PX, shape, showMedian),
119
+ draw: (ctx, xScale, yScale) => drawBox(ctx, bx, xScale, yScale, style, gap, MIN_BOX_WIDTH_PX, shape, showMedian, offset, capWidth),
97
120
  },
98
121
  axisId: axis,
99
122
  index,
100
123
  }), [
101
124
  bx,
125
+ isValue,
102
126
  series,
103
127
  lower,
104
128
  q1,
105
129
  median,
106
130
  q3,
107
131
  upper,
132
+ qLabel,
108
133
  style,
109
134
  gap,
110
135
  shape,
111
136
  showMedian,
137
+ offset,
138
+ capWidth,
112
139
  axis,
113
140
  index,
114
141
  ]);
@@ -105,17 +105,25 @@ export interface ChartContainerProps {
105
105
  /**
106
106
  * Makes the `region` cursor **draggable**: drag across the plot and the band
107
107
  * extends **bucket by bucket** (snapping to `cursorSequence` points); on
108
- * release this fires **once** with the selected `[start, end)` `TimeRange`, and
109
- * the cursor reverts to the single-bucket highlight (it does not keep the
110
- * range). Typical use — zoom the view to the returned range (the container
111
- * doesn't zoom itself; that's the consumer's call).
108
+ * release this fires **once** with the selected `[lo, hi]` span, and the cursor
109
+ * reverts to the single-bucket highlight (it does not keep the range). Typical
110
+ * use — zoom the view to the returned span (the container doesn't zoom itself;
111
+ * that's the consumer's call), or map it onto a data subscription's range params.
112
+ *
113
+ * The span is a **neutral numeric pair in axis units** — epoch ms on a **time**
114
+ * axis, the axis value (strike, distance, …) on a **value** axis — mirroring the
115
+ * polymorphic `range` input. A time consumer that wants a `TimeRange` builds one
116
+ * from the pair.
112
117
  *
113
118
  * With **no `cursorSequence`** the region cursor is the degenerate case — it
114
- * renders as a **line** on hover and the drag is **freeform** (raw `[start,
115
- * end)`, no bucket snapping); the same callback fires on release. No-op unless
116
- * `cursor="region"` (and a **time** x-axis).
119
+ * renders as a **line** on hover and the drag is **freeform** (raw `[lo, hi]`, no
120
+ * bucket snapping); the same callback fires on release. Bucket snapping needs a
121
+ * `cursorSequence`, which is **time-axis only** (a time interval over a value
122
+ * domain is meaningless), so a **value** axis is always freeform. No-op unless
123
+ * `cursor="region"` on a **time** or **value** x-axis (a **category** axis is
124
+ * excluded — an ordinal-slot select is a different gesture).
117
125
  */
118
- onRegionSelect?: (range: TimeRange) => void;
126
+ onRegionSelect?: (range: readonly [number, number]) => void;
119
127
  /**
120
128
  * Which modifier a region-drag needs — set `'shift'` when you also enable
121
129
  * `panZoom` and want **plain drag to pan, shift-drag to select**. It's only