@pond-ts/charts 0.53.1 → 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/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` (same sample `key` **and** the layer's own
88
- * series `id` — `seriesId`; a no-id layer passes `undefined` and never matches)
89
- * draws in the style's `highlight` colour **and outlined**, so a click reads back
90
- * on the canvas; a bar matching `hovered` draws in `highlight` **without** the
91
- * outline (a lighter "this bar is live" on pointer-over); all others use the flat
92
- * `fill`. `globalAlpha` carries the fill opacity and is restored so it doesn't
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. Returns {@link LayerDrawStats} for `onDrawStats`.
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 sample `key` (begin), so two series
151
- // sharing a timestamp don't both light up (a no-id, non-selectable layer
152
- // passes `seriesId === undefined` and never matches). Both the committed
153
- // selection and the transient hover use the `highlight` fill; only the
154
- // selection adds the outline, so hover reads as a lighter "this bar is live"
155
- // and select as the committed pick.
156
- const selected = selection !== null &&
157
- selection.id === seriesId &&
158
- selection.key === cs.begin[i];
159
- const isHovered = hovered !== null &&
160
- hovered.id === seriesId &&
161
- hovered.key === cs.begin[i];
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
@@ -601,6 +601,15 @@ export interface TrackerSample {
601
601
  * quote) emits `"<as> <role>"` composites (`iv lower`, `SPY high`) when its
602
602
  * `as` is set, else the raw column / role word. */
603
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;
604
613
  }
605
614
  /** One line of a {@link CursorFlag} — a labelled, coloured value. */
606
615
  export interface CursorFlagLine {
@@ -667,13 +676,25 @@ export interface SelectInfo {
667
676
  /** Display label (`as` ?? column ?? id) — labels the selection in a readout. */
668
677
  readonly label: string;
669
678
  /**
670
- * An optional **stable per-mark identity within the layer** — a *category's
671
- * column name* on the categorical axis, where every bar shares the layer's
672
- * `id` but each column needs its own stable handle. When present, the
673
- * highlight match + controlled `selected` echo key on `(id, mark)` instead of
674
- * the sample `key`, so a pinned selection survives a column reorder / data
675
- * update (the slot index is not stable; the column name is). `undefined` for
676
- * marks whose sample `key` is already their identity (a time / value bar).
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.
677
698
  */
678
699
  readonly mark?: string;
679
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
- // Runtime-necessary even though it reads as dead code: `column()` returns
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
- if (kind !== 'time') {
331
- // Interval / timeRange: the key's own endpoints are the bar span.
332
- const { begin, end } = keyBeginEnd(series);
333
- return { begin, end, y, length: n };
334
- }
335
- // Point key (begin === end): synthesize a span from neighbour spacing so the
336
- // bars have width (see neighbourSpans).
337
- const { begin, end } = neighbourSpans(series.keyColumn().begin, n);
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 { begin, end } = neighbourSpans(series.axisValues(), n);
365
- return { begin, end, y, length: n };
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 `v + b` over the typed arrays instead of a d3-scale
640
- // closure per point (each axis independently; a non-affine axis, e.g. a
641
- // real-gap trading x, falls back to the exact scale call). Without this the
642
- // sweep would re-introduce the per-point d3-scale cost the line/area paths
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.k * xv + ax.b : xScale(xv);
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.k * y + ay.b : yScale(y);
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);
package/dist/line.js CHANGED
@@ -26,8 +26,8 @@ export function strokeAffinePolyline(ctx, xs, ys, ax, ay) {
26
26
  penDown = false;
27
27
  continue;
28
28
  }
29
- const px = ax.k * xs[j] + ax.b;
30
- const py = ay.k * v + ay.b;
29
+ const px = (xs[j] - ax.v0) * ax.k + ax.p0;
30
+ const py = (v - ay.v0) * ay.k + ay.p0;
31
31
  if (penDown)
32
32
  ctx.lineTo(px, py);
33
33
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.53.1",
3
+ "version": "0.54.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",
@@ -38,8 +38,8 @@
38
38
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
39
39
  },
40
40
  "peerDependencies": {
41
- "@pond-ts/react": "^0.53.0",
42
- "pond-ts": "^0.53.0",
41
+ "@pond-ts/react": "^0.54.0",
42
+ "pond-ts": "^0.54.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {