@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/bars.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { barSpanPx } from './range.js';
2
2
  import { visibleSpanRange } from './culling.js';
3
3
  import { decimateBars } from './decimate.js';
4
+ import { NO_SPANS, spanMatchesAny } from './span.js';
4
5
  /**
5
6
  * The `[min, max]` vertical extent the bars occupy — the finite values of `cs.y`
6
7
  * **widened to include `0`**, since a bar spans from its value to the baseline
@@ -72,11 +73,11 @@ export function resolveBarBaseline(yScale) {
72
73
  * hit region are deliberately *not* the same geometry: the `gapPx` inset
73
74
  * separates columns visually without carving a dead channel out of the target.
74
75
  */
75
- export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx) {
76
+ export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx, maxWidthPx) {
76
77
  const v = cs.y[i];
77
78
  if (!Number.isFinite(v))
78
79
  return null;
79
- const [x0, x1] = barSpanPx(cs.begin[i], cs.end[i], xScale, gapPx, minWidthPx);
80
+ const [x0, x1] = barSpanPx(cs.begin[i], cs.end[i], xScale, gapPx, minWidthPx, maxWidthPx);
80
81
  const yValue = yScale(v);
81
82
  const yBase = yScale(baseline);
82
83
  return [x0, x1, Math.min(yValue, yBase), Math.max(yValue, yBase)];
@@ -225,6 +226,78 @@ function barMatches(m, seriesId, stable, begin) {
225
226
  ? m.mark === stable
226
227
  : m.key === begin;
227
228
  }
229
+ /**
230
+ * Does **any** member of the selection set identify this bar? The set form of
231
+ * {@link barMatches} ([PND-MULTISEL]).
232
+ *
233
+ * Linear over the set on purpose: a selection is a handful of marks a person
234
+ * clicked, not a data structure — building a Set per draw would cost more than
235
+ * it saves at these sizes, and the common cases are 0 or 1 members, which this
236
+ * short-circuits on.
237
+ *
238
+ * **The `<MultiSelector>` sweep broke that assumption for large sets** (RFC
239
+ * §8): a live preview can put every visible bar in `hovered`, and a
240
+ * demoted-on-edit selection can hold as many mark entries — V visible bars ×
241
+ * C entries is quadratic-shaped (measured: 2.6k×2.6k ≈ 4.5 ms/frame, 10k×10k
242
+ * ≈ 64 ms). Past {@link MARK_INDEX_THRESHOLD} the draw builds
243
+ * {@link buildBarMarkIndex} once — the *same* match, O(1) per bar — and small
244
+ * sets keep this scan (cheaper than a Set at click sizes).
245
+ */
246
+ function barMatchesAny(sel, seriesId, stable, begin) {
247
+ for (let i = 0; i < sel.length; i += 1) {
248
+ if (barMatches(sel[i], seriesId, stable, begin))
249
+ return true;
250
+ }
251
+ return false;
252
+ }
253
+ /** Past this many entries, a per-draw set index beats the linear scan. Small
254
+ * enough that a sweep preview always indexes; big enough that a clicked
255
+ * handful never pays a Set build. */
256
+ const MARK_INDEX_THRESHOLD = 16;
257
+ /**
258
+ * Stroke a selected mark's outline **inside** its ink rect. Canvas strokes
259
+ * centre on the path, so a plain `strokeRect(x0, yTop, …)` paints
260
+ * `lineWidth / 2` *outside* the rect — which, with the default
261
+ * `outlineWidth: 1.5` against the default `gap: 1`, is enough to bridge the
262
+ * whole inter-bar gap from both sides: a swept run of selected bars fused
263
+ * into one unreadable block (you could not count them). Insetting the path by
264
+ * half the line width keeps every stroked pixel within the bar's own ink, so
265
+ * adjacent selected marks stay separated by exactly the gap the resting bars
266
+ * show. A mark too thin to contain its outline (either dimension
267
+ * `<= lineWidth`) skips the stroke — the highlight fill is already the
268
+ * signal, and an outline wider than the bar would only smear into the
269
+ * neighbours this exists to keep distinct.
270
+ */
271
+ export function strokeSelectedOutline(ctx, x0, x1, yTop, yBottom, lineWidth) {
272
+ const w = x1 - x0;
273
+ const h = yBottom - yTop;
274
+ if (w <= lineWidth || h <= lineWidth)
275
+ return;
276
+ const inset = lineWidth / 2;
277
+ ctx.lineWidth = lineWidth;
278
+ ctx.strokeRect(x0 + inset, yTop + inset, w - lineWidth, h - lineWidth);
279
+ }
280
+ function buildBarMarkIndex(sel, seriesId) {
281
+ const marks = new Set();
282
+ const keysMarkless = new Set();
283
+ const keysAll = new Set();
284
+ for (let i = 0; i < sel.length; i += 1) {
285
+ const m = sel[i];
286
+ if (m.id !== seriesId)
287
+ continue;
288
+ keysAll.add(m.key);
289
+ if (m.mark !== undefined)
290
+ marks.add(m.mark);
291
+ else
292
+ keysMarkless.add(m.key);
293
+ }
294
+ return { marks, keysMarkless, keysAll };
295
+ }
296
+ function indexMatches(ix, stable, begin) {
297
+ return stable !== undefined
298
+ ? ix.marks.has(stable) || ix.keysMarkless.has(begin)
299
+ : ix.keysAll.has(begin);
300
+ }
228
301
  /**
229
302
  * Fill one rectangle per bar in `cs`, each spanning its key's `[begin, end]`
230
303
  * (inset by `gapPx`) from the resolved `baseline` to the value.
@@ -277,7 +350,16 @@ function barMatches(m, seriesId, stable, begin) {
277
350
  * repaint them one flat colour; per-bar-coloured layers draw every visible bar.
278
351
  * Returns {@link LayerDrawStats} for `onDrawStats`.
279
352
  */
280
- export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered, decimate = true, binFills, banding) {
353
+ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered, decimate = true, binFills, banding,
354
+ // Span descriptors covering this layer (interaction RFC A5.2), already
355
+ // narrowed to its `id` (and constant-label `rows` resolved) by the component
356
+ // — see `spansForLayer`. A bar is selected when a mark entry names it OR a
357
+ // span covers it: an O(1)-per-bar half-open test of `begin` against the
358
+ // span's `x` (and the bar's value against `y` when present), so a span over
359
+ // ten thousand bars costs each one interval test, not a scan. Like the mark
360
+ // highlight, spans are ignored on the decimated envelope path (aggregate
361
+ // columns have no per-bar identity).
362
+ spans = NO_SPANS) {
281
363
  ctx.save();
282
364
  ctx.globalAlpha = style.opacity;
283
365
  const sourceCount = cs.length; // pre-cull, pre-decimation (for draw stats)
@@ -286,6 +368,12 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
286
368
  // the `begin[i]` selection/hover match stays correct; full range when `xScale`
287
369
  // has no domain (a test stub). A selected/hovered bar off-screen isn't drawn
288
370
  // (its highlight would be off-screen anyway).
371
+ // Dim only when a selection actually exists AND the theme opted in: with an
372
+ // empty set there is nothing to recede from, and with no `dimmed` value the
373
+ // library never invents one (RFC A2.3). A span is a selection too — a swept
374
+ // range recedes the bars outside it exactly as a clicked set does.
375
+ const hasSpans = spans.length > 0;
376
+ const dimming = style.dimmed !== undefined && (selection.length > 0 || hasSpans);
289
377
  const [vStart, vEnd] = visibleSpanRange(cs.begin, cs.end, cs.length, xScale);
290
378
  // Decimate the visible bars to per-column envelope rects once dense (see the
291
379
  // header). `null` below the visible-density threshold ⇒ the full per-bar loop.
@@ -328,12 +416,21 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
328
416
  // may already have: an interactive layer echoes the hovered bar's mark on
329
417
  // every pointer move. See BarSeries.marks — this hoist keeps the draw path
330
418
  // clean, it doesn't make the channel free.)
331
- const marks = selection?.mark !== undefined || hovered?.mark !== undefined
419
+ const marks = selection.some((m) => m.mark !== undefined) ||
420
+ hovered.some((m) => m.mark !== undefined)
332
421
  ? cs.marks
333
422
  : undefined;
423
+ // Large sets (a sweep preview in `hovered`, a demoted sweep in `selection`)
424
+ // switch to the O(1)-per-bar set index; a clicked handful keeps the scan.
425
+ const selIndex = selection.length > MARK_INDEX_THRESHOLD
426
+ ? buildBarMarkIndex(selection, seriesId)
427
+ : null;
428
+ const hovIndex = hovered.length > MARK_INDEX_THRESHOLD
429
+ ? buildBarMarkIndex(hovered, seriesId)
430
+ : null;
334
431
  let drawn = 0;
335
432
  for (let i = vStart; i < vEnd; i += 1) {
336
- const rect = barRect(cs, i, xScale, yScale, baseline, gapPx, style.minWidth);
433
+ const rect = barRect(cs, i, xScale, yScale, baseline, gapPx, style.minWidth, style.maxWidth);
337
434
  if (rect === null)
338
435
  continue;
339
436
  const [x0, x1, yTop, yBottom] = rect;
@@ -345,22 +442,28 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
345
442
  // sets one and `highlight` otherwise, always without the outline — so hover
346
443
  // reads as a lighter "this bar is live" and select as the committed pick.
347
444
  const stable = marks?.[i];
348
- const selected = barMatches(selection, seriesId, stable, cs.begin[i]);
349
- const isHovered = barMatches(hovered, seriesId, stable, cs.begin[i]);
445
+ const selected = (selIndex !== null
446
+ ? indexMatches(selIndex, stable, cs.begin[i])
447
+ : barMatchesAny(selection, seriesId, stable, cs.begin[i])) ||
448
+ (hasSpans && spanMatchesAny(spans, cs.begin[i], cs.y[i]));
449
+ const isHovered = hovIndex !== null
450
+ ? indexMatches(hovIndex, stable, cs.begin[i])
451
+ : barMatchesAny(hovered, seriesId, stable, cs.begin[i]);
350
452
  if (fills !== undefined) {
351
453
  // Per-bar fills: the bar keeps its own colour under hover / selection —
352
454
  // highlight pops the alpha to 1 and outlines the selection in the bar's
353
455
  // own fill (the drawStacks binFills convention; see the header).
354
- const fill = fills[i] ?? style.fill;
456
+ const fill = dimming && !selected && !isHovered
457
+ ? style.dimmed
458
+ : (fills[i] ?? style.fill);
355
459
  ctx.globalAlpha =
356
460
  selected || isHovered ? (style.emphasisOpacity ?? 1) : style.opacity;
357
461
  ctx.fillStyle = fill;
358
462
  ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
359
463
  drawn += 1;
360
464
  if (selected) {
361
- ctx.lineWidth = style.outlineWidth;
362
465
  ctx.strokeStyle = style.selectedOutline ?? fill;
363
- ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
466
+ strokeSelectedOutline(ctx, x0, x1, yTop, yBottom, style.outlineWidth);
364
467
  }
365
468
  continue;
366
469
  }
@@ -373,6 +476,15 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
373
476
  ctx.globalAlpha =
374
477
  selected || isHovered ? (style.emphasisOpacity ?? 1) : style.opacity;
375
478
  const v = cs.y[i];
479
+ if (dimming && !selected && !isHovered) {
480
+ // A dimmed banded bar draws flat: the ladder encodes *where the value
481
+ // sits*, which is exactly what a receded bar should stop competing
482
+ // over. Painting a dim gradient just lowers its voice.
483
+ ctx.fillStyle = style.dimmed;
484
+ ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
485
+ drawn += 1;
486
+ continue;
487
+ }
376
488
  let topFill = ladder.colors[0];
377
489
  for (let bk = 0; bk < ladder.colors.length; bk += 1) {
378
490
  if (!bandSpanInto(baseline, v, ladder.thresholds, bk))
@@ -390,9 +502,8 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
390
502
  // Outline the whole bar (not the last band) in the colour of the band
391
503
  // the value reached — the one colour that means something for a bar
392
504
  // painted in several.
393
- ctx.lineWidth = style.outlineWidth;
394
505
  ctx.strokeStyle = style.selectedOutline ?? topFill;
395
- ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
506
+ strokeSelectedOutline(ctx, x0, x1, yTop, yBottom, style.outlineWidth);
396
507
  }
397
508
  continue;
398
509
  }
@@ -407,11 +518,17 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
407
518
  // selected. Selection outranks hover on a bar that is both (as the outline
408
519
  // already did). With no `hover` colour this is the shipped two-step —
409
520
  // `highlight` for either state (see BarStyle.hover).
521
+ // Precedence, shared with `drawStacks`: selected > hovered > dimmed > rest.
522
+ // Hover outranks dim deliberately — dim says "this is not in your
523
+ // selection", hover says "your pointer is here now", and suppressing live
524
+ // pointer feedback to keep a bar receded makes the chart feel broken.
410
525
  ctx.fillStyle = selected
411
526
  ? style.highlight
412
527
  : isHovered
413
528
  ? (style.hover ?? style.highlight)
414
- : style.fill;
529
+ : dimming
530
+ ? style.dimmed
531
+ : style.fill;
415
532
  ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
416
533
  drawn += 1;
417
534
  if (selected) {
@@ -421,9 +538,8 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
421
538
  // fill, so only the half-stroke falling outside the rect reads. A theme
422
539
  // that needs the two states clearly apart sets `BarStyle.hover` (#577);
423
540
  // the outline is the shape cue, not the whole signal.
424
- ctx.lineWidth = style.outlineWidth;
425
541
  ctx.strokeStyle = style.selectedOutline ?? style.highlight;
426
- ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
542
+ strokeSelectedOutline(ctx, x0, x1, yTop, yBottom, style.outlineWidth);
427
543
  }
428
544
  }
429
545
  ctx.restore();
@@ -542,6 +658,29 @@ export function barAt(cs, px, py, xScale, yScale, baseline, minWidthPx) {
542
658
  }
543
659
  return null;
544
660
  }
661
+ function buildStackMarkIndex(sel, seriesId) {
662
+ const marks = new Set();
663
+ const labelsByKey = new Map();
664
+ for (let i = 0; i < sel.length; i += 1) {
665
+ const m = sel[i];
666
+ if (m.id !== seriesId)
667
+ continue;
668
+ if (m.mark !== undefined)
669
+ marks.add(m.mark);
670
+ let labels = labelsByKey.get(m.key);
671
+ if (labels === undefined) {
672
+ labels = new Set();
673
+ labelsByKey.set(m.key, labels);
674
+ }
675
+ labels.add(m.label);
676
+ }
677
+ return { marks, labelsByKey };
678
+ }
679
+ function stackIndexMatches(ix, stableMark, begin, group) {
680
+ return stableMark !== undefined
681
+ ? ix.marks.has(stableMark)
682
+ : (ix.labelsByKey.get(begin)?.has(group) ?? false);
683
+ }
545
684
  /**
546
685
  * The `[min, max]` extent of the **value (stacked) axis**. For a true multi-group
547
686
  * stack it is `[minNegTotal, maxPosTotal]` — each bin's positive segments summed
@@ -640,7 +779,7 @@ export function stackBase(orientation, xScale, yScale) {
640
779
  * is unfloored. Shared by {@link drawStacks} and {@link stackAt} so the drawn rect
641
780
  * and the hit rect are identical.
642
781
  */
643
- export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, gapPx, minSpanPx) {
782
+ export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, gapPx, minSpanPx, maxSpanPx) {
644
783
  const G = ss.groups.length;
645
784
  const v = ss.values[b * G + g];
646
785
  // Skip non-finite (a gap) or zero (a zero-extent rect that can't draw or be
@@ -662,12 +801,12 @@ export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, ga
662
801
  if (!Number.isFinite(v) || v === 0)
663
802
  return null;
664
803
  if (orientation === 'vertical') {
665
- const [x0, x1] = barSpanPx(ss.begin[b], ss.end[b], xScale, gapPx, minSpanPx);
804
+ const [x0, x1] = barSpanPx(ss.begin[b], ss.end[b], xScale, gapPx, minSpanPx, maxSpanPx);
666
805
  const yA = yScale(cumBefore);
667
806
  const yB = yScale(cumBefore + v);
668
807
  return [x0, x1, Math.min(yA, yB), Math.max(yA, yB)];
669
808
  }
670
- const [y0, y1] = barSpanPx(ss.begin[b], ss.end[b], yScale, gapPx, minSpanPx);
809
+ const [y0, y1] = barSpanPx(ss.begin[b], ss.end[b], yScale, gapPx, minSpanPx, maxSpanPx);
671
810
  const xA = xScale(cumBefore);
672
811
  const xB = xScale(cumBefore + v);
673
812
  return [Math.min(xA, xB), Math.max(xA, xB), y0, y1];
@@ -686,7 +825,15 @@ export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, ga
686
825
  *
687
826
  * O(N·G) over bins × groups, one fill (+ optional stroke) per drawn segment.
688
827
  */
689
- export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, minSpanPx, seriesId, selection, hover, banding) {
828
+ export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, minSpanPx, seriesId, selection, hover, banding,
829
+ // Span descriptors covering this layer (interaction RFC A5.2), already
830
+ // narrowed to its `id` by the component (`spansForLayer`). A segment is
831
+ // selected when a mark entry names it OR a span covers it — the O(1) test of
832
+ // the bin `begin` against the half-open `x` interval, the segment's own
833
+ // value against `y` when present, and its group against `rows` when present
834
+ // (the group is this layer's label channel, so `rows` stays testable per
835
+ // segment where `drawBars`' constant label lets the component resolve it).
836
+ spans = NO_SPANS) {
690
837
  const G = ss.groups.length;
691
838
  const base = stackBase(orientation, xScale, yScale);
692
839
  // Threshold banding applies to a **plain** bar only. `G === 1` is exactly the
@@ -695,6 +842,19 @@ export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, m
695
842
  // each segment is already a slice of a total — so the ladder is dropped here
696
843
  // and warned about at the prop boundary rather than half-applied.
697
844
  const ladder = G === 1 && style.binFills === undefined ? banding : undefined;
845
+ // See `drawBars` — dim only with a real selection (marks or spans) and an
846
+ // opted-in theme.
847
+ const hasSpans = spans.length > 0;
848
+ const dimming = (style.dimmed !== undefined || style.dimmedFills !== undefined) &&
849
+ (selection.length > 0 || hasSpans);
850
+ // The large-set switch (see barMatchesAny): a sweep preview / demoted sweep
851
+ // indexes once, a clicked handful keeps the linear scan.
852
+ const selIndex = selection.length > MARK_INDEX_THRESHOLD
853
+ ? buildStackMarkIndex(selection, seriesId)
854
+ : null;
855
+ const hovIndex = hover.length > MARK_INDEX_THRESHOLD
856
+ ? buildStackMarkIndex(hover, seriesId)
857
+ : null;
698
858
  ctx.save();
699
859
  ctx.globalAlpha = style.opacity;
700
860
  for (let b = 0; b < ss.length; b += 1) {
@@ -705,7 +865,7 @@ export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, m
705
865
  let cumNeg = base;
706
866
  for (let g = 0; g < G; g += 1) {
707
867
  const v = ss.values[b * G + g];
708
- const rect = segmentRect(ss, b, g, orientation, xScale, yScale, v < 0 ? cumNeg : cumPos, gapPx, minSpanPx);
868
+ const rect = segmentRect(ss, b, g, orientation, xScale, yScale, v < 0 ? cumNeg : cumPos, gapPx, minSpanPx, style.maxWidth);
709
869
  if (Number.isFinite(v)) {
710
870
  if (v > 0)
711
871
  cumPos += v;
@@ -724,8 +884,42 @@ export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, m
724
884
  (stableMark !== undefined
725
885
  ? m.mark === stableMark
726
886
  : m.key === ss.begin[b] && m.label === ss.groups[g]);
727
- const selected = matches(selection);
728
- const isHovered = matches(hover);
887
+ // Indexed rather than `.some(cb)` — this is the inner loop of the draw,
888
+ // and a closure per segment per frame is avoidable garbage.
889
+ let selected = false;
890
+ if (selIndex !== null) {
891
+ selected = stackIndexMatches(selIndex, stableMark, ss.begin[b], ss.groups[g]);
892
+ }
893
+ else {
894
+ for (let si = 0; si < selection.length; si += 1) {
895
+ if (matches(selection[si])) {
896
+ selected = true;
897
+ break;
898
+ }
899
+ }
900
+ }
901
+ // A span covers this segment when the bin's `begin` is inside its
902
+ // half-open `x` (and its `y` / `rows` channels pass — see the param doc).
903
+ // The label channel is what the hit reports: the stable per-bin `mark`
904
+ // (a categorical bar's category name) when the series carries marks,
905
+ // else the group — `hitTest` builds `label: stableMark ?? name`, and the
906
+ // span test must read the same field or `selectionContains` and the
907
+ // canvas disagree on every categorical bar.
908
+ if (!selected && hasSpans) {
909
+ selected = spanMatchesAny(spans, ss.begin[b], v, stableMark ?? ss.groups[g]);
910
+ }
911
+ let isHovered = false;
912
+ if (hovIndex !== null) {
913
+ isHovered = stackIndexMatches(hovIndex, stableMark, ss.begin[b], ss.groups[g]);
914
+ }
915
+ else {
916
+ for (let hi = 0; hi < hover.length; hi += 1) {
917
+ if (matches(hover[hi])) {
918
+ isHovered = true;
919
+ break;
920
+ }
921
+ }
922
+ }
729
923
  // A hovered / selected segment pops its alpha; a resting one draws at the
730
924
  // shared one. `emphasisOpacity` makes the *difference* themeable, where
731
925
  // before only the resting floor was.
@@ -738,6 +932,11 @@ export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, m
738
932
  // the bar's length at the ladder boundaries, transposing on
739
933
  // orientation — vertical bars band along y, horizontal along x, while
740
934
  // the bin span (the other axis) is shared by every band.
935
+ if (dimming && !selected && !isHovered) {
936
+ ctx.fillStyle = style.dimmedFills?.[g] ?? style.dimmed ?? fill;
937
+ ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
938
+ continue;
939
+ }
741
940
  let topFill = ladder.colors[0];
742
941
  for (let bk = 0; bk < ladder.colors.length; bk += 1) {
743
942
  if (!bandSpanInto(base, v, ladder.thresholds, bk))
@@ -758,11 +957,10 @@ export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, m
758
957
  topFill = ladder.colors[bk];
759
958
  }
760
959
  if (selected) {
761
- ctx.lineWidth = style.outlineWidth;
762
960
  // A themed outline if the theme sets one, else the band the value
763
961
  // reached — the one colour that means anything on a banded bar.
764
962
  ctx.strokeStyle = style.selectedOutline ?? topFill;
765
- ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
963
+ strokeSelectedOutline(ctx, x0, x1, yTop, yBottom, style.outlineWidth);
766
964
  }
767
965
  continue;
768
966
  }
@@ -771,19 +969,32 @@ export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, m
771
969
  // selected segment takes `highlight` and a hovered one `hover`, exactly
772
970
  // as the single-series path does. With `binFills` the bar keeps its own
773
971
  // colour (the design exclusion) and the alpha pop above is the signal.
774
- const emphasised = style.binFills === undefined
775
- ? selected
776
- ? (style.highlight ?? fill)
777
- : isHovered
778
- ? (style.hover ?? style.highlight ?? fill)
779
- : fill
780
- : fill;
972
+ // Same precedence as `drawBars`: selected > hovered > dimmed > rest.
973
+ const emphasised = dimming && !selected && !isHovered
974
+ ? (style.dimmedFills?.[g] ?? style.dimmed ?? fill)
975
+ : style.binFills !== undefined
976
+ ? fill
977
+ : selected
978
+ ? // A group ramp is meaning-carrying colour, so a selected
979
+ // segment keeps it: the outline and the receded neighbours are
980
+ // the cue. Same exclusion `binFills` gets, one line up.
981
+ style.groupColored
982
+ ? fill
983
+ : (style.highlight ?? fill)
984
+ : isHovered
985
+ ? // Per-group where the ramp supplies one, so hovering
986
+ // brightens the segment instead of recolouring it out of
987
+ // its group.
988
+ (style.hoverFills?.[g] ??
989
+ style.hover ??
990
+ style.highlight ??
991
+ fill)
992
+ : fill;
781
993
  ctx.fillStyle = emphasised;
782
994
  ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
783
995
  if (selected) {
784
- ctx.lineWidth = style.outlineWidth;
785
996
  ctx.strokeStyle = style.selectedOutline ?? emphasised;
786
- ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
997
+ strokeSelectedOutline(ctx, x0, x1, yTop, yBottom, style.outlineWidth);
787
998
  }
788
999
  }
789
1000
  }
@@ -800,7 +1011,11 @@ export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, m
800
1011
  * O(N·G) over bins × groups (no spatial index — histogram bin/group counts are
801
1012
  * small; click / hover are cheap events).
802
1013
  */
803
- export function stackAt(ss, px, py, orientation, xScale, yScale, gapPx, minSpanPx) {
1014
+ export function stackAt(ss, px, py, orientation, xScale, yScale, gapPx, minSpanPx,
1015
+ /** Must match the draw's cap ([PND-BARWIDTH]) — this function's whole
1016
+ * contract is that its rect is the drawn rect, so a cap applied to one and
1017
+ * not the other silently drifts the hit target off the ink. */
1018
+ maxSpanPx) {
804
1019
  const G = ss.groups.length;
805
1020
  const base = stackBase(orientation, xScale, yScale);
806
1021
  for (let b = 0; b < ss.length; b += 1) {
@@ -810,7 +1025,7 @@ export function stackAt(ss, px, py, orientation, xScale, yScale, gapPx, minSpanP
810
1025
  let cumNeg = base;
811
1026
  for (let g = 0; g < G; g += 1) {
812
1027
  const v = ss.values[b * G + g];
813
- const rect = segmentRect(ss, b, g, orientation, xScale, yScale, v < 0 ? cumNeg : cumPos, gapPx, minSpanPx);
1028
+ const rect = segmentRect(ss, b, g, orientation, xScale, yScale, v < 0 ? cumNeg : cumPos, gapPx, minSpanPx, maxSpanPx);
814
1029
  if (Number.isFinite(v)) {
815
1030
  if (v > 0)
816
1031
  cumPos += v;
package/dist/box.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { BoxSeries } from './data.js';
2
2
  import type { Scale } from './line.js';
3
3
  import type { BoxStyle } from './theme.js';
4
- import type { LayerDrawStats } from './context.js';
4
+ import type { LayerDrawStats, SpanSelection } from './context.js';
5
5
  import { type DecimateOption } from './decimate.js';
6
6
  /**
7
7
  * The `[min, max]` vertical extent of the **drawn** boxes — the lowest `lower`
@@ -79,7 +79,7 @@ export type BoxShape = 'whisker' | 'solid' | 'none';
79
79
  * O(N) over the keys, a fixed number of path ops each — no per-key allocation
80
80
  * beyond the `barSpanPx` tuple.
81
81
  */
82
- export declare function drawBox(ctx: CanvasRenderingContext2D, box: BoxSeries, xScale: Scale, yScale: Scale, style: BoxStyle, gapPx?: number, minWidthPx?: number, shape?: BoxShape, showMedian?: boolean, offsetPx?: number, capWidthPx?: number, selectedKey?: number | null, hoveredKey?: number | null, decimate?: DecimateOption): LayerDrawStats;
82
+ export declare function drawBox(ctx: CanvasRenderingContext2D, box: BoxSeries, xScale: Scale, yScale: Scale, style: BoxStyle, gapPx?: number, minWidthPx?: number, shape?: BoxShape, showMedian?: boolean, offsetPx?: number, capWidthPx?: number, selectedKeys?: readonly number[], hoveredKeys?: readonly number[], decimate?: DecimateOption, spans?: readonly SpanSelection[]): LayerDrawStats;
83
83
  /**
84
84
  * This key is drawable — the quantiles it actually carries are all finite at `i`.
85
85
  * `lower`/`upper` (the whisker reach) are always required; `q1`/`q3` only when the