@perspective-dev/viewer-charts 5.2.0 → 5.3.1

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 (109) 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/candlestick/candlestick-render.d.ts +3 -0
  7. package/dist/esm/charts/cartesian/cartesian.d.ts +7 -0
  8. package/dist/esm/charts/cartesian/glyph.d.ts +2 -1
  9. package/dist/esm/charts/cartesian/glyphs/density.d.ts +2 -1
  10. package/dist/esm/charts/cartesian/glyphs/lines.d.ts +2 -1
  11. package/dist/esm/charts/cartesian/glyphs/points.d.ts +2 -1
  12. package/dist/esm/charts/cartesian/tooltip-lines.d.ts +1 -1
  13. package/dist/esm/charts/chart-base.d.ts +24 -9
  14. package/dist/esm/charts/chart.d.ts +81 -6
  15. package/dist/esm/charts/common/category-axis-resolver.d.ts +1 -1
  16. package/dist/esm/charts/common/draw-tooltip-box.d.ts +2 -1
  17. package/dist/esm/charts/common/tree-chart.d.ts +1 -0
  18. package/dist/esm/charts/common/tree-interact.d.ts +1 -1
  19. package/dist/esm/charts/heatmap/heatmap.d.ts +1 -0
  20. package/dist/esm/charts/series/series-build.d.ts +7 -0
  21. package/dist/esm/charts/series/series-interact.d.ts +1 -1
  22. package/dist/esm/charts/series/series-type.d.ts +18 -0
  23. package/dist/esm/charts/series/series.d.ts +10 -1
  24. package/dist/esm/charts/sunburst/sunburst-render.d.ts +4 -0
  25. package/dist/esm/index.d.ts +2 -0
  26. package/dist/esm/interaction/axis-indicators.d.ts +11 -0
  27. package/dist/esm/interaction/host-sink-dom.d.ts +3 -2
  28. package/dist/esm/interaction/host-sink-message.d.ts +3 -2
  29. package/dist/esm/interaction/lazy-tooltip.d.ts +2 -2
  30. package/dist/esm/interaction/legend-controller.d.ts +246 -0
  31. package/dist/esm/interaction/tooltip-controller.d.ts +20 -95
  32. package/dist/esm/interaction/tooltip-grid.d.ts +33 -0
  33. package/dist/esm/layout/facet-grid.d.ts +6 -0
  34. package/dist/esm/layout/ticks.d.ts +1 -0
  35. package/dist/esm/map/tile-source.d.ts +77 -8
  36. package/dist/esm/perspective-viewer-charts.js +1 -2
  37. package/dist/esm/perspective-viewer-charts.js.map +4 -4
  38. package/dist/esm/plugin/charts.d.ts +2 -0
  39. package/dist/esm/plugin/format-defaults.d.ts +9 -0
  40. package/dist/esm/plugin/plugin.d.ts +28 -1
  41. package/dist/esm/theme/gradient.d.ts +32 -0
  42. package/dist/esm/theme/overrides.d.ts +19 -0
  43. package/dist/esm/transport/protocol.d.ts +58 -5
  44. package/dist/esm/transport/renderer-transport.d.ts +3 -0
  45. package/dist/esm/utils/css.d.ts +4 -0
  46. package/dist/esm/worker/renderer.worker.d.ts +46 -1
  47. package/package.json +1 -1
  48. package/src/css/perspective-viewer-charts.css +0 -1
  49. package/src/ts/axis/bar-axis.ts +2 -7
  50. package/src/ts/axis/legend.ts +351 -24
  51. package/src/ts/axis/map-ticks.ts +94 -0
  52. package/src/ts/axis/numeric-axis.ts +2 -9
  53. package/src/ts/charts/candlestick/candlestick-build.ts +1 -0
  54. package/src/ts/charts/candlestick/candlestick-interact.ts +46 -40
  55. package/src/ts/charts/candlestick/candlestick-render.ts +81 -10
  56. package/src/ts/charts/candlestick/candlestick.ts +4 -3
  57. package/src/ts/charts/cartesian/cartesian-interact.ts +9 -3
  58. package/src/ts/charts/cartesian/cartesian-render.ts +374 -75
  59. package/src/ts/charts/cartesian/cartesian.ts +13 -0
  60. package/src/ts/charts/cartesian/glyph.ts +6 -2
  61. package/src/ts/charts/cartesian/glyphs/density.ts +2 -2
  62. package/src/ts/charts/cartesian/glyphs/lines.ts +15 -11
  63. package/src/ts/charts/cartesian/glyphs/points.ts +9 -5
  64. package/src/ts/charts/cartesian/tooltip-lines.ts +8 -8
  65. package/src/ts/charts/chart-base.ts +76 -42
  66. package/src/ts/charts/chart.ts +111 -7
  67. package/src/ts/charts/common/category-axis-resolver.ts +44 -5
  68. package/src/ts/charts/common/draw-tooltip-box.ts +13 -30
  69. package/src/ts/charts/common/tree-chart.ts +4 -0
  70. package/src/ts/charts/common/tree-chrome.ts +102 -23
  71. package/src/ts/charts/common/tree-interact.ts +18 -17
  72. package/src/ts/charts/heatmap/heatmap-build.ts +7 -1
  73. package/src/ts/charts/heatmap/heatmap-interact.ts +75 -8
  74. package/src/ts/charts/heatmap/heatmap-render.ts +105 -24
  75. package/src/ts/charts/heatmap/heatmap.ts +8 -3
  76. package/src/ts/charts/map/map.ts +3 -5
  77. package/src/ts/charts/series/series-build.ts +15 -2
  78. package/src/ts/charts/series/series-interact.ts +14 -24
  79. package/src/ts/charts/series/series-render.ts +310 -132
  80. package/src/ts/charts/series/series-type.ts +65 -0
  81. package/src/ts/charts/series/series.ts +31 -8
  82. package/src/ts/charts/sunburst/sunburst-render.ts +16 -1
  83. package/src/ts/charts/treemap/treemap-render.ts +12 -1
  84. package/src/ts/data/view-reader.ts +1 -4
  85. package/src/ts/index.ts +2 -0
  86. package/src/ts/interaction/axis-indicators.ts +137 -0
  87. package/src/ts/interaction/host-sink-dom.ts +66 -2
  88. package/src/ts/interaction/host-sink-message.ts +8 -2
  89. package/src/ts/interaction/lazy-tooltip.ts +5 -5
  90. package/src/ts/interaction/legend-controller.ts +1084 -0
  91. package/src/ts/interaction/tooltip-controller.ts +43 -131
  92. package/src/ts/interaction/tooltip-grid.ts +187 -0
  93. package/src/ts/layout/facet-grid.ts +8 -1
  94. package/src/ts/layout/ticks.ts +48 -12
  95. package/src/ts/map/tile-loader.ts +16 -1
  96. package/src/ts/map/tile-source.ts +199 -53
  97. package/src/ts/map/tile-sources.json +18 -0
  98. package/src/ts/plugin/charts.ts +40 -6
  99. package/src/ts/plugin/format-defaults.ts +37 -0
  100. package/src/ts/plugin/plugin.ts +330 -76
  101. package/src/ts/shaders/line.vert.glsl +5 -2
  102. package/src/ts/shaders/scatter.vert.glsl +0 -7
  103. package/src/ts/theme/gradient.ts +265 -1
  104. package/src/ts/theme/overrides.ts +76 -0
  105. package/src/ts/transport/protocol.ts +60 -4
  106. package/src/ts/transport/renderer-transport.ts +107 -14
  107. package/src/ts/utils/css.ts +12 -0
  108. package/src/ts/worker/dispatch.ts +6 -2
  109. package/src/ts/worker/renderer.worker.ts +191 -7
@@ -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,
@@ -1088,6 +1128,11 @@ export function layoutForRecord(
1088
1128
  * Draw axes chrome + legend + tooltip onto the overlay canvas.
1089
1129
  */
1090
1130
  export function renderBarChromeOverlay(chart: SeriesChart): void {
1131
+ paintBarChromeOverlay(chart);
1132
+ chart.presentOverlay();
1133
+ }
1134
+
1135
+ function paintBarChromeOverlay(chart: SeriesChart): void {
1091
1136
  if (
1092
1137
  !chart._chromeCanvas ||
1093
1138
  !chart._lastLayout ||
@@ -1346,66 +1391,89 @@ function renderFacetedBarChromeOverlay(chart: SeriesChart): void {
1346
1391
  }
1347
1392
 
1348
1393
  /**
1349
- * Aggregate-level legend for the faceted frame, painted into the
1350
- * grid's shared right gutter. One entry per aggregate (facets absorb
1351
- * the split dimension; every split of an aggregate shares its color —
1352
- * see `ensurePalette`). A legend toggle targets the aggregate's full
1353
- * seriesId set, so hiding "Sales" hides it in every facet at once; an
1354
- * 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.
1355
1396
  */
1356
- function renderFacetedBarLegend(chart: SeriesChart, grid: FacetGrid): void {
1357
- chart._legendRects = [];
1358
- if (!chart._chromeCanvas || !grid.legendRect) {
1359
- return;
1360
- }
1361
-
1362
- const M = chart._aggregates.length;
1363
- if (M <= 1) {
1364
- return;
1365
- }
1397
+ interface SeriesLegendEntry {
1398
+ label: string;
1399
+ color: [number, number, number];
1400
+ seriesIds: number[];
1401
+ hidden: boolean;
1402
+ }
1366
1403
 
1367
- 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;
1368
1420
  if (!ctx) {
1369
1421
  return;
1370
1422
  }
1371
1423
 
1372
1424
  ctx.save();
1373
-
1374
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
+
1375
1437
  const swatchSize = 10;
1376
- const lineHeight = 18;
1377
- const x = grid.legendRect.x + 12;
1378
- 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
+ );
1379
1446
 
1447
+ ctx.beginPath();
1448
+ ctx.rect(content.x, content.y, content.width, content.height);
1449
+ ctx.clip();
1380
1450
  ctx.font = `11px ${theme.fontFamily}`;
1381
1451
  ctx.textAlign = "left";
1382
1452
  ctx.textBaseline = "middle";
1383
1453
 
1384
- for (let k = 0; k < M; k++) {
1385
- const seriesIds: number[] = [];
1386
- let color: [number, number, number] = [0.5, 0.5, 0.5];
1387
- for (const s of chart._series) {
1388
- if (s.aggIdx === k) {
1389
- seriesIds.push(s.seriesId);
1390
- color = s.color;
1391
- }
1392
- }
1393
-
1394
- const label = chart._aggregates[k];
1395
- const hidden = seriesIds.every((sid) => chart._hiddenSeries.has(sid));
1396
- const r = Math.round(color[0] * 255);
1397
- const g = Math.round(color[1] * 255);
1398
- 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);
1399
1466
 
1400
- ctx.globalAlpha = hidden ? 0.3 : 1.0;
1467
+ ctx.globalAlpha = e.hidden ? 0.3 : 1.0;
1401
1468
  ctx.fillStyle = `rgb(${r},${g},${b})`;
1402
1469
  ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize);
1403
1470
 
1471
+ const shown = truncateText(ctx, e.label, textMax);
1472
+ const textW = ctx.measureText(shown).width;
1404
1473
  ctx.fillStyle = theme.legendText;
1405
- ctx.fillText(label, x + swatchSize + 6, y);
1474
+ ctx.fillText(shown, x + swatchSize + 6, y);
1406
1475
 
1407
- const textW = ctx.measureText(label).width;
1408
- if (hidden) {
1476
+ if (e.hidden) {
1409
1477
  ctx.strokeStyle = theme.legendText;
1410
1478
  ctx.lineWidth = 1;
1411
1479
  ctx.beginPath();
@@ -1417,11 +1485,14 @@ function renderFacetedBarLegend(chart: SeriesChart, grid: FacetGrid): void {
1417
1485
  ctx.globalAlpha = 1.0;
1418
1486
 
1419
1487
  chart._legendRects.push({
1420
- seriesIds,
1488
+ seriesIds: e.seriesIds,
1421
1489
  rect: {
1422
1490
  x: x - 2,
1423
1491
  y: y - lineHeight / 2,
1424
- width: swatchSize + 6 + textW + 4,
1492
+ width: Math.min(
1493
+ swatchSize + 6 + textW + 4,
1494
+ Math.max(1, content.width),
1495
+ ),
1425
1496
  height: lineHeight,
1426
1497
  },
1427
1498
  });
@@ -1430,38 +1501,95 @@ function renderFacetedBarLegend(chart: SeriesChart, grid: FacetGrid): void {
1430
1501
  }
1431
1502
 
1432
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
+ });
1433
1513
  }
1434
1514
 
1435
1515
  /**
1436
- * Cached parallel array of measured legend text widths. The legend
1437
- * renderer reads from this each frame instead of re-running
1438
- * `ctx.measureText` per series; the widths only change on series-set
1439
- * 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.
1440
1523
  */
1441
- let _legendTextWidths: Float64Array = new Float64Array(0);
1442
-
1443
- function ensureLegendLayout(
1444
- chart: SeriesChart,
1445
- ctx: Context2D,
1446
- fontFamily: string,
1447
- ): void {
1448
- if (chart._legendCacheValid) {
1524
+ function renderFacetedBarLegend(chart: SeriesChart, grid: FacetGrid): void {
1525
+ chart._legendRects = [];
1526
+ if (!chart._chromeCanvas || !chart._lastLayout) {
1449
1527
  return;
1450
1528
  }
1451
1529
 
1452
- const series = chart._series;
1453
- if (_legendTextWidths.length < series.length) {
1454
- _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;
1455
1537
  }
1456
1538
 
1457
- ctx.save();
1458
- ctx.font = `11px ${fontFamily}`;
1459
- for (let i = 0; i < series.length; i++) {
1460
- _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
+ }
1461
1552
  }
1462
1553
 
1463
- ctx.restore();
1464
- 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
+ );
1465
1593
  }
1466
1594
 
1467
1595
  function renderBarLegend(chart: SeriesChart): void {
@@ -1470,73 +1598,65 @@ function renderBarLegend(chart: SeriesChart): void {
1470
1598
  return;
1471
1599
  }
1472
1600
 
1473
- if (chart._series.length <= 1) {
1474
- return;
1475
- }
1476
-
1477
- const ctx = chart._chromeCanvas.getContext("2d") as Context2D | null;
1478
- 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();
1479
1606
  return;
1480
1607
  }
1481
1608
 
1482
- ctx.save();
1483
-
1484
- const theme = chart._resolveTheme();
1485
- const textColor = theme.legendText;
1486
- const fontFamily = theme.fontFamily;
1487
-
1488
- ensureLegendLayout(chart, ctx, fontFamily);
1489
-
1490
1609
  const layout = chart._lastLayout;
1491
- const swatchSize = 10;
1492
- const lineHeight = 18;
1493
- const x = layout.plotRect.x + layout.plotRect.width + 12;
1494
- let y = layout.margins.top + 10;
1495
-
1496
- ctx.font = `11px ${fontFamily}`;
1497
- ctx.textAlign = "left";
1498
- ctx.textBaseline = "middle";
1499
-
1500
- const series = chart._series;
1501
- const widths = _legendTextWidths;
1502
- for (let i = 0; i < series.length; i++) {
1503
- const s = series[i];
1504
- const hidden = chart._hiddenSeries.has(s.seriesId);
1505
- const r = Math.round(s.color[0] * 255);
1506
- const g = Math.round(s.color[1] * 255);
1507
- const b = Math.round(s.color[2] * 255);
1508
-
1509
- ctx.globalAlpha = hidden ? 0.3 : 1.0;
1510
- ctx.fillStyle = `rgb(${r},${g},${b})`;
1511
- ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize);
1512
-
1513
- ctx.fillStyle = textColor;
1514
- ctx.fillText(s.label, x + swatchSize + 6, y);
1515
-
1516
- const textW = widths[i];
1517
- if (hidden) {
1518
- ctx.strokeStyle = textColor;
1519
- ctx.lineWidth = 1;
1520
- ctx.beginPath();
1521
- ctx.moveTo(x + swatchSize + 6, y);
1522
- ctx.lineTo(x + swatchSize + 6 + textW, y);
1523
- ctx.stroke();
1524
- }
1525
-
1526
- ctx.globalAlpha = 1.0;
1527
-
1528
- const rect: PlotRect = {
1529
- x: x - 2,
1530
- y: y - lineHeight / 2,
1531
- width: swatchSize + 6 + textW + 4,
1532
- height: lineHeight,
1533
- };
1534
- chart._legendRects.push({ seriesIds: [s.seriesId], rect });
1535
-
1536
- y += lineHeight;
1537
- }
1538
-
1539
- 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
+ );
1540
1660
  }
1541
1661
 
1542
1662
  function renderBarTooltipCanvas(chart: SeriesChart): void {
@@ -1575,13 +1695,71 @@ function renderBarTooltipCanvas(chart: SeriesChart): void {
1575
1695
 
1576
1696
  const lines = buildBarTooltipLines(chart, b);
1577
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
+
1578
1755
  renderCanvasTooltip(
1579
1756
  chart._chromeCanvas,
1580
1757
  pos,
1581
1758
  lines,
1582
1759
  layout,
1583
1760
  theme,
1584
- chart._glManager?.dpr ?? 1,
1761
+ dpr,
1762
+ tooltipStyleOf(chart._pluginConfig),
1585
1763
  );
1586
1764
  }
1587
1765
 
@@ -10,6 +10,8 @@
10
10
  // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
+ import { parseCssColorList, parseCssColorStrict } from "../../theme/gradient";
14
+
13
15
  export type ChartType = "bar" | "line" | "scatter" | "area";
14
16
 
15
17
  /**
@@ -45,6 +47,18 @@ export interface ColumnChartConfig {
45
47
  * `"skip"`). Default `"skip"`. No effect on bar / scatter.
46
48
  */
47
49
  interpolate?: InterpolateMode;
50
+
51
+ /**
52
+ * Single series-color override (`#rrggbb`) — the no-`split_by`
53
+ * shape.
54
+ */
55
+ color?: string;
56
+
57
+ /**
58
+ * Discrete color palette cycled over this column's split-generated
59
+ * series — the `split_by` shape.
60
+ */
61
+ palette?: string;
48
62
  }
49
63
 
50
64
  /**
@@ -98,6 +112,57 @@ export function resolveAltAxis(
98
112
  return cfg?.[aggName]?.alt_axis === true;
99
113
  }
100
114
 
115
+ /**
116
+ * Resolve a per-series color override from `columns_config`, or `null`
117
+ * when the theme palette applies.
118
+ */
119
+ export function resolveSeriesColorOverride(
120
+ series: { aggName: string; splitIdx: number },
121
+ facetActive: boolean,
122
+ cfg: Record<string, ColumnChartConfig> | undefined,
123
+ ): [number, number, number] | null {
124
+ const entry = cfg?.[series.aggName];
125
+ if (!entry) {
126
+ return null;
127
+ }
128
+
129
+ const palette =
130
+ typeof entry.palette === "string"
131
+ ? parsePaletteCached(entry.palette)
132
+ : null;
133
+ if (palette && palette.length > 0) {
134
+ const idx = facetActive ? 0 : series.splitIdx % palette.length;
135
+ return palette[idx];
136
+ }
137
+
138
+ if (typeof entry.color === "string") {
139
+ return parseCssColorStrict(entry.color) ?? null;
140
+ }
141
+
142
+ return null;
143
+ }
144
+
145
+ const PALETTE_PARSE_CACHE = new Map<
146
+ string,
147
+ Array<[number, number, number]> | null
148
+ >();
149
+
150
+ function parsePaletteCached(
151
+ src: string,
152
+ ): Array<[number, number, number]> | null {
153
+ let parsed = PALETTE_PARSE_CACHE.get(src);
154
+ if (parsed === undefined) {
155
+ parsed = parseCssColorList(src);
156
+ if (PALETTE_PARSE_CACHE.size > 256) {
157
+ PALETTE_PARSE_CACHE.clear();
158
+ }
159
+
160
+ PALETTE_PARSE_CACHE.set(src, parsed);
161
+ }
162
+
163
+ return parsed;
164
+ }
165
+
101
166
  /**
102
167
  * Resolve the interpolation mode for this aggregate.
103
168
  */