@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/bars.js CHANGED
@@ -82,7 +82,8 @@ export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx) {
82
82
  * (inset by `gapPx`) from the resolved `baseline` to the value.
83
83
  *
84
84
  * A gap (non-finite value) is skipped — no bar, no zero-height sliver. A bar
85
- * matching the current `selection` (same `begin` **and** the layer's own `label`)
85
+ * matching the current `selection` (same sample `key` **and** the layer's own
86
+ * series `id` — `seriesId`; a no-id layer passes `undefined` and never matches)
86
87
  * draws in the style's `highlight` colour **and outlined**, so a click reads back
87
88
  * on the canvas; a bar matching `hovered` draws in `highlight` **without** the
88
89
  * outline (a lighter "this bar is live" on pointer-over); all others use the flat
@@ -92,7 +93,7 @@ export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx) {
92
93
  * O(N) over the events, one fill (+ optional stroke) per bar, no per-bar
93
94
  * allocation beyond the rect tuple.
94
95
  */
95
- export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, label, selection, hovered) {
96
+ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered) {
96
97
  ctx.save();
97
98
  ctx.globalAlpha = style.opacity;
98
99
  for (let i = 0; i < cs.length; i += 1) {
@@ -100,16 +101,18 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, label,
100
101
  if (rect === null)
101
102
  continue;
102
103
  const [x0, x1, yTop, yBottom] = rect;
103
- // Match by key (begin) **and** label, so two series sharing a timestamp don't
104
- // both light up. Both the committed selection and the transient hover use the
105
- // `highlight` fill; only the selection adds the outline, so hover reads as a
106
- // lighter "this bar is live" and select as the committed pick.
104
+ // Match by the series `id` **and** the sample `key` (begin), so two series
105
+ // sharing a timestamp don't both light up (a no-id, non-selectable layer
106
+ // passes `seriesId === undefined` and never matches). Both the committed
107
+ // selection and the transient hover use the `highlight` fill; only the
108
+ // selection adds the outline, so hover reads as a lighter "this bar is live"
109
+ // and select as the committed pick.
107
110
  const selected = selection !== null &&
108
- selection.key === cs.begin[i] &&
109
- selection.label === label;
111
+ selection.id === seriesId &&
112
+ selection.key === cs.begin[i];
110
113
  const isHovered = hovered !== null &&
111
- hovered.key === cs.begin[i] &&
112
- hovered.label === label;
114
+ hovered.id === seriesId &&
115
+ hovered.key === cs.begin[i];
113
116
  ctx.fillStyle = selected || isHovered ? style.highlight : style.fill;
114
117
  ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
115
118
  if (selected) {
@@ -168,4 +171,180 @@ export function barAt(cs, px, py, xScale, yScale, baseline, gapPx, minWidthPx) {
168
171
  }
169
172
  return null;
170
173
  }
174
+ /**
175
+ * The `[min, max]` extent of the **value (stacked) axis**. For a true multi-group
176
+ * stack it is `[0, maxTotal]`, where `maxTotal` is the tallest bin's summed finite
177
+ * non-negative segments. For a **single-group** series (`G === 1` — the plain /
178
+ * categorical bar case) it spans the values' own `[min, max]`, so a **negative**
179
+ * bar's floor is in the domain (segments below the baseline stay visible). `0` is
180
+ * always pulled in so the bars rest on a visible baseline (the bar analog of
181
+ * {@link barExtent}). An empty / all-gap series returns `[0, 1]` so the axis still
182
+ * has a usable domain. Feeds the y auto-fit for a vertical histogram, the x
183
+ * auto-fit for a horizontal one.
184
+ */
185
+ export function stackValueExtent(ss) {
186
+ const G = ss.groups.length;
187
+ let max = 0;
188
+ let min = 0;
189
+ for (let b = 0; b < ss.length; b += 1) {
190
+ let cum = 0;
191
+ for (let g = 0; g < G; g += 1) {
192
+ const v = ss.values[b * G + g];
193
+ if (!Number.isFinite(v))
194
+ continue;
195
+ if (G === 1) {
196
+ // Single-group: a bar honours its sign, so track both ends.
197
+ if (v > max)
198
+ max = v;
199
+ if (v < min)
200
+ min = v;
201
+ }
202
+ else if (v > 0) {
203
+ cum += v; // True stack: sum the positive segments.
204
+ }
205
+ }
206
+ if (cum > max)
207
+ max = cum;
208
+ }
209
+ // Empty / all-gap / all-zero → a usable unit domain; otherwise the real extent
210
+ // (with 0 pulled in via the `min`/`max` seeds above).
211
+ if (min === 0 && max === 0)
212
+ return [0, 1];
213
+ return [min, max];
214
+ }
215
+ /**
216
+ * The `[min, max]` extent of the **bin axis** — the first bin's `begin` to the
217
+ * last bin's `end` (the slots are ascending). `null` for an empty series. Feeds
218
+ * the x auto-fit for a vertical histogram, the y auto-fit for a horizontal one.
219
+ */
220
+ export function stackBinExtent(ss) {
221
+ if (ss.length === 0)
222
+ return null;
223
+ return [ss.begin[0], ss.end[ss.length - 1]];
224
+ }
225
+ /**
226
+ * The pixel rect `[x0, x1, yTop, yBottom]` (ascending on both axes) of bin `b`'s
227
+ * segment `g`, stacked so it sits atop `cumBefore` (the summed value of the
228
+ * segments below it, in value units). `null` for a gap (see below). Transposes on
229
+ * `orientation`:
230
+ *
231
+ * - **vertical** — the bin span is horizontal (`barSpanPx` on `xScale`); the
232
+ * segment runs vertically from `yScale(cumBefore)` to `yScale(cumBefore + v)`.
233
+ * - **horizontal** — the bin span is vertical (`barSpanPx` on `yScale`); the
234
+ * segment runs horizontally from `xScale(cumBefore)` to `xScale(cumBefore + v)`.
235
+ *
236
+ * `null` for a **gap** — a non-finite, negative, **or zero** value: none of them
237
+ * draw (a zero segment has no extent), and each contributes nothing to the running
238
+ * total. `minSpanPx` floors the **bin** span (bar thickness); the value direction
239
+ * is unfloored. Shared by {@link drawStacks} and {@link stackAt} so the drawn rect
240
+ * and the hit rect are identical.
241
+ */
242
+ export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, gapPx, minSpanPx) {
243
+ const G = ss.groups.length;
244
+ const v = ss.values[b * G + g];
245
+ // Skip non-finite (a gap) or zero (a zero-extent rect that can't draw or be
246
+ // hit-tested). A **negative** value is a gap only in a true multi-group stack
247
+ // (`G > 1`) — stacking a negative segment is undefined. A **single-group**
248
+ // series (`G === 1`) is a plain bar: it honours its sign and draws from the
249
+ // baseline *down* to a negative value (the categorical row-read's P&L / delta
250
+ // case), so negatives are kept and the `Math.min/Math.max` below normalizes the
251
+ // below-baseline rect.
252
+ if (!Number.isFinite(v) || v === 0 || (v < 0 && G > 1))
253
+ return null;
254
+ if (orientation === 'vertical') {
255
+ const [x0, x1] = barSpanPx(ss.begin[b], ss.end[b], xScale, gapPx, minSpanPx);
256
+ const yA = yScale(cumBefore);
257
+ const yB = yScale(cumBefore + v);
258
+ return [x0, x1, Math.min(yA, yB), Math.max(yA, yB)];
259
+ }
260
+ const [y0, y1] = barSpanPx(ss.begin[b], ss.end[b], yScale, gapPx, minSpanPx);
261
+ const xA = xScale(cumBefore);
262
+ const xB = xScale(cumBefore + v);
263
+ return [Math.min(xA, xB), Math.max(xA, xB), y0, y1];
264
+ }
265
+ /**
266
+ * Fill every segment of every bin in `ss`, stacking each bin's groups from the
267
+ * value baseline outward (bottom → top vertical, left → right horizontal). A gap
268
+ * (non-finite, or a negative segment of a true multi-group stack) is skipped and
269
+ * adds nothing to the running total, so the segments above it close the space; a
270
+ * single-group series draws its negative bars below the baseline (see
271
+ * {@link segmentRect}). A segment matching the current
272
+ * `selection` (same series `id`, bin `key` **and** group `label`) draws in its
273
+ * group's `highlight` **and** outlined; one matching `hover` draws in `highlight`
274
+ * without the outline; all others use the flat `fill`. `globalAlpha` carries the
275
+ * shared opacity and is restored.
276
+ *
277
+ * O(N·G) over bins × groups, one fill (+ optional stroke) per drawn segment.
278
+ */
279
+ export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, minSpanPx, seriesId, selection, hover) {
280
+ const G = ss.groups.length;
281
+ ctx.save();
282
+ ctx.globalAlpha = style.opacity;
283
+ for (let b = 0; b < ss.length; b += 1) {
284
+ let cum = 0;
285
+ for (let g = 0; g < G; g += 1) {
286
+ const rect = segmentRect(ss, b, g, orientation, xScale, yScale, cum, gapPx, minSpanPx);
287
+ const v = ss.values[b * G + g];
288
+ if (Number.isFinite(v) && v > 0)
289
+ cum += v;
290
+ if (rect === null)
291
+ continue;
292
+ const [x0, x1, yTop, yBottom] = rect;
293
+ // With `marks` (the categorical axis), match on the stable per-bin name so a
294
+ // pinned selection survives a column reorder; otherwise on the sample `key`
295
+ // (begin) + group `label`, as a time / value stack does.
296
+ const stableMark = ss.marks?.[b];
297
+ const matches = (m) => m !== null &&
298
+ m.id === seriesId &&
299
+ (stableMark !== undefined
300
+ ? m.mark === stableMark
301
+ : m.key === ss.begin[b] && m.label === ss.groups[g]);
302
+ const selected = matches(selection);
303
+ const isHovered = matches(hover);
304
+ // A hovered / selected segment pops to full opacity in its own colour; a
305
+ // resting one draws at the shared alpha.
306
+ ctx.globalAlpha = selected || isHovered ? 1 : style.opacity;
307
+ // A per-bin colour (the single-series band case) overrides the group fill.
308
+ const fill = style.binFills?.[b] ?? style.fills[g];
309
+ ctx.fillStyle = fill;
310
+ ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
311
+ if (selected) {
312
+ ctx.lineWidth = style.outlineWidth;
313
+ ctx.strokeStyle = fill;
314
+ ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
315
+ }
316
+ }
317
+ }
318
+ ctx.restore();
319
+ }
320
+ /**
321
+ * Hit-test plot-pixel `(px, py)` against `ss`'s stacked segments — the **first**
322
+ * segment whose rect contains the point, or `null`. The geometry is
323
+ * {@link segmentRect}, so the hit rect is exactly the drawn rect. The returned
324
+ * tuple is `[bin, group, begin, groupName, value]` for the chart to assemble a
325
+ * `SelectInfo` (it owns the colour). Orientation-agnostic — it reads `(px, py)`,
326
+ * so a horizontal histogram hit-tests the same way a vertical one does.
327
+ *
328
+ * O(N·G) over bins × groups (no spatial index — histogram bin/group counts are
329
+ * small; click / hover are cheap events).
330
+ */
331
+ export function stackAt(ss, px, py, orientation, xScale, yScale, gapPx, minSpanPx) {
332
+ const G = ss.groups.length;
333
+ for (let b = 0; b < ss.length; b += 1) {
334
+ let cum = 0;
335
+ for (let g = 0; g < G; g += 1) {
336
+ const rect = segmentRect(ss, b, g, orientation, xScale, yScale, cum, gapPx, minSpanPx);
337
+ const v = ss.values[b * G + g];
338
+ if (Number.isFinite(v) && v > 0)
339
+ cum += v;
340
+ if (rect === null)
341
+ continue;
342
+ const [x0, x1, yTop, yBottom] = rect;
343
+ if (px >= x0 && px <= x1 && py >= yTop && py <= yBottom) {
344
+ return [b, g, ss.begin[b], ss.groups[g], v];
345
+ }
346
+ }
347
+ }
348
+ return null;
349
+ }
171
350
  //# sourceMappingURL=bars.js.map
package/dist/context.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import type { ScaleLinear, ScaleTime } from 'd3-scale';
2
2
  import type { ChartTheme } from './theme.js';
3
3
  import type { AxisFormat } from './format.js';
4
+ import type { Interval, TimeRange } from 'pond-ts';
5
+ import type { TradingTimeScale, DiscontinuityProvider } from './tradingTimeScale.js';
6
+ import type { ScaleBand } from './bandScale.js';
4
7
  /**
5
8
  * The frame a {@link ChartContainer} provides to its rows and the time axis.
6
9
  * The container owns the **shared x geometry**: each side is split into *slots*
@@ -63,13 +66,45 @@ export interface ContainerFrame {
63
66
  * (`yScale.invert`). The x always snaps to the data grid either way.
64
67
  */
65
68
  readonly crosshairSnap: boolean;
69
+ /**
70
+ * `cursor="region"` buckets — the intervals (from `cursorSequence`) realized
71
+ * over the current view, sorted + non-overlapping. `Layers` finds the one under
72
+ * the pointer and shades it (mapped through `xScale`, so on a trading-time axis
73
+ * the closed part of the bucket collapses). `undefined` when no `cursorSequence`
74
+ * is set.
75
+ */
76
+ readonly cursorBuckets: readonly Interval[] | undefined;
77
+ /**
78
+ * The `region`-cursor **drag anchor** (epoch ms), or `null` when not dragging.
79
+ * A drag on a region cursor (only when {@link onRegionSelect} is set) records
80
+ * the press time here; the band then spans from the anchor's bucket to the
81
+ * pointer's bucket (extending bucket by bucket). Cleared on release.
82
+ */
83
+ readonly regionAnchor: number | null;
84
+ /** Set / clear the region-drag anchor (see {@link regionAnchor}). */
85
+ setRegionAnchor(time: number | null): void;
86
+ /**
87
+ * One-shot callback fired when a `region`-cursor **drag** is released, with the
88
+ * selected `[start, end)` `TimeRange` (snapped to the `cursorSequence` buckets).
89
+ * Providing it is what makes the region cursor **draggable**; the cursor does
90
+ * not keep the range (it reverts to the single-bucket highlight). Typical use:
91
+ * zoom the view to the returned range.
92
+ */
93
+ readonly onRegionSelect: ((range: TimeRange) => void) | undefined;
94
+ /**
95
+ * Require a modifier key held to start a region-drag — set to `'shift'` to make
96
+ * plain drag **pan** and **shift**-drag select, when `panZoom` is on. Only
97
+ * enforced while pan is enabled (with no pan there's no gesture to share, so the
98
+ * modifier is optional). `undefined` ⇒ a region-drag preempts pan.
99
+ */
100
+ readonly regionSelectModifier: 'shift' | undefined;
66
101
  /**
67
102
  * The selected mark, or `null`. Shared across rows (single selection). A layer
68
- * highlights the mark matching **both** the key (epoch ms) and the series
69
- * (`label`) — so two series sharing a timestamp don't both light up. A
103
+ * highlights the mark matching the selection's series **`id`** and the clicked
104
+ * sample `key` (epoch ms) — the `id` picks the series (so two series sharing a
105
+ * timestamp don't both light up), the `key` picks the mark within it. A
70
106
  * controlled `selected` prop pins it; otherwise a click on a selectable layer
71
- * sets it. The full {@link SelectInfo} (not just the key) is the identity so
72
- * multi-series Bar/Scatter can target the exact clicked mark.
107
+ * (one with an `id`) sets it.
73
108
  */
74
109
  readonly selected: SelectInfo | null;
75
110
  /**
@@ -86,12 +121,13 @@ export interface ContainerFrame {
86
121
  * from the committed `selected`. A row's pointer-move surface hit-tests its
87
122
  * selectable layers and sets it; a layer that supports hover-highlight (Bar)
88
123
  * draws the matching mark lit (a lighter treatment than `selected`'s outline).
89
- * Set-on-change (deduped by key+label) so the data canvas repaints only on a
90
- * mark transition, not every pointer move.
124
+ * Set-on-change (deduped by the series `id` + sample `key`) so the data canvas
125
+ * repaints only on a mark transition, not every pointer move.
91
126
  */
92
127
  readonly hovered: SelectInfo | null;
93
128
  /** Set the hovered mark (or `null` to clear) from a pointer-move hit-test;
94
- * deduped, so an unchanged mark is a no-op (no repaint). */
129
+ * deduped by series `id` + sample `key`, so an unchanged mark is a no-op
130
+ * (no repaint). */
95
131
  setHovered(hit: SelectInfo | null): void;
96
132
  /** The default in-chart cursor presentation for all rows ({@link CursorMode});
97
133
  * a row may override it via its own `cursor`. */
@@ -116,6 +152,14 @@ export interface ContainerFrame {
116
152
  */
117
153
  registerTrackerSource(key: symbol, source: TrackerSource): void;
118
154
  unregisterTrackerSource(key: symbol): void;
155
+ /**
156
+ * Register this layer as **selectable** — a layer calls this (keyed by its
157
+ * per-instance slot) only when it was given an `id`, so the container knows at
158
+ * least one series can be selected. Powers the dev-warn when `selected` /
159
+ * `onSelect` are wired but no layer carries an `id`. Unregister on unmount.
160
+ */
161
+ registerSelectable(key: symbol): void;
162
+ unregisterSelectable(key: symbol): void;
119
163
  /**
120
164
  * Shared x→pixel scale, range `[0, plotWidth]`. A d3 `scaleTime` (default) so
121
165
  * ticks land on wall-clock boundaries, or a `scaleLinear` when the data is
@@ -124,16 +168,28 @@ export interface ContainerFrame {
124
168
  * scales are callable
125
169
  * (`value → px`) and expose `invert`/`ticks`/`tickFormat`; consumers use only
126
170
  * that shared surface (the cursor coerces `invert` via `+`, `<TimeAxis>` keys
127
- * ticks via `+d`), so either kind drops in.
171
+ * ticks via `+d`), so either kind drops in. A **`scaleTradingTime`** (when the
172
+ * container is given `discontinuities`) is the third kind — same callable /
173
+ * `invert` / `ticks` / `tickFormat` surface, but the mapping runs through
174
+ * trading time so closed-market gaps collapse (see {@link discontinuities}).
128
175
  */
129
- readonly xScale: ScaleTime<number, number> | ScaleLinear<number, number>;
176
+ readonly xScale: ScaleTime<number, number> | ScaleLinear<number, number> | TradingTimeScale | ScaleBand;
130
177
  /**
131
- * The resolved kind of the shared x scale `'time'` (a `scaleTime`) or
132
- * `'value'` (a `scaleLinear`), inferred from the layers' data. `<XAxis>` reads
133
- * it to pick its default tick formatter (a time format vs a number format),
134
- * and the cursor readout to format the x position.
178
+ * The discontinuity provider backing a **trading-time** x axis, if one was
179
+ * supplied to the container closed-market time (weekends, holidays,
180
+ * overnight, lunch breaks) collapsed. `undefined` for a normal continuous
181
+ * time / value axis. Pan and zoom read it to move the view in *trading* time
182
+ * rather than raw wall-clock ms.
135
183
  */
136
- readonly xKind: 'time' | 'value';
184
+ readonly discontinuities?: DiscontinuityProvider | undefined;
185
+ /**
186
+ * The resolved kind of the shared x scale — `'time'` (a `scaleTime`),
187
+ * `'value'` (a `scaleLinear`), or `'category'` (a {@link ScaleBand}: an ordinal
188
+ * column-domain axis, one slot per category). Inferred from the layers' data.
189
+ * `<XAxis>` reads it to pick its default tick formatter (time / number / the
190
+ * category label), and the cursor readout to format the x position.
191
+ */
192
+ readonly xKind: 'time' | 'value' | 'category';
137
193
  /** Pan/zoom enabled — the plot drag-pans and wheel-zooms the shared time range. */
138
194
  readonly panZoom: boolean;
139
195
  /** Minimum visible duration (ms) — the zoom-in floor. */
@@ -293,17 +349,26 @@ export interface RowLayer {
293
349
  yExtent(): [number, number] | null;
294
350
  /**
295
351
  * The **kind of x axis** this layer's data lives on — `'time'` for a
296
- * `TimeSeries`, `'value'` for a `ValueSeries`. The container infers the one
297
- * shared x scale from its layers (all must agree a mix is an error), so the
298
- * axis kind never needs declaring. See {@link ContainerFrame.xScale}.
352
+ * `TimeSeries`, `'value'` for a `ValueSeries`, `'category'` for a categorical
353
+ * (ordinal column-domain) layer. The container infers the one shared x scale
354
+ * from its layers (all must agree a mix is an error), so the axis kind never
355
+ * needs declaring. See {@link ContainerFrame.xScale}.
299
356
  */
300
- readonly xKind: 'time' | 'value';
357
+ readonly xKind: 'time' | 'value' | 'category';
301
358
  /**
302
359
  * This layer's `[min, max]` along the **x** axis (the key / value-axis extent),
303
360
  * or `null` if empty. The container unions these to auto-fit the shared x
304
- * domain when no explicit `range` is given.
361
+ * domain when no explicit `range` is given. For a `'category'` layer this is
362
+ * the slot extent `[0, n]` (n = category count).
305
363
  */
306
364
  xExtent(): readonly [number, number] | null;
365
+ /**
366
+ * A `'category'` layer's ordered category names (the ordinal axis domain the
367
+ * container builds a {@link ScaleBand} + label formatter from). `undefined` /
368
+ * absent for a `'time'` or `'value'` layer. Category layers in one container
369
+ * must agree on this list (a mix is an error), the same way {@link xKind} must.
370
+ */
371
+ xCategories?(): readonly string[] | null;
307
372
  /**
308
373
  * The layer's value(s) at `time` — the nearest sample — for the scrub tracker:
309
374
  * one for a line, two (lower/upper) for a band, empty at a gap. Each carries
@@ -324,9 +389,12 @@ export interface RowLayer {
324
389
  /**
325
390
  * Hit-test plot-pixel `(px, py)` against this layer's marks for click
326
391
  * selection — the select-analog of {@link sampleAt}. Returns the hit mark or
327
- * `null`. **Optional:** layers without discrete selectable marks (line, band,
328
- * area) omit it; bar / box / scatter implement it. `xScale`/`yScale` map
329
- * data→pixels (the row resolves the layer's axis scale, as for `draw`).
392
+ * `null`. **Optional, and gated on the layer's `id`:** a layer only wires
393
+ * `hitTest` when it was given an `id` (the series identity). Layers without an
394
+ * `id` or without discrete selectable marks (line, band, area) — omit it,
395
+ * so they render + read out but never select/hover (a click on them resolves
396
+ * to empty space ⇒ deselect). `xScale`/`yScale` map data→pixels (the row
397
+ * resolves the layer's axis scale, as for `draw`).
330
398
  */
331
399
  hitTest?(px: number, py: number, xScale: (value: number) => number, yScale: (value: number) => number): SelectInfo | null;
332
400
  /** Draw into the plot canvas. `xScale`/`yScale` map data→pixels. */
@@ -370,24 +438,47 @@ export interface CursorFlag {
370
438
  */
371
439
  export interface TrackerSource {
372
440
  sampleAt(time: number): readonly TrackerSample[];
373
- readonly xKind: 'time' | 'value';
441
+ readonly xKind: 'time' | 'value' | 'category';
374
442
  xExtent(): readonly [number, number] | null;
443
+ /** A `'category'` source's ordered category names (see {@link RowLayer.xCategories}). */
444
+ xCategories?(): readonly string[] | null;
375
445
  }
376
446
  /**
377
- * One selected mark — what {@link RowLayer.hitTest} returns and `onSelect`
378
- * reports. Mirrors {@link TrackerSample}: the mark's key (its stable identity,
379
- * for controlled selection + highlight matching), value, colour, and series
380
- * label.
447
+ * One selection — what {@link RowLayer.hitTest} returns and `onSelect` reports.
448
+ * Selection identity is the **series `id`**, not the sample: `key`/`value` are
449
+ * click **provenance** (the nearest sample under the pointer, informational);
450
+ * equality, dedup, and the controlled echo all key on `id`. Because `id` is a
451
+ * stable series identity — distinct from the `as` theme role, which can repeat —
452
+ * a selection survives a streaming data update where a sample `key` would go
453
+ * stale. Only layers that carry an `id` are selectable (see {@link RowLayer.hitTest}).
381
454
  */
382
455
  export interface SelectInfo {
383
- /** The mark's key as epoch ms (its event's `begin`) — its stable identity. */
456
+ /**
457
+ * The **series identity** — the layer's `id` prop. The selection / dedup /
458
+ * controlled-echo key; stable across data updates (unlike {@link key}).
459
+ */
460
+ readonly id: string;
461
+ /**
462
+ * The clicked sample's key as epoch ms (its event's `begin`) — click
463
+ * **provenance**, informational. NOT the selection identity (that is {@link id}).
464
+ */
384
465
  readonly key: number;
385
- /** The mark's value (the plotted column). */
466
+ /** The clicked sample's value (the plotted column) — provenance. */
386
467
  readonly value: number;
387
468
  /** The mark's resolved style colour. */
388
469
  readonly color: string;
389
- /** Series identity (`as` ?? column) — labels the selection in a readout. */
470
+ /** Display label (`as` ?? column ?? id) — labels the selection in a readout. */
390
471
  readonly label: string;
472
+ /**
473
+ * An optional **stable per-mark identity within the layer** — a *category's
474
+ * column name* on the categorical axis, where every bar shares the layer's
475
+ * `id` but each column needs its own stable handle. When present, the
476
+ * highlight match + controlled `selected` echo key on `(id, mark)` instead of
477
+ * the sample `key`, so a pinned selection survives a column reorder / data
478
+ * update (the slot index is not stable; the column name is). `undefined` for
479
+ * marks whose sample `key` is already their identity (a time / value bar).
480
+ */
481
+ readonly mark?: string;
391
482
  }
392
483
  /** The hover snapshot handed to `onTrackerChanged` — the cursor time + every
393
484
  * series' value there, so a consumer can render the readout outside the chart. */
@@ -411,7 +502,7 @@ export interface TrackerInfo {
411
502
  * time pinned to the x-axis. The ChartIQ / trading-terminal readout. Values
412
503
  * snap to the series (the axis pills read like ticks), not the raw mouse Y.
413
504
  */
414
- export type CursorMode = 'none' | 'line' | 'point' | 'inline' | 'flag' | 'crosshair';
505
+ export type CursorMode = 'none' | 'line' | 'point' | 'inline' | 'flag' | 'crosshair' | 'region';
415
506
  /** A registered layer plus the axis id it draws against. */
416
507
  export interface LayerEntry {
417
508
  readonly layer: RowLayer;
package/dist/data.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { SeriesSchema, TimeSeries, ValueSeries, ValueSeriesSchema } from 'pond-ts';
1
+ import { ValueSeries } from 'pond-ts';
2
+ import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
2
3
  /**
3
4
  * A chart-ready columnar view of a series: parallel typed arrays for the time
4
5
  * (x) and value (y) axes, plus the logical row count.
@@ -96,6 +97,40 @@ export interface BarSeries {
96
97
  readonly y: Float64Array;
97
98
  readonly length: number;
98
99
  }
100
+ /**
101
+ * A chart-ready view of a **stacked / histogram** bar series — the multi-segment
102
+ * generalization of {@link BarSeries}. Each of the `length` bins spans
103
+ * `[begin[i], end[i]]` on the **bin axis** (time ms, a value, or a band edge) and
104
+ * carries one value per `group` (a stack segment). `groups` lists the segment
105
+ * identities **bottom → top**; `values` is a flat `length × groups.length` grid
106
+ * in **row-major** order, so bin `b`'s segment `g` is `values[b * groups.length + g]`.
107
+ *
108
+ * A single-series bar (the {@link BarSeries} case) is just `groups.length === 1`.
109
+ * Missing / non-finite segment values are `NaN` — the gap signal a stack skips
110
+ * (no segment, and it contributes nothing to the running total), the same
111
+ * `Number.isFinite` contract as {@link BarSeries}. Segment values are assumed
112
+ * **non-negative** (counts / durations); a negative value is treated as a gap
113
+ * (diverging stacks are out of scope — see the histogram guide).
114
+ *
115
+ * The bin axis is x for a **vertical** histogram (bars grow up) and y for a
116
+ * **horizontal** one (bars grow right); the same grid drives both — the draw
117
+ * layer transposes by orientation, the data does not change.
118
+ */
119
+ export interface StackedBarSeries {
120
+ readonly begin: Float64Array;
121
+ readonly end: Float64Array;
122
+ readonly groups: readonly string[];
123
+ readonly values: Float64Array;
124
+ readonly length: number;
125
+ /**
126
+ * Optional **stable per-bin identity** — `marks[b]` names bin `b` (a category's
127
+ * column name on the categorical axis). When present, the draw / hit-test /
128
+ * selection key on this name instead of the bin's `begin` slot index, so a
129
+ * pinned selection survives a column reorder (the slot index is not stable).
130
+ * `undefined` for a time / value series whose `begin` is already stable.
131
+ */
132
+ readonly marks?: readonly string[];
133
+ }
99
134
  /** The five quantile column names a {@link boxFromTimeSeries} reads, in order. */
100
135
  export interface BoxColumns {
101
136
  /** Lower whisker end (e.g. `p5` / `min`). */
@@ -242,4 +277,142 @@ export declare function barsFromTimeSeries<S extends SeriesSchema>(series: TimeS
242
277
  * @throws TypeError if `column` is not a numeric column.
243
278
  */
244
279
  export declare function barsFromValueSeries<VS extends ValueSeriesSchema>(series: ValueSeries<VS>, column: string): BarSeries;
280
+ /**
281
+ * Build a {@link StackedBarSeries} from a **`Map` of grouped series** — one series
282
+ * per stack group. This is the natural reader for pond's grouped-aggregate output:
283
+ * `series.partitionBy('host', { groups }).aggregate(Sequence.every('5m'), { n: 'count' }).toMap()`
284
+ * yields a `Map<host, TimeSeries>`, one interval-keyed series per host. The stack
285
+ * order (`groups`, bottom → top) is the map's **insertion order** (stable when you
286
+ * pass `partitionBy`'s `{ groups }` option).
287
+ *
288
+ * **Aligned by bucket key, not by index.** Each partition's `aggregate` spans only
289
+ * *its own* events' range, so the groups generally have **different** grids (host A
290
+ * might have buckets 0–8, host B buckets 3–9). This reader takes the **union** of
291
+ * every group's `[begin, end)` slots (ascending) and places each group's `column`
292
+ * value at the matching `begin`; a bucket a group is missing reads as a gap
293
+ * (`NaN`, contributing nothing to that stack). So the segments always line up on
294
+ * the real bucket, never on a positional accident. (Pass `aggregate`'s
295
+ * `{ range }` option if you want every group padded to one dense grid — the union
296
+ * is then that grid.) When two groups carry the **same `begin`**, the first
297
+ * group's `end` sets that slot's width — correct for the uniform-width buckets
298
+ * `aggregate` / `pivotByGroup` produce (all groups share the grid width), which is
299
+ * the intended input.
300
+ *
301
+ * @throws Error if `groups` is empty.
302
+ * @throws RangeError / TypeError (via {@link readNumericColumn}) if `column` is
303
+ * missing or non-numeric in any member.
304
+ */
305
+ export declare function stacksFromGroups<S extends SeriesSchema>(groups: ReadonlyMap<string, TimeSeries<S>>, column: string): StackedBarSeries;
306
+ /**
307
+ * Build a {@link StackedBarSeries} from a **wide** series — one numeric column
308
+ * per stack group. This is the reader for pond's `pivotByGroup` output (long →
309
+ * wide reshape: each group value becomes its own column), or any series that is
310
+ * already wide (e.g. `in` / `out` traffic). `columns` names the segment columns
311
+ * **bottom → top**; a `ValueSeries` bins on its value axis (neighbour-spaced
312
+ * slots), a `TimeSeries` on its key (interval spans or neighbour-spaced points).
313
+ *
314
+ * @throws RangeError / TypeError if any column is missing or non-numeric.
315
+ */
316
+ export declare function stacksFromColumns<S extends SeriesSchema, VS extends ValueSeriesSchema>(series: TimeSeries<S> | ValueSeries<VS>, columns: readonly string[]): StackedBarSeries;
317
+ /**
318
+ * A single bin record from `byColumn` — its `[start, end)` range plus the mapped
319
+ * aggregate columns (read by name via {@link stacksFromBins}). Deliberately just
320
+ * the `start`/`end` shape (no index signature) so pond's
321
+ * `byColumn(...): Array<{ start, end } & ReduceResult>` assigns to it structurally
322
+ * — the aggregate fields ride along and are read out by the reader.
323
+ */
324
+ export type BinRecord = {
325
+ readonly start: number;
326
+ readonly end: number;
327
+ };
328
+ /** Options for {@link stacksFromBins}. */
329
+ export interface StacksFromBinsOptions {
330
+ /**
331
+ * Use uniform **unit slots** (`[i, i+1]`) for the bins instead of their numeric
332
+ * `[start, end]` edges — an **ordinal** band axis (heart-rate zones, Coggan
333
+ * power zones) where every band reads the same width regardless of its numeric
334
+ * span. The caller labels the slots via `<YAxis ticks>` at `i + 0.5`. Omitted /
335
+ * `false` ⇒ real numeric edges (a true value axis — power W, risk %).
336
+ */
337
+ readonly ordinal?: boolean;
338
+ }
339
+ /**
340
+ * Build a {@link StackedBarSeries} from **`byColumn` bin records** — the array of
341
+ * `{ start, end, …aggregates }` a value-band aggregation returns
342
+ * (`series.byColumn('power', { width: 20 }, { seconds: { from: 'dt', using: 'sum' } })`).
343
+ * `columns` names the aggregate field(s) to draw as segments (`['seconds']` for a
344
+ * plain distribution; several for a stacked value-band histogram).
345
+ *
346
+ * By default each bin keeps its real numeric `[start, end]` edges — a true value
347
+ * axis (power W, risk %). Pass `{ ordinal: true }` for uniform unit slots
348
+ * (`[i, i+1]`) when the bins are **categories** whose numeric width shouldn't
349
+ * distort the layout (heart-rate zones); label them with `<YAxis ticks>`.
350
+ *
351
+ * A missing / non-finite aggregate reads as a gap (`NaN`).
352
+ */
353
+ export declare function stacksFromBins(bins: readonly BinRecord[], columns: readonly string[], options?: StacksFromBinsOptions): StackedBarSeries;
354
+ /**
355
+ * One category's `{ label, value }` for a categorical bar chart — the row-read /
356
+ * transpose view's `(columnName, cell)` pair (categorical-axis RFC, Phase 1). An
357
+ * ordered list of these is the explicit categorical data source; Phase 2's
358
+ * transpose reader produces the same list from a wide series' row.
359
+ *
360
+ * **Labels are the stable identity** — the axis maps each `label` to a slot and
361
+ * selection/highlight key on it (so a pick survives a reorder). They must be
362
+ * **unique** within the list: two categories sharing a label collapse to one axis
363
+ * tick and both highlight together on a pick. The transpose reader satisfies this
364
+ * for free (a series' column names are unique); only a hand-built list can break
365
+ * it. `value` may be **negative** — a single-series category bar draws it below
366
+ * the baseline (the P&L / delta case).
367
+ */
368
+ export interface CategoryDatum {
369
+ readonly label: string;
370
+ readonly value: number;
371
+ }
372
+ /**
373
+ * Build a {@link StackedBarSeries} (single group, `G === 1`) from an ordered list
374
+ * of `{ label, value }` categories — one **unit slot** `[i, i+1]` per category, in
375
+ * order. This is the categorical row-read's geometry: the slots are ordinal
376
+ * indices (the bar's pixel span comes from the container's {@link ScaleBand}), and
377
+ * the `label`s become the axis's ordered category names (`xCategories`). A
378
+ * non-finite value reads as a gap (`NaN`). Reuses the shipped stacked geometry —
379
+ * no new draw path.
380
+ */
381
+ export declare function categoryStack(records: readonly CategoryDatum[]): StackedBarSeries;
382
+ /** Which row {@link transposeRow} reads across. */
383
+ export type RowAt = 'first' | 'last' | number | {
384
+ readonly time: number;
385
+ };
386
+ /** Options for {@link transposeRow}. */
387
+ export interface TransposeRowOptions {
388
+ /**
389
+ * Which row to read across. **Default `'last'`** — the head / latest row (the
390
+ * live snapshot). `'first'`, an **index** (negative from the end), or
391
+ * `{ time }` for the row nearest a key.
392
+ */
393
+ readonly at?: RowAt;
394
+ /**
395
+ * The columns to lay on the axis, **in order** — a declared / bounded set (a
396
+ * watchlist, or a top-N computed upstream; the RFC §7 "bound in the data layer"
397
+ * stance). Omit to use **every numeric value column** of the series, in schema
398
+ * order. A named column that's missing / non-numeric in the row reads as a gap.
399
+ */
400
+ readonly columns?: readonly string[];
401
+ }
402
+ /**
403
+ * **The transpose reader** (categorical-axis RFC, Phase 1 PR2): read **one row**
404
+ * of a wide `TimeSeries` **across** — its columns become the categories, that
405
+ * row's cells the values — for `<BarChart categories={…}>`. This is "columns on
406
+ * x": the schema's numeric columns (a `pivotByGroup` output's per-group columns,
407
+ * a vol term structure's per-expiry columns, …) laid out at one instant.
408
+ *
409
+ * The row is picked the ordinary way (`options.at`, default the **head row**):
410
+ * `series.last()` / `.first()` / `.at(index)` / `.nearest(time)`. So "which row"
411
+ * is just row selection — the live snapshot is the head row; a static report
412
+ * pins a row by index or time. (Binding the row to a scrubbing time cursor is a
413
+ * later phase.) Pass `options.columns` to bound / order the category set; omit it
414
+ * to take every numeric value column. An empty series (or a row past the ends)
415
+ * yields `[]`; a missing / non-numeric cell reads as a gap (`NaN`).
416
+ */
417
+ export declare function transposeRow<S extends SeriesSchema>(series: TimeSeries<S>, options?: TransposeRowOptions): CategoryDatum[];
245
418
  //# sourceMappingURL=data.d.ts.map