@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/bars.js CHANGED
@@ -172,9 +172,12 @@ export function barAt(cs, px, py, xScale, yScale, baseline, gapPx, minWidthPx) {
172
172
  return null;
173
173
  }
174
174
  /**
175
- * The `[min, max]` extent of the **value (stacked) axis** always `[0, maxTotal]`,
176
- * where `maxTotal` is the tallest bin's summed finite non-negative segments. `0` is
177
- * pulled in so the stack rests on a visible baseline (the bar analog of
175
+ * The `[min, max]` extent of the **value (stacked) axis**. For a true multi-group
176
+ * stack it is `[0, maxTotal]`, where `maxTotal` is the tallest bin's summed finite
177
+ * non-negative segments. For a **single-group** series (`G === 1` the plain /
178
+ * categorical bar case) it spans the values' own `[min, max]`, so a **negative**
179
+ * bar's floor is in the domain (segments below the baseline stay visible). `0` is
180
+ * always pulled in so the bars rest on a visible baseline (the bar analog of
178
181
  * {@link barExtent}). An empty / all-gap series returns `[0, 1]` so the axis still
179
182
  * has a usable domain. Feeds the y auto-fit for a vertical histogram, the x
180
183
  * auto-fit for a horizontal one.
@@ -182,17 +185,32 @@ export function barAt(cs, px, py, xScale, yScale, baseline, gapPx, minWidthPx) {
182
185
  export function stackValueExtent(ss) {
183
186
  const G = ss.groups.length;
184
187
  let max = 0;
188
+ let min = 0;
185
189
  for (let b = 0; b < ss.length; b += 1) {
186
190
  let cum = 0;
187
191
  for (let g = 0; g < G; g += 1) {
188
192
  const v = ss.values[b * G + g];
189
- if (Number.isFinite(v) && v > 0)
190
- cum += v;
193
+ if (!Number.isFinite(v))
194
+ continue;
195
+ if (G === 1) {
196
+ // Single-group: a bar honours its sign, so track both ends.
197
+ if (v > max)
198
+ max = v;
199
+ if (v < min)
200
+ min = v;
201
+ }
202
+ else if (v > 0) {
203
+ cum += v; // True stack: sum the positive segments.
204
+ }
191
205
  }
192
206
  if (cum > max)
193
207
  max = cum;
194
208
  }
195
- return [0, max > 0 ? max : 1];
209
+ // Empty / all-gap / all-zero a usable unit domain; otherwise the real extent
210
+ // (with 0 pulled in via the `min`/`max` seeds above).
211
+ if (min === 0 && max === 0)
212
+ return [0, 1];
213
+ return [min, max];
196
214
  }
197
215
  /**
198
216
  * The `[min, max]` extent of the **bin axis** — the first bin's `begin` to the
@@ -224,9 +242,14 @@ export function stackBinExtent(ss) {
224
242
  export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, gapPx, minSpanPx) {
225
243
  const G = ss.groups.length;
226
244
  const v = ss.values[b * G + g];
227
- // Skip non-finite / negative / zero: a zero segment would otherwise draw a
228
- // wasted zero-extent rect (and can't be hit-tested).
229
- if (!Number.isFinite(v) || v <= 0)
245
+ // Skip non-finite (a gap) or zero (a zero-extent rect that can't draw or be
246
+ // hit-tested). A **negative** value is a gap only in a true multi-group stack
247
+ // (`G > 1`) stacking a negative segment is undefined. A **single-group**
248
+ // series (`G === 1`) is a plain bar: it honours its sign and draws from the
249
+ // baseline *down* to a negative value (the categorical row-read's P&L / delta
250
+ // case), so negatives are kept and the `Math.min/Math.max` below normalizes the
251
+ // below-baseline rect.
252
+ if (!Number.isFinite(v) || v === 0 || (v < 0 && G > 1))
230
253
  return null;
231
254
  if (orientation === 'vertical') {
232
255
  const [x0, x1] = barSpanPx(ss.begin[b], ss.end[b], xScale, gapPx, minSpanPx);
@@ -242,8 +265,10 @@ export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, ga
242
265
  /**
243
266
  * Fill every segment of every bin in `ss`, stacking each bin's groups from the
244
267
  * value baseline outward (bottom → top vertical, left → right horizontal). A gap
245
- * (non-finite / negative) segment is skipped and adds nothing to the running
246
- * total, so the segments above it close the space. A segment matching the current
268
+ * (non-finite, or a negative segment of a true multi-group stack) is skipped and
269
+ * adds nothing to the running total, so the segments above it close the space; a
270
+ * single-group series draws its negative bars below the baseline (see
271
+ * {@link segmentRect}). A segment matching the current
247
272
  * `selection` (same series `id`, bin `key` **and** group `label`) draws in its
248
273
  * group's `highlight` **and** outlined; one matching `hover` draws in `highlight`
249
274
  * without the outline; all others use the flat `fill`. `globalAlpha` carries the
@@ -265,20 +290,27 @@ export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, m
265
290
  if (rect === null)
266
291
  continue;
267
292
  const [x0, x1, yTop, yBottom] = rect;
293
+ // With `marks` (the categorical axis), match on the stable per-bin name so a
294
+ // pinned selection survives a column reorder; otherwise on the sample `key`
295
+ // (begin) + group `label`, as a time / value stack does.
296
+ const stableMark = ss.marks?.[b];
268
297
  const matches = (m) => m !== null &&
269
298
  m.id === seriesId &&
270
- m.key === ss.begin[b] &&
271
- m.label === ss.groups[g];
299
+ (stableMark !== undefined
300
+ ? m.mark === stableMark
301
+ : m.key === ss.begin[b] && m.label === ss.groups[g]);
272
302
  const selected = matches(selection);
273
303
  const isHovered = matches(hover);
274
304
  // A hovered / selected segment pops to full opacity in its own colour; a
275
305
  // resting one draws at the shared alpha.
276
306
  ctx.globalAlpha = selected || isHovered ? 1 : style.opacity;
277
- ctx.fillStyle = style.fills[g];
307
+ // A per-bin colour (the single-series band case) overrides the group fill.
308
+ const fill = style.binFills?.[b] ?? style.fills[g];
309
+ ctx.fillStyle = fill;
278
310
  ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
279
311
  if (selected) {
280
312
  ctx.lineWidth = style.outlineWidth;
281
- ctx.strokeStyle = style.fills[g];
313
+ ctx.strokeStyle = fill;
282
314
  ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
283
315
  }
284
316
  }
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 where **all five** quantiles are
7
- * finite or `null` if none are. Gap keys (any quantile `NaN`) are excluded,
8
- * matching what {@link drawBox} draws, so they don't drag the y-domain.
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
- * Then, if `showMedian`, the median line across the box on top. Fills are
48
- * bracketed by `save`/`restore` so their `globalAlpha` doesn't leak.
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
- * **Gap-aware**: a key with any quantile non-finite is skipped entirely (no
51
- * partial box) the same contract as a band gap.
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
- /** All five quantiles finite at `i` — i.e. this key is drawn. */
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 where **all five** quantiles are
7
- * finite or `null` if none are. Gap keys (any quantile `NaN`) are excluded,
8
- * matching what {@link drawBox} draws, so they don't drag the y-domain.
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
- * Then, if `showMedian`, the median line across the box on top. Fills are
60
- * bracketed by `save`/`restore` so their `globalAlpha` doesn't leak.
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
- * **Gap-aware**: a key with any quantile non-finite is skipped entirely (no
63
- * partial box) the same contract as a band gap.
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 [x0, x1] = barSpanPx(box.x[i], box.xEnd[i], xScale, gapPx, minWidthPx);
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 a
81
- // more-prominent inner q1→q3 box on top (same fill at rising opacity, so the
82
- // inner reads darker on a light ground / brighter on a dark one). No stems,
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
- ctx.globalAlpha = Math.min(1, style.fillOpacity * 2);
89
- ctx.fillRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
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
- ctx.save();
95
- ctx.fillStyle = style.fill;
96
- ctx.globalAlpha = style.fillOpacity;
97
- ctx.fillRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
98
- ctx.restore();
99
- ctx.strokeStyle = style.stroke;
100
- ctx.lineWidth = style.strokeWidth;
101
- ctx.strokeRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
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: a stem from each box edge to the whisker end, with a cap.
104
- const capHalf = ((x1 - x0) * WHISKER_CAP_FRACTION) / 2;
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: stem q3 upper, cap at 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: stem q1 lower, cap at lower.
114
- ctx.moveTo(mid, yQ1);
115
- ctx.lineTo(mid, yLower);
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 — always optional.
122
- if (showMedian) {
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
- /** All five quantiles finite at `i` — i.e. this key is drawn. */
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
- return (Number.isFinite(box.lower[i]) &&
135
- Number.isFinite(box.q1[i]) &&
136
- Number.isFinite(box.median[i]) &&
137
- Number.isFinite(box.q3[i]) &&
138
- Number.isFinite(box.upper[i]));
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,9 @@
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 } from 'pond-ts';
4
5
  import type { TradingTimeScale, DiscontinuityProvider } from './tradingTimeScale.js';
6
+ import type { ScaleBand } from './bandScale.js';
5
7
  /**
6
8
  * The frame a {@link ChartContainer} provides to its rows and the time axis.
7
9
  * The container owns the **shared x geometry**: each side is split into *slots*
@@ -64,6 +66,44 @@ export interface ContainerFrame {
64
66
  * (`yScale.invert`). The x always snaps to the data grid either way.
65
67
  */
66
68
  readonly crosshairSnap: boolean;
69
+ /**
70
+ * `cursor="region"` buckets — the intervals (from `cursorSequence`) realized
71
+ * over the current view, sorted + non-overlapping. `Layers` finds the one under
72
+ * the pointer and shades it (mapped through `xScale`, so on a trading-time axis
73
+ * the closed part of the bucket collapses). `undefined` when no `cursorSequence`
74
+ * is set.
75
+ */
76
+ readonly cursorBuckets: readonly Interval[] | undefined;
77
+ /**
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.
84
+ */
85
+ readonly regionAnchor: number | null;
86
+ /** Set / clear the region-drag anchor (see {@link regionAnchor}). */
87
+ setRegionAnchor(value: number | null): void;
88
+ /**
89
+ * One-shot callback fired when a `region`-cursor **drag** is released, with the
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.
95
+ * Providing it is what makes the region cursor **draggable**; the cursor does
96
+ * not keep the range (it reverts to the single-bucket highlight). Typical use:
97
+ * zoom the view, or map the span onto a subscription's range params.
98
+ */
99
+ readonly onRegionSelect: ((range: readonly [number, number]) => void) | undefined;
100
+ /**
101
+ * Require a modifier key held to start a region-drag — set to `'shift'` to make
102
+ * plain drag **pan** and **shift**-drag select, when `panZoom` is on. Only
103
+ * enforced while pan is enabled (with no pan there's no gesture to share, so the
104
+ * modifier is optional). `undefined` ⇒ a region-drag preempts pan.
105
+ */
106
+ readonly regionSelectModifier: 'shift' | undefined;
67
107
  /**
68
108
  * The selected mark, or `null`. Shared across rows (single selection). A layer
69
109
  * highlights the mark matching the selection's series **`id`** and the clicked
@@ -139,7 +179,7 @@ export interface ContainerFrame {
139
179
  * `invert` / `ticks` / `tickFormat` surface, but the mapping runs through
140
180
  * trading time so closed-market gaps collapse (see {@link discontinuities}).
141
181
  */
142
- readonly xScale: ScaleTime<number, number> | ScaleLinear<number, number> | TradingTimeScale;
182
+ readonly xScale: ScaleTime<number, number> | ScaleLinear<number, number> | TradingTimeScale | ScaleBand;
143
183
  /**
144
184
  * The discontinuity provider backing a **trading-time** x axis, if one was
145
185
  * supplied to the container — closed-market time (weekends, holidays,
@@ -149,12 +189,13 @@ export interface ContainerFrame {
149
189
  */
150
190
  readonly discontinuities?: DiscontinuityProvider | undefined;
151
191
  /**
152
- * The resolved kind of the shared x scale — `'time'` (a `scaleTime`) or
153
- * `'value'` (a `scaleLinear`), inferred from the layers' data. `<XAxis>` reads
154
- * it to pick its default tick formatter (a time format vs a number format),
155
- * and the cursor readout to format the x position.
192
+ * The resolved kind of the shared x scale — `'time'` (a `scaleTime`),
193
+ * `'value'` (a `scaleLinear`), or `'category'` (a {@link ScaleBand}: an ordinal
194
+ * column-domain axis, one slot per category). Inferred from the layers' data.
195
+ * `<XAxis>` reads it to pick its default tick formatter (time / number / the
196
+ * category label), and the cursor readout to format the x position.
156
197
  */
157
- readonly xKind: 'time' | 'value';
198
+ readonly xKind: 'time' | 'value' | 'category';
158
199
  /** Pan/zoom enabled — the plot drag-pans and wheel-zooms the shared time range. */
159
200
  readonly panZoom: boolean;
160
201
  /** Minimum visible duration (ms) — the zoom-in floor. */
@@ -314,17 +355,37 @@ export interface RowLayer {
314
355
  yExtent(): [number, number] | null;
315
356
  /**
316
357
  * The **kind of x axis** this layer's data lives on — `'time'` for a
317
- * `TimeSeries`, `'value'` for a `ValueSeries`. The container infers the one
318
- * shared x scale from its layers (all must agree a mix is an error), so the
319
- * axis kind never needs declaring. See {@link ContainerFrame.xScale}.
358
+ * `TimeSeries`, `'value'` for a `ValueSeries`, `'category'` for a categorical
359
+ * (ordinal column-domain) layer. The container infers the one shared x scale
360
+ * from its layers (all must agree a mix is an error), so the axis kind never
361
+ * needs declaring. See {@link ContainerFrame.xScale}.
320
362
  */
321
- readonly xKind: 'time' | 'value';
363
+ readonly xKind: 'time' | 'value' | 'category';
322
364
  /**
323
365
  * This layer's `[min, max]` along the **x** axis (the key / value-axis extent),
324
366
  * or `null` if empty. The container unions these to auto-fit the shared x
325
- * domain when no explicit `range` is given.
367
+ * domain when no explicit `range` is given. For a `'category'` layer this is
368
+ * the slot extent `[0, n]` (n = category count).
326
369
  */
327
370
  xExtent(): readonly [number, number] | null;
371
+ /**
372
+ * A `'category'` layer's ordered category names (the ordinal axis domain the
373
+ * container builds a {@link ScaleBand} + label formatter from). `undefined` /
374
+ * absent for a `'time'` or `'value'` layer. Category layers in one container
375
+ * must agree on this list (a mix is an error), the same way {@link xKind} must.
376
+ */
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;
328
389
  /**
329
390
  * The layer's value(s) at `time` — the nearest sample — for the scrub tracker:
330
391
  * one for a line, two (lower/upper) for a band, empty at a gap. Each carries
@@ -394,8 +455,12 @@ export interface CursorFlag {
394
455
  */
395
456
  export interface TrackerSource {
396
457
  sampleAt(time: number): readonly TrackerSample[];
397
- readonly xKind: 'time' | 'value';
458
+ readonly xKind: 'time' | 'value' | 'category';
398
459
  xExtent(): readonly [number, number] | null;
460
+ /** A `'category'` source's ordered category names (see {@link RowLayer.xCategories}). */
461
+ xCategories?(): readonly string[] | null;
462
+ /** A bar/histogram source's bar `[begin, end)` spans (see {@link RowLayer.binIntervals}). */
463
+ binIntervals?(): readonly Interval[] | null;
399
464
  }
400
465
  /**
401
466
  * One selection — what {@link RowLayer.hitTest} returns and `onSelect` reports.
@@ -423,6 +488,16 @@ export interface SelectInfo {
423
488
  readonly color: string;
424
489
  /** Display label (`as` ?? column ?? id) — labels the selection in a readout. */
425
490
  readonly label: string;
491
+ /**
492
+ * An optional **stable per-mark identity within the layer** — a *category's
493
+ * column name* on the categorical axis, where every bar shares the layer's
494
+ * `id` but each column needs its own stable handle. When present, the
495
+ * highlight match + controlled `selected` echo key on `(id, mark)` instead of
496
+ * the sample `key`, so a pinned selection survives a column reorder / data
497
+ * update (the slot index is not stable; the column name is). `undefined` for
498
+ * marks whose sample `key` is already their identity (a time / value bar).
499
+ */
500
+ readonly mark?: string;
426
501
  }
427
502
  /** The hover snapshot handed to `onTrackerChanged` — the cursor time + every
428
503
  * series' value there, so a consumer can render the readout outside the chart. */
@@ -446,7 +521,7 @@ export interface TrackerInfo {
446
521
  * time pinned to the x-axis. The ChartIQ / trading-terminal readout. Values
447
522
  * snap to the series (the axis pills read like ticks), not the raw mouse Y.
448
523
  */
449
- export type CursorMode = 'none' | 'line' | 'point' | 'inline' | 'flag' | 'crosshair';
524
+ export type CursorMode = 'none' | 'line' | 'point' | 'inline' | 'flag' | 'crosshair' | 'region';
450
525
  /** A registered layer plus the axis id it draws against. */
451
526
  export interface LayerEntry {
452
527
  readonly layer: RowLayer;