@pond-ts/charts 0.42.0 → 0.43.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 CHANGED
@@ -8,7 +8,8 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
8
8
  tag, so this file covers them all. Pre-1.0: minor bumps may include new features
9
9
  and type-level changes; patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.42.0...HEAD
11
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.43.0...HEAD
12
+ [0.43.0]: https://github.com/pjm17971/pond-ts/compare/v0.42.0...v0.43.0
12
13
  [0.42.0]: https://github.com/pjm17971/pond-ts/compare/v0.41.0...v0.42.0
13
14
  [0.41.0]: https://github.com/pjm17971/pond-ts/compare/v0.40.0...v0.41.0
14
15
  [0.40.0]: https://github.com/pjm17971/pond-ts/compare/v0.39.0...v0.40.0
@@ -40,6 +41,77 @@ and type-level changes; patch bumps are strictly additive.
40
41
 
41
42
  ## [Unreleased]
42
43
 
44
+ ## [0.43.0] — 2026-07-11
45
+
46
+ The **categorical x-axis** release: a first-class ordinal band scale (ticker /
47
+ account / expiry on x — the transpose view of a time series), plus the charts
48
+ **interaction** wave that landed after v0.42.0 was cut — the region cursor and
49
+ its drag-to-select gesture, per-bin band colour, and annotation edges that snap
50
+ to session boundaries.
51
+
52
+ ### Added
53
+
54
+ - **`@pond-ts/charts`: a first-class categorical x-axis.** `<BarChart
55
+ categories={[{ label, value }]}>` draws one bar per category on an ordinal
56
+ **band scale** (the transpose view's "columns on x" — ticker / account / zone
57
+ on x). The container infers `xKind:'category'` and builds a `scaleBand` over the
58
+ labels; `<CategoryAxis>` ticks once per category. Colour per category via
59
+ `binColors`; selection reports the category name. **Negative** category values
60
+ draw below the baseline (the P&L / delta case) — a single-series category bar
61
+ honours its sign. New exports `scaleBand` / `ScaleBand`, `CategoryAxis`,
62
+ `categoryStack` / `CategoryDatum`. Additive — a new x-kind alongside time /
63
+ value; existing charts are unchanged. First slice of the categorical-axis RFC
64
+ (`docs/rfcs/categorical-axis.md`, Phase 1).
65
+ - **`@pond-ts/charts`: `transposeRow`** — read one **row** of a wide `TimeSeries`
66
+ **across** into `{ label, value }[]` for `<BarChart categories>`: the schema's
67
+ numeric columns (a `pivotByGroup` output's per-group columns, a term
68
+ structure's per-expiry columns) become the categories at one instant. Pick the
69
+ row with `at` (`'last'` — the head/live row — by default; `'first'`, an index,
70
+ or `{ time }`); bound / order the set with `columns`.
71
+ - **`@pond-ts/charts`: stable per-column selection identity.** `SelectInfo` gains
72
+ an optional **`mark`** — a stable per-mark identity within a layer. A categorical
73
+ bar reports its **column name** as `mark`, and a controlled `selected` echo /
74
+ the highlight match key on `(id, mark)`, so a pinned selection survives a column
75
+ reorder (the slot index doesn't; the name does). Additive — `mark` is
76
+ `undefined` for a time / value bar (whose sample `key` is already its identity).
77
+ Plus a category-axis **label policy**: a dense axis thins (keeps every k-th) and
78
+ ellipsis-truncates its labels so they stay legible while every bar draws.
79
+ - **`@pond-ts/charts`: region cursor (`cursor="region"`).** A shaded **band**
80
+ highlights the bucket under the pointer, bucketed by a new **`cursorSequence`**
81
+ prop — a `Sequence` (`Sequence.every('15m')`, `Sequence.calendar('week')`)
82
+ realized over the view, or a `BoundedSequence` (a `TradingCalendar`'s
83
+ `sessionSequence()` / `barSequence()`) used as-is. The band maps through the x
84
+ scale, so on a trading-time axis the closed part of a bucket collapses (crops
85
+ to live sessions). Time-axis only (a no-op on a value axis). (#409, #413)
86
+ - **`@pond-ts/charts`: draggable region cursor → one-shot select.** Opt-in
87
+ **`onRegionSelect?: (range: TimeRange) => void`** makes the region cursor
88
+ draggable: the band extends bucket by bucket and fires **once** on release
89
+ with the selected `[start, end)` `TimeRange` (the cursor doesn't keep it —
90
+ typical use is to zoom the view). With **no `cursorSequence`** it degenerates
91
+ to a hover **line** + **freeform** drag. **`regionSelectModifier="shift"`**
92
+ resolves the gesture conflict with `panZoom` (plain drag pans, shift-drag
93
+ selects); omitted, a region-drag preempts pan. (#416)
94
+ - **`@pond-ts/charts`: `binColors` — per-bin colour for single-series bars.**
95
+ `<BarChart binColors={[...]}>` colours each bar/band segment individually (one
96
+ colour per bin, in order), the single-series analog of the stacked `colors`
97
+ prop — used by the category axis (colour per category) and any single-series
98
+ band chart. (#408)
99
+ - **`@pond-ts/charts`: annotation edges snap to session boundaries.** When a
100
+ `<ChartContainer>` carries a trading calendar (disjoint x axis), dragging a
101
+ `<Region>` edge (or creating one) snaps to the nearest **session boundary**
102
+ rather than raw wall-clock, so a drawn span aligns with real market sessions.
103
+ (#410)
104
+
105
+ ### Fixed
106
+
107
+ - **`@pond-ts/charts`: region body-move no longer distorts across a session
108
+ boundary.** On a trading-time (discontinuous) axis, dragging a `<Region>`
109
+ annotation by its body now translates it rigidly in pixel space, so the box
110
+ keeps its width as it crosses a collapsed gap (it previously applied one
111
+ value-delta to both edges, which stretched the box in the different
112
+ rate-contexts either side of a session boundary). No-op on a continuous axis.
113
+ (#405)
114
+
43
115
  ## [0.42.0] — 2026-07-10
44
116
 
45
117
  The **trading-calendar** release: a new `@pond-ts/financial` package (its first
@@ -82,6 +154,16 @@ collapses closed-market time (weekends, holidays, overnight, lunch breaks).
82
154
  (`theme.axis.sessionDivider`), aligned with the labels.
83
155
  - `Charts/TradingTimeAxis` stories (weekend/holiday/half-day/intraday,
84
156
  continuous-vs-trading, daily-months, proportional-vs-uniform).
157
+ - **`@pond-ts/charts`: first-class histograms.** `<BarChart>` gains **stacking**
158
+ — a group-by dimension → stacked segments, from a wide series (`columns`), a
159
+ `Map<group, TimeSeries>` (the `partitionBy().aggregate().toMap()` shape), or a
160
+ `byColumn` `bins` array; per-group colour via `colors` or theme roles — and an
161
+ **`orientation`** prop (`'vertical'` default | `'horizontal'`, bars grow right
162
+ with the bins on a y band axis). New readers `stacksFromGroups` /
163
+ `stacksFromColumns` / `stacksFromBins` plus `StackedBarSeries` / `BinRecord` /
164
+ `Orientation` types. All data generation composes from existing operators
165
+ (`aggregate` / `byColumn` / `partitionBy`); no core changes. Guide: How-to
166
+ guides → Histograms. (#401)
85
167
  - **`@pond-ts/charts`:** selection now has a stable series identity. `SelectInfo`
86
168
  carries an `id`, and `BarChart` / `ScatterChart` take an optional `id` prop —
87
169
  the series identity used for selection + hover. An `id` **gates interactivity**:
@@ -1,6 +1,6 @@
1
1
  import { ValueSeries } from 'pond-ts';
2
2
  import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
3
- import { type BinRecord } from './data.js';
3
+ import { type BinRecord, type CategoryDatum } from './data.js';
4
4
  import { type Orientation } from './bars.js';
5
5
  export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
6
6
  /**
@@ -29,6 +29,17 @@ export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends
29
29
  * draw. Pair with `ordinal` for a category (band) axis.
30
30
  */
31
31
  bins?: readonly BinRecord[];
32
+ /**
33
+ * **Categorical** data — an ordered `{ label, value }[]`, one bar per category
34
+ * on a first-class **ordinal category x-axis** (the container infers
35
+ * `xKind:'category'` and builds a band scale over the labels). The transpose
36
+ * view's "columns on x": each `label` is a category (ticker / account / zone),
37
+ * `value` its bar height. Provide **exactly one** of `series` / `bins` /
38
+ * `categories`; `categories` takes no `column`/`columns` and is **vertical only**
39
+ * (categories on x). Colour per category via `binColors`. (Categorical-axis RFC,
40
+ * Phase 1.)
41
+ */
42
+ categories?: readonly CategoryDatum[];
32
43
  /** Name of the numeric value column for the bar height (single series). Provide
33
44
  * `column` **or** `columns`, not both. */
34
45
  column?: string;
@@ -55,6 +66,17 @@ export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends
55
66
  * this is the stack's one colour input.
56
67
  */
57
68
  colors?: Readonly<Record<string, string>>;
69
+ /**
70
+ * **Per-bin** colours for a single-series band chart — `binColors[i]` fills
71
+ * bar `i` (aligned to the bins / bands in order), overriding the `as`/theme
72
+ * fill. This is the way to colour heart-rate / power **zones** or value bands
73
+ * each their own colour (the `colors` map above is per-**group**, for stacks).
74
+ * An `undefined`/short entry falls back to the theme fill. Meant for a
75
+ * single-series chart (`column` + `bins`, or a horizontal single series); on a
76
+ * multi-group stack it would tint every segment of a bin alike, so it's not
77
+ * the tool there.
78
+ */
79
+ binColors?: readonly (string | undefined)[];
58
80
  /**
59
81
  * Bar growth direction (the histogram orientation). **Default `'vertical'`.**
60
82
  *
@@ -147,5 +169,5 @@ export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends
147
169
  * </Layers>
148
170
  * ```
149
171
  */
150
- export declare function BarChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, bins, column, columns, as: semantic, colors, orientation, ordinal, id, axis, gap, index, }: BarChartProps<S, VS>): null;
172
+ export declare function BarChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, bins, categories, column, columns, as: semantic, colors, binColors, orientation, ordinal, id, axis, gap, index, }: BarChartProps<S, VS>): null;
151
173
  //# sourceMappingURL=BarChart.d.ts.map
package/dist/BarChart.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
2
  import { ValueSeries } from 'pond-ts';
3
- import { barsFromTimeSeries, barsFromValueSeries, stacksFromBins, stacksFromColumns, stacksFromGroups, } from './data.js';
3
+ import { barsFromTimeSeries, barsFromValueSeries, categoryStack, stacksFromBins, stacksFromColumns, stacksFromGroups, } from './data.js';
4
4
  import { barAt, barExtent, barIndexAtTime, drawBars, drawStacks, resolveBarBaseline, stackAt, stackBinExtent, stackValueExtent, } from './bars.js';
5
5
  import { ContainerContext, LayersContext, } from './context.js';
6
6
  import { useSlotKey } from './use-slot-key.js';
@@ -47,7 +47,7 @@ import { useSlotKey } from './use-slot-key.js';
47
47
  * </Layers>
48
48
  * ```
49
49
  */
50
- export function BarChart({ series, bins, column, columns, as: semantic, colors, orientation = 'vertical', ordinal = false, id, axis, gap, index = 0, }) {
50
+ export function BarChart({ series, bins, categories, column, columns, as: semantic, colors, binColors, orientation = 'vertical', ordinal = false, id, axis, gap, index = 0, }) {
51
51
  const container = useContext(ContainerContext);
52
52
  if (container === null) {
53
53
  throw new Error('<BarChart> must be rendered inside a <ChartContainer>');
@@ -58,8 +58,19 @@ export function BarChart({ series, bins, column, columns, as: semantic, colors,
58
58
  }
59
59
  // Validate the data-source / value-column combination up front (throws are
60
60
  // stable across renders, so no need to memoize them).
61
- if ((series === undefined) === (bins === undefined)) {
62
- throw new Error('<BarChart> needs exactly one of `series` or `bins`');
61
+ const nSources = (series !== undefined ? 1 : 0) +
62
+ (bins !== undefined ? 1 : 0) +
63
+ (categories !== undefined ? 1 : 0);
64
+ if (nSources !== 1) {
65
+ throw new Error('<BarChart> needs exactly one of `series`, `bins`, or `categories`');
66
+ }
67
+ if (categories !== undefined) {
68
+ if (column !== undefined || columns !== undefined) {
69
+ throw new Error('<BarChart categories> takes no `column`/`columns` (each datum carries its own value)');
70
+ }
71
+ if (orientation === 'horizontal') {
72
+ throw new Error('<BarChart categories> is vertical only (categories on x); horizontal category axes are not yet supported');
73
+ }
63
74
  }
64
75
  const isMap = series instanceof Map;
65
76
  if (isMap && columns !== undefined) {
@@ -76,6 +87,12 @@ export function BarChart({ series, bins, column, columns, as: semantic, colors,
76
87
  // stack, any horizontal — builds a StackedBarSeries (G === 1 for a single
77
88
  // horizontal bar) so one oriented draw path covers it.
78
89
  const shape = useMemo(() => {
90
+ if (categories !== undefined) {
91
+ // Categorical row-read: one unit-slot bar per category (G === 1), drawn on
92
+ // the container's band scale. The reused stacked geometry — only the axis
93
+ // (band scale + labels) is new.
94
+ return { kind: 'stacked', ss: categoryStack(categories) };
95
+ }
79
96
  if (bins !== undefined) {
80
97
  const cols = columns ?? (column !== undefined ? [column] : undefined);
81
98
  if (cols === undefined) {
@@ -111,18 +128,33 @@ export function BarChart({ series, bins, column, columns, as: semantic, colors,
111
128
  ? barsFromValueSeries(s, column)
112
129
  : barsFromTimeSeries(s, column),
113
130
  };
114
- }, [series, bins, column, columns, ordinal, orientation, isMap, label]);
115
- // The bin axis kind (time vs value) — a `TimeSeries`/`Map` bins on time, a
116
- // `ValueSeries`/`bins`-array on a value axis. For a vertical histogram this is
117
- // the shared x-kind; a horizontal one puts the *value* on x (always 'value')
118
- // and the bin axis on a linear y.
119
- const binAxisKind = bins !== undefined
120
- ? 'value'
121
- : isMap
122
- ? 'time'
123
- : series instanceof ValueSeries
124
- ? 'value'
125
- : 'time';
131
+ }, [
132
+ series,
133
+ bins,
134
+ categories,
135
+ column,
136
+ columns,
137
+ ordinal,
138
+ orientation,
139
+ isMap,
140
+ label,
141
+ ]);
142
+ // The category labels — the ordinal axis's ordered column set (`xCategories`),
143
+ // and the per-bar readout label. `null` unless this is a categorical chart.
144
+ const categoryLabels = useMemo(() => categories?.map((c) => c.label) ?? null, [categories]);
145
+ // The bin axis kind — `'category'` for a categorical chart, else `'time'`/`'value'`
146
+ // (a `TimeSeries`/`Map` bins on time, a `ValueSeries`/`bins`-array on a value
147
+ // axis). For a vertical chart this is the shared x-kind; a horizontal one puts
148
+ // the *value* on x (always 'value') and the bin axis on a linear y.
149
+ const binAxisKind = categories !== undefined
150
+ ? 'category'
151
+ : bins !== undefined
152
+ ? 'value'
153
+ : isMap
154
+ ? 'time'
155
+ : series instanceof ValueSeries
156
+ ? 'value'
157
+ : 'time';
126
158
  const { bar } = container.theme;
127
159
  // Single-series style: the `as` role → theme bar style (the single channel).
128
160
  const singleStyle = (semantic !== undefined ? bar[semantic] : undefined) ?? bar.default;
@@ -137,8 +169,13 @@ export function BarChart({ series, bins, column, columns, as: semantic, colors,
137
169
  const stackStyle = useMemo(() => {
138
170
  const base = bar.default;
139
171
  const fills = (groups ?? []).map((g) => colors?.[g] ?? (bar[g] ?? base).fill);
140
- return { fills, opacity: base.opacity, outlineWidth: base.outlineWidth };
141
- }, [bar, groups, colors]);
172
+ return {
173
+ fills,
174
+ opacity: base.opacity,
175
+ outlineWidth: base.outlineWidth,
176
+ ...(binColors !== undefined ? { binFills: binColors } : {}),
177
+ };
178
+ }, [bar, groups, colors, binColors]);
142
179
  // The current selection / hover, narrowed to the identity the highlight match
143
180
  // needs. For a stack that's (id, key, label = group); the single path uses just
144
181
  // (id, key). Read here so a change re-registers the layer → the canvas repaints.
@@ -146,13 +183,21 @@ export function BarChart({ series, bins, column, columns, as: semantic, colors,
146
183
  const hoveredMark = container.hovered;
147
184
  const selection = useMemo(() => selected === null
148
185
  ? null
149
- : { id: selected.id, key: selected.key, label: selected.label }, [selected]);
186
+ : {
187
+ id: selected.id,
188
+ key: selected.key,
189
+ label: selected.label,
190
+ ...(selected.mark !== undefined ? { mark: selected.mark } : {}),
191
+ }, [selected]);
150
192
  const hover = useMemo(() => hoveredMark === null
151
193
  ? null
152
194
  : {
153
195
  id: hoveredMark.id,
154
196
  key: hoveredMark.key,
155
197
  label: hoveredMark.label,
198
+ ...(hoveredMark.mark !== undefined
199
+ ? { mark: hoveredMark.mark }
200
+ : {}),
156
201
  }, [hoveredMark]);
157
202
  const entry = useMemo(() => {
158
203
  // ── Single-series, vertical: the original bar path, pixels unchanged. ──
@@ -218,6 +263,11 @@ export function BarChart({ series, bins, column, columns, as: semantic, colors,
218
263
  xKind: vertical ? binAxisKind : 'value',
219
264
  xExtent: vertical ? binExtent : valueExtent,
220
265
  yExtent: vertical ? valueExtent : binExtent,
266
+ // A categorical chart hands the container its ordered category names — the
267
+ // ordinal axis domain the shared band scale + label formatter build on.
268
+ ...(categoryLabels !== null
269
+ ? { xCategories: () => categoryLabels }
270
+ : {}),
221
271
  // No x-scrub flag for a stack / horizontal chart — hover + click read it
222
272
  // out instead (the flag is single-series-vertical only).
223
273
  sampleAt: () => [],
@@ -228,13 +278,22 @@ export function BarChart({ series, bins, column, columns, as: semantic, colors,
228
278
  const hit = stackAt(ss, px, py, orientation, xScale, yScale, gapPx, stackMinWidth);
229
279
  if (hit === null)
230
280
  return null;
231
- const [, g, begin, name, value] = hit;
281
+ const [bi, g, begin, name, value] = hit;
282
+ // A categorical bar carries a stable per-bar `mark` (its column
283
+ // name); the selection keys on `(id, mark)` so it survives a
284
+ // reorder. `bi` is the exact bin index from the hit.
285
+ const stableMark = ss.marks?.[bi];
232
286
  return {
233
287
  id,
234
288
  key: begin,
235
289
  value,
236
- color: stackStyle.fills[g],
237
- label: name,
290
+ // A per-bin colour override wins over the group fill, so the
291
+ // readout pill reads the bar's own colour.
292
+ color: stackStyle.binFills?.[bi] ?? stackStyle.fills[g],
293
+ // A categorical bar reports its category name; a stack reports
294
+ // the group.
295
+ label: stableMark ?? name,
296
+ ...(stableMark !== undefined ? { mark: stableMark } : {}),
238
297
  };
239
298
  },
240
299
  }),
@@ -246,6 +305,7 @@ export function BarChart({ series, bins, column, columns, as: semantic, colors,
246
305
  }, [
247
306
  shape,
248
307
  binAxisKind,
308
+ categoryLabels,
249
309
  orientation,
250
310
  singleStyle,
251
311
  stackStyle,
@@ -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,55 @@ 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 `[start, end)` `TimeRange`, and
109
+ * the cursor reverts to the single-bucket highlight (it does not keep the
110
+ * range). Typical use — zoom the view to the returned range (the container
111
+ * doesn't zoom itself; that's the consumer's call).
112
+ *
113
+ * With **no `cursorSequence`** the region cursor is the degenerate case — it
114
+ * renders as a **line** on hover and the drag is **freeform** (raw `[start,
115
+ * end)`, no bucket snapping); the same callback fires on release. No-op unless
116
+ * `cursor="region"` (and a **time** x-axis).
117
+ */
118
+ onRegionSelect?: (range: TimeRange) => void;
119
+ /**
120
+ * Which modifier a region-drag needs — set `'shift'` when you also enable
121
+ * `panZoom` and want **plain drag to pan, shift-drag to select**. It's only
122
+ * enforced while `panZoom` is on (with pan off there's no gesture conflict, so
123
+ * shift is optional — either drag selects). **Omitted** ⇒ a region-drag
124
+ * **preempts** pan (drag always selects; document that precedence for users).
125
+ * Wheel-zoom is unaffected in every case.
126
+ */
127
+ regionSelectModifier?: 'shift';
81
128
  /**
82
129
  * Fires on pointer move with the hovered time + every series' value there (so
83
130
  * you can render a readout outside the chart), and `null` on leave.
@@ -226,5 +273,5 @@ export interface ChartContainerProps {
226
273
  * {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
227
274
  * (`<YAxis>`).
228
275
  */
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;
276
+ 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
277
  //# 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,42 @@ 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"` buckets: realize the `cursorSequence` over the current view
376
+ // (a `Sequence` → `.bounded`; a `BoundedSequence` used as-is), so the band can
377
+ // find the interval under the pointer. Memoized on the sequence + view range;
378
+ // a coarse sequence (days / sessions) is a handful of intervals.
379
+ // A `Sequence` bucket is a **time** interval, so gate it to a time axis — on a
380
+ // value axis (a horizontal histogram, a value-keyed chart) the value domain is
381
+ // not epoch-ms, so realizing time buckets over it is meaningless (it would
382
+ // shade the whole plot). Region cursor is time-axis only.
383
+ const cursorBuckets = useMemo(() => {
384
+ if (cursorSequence === undefined || resolvedKind !== 'time')
385
+ return undefined;
386
+ if (!(cursorSequence instanceof Sequence))
387
+ return cursorSequence.intervals();
388
+ // `bounded` (sample 'begin') drops a partial *leading* bucket — the one that
389
+ // contains the view start begins before it. Widen the realized range back by
390
+ // one bucket width so that covering bucket is included (a coarse calendar
391
+ // unit is bounded at ~a year; a fixed step uses its own width).
392
+ const back = cursorSequence.kind() === 'fixed'
393
+ ? cursorSequence.stepMs()
394
+ : 366 * 86_400_000;
395
+ return cursorSequence.bounded({ start: d0 - back, end: d1 }).intervals();
396
+ }, [cursorSequence, d0, d1, resolvedKind]);
332
397
  // Emit { time, values } for an outside readout — recomputed as the cursor moves
333
398
  // *or* the window slides under it (xScale change → new time at the same pixel).
334
399
  // Out of the plot (null, or a controlled trackerPosition d3 extrapolated past
@@ -369,6 +434,11 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
369
434
  cursorRowKey: hoverPoint?.rowKey ?? null,
370
435
  setHoverY,
371
436
  crosshairSnap,
437
+ cursorBuckets,
438
+ regionAnchor,
439
+ setRegionAnchor,
440
+ onRegionSelect,
441
+ regionSelectModifier,
372
442
  draggingKey,
373
443
  setDragging,
374
444
  selected: selectedValue,
@@ -417,6 +487,11 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
417
487
  hoverPoint,
418
488
  setHoverY,
419
489
  crosshairSnap,
490
+ cursorBuckets,
491
+ regionAnchor,
492
+ setRegionAnchor,
493
+ onRegionSelect,
494
+ regionSelectModifier,
420
495
  draggingKey,
421
496
  setDragging,
422
497
  selectedValue,