@pond-ts/charts 0.53.0 → 0.54.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 +900 -4
- package/dist/AreaChart.d.ts +9 -1
- package/dist/AreaChart.js +46 -5
- package/dist/BarChart.d.ts +17 -8
- package/dist/BarChart.js +17 -4
- package/dist/ChartContainer.js +10 -1
- package/dist/LineChart.d.ts +12 -1
- package/dist/LineChart.js +46 -5
- package/dist/XAxis.js +13 -1
- package/dist/affine.d.ts +35 -14
- package/dist/affine.js +34 -17
- package/dist/area.js +4 -4
- package/dist/bars.d.ts +42 -15
- package/dist/bars.js +97 -23
- package/dist/context.d.ts +39 -7
- package/dist/data.d.ts +58 -0
- package/dist/data.js +85 -22
- package/dist/decimate.js +7 -7
- package/dist/elapsed.js +43 -1
- package/dist/line.js +2 -2
- package/package.json +3 -3
package/dist/bars.d.ts
CHANGED
|
@@ -54,22 +54,52 @@ export declare function resolveBarBaseline(yScale: Scale): number;
|
|
|
54
54
|
* hit rect are the same geometry.
|
|
55
55
|
*/
|
|
56
56
|
export declare function barRect(cs: BarSeries, i: number, xScale: Scale, yScale: Scale, baseline: number, gapPx: number, minWidthPx: number): [x0: number, x1: number, yTop: number, yBottom: number] | null;
|
|
57
|
+
/**
|
|
58
|
+
* The narrowed selection / hover identity a **single-series** bar matches
|
|
59
|
+
* against: the layer's series `id`, the sample's `key` (its `begin`), and — when
|
|
60
|
+
* the series carries {@link BarSeries.marks} — the stable per-bar `mark`. The
|
|
61
|
+
* single-series sibling of {@link StackMark}, which additionally carries the
|
|
62
|
+
* stack's group `label` (a single-series bar has no group to disambiguate).
|
|
63
|
+
*/
|
|
64
|
+
export interface BarMark {
|
|
65
|
+
readonly id: string;
|
|
66
|
+
readonly key: number;
|
|
67
|
+
readonly mark?: string;
|
|
68
|
+
}
|
|
57
69
|
/**
|
|
58
70
|
* Fill one rectangle per bar in `cs`, each spanning its key's `[begin, end]`
|
|
59
71
|
* (inset by `gapPx`) from the resolved `baseline` to the value.
|
|
60
72
|
*
|
|
61
73
|
* A gap (non-finite value) is skipped — no bar, no zero-height sliver. A bar
|
|
62
|
-
* matching the current `selection` (
|
|
63
|
-
*
|
|
64
|
-
* draws in the style's `highlight` colour **and
|
|
65
|
-
* on the canvas; a bar matching `hovered`
|
|
66
|
-
* outline (a lighter "this bar is live" on
|
|
67
|
-
* `fill`. `globalAlpha` carries the fill
|
|
68
|
-
* leak into later layers.
|
|
74
|
+
* matching the current `selection` (the layer's own series `id` — `seriesId`; a
|
|
75
|
+
* no-id layer passes `undefined` and never matches — plus the bar's identity,
|
|
76
|
+
* see {@link barMatches}) draws in the style's `highlight` colour **and
|
|
77
|
+
* outlined**, so a click reads back on the canvas; a bar matching `hovered`
|
|
78
|
+
* draws in `highlight` **without** the outline (a lighter "this bar is live" on
|
|
79
|
+
* pointer-over); all others use the flat `fill`. `globalAlpha` carries the fill
|
|
80
|
+
* opacity and is restored so it doesn't leak into later layers.
|
|
81
|
+
*
|
|
82
|
+
* **Which identity.** A selection carrying a `mark` matches against the series'
|
|
83
|
+
* stable per-bar name ({@link BarSeries.marks} — the sample's own axis key,
|
|
84
|
+
* which the readers always supply); one without falls back to the sample `key`
|
|
85
|
+
* (the bar's `begin`). The mark path is what lets a caller pin a bar on a
|
|
86
|
+
* **point-keyed** series without re-deriving the neighbour-spaced span, since
|
|
87
|
+
* there `begin` is not the sample's key but an edge computed from it.
|
|
69
88
|
*
|
|
70
89
|
* O(N) over the events, one fill (+ optional stroke) per bar, no per-bar
|
|
71
90
|
* allocation beyond the rect tuple.
|
|
72
91
|
*
|
|
92
|
+
* **Per-bar fills (`binFills`):** an optional colour array aligned
|
|
93
|
+
* index-for-index to the source bars — bar `i` fills with `binFills[i]`
|
|
94
|
+
* (an `undefined` entry falls back to the flat `fill`). This is the
|
|
95
|
+
* direction-coloured financial volume row (rising / falling) and the
|
|
96
|
+
* value-band case on a time axis. Highlight follows {@link drawStacks}'s
|
|
97
|
+
* binFills convention rather than the flat path's: the bar **keeps its own
|
|
98
|
+
* colour** under hover / selection — the highlight pops `globalAlpha` to 1
|
|
99
|
+
* (and outlines the selection in the bar's own fill) — so a red / green bar
|
|
100
|
+
* stays red / green while live, instead of swapping to the single
|
|
101
|
+
* `highlight` colour and losing its meaning.
|
|
102
|
+
*
|
|
73
103
|
* **M4 column decimation ([PND-MARKDEC]):** once the *visible* bars are denser
|
|
74
104
|
* than ~2 per device pixel, they overplot into a solid silhouette, so
|
|
75
105
|
* `decimate !== false` replaces them with one **envelope rect per pixel column**
|
|
@@ -79,15 +109,12 @@ export declare function barRect(cs: BarSeries, i: number, xScale: Scale, yScale:
|
|
|
79
109
|
* columns aren't individually selectable, so per-bar selection/hover highlight is
|
|
80
110
|
* suppressed (a <1px bar's ring wouldn't be visible anyway); interaction still
|
|
81
111
|
* reads the **source** bars via {@link barAt} (§2.3). Pass `decimate={false}` to
|
|
82
|
-
* always draw every bar.
|
|
112
|
+
* always draw every bar. **`binFills` disables the envelope pass** — a single
|
|
113
|
+
* envelope rect spans many differently-coloured bars, so decimating would
|
|
114
|
+
* repaint them one flat colour; per-bar-coloured layers draw every visible bar.
|
|
115
|
+
* Returns {@link LayerDrawStats} for `onDrawStats`.
|
|
83
116
|
*/
|
|
84
|
-
export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, xScale: Scale, yScale: Scale, style: BarStyle, baseline: number, gapPx: number, seriesId: string | undefined, selection:
|
|
85
|
-
key: number;
|
|
86
|
-
id: string;
|
|
87
|
-
} | null, hovered: {
|
|
88
|
-
key: number;
|
|
89
|
-
id: string;
|
|
90
|
-
} | null, decimate?: DecimateOption): LayerDrawStats;
|
|
117
|
+
export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, xScale: Scale, yScale: Scale, style: BarStyle, baseline: number, gapPx: number, seriesId: string | undefined, selection: BarMark | null, hovered: BarMark | null, decimate?: DecimateOption, binFills?: readonly (string | undefined)[]): LayerDrawStats;
|
|
91
118
|
/**
|
|
92
119
|
* The index of the bar whose key span `[begin, end]` contains `time` — the bar
|
|
93
120
|
* **under the cursor** — or `-1` if `time` falls in no bar's span. This is the
|
package/dist/bars.js
CHANGED
|
@@ -79,22 +79,66 @@ export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx) {
|
|
|
79
79
|
const yBase = yScale(baseline);
|
|
80
80
|
return [x0, x1, Math.min(yValue, yBase), Math.max(yValue, yBase)];
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Does `m` identify the bar with stable identity `stable` and key `begin`?
|
|
84
|
+
* The mark decides **only when both sides have one** — `m.mark` (the selection
|
|
85
|
+
* names a bar) and `stable` (this series names its bars). Either missing falls
|
|
86
|
+
* back to `m.key === begin`, so both of these keep working unchanged:
|
|
87
|
+
*
|
|
88
|
+
* - a selection with **no `mark`** — every controlled `selected={{ id, key }}`
|
|
89
|
+
* that predates this channel, against a series that now carries marks;
|
|
90
|
+
* - a series with **no `marks`** — a hand-built {@link BarSeries} (tests, an
|
|
91
|
+
* outside caller assembling the view themselves).
|
|
92
|
+
*
|
|
93
|
+
* This is the `mark`-first rule {@link drawStacks} applies, with one deliberate
|
|
94
|
+
* difference: it falls back on the **selection** carrying no mark, where
|
|
95
|
+
* `drawStacks` falls back on the **series** carrying none. `drawStacks` can
|
|
96
|
+
* switch on the series alone because only `categoryStack` produces marks and it
|
|
97
|
+
* never had key-pinned consumers. Every reader-built bar series now carries
|
|
98
|
+
* marks, so that unconditional switch would silently stop matching each shipped
|
|
99
|
+
* key-pinned selection — key-pinning is the only selection bars ever had.
|
|
100
|
+
*/
|
|
101
|
+
function barMatches(m, seriesId, stable, begin) {
|
|
102
|
+
if (m === null || m.id !== seriesId)
|
|
103
|
+
return false;
|
|
104
|
+
return m.mark !== undefined && stable !== undefined
|
|
105
|
+
? m.mark === stable
|
|
106
|
+
: m.key === begin;
|
|
107
|
+
}
|
|
82
108
|
/**
|
|
83
109
|
* Fill one rectangle per bar in `cs`, each spanning its key's `[begin, end]`
|
|
84
110
|
* (inset by `gapPx`) from the resolved `baseline` to the value.
|
|
85
111
|
*
|
|
86
112
|
* A gap (non-finite value) is skipped — no bar, no zero-height sliver. A bar
|
|
87
|
-
* matching the current `selection` (
|
|
88
|
-
*
|
|
89
|
-
* draws in the style's `highlight` colour **and
|
|
90
|
-
* on the canvas; a bar matching `hovered`
|
|
91
|
-
* outline (a lighter "this bar is live" on
|
|
92
|
-
* `fill`. `globalAlpha` carries the fill
|
|
93
|
-
* leak into later layers.
|
|
113
|
+
* matching the current `selection` (the layer's own series `id` — `seriesId`; a
|
|
114
|
+
* no-id layer passes `undefined` and never matches — plus the bar's identity,
|
|
115
|
+
* see {@link barMatches}) draws in the style's `highlight` colour **and
|
|
116
|
+
* outlined**, so a click reads back on the canvas; a bar matching `hovered`
|
|
117
|
+
* draws in `highlight` **without** the outline (a lighter "this bar is live" on
|
|
118
|
+
* pointer-over); all others use the flat `fill`. `globalAlpha` carries the fill
|
|
119
|
+
* opacity and is restored so it doesn't leak into later layers.
|
|
120
|
+
*
|
|
121
|
+
* **Which identity.** A selection carrying a `mark` matches against the series'
|
|
122
|
+
* stable per-bar name ({@link BarSeries.marks} — the sample's own axis key,
|
|
123
|
+
* which the readers always supply); one without falls back to the sample `key`
|
|
124
|
+
* (the bar's `begin`). The mark path is what lets a caller pin a bar on a
|
|
125
|
+
* **point-keyed** series without re-deriving the neighbour-spaced span, since
|
|
126
|
+
* there `begin` is not the sample's key but an edge computed from it.
|
|
94
127
|
*
|
|
95
128
|
* O(N) over the events, one fill (+ optional stroke) per bar, no per-bar
|
|
96
129
|
* allocation beyond the rect tuple.
|
|
97
130
|
*
|
|
131
|
+
* **Per-bar fills (`binFills`):** an optional colour array aligned
|
|
132
|
+
* index-for-index to the source bars — bar `i` fills with `binFills[i]`
|
|
133
|
+
* (an `undefined` entry falls back to the flat `fill`). This is the
|
|
134
|
+
* direction-coloured financial volume row (rising / falling) and the
|
|
135
|
+
* value-band case on a time axis. Highlight follows {@link drawStacks}'s
|
|
136
|
+
* binFills convention rather than the flat path's: the bar **keeps its own
|
|
137
|
+
* colour** under hover / selection — the highlight pops `globalAlpha` to 1
|
|
138
|
+
* (and outlines the selection in the bar's own fill) — so a red / green bar
|
|
139
|
+
* stays red / green while live, instead of swapping to the single
|
|
140
|
+
* `highlight` colour and losing its meaning.
|
|
141
|
+
*
|
|
98
142
|
* **M4 column decimation ([PND-MARKDEC]):** once the *visible* bars are denser
|
|
99
143
|
* than ~2 per device pixel, they overplot into a solid silhouette, so
|
|
100
144
|
* `decimate !== false` replaces them with one **envelope rect per pixel column**
|
|
@@ -104,9 +148,12 @@ export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx) {
|
|
|
104
148
|
* columns aren't individually selectable, so per-bar selection/hover highlight is
|
|
105
149
|
* suppressed (a <1px bar's ring wouldn't be visible anyway); interaction still
|
|
106
150
|
* reads the **source** bars via {@link barAt} (§2.3). Pass `decimate={false}` to
|
|
107
|
-
* always draw every bar.
|
|
151
|
+
* always draw every bar. **`binFills` disables the envelope pass** — a single
|
|
152
|
+
* envelope rect spans many differently-coloured bars, so decimating would
|
|
153
|
+
* repaint them one flat colour; per-bar-coloured layers draw every visible bar.
|
|
154
|
+
* Returns {@link LayerDrawStats} for `onDrawStats`.
|
|
108
155
|
*/
|
|
109
|
-
export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered, decimate = true) {
|
|
156
|
+
export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered, decimate = true, binFills) {
|
|
110
157
|
ctx.save();
|
|
111
158
|
ctx.globalAlpha = style.opacity;
|
|
112
159
|
const sourceCount = cs.length; // pre-cull, pre-decimation (for draw stats)
|
|
@@ -119,9 +166,13 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
|
|
|
119
166
|
// Decimate the visible bars to per-column envelope rects once dense (see the
|
|
120
167
|
// header). `null` below the visible-density threshold ⇒ the full per-bar loop.
|
|
121
168
|
// `{ threshold }` tunes the samples-per-pixel factor `k` (as line/area/band do);
|
|
122
|
-
// `undefined` ⇒ decimateBars' default (2).
|
|
169
|
+
// `undefined` ⇒ decimateBars' default (2). Per-bar fills skip the envelope —
|
|
170
|
+
// one flat rect can't carry many bars' colours (see the header) — but an
|
|
171
|
+
// *empty* colour array is "no colours" (every bar would flat-fill anyway), so
|
|
172
|
+
// it stays on the legacy path end-to-end (L2 review, PR #542).
|
|
173
|
+
const fills = binFills !== undefined && binFills.length > 0 ? binFills : undefined;
|
|
123
174
|
const k = typeof decimate === 'object' ? decimate.threshold : undefined;
|
|
124
|
-
const envelope = decimate !== false
|
|
175
|
+
const envelope = decimate !== false && fills === undefined
|
|
125
176
|
? decimateBars(cs, xScale, ctx, baseline, k, vEnd - vStart)
|
|
126
177
|
: null;
|
|
127
178
|
if (envelope !== null) {
|
|
@@ -141,24 +192,47 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
|
|
|
141
192
|
ctx.restore();
|
|
142
193
|
return { sourceCount, drawnCount: drawn, decimated: true };
|
|
143
194
|
}
|
|
195
|
+
// The stable per-bar identity is consulted only when the live selection /
|
|
196
|
+
// hover actually carries a `mark` — `cs.marks` builds its strings lazily, so
|
|
197
|
+
// the *draw* never materializes them on its own. (The component's `hitTest`
|
|
198
|
+
// may already have: an interactive layer echoes the hovered bar's mark on
|
|
199
|
+
// every pointer move. See BarSeries.marks — this hoist keeps the draw path
|
|
200
|
+
// clean, it doesn't make the channel free.)
|
|
201
|
+
const marks = selection?.mark !== undefined || hovered?.mark !== undefined
|
|
202
|
+
? cs.marks
|
|
203
|
+
: undefined;
|
|
144
204
|
let drawn = 0;
|
|
145
205
|
for (let i = vStart; i < vEnd; i += 1) {
|
|
146
206
|
const rect = barRect(cs, i, xScale, yScale, baseline, gapPx, style.minWidth);
|
|
147
207
|
if (rect === null)
|
|
148
208
|
continue;
|
|
149
209
|
const [x0, x1, yTop, yBottom] = rect;
|
|
150
|
-
// Match by the series `id` **and** the
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
// selection
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const isHovered = hovered
|
|
160
|
-
|
|
161
|
-
|
|
210
|
+
// Match by the series `id` **and** the bar's identity — its stable `mark`
|
|
211
|
+
// when the selection carries one, else the sample `key` (begin) — so two
|
|
212
|
+
// series sharing a timestamp don't both light up (a no-id, non-selectable
|
|
213
|
+
// layer passes `seriesId === undefined` and never matches). Both the
|
|
214
|
+
// committed selection and the transient hover use the `highlight` fill; only
|
|
215
|
+
// the selection adds the outline, so hover reads as a lighter "this bar is
|
|
216
|
+
// live" and select as the committed pick.
|
|
217
|
+
const stable = marks?.[i];
|
|
218
|
+
const selected = barMatches(selection, seriesId, stable, cs.begin[i]);
|
|
219
|
+
const isHovered = barMatches(hovered, seriesId, stable, cs.begin[i]);
|
|
220
|
+
if (fills !== undefined) {
|
|
221
|
+
// Per-bar fills: the bar keeps its own colour under hover / selection —
|
|
222
|
+
// highlight pops the alpha to 1 and outlines the selection in the bar's
|
|
223
|
+
// own fill (the drawStacks binFills convention; see the header).
|
|
224
|
+
const fill = fills[i] ?? style.fill;
|
|
225
|
+
ctx.globalAlpha = selected || isHovered ? 1 : style.opacity;
|
|
226
|
+
ctx.fillStyle = fill;
|
|
227
|
+
ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
228
|
+
drawn += 1;
|
|
229
|
+
if (selected) {
|
|
230
|
+
ctx.lineWidth = style.outlineWidth;
|
|
231
|
+
ctx.strokeStyle = fill;
|
|
232
|
+
ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
233
|
+
}
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
162
236
|
ctx.fillStyle = selected || isHovered ? style.highlight : style.fill;
|
|
163
237
|
ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
164
238
|
drawn += 1;
|
package/dist/context.d.ts
CHANGED
|
@@ -163,6 +163,17 @@ export interface ContainerFrame {
|
|
|
163
163
|
* x axis suppresses its boundary (second) label row when it's set — a
|
|
164
164
|
* custom format owns the whole label, so the ladder mustn't second-line it. */
|
|
165
165
|
readonly xFormatCustom: boolean;
|
|
166
|
+
/**
|
|
167
|
+
* Whether an explicit container `cursorFormat` shaped {@link formatReadout} —
|
|
168
|
+
* as opposed to the axis kind supplying its own default readout (the elapsed
|
|
169
|
+
* axis's finer duration). The two are indistinguishable from the field alone,
|
|
170
|
+
* and `<XAxis>` must tell them apart to honour the documented pill precedence
|
|
171
|
+
* `cursorFormat → axis format → container`: a **`cursorFormat`** outranks an
|
|
172
|
+
* explicit `<XAxis format>`, a **default** does not. Without this the elapsed
|
|
173
|
+
* default silently occupied the `cursorFormat` slot and a wall-clock strip's
|
|
174
|
+
* pill read durations (issue #540, finding 2).
|
|
175
|
+
*/
|
|
176
|
+
readonly xReadoutCustom: boolean;
|
|
166
177
|
/**
|
|
167
178
|
* The shared **x-side tick count** — the `count` every x-side `ticks()` /
|
|
168
179
|
* `tickFormat()` call passes (`<XAxis>` labels, the canvas x gridlines and
|
|
@@ -590,6 +601,15 @@ export interface TrackerSample {
|
|
|
590
601
|
* quote) emits `"<as> <role>"` composites (`iv lower`, `SPY high`) when its
|
|
591
602
|
* `as` is set, else the raw column / role word. */
|
|
592
603
|
readonly label: string;
|
|
604
|
+
/**
|
|
605
|
+
* Optional **source value for the off-chart readout**, when the layer plots a
|
|
606
|
+
* *derived* column but a `readout` column names the raw value (see
|
|
607
|
+
* `LineChart`/`AreaChart` `readout`). `value` stays the plotted number — so
|
|
608
|
+
* the in-chart cursor dot is unchanged — while an off-chart consumer shows
|
|
609
|
+
* `readout ?? value`. `undefined` when the layer has no `readout` column (the
|
|
610
|
+
* common case: the plotted value *is* the value to show).
|
|
611
|
+
*/
|
|
612
|
+
readonly readout?: number;
|
|
593
613
|
}
|
|
594
614
|
/** One line of a {@link CursorFlag} — a labelled, coloured value. */
|
|
595
615
|
export interface CursorFlagLine {
|
|
@@ -656,13 +676,25 @@ export interface SelectInfo {
|
|
|
656
676
|
/** Display label (`as` ?? column ?? id) — labels the selection in a readout. */
|
|
657
677
|
readonly label: string;
|
|
658
678
|
/**
|
|
659
|
-
* An optional **stable per-mark identity within the layer** —
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
679
|
+
* An optional **stable per-mark identity within the layer** — every mark
|
|
680
|
+
* shares the layer's `id`, so this is the handle that picks one *within* it.
|
|
681
|
+
* When a selection carries it, the highlight match + controlled `selected`
|
|
682
|
+
* echo key on `(id, mark)` instead of the sample `key`, so a pin survives a
|
|
683
|
+
* reorder / data update that renumbers the slot.
|
|
684
|
+
*
|
|
685
|
+
* Two layers report one today:
|
|
686
|
+
*
|
|
687
|
+
* - A **categorical** bar reports its *column name* — the slot index is not
|
|
688
|
+
* stable across a reorder, the name is.
|
|
689
|
+
* - A **single-series** bar (`<BarChart series column>`) reports its own axis
|
|
690
|
+
* key, stringified. On a **point-keyed** series the sample `key` is *not*
|
|
691
|
+
* its identity: the bar span is synthesized from neighbour spacing, so
|
|
692
|
+
* `key` is a derived edge (`t - halfGap`) rather than the sample's own
|
|
693
|
+
* time. See `BarSeries.marks`.
|
|
694
|
+
*
|
|
695
|
+
* `undefined` for every other mark (scatter, box, candle), whose sample `key`
|
|
696
|
+
* *is* its identity. A selection without a `mark` still matches on `key`
|
|
697
|
+
* everywhere — the mark is an additional channel, not a replacement.
|
|
666
698
|
*/
|
|
667
699
|
readonly mark?: string;
|
|
668
700
|
}
|
package/dist/data.d.ts
CHANGED
|
@@ -121,6 +121,35 @@ export interface BarSeries {
|
|
|
121
121
|
readonly end: Float64Array;
|
|
122
122
|
readonly y: Float64Array;
|
|
123
123
|
readonly length: number;
|
|
124
|
+
/**
|
|
125
|
+
* Optional **stable per-bar identity** — `marks[i]` names bar `i`. The
|
|
126
|
+
* single-series sibling of {@link StackedBarSeries.marks}: when present, the
|
|
127
|
+
* draw / hit-test / selection can key on this name instead of the bar's
|
|
128
|
+
* `begin` **edge**.
|
|
129
|
+
*
|
|
130
|
+
* The readers ({@link barsFromTimeSeries} / {@link barsFromValueSeries}) fill
|
|
131
|
+
* it with the **sample's own axis key** — `String(key[i])`, the timestamp or
|
|
132
|
+
* axis value the row is keyed on. That is the identity a caller already
|
|
133
|
+
* owns, and for a **point-keyed** series it is *not* `begin[i]`: there the
|
|
134
|
+
* span is synthesized, so `begin[i]` is a derived edge (`key - prevGap/2`,
|
|
135
|
+
* see {@link neighbourSpans}) and pinning a selection by key meant
|
|
136
|
+
* re-deriving the neighbour spacing. `undefined` on a hand-built view.
|
|
137
|
+
*
|
|
138
|
+
* Built **lazily** on first read and then memoized (~9 ms per 100k bars, on
|
|
139
|
+
* top of a ~0.8 ms reader). Who pays, precisely:
|
|
140
|
+
*
|
|
141
|
+
* - A **non-interactive** layer (no `id`, so no `hitTest`) never reads them.
|
|
142
|
+
* - An **interactive** one hit-tests on every *pointer move*, and that echo
|
|
143
|
+
* reads the hovered bar's mark — so the first move that lands on a bar
|
|
144
|
+
* materializes the array, once per data identity, on the input path
|
|
145
|
+
* (11.1 ms vs 1.7 ms for a warm 100k-bar hover).
|
|
146
|
+
*
|
|
147
|
+
* So this is not free for an interactive chart; it is bounded and paid once,
|
|
148
|
+
* where an eager array would cost every chart on every data update. At
|
|
149
|
+
* realistic bar counts it is under a millisecond either way. See
|
|
150
|
+
* `scripts/perf-barmarks.mjs`.
|
|
151
|
+
*/
|
|
152
|
+
readonly marks?: readonly string[];
|
|
124
153
|
}
|
|
125
154
|
/**
|
|
126
155
|
* A chart-ready view of a **stacked / histogram** bar series — the multi-segment
|
|
@@ -156,6 +185,27 @@ export interface StackedBarSeries {
|
|
|
156
185
|
*/
|
|
157
186
|
readonly marks?: readonly string[];
|
|
158
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Assert `column` names an existing **numeric** column of `series`, returning
|
|
190
|
+
* it. The single source of the reader's two errors, so a caller that reads a
|
|
191
|
+
* column **per event** rather than buffering it — the time-axis `readout` path
|
|
192
|
+
* in `LineChart` / `AreaChart` — rejects a bad name identically to one that
|
|
193
|
+
* materializes. Without it a mistyped `readout` throws on a `ValueSeries` (via
|
|
194
|
+
* {@link readValueColumn}) but silently produced no readout on a `TimeSeries`,
|
|
195
|
+
* where the per-event `get()` just returns `undefined`.
|
|
196
|
+
*
|
|
197
|
+
* The `undefined` guard is runtime-necessary even though it reads as dead code:
|
|
198
|
+
* `column()` returns `undefined` for an unknown name at runtime, but core's
|
|
199
|
+
* public overload currently types the result as non-`undefined` (see F-3 in the
|
|
200
|
+
* M1 friction note). Keep it — the "throws on unknown column" tests exercise it.
|
|
201
|
+
*
|
|
202
|
+
* @throws RangeError if `column` does not exist.
|
|
203
|
+
* @throws TypeError if `column` is not a numeric column.
|
|
204
|
+
*/
|
|
205
|
+
export declare function assertNumericColumn<S extends SeriesSchema>(series: TimeSeries<S>, column: string): {
|
|
206
|
+
kind: string;
|
|
207
|
+
read(i: number): number | undefined;
|
|
208
|
+
};
|
|
159
209
|
/**
|
|
160
210
|
* The quantile column names a {@link boxFromTimeSeries} / {@link boxFromValueSeries}
|
|
161
211
|
* reads. `lower`/`upper` (the whisker reach) are required; `q1`/`q3` (the box
|
|
@@ -306,6 +356,10 @@ export declare function ohlcFromTimeSeries<S extends SeriesSchema>(series: TimeS
|
|
|
306
356
|
* interval-keyed series (e.g. an `aggregate`/`window` rollup) draws its true
|
|
307
357
|
* bucket spans. Detected by `keyColumn().kind === 'time'`.
|
|
308
358
|
*
|
|
359
|
+
* Each bar also carries its **own key** as a stable {@link BarSeries.marks}
|
|
360
|
+
* identity, so a selection can be pinned on the sample rather than on the span
|
|
361
|
+
* this derived for it.
|
|
362
|
+
*
|
|
309
363
|
* @throws RangeError if `column` does not exist.
|
|
310
364
|
* @throws TypeError if `column` is not a numeric column.
|
|
311
365
|
*/
|
|
@@ -326,6 +380,10 @@ export declare function barsFromTimeSeries<S extends SeriesSchema>(series: TimeS
|
|
|
326
380
|
* (a slight drift from a true segment edge — fine for the bar look; key an
|
|
327
381
|
* interval/timeRange `TimeSeries` instead if exact edges matter).
|
|
328
382
|
*
|
|
383
|
+
* Each bar also carries its **axis value** as a stable {@link BarSeries.marks}
|
|
384
|
+
* identity — the centre it is drawn around, not the derived edge — so a
|
|
385
|
+
* selection can be pinned without re-deriving the neighbour spacing.
|
|
386
|
+
*
|
|
329
387
|
* @throws RangeError if `column` does not exist.
|
|
330
388
|
* @throws TypeError if `column` is not a numeric column.
|
|
331
389
|
*/
|
package/dist/data.js
CHANGED
|
@@ -1,4 +1,31 @@
|
|
|
1
1
|
import { ValueSeries } from 'pond-ts';
|
|
2
|
+
/**
|
|
3
|
+
* Assert `column` names an existing **numeric** column of `series`, returning
|
|
4
|
+
* it. The single source of the reader's two errors, so a caller that reads a
|
|
5
|
+
* column **per event** rather than buffering it — the time-axis `readout` path
|
|
6
|
+
* in `LineChart` / `AreaChart` — rejects a bad name identically to one that
|
|
7
|
+
* materializes. Without it a mistyped `readout` throws on a `ValueSeries` (via
|
|
8
|
+
* {@link readValueColumn}) but silently produced no readout on a `TimeSeries`,
|
|
9
|
+
* where the per-event `get()` just returns `undefined`.
|
|
10
|
+
*
|
|
11
|
+
* The `undefined` guard is runtime-necessary even though it reads as dead code:
|
|
12
|
+
* `column()` returns `undefined` for an unknown name at runtime, but core's
|
|
13
|
+
* public overload currently types the result as non-`undefined` (see F-3 in the
|
|
14
|
+
* M1 friction note). Keep it — the "throws on unknown column" tests exercise it.
|
|
15
|
+
*
|
|
16
|
+
* @throws RangeError if `column` does not exist.
|
|
17
|
+
* @throws TypeError if `column` is not a numeric column.
|
|
18
|
+
*/
|
|
19
|
+
export function assertNumericColumn(series, column) {
|
|
20
|
+
const col = series.column(column);
|
|
21
|
+
if (col === undefined) {
|
|
22
|
+
throw new RangeError(`unknown column '${column}'`);
|
|
23
|
+
}
|
|
24
|
+
if (col.kind !== 'number') {
|
|
25
|
+
throw new TypeError(`column '${column}' must be numeric (got '${col.kind}')`);
|
|
26
|
+
}
|
|
27
|
+
return col;
|
|
28
|
+
}
|
|
2
29
|
/**
|
|
3
30
|
* Read a numeric column into a `Float64Array`, missing cells as `NaN`.
|
|
4
31
|
*
|
|
@@ -15,17 +42,7 @@ import { ValueSeries } from 'pond-ts';
|
|
|
15
42
|
* @throws TypeError if `column` is not a numeric column.
|
|
16
43
|
*/
|
|
17
44
|
function readNumericColumn(series, column) {
|
|
18
|
-
|
|
19
|
-
// `undefined` for an unknown name at runtime, but core's public overload
|
|
20
|
-
// currently types the result as non-`undefined` (see F-3 in the M1 friction
|
|
21
|
-
// note). Keep the guard — the "throws on unknown column" test exercises it.
|
|
22
|
-
const col = series.column(column);
|
|
23
|
-
if (col === undefined) {
|
|
24
|
-
throw new RangeError(`unknown column '${column}'`);
|
|
25
|
-
}
|
|
26
|
-
if (col.kind !== 'number') {
|
|
27
|
-
throw new TypeError(`column '${column}' must be numeric (got '${col.kind}')`);
|
|
28
|
-
}
|
|
45
|
+
const col = assertNumericColumn(series, column);
|
|
29
46
|
const length = series.length;
|
|
30
47
|
const out = new Float64Array(length);
|
|
31
48
|
for (let i = 0; i < length; i += 1) {
|
|
@@ -302,6 +319,43 @@ function neighbourSpans(axis, n) {
|
|
|
302
319
|
}
|
|
303
320
|
return { begin, end };
|
|
304
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* Attach the lazy stable per-bar identity to a bar view — see
|
|
324
|
+
* {@link BarSeries.marks}. `keys` is the **sample's own** axis buffer (the key
|
|
325
|
+
* column's `begin` for a `TimeSeries`, `axisValues()` for a `ValueSeries`), not
|
|
326
|
+
* the possibly-derived bar span, and must already be trimmed to `bars.length`.
|
|
327
|
+
*
|
|
328
|
+
* The strings are built on first read and then memoized. That is why this is a
|
|
329
|
+
* getter rather than an eager array: 100k bars is ~9 ms of string allocation on
|
|
330
|
+
* top of a ~0.8 ms reader, and eager would charge it to every chart on every
|
|
331
|
+
* data update, for a channel a non-interactive one never uses at all. An
|
|
332
|
+
* interactive chart *does* pay it, once per data identity, on its first hover
|
|
333
|
+
* over a bar — see {@link BarSeries.marks}. `scripts/perf-barmarks.mjs` pins
|
|
334
|
+
* both halves.
|
|
335
|
+
*
|
|
336
|
+
* The getter is deliberately **enumerable**, so a `{...bs}` spread carries the
|
|
337
|
+
* marks through (materializing them) rather than silently dropping them — a
|
|
338
|
+
* perf surprise beats a correctness one. Nothing in the package spreads a
|
|
339
|
+
* `BarSeries` today; this is for outside callers. If one ever appears **in the
|
|
340
|
+
* draw path**, it would force materialization on every frame — but
|
|
341
|
+
* `perf-barmarks.mjs`'s `marks untouched` row is measured on exactly that, so
|
|
342
|
+
* it would show up as a reader regression rather than pass silently.
|
|
343
|
+
*/
|
|
344
|
+
function withKeyMarks(bars, keys) {
|
|
345
|
+
let marks;
|
|
346
|
+
return {
|
|
347
|
+
...bars,
|
|
348
|
+
get marks() {
|
|
349
|
+
if (marks === undefined) {
|
|
350
|
+
const out = new Array(bars.length);
|
|
351
|
+
for (let i = 0; i < bars.length; i += 1)
|
|
352
|
+
out[i] = String(keys[i]);
|
|
353
|
+
marks = out;
|
|
354
|
+
}
|
|
355
|
+
return marks;
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
305
359
|
/**
|
|
306
360
|
* Build a {@link BarSeries} from a pond `TimeSeries` — one bar per event, the
|
|
307
361
|
* key's `[begin, end]` as the x-span and `column` as the height.
|
|
@@ -320,6 +374,10 @@ function neighbourSpans(axis, n) {
|
|
|
320
374
|
* interval-keyed series (e.g. an `aggregate`/`window` rollup) draws its true
|
|
321
375
|
* bucket spans. Detected by `keyColumn().kind === 'time'`.
|
|
322
376
|
*
|
|
377
|
+
* Each bar also carries its **own key** as a stable {@link BarSeries.marks}
|
|
378
|
+
* identity, so a selection can be pinned on the sample rather than on the span
|
|
379
|
+
* this derived for it.
|
|
380
|
+
*
|
|
323
381
|
* @throws RangeError if `column` does not exist.
|
|
324
382
|
* @throws TypeError if `column` is not a numeric column.
|
|
325
383
|
*/
|
|
@@ -327,15 +385,15 @@ export function barsFromTimeSeries(series, column) {
|
|
|
327
385
|
const y = readNumericColumn(series, column);
|
|
328
386
|
const n = series.length;
|
|
329
387
|
const kind = series.keyColumn().kind;
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
return { begin, end, y, length: n };
|
|
388
|
+
// Interval / timeRange: the key's own endpoints are the bar span. Point key
|
|
389
|
+
// (begin === end): synthesize a span from neighbour spacing so the bars have
|
|
390
|
+
// width (see neighbourSpans).
|
|
391
|
+
const { begin, end } = kind !== 'time'
|
|
392
|
+
? keyBeginEnd(series)
|
|
393
|
+
: neighbourSpans(series.keyColumn().begin, n);
|
|
394
|
+
// The marks key on the event's own timestamp — which for a point key is the
|
|
395
|
+
// bar's *centre*, not the `begin` edge derived above (see BarSeries.marks).
|
|
396
|
+
return withKeyMarks({ begin, end, y, length: n }, timeAxis(series));
|
|
339
397
|
}
|
|
340
398
|
/**
|
|
341
399
|
* Build a {@link BarSeries} from a pond `ValueSeries` — the value-axis sibling
|
|
@@ -353,6 +411,10 @@ export function barsFromTimeSeries(series, column) {
|
|
|
353
411
|
* (a slight drift from a true segment edge — fine for the bar look; key an
|
|
354
412
|
* interval/timeRange `TimeSeries` instead if exact edges matter).
|
|
355
413
|
*
|
|
414
|
+
* Each bar also carries its **axis value** as a stable {@link BarSeries.marks}
|
|
415
|
+
* identity — the centre it is drawn around, not the derived edge — so a
|
|
416
|
+
* selection can be pinned without re-deriving the neighbour spacing.
|
|
417
|
+
*
|
|
356
418
|
* @throws RangeError if `column` does not exist.
|
|
357
419
|
* @throws TypeError if `column` is not a numeric column.
|
|
358
420
|
*/
|
|
@@ -361,8 +423,9 @@ export function barsFromValueSeries(series, column) {
|
|
|
361
423
|
const n = series.length;
|
|
362
424
|
// axisValues() is the monotonic key buffer (zero-copy); neighbourSpans reads it
|
|
363
425
|
// and allocates fresh span buffers (never mutates the source).
|
|
364
|
-
const
|
|
365
|
-
|
|
426
|
+
const axis = series.axisValues();
|
|
427
|
+
const { begin, end } = neighbourSpans(axis, n);
|
|
428
|
+
return withKeyMarks({ begin, end, y, length: n }, axis);
|
|
366
429
|
}
|
|
367
430
|
/**
|
|
368
431
|
* The per-bin `[begin, end]` slots for a `TimeSeries`, key-shape aware — the same
|
package/dist/decimate.js
CHANGED
|
@@ -636,11 +636,11 @@ export function decimateBars(cs, xScale, ctx, baseline, k = 2, visibleCount = cs
|
|
|
636
636
|
export function decimateScatter(cs, xScale, yScale, cellPx, vStart = 0, vEnd = cs.length) {
|
|
637
637
|
const cell = cellPx > 0 ? cellPx : 1;
|
|
638
638
|
// Affine fast path ([PND-AFFINE]) for the per-point pixel mapping the sweep
|
|
639
|
-
// needs — an inline `
|
|
640
|
-
// closure per point (each axis independently; a non-affine
|
|
641
|
-
// real-gap trading x, falls back to the exact scale call).
|
|
642
|
-
// sweep would re-introduce the per-point d3-scale cost the
|
|
643
|
-
// shed, making the decimation's own cost dominate.
|
|
639
|
+
// needs — an inline rebased `(v − v0)·k + p0` over the typed arrays instead
|
|
640
|
+
// of a d3-scale closure per point (each axis independently; a non-affine
|
|
641
|
+
// axis, e.g. a real-gap trading x, falls back to the exact scale call).
|
|
642
|
+
// Without this the sweep would re-introduce the per-point d3-scale cost the
|
|
643
|
+
// line/area paths shed, making the decimation's own cost dominate.
|
|
644
644
|
const ax = affineOf(xScale);
|
|
645
645
|
const ay = affineOf(yScale);
|
|
646
646
|
const outX = [];
|
|
@@ -652,13 +652,13 @@ export function decimateScatter(cs, xScale, yScale, cellPx, vStart = 0, vEnd = c
|
|
|
652
652
|
if (!Number.isFinite(y))
|
|
653
653
|
continue; // gap — no mark
|
|
654
654
|
const xv = cs.x[i];
|
|
655
|
-
const px = ax !== null ? ax.
|
|
655
|
+
const px = ax !== null ? (xv - ax.v0) * ax.k + ax.p0 : xScale(xv);
|
|
656
656
|
const col = Math.floor(px / cell);
|
|
657
657
|
if (col !== curCol) {
|
|
658
658
|
rows.clear();
|
|
659
659
|
curCol = col;
|
|
660
660
|
}
|
|
661
|
-
const py = ay !== null ? ay.
|
|
661
|
+
const py = ay !== null ? (y - ay.v0) * ay.k + ay.p0 : yScale(y);
|
|
662
662
|
const row = Math.floor(py / cell);
|
|
663
663
|
if (!rows.has(row)) {
|
|
664
664
|
rows.add(row);
|