@pond-ts/charts 0.43.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 +82 -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 +44 -21
- package/dist/Layers.js +19 -15
- package/dist/ScatterChart.d.ts +33 -6
- package/dist/ScatterChart.js +36 -16
- package/dist/box.d.ts +26 -9
- package/dist/box.js +86 -42
- package/dist/context.d.ts +28 -9
- package/dist/data.d.ts +73 -30
- package/dist/data.js +79 -21
- package/dist/scatter.d.ts +2 -2
- package/dist/scatter.js +8 -5
- package/package.json +3 -3
package/dist/Layers.js
CHANGED
|
@@ -2,7 +2,6 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { Children, cloneElement, isValidElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
3
3
|
import { Canvas } from './Canvas.js';
|
|
4
4
|
import { drawGrid, drawDividers, thinPixels } from './grid.js';
|
|
5
|
-
import { TimeRange } from 'pond-ts';
|
|
6
5
|
import { cursorParts, bandRect, regionSpan } from './tracker.js';
|
|
7
6
|
import { resolveSelection } from './select.js';
|
|
8
7
|
import { panRange, zoomRange, panRangeTrading, zoomRangeTrading, } from './viewport.js';
|
|
@@ -260,13 +259,17 @@ export function Layers({ children }) {
|
|
|
260
259
|
}
|
|
261
260
|
return;
|
|
262
261
|
}
|
|
263
|
-
// Region-cursor drag-select (opt-in via `onRegionSelect
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
262
|
+
// Region-cursor drag-select (opt-in via `onRegionSelect`): anchor the
|
|
263
|
+
// selection at the press; the band then extends as the pointer moves (bucket
|
|
264
|
+
// by bucket with a sequence, freeform without), and release commits the span.
|
|
265
|
+
// Works on a continuous x axis — time **or** value (a category axis is
|
|
266
|
+
// excluded; its ordinal-slot select is a different gesture). A
|
|
267
|
+
// `regionSelectModifier` (only while `panZoom` is on) gates it behind the key
|
|
268
|
+
// so plain drag can still pan; otherwise it preempts pan (returns before the
|
|
269
|
+
// pan is armed below).
|
|
270
|
+
if (c.cursor === 'region' &&
|
|
271
|
+
c.onRegionSelect &&
|
|
272
|
+
(c.xKind === 'time' || c.xKind === 'value')) {
|
|
270
273
|
const needsShift = c.regionSelectModifier === 'shift' && c.panZoom;
|
|
271
274
|
if (!needsShift || e.shiftKey) {
|
|
272
275
|
const px = Math.max(0, Math.min(c.plotWidth, e.clientX - e.currentTarget.getBoundingClientRect().left));
|
|
@@ -417,7 +420,7 @@ export function Layers({ children }) {
|
|
|
417
420
|
/* ignore */
|
|
418
421
|
}
|
|
419
422
|
if (span)
|
|
420
|
-
c.onRegionSelect?.(
|
|
423
|
+
c.onRegionSelect?.([span.start, span.end]);
|
|
421
424
|
return;
|
|
422
425
|
}
|
|
423
426
|
if (c.creating !== null) {
|
|
@@ -623,12 +626,13 @@ export function Layers({ children }) {
|
|
|
623
626
|
side: axisSides.get(defaultAxisId) ?? 'left',
|
|
624
627
|
};
|
|
625
628
|
})();
|
|
626
|
-
// `region` cursor (
|
|
627
|
-
// `cursorSequence` the band snaps to the bucket
|
|
628
|
-
// under a drag); with none
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
|
|
629
|
+
// `region` cursor (continuous x axis — time or value): shade the span under the
|
|
630
|
+
// pointer. With a `cursorSequence` (time axis only) the band snaps to the bucket
|
|
631
|
+
// (and extends bucket by bucket under a drag); with none — always the case on a
|
|
632
|
+
// value axis — it's the **freeform** case: a bare hover draws a plain line
|
|
633
|
+
// (`regionLine`), a drag shades the raw `[anchor, pointer]`. Edges map through
|
|
634
|
+
// `xScale`, so on a trading-time axis the band crops to live time.
|
|
635
|
+
const regionActive = parts.band && (container.xKind === 'time' || container.xKind === 'value');
|
|
632
636
|
const band = regionActive && cursorTime !== null
|
|
633
637
|
? bandRect(container.cursorBuckets ?? [], cursorTime, (v) => xScale(v), plotWidth, container.regionAnchor ?? undefined)
|
|
634
638
|
: null;
|
package/dist/ScatterChart.d.ts
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { ValueSeries } from 'pond-ts';
|
|
2
|
+
import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
|
|
2
3
|
import { type ColorEncoding, type RadiusEncoding } from './encoding.js';
|
|
3
|
-
export interface ScatterChartProps<S extends SeriesSchema> {
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
|
|
5
|
+
/**
|
|
6
|
+
* The source series. A `TimeSeries` scatters against the time axis; a
|
|
7
|
+
* `ValueSeries` (`series.byValue('cumDist')`, or `ValueSeries.fromColumns`
|
|
8
|
+
* for natively value-keyed data — IV marks keyed by strike) against its
|
|
9
|
+
* value axis — the container infers which from the data, no axis-type prop
|
|
10
|
+
* (mirrors `<LineChart>`). Either way the key / axis column supplies each
|
|
11
|
+
* point's x and `column` supplies y.
|
|
12
|
+
*
|
|
13
|
+
* **Live charts:** `series.byValue(…)` mints a *fresh* projection each call,
|
|
14
|
+
* so passing `series={s.byValue('dist')}` inline re-registers this layer
|
|
15
|
+
* every render — memoize the projection (`useMemo`) on a frequently
|
|
16
|
+
* re-rendering chart.
|
|
17
|
+
*/
|
|
18
|
+
series: TimeSeries<S> | ValueSeries<VS>;
|
|
6
19
|
/** Name of the numeric value column — each point's y. */
|
|
7
20
|
column: string;
|
|
8
21
|
/**
|
|
@@ -24,6 +37,11 @@ export interface ScatterChartProps<S extends SeriesSchema> {
|
|
|
24
37
|
* the key the controlled `selected` echo, dedup, and (later) multi-select all
|
|
25
38
|
* match on — so a selection survives a data update where a sample `key` goes
|
|
26
39
|
* stale.
|
|
40
|
+
*
|
|
41
|
+
* A point's identity within the series is its **x** (key / axis value). The
|
|
42
|
+
* key contract allows duplicate x's (equal timestamps; a value-axis plateau
|
|
43
|
+
* from `byValue('cumDist')`) — points sharing an x share identity, so
|
|
44
|
+
* selecting one highlights the last drawn point at that x.
|
|
27
45
|
*/
|
|
28
46
|
id?: string;
|
|
29
47
|
/**
|
|
@@ -59,6 +77,14 @@ export interface ScatterChartProps<S extends SeriesSchema> {
|
|
|
59
77
|
* dense scatter is noise; this is for a handful of called-out marks.
|
|
60
78
|
*/
|
|
61
79
|
label?: string | boolean;
|
|
80
|
+
/**
|
|
81
|
+
* A **pixel** shift applied to every point's x — zoom-stable. **Default `0`.**
|
|
82
|
+
* For pairing marks that share a key side by side (a call and a put mark at one
|
|
83
|
+
* strike: `offset={-4}` / `offset={+4}`). Pairs with `<BoxPlot offset>`; on the
|
|
84
|
+
* scatter the shift is exact — both the draw and the click hit-test move
|
|
85
|
+
* together, so a nudged point still selects.
|
|
86
|
+
*/
|
|
87
|
+
offset?: number;
|
|
62
88
|
/**
|
|
63
89
|
* @internal Declaration position among the `<Layers>` children, injected by
|
|
64
90
|
* `Layers` so z-order follows JSX order. Do not set.
|
|
@@ -66,7 +92,8 @@ export interface ScatterChartProps<S extends SeriesSchema> {
|
|
|
66
92
|
index?: number;
|
|
67
93
|
}
|
|
68
94
|
/**
|
|
69
|
-
* A scatter draw layer: one mark per finite point at `(
|
|
95
|
+
* A scatter draw layer: one mark per finite point at `(x, column-value)`
|
|
96
|
+
* — x from the series' key / axis column (time or value axis) —
|
|
70
97
|
* with **data-driven radius + colour** (the signed-off exception — encode from
|
|
71
98
|
* columns via scales, not a per-event style callback). Reads `column` into a
|
|
72
99
|
* {@link ChartSeries} (gaps as NaN → no mark), registers into the enclosing
|
|
@@ -92,5 +119,5 @@ export interface ScatterChartProps<S extends SeriesSchema> {
|
|
|
92
119
|
* </Layers>
|
|
93
120
|
* ```
|
|
94
121
|
*/
|
|
95
|
-
export declare function ScatterChart<S extends SeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, index, }: ScatterChartProps<S>): null;
|
|
122
|
+
export declare function ScatterChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, offset, index, }: ScatterChartProps<S, VS>): null;
|
|
96
123
|
//# sourceMappingURL=ScatterChart.d.ts.map
|
package/dist/ScatterChart.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { useContext, useEffect, useMemo } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { ValueSeries } from 'pond-ts';
|
|
3
|
+
import { fromTimeSeries, fromValueSeries } from './data.js';
|
|
3
4
|
import { drawScatter, hitTestScatter, nearestIndex, scatterExtent, } from './scatter.js';
|
|
4
5
|
import { resolveEncoding, } from './encoding.js';
|
|
5
6
|
import { ContainerContext, LayersContext } from './context.js';
|
|
6
7
|
import { useSlotKey } from './use-slot-key.js';
|
|
7
8
|
/**
|
|
8
|
-
* A scatter draw layer: one mark per finite point at `(
|
|
9
|
+
* A scatter draw layer: one mark per finite point at `(x, column-value)`
|
|
10
|
+
* — x from the series' key / axis column (time or value axis) —
|
|
9
11
|
* with **data-driven radius + colour** (the signed-off exception — encode from
|
|
10
12
|
* columns via scales, not a per-event style callback). Reads `column` into a
|
|
11
13
|
* {@link ChartSeries} (gaps as NaN → no mark), registers into the enclosing
|
|
@@ -31,7 +33,7 @@ import { useSlotKey } from './use-slot-key.js';
|
|
|
31
33
|
* </Layers>
|
|
32
34
|
* ```
|
|
33
35
|
*/
|
|
34
|
-
export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, index = 0, }) {
|
|
36
|
+
export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, offset = 0, index = 0, }) {
|
|
35
37
|
const container = useContext(ContainerContext);
|
|
36
38
|
if (container === null) {
|
|
37
39
|
throw new Error('<ScatterChart> must be rendered inside a <ChartContainer>');
|
|
@@ -40,7 +42,9 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
|
|
|
40
42
|
if (layers === null) {
|
|
41
43
|
throw new Error('<ScatterChart> must be rendered inside a <Layers>');
|
|
42
44
|
}
|
|
43
|
-
const cs = useMemo(() =>
|
|
45
|
+
const cs = useMemo(() => series instanceof ValueSeries
|
|
46
|
+
? fromValueSeries(series, column)
|
|
47
|
+
: fromTimeSeries(series, column), [series, column]);
|
|
44
48
|
// Styling: semantic identifier → theme scatter style. The single styling
|
|
45
49
|
// channel for the base mark.
|
|
46
50
|
const { scatter } = container.theme;
|
|
@@ -53,13 +57,26 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
|
|
|
53
57
|
// pulls a named numeric column to a Float64Array (gaps NaN) — the same path
|
|
54
58
|
// fromTimeSeries uses; an unknown / non-numeric column throws there (eager,
|
|
55
59
|
// so a typo surfaces at render, not silently as base-styled points).
|
|
56
|
-
const encoding = useMemo(() => resolveEncoding(cs, style.radius, style.color, radius, color, (col) =>
|
|
60
|
+
const encoding = useMemo(() => resolveEncoding(cs, style.radius, style.color, radius, color, (col) => series instanceof ValueSeries
|
|
61
|
+
? fromValueSeries(series, col).y
|
|
62
|
+
: fromTimeSeries(series, col).y), [cs, style.radius, style.color, radius, color, series]);
|
|
57
63
|
// Per-point label accessor: a column name reads that field, `true` reads the
|
|
58
64
|
// plotted column, anything else (false / omitted) ⇒ no labels.
|
|
59
65
|
const labelAt = useMemo(() => {
|
|
60
66
|
if (label === undefined || label === false)
|
|
61
67
|
return undefined;
|
|
62
68
|
const field = label === true ? column : label;
|
|
69
|
+
if (series instanceof ValueSeries) {
|
|
70
|
+
// Columnar read — a ValueSeries has no per-row events. The field is a
|
|
71
|
+
// runtime string, cast onto the schema-literal column name (the same
|
|
72
|
+
// pattern as data.ts' readValueColumn). A gap or an unknown column reads
|
|
73
|
+
// undefined => no label at that point.
|
|
74
|
+
const col = series.column(field);
|
|
75
|
+
return (i) => {
|
|
76
|
+
const v = col?.read(i);
|
|
77
|
+
return v === undefined || v === null ? undefined : String(v);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
63
80
|
return (i) => {
|
|
64
81
|
// series.at(i) is O(1) per row (columnar eventAt cache), so a label per
|
|
65
82
|
// point stays cheap. The field is a runtime string → cast off the literal-
|
|
@@ -71,26 +88,28 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
|
|
|
71
88
|
return v === undefined || v === null ? undefined : String(v);
|
|
72
89
|
};
|
|
73
90
|
}, [label, column, series]);
|
|
74
|
-
// The point's stable key is its event begin (epoch ms)
|
|
75
|
-
//
|
|
91
|
+
// The point's stable key is its x — the event begin (epoch ms) on a time
|
|
92
|
+
// axis, the axis value on a value axis; either way it's cs.x[i], the key
|
|
93
|
+
// column's begin buffer. Used for selection identity.
|
|
76
94
|
const keyAt = useMemo(() => (i) => cs.x[i], [cs]);
|
|
77
95
|
const entry = useMemo(() => ({
|
|
78
96
|
layer: {
|
|
79
97
|
yExtent: () => scatterExtent(cs),
|
|
80
|
-
|
|
98
|
+
// The container infers the shared x scale's kind + auto-fit domain from
|
|
99
|
+
// its layers: a ValueSeries scatters on a value axis, a TimeSeries on time.
|
|
100
|
+
xKind: series instanceof ValueSeries ? 'value' : 'time',
|
|
81
101
|
xExtent: () => cs.length === 0 ? null : [cs.x[0], cs.x[cs.length - 1]],
|
|
82
|
-
sampleAt: (
|
|
102
|
+
sampleAt: (x) => {
|
|
83
103
|
// No readout past the data (tracker policy — the dot snaps to a drawn
|
|
84
|
-
// mark, never extrapolates past the span); bounds from the
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
time > cs.x[cs.length - 1]) {
|
|
104
|
+
// mark, never extrapolates past the span); bounds from the columnar x
|
|
105
|
+
// axis (epoch ms or axis value — the bisect doesn't care).
|
|
106
|
+
if (cs.length === 0 || x < cs.x[0] || x > cs.x[cs.length - 1]) {
|
|
88
107
|
return [];
|
|
89
108
|
}
|
|
90
109
|
// Nearest *drawn* point by index (skips gaps) — O(log N). Reading by
|
|
91
110
|
// index gives the value, the snap-to x, and the encoded colour in one
|
|
92
111
|
// shot, so the readout swatch matches the mark the user sees.
|
|
93
|
-
const i = nearestIndex(cs,
|
|
112
|
+
const i = nearestIndex(cs, x);
|
|
94
113
|
if (i < 0)
|
|
95
114
|
return [];
|
|
96
115
|
return [
|
|
@@ -108,9 +127,9 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
|
|
|
108
127
|
...(id === undefined
|
|
109
128
|
? {}
|
|
110
129
|
: {
|
|
111
|
-
hitTest: (px, py, xScale, yScale) => hitTestScatter(cs, px, py, xScale, yScale, encoding, keyAt, id, seriesLabel),
|
|
130
|
+
hitTest: (px, py, xScale, yScale) => hitTestScatter(cs, px, py, xScale, yScale, encoding, keyAt, id, seriesLabel, offset),
|
|
112
131
|
}),
|
|
113
|
-
draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id),
|
|
132
|
+
draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id, offset),
|
|
114
133
|
},
|
|
115
134
|
axisId: axis,
|
|
116
135
|
index,
|
|
@@ -126,6 +145,7 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
|
|
|
126
145
|
labelAt,
|
|
127
146
|
font,
|
|
128
147
|
container.selected,
|
|
148
|
+
offset,
|
|
129
149
|
axis,
|
|
130
150
|
index,
|
|
131
151
|
]);
|
package/dist/box.d.ts
CHANGED
|
@@ -3,9 +3,10 @@ import type { Scale } from './line.js';
|
|
|
3
3
|
import type { BoxStyle } from './theme.js';
|
|
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
|
|
@@ -44,16 +45,32 @@ export type BoxShape = 'whisker' | 'solid' | 'none';
|
|
|
44
45
|
* reads darker on a light ground, brighter on a dark one), no stems/outline.
|
|
45
46
|
* - **`none`** — the `q1→q3` box fill + outline only, no spread marks.
|
|
46
47
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
48
|
+
* **Range-only** (`box.hasBox === false` — no `q1`/`q3`): there's no body, so
|
|
49
|
+
* `whisker` draws **one** full `lower→upper` stem with caps, `solid` draws just
|
|
50
|
+
* the outer bar, and `none` draws **nothing** (no body + no spread ⇒ empty — pick
|
|
51
|
+
* `whisker`/`solid` for a range-only box). `showMedian` is a no-op when the box
|
|
52
|
+
* carries no `median` (`hasMedian === false`).
|
|
49
53
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
54
|
+
* Then, if `showMedian` (and a median is present), the median line on top. Fills
|
|
55
|
+
* are bracketed by `save`/`restore` so their `globalAlpha` doesn't leak.
|
|
56
|
+
* `offsetPx` shifts every mark in pixel space (for pairing same-key marks);
|
|
57
|
+
* `capWidthPx` sets a fixed whisker-cap width (else half the box width — a small
|
|
58
|
+
* fixed cap keeps paired offset marks' T-bars from overlapping), clamped to the
|
|
59
|
+
* box width.
|
|
60
|
+
*
|
|
61
|
+
* **Gap-aware**: a key whose present quantiles aren't all finite is skipped
|
|
62
|
+
* entirely (no partial box) — the same contract as a band gap.
|
|
52
63
|
*
|
|
53
64
|
* O(N) over the keys, a fixed number of path ops each — no per-key allocation
|
|
54
65
|
* beyond the `barSpanPx` tuple.
|
|
55
66
|
*/
|
|
56
|
-
export declare function drawBox(ctx: CanvasRenderingContext2D, box: BoxSeries, xScale: Scale, yScale: Scale, style: BoxStyle, gapPx?: number, minWidthPx?: number, shape?: BoxShape, showMedian?: boolean): void;
|
|
57
|
-
/**
|
|
67
|
+
export declare function drawBox(ctx: CanvasRenderingContext2D, box: BoxSeries, xScale: Scale, yScale: Scale, style: BoxStyle, gapPx?: number, minWidthPx?: number, shape?: BoxShape, showMedian?: boolean, offsetPx?: number, capWidthPx?: number): void;
|
|
68
|
+
/**
|
|
69
|
+
* This key is drawable — the quantiles it actually carries are all finite at `i`.
|
|
70
|
+
* `lower`/`upper` (the whisker reach) are always required; `q1`/`q3` only when the
|
|
71
|
+
* box has a body (`hasBox !== false`), `median` only when it has a centre line
|
|
72
|
+
* (`hasMedian !== false`). So a **range-only** box (bid→ask, no body/median) draws
|
|
73
|
+
* wherever `lower`/`upper` are finite, and a full box still needs all five.
|
|
74
|
+
*/
|
|
58
75
|
export declare function isFiniteBox(box: BoxSeries, i: number): boolean;
|
|
59
76
|
//# sourceMappingURL=box.d.ts.map
|
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
|
|
@@ -369,6 +375,17 @@ export interface RowLayer {
|
|
|
369
375
|
* must agree on this list (a mix is an error), the same way {@link xKind} must.
|
|
370
376
|
*/
|
|
371
377
|
xCategories?(): readonly string[] | null;
|
|
378
|
+
/**
|
|
379
|
+
* A bar/histogram layer's bar `[begin, end)` spans, as pond `Interval`s — the
|
|
380
|
+
* **region cursor's snap buckets**. When present (and no `cursorSequence` is
|
|
381
|
+
* set), a region drag snaps bar by bar and a hover highlights the bar under the
|
|
382
|
+
* pointer, so a histogram gets bin-aligned selection for free. Only a
|
|
383
|
+
* **vertical** bar layer on a **continuous** (time / value) x axis publishes
|
|
384
|
+
* them — a horizontal chart puts the value on x (snapping counts is meaningless)
|
|
385
|
+
* and a **category** (ordinal-slot) axis is excluded from the region cursor.
|
|
386
|
+
* `null` / absent otherwise.
|
|
387
|
+
*/
|
|
388
|
+
binIntervals?(): readonly Interval[] | null;
|
|
372
389
|
/**
|
|
373
390
|
* The layer's value(s) at `time` — the nearest sample — for the scrub tracker:
|
|
374
391
|
* one for a line, two (lower/upper) for a band, empty at a gap. Each carries
|
|
@@ -442,6 +459,8 @@ export interface TrackerSource {
|
|
|
442
459
|
xExtent(): readonly [number, number] | null;
|
|
443
460
|
/** A `'category'` source's ordered category names (see {@link RowLayer.xCategories}). */
|
|
444
461
|
xCategories?(): readonly string[] | null;
|
|
462
|
+
/** A bar/histogram source's bar `[begin, end)` spans (see {@link RowLayer.binIntervals}). */
|
|
463
|
+
binIntervals?(): readonly Interval[] | null;
|
|
445
464
|
}
|
|
446
465
|
/**
|
|
447
466
|
* One selection — what {@link RowLayer.hitTest} returns and `onSelect` reports.
|