@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
@@ -31,6 +31,11 @@ import {
31
31
  } from "../../webgl/plot-frame";
32
32
  import { ensureGradientTexture } from "../../webgl/gradient-texture";
33
33
  import { renderCanvasTooltip } from "../../interaction/tooltip-controller";
34
+ import { tooltipStyleOf } from "../../interaction/tooltip-grid";
35
+ import {
36
+ renderAxisHoverIndicators,
37
+ type AxisIndicator,
38
+ } from "../../interaction/axis-indicators";
34
39
  import {
35
40
  computeTicks,
36
41
  renderGridlines,
@@ -41,6 +46,9 @@ import {
41
46
  type AxisDomain,
42
47
  } from "../../axis/numeric-axis";
43
48
  import { initCanvas, getScaledContext } from "../../axis/canvas";
49
+ import { computeMapDegreeTicks } from "../../axis/map-ticks";
50
+ import { mercatorToLonLat } from "../../map/mercator";
51
+ import { stepTickFormatter } from "../../layout/ticks";
44
52
  import {
45
53
  type CategoricalDomain,
46
54
  type CategoricalLevel,
@@ -54,7 +62,16 @@ import {
54
62
  renderLegendAt,
55
63
  renderCategoricalLegend,
56
64
  renderCategoricalLegendAt,
65
+ legendAutoFit,
66
+ gradientLegendAutoFit,
67
+ type LegendPaintView,
57
68
  } from "../../axis/legend";
69
+ import {
70
+ legendRightGutter,
71
+ legendSidebarWidth,
72
+ resolveLegendMode,
73
+ type LegendAutoFit,
74
+ } from "../../interaction/legend-controller";
58
75
 
59
76
  /**
60
77
  * NaN guard: `_xOrigin`/`_yOrigin` start as NaN before the first valid sample.
@@ -63,6 +80,43 @@ function rebaseOrigin(o: number): number {
63
80
  return isNaN(o) ? 0 : o;
64
81
  }
65
82
 
83
+ /**
84
+ * Legend entry count for `legend_mode: "auto"` resolution: the
85
+ * categorical swatch count when a string color column is wired, `0`
86
+ * for continuous gradient legends (no entry list — always compact).
87
+ */
88
+ function legendEntryCount(chart: CartesianChart): number {
89
+ return chart._colorIsString ? chart._uniqueColorLabels.size : 0;
90
+ }
91
+
92
+ /**
93
+ * Content measurements for a `legend_size_mode: "auto"` floating panel.
94
+ * Categorical legends hug their label list; gradient legends have no
95
+ * rows and hug their tick labels instead.
96
+ */
97
+ function legendFit(chart: CartesianChart, theme: Theme): LegendAutoFit {
98
+ const title = chart._colorName ?? undefined;
99
+ if (chart._colorIsString) {
100
+ return legendAutoFit(
101
+ chart._chromeCanvas,
102
+ theme,
103
+ chart._uniqueColorLabels.size,
104
+ () => chart._uniqueColorLabels.keys(),
105
+ { title },
106
+ );
107
+ }
108
+
109
+ return gradientLegendAutoFit(
110
+ chart._chromeCanvas,
111
+ theme,
112
+ { min: chart._colorMin, max: chart._colorMax },
113
+ chart._colorName
114
+ ? chart.getColumnFormatter(chart._colorName, "value")
115
+ : undefined,
116
+ title,
117
+ );
118
+ }
119
+
66
120
  /**
67
121
  * Full-frame render: gridlines → glyph draw inside the plot-frame
68
122
  * scissor → chrome overlay (axes + legend + tooltip).
@@ -368,7 +422,12 @@ function renderSinglePlotFrame(
368
422
 
369
423
  // One-pass plot-width / plot-height estimate to size the
370
424
  // categorical gutter overrides; same approach as series-render.
371
- const estRight = hasColorCol ? 80 : 16;
425
+ const estRight = legendRightGutter(
426
+ chart._pluginConfig,
427
+ hasColorCol,
428
+ 80,
429
+ legendEntryCount(chart),
430
+ );
372
431
  const estLeftPlain = 55 + (chart._yLabel ? 16 : 0);
373
432
  const estPlotWidth = Math.max(1, cssWidth - estLeftPlain - estRight);
374
433
  const leftExtra = chart._yCategoryDomain
@@ -378,13 +437,36 @@ function renderSinglePlotFrame(
378
437
  ? measureCategoricalAxisHeight(chart._xCategoryDomain, estPlotWidth)
379
438
  : undefined;
380
439
 
381
- const layout = new PlotLayout(cssWidth, cssHeight, {
382
- hasXLabel: !!chart._xLabel,
383
- hasYLabel: !!chart._yLabel,
384
- hasLegend: hasColorCol,
385
- leftExtra,
386
- bottomExtra,
387
- });
440
+ const isMap = chart._renderMode === "map";
441
+ const bareMap = isMap && !chart._pluginConfig.numeric_axes;
442
+ const layout = new PlotLayout(
443
+ cssWidth,
444
+ cssHeight,
445
+ bareMap
446
+ ? {
447
+ hasXLabel: false,
448
+ hasYLabel: false,
449
+ hasLegend: hasColorCol,
450
+ leftExtra: 0,
451
+ bottomExtra: 0,
452
+ rightExtra:
453
+ hasColorCol &&
454
+ resolveLegendMode(
455
+ chart._pluginConfig,
456
+ legendEntryCount(chart),
457
+ ) === "sidebar"
458
+ ? legendSidebarWidth(chart._pluginConfig, 80)
459
+ : 0,
460
+ }
461
+ : {
462
+ hasXLabel: !!chart._xLabel,
463
+ hasYLabel: !!chart._yLabel,
464
+ hasLegend: hasColorCol,
465
+ leftExtra,
466
+ bottomExtra,
467
+ rightExtra: estRight,
468
+ },
469
+ );
388
470
  chart._lastLayout = layout;
389
471
  if (chart._zoomController) {
390
472
  chart._zoomController.updateLayout(layout);
@@ -408,8 +490,6 @@ function renderSinglePlotFrame(
408
490
  const xTicks = chart._xIsString ? [] : numericTicks.xTicks;
409
491
  const yTicks = chart._yIsString ? [] : numericTicks.yTicks;
410
492
 
411
- const isMap = chart._renderMode === "map";
412
-
413
493
  // Defer the gridline draw past the GPU fence (see `_defer2D`) so the
414
494
  // gridline canvas doesn't present ahead of the GL glyphs on resize.
415
495
  // The closure captures this frame's `layout` / ticks / `theme`.
@@ -516,14 +596,28 @@ function renderFacetedFrame(
516
596
  // charts always have both axes, so the false branch maps to
517
597
  // per-cell mode (never to "none", which is reserved for tree
518
598
  // charts).
599
+ const isMap = chart._renderMode === "map";
600
+ const bareMap = isMap && !chart._pluginConfig.numeric_axes;
519
601
  const grid: FacetGrid = buildFacetGrid(labels, {
520
602
  cssWidth,
521
603
  cssHeight,
522
- xAxis: chart._lastEffectiveSharedX ? "outer" : "cell",
523
- yAxis: chart._lastEffectiveSharedY ? "outer" : "cell",
524
- hasLegend,
525
- hasXLabel: !!chart._xLabel,
526
- hasYLabel: !!chart._yLabel,
604
+ xAxis: bareMap
605
+ ? "none"
606
+ : chart._lastEffectiveSharedX
607
+ ? "outer"
608
+ : "cell",
609
+ yAxis: bareMap
610
+ ? "none"
611
+ : chart._lastEffectiveSharedY
612
+ ? "outer"
613
+ : "cell",
614
+ hasLegend:
615
+ hasLegend &&
616
+ resolveLegendMode(chart._pluginConfig, legendEntryCount(chart)) ===
617
+ "sidebar",
618
+ legendWidth: legendSidebarWidth(chart._pluginConfig, 96),
619
+ hasXLabel: !bareMap && !!chart._xLabel,
620
+ hasYLabel: !bareMap && !!chart._yLabel,
527
621
  gap: chart._facetConfig.facet_padding,
528
622
  });
529
623
  chart._facetGrid = grid;
@@ -618,7 +712,6 @@ function renderFacetedFrame(
618
712
  // own domain). Map mode skips gridlines entirely; the
619
713
  // basemap layer is rendered into the GL canvas inside the
620
714
  // facet's scissor below.
621
- const isMap = chart._renderMode === "map";
622
715
  if (gridlineCanvas && !isMap) {
623
716
  // Deferred to the post-fence 2D flush. The closure captures
624
717
  // this facet's `cell` (whose `cell.layout` already carries
@@ -733,6 +826,30 @@ function renderSinglePlotChromeOverlay(chart: CartesianChart): void {
733
826
  const isMap = chart._renderMode === "map";
734
827
 
735
828
  if (isMap) {
829
+ if (chart._pluginConfig.numeric_axes) {
830
+ const mt = computeMapDegreeTicks(layout);
831
+ renderCellXAxis(
832
+ chart._chromeCanvas!,
833
+ chart._lastXDomain!,
834
+ layout,
835
+ mt.xTicks,
836
+ theme,
837
+ !!chart._xLabel,
838
+ dpr,
839
+ mt.formatX,
840
+ );
841
+ renderCellYAxis(
842
+ chart._chromeCanvas!,
843
+ chart._lastYDomain!,
844
+ layout,
845
+ mt.yTicks,
846
+ theme,
847
+ !!chart._yLabel,
848
+ dpr,
849
+ mt.formatY,
850
+ );
851
+ }
852
+
736
853
  chart.renderMapChrome(chart._chromeCanvas!, layout, theme, dpr);
737
854
  } else {
738
855
  renderCartesianCellAxes(
@@ -748,37 +865,95 @@ function renderSinglePlotChromeOverlay(chart: CartesianChart): void {
748
865
  );
749
866
  }
750
867
 
751
- if (chart._lastHasColorCol) {
868
+ const legendMode = resolveLegendMode(
869
+ chart._pluginConfig,
870
+ legendEntryCount(chart),
871
+ );
872
+ let legendPainted = false;
873
+ if (chart._lastHasColorCol && legendMode !== "none") {
752
874
  const stops = chart._lastGradientStops ?? theme.gradientStops;
875
+ const floating = legendMode === "floating";
876
+ const view: LegendPaintView = {
877
+ mode: floating ? "floating" : "sidebar",
878
+ legend: chart._legend,
879
+ title: chart._colorName ?? undefined,
880
+ opacity: chart._pluginConfig.legend_opacity,
881
+ };
882
+ const floatBox = floating
883
+ ? chart._legend.floatingBox(
884
+ chart._pluginConfig,
885
+ layout.cssWidth,
886
+ layout.cssHeight,
887
+ legendFit(chart, theme),
888
+ )
889
+ : null;
753
890
  if (chart._colorIsString && chart._uniqueColorLabels.size > 0) {
754
891
  const palette = resolvePalette(
755
892
  theme.seriesPalette,
756
893
  stops,
757
894
  chart._uniqueColorLabels.size,
758
895
  );
759
- renderCategoricalLegend(
760
- chart._chromeCanvas!,
761
- layout,
762
- chart._uniqueColorLabels,
763
- palette,
764
- theme,
765
- );
896
+ if (floatBox) {
897
+ renderCategoricalLegendAt(
898
+ chart._chromeCanvas!,
899
+ floatBox,
900
+ chart._uniqueColorLabels,
901
+ palette,
902
+ theme,
903
+ view,
904
+ );
905
+ } else {
906
+ renderCategoricalLegend(
907
+ chart._chromeCanvas!,
908
+ layout,
909
+ chart._uniqueColorLabels,
910
+ palette,
911
+ theme,
912
+ view,
913
+ );
914
+ }
915
+
916
+ legendPainted = true;
766
917
  } else if (chart._colorName) {
767
- renderLegend(
768
- chart._chromeCanvas!,
769
- layout,
770
- {
771
- min: chart._colorMin,
772
- max: chart._colorMax,
773
- label: chart._colorName,
774
- },
775
- stops,
776
- theme,
777
- chart.getColumnFormatter(chart._colorName, "value"),
918
+ const colorDomain = {
919
+ min: chart._colorMin,
920
+ max: chart._colorMax,
921
+ label: chart._colorName,
922
+ };
923
+ const formatter = chart.getColumnFormatter(
924
+ chart._colorName,
925
+ "value",
778
926
  );
927
+ if (floatBox) {
928
+ renderLegendAt(
929
+ chart._chromeCanvas!,
930
+ floatBox,
931
+ colorDomain,
932
+ stops,
933
+ theme,
934
+ formatter,
935
+ view,
936
+ );
937
+ } else {
938
+ renderLegend(
939
+ chart._chromeCanvas!,
940
+ layout,
941
+ colorDomain,
942
+ stops,
943
+ theme,
944
+ formatter,
945
+ view,
946
+ );
947
+ }
948
+
949
+ legendPainted = true;
779
950
  }
780
951
  }
781
952
 
953
+ if (!legendPainted) {
954
+ chart._legend.clearPainted();
955
+ }
956
+
782
957
  renderScatterLabels(chart, chart._chromeCanvas!, layout, 0, 1);
783
958
 
784
959
  if (chart._hoveredIndex >= 0 && chart._xData && chart._yData) {
@@ -816,35 +991,44 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void {
816
991
  // (one pass per leftmost-column cell). Map mode replaces both
817
992
  // with `renderMapChrome` (attribution + scale bar), painted once
818
993
  // over the whole facet grid.
994
+ const mapAxes = isMap && chart._pluginConfig.numeric_axes;
995
+ const sharedMapTicks =
996
+ mapAxes && grid.cells.length > 0
997
+ ? computeMapDegreeTicks(grid.cells[0].layout)
998
+ : null;
819
999
  if (isMap) {
820
1000
  chart.renderMapChrome(canvas, chart._lastLayout!, theme, dpr);
821
1001
  }
822
1002
 
823
- if (!isMap && sharedX && grid.outerXAxisRect) {
1003
+ if ((!isMap || sharedMapTicks) && sharedX && grid.outerXAxisRect) {
824
1004
  renderOuterXAxis(
825
1005
  canvas,
826
1006
  grid.outerXAxisRect,
827
1007
  xDomain,
828
- sharedXTicks,
1008
+ sharedMapTicks ? sharedMapTicks.xTicks : sharedXTicks,
829
1009
  bottomRowLayouts(grid),
830
1010
  theme,
831
1011
  !!chart._xLabel,
832
1012
  dpr,
833
- chart.getColumnFormatter(chart._xName, "tick"),
1013
+ sharedMapTicks
1014
+ ? sharedMapTicks.formatX
1015
+ : chart.getColumnFormatter(chart._xName, "tick"),
834
1016
  );
835
1017
  }
836
1018
 
837
- if (!isMap && sharedY && grid.outerYAxisRect) {
1019
+ if ((!isMap || sharedMapTicks) && sharedY && grid.outerYAxisRect) {
838
1020
  renderOuterYAxis(
839
1021
  canvas,
840
1022
  grid.outerYAxisRect,
841
1023
  yDomain,
842
- sharedYTicks,
1024
+ sharedMapTicks ? sharedMapTicks.yTicks : sharedYTicks,
843
1025
  leftColumnLayouts(grid),
844
1026
  theme,
845
1027
  !!chart._yLabel,
846
1028
  dpr,
847
- chart.getColumnFormatter(chart._yName, "tick"),
1029
+ sharedMapTicks
1030
+ ? sharedMapTicks.formatY
1031
+ : chart.getColumnFormatter(chart._yName, "tick"),
848
1032
  );
849
1033
  }
850
1034
 
@@ -857,11 +1041,16 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void {
857
1041
  const d = zc ? zc.getVisibleDomain() : null;
858
1042
  const localX = d ? { ...xDomain, min: d.xMin, max: d.xMax } : xDomain;
859
1043
  const localY = d ? { ...yDomain, min: d.yMin, max: d.yMax } : yDomain;
860
- const ticks = independent
861
- ? computeTicks(localX, localY, cell.layout)
862
- : { xTicks: sharedXTicks, yTicks: sharedYTicks };
1044
+ const cellMapTicks = mapAxes
1045
+ ? computeMapDegreeTicks(cell.layout)
1046
+ : null;
1047
+ const ticks = cellMapTicks
1048
+ ? cellMapTicks
1049
+ : independent
1050
+ ? computeTicks(localX, localY, cell.layout)
1051
+ : { xTicks: sharedXTicks, yTicks: sharedYTicks };
863
1052
 
864
- if (!isMap && !sharedX) {
1053
+ if ((!isMap || cellMapTicks) && !sharedX) {
865
1054
  if (chart._xIsString && chart._xCategoryDomain) {
866
1055
  const cellCtx = getScaledContext(canvas, dpr);
867
1056
  if (cellCtx) {
@@ -881,12 +1070,14 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void {
881
1070
  theme,
882
1071
  !!chart._xLabel,
883
1072
  dpr,
884
- chart.getColumnFormatter(chart._xName, "tick"),
1073
+ cellMapTicks
1074
+ ? cellMapTicks.formatX
1075
+ : chart.getColumnFormatter(chart._xName, "tick"),
885
1076
  );
886
1077
  }
887
1078
  }
888
1079
 
889
- if (!isMap && !sharedY) {
1080
+ if ((!isMap || cellMapTicks) && !sharedY) {
890
1081
  if (chart._yIsString && chart._yCategoryDomain) {
891
1082
  const cellCtx = getScaledContext(canvas, dpr);
892
1083
  if (cellCtx) {
@@ -906,7 +1097,9 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void {
906
1097
  theme,
907
1098
  !!chart._yLabel,
908
1099
  dpr,
909
- chart.getColumnFormatter(chart._yName, "tick"),
1100
+ cellMapTicks
1101
+ ? cellMapTicks.formatY
1102
+ : chart.getColumnFormatter(chart._yName, "tick"),
910
1103
  );
911
1104
  }
912
1105
  }
@@ -919,10 +1112,29 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void {
919
1112
  }
920
1113
 
921
1114
  // Shared legend: categorical (string color) or gradient
922
- // (numeric color). Position derives from `grid.legendRect`
923
- // which `buildFacetGrid` populates when `hasLegend` was set.
924
- if (chart._lastHasColorCol && grid.legendRect) {
1115
+ const legendMode = resolveLegendMode(
1116
+ chart._pluginConfig,
1117
+ legendEntryCount(chart),
1118
+ );
1119
+ const floating = legendMode === "floating";
1120
+ const legendAnchor = floating
1121
+ ? chart._legend.floatingBox(
1122
+ chart._pluginConfig,
1123
+ chart._lastLayout!.cssWidth,
1124
+ chart._lastLayout!.cssHeight,
1125
+ legendFit(chart, theme),
1126
+ )
1127
+ : grid.legendRect;
1128
+ let legendPainted = false;
1129
+ if (chart._lastHasColorCol && legendMode !== "none" && legendAnchor) {
925
1130
  const stops = chart._lastGradientStops ?? theme.gradientStops;
1131
+ const view: LegendPaintView = {
1132
+ mode: floating ? "floating" : "sidebar",
1133
+ legend: chart._legend,
1134
+ title: chart._colorName ?? undefined,
1135
+ sidebarGutter: floating ? undefined : grid.legendRect?.width,
1136
+ opacity: chart._pluginConfig.legend_opacity,
1137
+ };
926
1138
  if (chart._colorIsString && chart._uniqueColorLabels.size > 0) {
927
1139
  const palette = resolvePalette(
928
1140
  theme.seriesPalette,
@@ -931,23 +1143,27 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void {
931
1143
  );
932
1144
  renderCategoricalLegendAt(
933
1145
  canvas,
934
- grid.legendRect,
1146
+ legendAnchor,
935
1147
  chart._uniqueColorLabels,
936
1148
  palette,
937
1149
  theme,
1150
+ view,
938
1151
  );
1152
+ legendPainted = true;
939
1153
  } else if (chart._colorName) {
940
1154
  // Numeric gradient legend in the shared outer rect. The
941
1155
  // label sits above the bar, so inset the rect's top by
942
1156
  // the usual 20 px that `renderLegend` reserves.
943
1157
  renderLegendAt(
944
1158
  canvas,
945
- {
946
- x: grid.legendRect.x,
947
- y: grid.legendRect.y + 20,
948
- width: grid.legendRect.width,
949
- height: grid.legendRect.height - 20,
950
- },
1159
+ floating
1160
+ ? legendAnchor
1161
+ : {
1162
+ x: legendAnchor.x,
1163
+ y: legendAnchor.y + 20,
1164
+ width: legendAnchor.width,
1165
+ height: legendAnchor.height - 20,
1166
+ },
951
1167
  {
952
1168
  min: chart._colorMin,
953
1169
  max: chart._colorMax,
@@ -956,10 +1172,16 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void {
956
1172
  stops,
957
1173
  theme,
958
1174
  chart.getColumnFormatter(chart._colorName, "value"),
1175
+ view,
959
1176
  );
1177
+ legendPainted = true;
960
1178
  }
961
1179
  }
962
1180
 
1181
+ if (!legendPainted) {
1182
+ chart._legend.clearPainted();
1183
+ }
1184
+
963
1185
  // Coordinated hover / click indicators across facets. The tooltip
964
1186
  // lines are whatever the last resolved lazy fetch produced (or
965
1187
  // null while a fetch is still in flight); `renderCanvasTooltip`
@@ -975,7 +1197,9 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void {
975
1197
  const dataY = chart._yData[chart._hoveredIndex] + yOrigin;
976
1198
  const sourceFacet = seriesFromIndex(chart, chart._hoveredIndex);
977
1199
  const opts = chart.glyph.tooltipOptions();
978
- const tooltipLines = chart._lazyTooltip.lines ?? [];
1200
+ const axisInd = axisIndicatorsEnabled(chart, opts);
1201
+ const style = tooltipStyleOf(chart._pluginConfig);
1202
+ const tooltipGrid = chart._lazyTooltip.grid ?? [];
979
1203
 
980
1204
  for (let i = 0; i < grid.cells.length; i++) {
981
1205
  const cell = grid.cells[i];
@@ -994,10 +1218,28 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void {
994
1218
  continue;
995
1219
  }
996
1220
 
1221
+ if (isSource && axisInd) {
1222
+ renderAxisHoverIndicators(
1223
+ canvas,
1224
+ cell.layout,
1225
+ theme,
1226
+ dpr,
1227
+ cartesianAxisIndicators(
1228
+ chart,
1229
+ cell.layout,
1230
+ pos,
1231
+ dataX,
1232
+ dataY,
1233
+ ),
1234
+ style.opacity,
1235
+ );
1236
+ }
1237
+
997
1238
  const coordinated = chart._facetConfig.coordinated_tooltip;
998
- const lines = isSource || coordinated ? tooltipLines : [];
999
- renderCanvasTooltip(canvas, pos, lines, cell.layout, theme, dpr, {
1000
- crosshair: opts.crosshair,
1239
+ const content = isSource || coordinated ? tooltipGrid : [];
1240
+ renderCanvasTooltip(canvas, pos, content, cell.layout, theme, dpr, {
1241
+ ...style,
1242
+ crosshair: opts.crosshair && (!isSource || !axisInd),
1001
1243
  highlightRadius: isSource ? opts.highlightRadius : 0,
1002
1244
  });
1003
1245
  }
@@ -1122,24 +1364,76 @@ function renderTooltip(
1122
1364
 
1123
1365
  const xOrigin = isNaN(chart._xOrigin) ? 0 : chart._xOrigin;
1124
1366
  const yOrigin = isNaN(chart._yOrigin) ? 0 : chart._yOrigin;
1125
- const pos = layout.dataToPixel(
1126
- chart._xData[idx] + xOrigin,
1127
- chart._yData[idx] + yOrigin,
1128
- );
1367
+ const dataX = chart._xData[idx] + xOrigin;
1368
+ const dataY = chart._yData[idx] + yOrigin;
1369
+ const pos = layout.dataToPixel(dataX, dataY);
1129
1370
 
1130
1371
  // Lines come from the async lazy tooltip fetch kicked off in
1131
1372
  // `handleCartesianHover`. While a fetch is in flight this is
1132
1373
  // `null`; the canvas tooltip helper still paints the crosshair /
1133
1374
  // highlight ring but skips the text box.
1134
- const lines = chart._lazyTooltip.lines ?? [];
1375
+ const grid = chart._lazyTooltip.grid ?? [];
1135
1376
  const theme = chart._resolveTheme();
1136
- renderCanvasTooltip(
1137
- canvas,
1138
- pos,
1139
- lines,
1140
- layout,
1141
- theme,
1142
- chart._glManager?.dpr ?? 1,
1143
- chart.glyph.tooltipOptions(),
1377
+ const dpr = chart._glManager?.dpr ?? 1;
1378
+ const opts = chart.glyph.tooltipOptions();
1379
+ const axisInd = axisIndicatorsEnabled(chart, opts);
1380
+ if (axisInd) {
1381
+ renderAxisHoverIndicators(
1382
+ canvas,
1383
+ layout,
1384
+ theme,
1385
+ dpr,
1386
+ cartesianAxisIndicators(chart, layout, pos, dataX, dataY),
1387
+ chart._pluginConfig.tooltip_opacity,
1388
+ );
1389
+ }
1390
+
1391
+ renderCanvasTooltip(canvas, pos, grid, layout, theme, dpr, {
1392
+ ...tooltipStyleOf(chart._pluginConfig),
1393
+ crosshair: opts.crosshair && !axisInd,
1394
+ highlightRadius: opts.highlightRadius,
1395
+ });
1396
+ }
1397
+
1398
+ function axisIndicatorsEnabled(
1399
+ chart: CartesianChart,
1400
+ opts: { axisIndicators: boolean },
1401
+ ): boolean {
1402
+ return (
1403
+ opts.axisIndicators &&
1404
+ (chart._renderMode !== "map" || !!chart._pluginConfig.numeric_axes)
1144
1405
  );
1145
1406
  }
1407
+
1408
+ function cartesianAxisIndicators(
1409
+ chart: CartesianChart,
1410
+ layout: PlotLayout,
1411
+ pos: { px: number; py: number },
1412
+ dataX: number,
1413
+ dataY: number,
1414
+ ): AxisIndicator[] {
1415
+ let xText: string;
1416
+ let yText: string;
1417
+ const xOverride = chart.getColumnFormatter(chart._xName, "tick");
1418
+ const yOverride = chart.getColumnFormatter(chart._yName, "tick");
1419
+ if (chart._renderMode === "map") {
1420
+ const mt = computeMapDegreeTicks(layout);
1421
+ const [lon, lat] = mercatorToLonLat(dataX, dataY);
1422
+ xText = xOverride ? xOverride(lon) : mt.formatX(dataX);
1423
+ yText = yOverride ? yOverride(lat) : mt.formatY(dataY);
1424
+ } else {
1425
+ const xFmt =
1426
+ xOverride ??
1427
+ stepTickFormatter(chart._lastXDomain?.isDate, chart._lastXTicks);
1428
+ const yFmt =
1429
+ yOverride ??
1430
+ stepTickFormatter(chart._lastYDomain?.isDate, chart._lastYTicks);
1431
+ xText = xFmt(dataX);
1432
+ yText = yFmt(dataY);
1433
+ }
1434
+
1435
+ return [
1436
+ { side: "bottom", px: pos.px, py: pos.py, text: xText },
1437
+ { side: "left", px: pos.px, py: pos.py, text: yText },
1438
+ ];
1439
+ }
@@ -65,6 +65,15 @@ export class CartesianChart extends AbstractChart {
65
65
  return this._colorName || null;
66
66
  }
67
67
 
68
+ /**
69
+ * Chrome-only repaint for legend scroll / floating-legend drag —
70
+ * the same lightweight path hover updates use. No GL pass; the
71
+ * composite re-presents over the retained plot bitmap.
72
+ */
73
+ repaintChrome(): void {
74
+ renderCartesianChromeOverlay(this);
75
+ }
76
+
68
77
  /**
69
78
  * Rendering pipeline selector. `"cartesian"` is the default —
70
79
  * draws axes, gridlines, and ticks via the chrome canvas.
@@ -67,12 +67,16 @@ export interface Glyph {
67
67
  buildTooltipLines(
68
68
  chart: CartesianChart,
69
69
  flatIdx: number,
70
- ): Promise<string[]>;
70
+ ): Promise<string[][]>;
71
71
 
72
72
  /**
73
73
  * Hover-overlay options (crosshair, highlight radius).
74
74
  */
75
- tooltipOptions(): { crosshair: boolean; highlightRadius: number };
75
+ tooltipOptions(): {
76
+ crosshair: boolean;
77
+ highlightRadius: number;
78
+ axisIndicators: boolean;
79
+ };
76
80
 
77
81
  /**
78
82
  * Release GL resources created by `ensureProgram`.