@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/dist/box.js
CHANGED
|
@@ -3,9 +3,10 @@ import { barSpanPx } from './range.js';
|
|
|
3
3
|
const WHISKER_CAP_FRACTION = 0.5;
|
|
4
4
|
/**
|
|
5
5
|
* The `[min, max]` vertical extent of the **drawn** boxes — the lowest `lower`
|
|
6
|
-
* whisker and highest `upper` whisker over keys
|
|
7
|
-
*
|
|
8
|
-
* matching what {@link drawBox}
|
|
6
|
+
* whisker and highest `upper` whisker over the keys {@link isFiniteBox} draws
|
|
7
|
+
* (a full box needs all five quantiles; a range-only box just `lower`/`upper`) —
|
|
8
|
+
* or `null` if none are. Gap keys are excluded, matching what {@link drawBox}
|
|
9
|
+
* draws, so they don't drag the y-domain.
|
|
9
10
|
*
|
|
10
11
|
* Only `lower`/`upper` bound the extent: they are the outermost reach of a key
|
|
11
12
|
* (the whisker ends), so `q1`/`median`/`q3` lie within `[lower, upper]` for any
|
|
@@ -56,70 +57,101 @@ export function boxIndexAtTime(box, time) {
|
|
|
56
57
|
* reads darker on a light ground, brighter on a dark one), no stems/outline.
|
|
57
58
|
* - **`none`** — the `q1→q3` box fill + outline only, no spread marks.
|
|
58
59
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
60
|
+
* **Range-only** (`box.hasBox === false` — no `q1`/`q3`): there's no body, so
|
|
61
|
+
* `whisker` draws **one** full `lower→upper` stem with caps, `solid` draws just
|
|
62
|
+
* the outer bar, and `none` draws **nothing** (no body + no spread ⇒ empty — pick
|
|
63
|
+
* `whisker`/`solid` for a range-only box). `showMedian` is a no-op when the box
|
|
64
|
+
* carries no `median` (`hasMedian === false`).
|
|
61
65
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
66
|
+
* Then, if `showMedian` (and a median is present), the median line on top. Fills
|
|
67
|
+
* are bracketed by `save`/`restore` so their `globalAlpha` doesn't leak.
|
|
68
|
+
* `offsetPx` shifts every mark in pixel space (for pairing same-key marks);
|
|
69
|
+
* `capWidthPx` sets a fixed whisker-cap width (else half the box width — a small
|
|
70
|
+
* fixed cap keeps paired offset marks' T-bars from overlapping), clamped to the
|
|
71
|
+
* box width.
|
|
72
|
+
*
|
|
73
|
+
* **Gap-aware**: a key whose present quantiles aren't all finite is skipped
|
|
74
|
+
* entirely (no partial box) — the same contract as a band gap.
|
|
64
75
|
*
|
|
65
76
|
* O(N) over the keys, a fixed number of path ops each — no per-key allocation
|
|
66
77
|
* beyond the `barSpanPx` tuple.
|
|
67
78
|
*/
|
|
68
|
-
export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx = 1, shape = 'whisker', showMedian = true) {
|
|
79
|
+
export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx = 1, shape = 'whisker', showMedian = true, offsetPx = 0, capWidthPx) {
|
|
80
|
+
// A range-only box (bid→ask segment) has no body / median; the whisker (or the
|
|
81
|
+
// solid bar) runs the full lower→upper. Flags default true (a full box).
|
|
82
|
+
const hasBox = box.hasBox !== false;
|
|
83
|
+
const drawMedian = showMedian && box.hasMedian !== false;
|
|
69
84
|
for (let i = 0; i < box.length; i += 1) {
|
|
70
85
|
if (!isFiniteBox(box, i))
|
|
71
86
|
continue;
|
|
72
|
-
const [
|
|
87
|
+
const [span0, span1] = barSpanPx(box.x[i], box.xEnd[i], xScale, gapPx, minWidthPx);
|
|
88
|
+
// `offsetPx` nudges the whole mark in pixel space (zoom-stable) — for pairing
|
|
89
|
+
// same-key marks (call/put at one strike) side by side without overlap.
|
|
90
|
+
const x0 = span0 + offsetPx;
|
|
91
|
+
const x1 = span1 + offsetPx;
|
|
73
92
|
const mid = (x0 + x1) / 2;
|
|
74
93
|
const yLower = yScale(box.lower[i]);
|
|
75
|
-
const yQ1 = yScale(box.q1[i]);
|
|
76
|
-
const yMedian = yScale(box.median[i]);
|
|
77
|
-
const yQ3 = yScale(box.q3[i]);
|
|
78
94
|
const yUpper = yScale(box.upper[i]);
|
|
95
|
+
// q1/q3 are NaN on a range-only box — read them only when there's a body.
|
|
96
|
+
const yQ1 = hasBox ? yScale(box.q1[i]) : 0;
|
|
97
|
+
const yQ3 = hasBox ? yScale(box.q3[i]) : 0;
|
|
79
98
|
if (shape === 'solid') {
|
|
80
|
-
// Candlestick: a light outer bar over the full lower→upper spread, then
|
|
81
|
-
// more-prominent inner q1→q3 box on top (same fill
|
|
82
|
-
//
|
|
83
|
-
// no outline.
|
|
99
|
+
// Candlestick: a light outer bar over the full lower→upper spread, then —
|
|
100
|
+
// when there's a body — a more-prominent inner q1→q3 box on top (same fill
|
|
101
|
+
// at rising opacity). No stems, no outline.
|
|
84
102
|
ctx.save();
|
|
85
103
|
ctx.fillStyle = style.fill;
|
|
86
104
|
ctx.globalAlpha = style.fillOpacity;
|
|
87
105
|
ctx.fillRect(x0, yUpper, x1 - x0, yLower - yUpper);
|
|
88
|
-
|
|
89
|
-
|
|
106
|
+
if (hasBox) {
|
|
107
|
+
ctx.globalAlpha = Math.min(1, style.fillOpacity * 2);
|
|
108
|
+
ctx.fillRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
|
|
109
|
+
}
|
|
90
110
|
ctx.restore();
|
|
91
111
|
}
|
|
92
112
|
else {
|
|
93
|
-
// `whisker` / `none`: the graded q1→q3 box fill + outline.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
113
|
+
// `whisker` / `none`: the graded q1→q3 box fill + outline (body only).
|
|
114
|
+
if (hasBox) {
|
|
115
|
+
ctx.save();
|
|
116
|
+
ctx.fillStyle = style.fill;
|
|
117
|
+
ctx.globalAlpha = style.fillOpacity;
|
|
118
|
+
ctx.fillRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
|
|
119
|
+
ctx.restore();
|
|
120
|
+
ctx.strokeStyle = style.stroke;
|
|
121
|
+
ctx.lineWidth = style.strokeWidth;
|
|
122
|
+
ctx.strokeRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
|
|
123
|
+
}
|
|
102
124
|
if (shape === 'whisker') {
|
|
103
|
-
// Whiskers
|
|
104
|
-
|
|
125
|
+
// Whiskers with end-caps. With a body: two stems (q3→upper, q1→lower).
|
|
126
|
+
// Range-only (no body): one stem spanning the full lower→upper.
|
|
127
|
+
// Cap half-width: an explicit `capWidthPx` (a fixed pixel cap — for
|
|
128
|
+
// pairing offset marks without their T-bars overlapping) else a fraction
|
|
129
|
+
// of the box width (responsive default). Never wider than the box.
|
|
130
|
+
const capHalf = capWidthPx !== undefined
|
|
131
|
+
? Math.min(capWidthPx, x1 - x0) / 2
|
|
132
|
+
: ((x1 - x0) * WHISKER_CAP_FRACTION) / 2;
|
|
105
133
|
ctx.strokeStyle = style.whisker;
|
|
106
134
|
ctx.lineWidth = style.whiskerWidth;
|
|
107
135
|
ctx.beginPath();
|
|
108
|
-
// Upper
|
|
109
|
-
ctx.moveTo(mid, yQ3);
|
|
136
|
+
// Upper stem: from the box top (q3) or, range-only, from lower.
|
|
137
|
+
ctx.moveTo(mid, hasBox ? yQ3 : yLower);
|
|
110
138
|
ctx.lineTo(mid, yUpper);
|
|
111
139
|
ctx.moveTo(mid - capHalf, yUpper);
|
|
112
140
|
ctx.lineTo(mid + capHalf, yUpper);
|
|
113
|
-
// Lower
|
|
114
|
-
|
|
115
|
-
|
|
141
|
+
// Lower cap (and, with a body, the lower stem q1→lower).
|
|
142
|
+
if (hasBox) {
|
|
143
|
+
ctx.moveTo(mid, yQ1);
|
|
144
|
+
ctx.lineTo(mid, yLower);
|
|
145
|
+
}
|
|
116
146
|
ctx.moveTo(mid - capHalf, yLower);
|
|
117
147
|
ctx.lineTo(mid + capHalf, yLower);
|
|
118
148
|
ctx.stroke();
|
|
119
149
|
}
|
|
120
150
|
}
|
|
121
|
-
// The median line across the box, on top —
|
|
122
|
-
|
|
151
|
+
// The median line across the box, on top — drawn only when the box carries a
|
|
152
|
+
// median column and `showMedian` is on.
|
|
153
|
+
if (drawMedian) {
|
|
154
|
+
const yMedian = yScale(box.median[i]);
|
|
123
155
|
ctx.strokeStyle = style.median;
|
|
124
156
|
ctx.lineWidth = style.medianWidth;
|
|
125
157
|
ctx.beginPath();
|
|
@@ -129,12 +161,24 @@ export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx =
|
|
|
129
161
|
}
|
|
130
162
|
}
|
|
131
163
|
}
|
|
132
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* This key is drawable — the quantiles it actually carries are all finite at `i`.
|
|
166
|
+
* `lower`/`upper` (the whisker reach) are always required; `q1`/`q3` only when the
|
|
167
|
+
* box has a body (`hasBox !== false`), `median` only when it has a centre line
|
|
168
|
+
* (`hasMedian !== false`). So a **range-only** box (bid→ask, no body/median) draws
|
|
169
|
+
* wherever `lower`/`upper` are finite, and a full box still needs all five.
|
|
170
|
+
*/
|
|
133
171
|
export function isFiniteBox(box, i) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
Number.isFinite(box.
|
|
172
|
+
if (!Number.isFinite(box.lower[i]) || !Number.isFinite(box.upper[i])) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
if (box.hasBox !== false &&
|
|
176
|
+
(!Number.isFinite(box.q1[i]) || !Number.isFinite(box.q3[i]))) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
if (box.hasMedian !== false && !Number.isFinite(box.median[i])) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
return true;
|
|
139
183
|
}
|
|
140
184
|
//# sourceMappingURL=box.js.map
|
package/dist/context.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ScaleLinear, ScaleTime } from 'd3-scale';
|
|
2
2
|
import type { ChartTheme } from './theme.js';
|
|
3
3
|
import type { AxisFormat } from './format.js';
|
|
4
|
-
import type { Interval
|
|
4
|
+
import type { Interval } from 'pond-ts';
|
|
5
5
|
import type { TradingTimeScale, DiscontinuityProvider } from './tradingTimeScale.js';
|
|
6
6
|
import type { ScaleBand } from './bandScale.js';
|
|
7
7
|
/**
|
|
@@ -75,22 +75,28 @@ export interface ContainerFrame {
|
|
|
75
75
|
*/
|
|
76
76
|
readonly cursorBuckets: readonly Interval[] | undefined;
|
|
77
77
|
/**
|
|
78
|
-
* The `region`-cursor **drag anchor** (epoch ms
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
78
|
+
* The `region`-cursor **drag anchor** in axis units (epoch ms on a time axis,
|
|
79
|
+
* the axis value on a value axis), or `null` when not dragging. A drag on a
|
|
80
|
+
* region cursor (only when {@link onRegionSelect} is set) records the press
|
|
81
|
+
* position here; the band then spans from the anchor's bucket to the pointer's
|
|
82
|
+
* bucket (extending bucket by bucket), or freeform when there are no buckets.
|
|
83
|
+
* Cleared on release.
|
|
82
84
|
*/
|
|
83
85
|
readonly regionAnchor: number | null;
|
|
84
86
|
/** Set / clear the region-drag anchor (see {@link regionAnchor}). */
|
|
85
|
-
setRegionAnchor(
|
|
87
|
+
setRegionAnchor(value: number | null): void;
|
|
86
88
|
/**
|
|
87
89
|
* One-shot callback fired when a `region`-cursor **drag** is released, with the
|
|
88
|
-
* selected `[
|
|
90
|
+
* selected `[lo, hi]` span in **axis units** — epoch ms on a time axis, the axis
|
|
91
|
+
* value on a value axis (snapped to the `cursorSequence` buckets when present,
|
|
92
|
+
* else the raw drag span). The neutral numeric pair mirrors the container's
|
|
93
|
+
* polymorphic `range` input (which never takes the axis *kind* from its value);
|
|
94
|
+
* a time-axis consumer who wants a `TimeRange` constructs one from the pair.
|
|
89
95
|
* Providing it is what makes the region cursor **draggable**; the cursor does
|
|
90
96
|
* not keep the range (it reverts to the single-bucket highlight). Typical use:
|
|
91
|
-
* zoom the view
|
|
97
|
+
* zoom the view, or map the span onto a subscription's range params.
|
|
92
98
|
*/
|
|
93
|
-
readonly onRegionSelect: ((range:
|
|
99
|
+
readonly onRegionSelect: ((range: readonly [number, number]) => void) | undefined;
|
|
94
100
|
/**
|
|
95
101
|
* Require a modifier key held to start a region-drag — set to `'shift'` to make
|
|
96
102
|
* plain drag **pan** and **shift**-drag select, when `panZoom` is on. Only
|
|
@@ -145,6 +151,17 @@ export interface ContainerFrame {
|
|
|
145
151
|
/** Format an epoch-ms instant the same way the time axis labels its ticks —
|
|
146
152
|
* shared by `<TimeAxis>` and the cursor-time readout. */
|
|
147
153
|
readonly formatTime: (epochMs: number) => string;
|
|
154
|
+
/**
|
|
155
|
+
* The shared **x-side tick count** — the `count` every x-side `ticks()` /
|
|
156
|
+
* `tickFormat()` call passes (`<XAxis>` labels, the canvas x gridlines and
|
|
157
|
+
* session dividers, {@link formatTime}), so labels, grid, and dividers all
|
|
158
|
+
* derive from the same instants. A fixed default on a continuous axis;
|
|
159
|
+
* **width-derived on a trading-time axis**, where the count caps how many
|
|
160
|
+
* calendar buckets `coarsenCalendar` may keep — a fixed small count would
|
|
161
|
+
* coarsen any long daily view to year grain (2 ticks) no matter how wide
|
|
162
|
+
* the plot is.
|
|
163
|
+
*/
|
|
164
|
+
readonly xTickCount: number;
|
|
148
165
|
/**
|
|
149
166
|
* Register a draw layer as a tracker source so the container can fan in every
|
|
150
167
|
* series' value at the cursor for `onTrackerChanged`. Keyed by the layer's
|
|
@@ -369,6 +386,17 @@ export interface RowLayer {
|
|
|
369
386
|
* must agree on this list (a mix is an error), the same way {@link xKind} must.
|
|
370
387
|
*/
|
|
371
388
|
xCategories?(): readonly string[] | null;
|
|
389
|
+
/**
|
|
390
|
+
* A bar/histogram layer's bar `[begin, end)` spans, as pond `Interval`s — the
|
|
391
|
+
* **region cursor's snap buckets**. When present (and no `cursorSequence` is
|
|
392
|
+
* set), a region drag snaps bar by bar and a hover highlights the bar under the
|
|
393
|
+
* pointer, so a histogram gets bin-aligned selection for free. Only a
|
|
394
|
+
* **vertical** bar layer on a **continuous** (time / value) x axis publishes
|
|
395
|
+
* them — a horizontal chart puts the value on x (snapping counts is meaningless)
|
|
396
|
+
* and a **category** (ordinal-slot) axis is excluded from the region cursor.
|
|
397
|
+
* `null` / absent otherwise.
|
|
398
|
+
*/
|
|
399
|
+
binIntervals?(): readonly Interval[] | null;
|
|
372
400
|
/**
|
|
373
401
|
* The layer's value(s) at `time` — the nearest sample — for the scrub tracker:
|
|
374
402
|
* one for a line, two (lower/upper) for a band, empty at a gap. Each carries
|
|
@@ -442,6 +470,8 @@ export interface TrackerSource {
|
|
|
442
470
|
xExtent(): readonly [number, number] | null;
|
|
443
471
|
/** A `'category'` source's ordered category names (see {@link RowLayer.xCategories}). */
|
|
444
472
|
xCategories?(): readonly string[] | null;
|
|
473
|
+
/** A bar/histogram source's bar `[begin, end)` spans (see {@link RowLayer.binIntervals}). */
|
|
474
|
+
binIntervals?(): readonly Interval[] | null;
|
|
445
475
|
}
|
|
446
476
|
/**
|
|
447
477
|
* One selection — what {@link RowLayer.hitTest} returns and `onSelect` reports.
|
package/dist/data.d.ts
CHANGED
|
@@ -30,19 +30,30 @@ export interface BandSeries {
|
|
|
30
30
|
readonly length: number;
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
|
-
* A chart-ready view of a box-and-whisker series ({@link BoxPlot}):
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* A key is drawn only where
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* `
|
|
45
|
-
*
|
|
33
|
+
* A chart-ready view of a box-and-whisker series ({@link BoxPlot}): a horizontal
|
|
34
|
+
* span (`x`/`xEnd`) per key plus its quantile edges — the required
|
|
35
|
+
* `lower`/`upper` (whisker reach) and the optional `q1`/`median`/`q3` (box body +
|
|
36
|
+
* centre line). The quantiles are pre-computed columns (a `rolling`/`aggregate`
|
|
37
|
+
* percentile pass upstream); the chart only reads them.
|
|
38
|
+
*
|
|
39
|
+
* A key is drawn only where the quantiles it **carries** are all finite (a full
|
|
40
|
+
* box needs all five; a range-only box just `lower`/`upper`); any present one
|
|
41
|
+
* `NaN` is a gap (the box draws nothing — same gap contract as {@link BandSeries}).
|
|
42
|
+
*
|
|
43
|
+
* `x` and `xEnd` are the box's horizontal span. An **interval**-keyed
|
|
44
|
+
* `TimeSeries` uses the key's own `[begin, end)`; a **point**-keyed `TimeSeries`
|
|
45
|
+
* (or a `ValueSeries`, always point-keyed on its value axis) synthesizes the span
|
|
46
|
+
* from **neighbour spacing** (each box centred on its key, reaching halfway to
|
|
47
|
+
* each neighbour — the same rule as bars / candles), so a point series still gets
|
|
48
|
+
* real box width instead of collapsing to the 1px floor.
|
|
49
|
+
*
|
|
50
|
+
* **Range-only boxes.** `q1`/`median`/`q3` are optional at the source (a bid→ask
|
|
51
|
+
* IV segment is a degenerate box — whiskers only, no body). `hasBox` is `false`
|
|
52
|
+
* when `q1`/`q3` were omitted (no box body; the whisker runs the full
|
|
53
|
+
* `lower→upper`), and `hasMedian` is `false` when `median` was omitted (no centre
|
|
54
|
+
* line). Absent quantile buffers are all-`NaN`; the flags — not the buffers —
|
|
55
|
+
* decide what draws, so an absent quantile isn't confused with a per-row gap.
|
|
56
|
+
* Both default to `true` (a full five-number box) when unset.
|
|
46
57
|
*/
|
|
47
58
|
export interface BoxSeries {
|
|
48
59
|
readonly x: Float64Array;
|
|
@@ -53,6 +64,10 @@ export interface BoxSeries {
|
|
|
53
64
|
readonly q3: Float64Array;
|
|
54
65
|
readonly upper: Float64Array;
|
|
55
66
|
readonly length: number;
|
|
67
|
+
/** `false` ⇒ `q1`/`q3` absent (range-only box, no body). Default `true`. */
|
|
68
|
+
readonly hasBox?: boolean;
|
|
69
|
+
/** `false` ⇒ `median` absent (no centre line). Default `true`. */
|
|
70
|
+
readonly hasMedian?: boolean;
|
|
56
71
|
}
|
|
57
72
|
/**
|
|
58
73
|
* A chart-ready view of an OHLC series ({@link Candlestick}): the candle's
|
|
@@ -131,17 +146,23 @@ export interface StackedBarSeries {
|
|
|
131
146
|
*/
|
|
132
147
|
readonly marks?: readonly string[];
|
|
133
148
|
}
|
|
134
|
-
/**
|
|
149
|
+
/**
|
|
150
|
+
* The quantile column names a {@link boxFromTimeSeries} / {@link boxFromValueSeries}
|
|
151
|
+
* reads. `lower`/`upper` (the whisker reach) are required; `q1`/`q3` (the box
|
|
152
|
+
* body) and `median` (the centre line) are **optional** — omit them for a
|
|
153
|
+
* range-only box (a bid→ask IV segment: whiskers only, no body). Omitting exactly
|
|
154
|
+
* one of `q1`/`q3` is a data error (a box needs both edges or neither).
|
|
155
|
+
*/
|
|
135
156
|
export interface BoxColumns {
|
|
136
|
-
/** Lower whisker end (e.g. `p5` / `min`). */
|
|
157
|
+
/** Lower whisker end (e.g. `p5` / `min`). Required. */
|
|
137
158
|
readonly lower: string;
|
|
138
|
-
/** Box bottom — first quartile (e.g. `p25`). */
|
|
139
|
-
readonly q1
|
|
140
|
-
/** Median line inside the box (e.g. `p50`). */
|
|
141
|
-
readonly median
|
|
142
|
-
/** Box top — third quartile (e.g. `p75`). */
|
|
143
|
-
readonly q3
|
|
144
|
-
/** Upper whisker end (e.g. `p95` / `max`). */
|
|
159
|
+
/** Box bottom — first quartile (e.g. `p25`). Omit with `q3` for a range-only box. */
|
|
160
|
+
readonly q1?: string | undefined;
|
|
161
|
+
/** Median line inside the box (e.g. `p50`). Omit for no centre line. */
|
|
162
|
+
readonly median?: string | undefined;
|
|
163
|
+
/** Box top — third quartile (e.g. `p75`). Omit with `q1` for a range-only box. */
|
|
164
|
+
readonly q3?: string | undefined;
|
|
165
|
+
/** Upper whisker end (e.g. `p95` / `max`). Required. */
|
|
145
166
|
readonly upper: string;
|
|
146
167
|
}
|
|
147
168
|
/** The four OHLC column names {@link ohlcFromTimeSeries} reads. */
|
|
@@ -205,16 +226,38 @@ export declare function bandFromTimeSeries<S extends SeriesSchema>(series: TimeS
|
|
|
205
226
|
*/
|
|
206
227
|
export declare function bandFromValueSeries<VS extends ValueSeriesSchema>(series: ValueSeries<VS>, lower: string, upper: string): BandSeries;
|
|
207
228
|
/**
|
|
208
|
-
* Build a {@link BoxSeries} from a pond `TimeSeries`
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* typically `rolling`/`aggregate` percentiles
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
229
|
+
* Build a {@link BoxSeries} from a pond `TimeSeries`. `lower`/`upper` (the whisker
|
|
230
|
+
* reach) are required; `q1`/`q3` (the box body) and `median` (the centre line)
|
|
231
|
+
* are optional — omit them for a **range-only** box (a bid→ask segment). The
|
|
232
|
+
* quantile columns are typically `rolling`/`aggregate` percentiles; a key with
|
|
233
|
+
* any **present** quantile missing reads as a gap (the box draws nothing).
|
|
234
|
+
*
|
|
235
|
+
* **Key-shape aware, like {@link ohlcFromTimeSeries}.** An **interval /
|
|
236
|
+
* timeRange**-keyed series uses the key's own `[begin, end)` as the box span; a
|
|
237
|
+
* **point**-keyed (`time`) series synthesizes the span from neighbour spacing
|
|
238
|
+
* (each box centred on its timestamp, halfway to each neighbour), so a raw
|
|
239
|
+
* percentile-per-timestamp feed renders as contiguous boxes instead of collapsing
|
|
240
|
+
* to the 1px floor.
|
|
241
|
+
*
|
|
242
|
+
* @throws RangeError if any named quantile column does not exist, or if exactly
|
|
243
|
+
* one of `q1`/`q3` is given.
|
|
244
|
+
* @throws TypeError if any named quantile column is not a numeric column.
|
|
216
245
|
*/
|
|
217
246
|
export declare function boxFromTimeSeries<S extends SeriesSchema>(series: TimeSeries<S>, columns: BoxColumns): BoxSeries;
|
|
247
|
+
/**
|
|
248
|
+
* Build a {@link BoxSeries} from a pond `ValueSeries` — the value-axis sibling of
|
|
249
|
+
* {@link boxFromTimeSeries} (a volatility smile's per-strike bid/ask IV segments,
|
|
250
|
+
* a per-strike intraday IV distribution). A `ValueSeries` is **point-keyed** on
|
|
251
|
+
* its value axis, so the box span comes from **neighbour spacing** on
|
|
252
|
+
* `axisValues()` (each box centred on its axis value, halfway to each neighbour —
|
|
253
|
+
* the same rule as {@link barsFromValueSeries}), instead of collapsing to a point.
|
|
254
|
+
* Same optional-quantile / range-only contract as the time reader.
|
|
255
|
+
*
|
|
256
|
+
* @throws RangeError if any named quantile column does not exist, or if exactly
|
|
257
|
+
* one of `q1`/`q3` is given.
|
|
258
|
+
* @throws TypeError if any named quantile column is not a numeric column.
|
|
259
|
+
*/
|
|
260
|
+
export declare function boxFromValueSeries<VS extends ValueSeriesSchema>(series: ValueSeries<VS>, columns: BoxColumns): BoxSeries;
|
|
218
261
|
/**
|
|
219
262
|
* Build an {@link OhlcSeries} from a pond `TimeSeries` — four numeric price
|
|
220
263
|
* columns (`open`/`high`/`low`/`close`) plus the candle's horizontal slot.
|
package/dist/data.js
CHANGED
|
@@ -65,14 +65,6 @@ function timeAxis(series) {
|
|
|
65
65
|
// it lines up with the value arrays.
|
|
66
66
|
return series.keyColumn().begin.subarray(0, series.length);
|
|
67
67
|
}
|
|
68
|
-
/**
|
|
69
|
-
* The key column's `end` buffer aligned to the logical length (zero-copy). For a
|
|
70
|
-
* point-in-time key the column sets `end === begin`, so this returns the same
|
|
71
|
-
* timestamps as {@link timeAxis} — an interval key gives a distinct span.
|
|
72
|
-
*/
|
|
73
|
-
function timeEndAxis(series) {
|
|
74
|
-
return series.keyColumn().end.subarray(0, series.length);
|
|
75
|
-
}
|
|
76
68
|
/**
|
|
77
69
|
* Build a {@link ChartSeries} from a pond `TimeSeries` by reading its columnar
|
|
78
70
|
* buffers directly — no per-event materialization. `column` names a numeric
|
|
@@ -150,25 +142,91 @@ export function bandFromValueSeries(series, lower, upper) {
|
|
|
150
142
|
};
|
|
151
143
|
}
|
|
152
144
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
145
|
+
* Reject a half-specified box body — `q1`/`q3` are both-or-neither (a box needs
|
|
146
|
+
* two edges or none). Called by both box readers before they build.
|
|
147
|
+
*/
|
|
148
|
+
function validateBoxColumns(columns) {
|
|
149
|
+
if ((columns.q1 === undefined) !== (columns.q3 === undefined)) {
|
|
150
|
+
throw new RangeError(`BoxPlot: 'q1' and 'q3' are both-or-neither — a box body needs both edges ` +
|
|
151
|
+
`(got q1=${columns.q1 ?? 'undefined'}, q3=${columns.q3 ?? 'undefined'}). ` +
|
|
152
|
+
`Omit both for a range-only box (whiskers lower→upper).`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** An all-`NaN` buffer of length `n` — the value channel of an absent quantile. */
|
|
156
|
+
function nanBuffer(n) {
|
|
157
|
+
return new Float64Array(n).fill(NaN);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Build a {@link BoxSeries} from a pond `TimeSeries`. `lower`/`upper` (the whisker
|
|
161
|
+
* reach) are required; `q1`/`q3` (the box body) and `median` (the centre line)
|
|
162
|
+
* are optional — omit them for a **range-only** box (a bid→ask segment). The
|
|
163
|
+
* quantile columns are typically `rolling`/`aggregate` percentiles; a key with
|
|
164
|
+
* any **present** quantile missing reads as a gap (the box draws nothing).
|
|
158
165
|
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
166
|
+
* **Key-shape aware, like {@link ohlcFromTimeSeries}.** An **interval /
|
|
167
|
+
* timeRange**-keyed series uses the key's own `[begin, end)` as the box span; a
|
|
168
|
+
* **point**-keyed (`time`) series synthesizes the span from neighbour spacing
|
|
169
|
+
* (each box centred on its timestamp, halfway to each neighbour), so a raw
|
|
170
|
+
* percentile-per-timestamp feed renders as contiguous boxes instead of collapsing
|
|
171
|
+
* to the 1px floor.
|
|
172
|
+
*
|
|
173
|
+
* @throws RangeError if any named quantile column does not exist, or if exactly
|
|
174
|
+
* one of `q1`/`q3` is given.
|
|
175
|
+
* @throws TypeError if any named quantile column is not a numeric column.
|
|
161
176
|
*/
|
|
162
177
|
export function boxFromTimeSeries(series, columns) {
|
|
178
|
+
validateBoxColumns(columns);
|
|
179
|
+
const n = series.length;
|
|
180
|
+
const hasBox = columns.q1 !== undefined;
|
|
181
|
+
const hasMedian = columns.median !== undefined;
|
|
182
|
+
const { begin, end } = series.keyColumn().kind !== 'time'
|
|
183
|
+
? keyBeginEnd(series) // interval / timeRange: the key's own span
|
|
184
|
+
: neighbourSpans(series.keyColumn().begin, n); // point: neighbour spacing
|
|
163
185
|
return {
|
|
164
|
-
x:
|
|
165
|
-
xEnd:
|
|
186
|
+
x: begin,
|
|
187
|
+
xEnd: end,
|
|
166
188
|
lower: readNumericColumn(series, columns.lower),
|
|
167
|
-
q1: readNumericColumn(series, columns.q1),
|
|
168
|
-
median:
|
|
169
|
-
|
|
189
|
+
q1: hasBox ? readNumericColumn(series, columns.q1) : nanBuffer(n),
|
|
190
|
+
median: hasMedian
|
|
191
|
+
? readNumericColumn(series, columns.median)
|
|
192
|
+
: nanBuffer(n),
|
|
193
|
+
q3: hasBox ? readNumericColumn(series, columns.q3) : nanBuffer(n),
|
|
170
194
|
upper: readNumericColumn(series, columns.upper),
|
|
171
|
-
length:
|
|
195
|
+
length: n,
|
|
196
|
+
hasBox,
|
|
197
|
+
hasMedian,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Build a {@link BoxSeries} from a pond `ValueSeries` — the value-axis sibling of
|
|
202
|
+
* {@link boxFromTimeSeries} (a volatility smile's per-strike bid/ask IV segments,
|
|
203
|
+
* a per-strike intraday IV distribution). A `ValueSeries` is **point-keyed** on
|
|
204
|
+
* its value axis, so the box span comes from **neighbour spacing** on
|
|
205
|
+
* `axisValues()` (each box centred on its axis value, halfway to each neighbour —
|
|
206
|
+
* the same rule as {@link barsFromValueSeries}), instead of collapsing to a point.
|
|
207
|
+
* Same optional-quantile / range-only contract as the time reader.
|
|
208
|
+
*
|
|
209
|
+
* @throws RangeError if any named quantile column does not exist, or if exactly
|
|
210
|
+
* one of `q1`/`q3` is given.
|
|
211
|
+
* @throws TypeError if any named quantile column is not a numeric column.
|
|
212
|
+
*/
|
|
213
|
+
export function boxFromValueSeries(series, columns) {
|
|
214
|
+
validateBoxColumns(columns);
|
|
215
|
+
const n = series.length;
|
|
216
|
+
const hasBox = columns.q1 !== undefined;
|
|
217
|
+
const hasMedian = columns.median !== undefined;
|
|
218
|
+
const { begin, end } = neighbourSpans(series.axisValues(), n);
|
|
219
|
+
return {
|
|
220
|
+
x: begin,
|
|
221
|
+
xEnd: end,
|
|
222
|
+
lower: readValueColumn(series, columns.lower),
|
|
223
|
+
q1: hasBox ? readValueColumn(series, columns.q1) : nanBuffer(n),
|
|
224
|
+
median: hasMedian ? readValueColumn(series, columns.median) : nanBuffer(n),
|
|
225
|
+
q3: hasBox ? readValueColumn(series, columns.q3) : nanBuffer(n),
|
|
226
|
+
upper: readValueColumn(series, columns.upper),
|
|
227
|
+
length: n,
|
|
228
|
+
hasBox,
|
|
229
|
+
hasMedian,
|
|
172
230
|
};
|
|
173
231
|
}
|
|
174
232
|
/**
|
package/dist/format.d.ts
CHANGED
|
@@ -42,8 +42,10 @@ interface TimeTickable {
|
|
|
42
42
|
* - a **function** → used as-is (called with epoch ms);
|
|
43
43
|
* - a **specifier string** → `scale.tickFormat(count, specifier)` (one format for
|
|
44
44
|
* every value), wrapped to take epoch ms;
|
|
45
|
-
* - **`undefined`** → `scale.tickFormat()` —
|
|
46
|
-
*
|
|
45
|
+
* - **`undefined`** → `scale.tickFormat(count)` — the scale's default. On a d3
|
|
46
|
+
* `scaleTime` that is the **multi-scale** time format (which ignores `count`);
|
|
47
|
+
* a trading-time scale picks its **anchor grain** from `count`, so passing the
|
|
48
|
+
* axis's count here is what keeps the labels on the same grain as the ticks.
|
|
47
49
|
*
|
|
48
50
|
* The cursor time is epoch ms, so the resolved formatter wraps the d3 `Date`
|
|
49
51
|
* formatter in `new Date(ms)`.
|
package/dist/format.js
CHANGED
|
@@ -32,8 +32,10 @@ export function resolveAxisFormat(scale, count, format) {
|
|
|
32
32
|
* - a **function** → used as-is (called with epoch ms);
|
|
33
33
|
* - a **specifier string** → `scale.tickFormat(count, specifier)` (one format for
|
|
34
34
|
* every value), wrapped to take epoch ms;
|
|
35
|
-
* - **`undefined`** → `scale.tickFormat()` —
|
|
36
|
-
*
|
|
35
|
+
* - **`undefined`** → `scale.tickFormat(count)` — the scale's default. On a d3
|
|
36
|
+
* `scaleTime` that is the **multi-scale** time format (which ignores `count`);
|
|
37
|
+
* a trading-time scale picks its **anchor grain** from `count`, so passing the
|
|
38
|
+
* axis's count here is what keeps the labels on the same grain as the ticks.
|
|
37
39
|
*
|
|
38
40
|
* The cursor time is epoch ms, so the resolved formatter wraps the d3 `Date`
|
|
39
41
|
* formatter in `new Date(ms)`.
|
|
@@ -41,7 +43,9 @@ export function resolveAxisFormat(scale, count, format) {
|
|
|
41
43
|
export function resolveTimeFormat(scale, count, format) {
|
|
42
44
|
if (typeof format === 'function')
|
|
43
45
|
return format;
|
|
44
|
-
const tf = format !== undefined
|
|
46
|
+
const tf = format !== undefined
|
|
47
|
+
? scale.tickFormat(count, format)
|
|
48
|
+
: scale.tickFormat(count);
|
|
45
49
|
return (ms) => tf(new Date(ms));
|
|
46
50
|
}
|
|
47
51
|
//# sourceMappingURL=format.js.map
|
package/dist/scatter.d.ts
CHANGED
|
@@ -52,7 +52,7 @@ export declare function scatterExtent(cs: ChartSeries): [number, number] | null;
|
|
|
52
52
|
export declare function drawScatter(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: ScatterStyle, encoding: ResolvedEncoding, keyAt: (i: number) => number, labelAt: ((i: number) => string | undefined) | undefined, font: {
|
|
53
53
|
readonly family: string;
|
|
54
54
|
readonly size: number;
|
|
55
|
-
}, selected: SelectInfo | null, seriesId: string | undefined): void;
|
|
55
|
+
}, selected: SelectInfo | null, seriesId: string | undefined, offsetPx?: number): void;
|
|
56
56
|
/**
|
|
57
57
|
* Hit-test plot-pixel `(qx, qy)` against the scatter's points — the topmost
|
|
58
58
|
* point whose circle contains the click, or `null`. "Topmost" = the
|
|
@@ -68,5 +68,5 @@ export declare function drawScatter(ctx: CanvasRenderingContext2D, cs: ChartSeri
|
|
|
68
68
|
* Pure: takes the same `xScale`/`yScale` the row hands to `draw`, so it
|
|
69
69
|
* unit-tests without a DOM (mirrors the `sampleAt` / `resolveSelection` split).
|
|
70
70
|
*/
|
|
71
|
-
export declare function hitTestScatter(cs: ChartSeries, qx: number, qy: number, xScale: Scale, yScale: Scale, encoding: ResolvedEncoding, keyAt: (i: number) => number, id: string, seriesLabel: string): SelectInfo | null;
|
|
71
|
+
export declare function hitTestScatter(cs: ChartSeries, qx: number, qy: number, xScale: Scale, yScale: Scale, encoding: ResolvedEncoding, keyAt: (i: number) => number, id: string, seriesLabel: string, offsetPx?: number): SelectInfo | null;
|
|
72
72
|
//# sourceMappingURL=scatter.d.ts.map
|
package/dist/scatter.js
CHANGED
|
@@ -114,7 +114,7 @@ export function scatterExtent(cs) {
|
|
|
114
114
|
* of the selection match. A point lights only when the selection's
|
|
115
115
|
* `id` matches, keyed to the sample by its `key`.
|
|
116
116
|
*/
|
|
117
|
-
export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesId) {
|
|
117
|
+
export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesId, offsetPx = 0) {
|
|
118
118
|
ctx.save();
|
|
119
119
|
// The selection only lights up a point of *this* series; resolve the key once.
|
|
120
120
|
// A no-id (non-selectable) layer passes `undefined` and never matches.
|
|
@@ -126,7 +126,9 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
|
|
|
126
126
|
for (let i = 0; i < cs.length; i += 1) {
|
|
127
127
|
if (!isPoint(cs, i))
|
|
128
128
|
continue;
|
|
129
|
-
|
|
129
|
+
// `offsetPx` nudges the whole scatter in pixel space (zoom-stable) — for
|
|
130
|
+
// pairing same-key marks (call/put at one strike) beside each other.
|
|
131
|
+
const px = xScale(cs.x[i]) + offsetPx;
|
|
130
132
|
const py = yScale(cs.y[i]);
|
|
131
133
|
const r = encoding.radiusAt(i);
|
|
132
134
|
ctx.beginPath();
|
|
@@ -166,7 +168,7 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
|
|
|
166
168
|
const text = labelAt(i);
|
|
167
169
|
if (text === undefined || text === '')
|
|
168
170
|
continue;
|
|
169
|
-
const px = xScale(cs.x[i]);
|
|
171
|
+
const px = xScale(cs.x[i]) + offsetPx;
|
|
170
172
|
const py = yScale(cs.y[i]);
|
|
171
173
|
const r = encoding.radiusAt(i);
|
|
172
174
|
// Sit the label just right of the point (past its radius), vertically
|
|
@@ -193,11 +195,12 @@ const LABEL_GAP = 4;
|
|
|
193
195
|
* Pure: takes the same `xScale`/`yScale` the row hands to `draw`, so it
|
|
194
196
|
* unit-tests without a DOM (mirrors the `sampleAt` / `resolveSelection` split).
|
|
195
197
|
*/
|
|
196
|
-
export function hitTestScatter(cs, qx, qy, xScale, yScale, encoding, keyAt, id, seriesLabel) {
|
|
198
|
+
export function hitTestScatter(cs, qx, qy, xScale, yScale, encoding, keyAt, id, seriesLabel, offsetPx = 0) {
|
|
197
199
|
for (let i = cs.length - 1; i >= 0; i -= 1) {
|
|
198
200
|
if (!isPoint(cs, i))
|
|
199
201
|
continue;
|
|
200
|
-
|
|
202
|
+
// Match the drawn position (offset in px space) so the click target aligns.
|
|
203
|
+
const px = xScale(cs.x[i]) + offsetPx;
|
|
201
204
|
const py = yScale(cs.y[i]);
|
|
202
205
|
const r = encoding.radiusAt(i);
|
|
203
206
|
const dx = qx - px;
|