@perspective-dev/viewer-charts 5.3.0 → 5.4.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 (92) hide show
  1. package/dist/cdn/perspective-viewer-charts.js +1 -2
  2. package/dist/cdn/perspective-viewer-charts.js.map +4 -4
  3. package/dist/esm/axis/legend.d.ts +53 -5
  4. package/dist/esm/axis/map-ticks.d.ts +15 -0
  5. package/dist/esm/charts/candlestick/candlestick-interact.d.ts +2 -6
  6. package/dist/esm/charts/cartesian/cartesian.d.ts +6 -0
  7. package/dist/esm/charts/cartesian/glyph.d.ts +2 -1
  8. package/dist/esm/charts/cartesian/glyphs/density.d.ts +2 -1
  9. package/dist/esm/charts/cartesian/glyphs/lines.d.ts +2 -1
  10. package/dist/esm/charts/cartesian/glyphs/points.d.ts +2 -1
  11. package/dist/esm/charts/cartesian/tooltip-lines.d.ts +1 -1
  12. package/dist/esm/charts/chart-base.d.ts +2 -0
  13. package/dist/esm/charts/chart.d.ts +77 -6
  14. package/dist/esm/charts/common/draw-tooltip-box.d.ts +2 -1
  15. package/dist/esm/charts/common/tree-interact.d.ts +1 -1
  16. package/dist/esm/charts/series/series-build.d.ts +7 -0
  17. package/dist/esm/charts/series/series-interact.d.ts +1 -1
  18. package/dist/esm/charts/series/series.d.ts +6 -0
  19. package/dist/esm/index.d.ts +2 -0
  20. package/dist/esm/interaction/axis-indicators.d.ts +11 -0
  21. package/dist/esm/interaction/host-sink-dom.d.ts +3 -2
  22. package/dist/esm/interaction/host-sink-message.d.ts +3 -2
  23. package/dist/esm/interaction/lazy-tooltip.d.ts +2 -2
  24. package/dist/esm/interaction/legend-controller.d.ts +246 -0
  25. package/dist/esm/interaction/tooltip-controller.d.ts +20 -95
  26. package/dist/esm/interaction/tooltip-grid.d.ts +33 -0
  27. package/dist/esm/layout/facet-grid.d.ts +6 -0
  28. package/dist/esm/layout/ticks.d.ts +1 -0
  29. package/dist/esm/map/tile-source.d.ts +77 -8
  30. package/dist/esm/perspective-viewer-charts.js +1 -2
  31. package/dist/esm/perspective-viewer-charts.js.map +4 -4
  32. package/dist/esm/plugin/charts.d.ts +2 -0
  33. package/dist/esm/plugin/format-defaults.d.ts +9 -0
  34. package/dist/esm/plugin/plugin.d.ts +20 -0
  35. package/dist/esm/theme/gradient.d.ts +1 -0
  36. package/dist/esm/transport/protocol.d.ts +45 -3
  37. package/dist/esm/transport/renderer-transport.d.ts +3 -0
  38. package/dist/esm/worker/renderer.worker.d.ts +19 -1
  39. package/package.json +6 -6
  40. package/src/css/perspective-viewer-charts.css +0 -1
  41. package/src/ts/axis/bar-axis.ts +2 -7
  42. package/src/ts/axis/legend.ts +351 -24
  43. package/src/ts/axis/map-ticks.ts +94 -0
  44. package/src/ts/axis/numeric-axis.ts +2 -9
  45. package/src/ts/charts/candlestick/candlestick-interact.ts +46 -40
  46. package/src/ts/charts/candlestick/candlestick-render.ts +73 -10
  47. package/src/ts/charts/cartesian/cartesian-interact.ts +9 -3
  48. package/src/ts/charts/cartesian/cartesian-render.ts +369 -75
  49. package/src/ts/charts/cartesian/cartesian.ts +9 -0
  50. package/src/ts/charts/cartesian/glyph.ts +6 -2
  51. package/src/ts/charts/cartesian/glyphs/density.ts +2 -2
  52. package/src/ts/charts/cartesian/glyphs/lines.ts +15 -11
  53. package/src/ts/charts/cartesian/glyphs/points.ts +9 -5
  54. package/src/ts/charts/cartesian/tooltip-lines.ts +8 -8
  55. package/src/ts/charts/chart-base.ts +34 -32
  56. package/src/ts/charts/chart.ts +106 -7
  57. package/src/ts/charts/common/draw-tooltip-box.ts +13 -30
  58. package/src/ts/charts/common/tree-chrome.ts +102 -23
  59. package/src/ts/charts/common/tree-interact.ts +18 -17
  60. package/src/ts/charts/heatmap/heatmap-interact.ts +65 -7
  61. package/src/ts/charts/heatmap/heatmap-render.ts +100 -24
  62. package/src/ts/charts/map/map.ts +3 -5
  63. package/src/ts/charts/series/series-build.ts +14 -2
  64. package/src/ts/charts/series/series-interact.ts +12 -11
  65. package/src/ts/charts/series/series-render.ts +305 -132
  66. package/src/ts/charts/series/series.ts +10 -0
  67. package/src/ts/charts/sunburst/sunburst-render.ts +7 -1
  68. package/src/ts/charts/treemap/treemap-render.ts +7 -1
  69. package/src/ts/data/view-reader.ts +1 -4
  70. package/src/ts/index.ts +2 -0
  71. package/src/ts/interaction/axis-indicators.ts +137 -0
  72. package/src/ts/interaction/host-sink-dom.ts +66 -2
  73. package/src/ts/interaction/host-sink-message.ts +8 -2
  74. package/src/ts/interaction/lazy-tooltip.ts +5 -5
  75. package/src/ts/interaction/legend-controller.ts +1084 -0
  76. package/src/ts/interaction/tooltip-controller.ts +43 -131
  77. package/src/ts/interaction/tooltip-grid.ts +187 -0
  78. package/src/ts/layout/facet-grid.ts +8 -1
  79. package/src/ts/layout/ticks.ts +12 -0
  80. package/src/ts/map/tile-loader.ts +16 -1
  81. package/src/ts/map/tile-source.ts +199 -53
  82. package/src/ts/map/tile-sources.json +18 -0
  83. package/src/ts/plugin/charts.ts +40 -6
  84. package/src/ts/plugin/format-defaults.ts +37 -0
  85. package/src/ts/plugin/plugin.ts +236 -67
  86. package/src/ts/shaders/line.vert.glsl +5 -2
  87. package/src/ts/shaders/scatter.vert.glsl +0 -7
  88. package/src/ts/theme/gradient.ts +24 -0
  89. package/src/ts/transport/protocol.ts +46 -2
  90. package/src/ts/transport/renderer-transport.ts +83 -4
  91. package/src/ts/worker/dispatch.ts +6 -2
  92. package/src/ts/worker/renderer.worker.ts +78 -2
@@ -23,6 +23,7 @@ import {
23
23
  rightAxisDataToPixel,
24
24
  layoutForRecord,
25
25
  } from "./series-render";
26
+ import { tooltipStyleOf } from "../../interaction/tooltip-grid";
26
27
 
27
28
  const POINT_HIT_RADIUS_PX = 10;
28
29
 
@@ -598,26 +599,26 @@ export function handleBarLegendClick(
598
599
  export function buildBarTooltipLines(
599
600
  chart: SeriesChart,
600
601
  b: SeriesChartRecord,
601
- ): string[] {
602
- const lines: string[] = [];
602
+ ): string[][] {
603
+ const grid: string[][] = [];
603
604
  const s = chart._series[b.seriesId];
604
605
  const categoryPath = formatBarCategoryPath(chart, b.catIdx);
605
606
  if (categoryPath) {
606
- lines.push(categoryPath);
607
+ grid.push([categoryPath]);
607
608
  }
608
609
 
609
610
  const yFmt = chart.getColumnFormatter(s.aggName, "value");
610
- lines.push(`${s.aggName}: ${yFmt(b.value)}`);
611
+ grid.push([s.aggName, yFmt(b.value)]);
611
612
  if (s.splitKey) {
612
- lines.push(`Split: ${s.splitKey}`);
613
+ grid.push(["Split", s.splitKey]);
613
614
  }
614
615
 
615
616
  if (b.y0 !== 0) {
616
- lines.push(`Base: ${yFmt(b.y0)}`);
617
- lines.push(`Top: ${yFmt(b.y1)}`);
617
+ grid.push(["Base", yFmt(b.y0)]);
618
+ grid.push(["Top", yFmt(b.y1)]);
618
619
  }
619
620
 
620
- return lines;
621
+ return grid;
621
622
  }
622
623
 
623
624
  /**
@@ -705,12 +706,12 @@ function pinTooltip(chart: SeriesChart, b: SeriesChartRecord): void {
705
706
  : layout.dataToPixel(b.xCenter, anchorV)
706
707
  : rightAxisDataToPixel(chart, b.xCenter, anchorV, layout);
707
708
 
708
- const lines = buildBarTooltipLines(chart, b);
709
- if (lines.length === 0) {
709
+ const grid = buildBarTooltipLines(chart, b);
710
+ if (grid.length === 0) {
710
711
  return;
711
712
  }
712
713
 
713
- chart._tooltip.pin(lines, pos, layout);
714
+ chart._tooltip.pin(grid, pos, layout, tooltipStyleOf(chart._pluginConfig));
714
715
 
715
716
  chart._hoveredBarIdx = -1;
716
717
  chart._hoveredSample = null;
@@ -26,9 +26,14 @@ import {
26
26
  withScissor,
27
27
  } from "../../webgl/plot-frame";
28
28
  import { renderCanvasTooltip } from "../../interaction/tooltip-controller";
29
+ import { tooltipStyleOf } from "../../interaction/tooltip-grid";
30
+ import {
31
+ renderAxisHoverIndicators,
32
+ type AxisIndicator,
33
+ } from "../../interaction/axis-indicators";
29
34
  import { drawBars, BAR_TYPE_BAR_VAL as BAR_TYPE_BAR } from "./glyphs/draw-bars";
30
- import { getHoveredBar } from "./series-interact";
31
- import { computeNiceTicks } from "../../layout/ticks";
35
+ import { getHoveredBar, formatBarCategoryPath } from "./series-interact";
36
+ import { computeNiceTicks, stepTickFormatter } from "../../layout/ticks";
32
37
  import {
33
38
  renderOuterXAxis,
34
39
  renderOuterYAxis,
@@ -57,6 +62,19 @@ import { drawFacetTitle } from "../../axis/facet-chrome";
57
62
  import { getScaledContext, initCanvas } from "../../axis/canvas";
58
63
  import { drawGridlinesX, drawGridlinesY } from "../../axis/axis-primitives";
59
64
  import { buildBarTooltipLines } from "./series-interact";
65
+ import {
66
+ LEGEND_LINE_HEIGHT,
67
+ legendAutoFit,
68
+ paintFloatingLegendFrame,
69
+ paintLegendScrollbar,
70
+ truncateText,
71
+ type LegendPaintView,
72
+ } from "../../axis/legend";
73
+ import {
74
+ legendRightGutter,
75
+ resolveLegendMode,
76
+ legendSidebarWidth,
77
+ } from "../../interaction/legend-controller";
60
78
 
61
79
  /**
62
80
  * Reusable scratch for bar instance uploads.
@@ -529,7 +547,12 @@ export function renderBarFrame(
529
547
  ? 55
530
548
  : measureCategoricalAxisWidth(provisionalDomain);
531
549
  const estLeft = leftExtra + (hasCatLabel ? 16 : 0);
532
- const estRight = hasLegend ? 80 : 16;
550
+ const estRight = legendRightGutter(
551
+ chart._pluginConfig,
552
+ hasLegend,
553
+ 80,
554
+ chart._series.length,
555
+ );
533
556
  const estPlotWidthH = Math.max(1, cssWidth - estLeft - estRight);
534
557
  const bottomExtra = valueCatActive
535
558
  ? measureCategoricalAxisHeight(valueCatDomain, estPlotWidthH)
@@ -540,6 +563,7 @@ export function renderBarFrame(
540
563
  hasLegend,
541
564
  leftExtra,
542
565
  bottomExtra,
566
+ rightExtra: estRight,
543
567
  });
544
568
  } else if (numericCat) {
545
569
  // Y Bar with numeric category axis on X. Value axis (Y, left)
@@ -553,6 +577,12 @@ export function renderBarFrame(
553
577
  hasLegend,
554
578
  bottomExtra: 24,
555
579
  leftExtra,
580
+ rightExtra: legendRightGutter(
581
+ chart._pluginConfig,
582
+ hasLegend,
583
+ 80,
584
+ chart._series.length,
585
+ ),
556
586
  });
557
587
  } else {
558
588
  // Y Bar with categorical X. Value axis on the left may be
@@ -561,7 +591,12 @@ export function renderBarFrame(
561
591
  ? measureCategoricalAxisWidth(valueCatDomain)
562
592
  : 55;
563
593
  const estLeft = leftExtraBase + 16;
564
- const estRight = hasLegend ? 80 : 16;
594
+ const estRight = legendRightGutter(
595
+ chart._pluginConfig,
596
+ hasLegend,
597
+ 80,
598
+ chart._series.length,
599
+ );
565
600
  const estPlotWidth = Math.max(1, cssWidth - estLeft - estRight);
566
601
  const bottomExtra = measureCategoricalAxisHeight(
567
602
  provisionalDomain,
@@ -573,6 +608,7 @@ export function renderBarFrame(
573
608
  hasLegend,
574
609
  bottomExtra,
575
610
  leftExtra: valueCatActive ? leftExtraBase : undefined,
611
+ rightExtra: estRight,
576
612
  });
577
613
  }
578
614
 
@@ -898,7 +934,11 @@ function renderFacetedBarFrame(
898
934
  cssHeight,
899
935
  xAxis: horizontal ? valAxisMode : catAxisMode,
900
936
  yAxis: horizontal ? catAxisMode : valAxisMode,
901
- hasLegend,
937
+ hasLegend:
938
+ hasLegend &&
939
+ resolveLegendMode(chart._pluginConfig, chart._aggregates.length) ===
940
+ "sidebar",
941
+ legendWidth: legendSidebarWidth(chart._pluginConfig, 96),
902
942
  hasXLabel: horizontal ? true : hasCatLabel,
903
943
  hasYLabel: horizontal ? hasCatLabel : true,
904
944
  gap: chart._facetConfig.facet_padding,
@@ -1351,66 +1391,89 @@ function renderFacetedBarChromeOverlay(chart: SeriesChart): void {
1351
1391
  }
1352
1392
 
1353
1393
  /**
1354
- * Aggregate-level legend for the faceted frame, painted into the
1355
- * grid's shared right gutter. One entry per aggregate (facets absorb
1356
- * the split dimension; every split of an aggregate shares its color —
1357
- * see `ensurePalette`). A legend toggle targets the aggregate's full
1358
- * seriesId set, so hiding "Sales" hides it in every facet at once; an
1359
- * entry reads as hidden only when ALL of its series are hidden.
1394
+ * One toggleable legend row, resolved lazily — only rows inside the
1395
+ * visible scroll window are ever materialized.
1360
1396
  */
1361
- function renderFacetedBarLegend(chart: SeriesChart, grid: FacetGrid): void {
1362
- chart._legendRects = [];
1363
- if (!chart._chromeCanvas || !grid.legendRect) {
1364
- return;
1365
- }
1366
-
1367
- const M = chart._aggregates.length;
1368
- if (M <= 1) {
1369
- return;
1370
- }
1397
+ interface SeriesLegendEntry {
1398
+ label: string;
1399
+ color: [number, number, number];
1400
+ seriesIds: number[];
1401
+ hidden: boolean;
1402
+ }
1371
1403
 
1372
- const ctx = chart._chromeCanvas.getContext("2d") as Context2D | null;
1404
+ /**
1405
+ * Shared swatch-list painter for both series legends (per-series and
1406
+ * per-aggregate). Paints only the rows inside the scroll window,
1407
+ * clipped to the content rect, rebuilds `chart._legendRects` with the
1408
+ * visible rows' canvas-space rects (so toggle clicks stay correct
1409
+ * while scrolled), and reports the painted geometry to the chart's
1410
+ * `LegendController`.
1411
+ */
1412
+ function paintSeriesLegend(
1413
+ chart: SeriesChart,
1414
+ box: PlotRect,
1415
+ view: LegendPaintView,
1416
+ count: number,
1417
+ entryAt: (i: number) => SeriesLegendEntry,
1418
+ ): void {
1419
+ const ctx = chart._chromeCanvas!.getContext("2d") as Context2D | null;
1373
1420
  if (!ctx) {
1374
1421
  return;
1375
1422
  }
1376
1423
 
1377
1424
  ctx.save();
1378
-
1379
1425
  const theme = chart._resolveTheme();
1426
+ let content = box;
1427
+ if (view.mode === "floating") {
1428
+ content = paintFloatingLegendFrame(
1429
+ ctx,
1430
+ box,
1431
+ theme,
1432
+ view.title,
1433
+ view.opacity,
1434
+ );
1435
+ }
1436
+
1380
1437
  const swatchSize = 10;
1381
- const lineHeight = 18;
1382
- const x = grid.legendRect.x + 12;
1383
- let y = grid.legendRect.y + 10;
1438
+ const lineHeight = LEGEND_LINE_HEIGHT;
1439
+ const contentHeight = count * lineHeight;
1440
+ const scroll = chart._legend.clampScroll(content.height, contentHeight);
1441
+ const scrollable = contentHeight > content.height;
1442
+ const textMax = Math.max(
1443
+ 0,
1444
+ content.width - swatchSize - 6 - (scrollable ? 10 : 0),
1445
+ );
1384
1446
 
1447
+ ctx.beginPath();
1448
+ ctx.rect(content.x, content.y, content.width, content.height);
1449
+ ctx.clip();
1385
1450
  ctx.font = `11px ${theme.fontFamily}`;
1386
1451
  ctx.textAlign = "left";
1387
1452
  ctx.textBaseline = "middle";
1388
1453
 
1389
- for (let k = 0; k < M; k++) {
1390
- const seriesIds: number[] = [];
1391
- let color: [number, number, number] = [0.5, 0.5, 0.5];
1392
- for (const s of chart._series) {
1393
- if (s.aggIdx === k) {
1394
- seriesIds.push(s.seriesId);
1395
- color = s.color;
1396
- }
1397
- }
1398
-
1399
- const label = chart._aggregates[k];
1400
- const hidden = seriesIds.every((sid) => chart._hiddenSeries.has(sid));
1401
- const r = Math.round(color[0] * 255);
1402
- const g = Math.round(color[1] * 255);
1403
- const b = Math.round(color[2] * 255);
1454
+ const x = content.x;
1455
+ const start = Math.floor(scroll / lineHeight);
1456
+ let y = content.y + lineHeight / 2 + start * lineHeight - scroll;
1457
+ for (
1458
+ let i = start;
1459
+ i < count && y - lineHeight / 2 < content.y + content.height;
1460
+ i++
1461
+ ) {
1462
+ const e = entryAt(i);
1463
+ const r = Math.round(e.color[0] * 255);
1464
+ const g = Math.round(e.color[1] * 255);
1465
+ const b = Math.round(e.color[2] * 255);
1404
1466
 
1405
- ctx.globalAlpha = hidden ? 0.3 : 1.0;
1467
+ ctx.globalAlpha = e.hidden ? 0.3 : 1.0;
1406
1468
  ctx.fillStyle = `rgb(${r},${g},${b})`;
1407
1469
  ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize);
1408
1470
 
1471
+ const shown = truncateText(ctx, e.label, textMax);
1472
+ const textW = ctx.measureText(shown).width;
1409
1473
  ctx.fillStyle = theme.legendText;
1410
- ctx.fillText(label, x + swatchSize + 6, y);
1474
+ ctx.fillText(shown, x + swatchSize + 6, y);
1411
1475
 
1412
- const textW = ctx.measureText(label).width;
1413
- if (hidden) {
1476
+ if (e.hidden) {
1414
1477
  ctx.strokeStyle = theme.legendText;
1415
1478
  ctx.lineWidth = 1;
1416
1479
  ctx.beginPath();
@@ -1422,11 +1485,14 @@ function renderFacetedBarLegend(chart: SeriesChart, grid: FacetGrid): void {
1422
1485
  ctx.globalAlpha = 1.0;
1423
1486
 
1424
1487
  chart._legendRects.push({
1425
- seriesIds,
1488
+ seriesIds: e.seriesIds,
1426
1489
  rect: {
1427
1490
  x: x - 2,
1428
1491
  y: y - lineHeight / 2,
1429
- width: swatchSize + 6 + textW + 4,
1492
+ width: Math.min(
1493
+ swatchSize + 6 + textW + 4,
1494
+ Math.max(1, content.width),
1495
+ ),
1430
1496
  height: lineHeight,
1431
1497
  },
1432
1498
  });
@@ -1435,38 +1501,95 @@ function renderFacetedBarLegend(chart: SeriesChart, grid: FacetGrid): void {
1435
1501
  }
1436
1502
 
1437
1503
  ctx.restore();
1504
+ paintLegendScrollbar(ctx, content, scroll, contentHeight, theme);
1505
+
1506
+ chart._legend.setPainted({
1507
+ mode: view.mode,
1508
+ box,
1509
+ content,
1510
+ contentHeight,
1511
+ sidebarGutter: view.sidebarGutter,
1512
+ });
1438
1513
  }
1439
1514
 
1440
1515
  /**
1441
- * Cached parallel array of measured legend text widths. The legend
1442
- * renderer reads from this each frame instead of re-running
1443
- * `ctx.measureText` per series; the widths only change on series-set
1444
- * or theme change. `_legendCacheValid` gates rebuild.
1516
+ * Aggregate-level legend for the faceted frame, painted into the
1517
+ * grid's shared right gutter (or as a floating panel). One entry per
1518
+ * aggregate (facets absorb the split dimension; every split of an
1519
+ * aggregate shares its color — see `ensurePalette`). A legend toggle
1520
+ * targets the aggregate's full seriesId set, so hiding "Sales" hides
1521
+ * it in every facet at once; an entry reads as hidden only when ALL of
1522
+ * its series are hidden.
1445
1523
  */
1446
- let _legendTextWidths: Float64Array = new Float64Array(0);
1447
-
1448
- function ensureLegendLayout(
1449
- chart: SeriesChart,
1450
- ctx: Context2D,
1451
- fontFamily: string,
1452
- ): void {
1453
- if (chart._legendCacheValid) {
1524
+ function renderFacetedBarLegend(chart: SeriesChart, grid: FacetGrid): void {
1525
+ chart._legendRects = [];
1526
+ if (!chart._chromeCanvas || !chart._lastLayout) {
1454
1527
  return;
1455
1528
  }
1456
1529
 
1457
- const series = chart._series;
1458
- if (_legendTextWidths.length < series.length) {
1459
- _legendTextWidths = new Float64Array(series.length);
1530
+ const cfg = chart._pluginConfig;
1531
+ const M = chart._aggregates.length;
1532
+ const mode = resolveLegendMode(cfg, M);
1533
+ const floating = mode === "floating";
1534
+ if (M <= 1 || mode === "none" || (!floating && !grid.legendRect)) {
1535
+ chart._legend.clearPainted();
1536
+ return;
1460
1537
  }
1461
1538
 
1462
- ctx.save();
1463
- ctx.font = `11px ${fontFamily}`;
1464
- for (let i = 0; i < series.length; i++) {
1465
- _legendTextWidths[i] = ctx.measureText(series[i].label).width;
1539
+ // Pre-bucket series by aggregate once (O(series)) so the per-row
1540
+ // resolver is O(1) — the windowed painter may touch only a few of
1541
+ // potentially many rows.
1542
+ const idsByAgg: number[][] = Array.from({ length: M }, () => []);
1543
+ const colorByAgg: [number, number, number][] = Array.from(
1544
+ { length: M },
1545
+ () => [0.5, 0.5, 0.5],
1546
+ );
1547
+ for (const s of chart._series) {
1548
+ if (s.aggIdx >= 0 && s.aggIdx < M) {
1549
+ idsByAgg[s.aggIdx].push(s.seriesId);
1550
+ colorByAgg[s.aggIdx] = s.color;
1551
+ }
1466
1552
  }
1467
1553
 
1468
- ctx.restore();
1469
- chart._legendCacheValid = true;
1554
+ const layout = chart._lastLayout;
1555
+ const box = floating
1556
+ ? chart._legend.floatingBox(
1557
+ cfg,
1558
+ layout.cssWidth,
1559
+ layout.cssHeight,
1560
+ legendAutoFit(
1561
+ chart._chromeCanvas,
1562
+ chart._resolveTheme(),
1563
+ M,
1564
+ () => chart._aggregates.slice(0, M),
1565
+ { title: "Legend" },
1566
+ ),
1567
+ )
1568
+ : {
1569
+ x: grid.legendRect!.x + 12,
1570
+ y: grid.legendRect!.y + 10,
1571
+ width: Math.max(1, grid.legendRect!.width - 16),
1572
+ height: Math.max(1, grid.legendRect!.height - 20),
1573
+ };
1574
+
1575
+ paintSeriesLegend(
1576
+ chart,
1577
+ box,
1578
+ {
1579
+ mode: floating ? "floating" : "sidebar",
1580
+ legend: chart._legend,
1581
+ title: "Legend",
1582
+ sidebarGutter: floating ? undefined : grid.legendRect!.width,
1583
+ opacity: cfg.legend_opacity,
1584
+ },
1585
+ M,
1586
+ (k) => ({
1587
+ label: chart._aggregates[k],
1588
+ color: colorByAgg[k],
1589
+ seriesIds: idsByAgg[k],
1590
+ hidden: idsByAgg[k].every((sid) => chart._hiddenSeries.has(sid)),
1591
+ }),
1592
+ );
1470
1593
  }
1471
1594
 
1472
1595
  function renderBarLegend(chart: SeriesChart): void {
@@ -1475,73 +1598,65 @@ function renderBarLegend(chart: SeriesChart): void {
1475
1598
  return;
1476
1599
  }
1477
1600
 
1478
- if (chart._series.length <= 1) {
1479
- return;
1480
- }
1481
-
1482
- const ctx = chart._chromeCanvas.getContext("2d") as Context2D | null;
1483
- if (!ctx) {
1601
+ const cfg = chart._pluginConfig;
1602
+ const series = chart._series;
1603
+ const mode = resolveLegendMode(cfg, series.length);
1604
+ if (series.length <= 1 || mode === "none") {
1605
+ chart._legend.clearPainted();
1484
1606
  return;
1485
1607
  }
1486
1608
 
1487
- ctx.save();
1488
-
1489
- const theme = chart._resolveTheme();
1490
- const textColor = theme.legendText;
1491
- const fontFamily = theme.fontFamily;
1492
-
1493
- ensureLegendLayout(chart, ctx, fontFamily);
1494
-
1495
1609
  const layout = chart._lastLayout;
1496
- const swatchSize = 10;
1497
- const lineHeight = 18;
1498
- const x = layout.plotRect.x + layout.plotRect.width + 12;
1499
- let y = layout.margins.top + 10;
1500
-
1501
- ctx.font = `11px ${fontFamily}`;
1502
- ctx.textAlign = "left";
1503
- ctx.textBaseline = "middle";
1504
-
1505
- const series = chart._series;
1506
- const widths = _legendTextWidths;
1507
- for (let i = 0; i < series.length; i++) {
1508
- const s = series[i];
1509
- const hidden = chart._hiddenSeries.has(s.seriesId);
1510
- const r = Math.round(s.color[0] * 255);
1511
- const g = Math.round(s.color[1] * 255);
1512
- const b = Math.round(s.color[2] * 255);
1513
-
1514
- ctx.globalAlpha = hidden ? 0.3 : 1.0;
1515
- ctx.fillStyle = `rgb(${r},${g},${b})`;
1516
- ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize);
1517
-
1518
- ctx.fillStyle = textColor;
1519
- ctx.fillText(s.label, x + swatchSize + 6, y);
1520
-
1521
- const textW = widths[i];
1522
- if (hidden) {
1523
- ctx.strokeStyle = textColor;
1524
- ctx.lineWidth = 1;
1525
- ctx.beginPath();
1526
- ctx.moveTo(x + swatchSize + 6, y);
1527
- ctx.lineTo(x + swatchSize + 6 + textW, y);
1528
- ctx.stroke();
1529
- }
1530
-
1531
- ctx.globalAlpha = 1.0;
1532
-
1533
- const rect: PlotRect = {
1534
- x: x - 2,
1535
- y: y - lineHeight / 2,
1536
- width: swatchSize + 6 + textW + 4,
1537
- height: lineHeight,
1538
- };
1539
- chart._legendRects.push({ seriesIds: [s.seriesId], rect });
1540
-
1541
- y += lineHeight;
1542
- }
1543
-
1544
- ctx.restore();
1610
+ const floating = mode === "floating";
1611
+ const title = chart._splitBy.join(" / ") || "Legend";
1612
+ const box = floating
1613
+ ? chart._legend.floatingBox(
1614
+ cfg,
1615
+ layout.cssWidth,
1616
+ layout.cssHeight,
1617
+ legendAutoFit(
1618
+ chart._chromeCanvas,
1619
+ chart._resolveTheme(),
1620
+ series.length,
1621
+ function* () {
1622
+ for (const s of series) {
1623
+ yield s.label;
1624
+ }
1625
+ },
1626
+ { title },
1627
+ ),
1628
+ )
1629
+ : {
1630
+ x: layout.plotRect.x + layout.plotRect.width + 12,
1631
+ y: layout.margins.top + 10,
1632
+ width: Math.max(
1633
+ 1,
1634
+ layout.cssWidth -
1635
+ layout.plotRect.x -
1636
+ layout.plotRect.width -
1637
+ 16,
1638
+ ),
1639
+ height: Math.max(1, layout.plotRect.height - 10),
1640
+ };
1641
+
1642
+ paintSeriesLegend(
1643
+ chart,
1644
+ box,
1645
+ {
1646
+ mode: floating ? "floating" : "sidebar",
1647
+ legend: chart._legend,
1648
+ title,
1649
+ sidebarGutter: floating ? undefined : layout.margins.right,
1650
+ opacity: cfg.legend_opacity,
1651
+ },
1652
+ series.length,
1653
+ (i) => ({
1654
+ label: series[i].label,
1655
+ color: series[i].color,
1656
+ seriesIds: [series[i].seriesId],
1657
+ hidden: chart._hiddenSeries.has(series[i].seriesId),
1658
+ }),
1659
+ );
1545
1660
  }
1546
1661
 
1547
1662
  function renderBarTooltipCanvas(chart: SeriesChart): void {
@@ -1580,13 +1695,71 @@ function renderBarTooltipCanvas(chart: SeriesChart): void {
1580
1695
 
1581
1696
  const lines = buildBarTooltipLines(chart, b);
1582
1697
  const theme = chart._resolveTheme();
1698
+ const dpr = chart._glManager?.dpr ?? 1;
1699
+
1700
+ const datumPos =
1701
+ b.axis === 0
1702
+ ? chart._isHorizontal
1703
+ ? layout.dataToPixel(b.y1, b.xCenter)
1704
+ : layout.dataToPixel(b.xCenter, b.y1)
1705
+ : rightAxisDataToPixel(chart, b.xCenter, b.y1, layout);
1706
+ const indicators: AxisIndicator[] = [];
1707
+
1708
+ let categoryText: string;
1709
+ if (
1710
+ chart._categoryAxisMode === "numeric" &&
1711
+ chart._categoryPositions &&
1712
+ chart._categoryPositions[b.catIdx] != null
1713
+ ) {
1714
+ const catFmt =
1715
+ chart.getColumnFormatter(chart._groupBy[0], "tick") ??
1716
+ stepTickFormatter(
1717
+ chart._numericCategoryDomain?.isDate,
1718
+ chart._lastCatTicks,
1719
+ );
1720
+ categoryText = catFmt(chart._categoryPositions[b.catIdx]);
1721
+ } else {
1722
+ categoryText = formatBarCategoryPath(chart, b.catIdx);
1723
+ }
1724
+
1725
+ if (categoryText !== "") {
1726
+ indicators.push({
1727
+ side: chart._isHorizontal ? "left" : "bottom",
1728
+ px: datumPos.px,
1729
+ py: datumPos.py,
1730
+ text: categoryText,
1731
+ });
1732
+ }
1733
+
1734
+ const valueDomain =
1735
+ b.axis === 1 ? chart._lastAltYDomain : chart._lastYDomain;
1736
+ const valueTicks = b.axis === 1 ? chart._lastAltYTicks : chart._lastYTicks;
1737
+ const valueFmt =
1738
+ chart.getColumnFormatter(chart._series[b.seriesId]?.aggName, "tick") ??
1739
+ stepTickFormatter(valueDomain?.isDate, valueTicks);
1740
+ indicators.push({
1741
+ side: chart._isHorizontal ? "bottom" : b.axis === 1 ? "right" : "left",
1742
+ px: datumPos.px,
1743
+ py: datumPos.py,
1744
+ text: valueFmt(b.y1),
1745
+ });
1746
+ renderAxisHoverIndicators(
1747
+ chart._chromeCanvas,
1748
+ layout,
1749
+ theme,
1750
+ dpr,
1751
+ indicators,
1752
+ chart._pluginConfig.tooltip_opacity,
1753
+ );
1754
+
1583
1755
  renderCanvasTooltip(
1584
1756
  chart._chromeCanvas,
1585
1757
  pos,
1586
1758
  lines,
1587
1759
  layout,
1588
1760
  theme,
1589
- chart._glManager?.dpr ?? 1,
1761
+ dpr,
1762
+ tooltipStyleOf(chart._pluginConfig),
1590
1763
  );
1591
1764
  }
1592
1765
 
@@ -30,6 +30,7 @@ import {
30
30
  } from "./series-build";
31
31
  import {
32
32
  renderBarFrame,
33
+ renderBarChromeOverlay,
33
34
  uploadBarInstances,
34
35
  invalidateGlyphBuffers,
35
36
  rebuildGlyphBuffers,
@@ -126,6 +127,15 @@ export class SeriesChart extends CategoricalYChart {
126
127
  return { lockAxis: this._isHorizontal ? "x" : "y" };
127
128
  }
128
129
 
130
+ /**
131
+ * Chrome-only repaint for legend scroll / floating-legend drag —
132
+ * the same lightweight path hover updates use. No GL pass; the
133
+ * composite re-presents over the retained plot bitmap.
134
+ */
135
+ repaintChrome(): void {
136
+ renderBarChromeOverlay(this);
137
+ }
138
+
129
139
  _locations: CachedLocations | null = null;
130
140
 
131
141
  // Series-specific categorical-axis bookkeeping. `_rowPaths`,
@@ -28,6 +28,7 @@ import {
28
28
  INNER_RING_PX,
29
29
  } from "./sunburst-layout";
30
30
  import { buildFacetGrid } from "../../layout/facet-grid";
31
+ import { legendTreeGutter } from "../../interaction/legend-controller";
31
32
  import { withChromeCache } from "../common/chrome-cache";
32
33
  import {
33
34
  renderBreadcrumbs as renderTreeBreadcrumbs,
@@ -106,7 +107,12 @@ export function renderSunburstFrame(
106
107
  chart._colorMin < chart._colorMax;
107
108
  const breadcrumbH =
108
109
  !hasSplits && chart._breadcrumbIds.length > 1 ? BREADCRUMB_H : 0;
109
- const legendW = hasLegend ? LEGEND_W : 0;
110
+ const legendW = legendTreeGutter(
111
+ chart._pluginConfig,
112
+ hasLegend,
113
+ LEGEND_W,
114
+ chart._colorMode === "series" ? chart._uniqueColorLabels.size : 0,
115
+ );
110
116
 
111
117
  if (hasSplits) {
112
118
  layoutFacetedSunburst(chart, cssWidth, cssHeight, legendW);