@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 +105 -1
- package/dist/BarChart.js +24 -1
- package/dist/BoxPlot.d.ts +80 -28
- package/dist/BoxPlot.js +67 -40
- package/dist/ChartContainer.d.ts +16 -8
- package/dist/ChartContainer.js +72 -26
- package/dist/Layers.js +31 -22
- package/dist/ScatterChart.d.ts +33 -6
- package/dist/ScatterChart.js +36 -16
- package/dist/XAxis.js +7 -5
- package/dist/box.d.ts +26 -9
- package/dist/box.js +86 -42
- package/dist/context.d.ts +39 -9
- package/dist/data.d.ts +73 -30
- package/dist/data.js +79 -21
- package/dist/format.d.ts +4 -2
- package/dist/format.js +7 -3
- package/dist/scatter.d.ts +2 -2
- package/dist/scatter.js +8 -5
- package/dist/tradingTimeScale.d.ts +6 -0
- package/dist/tradingTimeScale.js +6 -0
- package/package.json +3 -3
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.
|
|
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
|
|
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
|
-
/**
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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.
|
|
45
|
-
*
|
|
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
|
|
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
|
|
58
|
-
* line, and whiskers out to lower/upper
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
13
|
-
* line, and whiskers out to lower/upper
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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
|
-
|
|
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: (
|
|
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,
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
push(samples, at, bx.
|
|
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: (
|
|
74
|
-
// The in-chart `flag`:
|
|
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.
|
|
78
|
-
//
|
|
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,
|
|
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
|
]);
|
package/dist/ChartContainer.d.ts
CHANGED
|
@@ -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 `[
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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 `[
|
|
115
|
-
*
|
|
116
|
-
* `
|
|
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:
|
|
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
|