@pond-ts/charts 0.42.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +164 -1
- package/dist/BarChart.d.ts +24 -2
- package/dist/BarChart.js +106 -23
- package/dist/BoxPlot.d.ts +80 -28
- package/dist/BoxPlot.js +67 -40
- package/dist/CategoryAxis.d.ts +16 -0
- package/dist/CategoryAxis.js +19 -0
- package/dist/ChartContainer.d.ts +56 -1
- package/dist/ChartContainer.js +101 -3
- package/dist/Layers.js +77 -5
- package/dist/ScatterChart.d.ts +33 -6
- package/dist/ScatterChart.js +36 -16
- package/dist/XAxis.js +41 -2
- package/dist/annotations.d.ts +38 -1
- package/dist/annotations.js +68 -25
- package/dist/bandScale.d.ts +57 -0
- package/dist/bandScale.js +67 -0
- package/dist/bars.d.ts +23 -6
- package/dist/bars.js +47 -15
- package/dist/box.d.ts +26 -9
- package/dist/box.js +86 -42
- package/dist/context.d.ts +88 -13
- package/dist/data.d.ts +145 -30
- package/dist/data.js +146 -21
- package/dist/index.d.ts +5 -2
- package/dist/index.js +8 -1
- package/dist/scatter.d.ts +2 -2
- package/dist/scatter.js +8 -5
- package/dist/tracker.d.ts +37 -0
- package/dist/tracker.js +77 -6
- package/package.json +3 -3
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
|
]);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type XAxisProps } from './XAxis.js';
|
|
2
|
+
/**
|
|
3
|
+
* The category-flavoured preset of {@link XAxis} — `<CategoryAxis />` is
|
|
4
|
+
* `<XAxis />`. The axis kind follows the data: on a **category** container (a
|
|
5
|
+
* layer that plots on the ordinal column-domain axis) it ticks once per category,
|
|
6
|
+
* labelling each band centre with the category name (the container's shared
|
|
7
|
+
* formatter). Kept as the familiar name for categorical charts, mirroring
|
|
8
|
+
* {@link TimeAxis}. Forwards every {@link XAxisProps} (`label`, `side`, …).
|
|
9
|
+
*
|
|
10
|
+
* A high-cardinality axis (many categories) thins + truncates its labels to stay
|
|
11
|
+
* legible (categorical-axis RFC, Phase 1). The labels **come from the data** (the
|
|
12
|
+
* `categories` list), so a d3 `format` prop does not apply here (it can't name a
|
|
13
|
+
* category); customize a label by changing the `categories` datum's `label`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function CategoryAxis(props?: XAxisProps): import("react/jsx-runtime").JSX.Element;
|
|
16
|
+
//# sourceMappingURL=CategoryAxis.d.ts.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { XAxis } from './XAxis.js';
|
|
3
|
+
/**
|
|
4
|
+
* The category-flavoured preset of {@link XAxis} — `<CategoryAxis />` is
|
|
5
|
+
* `<XAxis />`. The axis kind follows the data: on a **category** container (a
|
|
6
|
+
* layer that plots on the ordinal column-domain axis) it ticks once per category,
|
|
7
|
+
* labelling each band centre with the category name (the container's shared
|
|
8
|
+
* formatter). Kept as the familiar name for categorical charts, mirroring
|
|
9
|
+
* {@link TimeAxis}. Forwards every {@link XAxisProps} (`label`, `side`, …).
|
|
10
|
+
*
|
|
11
|
+
* A high-cardinality axis (many categories) thins + truncates its labels to stay
|
|
12
|
+
* legible (categorical-axis RFC, Phase 1). The labels **come from the data** (the
|
|
13
|
+
* `categories` list), so a d3 `format` prop does not apply here (it can't name a
|
|
14
|
+
* category); customize a label by changing the `categories` datum's `label`.
|
|
15
|
+
*/
|
|
16
|
+
export function CategoryAxis(props = {}) {
|
|
17
|
+
return _jsx(XAxis, { ...props });
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=CategoryAxis.js.map
|
package/dist/ChartContainer.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import { type DiscontinuityProvider, type TradingCalendarLike } from './tradingTimeScale.js';
|
|
3
|
+
import { Sequence, BoundedSequence } from 'pond-ts';
|
|
3
4
|
import type { TimeRange } from 'pond-ts';
|
|
4
5
|
import { type AnnotationKind, type CreateSpec, type CursorMode, type SelectInfo, type TrackerInfo } from './context.js';
|
|
5
6
|
import { type AxisFormat } from './format.js';
|
|
@@ -75,9 +76,63 @@ export interface ChartContainerProps {
|
|
|
75
76
|
* via `<ChartRow cursor>`). **Default `'line'`** — the synced vertical line,
|
|
76
77
|
* with values surfaced *outside* the chart via {@link onTrackerChanged}.
|
|
77
78
|
* `'point'` / `'inline'` / `'flag'` add per-series marks; `'none'` hides it.
|
|
79
|
+
* `'region'` shades the bucket under the pointer (needs {@link cursorSequence}).
|
|
78
80
|
* See {@link CursorMode}.
|
|
79
81
|
*/
|
|
80
82
|
cursor?: CursorMode;
|
|
83
|
+
/**
|
|
84
|
+
* The bucketing for `cursor="region"` — the interval highlighted under the
|
|
85
|
+
* pointer. A pond {@link Sequence} (duration or calendar-aware —
|
|
86
|
+
* `Sequence.every('1d')`, `Sequence.calendar('month')`) is realized over the
|
|
87
|
+
* current view; a {@link BoundedSequence} (e.g. a `TradingCalendar`'s
|
|
88
|
+
* `sessionSequence()` / `barSequence()`) is used as-is, so the band can track
|
|
89
|
+
* whole **sessions**. Either way the band maps through `xScale`, so on a
|
|
90
|
+
* trading-time axis the closed part of the bucket collapses. Ignored unless
|
|
91
|
+
* `cursor="region"`.
|
|
92
|
+
*
|
|
93
|
+
* **Time axis only.** A bucket is a *time* interval, so the region cursor is
|
|
94
|
+
* gated to a **time** x-axis — on a **value** axis (a horizontal histogram, a
|
|
95
|
+
* value-keyed chart) it's a no-op (highlighting a value *band* on a horizontal
|
|
96
|
+
* histogram would be a different, y-oriented cursor).
|
|
97
|
+
*
|
|
98
|
+
* **Pass a stable reference.** The buckets are memoized on this value + the
|
|
99
|
+
* view range; a `Sequence`/`BoundedSequence` rebuilt inline every render
|
|
100
|
+
* re-realizes the buckets on each pointer move (harmless for a coarse
|
|
101
|
+
* day/session sequence, wasteful for a fine one over a wide view) — hoist it or
|
|
102
|
+
* `useMemo` it.
|
|
103
|
+
*/
|
|
104
|
+
cursorSequence?: Sequence | BoundedSequence;
|
|
105
|
+
/**
|
|
106
|
+
* Makes the `region` cursor **draggable**: drag across the plot and the band
|
|
107
|
+
* extends **bucket by bucket** (snapping to `cursorSequence` points); on
|
|
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.
|
|
117
|
+
*
|
|
118
|
+
* With **no `cursorSequence`** the region cursor is the degenerate case — it
|
|
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).
|
|
125
|
+
*/
|
|
126
|
+
onRegionSelect?: (range: readonly [number, number]) => void;
|
|
127
|
+
/**
|
|
128
|
+
* Which modifier a region-drag needs — set `'shift'` when you also enable
|
|
129
|
+
* `panZoom` and want **plain drag to pan, shift-drag to select**. It's only
|
|
130
|
+
* enforced while `panZoom` is on (with pan off there's no gesture conflict, so
|
|
131
|
+
* shift is optional — either drag selects). **Omitted** ⇒ a region-drag
|
|
132
|
+
* **preempts** pan (drag always selects; document that precedence for users).
|
|
133
|
+
* Wheel-zoom is unaffected in every case.
|
|
134
|
+
*/
|
|
135
|
+
regionSelectModifier?: 'shift';
|
|
81
136
|
/**
|
|
82
137
|
* Fires on pointer move with the hovered time + every series' value there (so
|
|
83
138
|
* you can render a readout outside the chart), and `null` on leave.
|
|
@@ -226,5 +281,5 @@ export interface ChartContainerProps {
|
|
|
226
281
|
* {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
|
|
227
282
|
* (`<YAxis>`).
|
|
228
283
|
*/
|
|
229
|
-
export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, selected, onSelect, hovered, onHover, panZoom, onTimeRangeChange, minDuration, cursor, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, theme, discontinuities, calendar, spacing, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
|
|
284
|
+
export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, selected, onSelect, hovered, onHover, panZoom, onTimeRangeChange, minDuration, cursor, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, theme, discontinuities, calendar, spacing, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
|
|
230
285
|
//# sourceMappingURL=ChartContainer.d.ts.map
|
package/dist/ChartContainer.js
CHANGED
|
@@ -2,6 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
3
3
|
import { scaleLinear, scaleTime } from 'd3-scale';
|
|
4
4
|
import { scaleTradingTime, } from './tradingTimeScale.js';
|
|
5
|
+
import { scaleBand } from './bandScale.js';
|
|
6
|
+
import { Sequence } from 'pond-ts';
|
|
5
7
|
import { ContainerContext, } from './context.js';
|
|
6
8
|
import { maxSlotWidths, sum } from './slots.js';
|
|
7
9
|
import { computeLabelLanes } from './annotations.js';
|
|
@@ -32,7 +34,7 @@ function normalizeRange(range) {
|
|
|
32
34
|
* {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
|
|
33
35
|
* (`<YAxis>`).
|
|
34
36
|
*/
|
|
35
|
-
export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, selected, onSelect, hovered, onHover, panZoom = false, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, theme, discontinuities, calendar, spacing, children, }) {
|
|
37
|
+
export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, selected, onSelect, hovered, onHover, panZoom = false, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, theme, discontinuities, calendar, spacing, children, }) {
|
|
36
38
|
// The explicit base domain from `range` (a tuple or a TimeRange). `undefined`
|
|
37
39
|
// ⇒ auto-fit (resolved from the layers below). Pan/zoom seeds from it; `seed`
|
|
38
40
|
// is the placeholder while auto-fitting.
|
|
@@ -78,6 +80,8 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
78
80
|
// still cursor stays put while a live window slides under it; a controlled
|
|
79
81
|
// `trackerPosition` resolves to a pixel below.
|
|
80
82
|
const [hoverX, setHoverX] = useState(null);
|
|
83
|
+
// The region-cursor drag anchor (epoch ms) — set on press, cleared on release.
|
|
84
|
+
const [regionAnchor, setRegionAnchor] = useState(null);
|
|
81
85
|
// The free-form crosshair also needs the pointer's y + which row (row-specific,
|
|
82
86
|
// unlike the shared x). One state object so a move updates both atomically.
|
|
83
87
|
const [hoverPoint, setHoverPoint] = useState(null);
|
|
@@ -151,11 +155,31 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
151
155
|
else if (kind !== s.xKind) {
|
|
152
156
|
throw new Error(`ChartContainer: rows mix x-axis kinds ('${kind}' and '${s.xKind}'). ` +
|
|
153
157
|
`A container has one shared x axis — every row must plot the same ` +
|
|
154
|
-
`kind (all time-keyed,
|
|
158
|
+
`kind (all time-keyed, all value-keyed, or all category).`);
|
|
155
159
|
}
|
|
156
160
|
}
|
|
157
161
|
return kind ?? 'time';
|
|
158
162
|
}, [sources]);
|
|
163
|
+
// A `'category'` container's ordered category names — the ordinal axis domain.
|
|
164
|
+
// Every category layer must agree on the same list (a mix is an error, like the
|
|
165
|
+
// kind), so the shared band scale has one authoritative slot order. `null` when
|
|
166
|
+
// no category layer has registered (or the kind isn't category).
|
|
167
|
+
const categories = useMemo(() => {
|
|
168
|
+
let cats = null;
|
|
169
|
+
for (const s of sources.values()) {
|
|
170
|
+
const c = s.xCategories?.() ?? null;
|
|
171
|
+
if (c === null)
|
|
172
|
+
continue;
|
|
173
|
+
if (cats === null)
|
|
174
|
+
cats = c;
|
|
175
|
+
else if (cats.length !== c.length || cats.some((v, i) => v !== c[i])) {
|
|
176
|
+
throw new Error(`ChartContainer: category rows disagree on the axis categories. ` +
|
|
177
|
+
`Every category layer in one container must share the same ordered ` +
|
|
178
|
+
`column set (got [${cats.join(', ')}] and [${c.join(', ')}]).`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return cats;
|
|
182
|
+
}, [sources]);
|
|
159
183
|
// Auto-fit extent — the union of the layers' x extents — used as the domain
|
|
160
184
|
// when no explicit `range` is given. (Same source registry as the kind; the
|
|
161
185
|
// two-pass register→resolve applies.)
|
|
@@ -300,6 +324,17 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
300
324
|
: undefined, [resolvedKind, discontinuities, calendar, spacing]);
|
|
301
325
|
const xDiscontinuities = resolvedKind === 'time' ? (discontinuities ?? calendarProvider) : undefined;
|
|
302
326
|
const { xScale, formatTime } = useMemo(() => {
|
|
327
|
+
if (resolvedKind === 'category') {
|
|
328
|
+
// Ordinal column-domain axis: a band scale over the category slots. The
|
|
329
|
+
// domain is **always** `[0, n]` (one unit slot per category) — NOT the
|
|
330
|
+
// resolved `[d0, d1]`: a category axis ignores an explicit `range` (its
|
|
331
|
+
// slots are absolute `0..n`, matching `categoryStack`), so an out-of-`[0,n]`
|
|
332
|
+
// range can't silently offset the labels from the bars. The pixel mapping
|
|
333
|
+
// stays linear; the formatter is the category-name lookup.
|
|
334
|
+
const cats = categories ?? [];
|
|
335
|
+
const s = scaleBand(cats).domain([0, cats.length]).range([0, plotWidth]);
|
|
336
|
+
return { xScale: s, formatTime: (v) => s.label(v) };
|
|
337
|
+
}
|
|
303
338
|
if (resolvedKind === 'value') {
|
|
304
339
|
const s = scaleLinear().domain([d0, d1]).range([0, plotWidth]);
|
|
305
340
|
return {
|
|
@@ -323,12 +358,65 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
323
358
|
xScale: s,
|
|
324
359
|
formatTime: resolveTimeFormat(s, TIME_TICK_COUNT, timeFormat),
|
|
325
360
|
};
|
|
326
|
-
}, [
|
|
361
|
+
}, [
|
|
362
|
+
resolvedKind,
|
|
363
|
+
categories,
|
|
364
|
+
d0,
|
|
365
|
+
d1,
|
|
366
|
+
plotWidth,
|
|
367
|
+
timeFormat,
|
|
368
|
+
xDiscontinuities,
|
|
369
|
+
]);
|
|
327
370
|
// The crosshair pixel (see resolveCursorX). A stored hoverX is a *plot* pixel;
|
|
328
371
|
// if plotWidth changes mid-hover (a gutter reserving, or a width change) it's
|
|
329
372
|
// briefly stale until the next pointer move — rare, and the bounds check below
|
|
330
373
|
// hides an out-of-plot crosshair meanwhile.
|
|
331
374
|
const cursorX = resolveCursorX(trackerPosition, hoverX, xScale);
|
|
375
|
+
// `cursor="region"` snap buckets — the intervals the band snaps to (and a drag
|
|
376
|
+
// extends bucket by bucket over). Two sources, in precedence order:
|
|
377
|
+
//
|
|
378
|
+
// 1. **An explicit `cursorSequence`** (time axis only): realized over the view
|
|
379
|
+
// (a `Sequence` → `.bounded`; a `BoundedSequence` used as-is). A `Sequence`
|
|
380
|
+
// bucket is a *time* interval, so it's gated to a time axis — realizing time
|
|
381
|
+
// buckets over a value domain is meaningless (it would shade the whole plot).
|
|
382
|
+
// 2. **A bar/histogram layer's bins** (`binIntervals`, time **or** value axis):
|
|
383
|
+
// when no `cursorSequence` is set, the region cursor snaps to the bars —
|
|
384
|
+
// a histogram gets bin-aligned selection for free (the first bar layer that
|
|
385
|
+
// publishes bins wins; a plain histogram has exactly one).
|
|
386
|
+
//
|
|
387
|
+
// With neither, `undefined` ⇒ the freeform region cursor (raw-span drag).
|
|
388
|
+
const cursorBuckets = useMemo(() => {
|
|
389
|
+
if (cursorSequence !== undefined && resolvedKind === 'time') {
|
|
390
|
+
if (!(cursorSequence instanceof Sequence))
|
|
391
|
+
return cursorSequence.intervals();
|
|
392
|
+
// `bounded` (sample 'begin') drops a partial *leading* bucket — the one that
|
|
393
|
+
// contains the view start begins before it. Widen the realized range back by
|
|
394
|
+
// one bucket width so that covering bucket is included (a coarse calendar
|
|
395
|
+
// unit is bounded at ~a year; a fixed step uses its own width).
|
|
396
|
+
const back = cursorSequence.kind() === 'fixed'
|
|
397
|
+
? cursorSequence.stepMs()
|
|
398
|
+
: 366 * 86_400_000;
|
|
399
|
+
return cursorSequence.bounded({ start: d0 - back, end: d1 }).intervals();
|
|
400
|
+
}
|
|
401
|
+
// No sequence → snap to a bar/histogram layer's bins, if any (a value axis,
|
|
402
|
+
// or a time-axis histogram with no explicit sequence). `binIntervals` is only
|
|
403
|
+
// published by a vertical bar layer on a continuous axis, so this is a no-op
|
|
404
|
+
// for line/area/scatter rows and for a category axis.
|
|
405
|
+
//
|
|
406
|
+
// **First bar layer wins** — deliberately non-fatal, unlike `xCategories`
|
|
407
|
+
// (which *throws* when category rows disagree, because a mismatched slot order
|
|
408
|
+
// corrupts the shared band scale). Two overlaid histograms with different bins
|
|
409
|
+
// is a degenerate layout the region cursor just snaps to whichever registered
|
|
410
|
+
// first; a wrong snap grid is harmless where a wrong axis is not.
|
|
411
|
+
if (resolvedKind === 'time' || resolvedKind === 'value') {
|
|
412
|
+
for (const s of sources.values()) {
|
|
413
|
+
const bins = s.binIntervals?.() ?? null;
|
|
414
|
+
if (bins && bins.length > 0)
|
|
415
|
+
return bins;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return undefined;
|
|
419
|
+
}, [cursorSequence, d0, d1, resolvedKind, sources]);
|
|
332
420
|
// Emit { time, values } for an outside readout — recomputed as the cursor moves
|
|
333
421
|
// *or* the window slides under it (xScale change → new time at the same pixel).
|
|
334
422
|
// Out of the plot (null, or a controlled trackerPosition d3 extrapolated past
|
|
@@ -369,6 +457,11 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
369
457
|
cursorRowKey: hoverPoint?.rowKey ?? null,
|
|
370
458
|
setHoverY,
|
|
371
459
|
crosshairSnap,
|
|
460
|
+
cursorBuckets,
|
|
461
|
+
regionAnchor,
|
|
462
|
+
setRegionAnchor,
|
|
463
|
+
onRegionSelect,
|
|
464
|
+
regionSelectModifier,
|
|
372
465
|
draggingKey,
|
|
373
466
|
setDragging,
|
|
374
467
|
selected: selectedValue,
|
|
@@ -417,6 +510,11 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
417
510
|
hoverPoint,
|
|
418
511
|
setHoverY,
|
|
419
512
|
crosshairSnap,
|
|
513
|
+
cursorBuckets,
|
|
514
|
+
regionAnchor,
|
|
515
|
+
setRegionAnchor,
|
|
516
|
+
onRegionSelect,
|
|
517
|
+
regionSelectModifier,
|
|
420
518
|
draggingKey,
|
|
421
519
|
setDragging,
|
|
422
520
|
selectedValue,
|
package/dist/Layers.js
CHANGED
|
@@ -2,7 +2,7 @@ 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 { cursorParts } from './tracker.js';
|
|
5
|
+
import { cursorParts, bandRect, regionSpan } from './tracker.js';
|
|
6
6
|
import { resolveSelection } from './select.js';
|
|
7
7
|
import { panRange, zoomRange, panRangeTrading, zoomRangeTrading, } from './viewport.js';
|
|
8
8
|
import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
|
|
@@ -69,7 +69,9 @@ export function Layers({ children }) {
|
|
|
69
69
|
// Explicit `<YAxis ticks>` drive the gridlines too, so they align with the
|
|
70
70
|
// axis labels; otherwise d3 auto-picks (the default).
|
|
71
71
|
const explicitY = tickValues.get(defaultAxisId);
|
|
72
|
-
|
|
72
|
+
// A category axis draws no vertical gridlines — a line through each bar
|
|
73
|
+
// centre reads as noise; the bars are the structure.
|
|
74
|
+
const xTickVals = container.xKind === 'category' ? [] : xScale.ticks(GRID_TICKS);
|
|
73
75
|
const xTicks = xTickVals.map((d) => xScale(+d));
|
|
74
76
|
const yTicks = gridY
|
|
75
77
|
? (explicitY ?? gridY.ticks(GRID_TICKS)).map((t) => gridY(t))
|
|
@@ -257,7 +259,33 @@ export function Layers({ children }) {
|
|
|
257
259
|
}
|
|
258
260
|
return;
|
|
259
261
|
}
|
|
260
|
-
|
|
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')) {
|
|
273
|
+
const needsShift = c.regionSelectModifier === 'shift' && c.panZoom;
|
|
274
|
+
if (!needsShift || e.shiftKey) {
|
|
275
|
+
const px = Math.max(0, Math.min(c.plotWidth, e.clientX - e.currentTarget.getBoundingClientRect().left));
|
|
276
|
+
c.setRegionAnchor(+c.xScale.invert(px));
|
|
277
|
+
c.setHoverX(px);
|
|
278
|
+
try {
|
|
279
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
/* ignore */
|
|
283
|
+
}
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
// Modifier required but not held → fall through to pan.
|
|
287
|
+
}
|
|
288
|
+
if (!c.panZoom || c.xKind === 'category')
|
|
261
289
|
return;
|
|
262
290
|
const r = c.timeRange;
|
|
263
291
|
// Arm a potential pan: record the anchor, but DON'T capture the pointer or
|
|
@@ -287,6 +315,13 @@ export function Layers({ children }) {
|
|
|
287
315
|
c.setHoverX(px); // share the preview x so other rows draw a guide there
|
|
288
316
|
return;
|
|
289
317
|
}
|
|
318
|
+
// Region drag in progress: just track the pointer x (the band spans from the
|
|
319
|
+
// anchor bucket to here); no pan, no hover hit-test.
|
|
320
|
+
if (c.regionAnchor !== null) {
|
|
321
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
322
|
+
c.setHoverX(Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left)));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
290
325
|
// A pan is only live while a button is held. A move with no buttons means
|
|
291
326
|
// the press already ended without us seeing the pointerup — which the
|
|
292
327
|
// deferred-capture path allows: an uncommitted (sub-slop) potential-pan
|
|
@@ -372,6 +407,22 @@ export function Layers({ children }) {
|
|
|
372
407
|
}, []);
|
|
373
408
|
const handlePointerUp = useCallback((e) => {
|
|
374
409
|
const c = containerRef.current;
|
|
410
|
+
// End a region drag: commit the anchor→pointer span as a one-shot range,
|
|
411
|
+
// then clear the anchor (the cursor reverts to the single-bucket highlight).
|
|
412
|
+
if (c.regionAnchor !== null) {
|
|
413
|
+
const px = Math.max(0, Math.min(c.plotWidth, e.clientX - e.currentTarget.getBoundingClientRect().left));
|
|
414
|
+
const span = regionSpan(c.cursorBuckets ?? [], c.regionAnchor, +c.xScale.invert(px));
|
|
415
|
+
c.setRegionAnchor(null);
|
|
416
|
+
try {
|
|
417
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
/* ignore */
|
|
421
|
+
}
|
|
422
|
+
if (span)
|
|
423
|
+
c.onRegionSelect?.([span.start, span.end]);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
375
426
|
if (c.creating !== null) {
|
|
376
427
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
377
428
|
const px = Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left));
|
|
@@ -439,6 +490,10 @@ export function Layers({ children }) {
|
|
|
439
490
|
c.setHoverY(null, null);
|
|
440
491
|
return;
|
|
441
492
|
}
|
|
493
|
+
// Cancel a region-drag on leave (no commit) — a safety net for the rare case
|
|
494
|
+
// where the pointer capture didn't take, so the anchor can't get stuck.
|
|
495
|
+
if (c.regionAnchor !== null)
|
|
496
|
+
c.setRegionAnchor(null);
|
|
442
497
|
c.setHoverX(null);
|
|
443
498
|
c.setHoverY(null, null);
|
|
444
499
|
c.setHovered(null);
|
|
@@ -478,7 +533,7 @@ export function Layers({ children }) {
|
|
|
478
533
|
return;
|
|
479
534
|
const onWheel = (e) => {
|
|
480
535
|
const c = containerRef.current;
|
|
481
|
-
if (!c.panZoom)
|
|
536
|
+
if (!c.panZoom || c.xKind === 'category')
|
|
482
537
|
return;
|
|
483
538
|
e.preventDefault();
|
|
484
539
|
const rect = el.getBoundingClientRect();
|
|
@@ -571,6 +626,23 @@ export function Layers({ children }) {
|
|
|
571
626
|
side: axisSides.get(defaultAxisId) ?? 'left',
|
|
572
627
|
};
|
|
573
628
|
})();
|
|
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');
|
|
636
|
+
const band = regionActive && cursorTime !== null
|
|
637
|
+
? bandRect(container.cursorBuckets ?? [], cursorTime, (v) => xScale(v), plotWidth, container.regionAnchor ?? undefined)
|
|
638
|
+
: null;
|
|
639
|
+
// Degenerate region cursor (no sequence, not mid-drag): a plain vertical line.
|
|
640
|
+
const regionLine = regionActive &&
|
|
641
|
+
container.cursorBuckets === undefined &&
|
|
642
|
+
container.regionAnchor === null &&
|
|
643
|
+
cursorX !== null &&
|
|
644
|
+
cursorX >= 0 &&
|
|
645
|
+
cursorX <= plotWidth;
|
|
574
646
|
// Cross-row guide lines: the x-positions of annotations on the OTHER rows
|
|
575
647
|
// (markers + region edges), so a mark on one row reads against this row's data +
|
|
576
648
|
// the shared x axis. A mark's own row skips itself; baselines cast no vertical
|
|
@@ -638,7 +710,7 @@ export function Layers({ children }) {
|
|
|
638
710
|
top: 0,
|
|
639
711
|
left: 0,
|
|
640
712
|
pointerEvents: 'none',
|
|
641
|
-
}, children: [parts.line &&
|
|
713
|
+
}, children: [band !== null && (_jsx("rect", { x: band.x0, y: 0, width: band.x1 - band.x0, height: row.height, fill: cursorColor, opacity: 0.12 })), regionLine && cursorX !== null && (_jsx("line", { x1: Math.round(cursorX), y1: 0, x2: Math.round(cursorX), y2: row.height, stroke: cursorColor, strokeWidth: 1, shapeRendering: "crispEdges" })), parts.line &&
|
|
642
714
|
cursorX !== null &&
|
|
643
715
|
cursorX >= 0 &&
|
|
644
716
|
cursorX <= plotWidth && (_jsx("line", { x1: Math.round(cursorX), y1: 0, x2: Math.round(cursorX), y2: row.height, stroke: cursorColor, strokeWidth: 1, shapeRendering: "crispEdges" })), parts.chip === 'flag' &&
|