@pond-ts/charts 0.53.1 → 0.55.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/context.d.ts CHANGED
@@ -541,6 +541,17 @@ export interface RowLayer {
541
541
  * must agree on this list (a mix is an error), the same way {@link xKind} must.
542
542
  */
543
543
  xCategories?(): readonly string[] | null;
544
+ /**
545
+ * A **horizontal** categorical source's ordered category names — the same
546
+ * list {@link xCategories} carries for a vertical one, but for the axis it
547
+ * lands on when the bars grow right: the **y** axis ([PND-HCAT]).
548
+ *
549
+ * The y axis stays a linear scale over the layer's unit slots (`[i, i+1]`),
550
+ * so this only supplies *labels*: a `<YAxis>` in the row with no explicit
551
+ * `ticks` derives one tick per category at the slot centre (`i + 0.5`).
552
+ * That hand-built tick list was the friction the gallery funnel documented.
553
+ */
554
+ binCategories?(): readonly string[] | null;
544
555
  /**
545
556
  * A bar/histogram layer's bar `[begin, end)` spans, as pond `Interval`s — the
546
557
  * **region cursor's snap buckets**. When present (and no `cursorSequence` is
@@ -601,6 +612,15 @@ export interface TrackerSample {
601
612
  * quote) emits `"<as> <role>"` composites (`iv lower`, `SPY high`) when its
602
613
  * `as` is set, else the raw column / role word. */
603
614
  readonly label: string;
615
+ /**
616
+ * Optional **source value for the off-chart readout**, when the layer plots a
617
+ * *derived* column but a `readout` column names the raw value (see
618
+ * `LineChart`/`AreaChart` `readout`). `value` stays the plotted number — so
619
+ * the in-chart cursor dot is unchanged — while an off-chart consumer shows
620
+ * `readout ?? value`. `undefined` when the layer has no `readout` column (the
621
+ * common case: the plotted value *is* the value to show).
622
+ */
623
+ readonly readout?: number;
604
624
  }
605
625
  /** One line of a {@link CursorFlag} — a labelled, coloured value. */
606
626
  export interface CursorFlagLine {
@@ -633,6 +653,17 @@ export interface TrackerSource {
633
653
  xExtent(): readonly [number, number] | null;
634
654
  /** A `'category'` source's ordered category names (see {@link RowLayer.xCategories}). */
635
655
  xCategories?(): readonly string[] | null;
656
+ /**
657
+ * A **horizontal** categorical source's ordered category names — the same
658
+ * list {@link xCategories} carries for a vertical one, but for the axis it
659
+ * lands on when the bars grow right: the **y** axis ([PND-HCAT]).
660
+ *
661
+ * The y axis stays a linear scale over the layer's unit slots (`[i, i+1]`),
662
+ * so this only supplies *labels*: a `<YAxis>` in the row with no explicit
663
+ * `ticks` derives one tick per category at the slot centre (`i + 0.5`).
664
+ * That hand-built tick list was the friction the gallery funnel documented.
665
+ */
666
+ binCategories?(): readonly string[] | null;
636
667
  /** A bar/histogram source's bar `[begin, end)` spans (see {@link RowLayer.binIntervals}). */
637
668
  binIntervals?(): readonly Interval[] | null;
638
669
  }
@@ -667,13 +698,25 @@ export interface SelectInfo {
667
698
  /** Display label (`as` ?? column ?? id) — labels the selection in a readout. */
668
699
  readonly label: string;
669
700
  /**
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).
701
+ * An optional **stable per-mark identity within the layer** — every mark
702
+ * shares the layer's `id`, so this is the handle that picks one *within* it.
703
+ * When a selection carries it, the highlight match + controlled `selected`
704
+ * echo key on `(id, mark)` instead of the sample `key`, so a pin survives a
705
+ * reorder / data update that renumbers the slot.
706
+ *
707
+ * Two layers report one today:
708
+ *
709
+ * - A **categorical** bar reports its *column name* — the slot index is not
710
+ * stable across a reorder, the name is.
711
+ * - A **single-series** bar (`<BarChart series column>`) reports its own axis
712
+ * key, stringified. On a **point-keyed** series the sample `key` is *not*
713
+ * its identity: the bar span is synthesized from neighbour spacing, so
714
+ * `key` is a derived edge (`t - halfGap`) rather than the sample's own
715
+ * time. See `BarSeries.marks`.
716
+ *
717
+ * `undefined` for every other mark (scatter, box, candle), whose sample `key`
718
+ * *is* its identity. A selection without a `mark` still matches on `key`
719
+ * everywhere — the mark is an additional channel, not a replacement.
677
720
  */
678
721
  readonly mark?: string;
679
722
  }
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
  */
@@ -389,6 +447,25 @@ export interface StacksFromBinsOptions {
389
447
  */
390
448
  readonly ordinal?: boolean;
391
449
  }
450
+ /**
451
+ * Build a {@link BarSeries} from **`byColumn` bin records** — the single-series
452
+ * sibling of {@link stacksFromBins}, for a histogram drawing **one** aggregate
453
+ * field.
454
+ *
455
+ * Why it exists ([PND-BARSEM]): a one-column histogram *is* a single-series
456
+ * bar chart — same mark, same geometry — but routing it through the stacked
457
+ * reader made its capabilities depend on which prop produced it (whole-slot
458
+ * hit-testing, the hover colour, the cursor readout and per-bar decimation all
459
+ * live on the single path). Reading it as a `BarSeries` lets the chart decide
460
+ * by what it *draws* rather than by how it was fed.
461
+ *
462
+ * `column` names the aggregate field; slots are the bins' numeric
463
+ * `[start, end]` edges, or uniform unit slots under `{ ordinal: true }` —
464
+ * identical to {@link stacksFromBins}. A missing / non-finite aggregate reads
465
+ * as a gap (`NaN`), and each bar carries its slot index as a stable
466
+ * {@link BarSeries.marks} identity (bin records have no key of their own).
467
+ */
468
+ export declare function barsFromBins(bins: readonly BinRecord[], column: string, options?: StacksFromBinsOptions): BarSeries;
392
469
  /**
393
470
  * Build a {@link StackedBarSeries} from **`byColumn` bin records** — the array of
394
471
  * `{ start, end, …aggregates }` a value-band aggregation returns
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
@@ -472,6 +535,52 @@ export function stacksFromColumns(series, columns) {
472
535
  }
473
536
  return { begin, end, groups: columns, values, length: n };
474
537
  }
538
+ /**
539
+ * Build a {@link BarSeries} from **`byColumn` bin records** — the single-series
540
+ * sibling of {@link stacksFromBins}, for a histogram drawing **one** aggregate
541
+ * field.
542
+ *
543
+ * Why it exists ([PND-BARSEM]): a one-column histogram *is* a single-series
544
+ * bar chart — same mark, same geometry — but routing it through the stacked
545
+ * reader made its capabilities depend on which prop produced it (whole-slot
546
+ * hit-testing, the hover colour, the cursor readout and per-bar decimation all
547
+ * live on the single path). Reading it as a `BarSeries` lets the chart decide
548
+ * by what it *draws* rather than by how it was fed.
549
+ *
550
+ * `column` names the aggregate field; slots are the bins' numeric
551
+ * `[start, end]` edges, or uniform unit slots under `{ ordinal: true }` —
552
+ * identical to {@link stacksFromBins}. A missing / non-finite aggregate reads
553
+ * as a gap (`NaN`), and each bar carries its slot index as a stable
554
+ * {@link BarSeries.marks} identity (bin records have no key of their own).
555
+ */
556
+ export function barsFromBins(bins, column, options = {}) {
557
+ const n = bins.length;
558
+ const begin = new Float64Array(n);
559
+ const end = new Float64Array(n);
560
+ const y = new Float64Array(n);
561
+ // The bin's own **start value** is its identity — the same axis-key
562
+ // convention every other reader uses, and stable under `ordinal` (which only
563
+ // changes the drawn slot, not which bin a row is). A slot *index* would be
564
+ // positional and would renumber if the bin set changed. Materialized through
565
+ // the shared lazy getter, so a non-interactive layer never pays for the
566
+ // strings (see {@link BarSeries.marks}).
567
+ const keys = new Float64Array(n);
568
+ for (let i = 0; i < n; i += 1) {
569
+ const bin = bins[i];
570
+ if (options.ordinal) {
571
+ begin[i] = i;
572
+ end[i] = i + 1;
573
+ }
574
+ else {
575
+ begin[i] = bin.start;
576
+ end[i] = bin.end;
577
+ }
578
+ keys[i] = bin.start;
579
+ const v = bin[column];
580
+ y[i] = typeof v === 'number' && Number.isFinite(v) ? v : NaN;
581
+ }
582
+ return withKeyMarks({ begin, end, y, length: n }, keys);
583
+ }
475
584
  /**
476
585
  * Build a {@link StackedBarSeries} from **`byColumn` bin records** — the array of
477
586
  * `{ start, end, …aggregates }` a value-band aggregation returns
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/index.d.ts CHANGED
@@ -2,19 +2,19 @@
2
2
  * `@pond-ts/charts` — the visualization end of pond.
3
3
  *
4
4
  * Canvas-rendered, streaming-first time-series charts with a
5
- * react-timeseries-charts-style declarative layout. The architecture (hard
6
- * layers: adapter typed-array store decimatorcanvas renderer → React
7
- * shell) is documented in the charts RFC at `docs/rfcs/charts.md`; the
8
- * milestone plan lives in `PLAN.md`. (A Path2D path cache was explored and
9
- * deferredit doesn't help the pan case, which re-decimates every frame; see
10
- * the [PND-DECIM] floor decision.)
5
+ * react-timeseries-charts-style declarative layout: compose
6
+ * `<ChartContainer>``<ChartRow>``<Layers>`draw layers
7
+ * (line/area/band/scatter/bar/box/candle), plus the standalone DOM row lists
8
+ * ({@link BarList} / {@link BoxList}). **The data contract is the pond series
9
+ * itself**every layer takes a `TimeSeries` / `ValueSeries` (or a
10
+ * partition `Map`, `byColumn` bins, category records) directly and shapes
11
+ * internally; an adapter you must call when starting from a series is an API
12
+ * failure (`docs/notes/charts-api-review-2026-08.md`). The exported `from*`
13
+ * view builders are **interop escape hatches** for non-pond data only.
11
14
  *
12
- * **M1 rendering spine.** The layout shell + the first draw layer:
13
- * `<ChartContainer>` (time axis) `<ChartRow>` (y-axis + canvas) →
14
- * `<LineChart>` (a gap-aware line), fed from a pond `TimeSeries` via
15
- * {@link fromTimeSeries}. Axes, themes, the variance band, and interactions
16
- * land in M2–M4. {@link Canvas} is the low-level DPR-aware primitive the rows
17
- * sit on.
15
+ * Architecture (typed-array store decimator canvas renderer React
16
+ * shell): `docs/rfcs/charts.md`; roadmap: `PLAN.md`. {@link Canvas} is the
17
+ * low-level DPR-aware primitive the rows sit on.
18
18
  *
19
19
  * @packageDocumentation
20
20
  */
@@ -49,6 +49,12 @@ export type { BarChartProps } from './BarChart.js';
49
49
  export { Candlestick } from './Candlestick.js';
50
50
  export type { CandlestickProps } from './Candlestick.js';
51
51
  export type { CandleVariant, ColorBy } from './ohlc.js';
52
+ export { BarList } from './BarList.js';
53
+ export type { BarListProps } from './BarList.js';
54
+ export { BoxList } from './BoxList.js';
55
+ export type { BoxListProps } from './BoxList.js';
56
+ export { listRowsFromTimeSeries, listRowsFromValueSeries } from './list.js';
57
+ export type { ListRow, ListValue, ListCellSpec, ListMarker, ListSortDirection, ListRowsOptions, BarListColumn, BoxListColumn, } from './list.js';
52
58
  export { Legend } from './Legend.js';
53
59
  export type { LegendProps, LegendPlacement } from './Legend.js';
54
60
  export type { SwatchSpec, LegendItemInput } from './swatch.js';
@@ -63,7 +69,7 @@ export type { RegionProps, BaselineProps, MarkerProps } from './annotations.js';
63
69
  export type { AnnotationKind, CreateSpec } from './context.js';
64
70
  export { YAxisIndicator, createLiveValue } from './indicators.js';
65
71
  export type { YAxisIndicatorProps, LiveValue } from './indicators.js';
66
- export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, ohlcFromTimeSeries, stacksFromGroups, stacksFromColumns, stacksFromBins, categoryStack, transposeRow, } from './data.js';
72
+ export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, barsFromBins, ohlcFromTimeSeries, stacksFromGroups, stacksFromColumns, stacksFromBins, categoryStack, transposeRow, } from './data.js';
67
73
  export type { ChartSeries, BandSeries, BoxSeries, BoxColumns, BarSeries, OhlcSeries, OhlcColumns, StackedBarSeries, BinRecord, StacksFromBinsOptions, CategoryDatum, RowAt, TransposeRowOptions, } from './data.js';
68
74
  export type { Orientation } from './bars.js';
69
75
  export type { RadiusEncoding, ColorEncoding } from './encoding.js';
package/dist/index.js CHANGED
@@ -2,19 +2,19 @@
2
2
  * `@pond-ts/charts` — the visualization end of pond.
3
3
  *
4
4
  * Canvas-rendered, streaming-first time-series charts with a
5
- * react-timeseries-charts-style declarative layout. The architecture (hard
6
- * layers: adapter typed-array store decimatorcanvas renderer → React
7
- * shell) is documented in the charts RFC at `docs/rfcs/charts.md`; the
8
- * milestone plan lives in `PLAN.md`. (A Path2D path cache was explored and
9
- * deferredit doesn't help the pan case, which re-decimates every frame; see
10
- * the [PND-DECIM] floor decision.)
5
+ * react-timeseries-charts-style declarative layout: compose
6
+ * `<ChartContainer>``<ChartRow>``<Layers>`draw layers
7
+ * (line/area/band/scatter/bar/box/candle), plus the standalone DOM row lists
8
+ * ({@link BarList} / {@link BoxList}). **The data contract is the pond series
9
+ * itself**every layer takes a `TimeSeries` / `ValueSeries` (or a
10
+ * partition `Map`, `byColumn` bins, category records) directly and shapes
11
+ * internally; an adapter you must call when starting from a series is an API
12
+ * failure (`docs/notes/charts-api-review-2026-08.md`). The exported `from*`
13
+ * view builders are **interop escape hatches** for non-pond data only.
11
14
  *
12
- * **M1 rendering spine.** The layout shell + the first draw layer:
13
- * `<ChartContainer>` (time axis) `<ChartRow>` (y-axis + canvas) →
14
- * `<LineChart>` (a gap-aware line), fed from a pond `TimeSeries` via
15
- * {@link fromTimeSeries}. Axes, themes, the variance band, and interactions
16
- * land in M2–M4. {@link Canvas} is the low-level DPR-aware primitive the rows
17
- * sit on.
15
+ * Architecture (typed-array store decimator canvas renderer React
16
+ * shell): `docs/rfcs/charts.md`; roadmap: `PLAN.md`. {@link Canvas} is the
17
+ * low-level DPR-aware primitive the rows sit on.
18
18
  *
19
19
  * @packageDocumentation
20
20
  */
@@ -33,6 +33,14 @@ export { ScatterChart } from './ScatterChart.js';
33
33
  export { BoxPlot } from './BoxPlot.js';
34
34
  export { BarChart } from './BarChart.js';
35
35
  export { Candlestick } from './Candlestick.js';
36
+ // The list family — DOM-rendered *ranked row lists* (the react-timeseries-charts
37
+ // `HorizontalBarChart` shape, reconceived as a table): one row per entity, a
38
+ // proportional bar / five-number box line per configured column, data cells,
39
+ // custom sort, per-row expander. Standalone — no <ChartContainer> (the in-plot
40
+ // horizontal bars remain `<BarChart orientation="horizontal">`).
41
+ export { BarList } from './BarList.js';
42
+ export { BoxList } from './BoxList.js';
43
+ export { listRowsFromTimeSeries, listRowsFromValueSeries } from './list.js';
36
44
  // The series key: rows enumerate the registered layers' resolved styles.
37
45
  export { Legend } from './Legend.js';
38
46
  // The headless legend — the same rows + hover/select sync as data, for
@@ -48,7 +56,7 @@ export { Region, Baseline, Marker } from './annotations.js';
48
56
  // Axis indicators — a value pill pinned to an axis edge (the ChartIQ live tag).
49
57
  // `createLiveValue` is the high-frequency, isolated-repaint update path.
50
58
  export { YAxisIndicator, createLiveValue } from './indicators.js';
51
- export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, ohlcFromTimeSeries,
59
+ export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, barsFromBins, ohlcFromTimeSeries,
52
60
  // Stacked / histogram readers — assemble a StackedBarSeries from pond's own
53
61
  // aggregation output: a Map of grouped series, a wide series, or byColumn bins.
54
62
  stacksFromGroups, stacksFromColumns, stacksFromBins,
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 {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The list family's **source union** — `rows` XOR `series`, shared by
3
+ * {@link BarList} and {@link BoxList} ([PND-CHARTAPI]).
4
+ *
5
+ * Two things this fixes, both flagged on #590's review and deferred to here
6
+ * so one union pattern serves the layers and the lists alike:
7
+ *
8
+ * 1. **The doors were both optional**, so `<BarList columns={…} />` with
9
+ * neither source, and `rows` + `series` together, type-checked and threw at
10
+ * render. Now each is a compile error.
11
+ * 2. **The generic row type could lie.** `R` is inferred from the *callbacks*
12
+ * (`sort`, cell `render`, `renderExpanded`, `onRowClick`), so merely
13
+ * annotating one of them while passing `series` inferred a custom `R` that
14
+ * the series door cannot honour — it produces plain {@link ListRow}s. The
15
+ * series member pins `R` to `ListRow`, so the annotation now fails to
16
+ * compile instead of lying at runtime.
17
+ */
18
+ import type { ReactNode } from 'react';
19
+ import type { SeriesSchema, TimeSeries, ValueSeries, ValueSeriesSchema } from 'pond-ts';
20
+ import type { ListRow, ListRowsOptions } from './list.js';
21
+ /**
22
+ * The **record door**: entity rows built by hand or from partition facts.
23
+ * `R` may extend {@link ListRow} with extra fields, which then flow into
24
+ * every callback fully typed.
25
+ */
26
+ export interface ListRowsSource<R extends ListRow> {
27
+ readonly rows: readonly R[];
28
+ readonly series?: never;
29
+ readonly label?: never;
30
+ }
31
+ /**
32
+ * The **series door**: one row per event, read internally — starting from a
33
+ * pond series there is no shaping step. Rows are plain {@link ListRow}s (the
34
+ * reader cannot know a caller's custom row shape), so this member does not
35
+ * carry `R`.
36
+ */
37
+ export interface ListSeriesSource<S extends SeriesSchema, VS extends ValueSeriesSchema> {
38
+ readonly series: TimeSeries<S> | ValueSeries<VS>;
39
+ readonly rows?: never;
40
+ /**
41
+ * The built-in label cell per row — from the row's ordinal and its axis key
42
+ * (epoch ms / axis value). **Omitted ⇒ the stringified key renders.**
43
+ */
44
+ readonly label?: ListRowsOptions['label'];
45
+ }
46
+ /** `rows` XOR `series` — exactly one door, enforced at compile time. */
47
+ export type ListSource<R extends ListRow, S extends SeriesSchema, VS extends ValueSeriesSchema> = ListRowsSource<R> | ListSeriesSource<S, VS>;
48
+ /**
49
+ * The row type a given source yields: a caller's `R` through the record door,
50
+ * plain {@link ListRow} through the series door. Callback props resolve
51
+ * against this, which is what stops the series door from claiming a custom
52
+ * row shape it cannot produce.
53
+ */
54
+ export type RowOf<Src> = Src extends {
55
+ rows: readonly (infer T)[];
56
+ } ? T extends ListRow ? T : ListRow : ListRow;
57
+ /** Narrowing helper for the components' runtime read of the union. */
58
+ export declare function isSeriesSource<R extends ListRow, S extends SeriesSchema, VS extends ValueSeriesSchema>(src: ListSource<R, S, VS>): src is ListSeriesSource<S, VS>;
59
+ /** Re-exported for the components' prop docs. */
60
+ export type ListLabel = (i: number, key: number) => ReactNode;
61
+ //# sourceMappingURL=list-source.d.ts.map
@@ -0,0 +1,5 @@
1
+ /** Narrowing helper for the components' runtime read of the union. */
2
+ export function isSeriesSource(src) {
3
+ return src.series !== undefined;
4
+ }
5
+ //# sourceMappingURL=list-source.js.map