@pond-ts/charts 0.42.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/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.42.0...HEAD
11
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.44.0...HEAD
12
+ [0.44.0]: https://github.com/pjm17971/pond-ts/compare/v0.43.0...v0.44.0
13
+ [0.43.0]: https://github.com/pjm17971/pond-ts/compare/v0.42.0...v0.43.0
12
14
  [0.42.0]: https://github.com/pjm17971/pond-ts/compare/v0.41.0...v0.42.0
13
15
  [0.41.0]: https://github.com/pjm17971/pond-ts/compare/v0.40.0...v0.41.0
14
16
  [0.40.0]: https://github.com/pjm17971/pond-ts/compare/v0.39.0...v0.40.0
@@ -40,6 +42,157 @@ and type-level changes; patch bumps are strictly additive.
40
42
 
41
43
  ## [Unreleased]
42
44
 
45
+ ## [0.44.0] — 2026-07-11
46
+
47
+ The **value-axis charts** release: cross-sectional data (a volatility smile keyed
48
+ by strike) becomes a first-class charting surface. `ValueSeries.fromColumns` is
49
+ the direct columnar door; `<ScatterChart>` and `<BoxPlot>` join `<LineChart>` on
50
+ the value axis; `<BoxPlot>` gains range-only (bid→ask) marks, `offset` pairing,
51
+ and `capWidth`; and the region cursor works on value axes and snaps to histogram
52
+ bins.
53
+
54
+ ### Added
55
+
56
+ - **`<BoxPlot>` finished for the value axis + range-only marks** (`@pond-ts/charts`).
57
+ Four coordinated changes, driven by the volatility smile's per-strike bid/ask IV
58
+ segments (`docs/notes/vol-smile-followups-2026-07.md` §1):
59
+ - **Accepts a `ValueSeries`** (`series.byValue('strike')` /
60
+ `ValueSeries.fromColumns`) — boxes on a value axis, the same instanceof branch
61
+ as `<LineChart>` / `<ScatterChart>`. The box **width** now comes from neighbour
62
+ spacing for a **point** key (a `ValueSeries`, or a point-keyed `TimeSeries`) —
63
+ like bars/candles — instead of collapsing to the 1px floor; an interval-keyed
64
+ `TimeSeries` still uses its `[begin, end)`.
65
+ - **Optional `q1`/`median`/`q3`** — omit `q1`+`q3` for a **range-only** box: a
66
+ whisker-only `lower→upper` segment, no body (a bid→ask IV mark honestly named,
67
+ not a candlestick abuse). Omitting exactly one of `q1`/`q3` throws.
68
+ - **`offset` prop** (`<BoxPlot>` and `<ScatterChart>`) — a **pixel** shift for
69
+ pairing same-key marks (call/put at one strike) side by side, zoom-stable. On
70
+ the scatter it moves the draw **and** the click hit-test together; on the box
71
+ the readout hit-tests in un-shifted data space (keep the offset small).
72
+ - **`capWidth` prop** (`<BoxPlot>`) — the whisker end-cap width in **pixels**
73
+ (else half the box width). A small fixed cap keeps two `offset`-paired marks'
74
+ T-bars from overlapping when the value-axis slot is wide; clamped to the box
75
+ width, `'whisker'` shape only.
76
+ - **Readout labels** carry the series' `as` identity (`iv upper`, `iv median`)
77
+ when set, instead of bare column names — the `as ?? column` convention
78
+ Line/Scatter already use.
79
+ - **`<ScatterChart>` accepts a `ValueSeries`** (`@pond-ts/charts`) — scatter
80
+ marks on the value axis, the same instanceof-branched adapter as
81
+ `<LineChart>` (the container infers the x kind from the data). The
82
+ data-driven `radius` / `color` encodings work unchanged on a value axis;
83
+ the per-point `label` reads through a new columnar branch (a `ValueSeries`
84
+ has no per-row events) — IV marks keyed by strike with open-interest
85
+ radius is the driving composition (vol smile). New value-axis Storybook
86
+ fan-out (`ValueAxis` / `ValueAxisEncoded` / `ValueAxisSmile` /
87
+ `ValueAxisFlag`) + Linux visual baselines.
88
+
89
+ - **`ValueSeries.fromColumns({ name, schema, columns, sort? })`** (`pond-ts`) —
90
+ the direct columnar door into value-land, for data that is _natively_
91
+ value-keyed (cross-sectional): an options chain keyed by strike, a spectrum
92
+ keyed by frequency. Exact `TimeSeries.fromColumns` contract with the axis in
93
+ place of time — same polymorphic `number[]` / `Float64Array` inputs, same
94
+ zero-copy adoption, same stable opt-in `sort`, same gap rule — the two doors
95
+ share one ingest engine. Previously cross-sectional callers had to launder
96
+ the axis through a fake `time` column (`TimeSeries.fromColumns` +
97
+ `byValue`); that detour is no longer needed.
98
+
99
+ - **The region cursor snaps to a histogram's bins** (`@pond-ts/charts`). On a
100
+ `<BarChart>` histogram, `cursor="region"` now snaps **bar by bar** with no
101
+ `cursorSequence`: hovering highlights the bar under the pointer, a drag extends
102
+ across whole bars, and `onRegionSelect` reports the selected bin range
103
+ `[lo, hi]` at the bar edges. The bar layer publishes its `[begin, end)` spans
104
+ (a new internal `binIntervals` channel) as the region cursor's snap buckets —
105
+ the same machinery a `cursorSequence` drives on a time axis, so it also covers a
106
+ time-axis histogram. Only a **vertical** bar layer on a continuous (time /
107
+ value) x axis publishes bins; a horizontal chart (value on x) and a categorical
108
+ axis stay freeform / excluded. An explicit `cursorSequence` still takes
109
+ precedence. New `HistogramBins` region-cursor story.
110
+
111
+ ### Changed
112
+
113
+ - **BREAKING (`@pond-ts/charts`): the region cursor works on a value x-axis, and
114
+ `onRegionSelect` reports a neutral `[lo, hi]` pair.** The drag-select callback
115
+ fired a `TimeRange`; it now fires `readonly [number, number]` in **axis units** —
116
+ epoch ms on a time axis, the axis value (strike, distance, …) on a value axis —
117
+ mirroring the container's polymorphic `range` input (which never takes the axis
118
+ _kind_ from its value). A time-axis consumer that wants a `TimeRange` builds one
119
+ from the pair (`new TimeRange({ start: lo, end: hi })`). The cursor itself is
120
+ ungated from time-only to any **continuous** x-axis (time **or** value; a
121
+ **category** axis stays excluded — an ordinal-slot select is a different gesture).
122
+ Bucket **snapping** stays time-only (a `cursorSequence` bucket is a time
123
+ interval), so a value axis is always **freeform** (hover line + raw-span drag).
124
+
125
+ ## [0.43.0] — 2026-07-11
126
+
127
+ The **categorical x-axis** release: a first-class ordinal band scale (ticker /
128
+ account / expiry on x — the transpose view of a time series), plus the charts
129
+ **interaction** wave that landed after v0.42.0 was cut — the region cursor and
130
+ its drag-to-select gesture, per-bin band colour, and annotation edges that snap
131
+ to session boundaries.
132
+
133
+ ### Added
134
+
135
+ - **`@pond-ts/charts`: a first-class categorical x-axis.** `<BarChart
136
+ categories={[{ label, value }]}>` draws one bar per category on an ordinal
137
+ **band scale** (the transpose view's "columns on x" — ticker / account / zone
138
+ on x). The container infers `xKind:'category'` and builds a `scaleBand` over the
139
+ labels; `<CategoryAxis>` ticks once per category. Colour per category via
140
+ `binColors`; selection reports the category name. **Negative** category values
141
+ draw below the baseline (the P&L / delta case) — a single-series category bar
142
+ honours its sign. New exports `scaleBand` / `ScaleBand`, `CategoryAxis`,
143
+ `categoryStack` / `CategoryDatum`. Additive — a new x-kind alongside time /
144
+ value; existing charts are unchanged. First slice of the categorical-axis RFC
145
+ (`docs/rfcs/categorical-axis.md`, Phase 1).
146
+ - **`@pond-ts/charts`: `transposeRow`** — read one **row** of a wide `TimeSeries`
147
+ **across** into `{ label, value }[]` for `<BarChart categories>`: the schema's
148
+ numeric columns (a `pivotByGroup` output's per-group columns, a term
149
+ structure's per-expiry columns) become the categories at one instant. Pick the
150
+ row with `at` (`'last'` — the head/live row — by default; `'first'`, an index,
151
+ or `{ time }`); bound / order the set with `columns`.
152
+ - **`@pond-ts/charts`: stable per-column selection identity.** `SelectInfo` gains
153
+ an optional **`mark`** — a stable per-mark identity within a layer. A categorical
154
+ bar reports its **column name** as `mark`, and a controlled `selected` echo /
155
+ the highlight match key on `(id, mark)`, so a pinned selection survives a column
156
+ reorder (the slot index doesn't; the name does). Additive — `mark` is
157
+ `undefined` for a time / value bar (whose sample `key` is already its identity).
158
+ Plus a category-axis **label policy**: a dense axis thins (keeps every k-th) and
159
+ ellipsis-truncates its labels so they stay legible while every bar draws.
160
+ - **`@pond-ts/charts`: region cursor (`cursor="region"`).** A shaded **band**
161
+ highlights the bucket under the pointer, bucketed by a new **`cursorSequence`**
162
+ prop — a `Sequence` (`Sequence.every('15m')`, `Sequence.calendar('week')`)
163
+ realized over the view, or a `BoundedSequence` (a `TradingCalendar`'s
164
+ `sessionSequence()` / `barSequence()`) used as-is. The band maps through the x
165
+ scale, so on a trading-time axis the closed part of a bucket collapses (crops
166
+ to live sessions). Time-axis only (a no-op on a value axis). (#409, #413)
167
+ - **`@pond-ts/charts`: draggable region cursor → one-shot select.** Opt-in
168
+ **`onRegionSelect?: (range: TimeRange) => void`** makes the region cursor
169
+ draggable: the band extends bucket by bucket and fires **once** on release
170
+ with the selected `[start, end)` `TimeRange` (the cursor doesn't keep it —
171
+ typical use is to zoom the view). With **no `cursorSequence`** it degenerates
172
+ to a hover **line** + **freeform** drag. **`regionSelectModifier="shift"`**
173
+ resolves the gesture conflict with `panZoom` (plain drag pans, shift-drag
174
+ selects); omitted, a region-drag preempts pan. (#416)
175
+ - **`@pond-ts/charts`: `binColors` — per-bin colour for single-series bars.**
176
+ `<BarChart binColors={[...]}>` colours each bar/band segment individually (one
177
+ colour per bin, in order), the single-series analog of the stacked `colors`
178
+ prop — used by the category axis (colour per category) and any single-series
179
+ band chart. (#408)
180
+ - **`@pond-ts/charts`: annotation edges snap to session boundaries.** When a
181
+ `<ChartContainer>` carries a trading calendar (disjoint x axis), dragging a
182
+ `<Region>` edge (or creating one) snaps to the nearest **session boundary**
183
+ rather than raw wall-clock, so a drawn span aligns with real market sessions.
184
+ (#410)
185
+
186
+ ### Fixed
187
+
188
+ - **`@pond-ts/charts`: region body-move no longer distorts across a session
189
+ boundary.** On a trading-time (discontinuous) axis, dragging a `<Region>`
190
+ annotation by its body now translates it rigidly in pixel space, so the box
191
+ keeps its width as it crosses a collapsed gap (it previously applied one
192
+ value-delta to both edges, which stretched the box in the different
193
+ rate-contexts either side of a session boundary). No-op on a continuous axis.
194
+ (#405)
195
+
43
196
  ## [0.42.0] — 2026-07-10
44
197
 
45
198
  The **trading-calendar** release: a new `@pond-ts/financial` package (its first
@@ -82,6 +235,16 @@ collapses closed-market time (weekends, holidays, overnight, lunch breaks).
82
235
  (`theme.axis.sessionDivider`), aligned with the labels.
83
236
  - `Charts/TradingTimeAxis` stories (weekend/holiday/half-day/intraday,
84
237
  continuous-vs-trading, daily-months, proportional-vs-uniform).
238
+ - **`@pond-ts/charts`: first-class histograms.** `<BarChart>` gains **stacking**
239
+ — a group-by dimension → stacked segments, from a wide series (`columns`), a
240
+ `Map<group, TimeSeries>` (the `partitionBy().aggregate().toMap()` shape), or a
241
+ `byColumn` `bins` array; per-group colour via `colors` or theme roles — and an
242
+ **`orientation`** prop (`'vertical'` default | `'horizontal'`, bars grow right
243
+ with the bins on a y band axis). New readers `stacksFromGroups` /
244
+ `stacksFromColumns` / `stacksFromBins` plus `StackedBarSeries` / `BinRecord` /
245
+ `Orientation` types. All data generation composes from existing operators
246
+ (`aggregate` / `byColumn` / `partitionBy`); no core changes. Guide: How-to
247
+ guides → Histograms. (#401)
85
248
  - **`@pond-ts/charts`:** selection now has a stable series identity. `SelectInfo`
86
249
  carries an `id`, and `BarChart` / `ScatterChart` take an optional `id` prop —
87
250
  the series identity used for selection + hover. An `id` **gates interactivity**:
@@ -1,6 +1,6 @@
1
1
  import { ValueSeries } from 'pond-ts';
2
2
  import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
3
- import { type BinRecord } from './data.js';
3
+ import { type BinRecord, type CategoryDatum } from './data.js';
4
4
  import { type Orientation } from './bars.js';
5
5
  export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
6
6
  /**
@@ -29,6 +29,17 @@ export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends
29
29
  * draw. Pair with `ordinal` for a category (band) axis.
30
30
  */
31
31
  bins?: readonly BinRecord[];
32
+ /**
33
+ * **Categorical** data — an ordered `{ label, value }[]`, one bar per category
34
+ * on a first-class **ordinal category x-axis** (the container infers
35
+ * `xKind:'category'` and builds a band scale over the labels). The transpose
36
+ * view's "columns on x": each `label` is a category (ticker / account / zone),
37
+ * `value` its bar height. Provide **exactly one** of `series` / `bins` /
38
+ * `categories`; `categories` takes no `column`/`columns` and is **vertical only**
39
+ * (categories on x). Colour per category via `binColors`. (Categorical-axis RFC,
40
+ * Phase 1.)
41
+ */
42
+ categories?: readonly CategoryDatum[];
32
43
  /** Name of the numeric value column for the bar height (single series). Provide
33
44
  * `column` **or** `columns`, not both. */
34
45
  column?: string;
@@ -55,6 +66,17 @@ export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends
55
66
  * this is the stack's one colour input.
56
67
  */
57
68
  colors?: Readonly<Record<string, string>>;
69
+ /**
70
+ * **Per-bin** colours for a single-series band chart — `binColors[i]` fills
71
+ * bar `i` (aligned to the bins / bands in order), overriding the `as`/theme
72
+ * fill. This is the way to colour heart-rate / power **zones** or value bands
73
+ * each their own colour (the `colors` map above is per-**group**, for stacks).
74
+ * An `undefined`/short entry falls back to the theme fill. Meant for a
75
+ * single-series chart (`column` + `bins`, or a horizontal single series); on a
76
+ * multi-group stack it would tint every segment of a bin alike, so it's not
77
+ * the tool there.
78
+ */
79
+ binColors?: readonly (string | undefined)[];
58
80
  /**
59
81
  * Bar growth direction (the histogram orientation). **Default `'vertical'`.**
60
82
  *
@@ -147,5 +169,5 @@ export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends
147
169
  * </Layers>
148
170
  * ```
149
171
  */
150
- export declare function BarChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, bins, column, columns, as: semantic, colors, orientation, ordinal, id, axis, gap, index, }: BarChartProps<S, VS>): null;
172
+ export declare function BarChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, bins, categories, column, columns, as: semantic, colors, binColors, orientation, ordinal, id, axis, gap, index, }: BarChartProps<S, VS>): null;
151
173
  //# sourceMappingURL=BarChart.d.ts.map
package/dist/BarChart.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
- import { ValueSeries } from 'pond-ts';
3
- import { barsFromTimeSeries, barsFromValueSeries, stacksFromBins, stacksFromColumns, stacksFromGroups, } from './data.js';
2
+ import { Interval, ValueSeries } from 'pond-ts';
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';
6
6
  import { useSlotKey } from './use-slot-key.js';
@@ -47,7 +47,7 @@ import { useSlotKey } from './use-slot-key.js';
47
47
  * </Layers>
48
48
  * ```
49
49
  */
50
- export function BarChart({ series, bins, column, columns, as: semantic, colors, orientation = 'vertical', ordinal = false, id, axis, gap, index = 0, }) {
50
+ export function BarChart({ series, bins, categories, column, columns, as: semantic, colors, binColors, orientation = 'vertical', ordinal = false, id, axis, gap, index = 0, }) {
51
51
  const container = useContext(ContainerContext);
52
52
  if (container === null) {
53
53
  throw new Error('<BarChart> must be rendered inside a <ChartContainer>');
@@ -58,8 +58,19 @@ export function BarChart({ series, bins, column, columns, as: semantic, colors,
58
58
  }
59
59
  // Validate the data-source / value-column combination up front (throws are
60
60
  // stable across renders, so no need to memoize them).
61
- if ((series === undefined) === (bins === undefined)) {
62
- throw new Error('<BarChart> needs exactly one of `series` or `bins`');
61
+ const nSources = (series !== undefined ? 1 : 0) +
62
+ (bins !== undefined ? 1 : 0) +
63
+ (categories !== undefined ? 1 : 0);
64
+ if (nSources !== 1) {
65
+ throw new Error('<BarChart> needs exactly one of `series`, `bins`, or `categories`');
66
+ }
67
+ if (categories !== undefined) {
68
+ if (column !== undefined || columns !== undefined) {
69
+ throw new Error('<BarChart categories> takes no `column`/`columns` (each datum carries its own value)');
70
+ }
71
+ if (orientation === 'horizontal') {
72
+ throw new Error('<BarChart categories> is vertical only (categories on x); horizontal category axes are not yet supported');
73
+ }
63
74
  }
64
75
  const isMap = series instanceof Map;
65
76
  if (isMap && columns !== undefined) {
@@ -76,6 +87,12 @@ export function BarChart({ series, bins, column, columns, as: semantic, colors,
76
87
  // stack, any horizontal — builds a StackedBarSeries (G === 1 for a single
77
88
  // horizontal bar) so one oriented draw path covers it.
78
89
  const shape = useMemo(() => {
90
+ if (categories !== undefined) {
91
+ // Categorical row-read: one unit-slot bar per category (G === 1), drawn on
92
+ // the container's band scale. The reused stacked geometry — only the axis
93
+ // (band scale + labels) is new.
94
+ return { kind: 'stacked', ss: categoryStack(categories) };
95
+ }
79
96
  if (bins !== undefined) {
80
97
  const cols = columns ?? (column !== undefined ? [column] : undefined);
81
98
  if (cols === undefined) {
@@ -111,18 +128,53 @@ export function BarChart({ series, bins, column, columns, as: semantic, colors,
111
128
  ? barsFromValueSeries(s, column)
112
129
  : barsFromTimeSeries(s, column),
113
130
  };
114
- }, [series, bins, column, columns, ordinal, orientation, isMap, label]);
115
- // The bin axis kind (time vs value) — a `TimeSeries`/`Map` bins on time, a
116
- // `ValueSeries`/`bins`-array on a value axis. For a vertical histogram this is
117
- // the shared x-kind; a horizontal one puts the *value* on x (always 'value')
118
- // and the bin axis on a linear y.
119
- const binAxisKind = bins !== undefined
120
- ? 'value'
121
- : isMap
122
- ? 'time'
123
- : series instanceof ValueSeries
124
- ? 'value'
125
- : 'time';
131
+ }, [
132
+ series,
133
+ bins,
134
+ categories,
135
+ column,
136
+ columns,
137
+ ordinal,
138
+ orientation,
139
+ isMap,
140
+ label,
141
+ ]);
142
+ // The category labels — the ordinal axis's ordered column set (`xCategories`),
143
+ // and the per-bar readout label. `null` unless this is a categorical chart.
144
+ const categoryLabels = useMemo(() => categories?.map((c) => c.label) ?? null, [categories]);
145
+ // The bin axis kind — `'category'` for a categorical chart, else `'time'`/`'value'`
146
+ // (a `TimeSeries`/`Map` bins on time, a `ValueSeries`/`bins`-array on a value
147
+ // axis). For a vertical chart this is the shared x-kind; a horizontal one puts
148
+ // the *value* on x (always 'value') and the bin axis on a linear y.
149
+ const binAxisKind = categories !== undefined
150
+ ? 'category'
151
+ : bins !== undefined
152
+ ? 'value'
153
+ : isMap
154
+ ? 'time'
155
+ : series instanceof ValueSeries
156
+ ? 'value'
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]);
126
178
  const { bar } = container.theme;
127
179
  // Single-series style: the `as` role → theme bar style (the single channel).
128
180
  const singleStyle = (semantic !== undefined ? bar[semantic] : undefined) ?? bar.default;
@@ -137,8 +189,13 @@ export function BarChart({ series, bins, column, columns, as: semantic, colors,
137
189
  const stackStyle = useMemo(() => {
138
190
  const base = bar.default;
139
191
  const fills = (groups ?? []).map((g) => colors?.[g] ?? (bar[g] ?? base).fill);
140
- return { fills, opacity: base.opacity, outlineWidth: base.outlineWidth };
141
- }, [bar, groups, colors]);
192
+ return {
193
+ fills,
194
+ opacity: base.opacity,
195
+ outlineWidth: base.outlineWidth,
196
+ ...(binColors !== undefined ? { binFills: binColors } : {}),
197
+ };
198
+ }, [bar, groups, colors, binColors]);
142
199
  // The current selection / hover, narrowed to the identity the highlight match
143
200
  // needs. For a stack that's (id, key, label = group); the single path uses just
144
201
  // (id, key). Read here so a change re-registers the layer → the canvas repaints.
@@ -146,13 +203,21 @@ export function BarChart({ series, bins, column, columns, as: semantic, colors,
146
203
  const hoveredMark = container.hovered;
147
204
  const selection = useMemo(() => selected === null
148
205
  ? null
149
- : { id: selected.id, key: selected.key, label: selected.label }, [selected]);
206
+ : {
207
+ id: selected.id,
208
+ key: selected.key,
209
+ label: selected.label,
210
+ ...(selected.mark !== undefined ? { mark: selected.mark } : {}),
211
+ }, [selected]);
150
212
  const hover = useMemo(() => hoveredMark === null
151
213
  ? null
152
214
  : {
153
215
  id: hoveredMark.id,
154
216
  key: hoveredMark.key,
155
217
  label: hoveredMark.label,
218
+ ...(hoveredMark.mark !== undefined
219
+ ? { mark: hoveredMark.mark }
220
+ : {}),
156
221
  }, [hoveredMark]);
157
222
  const entry = useMemo(() => {
158
223
  // ── Single-series, vertical: the original bar path, pixels unchanged. ──
@@ -163,6 +228,7 @@ export function BarChart({ series, bins, column, columns, as: semantic, colors,
163
228
  yExtent: () => barExtent(bs),
164
229
  xKind: binAxisKind,
165
230
  xExtent: () => bs.length === 0 ? null : [bs.begin[0], bs.end[bs.length - 1]],
231
+ ...(binBuckets !== null ? { binIntervals: () => binBuckets } : {}),
166
232
  sampleAt: (time) => {
167
233
  if (bs.length === 0)
168
234
  return [];
@@ -218,6 +284,12 @@ export function BarChart({ series, bins, column, columns, as: semantic, colors,
218
284
  xKind: vertical ? binAxisKind : 'value',
219
285
  xExtent: vertical ? binExtent : valueExtent,
220
286
  yExtent: vertical ? valueExtent : binExtent,
287
+ ...(binBuckets !== null ? { binIntervals: () => binBuckets } : {}),
288
+ // A categorical chart hands the container its ordered category names — the
289
+ // ordinal axis domain the shared band scale + label formatter build on.
290
+ ...(categoryLabels !== null
291
+ ? { xCategories: () => categoryLabels }
292
+ : {}),
221
293
  // No x-scrub flag for a stack / horizontal chart — hover + click read it
222
294
  // out instead (the flag is single-series-vertical only).
223
295
  sampleAt: () => [],
@@ -228,13 +300,22 @@ export function BarChart({ series, bins, column, columns, as: semantic, colors,
228
300
  const hit = stackAt(ss, px, py, orientation, xScale, yScale, gapPx, stackMinWidth);
229
301
  if (hit === null)
230
302
  return null;
231
- const [, g, begin, name, value] = hit;
303
+ const [bi, g, begin, name, value] = hit;
304
+ // A categorical bar carries a stable per-bar `mark` (its column
305
+ // name); the selection keys on `(id, mark)` so it survives a
306
+ // reorder. `bi` is the exact bin index from the hit.
307
+ const stableMark = ss.marks?.[bi];
232
308
  return {
233
309
  id,
234
310
  key: begin,
235
311
  value,
236
- color: stackStyle.fills[g],
237
- label: name,
312
+ // A per-bin colour override wins over the group fill, so the
313
+ // readout pill reads the bar's own colour.
314
+ color: stackStyle.binFills?.[bi] ?? stackStyle.fills[g],
315
+ // A categorical bar reports its category name; a stack reports
316
+ // the group.
317
+ label: stableMark ?? name,
318
+ ...(stableMark !== undefined ? { mark: stableMark } : {}),
238
319
  };
239
320
  },
240
321
  }),
@@ -246,6 +327,8 @@ export function BarChart({ series, bins, column, columns, as: semantic, colors,
246
327
  }, [
247
328
  shape,
248
329
  binAxisKind,
330
+ binBuckets,
331
+ categoryLabels,
249
332
  orientation,
250
333
  singleStyle,
251
334
  stackStyle,
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