@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/data.d.ts CHANGED
@@ -30,19 +30,30 @@ export interface BandSeries {
30
30
  readonly length: number;
31
31
  }
32
32
  /**
33
- * A chart-ready view of a box-and-whisker series ({@link BoxPlot}): the
34
- * interval-keyed time axis (`x` = key `begin`, `xEnd` = key `end`, the box's
35
- * horizontal span) plus the five quantile edges per key —
36
- * `lower`/`q1`/`median`/`q3`/`upper`. The quantiles are pre-computed columns
37
- * (a `rolling`/`aggregate` percentile pass upstream); the chart only reads them.
38
- *
39
- * A key is drawn only where **all five** quantiles are finite; any one `NaN` is
40
- * a gap (the box draws nothing same gap contract as {@link BandSeries}).
41
- *
42
- * `x` and `xEnd` are zero-copy views of the key column's `begin`/`end` buffers
43
- * (immutable by contract do not mutate). For a point-in-time key the column's
44
- * `end` coincides with `begin`, so `xEnd === x` and the box collapses to a
45
- * minimum-width mark via `barSpanPx`; an interval key gives the box real width.
33
+ * A chart-ready view of a box-and-whisker series ({@link BoxPlot}): a horizontal
34
+ * span (`x`/`xEnd`) per key plus its quantile edges the required
35
+ * `lower`/`upper` (whisker reach) and the optional `q1`/`median`/`q3` (box body +
36
+ * centre line). The quantiles are pre-computed columns (a `rolling`/`aggregate`
37
+ * percentile pass upstream); the chart only reads them.
38
+ *
39
+ * A key is drawn only where the quantiles it **carries** are all finite (a full
40
+ * box needs all five; a range-only box just `lower`/`upper`); any present one
41
+ * `NaN` is a gap (the box draws nothing — same gap contract as {@link BandSeries}).
42
+ *
43
+ * `x` and `xEnd` are the box's horizontal span. An **interval**-keyed
44
+ * `TimeSeries` uses the key's own `[begin, end)`; a **point**-keyed `TimeSeries`
45
+ * (or a `ValueSeries`, always point-keyed on its value axis) synthesizes the span
46
+ * from **neighbour spacing** (each box centred on its key, reaching halfway to
47
+ * each neighbour — the same rule as bars / candles), so a point series still gets
48
+ * real box width instead of collapsing to the 1px floor.
49
+ *
50
+ * **Range-only boxes.** `q1`/`median`/`q3` are optional at the source (a bid→ask
51
+ * IV segment is a degenerate box — whiskers only, no body). `hasBox` is `false`
52
+ * when `q1`/`q3` were omitted (no box body; the whisker runs the full
53
+ * `lower→upper`), and `hasMedian` is `false` when `median` was omitted (no centre
54
+ * line). Absent quantile buffers are all-`NaN`; the flags — not the buffers —
55
+ * decide what draws, so an absent quantile isn't confused with a per-row gap.
56
+ * Both default to `true` (a full five-number box) when unset.
46
57
  */
47
58
  export interface BoxSeries {
48
59
  readonly x: Float64Array;
@@ -53,6 +64,10 @@ export interface BoxSeries {
53
64
  readonly q3: Float64Array;
54
65
  readonly upper: Float64Array;
55
66
  readonly length: number;
67
+ /** `false` ⇒ `q1`/`q3` absent (range-only box, no body). Default `true`. */
68
+ readonly hasBox?: boolean;
69
+ /** `false` ⇒ `median` absent (no centre line). Default `true`. */
70
+ readonly hasMedian?: boolean;
56
71
  }
57
72
  /**
58
73
  * A chart-ready view of an OHLC series ({@link Candlestick}): the candle's
@@ -122,18 +137,32 @@ export interface StackedBarSeries {
122
137
  readonly groups: readonly string[];
123
138
  readonly values: Float64Array;
124
139
  readonly length: number;
140
+ /**
141
+ * Optional **stable per-bin identity** — `marks[b]` names bin `b` (a category's
142
+ * column name on the categorical axis). When present, the draw / hit-test /
143
+ * selection key on this name instead of the bin's `begin` slot index, so a
144
+ * pinned selection survives a column reorder (the slot index is not stable).
145
+ * `undefined` for a time / value series whose `begin` is already stable.
146
+ */
147
+ readonly marks?: readonly string[];
125
148
  }
126
- /** The five quantile column names a {@link boxFromTimeSeries} reads, in order. */
149
+ /**
150
+ * The quantile column names a {@link boxFromTimeSeries} / {@link boxFromValueSeries}
151
+ * reads. `lower`/`upper` (the whisker reach) are required; `q1`/`q3` (the box
152
+ * body) and `median` (the centre line) are **optional** — omit them for a
153
+ * range-only box (a bid→ask IV segment: whiskers only, no body). Omitting exactly
154
+ * one of `q1`/`q3` is a data error (a box needs both edges or neither).
155
+ */
127
156
  export interface BoxColumns {
128
- /** Lower whisker end (e.g. `p5` / `min`). */
157
+ /** Lower whisker end (e.g. `p5` / `min`). Required. */
129
158
  readonly lower: string;
130
- /** Box bottom — first quartile (e.g. `p25`). */
131
- readonly q1: string;
132
- /** Median line inside the box (e.g. `p50`). */
133
- readonly median: string;
134
- /** Box top — third quartile (e.g. `p75`). */
135
- readonly q3: string;
136
- /** Upper whisker end (e.g. `p95` / `max`). */
159
+ /** Box bottom — first quartile (e.g. `p25`). Omit with `q3` for a range-only box. */
160
+ readonly q1?: string | undefined;
161
+ /** Median line inside the box (e.g. `p50`). Omit for no centre line. */
162
+ readonly median?: string | undefined;
163
+ /** Box top — third quartile (e.g. `p75`). Omit with `q1` for a range-only box. */
164
+ readonly q3?: string | undefined;
165
+ /** Upper whisker end (e.g. `p95` / `max`). Required. */
137
166
  readonly upper: string;
138
167
  }
139
168
  /** The four OHLC column names {@link ohlcFromTimeSeries} reads. */
@@ -197,16 +226,38 @@ export declare function bandFromTimeSeries<S extends SeriesSchema>(series: TimeS
197
226
  */
198
227
  export declare function bandFromValueSeries<VS extends ValueSeriesSchema>(series: ValueSeries<VS>, lower: string, upper: string): BandSeries;
199
228
  /**
200
- * Build a {@link BoxSeries} from a pond `TimeSeries` five numeric quantile
201
- * columns (`lower`/`q1`/`median`/`q3`/`upper`) sharing the series' interval time
202
- * axis (`begin`/`end`, the box's horizontal span). The quantile columns are
203
- * typically `rolling`/`aggregate` percentiles (e.g. p5/p25/p50/p75/p95); a key
204
- * with any quantile missing reads as a gap (the box draws nothing).
205
- *
206
- * @throws RangeError if any quantile column does not exist.
207
- * @throws TypeError if any quantile column is not a numeric column.
229
+ * Build a {@link BoxSeries} from a pond `TimeSeries`. `lower`/`upper` (the whisker
230
+ * reach) are required; `q1`/`q3` (the box body) and `median` (the centre line)
231
+ * are optional omit them for a **range-only** box (a bid→ask segment). The
232
+ * quantile columns are typically `rolling`/`aggregate` percentiles; a key with
233
+ * any **present** quantile missing reads as a gap (the box draws nothing).
234
+ *
235
+ * **Key-shape aware, like {@link ohlcFromTimeSeries}.** An **interval /
236
+ * timeRange**-keyed series uses the key's own `[begin, end)` as the box span; a
237
+ * **point**-keyed (`time`) series synthesizes the span from neighbour spacing
238
+ * (each box centred on its timestamp, halfway to each neighbour), so a raw
239
+ * percentile-per-timestamp feed renders as contiguous boxes instead of collapsing
240
+ * to the 1px floor.
241
+ *
242
+ * @throws RangeError if any named quantile column does not exist, or if exactly
243
+ * one of `q1`/`q3` is given.
244
+ * @throws TypeError if any named quantile column is not a numeric column.
208
245
  */
209
246
  export declare function boxFromTimeSeries<S extends SeriesSchema>(series: TimeSeries<S>, columns: BoxColumns): BoxSeries;
247
+ /**
248
+ * Build a {@link BoxSeries} from a pond `ValueSeries` — the value-axis sibling of
249
+ * {@link boxFromTimeSeries} (a volatility smile's per-strike bid/ask IV segments,
250
+ * a per-strike intraday IV distribution). A `ValueSeries` is **point-keyed** on
251
+ * its value axis, so the box span comes from **neighbour spacing** on
252
+ * `axisValues()` (each box centred on its axis value, halfway to each neighbour —
253
+ * the same rule as {@link barsFromValueSeries}), instead of collapsing to a point.
254
+ * Same optional-quantile / range-only contract as the time reader.
255
+ *
256
+ * @throws RangeError if any named quantile column does not exist, or if exactly
257
+ * one of `q1`/`q3` is given.
258
+ * @throws TypeError if any named quantile column is not a numeric column.
259
+ */
260
+ export declare function boxFromValueSeries<VS extends ValueSeriesSchema>(series: ValueSeries<VS>, columns: BoxColumns): BoxSeries;
210
261
  /**
211
262
  * Build an {@link OhlcSeries} from a pond `TimeSeries` — four numeric price
212
263
  * columns (`open`/`high`/`low`/`close`) plus the candle's horizontal slot.
@@ -343,4 +394,68 @@ export interface StacksFromBinsOptions {
343
394
  * A missing / non-finite aggregate reads as a gap (`NaN`).
344
395
  */
345
396
  export declare function stacksFromBins(bins: readonly BinRecord[], columns: readonly string[], options?: StacksFromBinsOptions): StackedBarSeries;
397
+ /**
398
+ * One category's `{ label, value }` for a categorical bar chart — the row-read /
399
+ * transpose view's `(columnName, cell)` pair (categorical-axis RFC, Phase 1). An
400
+ * ordered list of these is the explicit categorical data source; Phase 2's
401
+ * transpose reader produces the same list from a wide series' row.
402
+ *
403
+ * **Labels are the stable identity** — the axis maps each `label` to a slot and
404
+ * selection/highlight key on it (so a pick survives a reorder). They must be
405
+ * **unique** within the list: two categories sharing a label collapse to one axis
406
+ * tick and both highlight together on a pick. The transpose reader satisfies this
407
+ * for free (a series' column names are unique); only a hand-built list can break
408
+ * it. `value` may be **negative** — a single-series category bar draws it below
409
+ * the baseline (the P&L / delta case).
410
+ */
411
+ export interface CategoryDatum {
412
+ readonly label: string;
413
+ readonly value: number;
414
+ }
415
+ /**
416
+ * Build a {@link StackedBarSeries} (single group, `G === 1`) from an ordered list
417
+ * of `{ label, value }` categories — one **unit slot** `[i, i+1]` per category, in
418
+ * order. This is the categorical row-read's geometry: the slots are ordinal
419
+ * indices (the bar's pixel span comes from the container's {@link ScaleBand}), and
420
+ * the `label`s become the axis's ordered category names (`xCategories`). A
421
+ * non-finite value reads as a gap (`NaN`). Reuses the shipped stacked geometry —
422
+ * no new draw path.
423
+ */
424
+ export declare function categoryStack(records: readonly CategoryDatum[]): StackedBarSeries;
425
+ /** Which row {@link transposeRow} reads across. */
426
+ export type RowAt = 'first' | 'last' | number | {
427
+ readonly time: number;
428
+ };
429
+ /** Options for {@link transposeRow}. */
430
+ export interface TransposeRowOptions {
431
+ /**
432
+ * Which row to read across. **Default `'last'`** — the head / latest row (the
433
+ * live snapshot). `'first'`, an **index** (negative from the end), or
434
+ * `{ time }` for the row nearest a key.
435
+ */
436
+ readonly at?: RowAt;
437
+ /**
438
+ * The columns to lay on the axis, **in order** — a declared / bounded set (a
439
+ * watchlist, or a top-N computed upstream; the RFC §7 "bound in the data layer"
440
+ * stance). Omit to use **every numeric value column** of the series, in schema
441
+ * order. A named column that's missing / non-numeric in the row reads as a gap.
442
+ */
443
+ readonly columns?: readonly string[];
444
+ }
445
+ /**
446
+ * **The transpose reader** (categorical-axis RFC, Phase 1 PR2): read **one row**
447
+ * of a wide `TimeSeries` **across** — its columns become the categories, that
448
+ * row's cells the values — for `<BarChart categories={…}>`. This is "columns on
449
+ * x": the schema's numeric columns (a `pivotByGroup` output's per-group columns,
450
+ * a vol term structure's per-expiry columns, …) laid out at one instant.
451
+ *
452
+ * The row is picked the ordinary way (`options.at`, default the **head row**):
453
+ * `series.last()` / `.first()` / `.at(index)` / `.nearest(time)`. So "which row"
454
+ * is just row selection — the live snapshot is the head row; a static report
455
+ * pins a row by index or time. (Binding the row to a scrubbing time cursor is a
456
+ * later phase.) Pass `options.columns` to bound / order the category set; omit it
457
+ * to take every numeric value column. An empty series (or a row past the ends)
458
+ * yields `[]`; a missing / non-numeric cell reads as a gap (`NaN`).
459
+ */
460
+ export declare function transposeRow<S extends SeriesSchema>(series: TimeSeries<S>, options?: TransposeRowOptions): CategoryDatum[];
346
461
  //# sourceMappingURL=data.d.ts.map
package/dist/data.js CHANGED
@@ -65,14 +65,6 @@ function timeAxis(series) {
65
65
  // it lines up with the value arrays.
66
66
  return series.keyColumn().begin.subarray(0, series.length);
67
67
  }
68
- /**
69
- * The key column's `end` buffer aligned to the logical length (zero-copy). For a
70
- * point-in-time key the column sets `end === begin`, so this returns the same
71
- * timestamps as {@link timeAxis} — an interval key gives a distinct span.
72
- */
73
- function timeEndAxis(series) {
74
- return series.keyColumn().end.subarray(0, series.length);
75
- }
76
68
  /**
77
69
  * Build a {@link ChartSeries} from a pond `TimeSeries` by reading its columnar
78
70
  * buffers directly — no per-event materialization. `column` names a numeric
@@ -150,25 +142,91 @@ export function bandFromValueSeries(series, lower, upper) {
150
142
  };
151
143
  }
152
144
  /**
153
- * Build a {@link BoxSeries} from a pond `TimeSeries` five numeric quantile
154
- * columns (`lower`/`q1`/`median`/`q3`/`upper`) sharing the series' interval time
155
- * axis (`begin`/`end`, the box's horizontal span). The quantile columns are
156
- * typically `rolling`/`aggregate` percentiles (e.g. p5/p25/p50/p75/p95); a key
157
- * with any quantile missing reads as a gap (the box draws nothing).
145
+ * Reject a half-specified box body `q1`/`q3` are both-or-neither (a box needs
146
+ * two edges or none). Called by both box readers before they build.
147
+ */
148
+ function validateBoxColumns(columns) {
149
+ if ((columns.q1 === undefined) !== (columns.q3 === undefined)) {
150
+ throw new RangeError(`BoxPlot: 'q1' and 'q3' are both-or-neither — a box body needs both edges ` +
151
+ `(got q1=${columns.q1 ?? 'undefined'}, q3=${columns.q3 ?? 'undefined'}). ` +
152
+ `Omit both for a range-only box (whiskers lower→upper).`);
153
+ }
154
+ }
155
+ /** An all-`NaN` buffer of length `n` — the value channel of an absent quantile. */
156
+ function nanBuffer(n) {
157
+ return new Float64Array(n).fill(NaN);
158
+ }
159
+ /**
160
+ * Build a {@link BoxSeries} from a pond `TimeSeries`. `lower`/`upper` (the whisker
161
+ * reach) are required; `q1`/`q3` (the box body) and `median` (the centre line)
162
+ * are optional — omit them for a **range-only** box (a bid→ask segment). The
163
+ * quantile columns are typically `rolling`/`aggregate` percentiles; a key with
164
+ * any **present** quantile missing reads as a gap (the box draws nothing).
158
165
  *
159
- * @throws RangeError if any quantile column does not exist.
160
- * @throws TypeError if any quantile column is not a numeric column.
166
+ * **Key-shape aware, like {@link ohlcFromTimeSeries}.** An **interval /
167
+ * timeRange**-keyed series uses the key's own `[begin, end)` as the box span; a
168
+ * **point**-keyed (`time`) series synthesizes the span from neighbour spacing
169
+ * (each box centred on its timestamp, halfway to each neighbour), so a raw
170
+ * percentile-per-timestamp feed renders as contiguous boxes instead of collapsing
171
+ * to the 1px floor.
172
+ *
173
+ * @throws RangeError if any named quantile column does not exist, or if exactly
174
+ * one of `q1`/`q3` is given.
175
+ * @throws TypeError if any named quantile column is not a numeric column.
161
176
  */
162
177
  export function boxFromTimeSeries(series, columns) {
178
+ validateBoxColumns(columns);
179
+ const n = series.length;
180
+ const hasBox = columns.q1 !== undefined;
181
+ const hasMedian = columns.median !== undefined;
182
+ const { begin, end } = series.keyColumn().kind !== 'time'
183
+ ? keyBeginEnd(series) // interval / timeRange: the key's own span
184
+ : neighbourSpans(series.keyColumn().begin, n); // point: neighbour spacing
163
185
  return {
164
- x: timeAxis(series),
165
- xEnd: timeEndAxis(series),
186
+ x: begin,
187
+ xEnd: end,
166
188
  lower: readNumericColumn(series, columns.lower),
167
- q1: readNumericColumn(series, columns.q1),
168
- median: readNumericColumn(series, columns.median),
169
- q3: readNumericColumn(series, columns.q3),
189
+ q1: hasBox ? readNumericColumn(series, columns.q1) : nanBuffer(n),
190
+ median: hasMedian
191
+ ? readNumericColumn(series, columns.median)
192
+ : nanBuffer(n),
193
+ q3: hasBox ? readNumericColumn(series, columns.q3) : nanBuffer(n),
170
194
  upper: readNumericColumn(series, columns.upper),
171
- length: series.length,
195
+ length: n,
196
+ hasBox,
197
+ hasMedian,
198
+ };
199
+ }
200
+ /**
201
+ * Build a {@link BoxSeries} from a pond `ValueSeries` — the value-axis sibling of
202
+ * {@link boxFromTimeSeries} (a volatility smile's per-strike bid/ask IV segments,
203
+ * a per-strike intraday IV distribution). A `ValueSeries` is **point-keyed** on
204
+ * its value axis, so the box span comes from **neighbour spacing** on
205
+ * `axisValues()` (each box centred on its axis value, halfway to each neighbour —
206
+ * the same rule as {@link barsFromValueSeries}), instead of collapsing to a point.
207
+ * Same optional-quantile / range-only contract as the time reader.
208
+ *
209
+ * @throws RangeError if any named quantile column does not exist, or if exactly
210
+ * one of `q1`/`q3` is given.
211
+ * @throws TypeError if any named quantile column is not a numeric column.
212
+ */
213
+ export function boxFromValueSeries(series, columns) {
214
+ validateBoxColumns(columns);
215
+ const n = series.length;
216
+ const hasBox = columns.q1 !== undefined;
217
+ const hasMedian = columns.median !== undefined;
218
+ const { begin, end } = neighbourSpans(series.axisValues(), n);
219
+ return {
220
+ x: begin,
221
+ xEnd: end,
222
+ lower: readValueColumn(series, columns.lower),
223
+ q1: hasBox ? readValueColumn(series, columns.q1) : nanBuffer(n),
224
+ median: hasMedian ? readValueColumn(series, columns.median) : nanBuffer(n),
225
+ q3: hasBox ? readValueColumn(series, columns.q3) : nanBuffer(n),
226
+ upper: readValueColumn(series, columns.upper),
227
+ length: n,
228
+ hasBox,
229
+ hasMedian,
172
230
  };
173
231
  }
174
232
  /**
@@ -452,4 +510,71 @@ export function stacksFromBins(bins, columns, options = {}) {
452
510
  }
453
511
  return { begin, end, groups: columns, values, length: n };
454
512
  }
513
+ /**
514
+ * Build a {@link StackedBarSeries} (single group, `G === 1`) from an ordered list
515
+ * of `{ label, value }` categories — one **unit slot** `[i, i+1]` per category, in
516
+ * order. This is the categorical row-read's geometry: the slots are ordinal
517
+ * indices (the bar's pixel span comes from the container's {@link ScaleBand}), and
518
+ * the `label`s become the axis's ordered category names (`xCategories`). A
519
+ * non-finite value reads as a gap (`NaN`). Reuses the shipped stacked geometry —
520
+ * no new draw path.
521
+ */
522
+ export function categoryStack(records) {
523
+ const n = records.length;
524
+ const begin = new Float64Array(n);
525
+ const end = new Float64Array(n);
526
+ const values = new Float64Array(n);
527
+ const marks = new Array(n);
528
+ for (let i = 0; i < n; i += 1) {
529
+ begin[i] = i;
530
+ end[i] = i + 1;
531
+ const v = records[i].value;
532
+ values[i] = Number.isFinite(v) ? v : NaN;
533
+ marks[i] = records[i].label;
534
+ }
535
+ // `marks` carry the category names — the stable per-bar identity the categorical
536
+ // axis selects on (the slot index `begin` renumbers on reorder; the name doesn't).
537
+ return { begin, end, groups: ['value'], values, length: n, marks };
538
+ }
539
+ /** A series' numeric value column names in schema order (the key column excluded). */
540
+ function numericValueColumns(series) {
541
+ const schema = series.schema;
542
+ return schema
543
+ .slice(1)
544
+ .filter((c) => c.kind === 'number')
545
+ .map((c) => c.name);
546
+ }
547
+ /**
548
+ * **The transpose reader** (categorical-axis RFC, Phase 1 PR2): read **one row**
549
+ * of a wide `TimeSeries` **across** — its columns become the categories, that
550
+ * row's cells the values — for `<BarChart categories={…}>`. This is "columns on
551
+ * x": the schema's numeric columns (a `pivotByGroup` output's per-group columns,
552
+ * a vol term structure's per-expiry columns, …) laid out at one instant.
553
+ *
554
+ * The row is picked the ordinary way (`options.at`, default the **head row**):
555
+ * `series.last()` / `.first()` / `.at(index)` / `.nearest(time)`. So "which row"
556
+ * is just row selection — the live snapshot is the head row; a static report
557
+ * pins a row by index or time. (Binding the row to a scrubbing time cursor is a
558
+ * later phase.) Pass `options.columns` to bound / order the category set; omit it
559
+ * to take every numeric value column. An empty series (or a row past the ends)
560
+ * yields `[]`; a missing / non-numeric cell reads as a gap (`NaN`).
561
+ */
562
+ export function transposeRow(series, options = {}) {
563
+ const at = options.at ?? 'last';
564
+ const event = at === 'last'
565
+ ? series.last()
566
+ : at === 'first'
567
+ ? series.first()
568
+ : typeof at === 'number'
569
+ ? series.at(at)
570
+ : series.nearest(at.time);
571
+ if (event === undefined)
572
+ return [];
573
+ const cols = options.columns ?? numericValueColumns(series);
574
+ const get = event.get.bind(event);
575
+ return cols.map((name) => {
576
+ const v = get(name);
577
+ return { label: name, value: typeof v === 'number' ? v : NaN };
578
+ });
579
+ }
455
580
  //# sourceMappingURL=data.js.map
package/dist/index.d.ts CHANGED
@@ -29,6 +29,7 @@ export type { YAxisProps } from './YAxis.js';
29
29
  export { XAxis } from './XAxis.js';
30
30
  export type { XAxisProps } from './XAxis.js';
31
31
  export { TimeAxis } from './TimeAxis.js';
32
+ export { CategoryAxis } from './CategoryAxis.js';
32
33
  export type { AxisFormat } from './format.js';
33
34
  export { LineChart } from './LineChart.js';
34
35
  export type { LineChartProps } from './LineChart.js';
@@ -47,13 +48,15 @@ export type { CandlestickProps } from './Candlestick.js';
47
48
  export type { CandleVariant, ColorBy } from './ohlc.js';
48
49
  export { scaleTradingTime } from './tradingTimeScale.js';
49
50
  export type { TradingTimeScale, DiscontinuityProvider, } from './tradingTimeScale.js';
51
+ export { scaleBand } from './bandScale.js';
52
+ export type { ScaleBand } from './bandScale.js';
50
53
  export { Region, Baseline, Marker } from './annotations.js';
51
54
  export type { RegionProps, BaselineProps, MarkerProps } from './annotations.js';
52
55
  export type { AnnotationKind, CreateSpec } from './context.js';
53
56
  export { YAxisIndicator, createLiveValue } from './indicators.js';
54
57
  export type { YAxisIndicatorProps, LiveValue } from './indicators.js';
55
- export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, ohlcFromTimeSeries, stacksFromGroups, stacksFromColumns, stacksFromBins, } from './data.js';
56
- export type { ChartSeries, BandSeries, BoxSeries, BoxColumns, BarSeries, OhlcSeries, OhlcColumns, StackedBarSeries, BinRecord, StacksFromBinsOptions, } from './data.js';
58
+ export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, ohlcFromTimeSeries, stacksFromGroups, stacksFromColumns, stacksFromBins, categoryStack, transposeRow, } from './data.js';
59
+ export type { ChartSeries, BandSeries, BoxSeries, BoxColumns, BarSeries, OhlcSeries, OhlcColumns, StackedBarSeries, BinRecord, StacksFromBinsOptions, CategoryDatum, RowAt, TransposeRowOptions, } from './data.js';
57
60
  export type { Orientation } from './bars.js';
58
61
  export type { RadiusEncoding, ColorEncoding } from './encoding.js';
59
62
  export type { Curve } from './curve.js';
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ export { Layers } from './Layers.js';
23
23
  export { YAxis } from './YAxis.js';
24
24
  export { XAxis } from './XAxis.js';
25
25
  export { TimeAxis } from './TimeAxis.js';
26
+ export { CategoryAxis } from './CategoryAxis.js';
26
27
  export { LineChart } from './LineChart.js';
27
28
  export { BandChart } from './BandChart.js';
28
29
  export { AreaChart } from './AreaChart.js';
@@ -31,6 +32,8 @@ export { BoxPlot } from './BoxPlot.js';
31
32
  export { BarChart } from './BarChart.js';
32
33
  export { Candlestick } from './Candlestick.js';
33
34
  export { scaleTradingTime } from './tradingTimeScale.js';
35
+ // The ordinal category (band) scale — the transpose view's "columns on x" axis.
36
+ export { scaleBand } from './bandScale.js';
34
37
  // Annotations — user-authored marks in the turquoise register (distinct from the
35
38
  // data): a shaded span, a horizontal value line, a vertical x line.
36
39
  export { Region, Baseline, Marker } from './annotations.js';
@@ -40,7 +43,11 @@ export { YAxisIndicator, createLiveValue } from './indicators.js';
40
43
  export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, ohlcFromTimeSeries,
41
44
  // Stacked / histogram readers — assemble a StackedBarSeries from pond's own
42
45
  // aggregation output: a Map of grouped series, a wide series, or byColumn bins.
43
- stacksFromGroups, stacksFromColumns, stacksFromBins, } from './data.js';
46
+ stacksFromGroups, stacksFromColumns, stacksFromBins,
47
+ // Categorical row-read: one bar per `{ label, value }` on the category axis.
48
+ categoryStack,
49
+ // The transpose reader — one row of a wide series read across into categories.
50
+ transposeRow, } from './data.js';
44
51
  export { defaultTheme, estelaTheme } from './theme.js';
45
52
  // CSS-custom-property → ChartTheme bridge: build a theme from a design system's
46
53
  // tokens (`cssVarTheme`), and a hook that re-resolves it on a `data-theme`
package/dist/scatter.d.ts CHANGED
@@ -52,7 +52,7 @@ export declare function scatterExtent(cs: ChartSeries): [number, number] | null;
52
52
  export declare function drawScatter(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: ScatterStyle, encoding: ResolvedEncoding, keyAt: (i: number) => number, labelAt: ((i: number) => string | undefined) | undefined, font: {
53
53
  readonly family: string;
54
54
  readonly size: number;
55
- }, selected: SelectInfo | null, seriesId: string | undefined): void;
55
+ }, selected: SelectInfo | null, seriesId: string | undefined, offsetPx?: number): void;
56
56
  /**
57
57
  * Hit-test plot-pixel `(qx, qy)` against the scatter's points — the topmost
58
58
  * point whose circle contains the click, or `null`. "Topmost" = the
@@ -68,5 +68,5 @@ export declare function drawScatter(ctx: CanvasRenderingContext2D, cs: ChartSeri
68
68
  * Pure: takes the same `xScale`/`yScale` the row hands to `draw`, so it
69
69
  * unit-tests without a DOM (mirrors the `sampleAt` / `resolveSelection` split).
70
70
  */
71
- export declare function hitTestScatter(cs: ChartSeries, qx: number, qy: number, xScale: Scale, yScale: Scale, encoding: ResolvedEncoding, keyAt: (i: number) => number, id: string, seriesLabel: string): SelectInfo | null;
71
+ export declare function hitTestScatter(cs: ChartSeries, qx: number, qy: number, xScale: Scale, yScale: Scale, encoding: ResolvedEncoding, keyAt: (i: number) => number, id: string, seriesLabel: string, offsetPx?: number): SelectInfo | null;
72
72
  //# sourceMappingURL=scatter.d.ts.map
package/dist/scatter.js CHANGED
@@ -114,7 +114,7 @@ export function scatterExtent(cs) {
114
114
  * of the selection match. A point lights only when the selection's
115
115
  * `id` matches, keyed to the sample by its `key`.
116
116
  */
117
- export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesId) {
117
+ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesId, offsetPx = 0) {
118
118
  ctx.save();
119
119
  // The selection only lights up a point of *this* series; resolve the key once.
120
120
  // A no-id (non-selectable) layer passes `undefined` and never matches.
@@ -126,7 +126,9 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
126
126
  for (let i = 0; i < cs.length; i += 1) {
127
127
  if (!isPoint(cs, i))
128
128
  continue;
129
- const px = xScale(cs.x[i]);
129
+ // `offsetPx` nudges the whole scatter in pixel space (zoom-stable) — for
130
+ // pairing same-key marks (call/put at one strike) beside each other.
131
+ const px = xScale(cs.x[i]) + offsetPx;
130
132
  const py = yScale(cs.y[i]);
131
133
  const r = encoding.radiusAt(i);
132
134
  ctx.beginPath();
@@ -166,7 +168,7 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
166
168
  const text = labelAt(i);
167
169
  if (text === undefined || text === '')
168
170
  continue;
169
- const px = xScale(cs.x[i]);
171
+ const px = xScale(cs.x[i]) + offsetPx;
170
172
  const py = yScale(cs.y[i]);
171
173
  const r = encoding.radiusAt(i);
172
174
  // Sit the label just right of the point (past its radius), vertically
@@ -193,11 +195,12 @@ const LABEL_GAP = 4;
193
195
  * Pure: takes the same `xScale`/`yScale` the row hands to `draw`, so it
194
196
  * unit-tests without a DOM (mirrors the `sampleAt` / `resolveSelection` split).
195
197
  */
196
- export function hitTestScatter(cs, qx, qy, xScale, yScale, encoding, keyAt, id, seriesLabel) {
198
+ export function hitTestScatter(cs, qx, qy, xScale, yScale, encoding, keyAt, id, seriesLabel, offsetPx = 0) {
197
199
  for (let i = cs.length - 1; i >= 0; i -= 1) {
198
200
  if (!isPoint(cs, i))
199
201
  continue;
200
- const px = xScale(cs.x[i]);
202
+ // Match the drawn position (offset in px space) so the click target aligns.
203
+ const px = xScale(cs.x[i]) + offsetPx;
201
204
  const py = yScale(cs.y[i]);
202
205
  const r = encoding.radiusAt(i);
203
206
  const dx = qx - px;
package/dist/tracker.d.ts CHANGED
@@ -4,7 +4,41 @@
4
4
  * themselves render as an SVG overlay in `Layers` (no cursor canvas); these
5
5
  * helpers stay pure, so they're unit-tested directly.
6
6
  */
7
+ import type { Interval } from 'pond-ts';
7
8
  import type { CursorMode } from './context.js';
9
+ /**
10
+ * The interval in the sorted, non-overlapping `buckets` that contains `t`
11
+ * (`begin ≤ t < end`), or `undefined` if `t` falls in no bucket. Binary search —
12
+ * the `region` cursor uses it to find the bucket under the pointer.
13
+ */
14
+ export declare function bucketAt(buckets: readonly Interval[], t: number): Interval | undefined;
15
+ /**
16
+ * The `[start, end)` **span** a region cursor covers, in axis units (not pixels —
17
+ * the drag-release callback reports this):
18
+ *
19
+ * - **Snapping** (`buckets` non-empty, `t1` in a bucket): the bucket at `t1`, or —
20
+ * with a drag anchor `t2` — the union of the `t1` and `t2` buckets, so a drag
21
+ * extends **bucket by bucket** either direction. A `t2` in no bucket is ignored.
22
+ * - **Freeform** (`t1` in no bucket — e.g. no `cursorSequence` at all): a drag
23
+ * spans the raw `[t1, t2]`; without a drag (`t2` omitted) there's nothing to
24
+ * shade (the cursor renders as a plain line), so it returns `null`.
25
+ */
26
+ export declare function regionSpan(buckets: readonly Interval[], t1: number, t2?: number): {
27
+ start: number;
28
+ end: number;
29
+ } | null;
30
+ /**
31
+ * The pixel band for the `region` cursor: the {@link regionSpan} for `t1` (and an
32
+ * optional drag anchor `t2`), its `[start, end)` mapped through `xScale` and
33
+ * clamped to `[0, plotWidth]`. Returns `null` when there's no span, or when the
34
+ * band has no width — including a span entirely in a **collapsed gap** on a
35
+ * trading-time scale (both edges map to the same pixel), so it draws nothing
36
+ * there rather than a zero-width sliver.
37
+ */
38
+ export declare function bandRect(buckets: readonly Interval[], t1: number, xScale: (value: number) => number, plotWidth: number, t2?: number): {
39
+ x0: number;
40
+ x1: number;
41
+ } | null;
8
42
  /** Default cursor mode — the synced vertical line (cursor enabled on the
9
43
  * container by default; pair with an off-chart readout via `onTrackerChanged`). */
10
44
  export declare const DEFAULT_CURSOR_MODE: CursorMode;
@@ -19,6 +53,9 @@ export declare function cursorParts(mode: CursorMode): {
19
53
  readonly line: boolean;
20
54
  readonly dots: boolean;
21
55
  readonly chip: 'none' | 'inline' | 'flag' | 'axis';
56
+ /** `region` mode: a shaded **band** over the bucket under the pointer (from
57
+ * `cursorSequence`), drawn by `Layers`; no line/dots/chip of its own. */
58
+ readonly band: boolean;
22
59
  };
23
60
  /**
24
61
  * The crosshair's plot-pixel x from the tracker inputs. A controlled