@pond-ts/charts 0.57.0 → 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 (73) hide show
  1. package/CHANGELOG.md +1070 -1
  2. package/dist/AreaChart.d.ts +12 -1
  3. package/dist/AreaChart.js +131 -13
  4. package/dist/BarChart.js +184 -30
  5. package/dist/BarList.d.ts +85 -5
  6. package/dist/BarList.js +25 -4
  7. package/dist/BoxList.d.ts +70 -3
  8. package/dist/BoxList.js +21 -7
  9. package/dist/BoxPlot.d.ts +2 -1
  10. package/dist/BoxPlot.js +101 -9
  11. package/dist/Candlestick.d.ts +13 -1
  12. package/dist/Candlestick.js +89 -3
  13. package/dist/ChartContainer.d.ts +36 -48
  14. package/dist/ChartContainer.js +465 -59
  15. package/dist/ChartRow.d.ts +9 -2
  16. package/dist/ChartRow.js +86 -12
  17. package/dist/HeatMap.d.ts +176 -0
  18. package/dist/HeatMap.js +344 -0
  19. package/dist/Layers.d.ts +5 -1
  20. package/dist/Layers.js +1014 -253
  21. package/dist/Legend.js +8 -4
  22. package/dist/LineChart.d.ts +18 -1
  23. package/dist/LineChart.js +165 -4
  24. package/dist/ListTable.d.ts +30 -3
  25. package/dist/ListTable.js +381 -23
  26. package/dist/ScatterChart.d.ts +3 -2
  27. package/dist/ScatterChart.js +68 -4
  28. package/dist/XAxis.js +40 -22
  29. package/dist/area.d.ts +34 -1
  30. package/dist/area.js +88 -1
  31. package/dist/bars.d.ts +57 -3
  32. package/dist/bars.js +237 -26
  33. package/dist/box.d.ts +2 -2
  34. package/dist/box.js +158 -40
  35. package/dist/brush.d.ts +142 -0
  36. package/dist/brush.js +179 -0
  37. package/dist/child-index.d.ts +27 -0
  38. package/dist/child-index.js +57 -0
  39. package/dist/context.d.ts +859 -33
  40. package/dist/cursors.d.ts +161 -0
  41. package/dist/cursors.js +503 -0
  42. package/dist/decimate.d.ts +78 -1
  43. package/dist/decimate.js +157 -0
  44. package/dist/heat.d.ts +163 -0
  45. package/dist/heat.js +659 -0
  46. package/dist/index.d.ts +11 -2
  47. package/dist/index.js +22 -0
  48. package/dist/line.d.ts +137 -0
  49. package/dist/line.js +328 -0
  50. package/dist/ohlc.d.ts +16 -1
  51. package/dist/ohlc.js +93 -4
  52. package/dist/scatter.d.ts +17 -9
  53. package/dist/scatter.js +221 -33
  54. package/dist/select.d.ts +13 -5
  55. package/dist/select.js +14 -6
  56. package/dist/selection-fixtures.d.ts +174 -0
  57. package/dist/selection-fixtures.js +569 -0
  58. package/dist/selection-stories.d.ts +73 -0
  59. package/dist/selection-stories.js +301 -0
  60. package/dist/selectors.d.ts +316 -0
  61. package/dist/selectors.js +391 -0
  62. package/dist/span.d.ts +122 -0
  63. package/dist/span.js +203 -0
  64. package/dist/sweep.d.ts +154 -0
  65. package/dist/sweep.js +282 -0
  66. package/dist/theme.d.ts +456 -5
  67. package/dist/theme.js +217 -41
  68. package/dist/tracker.d.ts +6 -0
  69. package/dist/tracker.js +6 -0
  70. package/dist/tradingAxis.fixture.d.ts +78 -0
  71. package/dist/tradingAxis.fixture.js +215 -0
  72. package/dist/useChartLegend.js +18 -3
  73. package/package.json +3 -3
@@ -14,6 +14,17 @@ export interface AreaChartCommon<S extends SeriesSchema = SeriesSchema, VS exten
14
14
  * single styling channel).
15
15
  */
16
16
  as?: string;
17
+ /**
18
+ * **Opt in to selection** — see `<LineChart id>`; the currency is identical
19
+ * because the premise is ([PND-TRACESEL]): a click commits a **series-scoped**
20
+ * `SelectInfo` (`NaN` key/value plus a stable `mark`), a sweep commits a
21
+ * `SpanSelection` with **no marks**.
22
+ *
23
+ * What differs is only the **hit test**: an area is a filled region, so the
24
+ * pointer counts as on it when it lies **between the trace and the
25
+ * baseline** — the whole shape is the target, not the 1.5px edge.
26
+ */
27
+ id?: string;
17
28
  /**
18
29
  * Which `<YAxis>` (by its `id`) this area scales against — picks the *scale*,
19
30
  * where `as` picks the *style*. **Omitted ⇒ the row's default axis.**
@@ -140,6 +151,6 @@ export declare function resolveAreaBaseline(baseline: number | undefined, yScale
140
151
  * </Layers>
141
152
  * ```
142
153
  */
143
- export declare function AreaChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, readout, as: semantic, axis, baseline, curve, gaps, decimate, legend, index, }: AreaChartProps<S, VS>): null;
154
+ export declare function AreaChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, readout, as: semantic, axis, id, baseline, curve, gaps, decimate, legend, index, }: AreaChartProps<S, VS>): null;
144
155
  export {};
145
156
  //# sourceMappingURL=AreaChart.d.ts.map
package/dist/AreaChart.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
2
  import { ValueSeries } from 'pond-ts';
3
3
  import { assertNumericColumn, fromTimeSeries, fromValueSeries, } from './data.js';
4
- import { areaExtent, drawArea } from './area.js';
4
+ import { areaExtent, areaHitIndex, areaStateStyle, drawArea } from './area.js';
5
+ import { drawPartitioned, plotExtentOf, strokeSpanEdges, } from './line.js';
6
+ import { sweepSpan } from './sweep.js';
5
7
  import { resolveCurve } from './curve.js';
6
8
  import { DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
7
- import { ContainerContext, LayersContext } from './context.js';
9
+ import { ContainerContext, LayersContext, } from './context.js';
8
10
  import { legendLabelFor, useLegendItems, } from './swatch.js';
9
11
  import { useSlotKey } from './use-slot-key.js';
10
12
  /** Read a d3 linear scale's domain lower bound (the axis floor) from the plain
@@ -54,7 +56,7 @@ function domainFloor(yScale) {
54
56
  * </Layers>
55
57
  * ```
56
58
  */
57
- export function AreaChart({ series, column, readout, as: semantic, axis, baseline, curve, gaps = DEFAULT_GAP_MODE, decimate = true, legend, index = 0, }) {
59
+ export function AreaChart({ series, column, readout, as: semantic, axis, id, baseline, curve, gaps = DEFAULT_GAP_MODE, decimate = true, legend, index = 0, }) {
58
60
  const container = useContext(ContainerContext);
59
61
  if (container === null) {
60
62
  throw new Error('<AreaChart> must be rendered inside a <ChartContainer>');
@@ -91,6 +93,45 @@ export function AreaChart({ series, column, readout, as: semantic, axis, baselin
91
93
  // Faintness of the inferred dashed connectors (dashed / step) — theme-level,
92
94
  // falling back to the shared default so a theme without it still renders faint.
93
95
  const gapConnectorOpacity = container.theme.gap?.connectorOpacity ?? DEFAULT_GAP_CONNECTOR_OPACITY;
96
+ // ── The trace's interaction state ([PND-TRACESEL]) — see `<LineChart>` for
97
+ // the reasoning; this is the same derivation over `AreaStyle`'s channels.
98
+ const selectedEntries = container.selected;
99
+ const hoveredEntries = container.hovered;
100
+ // The committed spans, plus the **live** ones of a sweep in flight. A
101
+ // previewed span draws exactly as a committed one, so releasing changes
102
+ // nothing visually — the preview cannot promise a picture the commit does not
103
+ // deliver. The live channel wins while it is non-empty, because during a drag
104
+ // it IS the current answer.
105
+ const previewing = container.previewSpans.length > 0;
106
+ const allSpans = previewing
107
+ ? container.previewSpans
108
+ : container.selectedSpans;
109
+ const traceState = useMemo(() => {
110
+ if (id === undefined)
111
+ return 'rest';
112
+ if (allSpans.some((sp) => sp.id === id))
113
+ return 'rest';
114
+ const mine = (e) => e.id === id;
115
+ if (selectedEntries.some(mine))
116
+ return 'selected';
117
+ if (hoveredEntries.some(mine))
118
+ return 'hover';
119
+ if (selectedEntries.length > 0 || allSpans.length > 0)
120
+ return 'dimmed';
121
+ return 'rest';
122
+ }, [id, selectedEntries, hoveredEntries, allSpans]);
123
+ // **`spanColor` only when this is the ONLY swept trace.** The hue is
124
+ // justified by identity not being in question inside a single series — but
125
+ // sweep two traces and both would go blue, so inside the window you could no
126
+ // longer tell them apart, which is the very thing the rule exists to prevent.
127
+ // With more than one, the window thickens and every trace keeps its colour.
128
+ const soleSpannedTrace = allSpans.length === 1;
129
+ const spanX = useMemo(() => {
130
+ if (id === undefined)
131
+ return null;
132
+ const mine = allSpans.find((sp) => sp.id === id);
133
+ return mine === undefined ? null : mine.x;
134
+ }, [id, allSpans]);
94
135
  const entry = useMemo(() => ({
95
136
  layer: {
96
137
  as: semantic,
@@ -99,6 +140,36 @@ export function AreaChart({ series, column, readout, as: semantic, axis, baselin
99
140
  // ValueSeries plots on a value axis, a TimeSeries on time.
100
141
  xKind: series instanceof ValueSeries ? 'value' : 'time',
101
142
  xExtent: () => cs.length === 0 ? null : [cs.x[0], cs.x[cs.length - 1]],
143
+ // ── Selection, gated on `id` ([PND-TRACESEL]). Same currency as
144
+ // `<LineChart>`; only `hitTest` differs, because a fill is not a stroke.
145
+ ...(id === undefined
146
+ ? {}
147
+ : {
148
+ sweepsRect: false,
149
+ sweepAxis: 'x',
150
+ sweepSpanOnly: true,
151
+ hitTest: (px, py, xScale, yScale) => {
152
+ const i = areaHitIndex(cs, baseline, px, py, xScale, yScale);
153
+ if (i === null)
154
+ return null;
155
+ return {
156
+ id,
157
+ // Series-scoped, with a stable `mark` for identity — see
158
+ // `<LineChart>`'s hitTest for why both halves are needed.
159
+ key: NaN,
160
+ value: NaN,
161
+ color: style.fill,
162
+ label,
163
+ mark: label,
164
+ };
165
+ },
166
+ beginSweep: () => cs.length === 0
167
+ ? null
168
+ : sweepSpan({
169
+ id,
170
+ bounds: [cs.x[0], cs.x[cs.length - 1]],
171
+ }),
172
+ }),
102
173
  sampleAt: (x) => {
103
174
  // No readout past the data (tracker policy — nearest clamps to an
104
175
  // endpoint outside the span); bounds from the columnar x axis.
@@ -151,16 +222,49 @@ export function AreaChart({ series, column, readout, as: semantic, axis, baselin
151
222
  ]
152
223
  : [];
153
224
  },
154
- draw: (ctx, xScale, yScale) => drawArea(ctx, cs, xScale, yScale, style,
155
- // Omitted baseline rests on the axis floor (resolved late from the
156
- // scale, so it tracks the auto-fit domain); a fixed baseline is used
157
- // verbatim.
158
- // A log axis has no position for zero — or anything at or below
159
- // it — so an explicit out-of-domain `baseline` would scale to
160
- // `NaN` and poison every coordinate in the fill path. Fall back
161
- // to the axis floor, which is exactly what an omitted baseline
162
- // already resolves to.
163
- resolveAreaBaseline(baseline, yScale), curveFactory, gaps, gapConnectorOpacity, decimate),
225
+ draw: (ctx, xScale, yScale) => {
226
+ const fill = (st, alpha) => () => {
227
+ const prior = ctx.globalAlpha;
228
+ if (alpha !== 1)
229
+ ctx.globalAlpha = prior * alpha;
230
+ const out = drawAreaWith(st);
231
+ ctx.globalAlpha = prior;
232
+ return out;
233
+ };
234
+ if (spanX === null) {
235
+ const [st, alpha] = areaStateStyle(style, traceState);
236
+ return fill(st, alpha)();
237
+ }
238
+ // EXPERIMENT: annotation-register rules at the window's edges,
239
+ // underneath the trace ink (drawn first). See `strokeSpanEdges`.
240
+ //
241
+ // **Committed spans only.** While the drag is live the brush band
242
+ // already strokes its own edges at the same two x positions, so
243
+ // drawing these too put two rules a fraction of a pixel apart on each
244
+ // boundary — which read as one muddy smear rather than as either. The
245
+ // handoff is the honest reading anyway: the band is the gesture's
246
+ // mark and belongs to the drag; these preview the annotation you
247
+ // would get, and belong to the result.
248
+ if (!previewing)
249
+ strokeSpanEdges(ctx, [xScale(spanX[0]), xScale(spanX[1])], ctx.canvas.height, container.theme.annotation?.spanEdge ?? '#f0b26b');
250
+ const [outStyle, outAlpha] = areaStateStyle(style, 'dimmed');
251
+ const [inStyle] = areaStateStyle(style, 'selected');
252
+ return drawPartitioned(ctx, [xScale(spanX[0]), xScale(spanX[1])], plotExtentOf(ctx, xScale, yScale).height, fill(outStyle, outAlpha), fill(style.spanColor === undefined || !soleSpannedTrace
253
+ ? inStyle
254
+ : { ...inStyle, color: style.spanColor, fill: style.spanColor }, 1), true, 0, plotExtentOf(ctx, xScale, yScale).width);
255
+ function drawAreaWith(st) {
256
+ return drawArea(ctx, cs, xScale, yScale, st,
257
+ // Omitted baseline rests on the axis floor (resolved late from the
258
+ // scale, so it tracks the auto-fit domain); a fixed baseline is used
259
+ // verbatim.
260
+ // A log axis has no position for zero — or anything at or below
261
+ // it — so an explicit out-of-domain `baseline` would scale to
262
+ // `NaN` and poison every coordinate in the fill path. Fall back
263
+ // to the axis floor, which is exactly what an omitted baseline
264
+ // already resolves to.
265
+ resolveAreaBaseline(baseline, yScale), curveFactory, gaps, gapConnectorOpacity, decimate);
266
+ }
267
+ },
164
268
  },
165
269
  axisId: axis,
166
270
  index,
@@ -178,6 +282,11 @@ export function AreaChart({ series, column, readout, as: semantic, axis, baselin
178
282
  gapConnectorOpacity,
179
283
  decimate,
180
284
  axis,
285
+ id,
286
+ traceState,
287
+ spanX,
288
+ soleSpannedTrace,
289
+ previewing,
181
290
  index,
182
291
  ]);
183
292
  // A stable per-instance slot (see useSlotKey) keeps this layer's z-position
@@ -194,6 +303,15 @@ export function AreaChart({ series, column, readout, as: semantic, axis, baselin
194
303
  useEffect(() => {
195
304
  registerTrackerSource(slot, entry.layer);
196
305
  }, [registerTrackerSource, slot, entry.layer]);
306
+ // Advertise selectability (only when an `id` was given) — see the same block
307
+ // in `LineChart.tsx` for why a trace was missing from this set.
308
+ const { registerSelectable, unregisterSelectable } = container;
309
+ useEffect(() => {
310
+ if (id === undefined)
311
+ return;
312
+ registerSelectable(slot);
313
+ return () => unregisterSelectable(slot);
314
+ }, [registerSelectable, unregisterSelectable, slot, id]);
197
315
  // And a legend row: the readout identity + the resolved area style (top line
198
316
  // over the translucent fill), so a `<Legend>` swatch can never drift.
199
317
  const legendRows = useMemo(() => {
package/dist/BarChart.js CHANGED
@@ -2,10 +2,14 @@ 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
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
@@ -191,15 +195,21 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
191
195
  : series instanceof ValueSeries
192
196
  ? 'value'
193
197
  : '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.
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.
201
211
  const binBuckets = useMemo(() => {
202
- if (orientation !== 'vertical' || binAxisKind === 'category')
212
+ if (orientation !== 'vertical')
203
213
  return null;
204
214
  const { begin, end, length } = shape.kind === 'single' ? shape.bs : shape.ss;
205
215
  if (length === 0)
@@ -327,9 +337,35 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
327
337
  const groups = shape.kind === 'stacked' ? shape.ss.groups : undefined;
328
338
  const stackStyle = useMemo(() => {
329
339
  const base = bar.default;
330
- 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;
331
356
  return {
332
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
+ : {}),
333
369
  opacity: base.opacity,
334
370
  outlineWidth: base.outlineWidth,
335
371
  // [PND-CATEMPH] Forward the themed emphasis so the category / horizontal
@@ -343,6 +379,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
343
379
  ...(base.emphasisOpacity !== undefined
344
380
  ? { emphasisOpacity: base.emphasisOpacity }
345
381
  : {}),
382
+ ...(base.dimmed !== undefined ? { dimmed: base.dimmed } : {}),
346
383
  ...(binColors !== undefined ? { binFills: binColors } : {}),
347
384
  };
348
385
  }, [bar, groups, colors, binColors]);
@@ -351,24 +388,32 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
351
388
  // (id, key). Read here so a change re-registers the layer → the canvas repaints.
352
389
  const selected = container.selected;
353
390
  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]);
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]);
372
417
  const entry = useMemo(() => {
373
418
  // ── Single-series, vertical: the original bar path, pixels unchanged. ──
374
419
  if (shape.kind === 'single') {
@@ -403,7 +448,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
403
448
  ...(id === undefined
404
449
  ? {}
405
450
  : {
406
- hitTest: (px, py, xScale, yScale) => {
451
+ hitTest: (px, py, xScale, yScale, mode) => {
407
452
  const baseline = resolveBarBaseline(yScale);
408
453
  // No `gapPx` — the hit region is the bar's whole slot (its
409
454
  // interval width, full plot height), not the inset rect the
@@ -412,6 +457,20 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
412
457
  if (hit === null)
413
458
  return null;
414
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
+ }
415
474
  // The bar's stable `mark` (its own axis key) rides the
416
475
  // selection, so the highlight match and a controlled echo key
417
476
  // on the *sample* rather than on the `begin` edge — which on a
@@ -430,8 +489,43 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
430
489
  ...(stableMark !== undefined ? { mark: stableMark } : {}),
431
490
  };
432
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
+ }),
433
527
  }),
434
- draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate, binColors, bandLadder),
528
+ draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate, binColors, bandLadder, layerSpans),
435
529
  },
436
530
  axisId: axis,
437
531
  index,
@@ -490,8 +584,67 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
490
584
  ...(stableMark !== undefined ? { mark: stableMark } : {}),
491
585
  };
492
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
+ },
493
646
  }),
494
- draw: (ctx, xScale, yScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover, bandLadder),
647
+ draw: (ctx, xScale, yScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover, bandLadder, layerSpans),
495
648
  },
496
649
  axisId: axis,
497
650
  index,
@@ -513,6 +666,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
513
666
  stackMinWidth,
514
667
  selection,
515
668
  hover,
669
+ layerSpans,
516
670
  axis,
517
671
  index,
518
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`.** */