@pond-ts/charts 0.41.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/dist/data.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ValueSeries } from 'pond-ts';
1
2
  /**
2
3
  * Read a numeric column into a `Float64Array`, missing cells as `NaN`.
3
4
  *
@@ -305,4 +306,217 @@ export function barsFromValueSeries(series, column) {
305
306
  const { begin, end } = neighbourSpans(series.axisValues(), n);
306
307
  return { begin, end, y, length: n };
307
308
  }
309
+ /**
310
+ * The per-bin `[begin, end]` slots for a `TimeSeries`, key-shape aware — the same
311
+ * rule {@link barsFromTimeSeries} applies: an interval / timeRange key uses its
312
+ * own endpoints; a point (`time`) key synthesizes a span from neighbour spacing
313
+ * (see {@link neighbourSpans}). Shared by the stacked readers so a stack draws
314
+ * true bucket spans over an `aggregate` rollup and contiguous bars over a raw
315
+ * point series.
316
+ */
317
+ function seriesSlots(series) {
318
+ const n = series.length;
319
+ if (series.keyColumn().kind !== 'time') {
320
+ return keyBeginEnd(series);
321
+ }
322
+ return neighbourSpans(series.keyColumn().begin, n);
323
+ }
324
+ /**
325
+ * Build a {@link StackedBarSeries} from a **`Map` of grouped series** — one series
326
+ * per stack group. This is the natural reader for pond's grouped-aggregate output:
327
+ * `series.partitionBy('host', { groups }).aggregate(Sequence.every('5m'), { n: 'count' }).toMap()`
328
+ * yields a `Map<host, TimeSeries>`, one interval-keyed series per host. The stack
329
+ * order (`groups`, bottom → top) is the map's **insertion order** (stable when you
330
+ * pass `partitionBy`'s `{ groups }` option).
331
+ *
332
+ * **Aligned by bucket key, not by index.** Each partition's `aggregate` spans only
333
+ * *its own* events' range, so the groups generally have **different** grids (host A
334
+ * might have buckets 0–8, host B buckets 3–9). This reader takes the **union** of
335
+ * every group's `[begin, end)` slots (ascending) and places each group's `column`
336
+ * value at the matching `begin`; a bucket a group is missing reads as a gap
337
+ * (`NaN`, contributing nothing to that stack). So the segments always line up on
338
+ * the real bucket, never on a positional accident. (Pass `aggregate`'s
339
+ * `{ range }` option if you want every group padded to one dense grid — the union
340
+ * is then that grid.) When two groups carry the **same `begin`**, the first
341
+ * group's `end` sets that slot's width — correct for the uniform-width buckets
342
+ * `aggregate` / `pivotByGroup` produce (all groups share the grid width), which is
343
+ * the intended input.
344
+ *
345
+ * @throws Error if `groups` is empty.
346
+ * @throws RangeError / TypeError (via {@link readNumericColumn}) if `column` is
347
+ * missing or non-numeric in any member.
348
+ */
349
+ export function stacksFromGroups(groups, column) {
350
+ const names = [...groups.keys()];
351
+ if (names.length === 0) {
352
+ throw new Error('stacksFromGroups: `groups` map is empty');
353
+ }
354
+ const series = [...groups.values()];
355
+ const G = names.length;
356
+ // Union of all groups' slots, keyed by begin (each begin → its end).
357
+ const ends = new Map();
358
+ const perGroupSlots = series.map((s) => seriesSlots(s));
359
+ for (let g = 0; g < G; g += 1) {
360
+ const { begin, end } = perGroupSlots[g];
361
+ for (let i = 0; i < series[g].length; i += 1) {
362
+ if (!ends.has(begin[i]))
363
+ ends.set(begin[i], end[i]);
364
+ }
365
+ }
366
+ const begins = [...ends.keys()].sort((a, b) => a - b);
367
+ const n = begins.length;
368
+ const beginArr = new Float64Array(n);
369
+ const endArr = new Float64Array(n);
370
+ const slotOf = new Map();
371
+ for (let i = 0; i < n; i += 1) {
372
+ beginArr[i] = begins[i];
373
+ endArr[i] = ends.get(begins[i]);
374
+ slotOf.set(begins[i], i);
375
+ }
376
+ const values = new Float64Array(n * G);
377
+ values.fill(NaN);
378
+ for (let g = 0; g < G; g += 1) {
379
+ const { begin } = perGroupSlots[g];
380
+ const col = readNumericColumn(series[g], column);
381
+ for (let i = 0; i < series[g].length; i += 1) {
382
+ const slot = slotOf.get(begin[i]);
383
+ if (slot !== undefined)
384
+ values[slot * G + g] = col[i];
385
+ }
386
+ }
387
+ return { begin: beginArr, end: endArr, groups: names, values, length: n };
388
+ }
389
+ /**
390
+ * Build a {@link StackedBarSeries} from a **wide** series — one numeric column
391
+ * per stack group. This is the reader for pond's `pivotByGroup` output (long →
392
+ * wide reshape: each group value becomes its own column), or any series that is
393
+ * already wide (e.g. `in` / `out` traffic). `columns` names the segment columns
394
+ * **bottom → top**; a `ValueSeries` bins on its value axis (neighbour-spaced
395
+ * slots), a `TimeSeries` on its key (interval spans or neighbour-spaced points).
396
+ *
397
+ * @throws RangeError / TypeError if any column is missing or non-numeric.
398
+ */
399
+ export function stacksFromColumns(series, columns) {
400
+ const n = series.length;
401
+ const G = columns.length;
402
+ const isValue = series instanceof ValueSeries;
403
+ const { begin, end } = isValue
404
+ ? neighbourSpans(series.axisValues(), n)
405
+ : seriesSlots(series);
406
+ const values = new Float64Array(n * G);
407
+ for (let g = 0; g < G; g += 1) {
408
+ const col = isValue
409
+ ? readValueColumn(series, columns[g])
410
+ : readNumericColumn(series, columns[g]);
411
+ for (let i = 0; i < n; i += 1) {
412
+ values[i * G + g] = col[i];
413
+ }
414
+ }
415
+ return { begin, end, groups: columns, values, length: n };
416
+ }
417
+ /**
418
+ * Build a {@link StackedBarSeries} from **`byColumn` bin records** — the array of
419
+ * `{ start, end, …aggregates }` a value-band aggregation returns
420
+ * (`series.byColumn('power', { width: 20 }, { seconds: { from: 'dt', using: 'sum' } })`).
421
+ * `columns` names the aggregate field(s) to draw as segments (`['seconds']` for a
422
+ * plain distribution; several for a stacked value-band histogram).
423
+ *
424
+ * By default each bin keeps its real numeric `[start, end]` edges — a true value
425
+ * axis (power W, risk %). Pass `{ ordinal: true }` for uniform unit slots
426
+ * (`[i, i+1]`) when the bins are **categories** whose numeric width shouldn't
427
+ * distort the layout (heart-rate zones); label them with `<YAxis ticks>`.
428
+ *
429
+ * A missing / non-finite aggregate reads as a gap (`NaN`).
430
+ */
431
+ export function stacksFromBins(bins, columns, options = {}) {
432
+ const n = bins.length;
433
+ const G = columns.length;
434
+ const begin = new Float64Array(n);
435
+ const end = new Float64Array(n);
436
+ const values = new Float64Array(n * G);
437
+ for (let i = 0; i < n; i += 1) {
438
+ const bin = bins[i];
439
+ if (options.ordinal) {
440
+ begin[i] = i;
441
+ end[i] = i + 1;
442
+ }
443
+ else {
444
+ begin[i] = bin.start;
445
+ end[i] = bin.end;
446
+ }
447
+ const fields = bin;
448
+ for (let g = 0; g < G; g += 1) {
449
+ const v = fields[columns[g]];
450
+ values[i * G + g] = typeof v === 'number' && Number.isFinite(v) ? v : NaN;
451
+ }
452
+ }
453
+ return { begin, end, groups: columns, values, length: n };
454
+ }
455
+ /**
456
+ * Build a {@link StackedBarSeries} (single group, `G === 1`) from an ordered list
457
+ * of `{ label, value }` categories — one **unit slot** `[i, i+1]` per category, in
458
+ * order. This is the categorical row-read's geometry: the slots are ordinal
459
+ * indices (the bar's pixel span comes from the container's {@link ScaleBand}), and
460
+ * the `label`s become the axis's ordered category names (`xCategories`). A
461
+ * non-finite value reads as a gap (`NaN`). Reuses the shipped stacked geometry —
462
+ * no new draw path.
463
+ */
464
+ export function categoryStack(records) {
465
+ const n = records.length;
466
+ const begin = new Float64Array(n);
467
+ const end = new Float64Array(n);
468
+ const values = new Float64Array(n);
469
+ const marks = new Array(n);
470
+ for (let i = 0; i < n; i += 1) {
471
+ begin[i] = i;
472
+ end[i] = i + 1;
473
+ const v = records[i].value;
474
+ values[i] = Number.isFinite(v) ? v : NaN;
475
+ marks[i] = records[i].label;
476
+ }
477
+ // `marks` carry the category names — the stable per-bar identity the categorical
478
+ // axis selects on (the slot index `begin` renumbers on reorder; the name doesn't).
479
+ return { begin, end, groups: ['value'], values, length: n, marks };
480
+ }
481
+ /** A series' numeric value column names in schema order (the key column excluded). */
482
+ function numericValueColumns(series) {
483
+ const schema = series.schema;
484
+ return schema
485
+ .slice(1)
486
+ .filter((c) => c.kind === 'number')
487
+ .map((c) => c.name);
488
+ }
489
+ /**
490
+ * **The transpose reader** (categorical-axis RFC, Phase 1 PR2): read **one row**
491
+ * of a wide `TimeSeries` **across** — its columns become the categories, that
492
+ * row's cells the values — for `<BarChart categories={…}>`. This is "columns on
493
+ * x": the schema's numeric columns (a `pivotByGroup` output's per-group columns,
494
+ * a vol term structure's per-expiry columns, …) laid out at one instant.
495
+ *
496
+ * The row is picked the ordinary way (`options.at`, default the **head row**):
497
+ * `series.last()` / `.first()` / `.at(index)` / `.nearest(time)`. So "which row"
498
+ * is just row selection — the live snapshot is the head row; a static report
499
+ * pins a row by index or time. (Binding the row to a scrubbing time cursor is a
500
+ * later phase.) Pass `options.columns` to bound / order the category set; omit it
501
+ * to take every numeric value column. An empty series (or a row past the ends)
502
+ * yields `[]`; a missing / non-numeric cell reads as a gap (`NaN`).
503
+ */
504
+ export function transposeRow(series, options = {}) {
505
+ const at = options.at ?? 'last';
506
+ const event = at === 'last'
507
+ ? series.last()
508
+ : at === 'first'
509
+ ? series.first()
510
+ : typeof at === 'number'
511
+ ? series.at(at)
512
+ : series.nearest(at.time);
513
+ if (event === undefined)
514
+ return [];
515
+ const cols = options.columns ?? numericValueColumns(series);
516
+ const get = event.get.bind(event);
517
+ return cols.map((name) => {
518
+ const v = get(name);
519
+ return { label: name, value: typeof v === 'number' ? v : NaN };
520
+ });
521
+ }
308
522
  //# sourceMappingURL=data.js.map
package/dist/grid.d.ts CHANGED
@@ -8,4 +8,18 @@
8
8
  * data layers that draw next.
9
9
  */
10
10
  export declare function drawGrid(ctx: CanvasRenderingContext2D, xTicks: readonly number[], yTicks: readonly number[], width: number, height: number, color: string, dash: readonly number[]): void;
11
+ /**
12
+ * Greedily thin an **ascending** list of pixel positions so no two kept lines
13
+ * are closer than `minGap` px — keeps the axis from crowding when collapse
14
+ * points are dense (e.g. a divider at every daily candle). Keeps the first of
15
+ * each cluster.
16
+ */
17
+ export declare function thinPixels(xs: readonly number[], minGap: number): number[];
18
+ /**
19
+ * Stroke **session dividers** — solid vertical lines at each `xs` pixel, spanning
20
+ * the plot height. Drawn a touch stronger than the dashed gridlines (solid, so a
21
+ * session/day boundary reads as structural, not just another tick) at the
22
+ * discontinuity provider's collapse points (see `DiscontinuityProvider.boundaries`).
23
+ */
24
+ export declare function drawDividers(ctx: CanvasRenderingContext2D, xs: readonly number[], height: number, color: string): void;
11
25
  //# sourceMappingURL=grid.d.ts.map
package/dist/grid.js CHANGED
@@ -26,4 +26,40 @@ export function drawGrid(ctx, xTicks, yTicks, width, height, color, dash) {
26
26
  ctx.stroke();
27
27
  ctx.restore();
28
28
  }
29
+ /**
30
+ * Greedily thin an **ascending** list of pixel positions so no two kept lines
31
+ * are closer than `minGap` px — keeps the axis from crowding when collapse
32
+ * points are dense (e.g. a divider at every daily candle). Keeps the first of
33
+ * each cluster.
34
+ */
35
+ export function thinPixels(xs, minGap) {
36
+ const out = [];
37
+ for (const x of xs) {
38
+ if (out.length === 0 || x - out[out.length - 1] >= minGap)
39
+ out.push(x);
40
+ }
41
+ return out;
42
+ }
43
+ /**
44
+ * Stroke **session dividers** — solid vertical lines at each `xs` pixel, spanning
45
+ * the plot height. Drawn a touch stronger than the dashed gridlines (solid, so a
46
+ * session/day boundary reads as structural, not just another tick) at the
47
+ * discontinuity provider's collapse points (see `DiscontinuityProvider.boundaries`).
48
+ */
49
+ export function drawDividers(ctx, xs, height, color) {
50
+ if (xs.length === 0)
51
+ return;
52
+ ctx.save();
53
+ ctx.strokeStyle = color;
54
+ ctx.lineWidth = 1;
55
+ ctx.setLineDash([]);
56
+ ctx.beginPath();
57
+ for (const x of xs) {
58
+ const px = Math.round(x) + 0.5;
59
+ ctx.moveTo(px, 0);
60
+ ctx.lineTo(px, height);
61
+ }
62
+ ctx.stroke();
63
+ ctx.restore();
64
+ }
29
65
  //# sourceMappingURL=grid.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';
@@ -45,13 +46,18 @@ export type { BarChartProps } from './BarChart.js';
45
46
  export { Candlestick } from './Candlestick.js';
46
47
  export type { CandlestickProps } from './Candlestick.js';
47
48
  export type { CandleVariant, ColorBy } from './ohlc.js';
49
+ export { scaleTradingTime } from './tradingTimeScale.js';
50
+ export type { TradingTimeScale, DiscontinuityProvider, } from './tradingTimeScale.js';
51
+ export { scaleBand } from './bandScale.js';
52
+ export type { ScaleBand } from './bandScale.js';
48
53
  export { Region, Baseline, Marker } from './annotations.js';
49
54
  export type { RegionProps, BaselineProps, MarkerProps } from './annotations.js';
50
55
  export type { AnnotationKind, CreateSpec } from './context.js';
51
56
  export { YAxisIndicator, createLiveValue } from './indicators.js';
52
57
  export type { YAxisIndicatorProps, LiveValue } from './indicators.js';
53
- export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, ohlcFromTimeSeries, } from './data.js';
54
- export type { ChartSeries, BandSeries, BoxSeries, BoxColumns, BarSeries, OhlcSeries, OhlcColumns, } 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';
60
+ export type { Orientation } from './bars.js';
55
61
  export type { RadiusEncoding, ColorEncoding } from './encoding.js';
56
62
  export type { Curve } from './curve.js';
57
63
  export type { GapMode } from './gaps.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';
@@ -30,13 +31,23 @@ export { ScatterChart } from './ScatterChart.js';
30
31
  export { BoxPlot } from './BoxPlot.js';
31
32
  export { BarChart } from './BarChart.js';
32
33
  export { Candlestick } from './Candlestick.js';
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';
33
37
  // Annotations — user-authored marks in the turquoise register (distinct from the
34
38
  // data): a shaded span, a horizontal value line, a vertical x line.
35
39
  export { Region, Baseline, Marker } from './annotations.js';
36
40
  // Axis indicators — a value pill pinned to an axis edge (the ChartIQ live tag).
37
41
  // `createLiveValue` is the high-frequency, isolated-repaint update path.
38
42
  export { YAxisIndicator, createLiveValue } from './indicators.js';
39
- export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, ohlcFromTimeSeries, } from './data.js';
43
+ export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, ohlcFromTimeSeries,
44
+ // Stacked / histogram readers — assemble a StackedBarSeries from pond's own
45
+ // aggregation output: a Map of grouped series, a wide series, or byColumn bins.
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';
40
51
  export { defaultTheme, estelaTheme } from './theme.js';
41
52
  // CSS-custom-property → ChartTheme bridge: build a theme from a design system's
42
53
  // tokens (`cssVarTheme`), and a hook that re-resolves it on a `data-theme`
package/dist/scatter.d.ts CHANGED
@@ -44,13 +44,15 @@ export declare function scatterExtent(cs: ChartSeries): [number, number] | null;
44
44
  * @param labelAt optional per-point text label; `undefined` ⇒ no labels drawn.
45
45
  * @param font `theme.font` (family + size) for label text.
46
46
  * @param selected the container's current selection (or `null`).
47
- * @param seriesLabel this layer's series identity (`as` ?? column) — the
48
- * `label` half of the selection match.
47
+ * @param seriesId this layer's stable series identity (its `id` prop, or
48
+ * `undefined` when the layer isn't selectable) — the series half
49
+ * of the selection match. A point lights only when the selection's
50
+ * `id` matches, keyed to the sample by its `key`.
49
51
  */
50
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: {
51
53
  readonly family: string;
52
54
  readonly size: number;
53
- }, selected: SelectInfo | null, seriesLabel: string): void;
55
+ }, selected: SelectInfo | null, seriesId: string | undefined): void;
54
56
  /**
55
57
  * Hit-test plot-pixel `(qx, qy)` against the scatter's points — the topmost
56
58
  * point whose circle contains the click, or `null`. "Topmost" = the
@@ -59,12 +61,12 @@ export declare function drawScatter(ctx: CanvasRenderingContext2D, cs: ChartSeri
59
61
  *
60
62
  * A point's hit radius is its drawn radius (data-driven or base) — clicking the
61
63
  * visible disc selects it. Distance is compared squared (no `sqrt` in the loop).
62
- * Returns the point's {@link SelectInfo} with `key = keyAt(i)` (its event
63
- * `begin`), the encoded fill colour (so the readout swatch matches the mark),
64
- * and the series `label`.
64
+ * Returns the point's {@link SelectInfo} with the series `id` (the selection
65
+ * identity), `key = keyAt(i)` (its event `begin` click provenance), the encoded
66
+ * fill colour (so the readout swatch matches the mark), and the display `label`.
65
67
  *
66
68
  * Pure: takes the same `xScale`/`yScale` the row hands to `draw`, so it
67
69
  * unit-tests without a DOM (mirrors the `sampleAt` / `resolveSelection` split).
68
70
  */
69
- export declare function hitTestScatter(cs: ChartSeries, qx: number, qy: number, xScale: Scale, yScale: Scale, encoding: ResolvedEncoding, keyAt: (i: number) => number, 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): SelectInfo | null;
70
72
  //# sourceMappingURL=scatter.d.ts.map
package/dist/scatter.js CHANGED
@@ -109,13 +109,16 @@ export function scatterExtent(cs) {
109
109
  * @param labelAt optional per-point text label; `undefined` ⇒ no labels drawn.
110
110
  * @param font `theme.font` (family + size) for label text.
111
111
  * @param selected the container's current selection (or `null`).
112
- * @param seriesLabel this layer's series identity (`as` ?? column) — the
113
- * `label` half of the selection match.
112
+ * @param seriesId this layer's stable series identity (its `id` prop, or
113
+ * `undefined` when the layer isn't selectable) — the series half
114
+ * of the selection match. A point lights only when the selection's
115
+ * `id` matches, keyed to the sample by its `key`.
114
116
  */
115
- export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesLabel) {
117
+ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesId) {
116
118
  ctx.save();
117
119
  // The selection only lights up a point of *this* series; resolve the key once.
118
- const selectedKey = selected !== null && selected.label === seriesLabel ? selected.key : null;
120
+ // A no-id (non-selectable) layer passes `undefined` and never matches.
121
+ const selectedKey = selected !== null && selected.id === seriesId ? selected.key : null;
119
122
  let selPx = 0;
120
123
  let selPy = 0;
121
124
  let selR = 0;
@@ -183,14 +186,14 @@ const LABEL_GAP = 4;
183
186
  *
184
187
  * A point's hit radius is its drawn radius (data-driven or base) — clicking the
185
188
  * visible disc selects it. Distance is compared squared (no `sqrt` in the loop).
186
- * Returns the point's {@link SelectInfo} with `key = keyAt(i)` (its event
187
- * `begin`), the encoded fill colour (so the readout swatch matches the mark),
188
- * and the series `label`.
189
+ * Returns the point's {@link SelectInfo} with the series `id` (the selection
190
+ * identity), `key = keyAt(i)` (its event `begin` click provenance), the encoded
191
+ * fill colour (so the readout swatch matches the mark), and the display `label`.
189
192
  *
190
193
  * Pure: takes the same `xScale`/`yScale` the row hands to `draw`, so it
191
194
  * unit-tests without a DOM (mirrors the `sampleAt` / `resolveSelection` split).
192
195
  */
193
- export function hitTestScatter(cs, qx, qy, xScale, yScale, encoding, keyAt, seriesLabel) {
196
+ export function hitTestScatter(cs, qx, qy, xScale, yScale, encoding, keyAt, id, seriesLabel) {
194
197
  for (let i = cs.length - 1; i >= 0; i -= 1) {
195
198
  if (!isPoint(cs, i))
196
199
  continue;
@@ -201,6 +204,7 @@ export function hitTestScatter(cs, qx, qy, xScale, yScale, encoding, keyAt, seri
201
204
  const dy = qy - py;
202
205
  if (dx * dx + dy * dy <= r * r) {
203
206
  return {
207
+ id,
204
208
  key: keyAt(i),
205
209
  value: cs.y[i],
206
210
  color: encoding.colorAt(i),
package/dist/theme.d.ts CHANGED
@@ -104,6 +104,13 @@ export interface ChartTheme {
104
104
  readonly grid: string;
105
105
  /** Gridline dash pattern (px on/off pairs); `[]` for solid. */
106
106
  readonly gridDash: readonly number[];
107
+ /**
108
+ * Stroke for **session dividers** — the solid verticals a trading-time axis
109
+ * draws at each collapsed gap (session/day open). Optional; falls back to
110
+ * {@link grid}. Set it a touch stronger than the gridlines so a session
111
+ * boundary reads as structural.
112
+ */
113
+ readonly sessionDivider?: string;
107
114
  /**
108
115
  * Typography for the axis **title** — the rotated y-axis unit strip and the
109
116
  * x-axis label (distinct from the per-tick `label` colour above). Omit a
package/dist/theme.js CHANGED
@@ -111,6 +111,7 @@ export const defaultTheme = {
111
111
  label: '#64748b',
112
112
  grid: '#e2e8f0',
113
113
  gridDash: [2, 2],
114
+ sessionDivider: '#cbd5e1', // slate-300 — a step stronger than the gridlines
114
115
  },
115
116
  font: {
116
117
  family: 'system-ui, -apple-system, sans-serif',
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
package/dist/tracker.js CHANGED
@@ -4,6 +4,72 @@
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
+ /**
8
+ * The interval in the sorted, non-overlapping `buckets` that contains `t`
9
+ * (`begin ≤ t < end`), or `undefined` if `t` falls in no bucket. Binary search —
10
+ * the `region` cursor uses it to find the bucket under the pointer.
11
+ */
12
+ export function bucketAt(buckets, t) {
13
+ let lo = 0;
14
+ let hi = buckets.length - 1;
15
+ while (lo <= hi) {
16
+ const mid = (lo + hi) >> 1;
17
+ const b = buckets[mid];
18
+ if (t < b.begin())
19
+ hi = mid - 1;
20
+ else if (t >= b.end())
21
+ lo = mid + 1;
22
+ else
23
+ return b;
24
+ }
25
+ return undefined;
26
+ }
27
+ /**
28
+ * The `[start, end)` **span** a region cursor covers, in axis units (not pixels —
29
+ * the drag-release callback reports this):
30
+ *
31
+ * - **Snapping** (`buckets` non-empty, `t1` in a bucket): the bucket at `t1`, or —
32
+ * with a drag anchor `t2` — the union of the `t1` and `t2` buckets, so a drag
33
+ * extends **bucket by bucket** either direction. A `t2` in no bucket is ignored.
34
+ * - **Freeform** (`t1` in no bucket — e.g. no `cursorSequence` at all): a drag
35
+ * spans the raw `[t1, t2]`; without a drag (`t2` omitted) there's nothing to
36
+ * shade (the cursor renders as a plain line), so it returns `null`.
37
+ */
38
+ export function regionSpan(buckets, t1, t2) {
39
+ const a = bucketAt(buckets, t1);
40
+ if (a === undefined) {
41
+ // Freeform: no bucket under t1. A drag spans the raw [t1, t2]; a bare hover
42
+ // has nothing to shade (Layers draws a line for the degenerate region cursor).
43
+ return t2 === undefined
44
+ ? null
45
+ : { start: Math.min(t1, t2), end: Math.max(t1, t2) };
46
+ }
47
+ if (t2 === undefined)
48
+ return { start: a.begin(), end: a.end() };
49
+ const b = bucketAt(buckets, t2);
50
+ if (b === undefined)
51
+ return { start: a.begin(), end: a.end() };
52
+ return {
53
+ start: Math.min(a.begin(), b.begin()),
54
+ end: Math.max(a.end(), b.end()),
55
+ };
56
+ }
57
+ /**
58
+ * The pixel band for the `region` cursor: the {@link regionSpan} for `t1` (and an
59
+ * optional drag anchor `t2`), its `[start, end)` mapped through `xScale` and
60
+ * clamped to `[0, plotWidth]`. Returns `null` when there's no span, or when the
61
+ * band has no width — including a span entirely in a **collapsed gap** on a
62
+ * trading-time scale (both edges map to the same pixel), so it draws nothing
63
+ * there rather than a zero-width sliver.
64
+ */
65
+ export function bandRect(buckets, t1, xScale, plotWidth, t2) {
66
+ const span = regionSpan(buckets, t1, t2);
67
+ if (span === null)
68
+ return null;
69
+ const x0 = Math.max(0, xScale(span.start));
70
+ const x1 = Math.min(plotWidth, xScale(span.end));
71
+ return x1 > x0 ? { x0, x1 } : null;
72
+ }
7
73
  /** Default cursor mode — the synced vertical line (cursor enabled on the
8
74
  * container by default; pair with an off-chart readout via `onTrackerChanged`). */
9
75
  export const DEFAULT_CURSOR_MODE = 'line';
@@ -15,22 +81,27 @@ export const DEFAULT_CURSOR_MODE = 'line';
15
81
  * value flag stacked near the top of the row (drawn in `Layers`).
16
82
  */
17
83
  export function cursorParts(mode) {
84
+ const base = { line: false, dots: false, chip: 'none', band: false };
18
85
  switch (mode) {
19
86
  case 'line':
20
- return { line: true, dots: false, chip: 'none' };
87
+ return { ...base, line: true };
21
88
  case 'point':
22
- return { line: false, dots: true, chip: 'none' };
89
+ return { ...base, dots: true };
23
90
  case 'inline':
24
- return { line: false, dots: true, chip: 'inline' };
91
+ return { ...base, dots: true, chip: 'inline' };
25
92
  case 'flag':
26
- return { line: false, dots: true, chip: 'flag' };
93
+ return { ...base, dots: true, chip: 'flag' };
27
94
  case 'crosshair':
28
95
  // A single reticle (not per-series): `Layers` draws the dashed vertical +
29
96
  // full-width horizontal lines, the centre dot, and one value pill itself
30
97
  // (so no generic line/dots here); the x-time pill is on `<XAxis>`.
31
- return { line: false, dots: false, chip: 'axis' };
98
+ return { ...base, chip: 'axis' };
99
+ case 'region':
100
+ // A shaded band over the bucket under the pointer — `Layers` resolves the
101
+ // bucket from `cursorBuckets` and draws the rect (cropped through xScale).
102
+ return { ...base, band: true };
32
103
  case 'none':
33
- return { line: false, dots: false, chip: 'none' };
104
+ return { ...base };
34
105
  }
35
106
  }
36
107
  /**