@pond-ts/charts 0.57.0 → 0.59.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.
Files changed (85) hide show
  1. package/API.md +576 -0
  2. package/CHANGELOG.md +1213 -1
  3. package/dist/AreaChart.d.ts +12 -1
  4. package/dist/AreaChart.js +131 -13
  5. package/dist/BarChart.d.ts +56 -7
  6. package/dist/BarChart.js +263 -39
  7. package/dist/BarList.d.ts +85 -5
  8. package/dist/BarList.js +25 -4
  9. package/dist/BoxList.d.ts +70 -3
  10. package/dist/BoxList.js +21 -7
  11. package/dist/BoxPlot.d.ts +2 -1
  12. package/dist/BoxPlot.js +101 -9
  13. package/dist/Candlestick.d.ts +13 -1
  14. package/dist/Candlestick.js +89 -3
  15. package/dist/ChartContainer.d.ts +36 -48
  16. package/dist/ChartContainer.js +465 -59
  17. package/dist/ChartRow.d.ts +9 -2
  18. package/dist/ChartRow.js +176 -14
  19. package/dist/HeatMap.d.ts +176 -0
  20. package/dist/HeatMap.js +344 -0
  21. package/dist/Layers.d.ts +5 -1
  22. package/dist/Layers.js +1014 -253
  23. package/dist/Legend.js +8 -4
  24. package/dist/LineChart.d.ts +18 -1
  25. package/dist/LineChart.js +165 -4
  26. package/dist/ListTable.d.ts +30 -3
  27. package/dist/ListTable.js +381 -23
  28. package/dist/ScatterChart.d.ts +3 -2
  29. package/dist/ScatterChart.js +68 -4
  30. package/dist/XAxis.js +40 -22
  31. package/dist/YAxis.d.ts +58 -2
  32. package/dist/YAxis.js +3 -1
  33. package/dist/area.d.ts +34 -1
  34. package/dist/area.js +88 -1
  35. package/dist/bars.d.ts +67 -6
  36. package/dist/bars.js +250 -35
  37. package/dist/box.d.ts +2 -2
  38. package/dist/box.js +158 -40
  39. package/dist/brush.d.ts +142 -0
  40. package/dist/brush.js +179 -0
  41. package/dist/child-index.d.ts +27 -0
  42. package/dist/child-index.js +57 -0
  43. package/dist/context.d.ts +870 -39
  44. package/dist/cursors.d.ts +161 -0
  45. package/dist/cursors.js +503 -0
  46. package/dist/data.d.ts +38 -0
  47. package/dist/data.js +43 -0
  48. package/dist/decimate.d.ts +78 -1
  49. package/dist/decimate.js +157 -0
  50. package/dist/format.d.ts +15 -0
  51. package/dist/format.js +16 -1
  52. package/dist/heat.d.ts +163 -0
  53. package/dist/heat.js +659 -0
  54. package/dist/index.d.ts +13 -4
  55. package/dist/index.js +27 -0
  56. package/dist/line.d.ts +137 -0
  57. package/dist/line.js +328 -0
  58. package/dist/ohlc.d.ts +16 -1
  59. package/dist/ohlc.js +93 -4
  60. package/dist/range.d.ts +14 -1
  61. package/dist/range.js +24 -3
  62. package/dist/scatter.d.ts +17 -9
  63. package/dist/scatter.js +221 -33
  64. package/dist/select.d.ts +13 -5
  65. package/dist/select.js +14 -6
  66. package/dist/selection-fixtures.d.ts +174 -0
  67. package/dist/selection-fixtures.js +569 -0
  68. package/dist/selection-stories.d.ts +73 -0
  69. package/dist/selection-stories.js +301 -0
  70. package/dist/selectors.d.ts +316 -0
  71. package/dist/selectors.js +391 -0
  72. package/dist/span.d.ts +122 -0
  73. package/dist/span.js +203 -0
  74. package/dist/sweep.d.ts +154 -0
  75. package/dist/sweep.js +282 -0
  76. package/dist/theme.d.ts +510 -5
  77. package/dist/theme.js +217 -41
  78. package/dist/tracker.d.ts +6 -0
  79. package/dist/tracker.js +6 -0
  80. package/dist/tradingAxis.fixture.d.ts +78 -0
  81. package/dist/tradingAxis.fixture.js +215 -0
  82. package/dist/useChartLegend.js +18 -3
  83. package/dist/yticks.d.ts +3 -0
  84. package/dist/yticks.js +104 -0
  85. package/package.json +6 -5
package/dist/BarChart.js CHANGED
@@ -1,11 +1,15 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
2
  import { Interval, ValueSeries } from 'pond-ts';
3
- import { barsFromTimeSeries, barsFromBins, barsFromValueSeries, categoryStack, stacksFromBins, stacksFromColumns, stacksFromGroups, } from './data.js';
3
+ import { barsFromTimeSeries, barsFromBins, barsFromValueSeries, categoryStack, categoryStacks, stacksFromBins, stacksFromColumns, stacksFromGroups, } from './data.js';
4
4
  import { barAt, barExtent, barIndexAtTime, drawBars, drawStacks, normalizeThresholds, resolveBarBaseline, stackAt, stackBinExtent, stackValueExtent, } from './bars.js';
5
+ import { spansForLayer } from './span.js';
5
6
  import { isDev } from './dev.js';
6
7
  import { ContainerContext, LayersContext, } from './context.js';
8
+ import { sweep1D } from './sweep.js';
7
9
  import { legendLabelFor, useLegendItems, } from './swatch.js';
8
10
  import { useSlotKey } from './use-slot-key.js';
11
+ /** Stable "nothing selected" identity for the narrowed mark lists below. */
12
+ const EMPTY_MARKS = [];
9
13
  /**
10
14
  * A bar / histogram draw layer. In its simplest form, one rectangle per event
11
15
  * spanning the key's `[begin, end]` from the axis baseline to a numeric
@@ -56,7 +60,7 @@ import { useSlotKey } from './use-slot-key.js';
56
60
  * </Layers>
57
61
  * ```
58
62
  */
59
- export function BarChart({ series, bins, categories, column, columns, as: semantic, colors, binColors, thresholds, bandColors, orientation = 'vertical', ordinal = false, id, axis, gap, decimate = true, legend, index = 0, }) {
63
+ export function BarChart({ series, bins, categories, column, columns, as: semantic, colors, binColors, thresholds, bandColors, orientation = 'vertical', ordinal = false, id, axis, gap, maxBarWidth, decimate = true, legend, index = 0, }) {
60
64
  const container = useContext(ContainerContext);
61
65
  if (container === null) {
62
66
  throw new Error('<BarChart> must be rendered inside a <ChartContainer>');
@@ -74,8 +78,11 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
74
78
  throw new Error('<BarChart> needs exactly one of `series`, `bins`, or `categories`');
75
79
  }
76
80
  if (categories !== undefined) {
77
- if (column !== undefined || columns !== undefined) {
78
- throw new Error('<BarChart categories> takes no `column`/`columns` (each datum carries its own value)');
81
+ if (column !== undefined) {
82
+ throw new Error('<BarChart categories> takes no `column` a single-value datum carries its own `value`, and a stacked one names its groups with `columns`');
83
+ }
84
+ if (columns !== undefined && columns.length === 0) {
85
+ throw new Error('<BarChart categories> with `columns` needs at least one group name');
79
86
  }
80
87
  }
81
88
  const isMap = series instanceof Map;
@@ -101,10 +108,21 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
101
108
  // horizontal bar) so one oriented draw path covers it.
102
109
  const shape = useMemo(() => {
103
110
  if (categories !== undefined) {
104
- // Categorical row-read: one unit-slot bar per category (G === 1), drawn on
105
- // the container's band scale. The reused stacked geometry — only the axis
111
+ // Categorical row-read: one unit-slot bar per category, drawn on the
112
+ // container's band scale. The reused stacked geometry — only the axis
106
113
  // (band scale + labels) is new.
107
- return { kind: 'stacked', ss: categoryStack(categories) };
114
+ //
115
+ // With `columns` it is a real multi-group stack ([PND-CATSTACK]): same
116
+ // slots, same `marks`, `G > 1`. Because `marks` is indexed by BIN, one
117
+ // selection entry naming `(id, mark)` matches every segment of a bar —
118
+ // which is the property that made the hand-composed workaround's
119
+ // "recedes from the waist up" bug inexpressible here.
120
+ return {
121
+ kind: 'stacked',
122
+ ss: columns !== undefined
123
+ ? categoryStacks(categories, columns)
124
+ : categoryStack(categories),
125
+ };
108
126
  }
109
127
  if (bins !== undefined) {
110
128
  const cols = columns ?? (column !== undefined ? [column] : undefined);
@@ -191,15 +209,21 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
191
209
  : series instanceof ValueSeries
192
210
  ? 'value'
193
211
  : 'time';
194
- // The bars' `[begin, end)` spans as pond `Interval`s — the region cursor's snap
195
- // buckets (a region drag snaps bar by bar; a hover highlights the bar under the
196
- // pointer). Published only for a **vertical** bar layer on a **continuous**
197
- // (time / value) x axis: a horizontal chart puts the value/count on x (snapping
198
- // it is meaningless) and a categorical (ordinal-slot) axis is out of the region
199
- // cursor's scope. Memoized off the shape alone, so a hover / selection change
200
- // (which rebuilds the layer entry) doesn't re-allocate the intervals.
212
+ // The bars' `[begin, end)` spans as pond `Interval`s — the shared snap
213
+ // buckets (a region drag snaps bar by bar; a `<MultiSelector>` sweep's band
214
+ // extends bar by bar). Published for any **vertical** bar layer: a
215
+ // horizontal chart puts the value/count on x (snapping it is meaningless).
216
+ // On a **category** axis the buckets are the unit slots `[i, i+1)` — the
217
+ // region cursor still ignores them (its band gates on a continuous axis),
218
+ // but the sweep's band needs them to snap to the slots' **outer edges**:
219
+ // the band scale's `invert` snaps a pixel to the slot *centre*, so a
220
+ // freeform sweep band ran centre-to-centre while capture and the committed
221
+ // span snapped outward (RFC A7.6's edge rule) — the band disagreed with
222
+ // what release would select. Memoized off the shape alone, so a hover /
223
+ // selection change (which rebuilds the layer entry) doesn't re-allocate
224
+ // the intervals.
201
225
  const binBuckets = useMemo(() => {
202
- if (orientation !== 'vertical' || binAxisKind === 'category')
226
+ if (orientation !== 'vertical')
203
227
  return null;
204
228
  const { begin, end, length } = shape.kind === 'single' ? shape.bs : shape.ss;
205
229
  if (length === 0)
@@ -240,6 +264,19 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
240
264
  // The stacked path's bar-thickness floor comes from `bar.default` (not the `as`
241
265
  // role — `as` is single-series only), matching how `gapPx` sources its default.
242
266
  const stackMinWidth = bar.default.minWidth;
267
+ // Same sourcing as `gapPx`: the prop wins, else the theme token, else
268
+ // uncapped ([PND-BARWIDTH]). `bar.default` rather than the `as` role for the
269
+ // stacked ceiling, for the reason above.
270
+ const maxWidthPx = maxBarWidth ?? bar.default.maxWidth;
271
+ // The single-series draw takes a `BarStyle` straight from the theme, so the
272
+ // prop override is applied by shadowing the token — same relationship `gap`
273
+ // has, and with the prop absent the role's own `maxWidth` (if any) already
274
+ // rides along untouched. Only the INK path gets this: `barSlotRect` (the hit
275
+ // region) stays the whole slot, so a narrow capped bar keeps a full-width
276
+ // target — the deliberate ink/hit split `gapPx` already relies on.
277
+ const singleDrawStyle = useMemo(() => maxBarWidth !== undefined
278
+ ? { ...singleStyle, maxWidth: maxBarWidth }
279
+ : singleStyle, [singleStyle, maxBarWidth]);
243
280
  // ── Threshold ladder ([PND-BANDBAR2]) ────────────────────────────────────
244
281
  // Resolved once here rather than per bar per frame: normalize the breakpoints
245
282
  // (sort, drop non-finite), then pair them with `bandColors` → the role's
@@ -327,9 +364,62 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
327
364
  const groups = shape.kind === 'stacked' ? shape.ss.groups : undefined;
328
365
  const stackStyle = useMemo(() => {
329
366
  const base = bar.default;
330
- const fills = (groups ?? []).map((g) => colors?.[g] ?? (bar[g] ?? base).fill);
367
+ // The theme's **group ramp** multi-group only, since a ramp exists to
368
+ // tell groups apart and `G === 1` (every categorical and single-series
369
+ // chart runs this same path) has nothing to tell apart. Resolution per
370
+ // group: `colors` → a role named after the group → the ramp → `fill`.
371
+ const multi = (groups?.length ?? 0) > 1;
372
+ const ramp = multi ? base.groups : undefined;
373
+ const rampDim = multi ? base.groupsDimmed : undefined;
374
+ const rampHover = multi ? base.groupsHover : undefined;
375
+ const at = (r, i) => r[i % r.length];
376
+ const fills = (groups ?? []).map((g, i) => colors?.[g] ??
377
+ bar[g]?.fill ??
378
+ (ramp !== undefined ? at(ramp, i) : base.fill));
379
+ // A ramp entry the call site overrode is no longer the ramp's colour, so
380
+ // its receded counterpart would be wrong — the whole ramp only means
381
+ // anything when it is the ramp that painted it. This gates the *derived*
382
+ // companions (`dimmedFills` / `hoverFills`), which need a counterpart per
383
+ // group and cannot invent one for an arbitrary call-site colour.
384
+ const ramped = ramp !== undefined && colors === undefined;
385
+ // `groupColored` derives nothing — it only says "a selected segment keeps
386
+ // its own fill rather than taking the flat `highlight`", because the colour
387
+ // is meaning-carrying.
388
+ //
389
+ // So the condition is exactly **"do the fills actually differ"**, read off
390
+ // the resolved `fills` rather than inferred from anything upstream of them.
391
+ // Both cheaper inferences are wrong, and each was shipped in turn:
392
+ //
393
+ // - Gating on `ramped` (the ramp painted it) meant a stack with `colors`
394
+ // collapsed BOTH segments of a selected bar to one `highlight` blue —
395
+ // losing the segment distinction exactly where the reader is looking. A
396
+ // call site naming its groups' colours is *more* deliberate than a
397
+ // fallback ramp, not less. Found building [PND-CATSTACK].
398
+ // - Gating on `groups.length > 1` (my fix for that) is wrong in the other
399
+ // direction: a multi-group stack under a theme with **no** group ramp, no
400
+ // `colors` and no per-group roles resolves every fill to `base.fill`, so
401
+ // claiming the colour carries meaning suppresses the highlight and leaves
402
+ // *nothing* — selection becomes invisible. `estelaTheme` is exactly that
403
+ // theme, and it ships. Found by Layer-2 review, which is the only way it
404
+ // could have been: every story and test renders `defaultTheme`, whose ramp
405
+ // makes the two gates indistinguishable.
406
+ //
407
+ // Reading `fills` also handles the degenerate `colors` map that assigns one
408
+ // colour to every group: nothing is distinguished, so the highlight applies.
409
+ const groupColoured = new Set(fills).size > 1;
331
410
  return {
332
411
  fills,
412
+ ...(groupColoured ? { groupColored: true } : {}),
413
+ ...(ramped && rampDim !== undefined
414
+ ? {
415
+ dimmedFills: (groups ?? []).map((_g, i) => at(rampDim, i)),
416
+ }
417
+ : {}),
418
+ ...(ramped && rampHover !== undefined
419
+ ? {
420
+ hoverFills: (groups ?? []).map((_g, i) => at(rampHover, i)),
421
+ }
422
+ : {}),
333
423
  opacity: base.opacity,
334
424
  outlineWidth: base.outlineWidth,
335
425
  // [PND-CATEMPH] Forward the themed emphasis so the category / horizontal
@@ -343,32 +433,42 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
343
433
  ...(base.emphasisOpacity !== undefined
344
434
  ? { emphasisOpacity: base.emphasisOpacity }
345
435
  : {}),
436
+ ...(base.dimmed !== undefined ? { dimmed: base.dimmed } : {}),
437
+ ...(maxWidthPx !== undefined ? { maxWidth: maxWidthPx } : {}),
346
438
  ...(binColors !== undefined ? { binFills: binColors } : {}),
347
439
  };
348
- }, [bar, groups, colors, binColors]);
440
+ }, [bar, groups, colors, binColors, maxWidthPx]);
349
441
  // The current selection / hover, narrowed to the identity the highlight match
350
442
  // needs. For a stack that's (id, key, label = group); the single path uses just
351
443
  // (id, key). Read here so a change re-registers the layer → the canvas repaints.
352
444
  const selected = container.selected;
353
445
  const hoveredMark = container.hovered;
354
- const selection = useMemo(() => selected === null
355
- ? null
356
- : {
357
- id: selected.id,
358
- key: selected.key,
359
- label: selected.label,
360
- ...(selected.mark !== undefined ? { mark: selected.mark } : {}),
361
- }, [selected]);
362
- const hover = useMemo(() => hoveredMark === null
363
- ? null
364
- : {
365
- id: hoveredMark.id,
366
- key: hoveredMark.key,
367
- label: hoveredMark.label,
368
- ...(hoveredMark.mark !== undefined
369
- ? { mark: hoveredMark.mark }
370
- : {}),
371
- }, [hoveredMark]);
446
+ // The selection is a set ([PND-MULTISEL]); narrow each member to the identity
447
+ // the draw path matches on. `EMPTY_MARKS` keeps the no-selection case
448
+ // reference-stable so it doesn't re-identify the layer entry each render.
449
+ const selection = useMemo(() => selected.length === 0
450
+ ? EMPTY_MARKS
451
+ : selected.map((m) => ({
452
+ id: m.id,
453
+ key: m.key,
454
+ label: m.label,
455
+ ...(m.mark !== undefined ? { mark: m.mark } : {}),
456
+ })), [selected]);
457
+ const hover = useMemo(() => hoveredMark.length === 0
458
+ ? EMPTY_MARKS
459
+ : hoveredMark.map((m) => ({
460
+ id: m.id,
461
+ key: m.key,
462
+ label: m.label,
463
+ ...(m.mark !== undefined ? { mark: m.mark } : {}),
464
+ })), [hoveredMark]);
465
+ // The selection's span entries, narrowed to this layer (interaction RFC
466
+ // A5.2). On the single-series path every mark shares one label, so a span's
467
+ // `rows` channel resolves here (once) rather than per bar; the stacked path's
468
+ // labels vary per segment (group / category), so `rows` rides through for
469
+ // `drawStacks` to test. Empty (and reference-stable) when no span names us —
470
+ // this layer neither re-registers nor repaints for other layers' spans.
471
+ const layerSpans = useMemo(() => spansForLayer(container.selectedSpans, id, shape.kind === 'single' ? label : undefined), [container.selectedSpans, id, shape.kind, label]);
372
472
  const entry = useMemo(() => {
373
473
  // ── Single-series, vertical: the original bar path, pixels unchanged. ──
374
474
  if (shape.kind === 'single') {
@@ -403,7 +503,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
403
503
  ...(id === undefined
404
504
  ? {}
405
505
  : {
406
- hitTest: (px, py, xScale, yScale) => {
506
+ hitTest: (px, py, xScale, yScale, mode) => {
407
507
  const baseline = resolveBarBaseline(yScale);
408
508
  // No `gapPx` — the hit region is the bar's whole slot (its
409
509
  // interval width, full plot height), not the inset rect the
@@ -412,6 +512,20 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
412
512
  if (hit === null)
413
513
  return null;
414
514
  const [bi, begin, value] = hit;
515
+ // A CLICK narrows the slot to the bar's drawn ink
516
+ // vertically (the x keeps the slot, so the gap between
517
+ // columns is still not a dead channel). Slots tile the
518
+ // whole plot, so without this a click could never resolve
519
+ // to null — and that null IS the deselect path (RFC §7's
520
+ // empty commit). Hover keeps the full slot: the highlight
521
+ // tracks continuously like the readout (#582).
522
+ if (mode === 'select') {
523
+ const yValue = yScale(value);
524
+ const yBase = yScale(baseline);
525
+ if (py < Math.min(yValue, yBase) ||
526
+ py > Math.max(yValue, yBase))
527
+ return null;
528
+ }
415
529
  // The bar's stable `mark` (its own axis key) rides the
416
530
  // selection, so the highlight match and a controlled echo key
417
531
  // on the *sample* rather than on the `begin` edge — which on a
@@ -430,8 +544,43 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
430
544
  ...(stableMark !== undefined ? { mark: stableMark } : {}),
431
545
  };
432
546
  },
547
+ // The <MultiSelector> sweep's range query (RFC A7.6): bars
548
+ // are sorted, non-overlapping intervals, so the covered set
549
+ // is a contiguous run — sweep1D's two binary searches. Each
550
+ // materialised hit is EXACTLY what hitTest reports for that
551
+ // bar, so a swept bar and a clicked bar are the same currency.
552
+ beginSweep: () => bs.length === 0
553
+ ? null
554
+ : sweep1D({
555
+ id,
556
+ begin: bs.begin,
557
+ end: bs.end,
558
+ length: bs.length,
559
+ // A gap bar (non-finite value) owns no membership.
560
+ selectable: (i) => Number.isFinite(bs.y[i]),
561
+ materialize: (lo, hi) => {
562
+ const out = [];
563
+ for (let i = lo; i < hi; i += 1) {
564
+ const v = bs.y[i];
565
+ if (!Number.isFinite(v))
566
+ continue;
567
+ const stableMark = bs.marks?.[i];
568
+ out.push({
569
+ id,
570
+ key: bs.begin[i],
571
+ value: v,
572
+ color: binColors?.[i] ?? singleStyle.fill,
573
+ label,
574
+ ...(stableMark !== undefined
575
+ ? { mark: stableMark }
576
+ : {}),
577
+ });
578
+ }
579
+ return out;
580
+ },
581
+ }),
433
582
  }),
434
- draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate, binColors, bandLadder),
583
+ draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleDrawStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate, binColors, bandLadder, layerSpans),
435
584
  },
436
585
  axisId: axis,
437
586
  index,
@@ -469,7 +618,20 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
469
618
  ? {}
470
619
  : {
471
620
  hitTest: (px, py, xScale, yScale) => {
472
- const hit = stackAt(ss, px, py, orientation, xScale, yScale, gapPx, stackMinWidth);
621
+ // The cap reaches the hit rect ONLY for a real stack, where the
622
+ // rect is what resolves *which segment* was hit and so must be
623
+ // the drawn one. A single-series chart has one segment per slot
624
+ // and nothing to disambiguate, so narrowing its target buys
625
+ // nothing and costs clickability.
626
+ //
627
+ // This path serves single-series **horizontal** charts as well as
628
+ // stacks (see the branch comment above), which is how the prop's
629
+ // documented guarantee — "a single-series bar hit-tests its whole
630
+ // slot" — was true only of vertical ones. Found by Layer-2 review;
631
+ // the fix is to make the guarantee orientation-independent rather
632
+ // than to narrow the claim, since the reason for the split is
633
+ // segment disambiguation and that is a property of the *stack*.
634
+ const hit = stackAt(ss, px, py, orientation, xScale, yScale, gapPx, stackMinWidth, ss.groups.length > 1 ? maxWidthPx : undefined);
473
635
  if (hit === null)
474
636
  return null;
475
637
  const [bi, g, begin, name, value] = hit;
@@ -490,8 +652,67 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
490
652
  ...(stableMark !== undefined ? { mark: stableMark } : {}),
491
653
  };
492
654
  },
655
+ // The sweep, on the binned/stacked/categorical path — **either
656
+ // orientation** ([PND-HSWEEP]). The session is identical, because
657
+ // `sweep1D` cuts in KEY-axis units and does not care which screen
658
+ // axis produced them; the bins are `ss.begin`/`ss.end` whichever
659
+ // way the chart is drawn. What differs is only where the gesture
660
+ // reads the pointer, which `sweepAxis` declares.
661
+ //
662
+ // The cut stays 1-D on a horizontal chart, deliberately: a
663
+ // vertical bar's sweep ignores the value axis (drag anywhere
664
+ // horizontally, take whole columns), so its transpose ignores it
665
+ // too. A rect here would be value-filtering — a capability the
666
+ // vertical chart has never had.
667
+ //
668
+ // A covered bin materialises every drawn segment (finite,
669
+ // non-zero — the marks hitTest can hit), assembled exactly as
670
+ // hitTest assembles them.
671
+ sweepAxis: vertical ? 'x' : 'y',
672
+ beginSweep: () => {
673
+ const G = ss.groups.length;
674
+ if (ss.length === 0 || G === 0)
675
+ return null;
676
+ const drawn = (b, g) => {
677
+ const v = ss.values[b * G + g];
678
+ return Number.isFinite(v) && v !== 0;
679
+ };
680
+ return sweep1D({
681
+ id,
682
+ begin: ss.begin,
683
+ end: ss.end,
684
+ length: ss.length,
685
+ selectable: (b) => {
686
+ for (let g = 0; g < G; g += 1)
687
+ if (drawn(b, g))
688
+ return true;
689
+ return false;
690
+ },
691
+ materialize: (lo, hi) => {
692
+ const out = [];
693
+ for (let b = lo; b < hi; b += 1) {
694
+ const stableMark = ss.marks?.[b];
695
+ for (let g = 0; g < G; g += 1) {
696
+ if (!drawn(b, g))
697
+ continue;
698
+ out.push({
699
+ id,
700
+ key: ss.begin[b],
701
+ value: ss.values[b * G + g],
702
+ color: stackStyle.binFills?.[b] ?? stackStyle.fills[g],
703
+ label: stableMark ?? ss.groups[g],
704
+ ...(stableMark !== undefined
705
+ ? { mark: stableMark }
706
+ : {}),
707
+ });
708
+ }
709
+ }
710
+ return out;
711
+ },
712
+ });
713
+ },
493
714
  }),
494
- draw: (ctx, xScale, yScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover, bandLadder),
715
+ draw: (ctx, xScale, yScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover, bandLadder, layerSpans),
495
716
  },
496
717
  axisId: axis,
497
718
  index,
@@ -503,6 +724,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
503
724
  categoryLabels,
504
725
  orientation,
505
726
  singleStyle,
727
+ singleDrawStyle,
506
728
  stackStyle,
507
729
  binColors,
508
730
  bandLadder,
@@ -511,8 +733,10 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
511
733
  gapPx,
512
734
  decimate,
513
735
  stackMinWidth,
736
+ maxWidthPx,
514
737
  selection,
515
738
  hover,
739
+ layerSpans,
516
740
  axis,
517
741
  index,
518
742
  ]);
package/dist/BarList.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type ReactNode } from 'react';
2
2
  import type { SeriesSchema, ValueSeriesSchema } from 'pond-ts';
3
3
  import { type BarListColumn, type ListCellSpec, type ListMarker, type ListRow, type ListSortDirection } from './list.js';
4
4
  import { type ListRowsSource, type ListSeriesSource } from './list-source.js';
5
+ import type { SelectModifiers } from './context.js';
5
6
  import { type ChartTheme } from './theme.js';
6
7
  /** The props both BarList source doors share. */
7
8
  export interface BarListCommon<R extends ListRow = ListRow> {
@@ -53,13 +54,92 @@ export interface BarListCommon<R extends ListRow = ListRow> {
53
54
  /** Observe a toggle (`expanded` is the row's **new** state). */
54
55
  onExpandToggle?: (key: string, expanded: boolean) => void;
55
56
  /**
56
- * The selected row's `key`, marked with an inset edge in the annotation
57
- * (marks) register — selection is a user's mark, not data. Consumer-owned
58
- * state: pair with `onRowClick`. `null` / omitted ⇒ none.
57
+ * The selected row(s), marked with an inset edge in the annotation (marks)
58
+ * register — selection is a user's mark, not data. Consumer-owned state:
59
+ * pair with {@link onRowClick}. `null` / omitted ⇒ none.
60
+ *
61
+ * **Accepts one key or a set**, the same union {@link hovered} takes
62
+ * ([PND-INTERACTCONF] / RFC `interaction.md` A3.1 — the list family speaks
63
+ * the canvas's interaction vocabulary, not a parallel one). Plural because
64
+ * a range of rows can be selected at once; passing a bare key still means
65
+ * exactly what it looks like.
66
+ *
67
+ * The library applies **no set arithmetic** — it renders what you hand back.
59
68
  */
60
- selected?: string | null;
61
- /** Row click (rows show hover + pointer affordances only when provided). */
69
+ selected?: string | readonly string[] | null;
70
+ /** Row click (rows show the pointer affordance only when provided). */
62
71
  onRowClick?: (row: R) => void;
72
+ /**
73
+ * **Plural select** — the list's answer to `<MultiSelector>`, and how a user
74
+ * produces a multi-row {@link selected}.
75
+ *
76
+ * Fires with the rows the gesture took plus its modifiers:
77
+ *
78
+ * - a **click** reports `[row]` — so this is a strict *superset* of
79
+ * {@link onRowClick}, the way `<MultiSelector>` is of `<Selector>`;
80
+ * - a **drag across rows** reports the whole inclusive run, in display
81
+ * order.
82
+ *
83
+ * **Mounting it is what enables the drag** (interaction RFC A4.2 rule 1 —
84
+ * the same rule that makes a bare `<MultiSelector />` enable the canvas
85
+ * sweep). A list with only `onRowClick` behaves exactly as it always has.
86
+ *
87
+ * **Crossing into another row is what makes it a range**, not a pixel slop:
88
+ * a row is tall and discrete, so a press-and-release on one row is always a
89
+ * click, and a horizontal wobble — which on a stack of rows means nothing —
90
+ * can never commit one. While the drag runs, the covered rows light as
91
+ * *hovered*: that is the live preview of what releasing would take, and it
92
+ * out-ranks {@link hovered} for the duration without touching it.
93
+ *
94
+ * **The library holds no state and applies no set arithmetic.** You get the
95
+ * run and the modifiers; you decide whether to replace or union, and feed
96
+ * the result back through {@link selected}:
97
+ *
98
+ * ```tsx
99
+ * onRowSelect={(rows, m) =>
100
+ * setSel((cur) => {
101
+ * const keys = rows.map((r) => r.key);
102
+ * return m.additive ? [...new Set([...cur, ...keys])] : keys;
103
+ * })
104
+ * }
105
+ * ```
106
+ *
107
+ * `modifiers.additive` is the platform-idiomatic add chord already resolved
108
+ * (⌘ on macOS, Ctrl elsewhere). **`shiftKey` is reported but carries no
109
+ * built-in meaning** — an ordinal range is a gesture here, not a modifier
110
+ * (see `SelectModifiers`), so a shift-click extend is yours to define if you
111
+ * want one.
112
+ */
113
+ onRowSelect?: (rows: readonly R[], modifiers: SelectModifiers) => void;
114
+ /**
115
+ * Controlled **hover-highlight** — the transiently lit row key(s), or `null`.
116
+ * **Omitted ⇒ uncontrolled** (the list tracks its own pointer, as it always
117
+ * has). The hover analog of {@link selected}: pass it to light rows from
118
+ * _outside_ the list — the chart bar the pointer is on, a map segment, a
119
+ * sibling list.
120
+ *
121
+ * **Accepts one key or a set**, the same union `<Selector hovered>`
122
+ * takes ([PND-INTERACTCONF] / RFC `interaction.md` A3.1 — the list family
123
+ * speaks the canvas's interaction vocabulary, not a parallel one). Plural
124
+ * because a sweep lights several marks at once; a plain pointer-over carries
125
+ * 0 or 1, so passing a bare key still means exactly what it looks like.
126
+ *
127
+ * The library applies **no set arithmetic** — it reports what the pointer is
128
+ * over and renders what you hand back.
129
+ */
130
+ hovered?: string | readonly string[] | null;
131
+ /**
132
+ * Fires when the pointer enters a row (with that row) or leaves every row
133
+ * (`null`) — the hover analog of `onRowClick`, and the list's half of the
134
+ * bidirectional channel: mirror it out to light the matching chart bar,
135
+ * pairing with {@link hovered} to sync hover both ways.
136
+ *
137
+ * Notification only (fires controlled or uncontrolled) and **deduped by row
138
+ * key**, so it reports a row transition, not every pointer move. Moving from
139
+ * one row straight to the next reports the new row — no `null` in between;
140
+ * `null` means the pointer genuinely left the rows.
141
+ */
142
+ onHover?: (row: R | null) => void;
63
143
  /** Each bar line's height in px. **Omitted ⇒ `8`.** */
64
144
  barHeight?: number;
65
145
  /** Rule between rows (`theme.axis.grid`). **Omitted ⇒ `true`.** */
package/dist/BarList.js CHANGED
@@ -36,7 +36,7 @@ export function BarList(props) {
36
36
  // One normalized view of the union — `isSeriesSource` is the runtime
37
37
  // narrowing; the doors are mutually exclusive by construction now.
38
38
  const source = props;
39
- const { rows, series, label, columns, domain, sortBy, sortDirection = 'desc', sort, before, after, renderExpanded, defaultExpanded, onExpandToggle, selected, onRowClick, markers, barHeight = 8, divided, baseline, theme = defaultTheme, } = source;
39
+ const { rows, series, label, columns, domain, sortBy, sortDirection = 'desc', sort, before, after, renderExpanded, defaultExpanded, onExpandToggle, selected, onRowClick, onRowSelect, hovered, onHover, markers, barHeight = 8, divided, baseline, theme = defaultTheme, } = source;
40
40
  // A runtime guard for JS consumers and `any`-typed call sites — the
41
41
  // props union makes both branches unreachable from typed TS, but a
42
42
  // silently-ignored source prop is a worse failure than a throw.
@@ -55,9 +55,30 @@ export function BarList(props) {
55
55
  frac: listFraction(m.value, scale),
56
56
  ...(m.label !== undefined ? { label: m.label } : {}),
57
57
  })), [markers, scale]);
58
- return (_jsx(ListTable, { rows: sorted, kind: "bar", markers: resolvedMarkers, before: before, after: after, renderExpanded: renderExpanded, defaultExpanded: defaultExpanded, onExpandToggle: onExpandToggle, selected: selected, onRowClick: onRowClick, divided: divided, baseline: baseline, theme: theme, renderGlyphs: (row) => (_jsx(_Fragment, { children: columns.map((col, ci) => {
58
+ return (_jsx(ListTable, { rows: sorted, kind: "bar", markers: resolvedMarkers, before: before, after: after, renderExpanded: renderExpanded, defaultExpanded: defaultExpanded, onExpandToggle: onExpandToggle, selected: selected, onRowClick: onRowClick, onRowSelect: onRowSelect, hovered: hovered, onHover: onHover, divided: divided, baseline: baseline, theme: theme, renderGlyphs: (row, state) => (_jsx(_Fragment, { children: columns.map((col, ci) => {
59
59
  const style = theme.bar[col.as ?? 'default'] ?? theme.bar.default;
60
60
  const frac = listFraction(row.values[col.column], scale);
61
+ // **The fill's state treatment — decorative, never load-bearing.**
62
+ // The band and the rail have already said "selected"; this only
63
+ // agrees with them. It is skipped entirely on a multi-metric row
64
+ // because there the fill IS the metric's identity, and recolouring
65
+ // it would trade a distinction the reader needs for one they
66
+ // already have (`ChartTheme.list`'s channel rule).
67
+ //
68
+ // Which is also why nothing below may be the *only* signal: strip
69
+ // this block and a selected row still reads as selected.
70
+ const soleMetric = columns.length === 1;
71
+ const fill = state.selected && soleMetric
72
+ ? style.highlight
73
+ : state.dimmed
74
+ ? (style.dimmed ?? style.fill)
75
+ : style.fill;
76
+ // A `dimmed` token carries its own alpha (`rgba(…,0.32)`), so
77
+ // multiplying `opacity` on top of it would dim twice. Fall back to
78
+ // the raw 0.32 only when the theme names no dimmed colour.
79
+ const fillOpacity = state.dimmed && style.dimmed === undefined
80
+ ? style.opacity * 0.32
81
+ : style.opacity;
61
82
  return (_jsxs("div", { "data-list-track": col.column, style: {
62
83
  position: 'relative',
63
84
  height: barHeight,
@@ -75,8 +96,8 @@ export function BarList(props) {
75
96
  bottom: 0,
76
97
  left: 0,
77
98
  width: `${frac * 100}%`,
78
- background: style.fill,
79
- opacity: style.opacity,
99
+ background: fill,
100
+ opacity: fillOpacity,
80
101
  borderRadius: barHeight / 2,
81
102
  } }))] }, `${ci} ${col.column}`));
82
103
  }) })) }));