@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/dist/BoxPlot.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
- import { boxFromTimeSeries } from './data.js';
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 five **pre-computed quantile columns** of `series` (typically a
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 box, the median
13
- * line, and whiskers out to lower/upper, over the key's interval x-span. Gap-aware
14
- * (a key missing any quantile draws nothing) and registers itself into the
15
- * enclosing {@link Layers}. Renders nothing to the DOM — the row draws it.
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
- * series={q}
24
- * lower="p5" q1="p25" median="p50" q3="p75" upper="p95"
25
- * as="latency"
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 bx = useMemo(() => boxFromTimeSeries(series, { lower, q1, median, q3, upper }), [series, lower, q1, median, q3, upper]);
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
- xKind: 'time',
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: (time) => {
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, time);
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
- // The median is the primary readout (median colour); the four quantile
63
- // edges ride the whisker colour, each labelled by its own column. A
64
- // single non-finite quantile is omitted (a gap key yields nothing — all
65
- // five missing but a malformed partial set still reads what it has).
66
- push(samples, at, bx.upper[i], style.whisker, upper);
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: (time) => {
74
- // The in-chart `flag`: all five values on **one** flag at the box's
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. All-or-nothing a gap
78
- // box (any quantile non-finite, not drawn) shows no flag.
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, time);
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
@@ -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
@@ -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, or all value-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
- }, [resolvedKind, d0, d1, plotWidth, timeFormat, xDiscontinuities]);
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
- const xTickVals = xScale.ticks(GRID_TICKS);
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
- if (!c.panZoom)
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' &&