@pond-ts/charts 0.41.0 → 0.42.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,148 @@ 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** — always `[0, maxTotal]`,
176
+ * where `maxTotal` is the tallest bin's summed finite non-negative segments. `0` is
177
+ * pulled in so the stack rests on a visible baseline (the bar analog of
178
+ * {@link barExtent}). An empty / all-gap series returns `[0, 1]` so the axis still
179
+ * has a usable domain. Feeds the y auto-fit for a vertical histogram, the x
180
+ * auto-fit for a horizontal one.
181
+ */
182
+ export function stackValueExtent(ss) {
183
+ const G = ss.groups.length;
184
+ let max = 0;
185
+ for (let b = 0; b < ss.length; b += 1) {
186
+ let cum = 0;
187
+ for (let g = 0; g < G; g += 1) {
188
+ const v = ss.values[b * G + g];
189
+ if (Number.isFinite(v) && v > 0)
190
+ cum += v;
191
+ }
192
+ if (cum > max)
193
+ max = cum;
194
+ }
195
+ return [0, max > 0 ? max : 1];
196
+ }
197
+ /**
198
+ * The `[min, max]` extent of the **bin axis** — the first bin's `begin` to the
199
+ * last bin's `end` (the slots are ascending). `null` for an empty series. Feeds
200
+ * the x auto-fit for a vertical histogram, the y auto-fit for a horizontal one.
201
+ */
202
+ export function stackBinExtent(ss) {
203
+ if (ss.length === 0)
204
+ return null;
205
+ return [ss.begin[0], ss.end[ss.length - 1]];
206
+ }
207
+ /**
208
+ * The pixel rect `[x0, x1, yTop, yBottom]` (ascending on both axes) of bin `b`'s
209
+ * segment `g`, stacked so it sits atop `cumBefore` (the summed value of the
210
+ * segments below it, in value units). `null` for a gap (see below). Transposes on
211
+ * `orientation`:
212
+ *
213
+ * - **vertical** — the bin span is horizontal (`barSpanPx` on `xScale`); the
214
+ * segment runs vertically from `yScale(cumBefore)` to `yScale(cumBefore + v)`.
215
+ * - **horizontal** — the bin span is vertical (`barSpanPx` on `yScale`); the
216
+ * segment runs horizontally from `xScale(cumBefore)` to `xScale(cumBefore + v)`.
217
+ *
218
+ * `null` for a **gap** — a non-finite, negative, **or zero** value: none of them
219
+ * draw (a zero segment has no extent), and each contributes nothing to the running
220
+ * total. `minSpanPx` floors the **bin** span (bar thickness); the value direction
221
+ * is unfloored. Shared by {@link drawStacks} and {@link stackAt} so the drawn rect
222
+ * and the hit rect are identical.
223
+ */
224
+ export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, gapPx, minSpanPx) {
225
+ const G = ss.groups.length;
226
+ const v = ss.values[b * G + g];
227
+ // Skip non-finite / negative / zero: a zero segment would otherwise draw a
228
+ // wasted zero-extent rect (and can't be hit-tested).
229
+ if (!Number.isFinite(v) || v <= 0)
230
+ return null;
231
+ if (orientation === 'vertical') {
232
+ const [x0, x1] = barSpanPx(ss.begin[b], ss.end[b], xScale, gapPx, minSpanPx);
233
+ const yA = yScale(cumBefore);
234
+ const yB = yScale(cumBefore + v);
235
+ return [x0, x1, Math.min(yA, yB), Math.max(yA, yB)];
236
+ }
237
+ const [y0, y1] = barSpanPx(ss.begin[b], ss.end[b], yScale, gapPx, minSpanPx);
238
+ const xA = xScale(cumBefore);
239
+ const xB = xScale(cumBefore + v);
240
+ return [Math.min(xA, xB), Math.max(xA, xB), y0, y1];
241
+ }
242
+ /**
243
+ * Fill every segment of every bin in `ss`, stacking each bin's groups from the
244
+ * value baseline outward (bottom → top vertical, left → right horizontal). A gap
245
+ * (non-finite / negative) segment is skipped and adds nothing to the running
246
+ * total, so the segments above it close the space. A segment matching the current
247
+ * `selection` (same series `id`, bin `key` **and** group `label`) draws in its
248
+ * group's `highlight` **and** outlined; one matching `hover` draws in `highlight`
249
+ * without the outline; all others use the flat `fill`. `globalAlpha` carries the
250
+ * shared opacity and is restored.
251
+ *
252
+ * O(N·G) over bins × groups, one fill (+ optional stroke) per drawn segment.
253
+ */
254
+ export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, minSpanPx, seriesId, selection, hover) {
255
+ const G = ss.groups.length;
256
+ ctx.save();
257
+ ctx.globalAlpha = style.opacity;
258
+ for (let b = 0; b < ss.length; b += 1) {
259
+ let cum = 0;
260
+ for (let g = 0; g < G; g += 1) {
261
+ const rect = segmentRect(ss, b, g, orientation, xScale, yScale, cum, gapPx, minSpanPx);
262
+ const v = ss.values[b * G + g];
263
+ if (Number.isFinite(v) && v > 0)
264
+ cum += v;
265
+ if (rect === null)
266
+ continue;
267
+ const [x0, x1, yTop, yBottom] = rect;
268
+ const matches = (m) => m !== null &&
269
+ m.id === seriesId &&
270
+ m.key === ss.begin[b] &&
271
+ m.label === ss.groups[g];
272
+ const selected = matches(selection);
273
+ const isHovered = matches(hover);
274
+ // A hovered / selected segment pops to full opacity in its own colour; a
275
+ // resting one draws at the shared alpha.
276
+ ctx.globalAlpha = selected || isHovered ? 1 : style.opacity;
277
+ ctx.fillStyle = style.fills[g];
278
+ ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
279
+ if (selected) {
280
+ ctx.lineWidth = style.outlineWidth;
281
+ ctx.strokeStyle = style.fills[g];
282
+ ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
283
+ }
284
+ }
285
+ }
286
+ ctx.restore();
287
+ }
288
+ /**
289
+ * Hit-test plot-pixel `(px, py)` against `ss`'s stacked segments — the **first**
290
+ * segment whose rect contains the point, or `null`. The geometry is
291
+ * {@link segmentRect}, so the hit rect is exactly the drawn rect. The returned
292
+ * tuple is `[bin, group, begin, groupName, value]` for the chart to assemble a
293
+ * `SelectInfo` (it owns the colour). Orientation-agnostic — it reads `(px, py)`,
294
+ * so a horizontal histogram hit-tests the same way a vertical one does.
295
+ *
296
+ * O(N·G) over bins × groups (no spatial index — histogram bin/group counts are
297
+ * small; click / hover are cheap events).
298
+ */
299
+ export function stackAt(ss, px, py, orientation, xScale, yScale, gapPx, minSpanPx) {
300
+ const G = ss.groups.length;
301
+ for (let b = 0; b < ss.length; b += 1) {
302
+ let cum = 0;
303
+ for (let g = 0; g < G; g += 1) {
304
+ const rect = segmentRect(ss, b, g, orientation, xScale, yScale, cum, gapPx, minSpanPx);
305
+ const v = ss.values[b * G + g];
306
+ if (Number.isFinite(v) && v > 0)
307
+ cum += v;
308
+ if (rect === null)
309
+ continue;
310
+ const [x0, x1, yTop, yBottom] = rect;
311
+ if (px >= x0 && px <= x1 && py >= yTop && py <= yBottom) {
312
+ return [b, g, ss.begin[b], ss.groups[g], v];
313
+ }
314
+ }
315
+ }
316
+ return null;
317
+ }
171
318
  //# sourceMappingURL=bars.js.map
package/dist/context.d.ts CHANGED
@@ -1,6 +1,7 @@
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 { TradingTimeScale, DiscontinuityProvider } from './tradingTimeScale.js';
4
5
  /**
5
6
  * The frame a {@link ChartContainer} provides to its rows and the time axis.
6
7
  * The container owns the **shared x geometry**: each side is split into *slots*
@@ -65,11 +66,11 @@ export interface ContainerFrame {
65
66
  readonly crosshairSnap: boolean;
66
67
  /**
67
68
  * 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
69
+ * highlights the mark matching the selection's series **`id`** and the clicked
70
+ * sample `key` (epoch ms) — the `id` picks the series (so two series sharing a
71
+ * timestamp don't both light up), the `key` picks the mark within it. A
70
72
  * 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.
73
+ * (one with an `id`) sets it.
73
74
  */
74
75
  readonly selected: SelectInfo | null;
75
76
  /**
@@ -86,12 +87,13 @@ export interface ContainerFrame {
86
87
  * from the committed `selected`. A row's pointer-move surface hit-tests its
87
88
  * selectable layers and sets it; a layer that supports hover-highlight (Bar)
88
89
  * 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.
90
+ * Set-on-change (deduped by the series `id` + sample `key`) so the data canvas
91
+ * repaints only on a mark transition, not every pointer move.
91
92
  */
92
93
  readonly hovered: SelectInfo | null;
93
94
  /** 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). */
95
+ * deduped by series `id` + sample `key`, so an unchanged mark is a no-op
96
+ * (no repaint). */
95
97
  setHovered(hit: SelectInfo | null): void;
96
98
  /** The default in-chart cursor presentation for all rows ({@link CursorMode});
97
99
  * a row may override it via its own `cursor`. */
@@ -116,6 +118,14 @@ export interface ContainerFrame {
116
118
  */
117
119
  registerTrackerSource(key: symbol, source: TrackerSource): void;
118
120
  unregisterTrackerSource(key: symbol): void;
121
+ /**
122
+ * Register this layer as **selectable** — a layer calls this (keyed by its
123
+ * per-instance slot) only when it was given an `id`, so the container knows at
124
+ * least one series can be selected. Powers the dev-warn when `selected` /
125
+ * `onSelect` are wired but no layer carries an `id`. Unregister on unmount.
126
+ */
127
+ registerSelectable(key: symbol): void;
128
+ unregisterSelectable(key: symbol): void;
119
129
  /**
120
130
  * Shared x→pixel scale, range `[0, plotWidth]`. A d3 `scaleTime` (default) so
121
131
  * ticks land on wall-clock boundaries, or a `scaleLinear` when the data is
@@ -124,9 +134,20 @@ export interface ContainerFrame {
124
134
  * scales are callable
125
135
  * (`value → px`) and expose `invert`/`ticks`/`tickFormat`; consumers use only
126
136
  * that shared surface (the cursor coerces `invert` via `+`, `<TimeAxis>` keys
127
- * ticks via `+d`), so either kind drops in.
137
+ * ticks via `+d`), so either kind drops in. A **`scaleTradingTime`** (when the
138
+ * container is given `discontinuities`) is the third kind — same callable /
139
+ * `invert` / `ticks` / `tickFormat` surface, but the mapping runs through
140
+ * trading time so closed-market gaps collapse (see {@link discontinuities}).
128
141
  */
129
- readonly xScale: ScaleTime<number, number> | ScaleLinear<number, number>;
142
+ readonly xScale: ScaleTime<number, number> | ScaleLinear<number, number> | TradingTimeScale;
143
+ /**
144
+ * The discontinuity provider backing a **trading-time** x axis, if one was
145
+ * supplied to the container — closed-market time (weekends, holidays,
146
+ * overnight, lunch breaks) collapsed. `undefined` for a normal continuous
147
+ * time / value axis. Pan and zoom read it to move the view in *trading* time
148
+ * rather than raw wall-clock ms.
149
+ */
150
+ readonly discontinuities?: DiscontinuityProvider | undefined;
130
151
  /**
131
152
  * The resolved kind of the shared x scale — `'time'` (a `scaleTime`) or
132
153
  * `'value'` (a `scaleLinear`), inferred from the layers' data. `<XAxis>` reads
@@ -324,9 +345,12 @@ export interface RowLayer {
324
345
  /**
325
346
  * Hit-test plot-pixel `(px, py)` against this layer's marks for click
326
347
  * 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`).
348
+ * `null`. **Optional, and gated on the layer's `id`:** a layer only wires
349
+ * `hitTest` when it was given an `id` (the series identity). Layers without an
350
+ * `id` or without discrete selectable marks (line, band, area) — omit it,
351
+ * so they render + read out but never select/hover (a click on them resolves
352
+ * to empty space ⇒ deselect). `xScale`/`yScale` map data→pixels (the row
353
+ * resolves the layer's axis scale, as for `draw`).
330
354
  */
331
355
  hitTest?(px: number, py: number, xScale: (value: number) => number, yScale: (value: number) => number): SelectInfo | null;
332
356
  /** Draw into the plot canvas. `xScale`/`yScale` map data→pixels. */
@@ -374,19 +398,30 @@ export interface TrackerSource {
374
398
  xExtent(): readonly [number, number] | null;
375
399
  }
376
400
  /**
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.
401
+ * One selection — what {@link RowLayer.hitTest} returns and `onSelect` reports.
402
+ * Selection identity is the **series `id`**, not the sample: `key`/`value` are
403
+ * click **provenance** (the nearest sample under the pointer, informational);
404
+ * equality, dedup, and the controlled echo all key on `id`. Because `id` is a
405
+ * stable series identity — distinct from the `as` theme role, which can repeat —
406
+ * a selection survives a streaming data update where a sample `key` would go
407
+ * stale. Only layers that carry an `id` are selectable (see {@link RowLayer.hitTest}).
381
408
  */
382
409
  export interface SelectInfo {
383
- /** The mark's key as epoch ms (its event's `begin`) — its stable identity. */
410
+ /**
411
+ * The **series identity** — the layer's `id` prop. The selection / dedup /
412
+ * controlled-echo key; stable across data updates (unlike {@link key}).
413
+ */
414
+ readonly id: string;
415
+ /**
416
+ * The clicked sample's key as epoch ms (its event's `begin`) — click
417
+ * **provenance**, informational. NOT the selection identity (that is {@link id}).
418
+ */
384
419
  readonly key: number;
385
- /** The mark's value (the plotted column). */
420
+ /** The clicked sample's value (the plotted column) — provenance. */
386
421
  readonly value: number;
387
422
  /** The mark's resolved style colour. */
388
423
  readonly color: string;
389
- /** Series identity (`as` ?? column) — labels the selection in a readout. */
424
+ /** Display label (`as` ?? column ?? id) — labels the selection in a readout. */
390
425
  readonly label: string;
391
426
  }
392
427
  /** The hover snapshot handed to `onTrackerChanged` — the cursor time + every
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,32 @@ 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
+ }
99
126
  /** The five quantile column names a {@link boxFromTimeSeries} reads, in order. */
100
127
  export interface BoxColumns {
101
128
  /** Lower whisker end (e.g. `p5` / `min`). */
@@ -242,4 +269,78 @@ export declare function barsFromTimeSeries<S extends SeriesSchema>(series: TimeS
242
269
  * @throws TypeError if `column` is not a numeric column.
243
270
  */
244
271
  export declare function barsFromValueSeries<VS extends ValueSeriesSchema>(series: ValueSeries<VS>, column: string): BarSeries;
272
+ /**
273
+ * Build a {@link StackedBarSeries} from a **`Map` of grouped series** — one series
274
+ * per stack group. This is the natural reader for pond's grouped-aggregate output:
275
+ * `series.partitionBy('host', { groups }).aggregate(Sequence.every('5m'), { n: 'count' }).toMap()`
276
+ * yields a `Map<host, TimeSeries>`, one interval-keyed series per host. The stack
277
+ * order (`groups`, bottom → top) is the map's **insertion order** (stable when you
278
+ * pass `partitionBy`'s `{ groups }` option).
279
+ *
280
+ * **Aligned by bucket key, not by index.** Each partition's `aggregate` spans only
281
+ * *its own* events' range, so the groups generally have **different** grids (host A
282
+ * might have buckets 0–8, host B buckets 3–9). This reader takes the **union** of
283
+ * every group's `[begin, end)` slots (ascending) and places each group's `column`
284
+ * value at the matching `begin`; a bucket a group is missing reads as a gap
285
+ * (`NaN`, contributing nothing to that stack). So the segments always line up on
286
+ * the real bucket, never on a positional accident. (Pass `aggregate`'s
287
+ * `{ range }` option if you want every group padded to one dense grid — the union
288
+ * is then that grid.) When two groups carry the **same `begin`**, the first
289
+ * group's `end` sets that slot's width — correct for the uniform-width buckets
290
+ * `aggregate` / `pivotByGroup` produce (all groups share the grid width), which is
291
+ * the intended input.
292
+ *
293
+ * @throws Error if `groups` is empty.
294
+ * @throws RangeError / TypeError (via {@link readNumericColumn}) if `column` is
295
+ * missing or non-numeric in any member.
296
+ */
297
+ export declare function stacksFromGroups<S extends SeriesSchema>(groups: ReadonlyMap<string, TimeSeries<S>>, column: string): StackedBarSeries;
298
+ /**
299
+ * Build a {@link StackedBarSeries} from a **wide** series — one numeric column
300
+ * per stack group. This is the reader for pond's `pivotByGroup` output (long →
301
+ * wide reshape: each group value becomes its own column), or any series that is
302
+ * already wide (e.g. `in` / `out` traffic). `columns` names the segment columns
303
+ * **bottom → top**; a `ValueSeries` bins on its value axis (neighbour-spaced
304
+ * slots), a `TimeSeries` on its key (interval spans or neighbour-spaced points).
305
+ *
306
+ * @throws RangeError / TypeError if any column is missing or non-numeric.
307
+ */
308
+ export declare function stacksFromColumns<S extends SeriesSchema, VS extends ValueSeriesSchema>(series: TimeSeries<S> | ValueSeries<VS>, columns: readonly string[]): StackedBarSeries;
309
+ /**
310
+ * A single bin record from `byColumn` — its `[start, end)` range plus the mapped
311
+ * aggregate columns (read by name via {@link stacksFromBins}). Deliberately just
312
+ * the `start`/`end` shape (no index signature) so pond's
313
+ * `byColumn(...): Array<{ start, end } & ReduceResult>` assigns to it structurally
314
+ * — the aggregate fields ride along and are read out by the reader.
315
+ */
316
+ export type BinRecord = {
317
+ readonly start: number;
318
+ readonly end: number;
319
+ };
320
+ /** Options for {@link stacksFromBins}. */
321
+ export interface StacksFromBinsOptions {
322
+ /**
323
+ * Use uniform **unit slots** (`[i, i+1]`) for the bins instead of their numeric
324
+ * `[start, end]` edges — an **ordinal** band axis (heart-rate zones, Coggan
325
+ * power zones) where every band reads the same width regardless of its numeric
326
+ * span. The caller labels the slots via `<YAxis ticks>` at `i + 0.5`. Omitted /
327
+ * `false` ⇒ real numeric edges (a true value axis — power W, risk %).
328
+ */
329
+ readonly ordinal?: boolean;
330
+ }
331
+ /**
332
+ * Build a {@link StackedBarSeries} from **`byColumn` bin records** — the array of
333
+ * `{ start, end, …aggregates }` a value-band aggregation returns
334
+ * (`series.byColumn('power', { width: 20 }, { seconds: { from: 'dt', using: 'sum' } })`).
335
+ * `columns` names the aggregate field(s) to draw as segments (`['seconds']` for a
336
+ * plain distribution; several for a stacked value-band histogram).
337
+ *
338
+ * By default each bin keeps its real numeric `[start, end]` edges — a true value
339
+ * axis (power W, risk %). Pass `{ ordinal: true }` for uniform unit slots
340
+ * (`[i, i+1]`) when the bins are **categories** whose numeric width shouldn't
341
+ * distort the layout (heart-rate zones); label them with `<YAxis ticks>`.
342
+ *
343
+ * A missing / non-finite aggregate reads as a gap (`NaN`).
344
+ */
345
+ export declare function stacksFromBins(bins: readonly BinRecord[], columns: readonly string[], options?: StacksFromBinsOptions): StackedBarSeries;
245
346
  //# sourceMappingURL=data.d.ts.map
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,150 @@ 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
+ }
308
455
  //# 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