@pond-ts/charts 0.56.2 → 0.58.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 (78) hide show
  1. package/CHANGELOG.md +1218 -1
  2. package/dist/AreaChart.d.ts +12 -1
  3. package/dist/AreaChart.js +131 -13
  4. package/dist/BarChart.d.ts +84 -9
  5. package/dist/BarChart.js +295 -40
  6. package/dist/BarList.d.ts +85 -5
  7. package/dist/BarList.js +25 -4
  8. package/dist/BoxList.d.ts +70 -3
  9. package/dist/BoxList.js +21 -7
  10. package/dist/BoxPlot.d.ts +2 -1
  11. package/dist/BoxPlot.js +101 -9
  12. package/dist/Candlestick.d.ts +13 -1
  13. package/dist/Candlestick.js +89 -3
  14. package/dist/ChartContainer.d.ts +79 -48
  15. package/dist/ChartContainer.js +482 -60
  16. package/dist/ChartRow.d.ts +9 -2
  17. package/dist/ChartRow.js +86 -12
  18. package/dist/HeatMap.d.ts +176 -0
  19. package/dist/HeatMap.js +344 -0
  20. package/dist/Layers.d.ts +5 -1
  21. package/dist/Layers.js +1014 -253
  22. package/dist/Legend.js +8 -4
  23. package/dist/LineChart.d.ts +18 -1
  24. package/dist/LineChart.js +165 -4
  25. package/dist/ListTable.d.ts +30 -3
  26. package/dist/ListTable.js +381 -23
  27. package/dist/ScatterChart.d.ts +3 -2
  28. package/dist/ScatterChart.js +68 -4
  29. package/dist/XAxis.js +40 -22
  30. package/dist/YAxis.d.ts +28 -1
  31. package/dist/YAxis.js +24 -2
  32. package/dist/annotations.d.ts +74 -0
  33. package/dist/annotations.js +97 -7
  34. package/dist/area.d.ts +34 -1
  35. package/dist/area.js +88 -1
  36. package/dist/bars.d.ts +178 -5
  37. package/dist/bars.js +504 -46
  38. package/dist/box.d.ts +2 -2
  39. package/dist/box.js +158 -40
  40. package/dist/brush.d.ts +142 -0
  41. package/dist/brush.js +179 -0
  42. package/dist/child-index.d.ts +27 -0
  43. package/dist/child-index.js +57 -0
  44. package/dist/context.d.ts +871 -36
  45. package/dist/cursors.d.ts +161 -0
  46. package/dist/cursors.js +503 -0
  47. package/dist/decimate.d.ts +78 -1
  48. package/dist/decimate.js +157 -0
  49. package/dist/heat.d.ts +163 -0
  50. package/dist/heat.js +659 -0
  51. package/dist/index.d.ts +13 -4
  52. package/dist/index.js +25 -2
  53. package/dist/line.d.ts +137 -0
  54. package/dist/line.js +328 -0
  55. package/dist/ohlc.d.ts +16 -1
  56. package/dist/ohlc.js +93 -4
  57. package/dist/scatter.d.ts +17 -9
  58. package/dist/scatter.js +221 -33
  59. package/dist/select.d.ts +13 -5
  60. package/dist/select.js +14 -6
  61. package/dist/selection-fixtures.d.ts +174 -0
  62. package/dist/selection-fixtures.js +569 -0
  63. package/dist/selection-stories.d.ts +73 -0
  64. package/dist/selection-stories.js +301 -0
  65. package/dist/selectors.d.ts +316 -0
  66. package/dist/selectors.js +391 -0
  67. package/dist/span.d.ts +122 -0
  68. package/dist/span.js +203 -0
  69. package/dist/sweep.d.ts +154 -0
  70. package/dist/sweep.js +282 -0
  71. package/dist/theme.d.ts +517 -11
  72. package/dist/theme.js +220 -39
  73. package/dist/tracker.d.ts +6 -0
  74. package/dist/tracker.js +6 -0
  75. package/dist/tradingAxis.fixture.d.ts +78 -0
  76. package/dist/tradingAxis.fixture.js +215 -0
  77. package/dist/useChartLegend.js +18 -3
  78. package/package.json +3 -3
package/dist/BarChart.js CHANGED
@@ -1,10 +1,15 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
2
  import { Interval, ValueSeries } from 'pond-ts';
3
3
  import { barsFromTimeSeries, barsFromBins, barsFromValueSeries, categoryStack, stacksFromBins, stacksFromColumns, stacksFromGroups, } from './data.js';
4
- import { barAt, barExtent, barIndexAtTime, drawBars, drawStacks, resolveBarBaseline, stackAt, stackBinExtent, stackValueExtent, } from './bars.js';
4
+ import { barAt, barExtent, barIndexAtTime, drawBars, drawStacks, normalizeThresholds, resolveBarBaseline, stackAt, stackBinExtent, stackValueExtent, } from './bars.js';
5
+ import { spansForLayer } from './span.js';
6
+ import { isDev } from './dev.js';
5
7
  import { ContainerContext, LayersContext, } from './context.js';
8
+ import { sweep1D } from './sweep.js';
6
9
  import { legendLabelFor, useLegendItems, } from './swatch.js';
7
10
  import { useSlotKey } from './use-slot-key.js';
11
+ /** Stable "nothing selected" identity for the narrowed mark lists below. */
12
+ const EMPTY_MARKS = [];
8
13
  /**
9
14
  * A bar / histogram draw layer. In its simplest form, one rectangle per event
10
15
  * spanning the key's `[begin, end]` from the axis baseline to a numeric
@@ -24,14 +29,21 @@ import { useSlotKey } from './use-slot-key.js';
24
29
  * domain spans zero, or on the axis floor when an explicit `<YAxis min>` sits
25
30
  * above zero (see {@link resolveBarBaseline}).
26
31
  *
27
- * **Baseline (stacked).** A stack is **cumulative from value 0** — the segments
28
- * sum upward from the zero line, so its value axis **must include 0**. The
29
- * auto-fit guarantees this: {@link stackValueExtent} always returns `[0, maxTotal]`.
30
- * An explicit `<YAxis min>` **above** 0 is therefore unsupported for a stack — it
31
- * would hide the bottom of the cumulative column; only the portion above the floor
32
- * draws (clipped cleanly at the plot floor, as any bar below an explicit floor is).
33
- * Segment values are assumed **non-negative** (a negative or zero segment is
34
- * skipped — diverging stacks are out of scope).
32
+ * **Baseline (stacked).** A stack is **cumulative from value 0** — so its value
33
+ * axis **must include 0**. The auto-fit guarantees this:
34
+ * {@link stackValueExtent} returns `[minNegativeTotal, maxPositiveTotal]`, both
35
+ * seeded at `0`. An explicit `<YAxis min>` **above** 0 is therefore unsupported
36
+ * for a stack — it would hide the bottom of the cumulative column; only the
37
+ * portion above the floor draws (clipped cleanly at the plot floor, as any bar
38
+ * below an explicit floor is).
39
+ *
40
+ * **Signed stacks are supported** ([PND-SIGNSTACK]): each bin keeps two running
41
+ * totals, so positive segments stack **up** from the zero line and negative
42
+ * ones stack **down** from it — the signed histogram (net flow by category,
43
+ * inflow/outflow, buy/sell pressure by venue). A **zero** segment is still
44
+ * skipped, having no extent to draw or hit-test. This changed in the
45
+ * threshold-banding wave: negative segments were previously dropped outright
46
+ * and silently, so a mixed-sign series rendered as an all-positive chart.
35
47
  *
36
48
  * **Interaction (opt-in via `id`).** Hover lights the bar / segment under the
37
49
  * cursor (hit-tested by pixel rect, so it works in both orientations); click
@@ -48,7 +60,7 @@ import { useSlotKey } from './use-slot-key.js';
48
60
  * </Layers>
49
61
  * ```
50
62
  */
51
- export function BarChart({ series, bins, categories, column, columns, as: semantic, colors, binColors, 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, decimate = true, legend, index = 0, }) {
52
64
  const container = useContext(ContainerContext);
53
65
  if (container === null) {
54
66
  throw new Error('<BarChart> must be rendered inside a <ChartContainer>');
@@ -183,15 +195,21 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
183
195
  : series instanceof ValueSeries
184
196
  ? 'value'
185
197
  : 'time';
186
- // The bars' `[begin, end)` spans as pond `Interval`s — the region cursor's snap
187
- // buckets (a region drag snaps bar by bar; a hover highlights the bar under the
188
- // pointer). Published only for a **vertical** bar layer on a **continuous**
189
- // (time / value) x axis: a horizontal chart puts the value/count on x (snapping
190
- // it is meaningless) and a categorical (ordinal-slot) axis is out of the region
191
- // cursor's scope. Memoized off the shape alone, so a hover / selection change
192
- // (which rebuilds the layer entry) doesn't re-allocate the intervals.
198
+ // The bars' `[begin, end)` spans as pond `Interval`s — the shared snap
199
+ // buckets (a region drag snaps bar by bar; a `<MultiSelector>` sweep's band
200
+ // extends bar by bar). Published for any **vertical** bar layer: a
201
+ // horizontal chart puts the value/count on x (snapping it is meaningless).
202
+ // On a **category** axis the buckets are the unit slots `[i, i+1)` — the
203
+ // region cursor still ignores them (its band gates on a continuous axis),
204
+ // but the sweep's band needs them to snap to the slots' **outer edges**:
205
+ // the band scale's `invert` snaps a pixel to the slot *centre*, so a
206
+ // freeform sweep band ran centre-to-centre while capture and the committed
207
+ // span snapped outward (RFC A7.6's edge rule) — the band disagreed with
208
+ // what release would select. Memoized off the shape alone, so a hover /
209
+ // selection change (which rebuilds the layer entry) doesn't re-allocate
210
+ // the intervals.
193
211
  const binBuckets = useMemo(() => {
194
- if (orientation !== 'vertical' || binAxisKind === 'category')
212
+ if (orientation !== 'vertical')
195
213
  return null;
196
214
  const { begin, end, length } = shape.kind === 'single' ? shape.bs : shape.ss;
197
215
  if (length === 0)
@@ -232,17 +250,136 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
232
250
  // The stacked path's bar-thickness floor comes from `bar.default` (not the `as`
233
251
  // role — `as` is single-series only), matching how `gapPx` sources its default.
234
252
  const stackMinWidth = bar.default.minWidth;
253
+ // ── Threshold ladder ([PND-BANDBAR2]) ────────────────────────────────────
254
+ // Resolved once here rather than per bar per frame: normalize the breakpoints
255
+ // (sort, drop non-finite), then pair them with `bandColors` → the role's
256
+ // `BarStyle.bands`. Everything that can go wrong with the pairing is a
257
+ // *silent* wrong-looking chart, so each case dev-warns — this feature exists
258
+ // because a quietly-unbanded bar was the workaround's failure mode.
259
+ // Value-compare the two array props rather than relying on their identity.
260
+ // `thresholds={[1, 2]}` inline is the documented usage and the shape every
261
+ // story and doc example uses — and a fresh array each render would rebuild
262
+ // the ladder, hence the layer `entry` below, hence a `registerLayer` call
263
+ // **every render**. That is a repaint treadmill, not just a noisy warning.
264
+ // The same value-compare-on-registration reasoning `<YAxis ticks>` already
265
+ // applies.
266
+ const thresholdKey = thresholds === undefined ? '' : thresholds.join(',');
267
+ const bandColorKey = bandColors === undefined ? '' : bandColors.join(',');
268
+ const bandLadder = useMemo(() => {
269
+ const steps = normalizeThresholds(thresholds);
270
+ if (steps === null) {
271
+ if (isDev && thresholds !== undefined && thresholds.length > 0) {
272
+ console.warn('<BarChart thresholds>: no usable breakpoints, so no banding was ' +
273
+ 'applied — each must be finite and greater than zero. Bars draw ' +
274
+ 'in the flat fill.');
275
+ }
276
+ return undefined;
277
+ }
278
+ // Some, but not all, entries dropped. Silently banding on a subset of what
279
+ // the caller wrote is exactly the class of quiet wrongness this feature is
280
+ // meant to remove, so say so.
281
+ if (isDev && thresholds !== undefined && steps.length < thresholds.length) {
282
+ console.warn(`<BarChart thresholds>: dropped ${thresholds.length - steps.length} ` +
283
+ 'breakpoint(s) that were not finite and greater than zero. The ' +
284
+ 'ladder is walked on the magnitude and mirrored onto whichever side ' +
285
+ 'of zero a bar is on, so a negative breakpoint has no meaning; ' +
286
+ `banding on [${steps.join(', ')}].`);
287
+ }
288
+ const want = steps.length + 1;
289
+ const supplied = bandColors ?? singleStyle.bands;
290
+ if (supplied === undefined || supplied.length === 0) {
291
+ if (isDev) {
292
+ console.warn(`<BarChart thresholds>: ${steps.length} breakpoint(s) need ${want} ` +
293
+ 'band colours, but neither `bandColors` nor the theme role’s ' +
294
+ '`BarStyle.bands` supplies any. Bars draw in the flat fill.');
295
+ }
296
+ return undefined;
297
+ }
298
+ if (supplied.length < want && isDev) {
299
+ console.warn(`<BarChart thresholds>: ${steps.length} breakpoint(s) need ${want} ` +
300
+ `band colours but only ${supplied.length} were supplied; bands ` +
301
+ 'above the last colour fall back to the flat fill.');
302
+ }
303
+ // Pad a short ladder with the flat fill so the draw path can index freely.
304
+ const resolved = supplied.length >= want
305
+ ? supplied.slice(0, want)
306
+ : [
307
+ ...supplied,
308
+ ...Array.from({ length: want - supplied.length }, () => singleStyle.fill),
309
+ ];
310
+ return { thresholds: steps, colors: resolved };
311
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `thresholdKey` /
312
+ // `bandColorKey` are the value-compared stand-ins for the array props.
313
+ }, [thresholdKey, bandColorKey, singleStyle]);
314
+ // Conflicts between the ladder and the shapes it can't apply to. In an effect
315
+ // so a re-render doesn't re-log; each fires once per genuinely new pairing.
316
+ const multiGroup = shape.kind === 'stacked' && shape.ss.groups.length > 1;
317
+ const hasLadder = bandLadder !== undefined;
318
+ const hasBinColors = binColors !== undefined;
319
+ useEffect(() => {
320
+ if (!isDev || !hasLadder)
321
+ return;
322
+ if (hasBinColors) {
323
+ console.warn('<BarChart>: `thresholds` and `binColors` are both set. They are two ' +
324
+ 'answers to “what colour is this bar”; `binColors` wins as the more ' +
325
+ 'specific one, and the threshold bands are ignored.');
326
+ }
327
+ if (multiGroup) {
328
+ console.warn('<BarChart>: `thresholds` is ignored on a multi-group stack — a ' +
329
+ 'segment that is already one slice of a total has no defined ' +
330
+ 'banding. Threshold bands apply to single-value bars (`series` / ' +
331
+ '`bins` / `categories`), in either orientation.');
332
+ }
333
+ }, [hasLadder, hasBinColors, multiGroup]);
235
334
  // Stacked style: per-group fills (colors override → theme role → default),
236
335
  // plus the shared opacity / outline from the default bar style. Memoized on the
237
336
  // groups + colours so a selection change doesn't rebuild it.
238
337
  const groups = shape.kind === 'stacked' ? shape.ss.groups : undefined;
239
338
  const stackStyle = useMemo(() => {
240
339
  const base = bar.default;
241
- const fills = (groups ?? []).map((g) => colors?.[g] ?? (bar[g] ?? base).fill);
340
+ // The theme's **group ramp** multi-group only, since a ramp exists to
341
+ // tell groups apart and `G === 1` (every categorical and single-series
342
+ // chart runs this same path) has nothing to tell apart. Resolution per
343
+ // group: `colors` → a role named after the group → the ramp → `fill`.
344
+ const multi = (groups?.length ?? 0) > 1;
345
+ const ramp = multi ? base.groups : undefined;
346
+ const rampDim = multi ? base.groupsDimmed : undefined;
347
+ const rampHover = multi ? base.groupsHover : undefined;
348
+ const at = (r, i) => r[i % r.length];
349
+ const fills = (groups ?? []).map((g, i) => colors?.[g] ??
350
+ bar[g]?.fill ??
351
+ (ramp !== undefined ? at(ramp, i) : base.fill));
352
+ // A ramp entry the call site overrode is no longer the ramp's colour, so
353
+ // its receded counterpart would be wrong — the whole ramp only means
354
+ // anything when it is the ramp that painted it.
355
+ const ramped = ramp !== undefined && colors === undefined;
242
356
  return {
243
357
  fills,
358
+ ...(ramped ? { groupColored: true } : {}),
359
+ ...(ramped && rampDim !== undefined
360
+ ? {
361
+ dimmedFills: (groups ?? []).map((_g, i) => at(rampDim, i)),
362
+ }
363
+ : {}),
364
+ ...(ramped && rampHover !== undefined
365
+ ? {
366
+ hoverFills: (groups ?? []).map((_g, i) => at(rampHover, i)),
367
+ }
368
+ : {}),
244
369
  opacity: base.opacity,
245
370
  outlineWidth: base.outlineWidth,
371
+ // [PND-CATEMPH] Forward the themed emphasis so the category / horizontal
372
+ // path can read the same `fill → hover → highlight` channel every other
373
+ // bar does, instead of accepting those theme values and ignoring them.
374
+ highlight: base.highlight,
375
+ ...(base.hover !== undefined ? { hover: base.hover } : {}),
376
+ ...(base.selectedOutline !== undefined
377
+ ? { selectedOutline: base.selectedOutline }
378
+ : {}),
379
+ ...(base.emphasisOpacity !== undefined
380
+ ? { emphasisOpacity: base.emphasisOpacity }
381
+ : {}),
382
+ ...(base.dimmed !== undefined ? { dimmed: base.dimmed } : {}),
246
383
  ...(binColors !== undefined ? { binFills: binColors } : {}),
247
384
  };
248
385
  }, [bar, groups, colors, binColors]);
@@ -251,24 +388,32 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
251
388
  // (id, key). Read here so a change re-registers the layer → the canvas repaints.
252
389
  const selected = container.selected;
253
390
  const hoveredMark = container.hovered;
254
- const selection = useMemo(() => selected === null
255
- ? null
256
- : {
257
- id: selected.id,
258
- key: selected.key,
259
- label: selected.label,
260
- ...(selected.mark !== undefined ? { mark: selected.mark } : {}),
261
- }, [selected]);
262
- const hover = useMemo(() => hoveredMark === null
263
- ? null
264
- : {
265
- id: hoveredMark.id,
266
- key: hoveredMark.key,
267
- label: hoveredMark.label,
268
- ...(hoveredMark.mark !== undefined
269
- ? { mark: hoveredMark.mark }
270
- : {}),
271
- }, [hoveredMark]);
391
+ // The selection is a set ([PND-MULTISEL]); narrow each member to the identity
392
+ // the draw path matches on. `EMPTY_MARKS` keeps the no-selection case
393
+ // reference-stable so it doesn't re-identify the layer entry each render.
394
+ const selection = useMemo(() => selected.length === 0
395
+ ? EMPTY_MARKS
396
+ : selected.map((m) => ({
397
+ id: m.id,
398
+ key: m.key,
399
+ label: m.label,
400
+ ...(m.mark !== undefined ? { mark: m.mark } : {}),
401
+ })), [selected]);
402
+ const hover = useMemo(() => hoveredMark.length === 0
403
+ ? EMPTY_MARKS
404
+ : hoveredMark.map((m) => ({
405
+ id: m.id,
406
+ key: m.key,
407
+ label: m.label,
408
+ ...(m.mark !== undefined ? { mark: m.mark } : {}),
409
+ })), [hoveredMark]);
410
+ // The selection's span entries, narrowed to this layer (interaction RFC
411
+ // A5.2). On the single-series path every mark shares one label, so a span's
412
+ // `rows` channel resolves here (once) rather than per bar; the stacked path's
413
+ // labels vary per segment (group / category), so `rows` rides through for
414
+ // `drawStacks` to test. Empty (and reference-stable) when no span names us —
415
+ // this layer neither re-registers nor repaints for other layers' spans.
416
+ const layerSpans = useMemo(() => spansForLayer(container.selectedSpans, id, shape.kind === 'single' ? label : undefined), [container.selectedSpans, id, shape.kind, label]);
272
417
  const entry = useMemo(() => {
273
418
  // ── Single-series, vertical: the original bar path, pixels unchanged. ──
274
419
  if (shape.kind === 'single') {
@@ -303,7 +448,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
303
448
  ...(id === undefined
304
449
  ? {}
305
450
  : {
306
- hitTest: (px, py, xScale, yScale) => {
451
+ hitTest: (px, py, xScale, yScale, mode) => {
307
452
  const baseline = resolveBarBaseline(yScale);
308
453
  // No `gapPx` — the hit region is the bar's whole slot (its
309
454
  // interval width, full plot height), not the inset rect the
@@ -312,6 +457,20 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
312
457
  if (hit === null)
313
458
  return null;
314
459
  const [bi, begin, value] = hit;
460
+ // A CLICK narrows the slot to the bar's drawn ink
461
+ // vertically (the x keeps the slot, so the gap between
462
+ // columns is still not a dead channel). Slots tile the
463
+ // whole plot, so without this a click could never resolve
464
+ // to null — and that null IS the deselect path (RFC §7's
465
+ // empty commit). Hover keeps the full slot: the highlight
466
+ // tracks continuously like the readout (#582).
467
+ if (mode === 'select') {
468
+ const yValue = yScale(value);
469
+ const yBase = yScale(baseline);
470
+ if (py < Math.min(yValue, yBase) ||
471
+ py > Math.max(yValue, yBase))
472
+ return null;
473
+ }
315
474
  // The bar's stable `mark` (its own axis key) rides the
316
475
  // selection, so the highlight match and a controlled echo key
317
476
  // on the *sample* rather than on the `begin` edge — which on a
@@ -330,8 +489,43 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
330
489
  ...(stableMark !== undefined ? { mark: stableMark } : {}),
331
490
  };
332
491
  },
492
+ // The <MultiSelector> sweep's range query (RFC A7.6): bars
493
+ // are sorted, non-overlapping intervals, so the covered set
494
+ // is a contiguous run — sweep1D's two binary searches. Each
495
+ // materialised hit is EXACTLY what hitTest reports for that
496
+ // bar, so a swept bar and a clicked bar are the same currency.
497
+ beginSweep: () => bs.length === 0
498
+ ? null
499
+ : sweep1D({
500
+ id,
501
+ begin: bs.begin,
502
+ end: bs.end,
503
+ length: bs.length,
504
+ // A gap bar (non-finite value) owns no membership.
505
+ selectable: (i) => Number.isFinite(bs.y[i]),
506
+ materialize: (lo, hi) => {
507
+ const out = [];
508
+ for (let i = lo; i < hi; i += 1) {
509
+ const v = bs.y[i];
510
+ if (!Number.isFinite(v))
511
+ continue;
512
+ const stableMark = bs.marks?.[i];
513
+ out.push({
514
+ id,
515
+ key: bs.begin[i],
516
+ value: v,
517
+ color: binColors?.[i] ?? singleStyle.fill,
518
+ label,
519
+ ...(stableMark !== undefined
520
+ ? { mark: stableMark }
521
+ : {}),
522
+ });
523
+ }
524
+ return out;
525
+ },
526
+ }),
333
527
  }),
334
- draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate, binColors),
528
+ draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate, binColors, bandLadder, layerSpans),
335
529
  },
336
530
  axisId: axis,
337
531
  index,
@@ -390,8 +584,67 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
390
584
  ...(stableMark !== undefined ? { mark: stableMark } : {}),
391
585
  };
392
586
  },
587
+ // The sweep, on the binned/stacked/categorical path — **either
588
+ // orientation** ([PND-HSWEEP]). The session is identical, because
589
+ // `sweep1D` cuts in KEY-axis units and does not care which screen
590
+ // axis produced them; the bins are `ss.begin`/`ss.end` whichever
591
+ // way the chart is drawn. What differs is only where the gesture
592
+ // reads the pointer, which `sweepAxis` declares.
593
+ //
594
+ // The cut stays 1-D on a horizontal chart, deliberately: a
595
+ // vertical bar's sweep ignores the value axis (drag anywhere
596
+ // horizontally, take whole columns), so its transpose ignores it
597
+ // too. A rect here would be value-filtering — a capability the
598
+ // vertical chart has never had.
599
+ //
600
+ // A covered bin materialises every drawn segment (finite,
601
+ // non-zero — the marks hitTest can hit), assembled exactly as
602
+ // hitTest assembles them.
603
+ sweepAxis: vertical ? 'x' : 'y',
604
+ beginSweep: () => {
605
+ const G = ss.groups.length;
606
+ if (ss.length === 0 || G === 0)
607
+ return null;
608
+ const drawn = (b, g) => {
609
+ const v = ss.values[b * G + g];
610
+ return Number.isFinite(v) && v !== 0;
611
+ };
612
+ return sweep1D({
613
+ id,
614
+ begin: ss.begin,
615
+ end: ss.end,
616
+ length: ss.length,
617
+ selectable: (b) => {
618
+ for (let g = 0; g < G; g += 1)
619
+ if (drawn(b, g))
620
+ return true;
621
+ return false;
622
+ },
623
+ materialize: (lo, hi) => {
624
+ const out = [];
625
+ for (let b = lo; b < hi; b += 1) {
626
+ const stableMark = ss.marks?.[b];
627
+ for (let g = 0; g < G; g += 1) {
628
+ if (!drawn(b, g))
629
+ continue;
630
+ out.push({
631
+ id,
632
+ key: ss.begin[b],
633
+ value: ss.values[b * G + g],
634
+ color: stackStyle.binFills?.[b] ?? stackStyle.fills[g],
635
+ label: stableMark ?? ss.groups[g],
636
+ ...(stableMark !== undefined
637
+ ? { mark: stableMark }
638
+ : {}),
639
+ });
640
+ }
641
+ }
642
+ return out;
643
+ },
644
+ });
645
+ },
393
646
  }),
394
- draw: (ctx, xScale, yScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover),
647
+ draw: (ctx, xScale, yScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover, bandLadder, layerSpans),
395
648
  },
396
649
  axisId: axis,
397
650
  index,
@@ -405,6 +658,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
405
658
  singleStyle,
406
659
  stackStyle,
407
660
  binColors,
661
+ bandLadder,
408
662
  label,
409
663
  id,
410
664
  gapPx,
@@ -412,6 +666,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
412
666
  stackMinWidth,
413
667
  selection,
414
668
  hover,
669
+ layerSpans,
415
670
  axis,
416
671
  index,
417
672
  ]);
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
  }) })) }));