@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/CHANGELOG.md +74 -5
- package/dist/BarChart.d.ts +117 -54
- package/dist/BarChart.js +241 -104
- package/dist/ChartContainer.d.ts +52 -6
- package/dist/ChartContainer.js +81 -4
- package/dist/Layers.js +47 -8
- package/dist/ScatterChart.d.ts +15 -3
- package/dist/ScatterChart.js +23 -5
- package/dist/bars.d.ts +96 -5
- package/dist/bars.js +157 -10
- package/dist/context.d.ts +54 -19
- package/dist/data.d.ts +102 -1
- package/dist/data.js +147 -0
- package/dist/grid.d.ts +14 -0
- package/dist/grid.js +36 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -1
- package/dist/scatter.d.ts +9 -7
- package/dist/scatter.js +12 -8
- package/dist/theme.d.ts +7 -0
- package/dist/theme.js +1 -0
- package/dist/tradingTimeScale.d.ts +97 -0
- package/dist/tradingTimeScale.js +152 -0
- package/dist/viewport.d.ts +23 -0
- package/dist/viewport.js +51 -0
- package/package.json +3 -3
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 `
|
|
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,
|
|
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
|
|
104
|
-
//
|
|
105
|
-
// `
|
|
106
|
-
//
|
|
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.
|
|
109
|
-
selection.
|
|
111
|
+
selection.id === seriesId &&
|
|
112
|
+
selection.key === cs.begin[i];
|
|
110
113
|
const isHovered = hovered !== null &&
|
|
111
|
-
hovered.
|
|
112
|
-
hovered.
|
|
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
|
|
69
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
328
|
-
*
|
|
329
|
-
*
|
|
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
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|