@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
@@ -17,12 +17,21 @@ import type { GradientStop } from "../../theme/gradient";
17
17
  import type { Vec3 } from "../../theme/palette";
18
18
  import type { Theme } from "../../theme/theme";
19
19
  import {
20
+ gradientLegendAutoFit,
21
+ legendAutoFit,
20
22
  renderCategoricalLegend,
21
23
  renderCategoricalLegendAt,
22
24
  renderLegend,
25
+ renderLegendAt,
26
+ type LegendPaintView,
23
27
  } from "../../axis/legend";
28
+ import {
29
+ legendSidebarWidth,
30
+ resolveLegendMode,
31
+ } from "../../interaction/legend-controller";
24
32
  import type { TreeChartBase } from "./tree-chart";
25
33
  import { drawTooltipBox } from "./draw-tooltip-box";
34
+ import { tooltipStyleOf } from "../../interaction/tooltip-grid";
26
35
 
27
36
  /**
28
37
  * Click target for one breadcrumb segment. Tree-chart hit-testing
@@ -112,23 +121,24 @@ export function renderTreeTooltip(
112
121
  cssHeight: number,
113
122
  fontFamily: string,
114
123
  ): void {
115
- const lines =
124
+ const grid =
116
125
  chart._lazyTooltip.hoveredTarget === nodeId
117
- ? (chart._lazyTooltip.lines ?? [])
126
+ ? (chart._lazyTooltip.grid ?? [])
118
127
  : [];
119
- if (lines.length === 0) {
128
+ if (grid.length === 0) {
120
129
  return;
121
130
  }
122
131
 
123
132
  drawTooltipBox(
124
133
  ctx,
125
134
  chart._resolveTheme(),
126
- lines,
135
+ grid,
127
136
  cx,
128
137
  cy,
129
138
  cssWidth,
130
139
  cssHeight,
131
140
  fontFamily,
141
+ tooltipStyleOf(chart._pluginConfig),
132
142
  );
133
143
  }
134
144
 
@@ -159,50 +169,119 @@ export function renderTreeColorLegend(
159
169
  cssHeight: number,
160
170
  categoricalRect: PlotRect | null = null,
161
171
  ): void {
162
- if (chart._colorMode === "series" && chart._uniqueColorLabels.size > 1) {
163
- if (categoricalRect) {
172
+ const cfg = chart._pluginConfig;
173
+ const hasCategorical =
174
+ chart._colorMode === "series" && chart._uniqueColorLabels.size > 1;
175
+ const hasNumeric =
176
+ chart._colorMode === "numeric" && chart._colorMin < chart._colorMax;
177
+ const mode = resolveLegendMode(
178
+ cfg,
179
+ hasCategorical ? chart._uniqueColorLabels.size : 0,
180
+ );
181
+ if (mode === "none" || (!hasCategorical && !hasNumeric)) {
182
+ chart._legend.clearPainted();
183
+ return;
184
+ }
185
+
186
+ const floating = mode === "floating";
187
+ const view: LegendPaintView = {
188
+ mode: floating ? "floating" : "sidebar",
189
+ legend: chart._legend,
190
+ title: chart._colorName || "Legend",
191
+ opacity: cfg.legend_opacity,
192
+ };
193
+ const floatBox = floating
194
+ ? chart._legend.floatingBox(
195
+ cfg,
196
+ cssWidth,
197
+ cssHeight,
198
+ hasCategorical
199
+ ? legendAutoFit(
200
+ canvas,
201
+ theme,
202
+ chart._uniqueColorLabels.size,
203
+ () => chart._uniqueColorLabels.keys(),
204
+ { title: view.title },
205
+ )
206
+ : gradientLegendAutoFit(
207
+ canvas,
208
+ theme,
209
+ { min: chart._colorMin, max: chart._colorMax },
210
+ undefined,
211
+ view.title,
212
+ ),
213
+ )
214
+ : null;
215
+
216
+ if (hasCategorical) {
217
+ if (floatBox) {
218
+ renderCategoricalLegendAt(
219
+ canvas,
220
+ floatBox,
221
+ chart._uniqueColorLabels,
222
+ palette,
223
+ theme,
224
+ view,
225
+ );
226
+ } else if (categoricalRect) {
164
227
  renderCategoricalLegendAt(
165
228
  canvas,
166
229
  categoricalRect,
167
230
  chart._uniqueColorLabels,
168
231
  palette,
169
232
  theme,
233
+ { ...view, sidebarGutter: categoricalRect.width },
170
234
  );
171
235
  } else {
172
236
  renderCategoricalLegend(
173
237
  canvas,
174
- syntheticLegendLayout(cssWidth, cssHeight),
238
+ syntheticLegendLayout(cssWidth, cssHeight, cfg),
175
239
  chart._uniqueColorLabels,
176
240
  palette,
177
241
  theme,
242
+ view,
243
+ );
244
+ }
245
+ } else {
246
+ const colorDomain = {
247
+ min: chart._colorMin,
248
+ max: chart._colorMax,
249
+ label: chart._colorName,
250
+ };
251
+ const formatter = chart.getColumnFormatter(chart._colorName, "value");
252
+ if (floatBox) {
253
+ renderLegendAt(
254
+ canvas,
255
+ floatBox,
256
+ colorDomain,
257
+ stops,
258
+ theme,
259
+ formatter,
260
+ view,
261
+ );
262
+ } else {
263
+ renderLegend(
264
+ canvas,
265
+ syntheticLegendLayout(cssWidth, cssHeight, cfg),
266
+ colorDomain,
267
+ stops,
268
+ theme,
269
+ formatter,
270
+ view,
178
271
  );
179
272
  }
180
- } else if (
181
- chart._colorMode === "numeric" &&
182
- chart._colorMin < chart._colorMax
183
- ) {
184
- renderLegend(
185
- canvas,
186
- syntheticLegendLayout(cssWidth, cssHeight),
187
- {
188
- min: chart._colorMin,
189
- max: chart._colorMax,
190
- label: chart._colorName,
191
- },
192
- stops,
193
- theme,
194
- chart.getColumnFormatter(chart._colorName, "value"),
195
- );
196
273
  }
197
274
  }
198
275
 
199
276
  function syntheticLegendLayout(
200
277
  cssWidth: number,
201
278
  cssHeight: number,
279
+ cfg: TreeChartBase["_pluginConfig"],
202
280
  ): PlotLayout {
203
281
  return new PlotLayout(cssWidth, cssHeight, {
204
282
  hasXLabel: false,
205
283
  hasYLabel: false,
206
284
  hasLegend: true,
285
+ rightExtra: legendSidebarWidth(cfg, 80),
207
286
  });
208
287
  }
@@ -13,6 +13,7 @@
13
13
  import type { TreeChartBase } from "./tree-chart";
14
14
  import { NULL_NODE, ancestorNames } from "./node-store";
15
15
  import { rebuildBreadcrumbs } from "./tree-data";
16
+ import { tooltipStyleOf } from "../../interaction/tooltip-grid";
16
17
 
17
18
  /**
18
19
  * Common subset of `TreemapChart` / `SunburstChart` reached by the
@@ -73,9 +74,9 @@ export async function emitTreeNodeEvent(
73
74
  export async function buildTreeTooltipLines(
74
75
  chart: TreeInteractChart,
75
76
  nodeId: number,
76
- ): Promise<string[]> {
77
+ ): Promise<string[][]> {
77
78
  const store = chart._nodeStore;
78
- const lines: string[] = [];
79
+ const grid: string[][] = [];
79
80
 
80
81
  const pathNames: string[] = [];
81
82
  let p = nodeId;
@@ -86,19 +87,17 @@ export async function buildTreeTooltipLines(
86
87
 
87
88
  pathNames.reverse();
88
89
  if (pathNames.length > 0) {
89
- lines.push(pathNames.join(" › "));
90
+ grid.push([pathNames.join(" › ")]);
90
91
  } else {
91
- lines.push(store.name[nodeId]);
92
+ grid.push([store.name[nodeId]]);
92
93
  }
93
94
 
94
95
  const sizeFmt = chart.getColumnFormatter(chart._sizeName, "value");
95
- lines.push(`Value: ${sizeFmt(store.value[nodeId])}`);
96
+ grid.push(["Value", sizeFmt(store.value[nodeId])]);
96
97
 
97
98
  if (chart._colorName && !isNaN(store.colorValue[nodeId])) {
98
99
  const colorFmt = chart.getColumnFormatter(chart._colorName, "value");
99
- lines.push(
100
- `${chart._colorName}: ${colorFmt(store.colorValue[nodeId])}`,
101
- );
100
+ grid.push([chart._colorName, colorFmt(store.colorValue[nodeId])]);
102
101
  }
103
102
 
104
103
  const rowIdx = store.leafRowIdx[nodeId];
@@ -117,20 +116,21 @@ export async function buildTreeTooltipLines(
117
116
  }
118
117
 
119
118
  if (typeof value === "number") {
120
- lines.push(
121
- `${name}: ${chart.getColumnFormatter(name, "value")(value)}`,
122
- );
119
+ grid.push([
120
+ name,
121
+ chart.getColumnFormatter(name, "value")(value),
122
+ ]);
123
123
  } else {
124
- lines.push(`${name}: ${value}`);
124
+ grid.push([name, String(value)]);
125
125
  }
126
126
  }
127
127
  }
128
128
 
129
129
  if (store.firstChild[nodeId] !== NULL_NODE) {
130
- lines.push(`Children: ${store.childCount[nodeId]}`);
130
+ grid.push(["Children", String(store.childCount[nodeId])]);
131
131
  }
132
132
 
133
- return lines;
133
+ return grid;
134
134
  }
135
135
 
136
136
  /**
@@ -150,19 +150,20 @@ export function showTreePinnedTooltip(
150
150
  const cssWidth = chart._glManager?.cssWidth ?? 0;
151
151
  const cssHeight = chart._glManager?.cssHeight ?? 0;
152
152
 
153
- buildTreeTooltipLines(chart, nodeId).then((lines) => {
153
+ buildTreeTooltipLines(chart, nodeId).then((grid) => {
154
154
  if (chart._pinnedNodeId !== nodeId) {
155
155
  return;
156
156
  }
157
157
 
158
- if (lines.length === 0) {
158
+ if (grid.length === 0) {
159
159
  return;
160
160
  }
161
161
 
162
162
  chart._tooltip.pin(
163
- lines,
163
+ grid,
164
164
  { px: anchor.cx, py: anchor.cy },
165
165
  { cssWidth, cssHeight },
166
+ tooltipStyleOf(chart._pluginConfig),
166
167
  );
167
168
  });
168
169
 
@@ -13,6 +13,12 @@
13
13
  import type { HeatmapChart } from "./heatmap";
14
14
  import type { HeatmapCell } from "./heatmap-build";
15
15
  import { renderCanvasTooltip } from "../../interaction/tooltip-controller";
16
+ import {
17
+ renderAxisHoverIndicators,
18
+ type AxisIndicator,
19
+ } from "../../interaction/axis-indicators";
20
+ import { computeNiceTicks, stepTickFormatter } from "../../layout/ticks";
21
+ import { tooltipStyleOf } from "../../interaction/tooltip-grid";
16
22
  import type { CategoricalLevel } from "../../axis/categorical-axis";
17
23
 
18
24
  /**
@@ -216,6 +222,8 @@ export function renderHeatmapTooltip(chart: HeatmapChart): void {
216
222
  let xLevels: CategoricalLevel[];
217
223
  let yLevels: CategoricalLevel[];
218
224
  let xPositions: Float64Array | null;
225
+ let yPositions: Float64Array | null;
226
+ let xNumericDomain: { isDate?: boolean } | null;
219
227
  let facetLabel: string | null = null;
220
228
 
221
229
  if (chart._hoveredFacetIdx >= 0) {
@@ -228,6 +236,8 @@ export function renderHeatmapTooltip(chart: HeatmapChart): void {
228
236
  xLevels = facet.pipeline.xLevels;
229
237
  yLevels = facet.pipeline.yLevels;
230
238
  xPositions = facet.pipeline.xPositions;
239
+ yPositions = facet.pipeline.yPositions;
240
+ xNumericDomain = facet.pipeline.xNumericDomain;
231
241
  facetLabel = facet.label;
232
242
  } else {
233
243
  if (!chart._lastLayout) {
@@ -238,6 +248,8 @@ export function renderHeatmapTooltip(chart: HeatmapChart): void {
238
248
  xLevels = chart._xLevels;
239
249
  yLevels = chart._yLevels;
240
250
  xPositions = chart._xPositions;
251
+ yPositions = chart._yPositions;
252
+ xNumericDomain = chart._xNumericDomain;
241
253
  }
242
254
 
243
255
  const cell = chart._hoveredCell;
@@ -247,9 +259,9 @@ export function renderHeatmapTooltip(chart: HeatmapChart): void {
247
259
  // many pixels.
248
260
  const pos = { px: chart._hoveredMouseX, py: chart._hoveredMouseY };
249
261
 
250
- const lines: string[] = [];
262
+ const grid: string[][] = [];
251
263
  if (facetLabel) {
252
- lines.push(facetLabel);
264
+ grid.push([facetLabel]);
253
265
  }
254
266
 
255
267
  const xPath =
@@ -261,23 +273,69 @@ export function renderHeatmapTooltip(chart: HeatmapChart): void {
261
273
  : formatHierarchicalPath(xLevels, cell.xIdx);
262
274
  const yPath = formatHierarchicalPath(yLevels, cell.yIdx);
263
275
  if (xPath) {
264
- lines.push(xPath);
276
+ grid.push([xPath]);
265
277
  }
266
278
 
267
279
  if (yPath) {
268
- lines.push(yPath);
280
+ grid.push([yPath]);
269
281
  }
270
282
 
271
283
  const valueFmt = chart.getColumnFormatter(chart._columnSlots[0], "value");
272
- lines.push(`Value: ${valueFmt(cell.value)}`);
284
+ grid.push(["Value", valueFmt(cell.value)]);
273
285
 
274
286
  const theme = chart._resolveTheme();
287
+ const dpr = chart._glManager?.dpr ?? 1;
288
+
289
+ const cellPos = layout.dataToPixel(
290
+ xPositions ? xPositions[cell.xIdx] : cell.xIdx,
291
+ yPositions ? yPositions[cell.yIdx] : cell.yIdx,
292
+ );
293
+ const badgeX =
294
+ chart._xAxisMode.mode === "numeric" && xPositions
295
+ ? (
296
+ chart.getColumnFormatter(chart._groupBy[0], "tick") ??
297
+ stepTickFormatter(
298
+ xNumericDomain?.isDate,
299
+ computeNiceTicks(layout.paddedXMin, layout.paddedXMax, 6),
300
+ )
301
+ )(xPositions[cell.xIdx])
302
+ : xPath;
303
+
304
+ const indicators: AxisIndicator[] = [];
305
+ if (badgeX) {
306
+ indicators.push({
307
+ side: "bottom",
308
+ px: cellPos.px,
309
+ py: cellPos.py,
310
+ text: badgeX,
311
+ });
312
+ }
313
+
314
+ if (yPath) {
315
+ indicators.push({
316
+ side: "left",
317
+ px: cellPos.px,
318
+ py: cellPos.py,
319
+ text: yPath,
320
+ });
321
+ }
322
+
323
+ renderAxisHoverIndicators(
324
+ chart._chromeCanvas,
325
+ layout,
326
+ theme,
327
+ dpr,
328
+ indicators,
329
+ chart._pluginConfig.tooltip_opacity,
330
+ );
331
+
275
332
  renderCanvasTooltip(
276
333
  chart._chromeCanvas,
277
334
  pos,
278
- lines,
335
+ grid,
279
336
  layout,
280
337
  theme,
281
- chart._glManager?.dpr ?? 1,
338
+ dpr,
339
+ tooltipStyleOf(chart._pluginConfig),
282
340
  );
283
341
  }
@@ -47,7 +47,17 @@ const HEATMAP_Y_AXIS_OPTS: CategoricalYAxisOptions = {
47
47
  skipLeafLevel: true,
48
48
  };
49
49
 
50
- import { renderLegend, renderLegendAt } from "../../axis/legend";
50
+ import {
51
+ gradientLegendAutoFit,
52
+ renderLegend,
53
+ renderLegendAt,
54
+ type LegendPaintView,
55
+ } from "../../axis/legend";
56
+ import {
57
+ legendRightGutter,
58
+ resolveLegendMode,
59
+ legendSidebarWidth,
60
+ } from "../../interaction/legend-controller";
51
61
  import heatmapVert from "../../shaders/heatmap.vert.glsl";
52
62
  import heatmapFrag from "../../shaders/heatmap.frag.glsl";
53
63
  import { colorValueToT } from "../../theme/gradient";
@@ -110,6 +120,7 @@ export function renderHeatmapFrame(
110
120
  // Measure both hierarchical axes *before* building the layout so the
111
121
  // plot rect accounts for their footprints. Numeric axes get fixed
112
122
  // gutters matching bar's branch (24px bottom, 55px left).
123
+ const rightExtra = legendRightGutter(chart._pluginConfig, true, 80, 0);
113
124
  const estLeft = yNumeric
114
125
  ? 55
115
126
  : measureCategoricalAxisWidth(yDomain, HEATMAP_Y_AXIS_OPTS);
@@ -117,7 +128,7 @@ export function renderHeatmapFrame(
117
128
  ? 24
118
129
  : measureCategoricalAxisHeight(
119
130
  xDomain,
120
- Math.max(1, cssWidth - estLeft - 110),
131
+ Math.max(1, cssWidth - estLeft - 30 - rightExtra),
121
132
  );
122
133
 
123
134
  const layout = new PlotLayout(cssWidth, cssHeight, {
@@ -126,6 +137,7 @@ export function renderHeatmapFrame(
126
137
  hasLegend: true,
127
138
  bottomExtra,
128
139
  leftExtra: estLeft,
140
+ rightExtra,
129
141
  });
130
142
  chart._lastLayout = layout;
131
143
  if (chart._zoomController) {
@@ -482,20 +494,58 @@ function paintHeatmapChromeOverlay(chart: HeatmapChart): void {
482
494
  );
483
495
  }
484
496
 
485
- // Color legend on the right. The aggregate column name is in
486
- // `_columnSlots[0]` (heatmap's only data column slot is "Color").
487
- renderLegend(
488
- chart._chromeCanvas,
489
- layout,
490
- {
497
+ const legendMode = resolveLegendMode(chart._pluginConfig, 0);
498
+ if (legendMode === "none") {
499
+ chart._legend.clearPainted();
500
+ } else {
501
+ const colorDomain = {
491
502
  min: chart._colorMin,
492
503
  max: chart._colorMax,
493
504
  label: chart._aggName,
494
- },
495
- theme.gradientStops,
496
- theme,
497
- chart.getColumnFormatter(chart._columnSlots[0], "value"),
498
- );
505
+ };
506
+ const formatter = chart.getColumnFormatter(
507
+ chart._columnSlots[0],
508
+ "value",
509
+ );
510
+ const view: LegendPaintView = {
511
+ mode: legendMode === "floating" ? "floating" : "sidebar",
512
+ legend: chart._legend,
513
+ title: chart._aggName,
514
+ opacity: chart._pluginConfig.legend_opacity,
515
+ };
516
+ if (legendMode === "floating") {
517
+ renderLegendAt(
518
+ chart._chromeCanvas,
519
+ chart._legend.floatingBox(
520
+ chart._pluginConfig,
521
+ layout.cssWidth,
522
+ layout.cssHeight,
523
+ gradientLegendAutoFit(
524
+ chart._chromeCanvas,
525
+ theme,
526
+ colorDomain,
527
+ formatter,
528
+ chart._aggName,
529
+ ),
530
+ ),
531
+ colorDomain,
532
+ theme.gradientStops,
533
+ theme,
534
+ formatter,
535
+ view,
536
+ );
537
+ } else {
538
+ renderLegend(
539
+ chart._chromeCanvas,
540
+ layout,
541
+ colorDomain,
542
+ theme.gradientStops,
543
+ theme,
544
+ formatter,
545
+ view,
546
+ );
547
+ }
548
+ }
499
549
 
500
550
  if (chart._hoveredCell) {
501
551
  renderHeatmapTooltip(chart);
@@ -528,7 +578,8 @@ function renderFacetedHeatmap(
528
578
  cssHeight,
529
579
  xAxis: effectiveSharedX ? "outer" : "cell",
530
580
  yAxis: effectiveSharedY ? "outer" : "cell",
531
- hasLegend: true,
581
+ hasLegend: resolveLegendMode(chart._pluginConfig, 0) === "sidebar",
582
+ legendWidth: legendSidebarWidth(chart._pluginConfig, 96),
532
583
  hasXLabel: chart._groupBy.length > 0,
533
584
  hasYLabel: false,
534
585
  gap: 8,
@@ -793,18 +844,34 @@ function renderFacetedHeatmapChromeOverlay(chart: HeatmapChart): void {
793
844
  );
794
845
  }
795
846
 
796
- // Shared colorbar at `grid.legendRect`. No meaningful single label —
797
- // the facet titles already name each column, and a combined label
798
- // would be ambiguous when columns differ.
799
- if (grid.legendRect) {
847
+ const legendMode = resolveLegendMode(chart._pluginConfig, 0);
848
+ const floating = legendMode === "floating";
849
+ const facetLayout = chart._facets[0].layout;
850
+ const legendAnchor = floating
851
+ ? chart._legend.floatingBox(
852
+ chart._pluginConfig,
853
+ facetLayout.cssWidth,
854
+ facetLayout.cssHeight,
855
+ gradientLegendAutoFit(
856
+ chart._chromeCanvas,
857
+ theme,
858
+ { min: chart._colorMin, max: chart._colorMax },
859
+ chart.getColumnFormatter(chart._columnSlots[0], "value"),
860
+ chart._aggName,
861
+ ),
862
+ )
863
+ : grid.legendRect;
864
+ if (legendMode !== "none" && legendAnchor) {
800
865
  renderLegendAt(
801
866
  chart._chromeCanvas,
802
- {
803
- x: grid.legendRect.x,
804
- y: grid.legendRect.y + 20,
805
- width: grid.legendRect.width,
806
- height: Math.max(1, grid.legendRect.height - 20),
807
- },
867
+ floating
868
+ ? legendAnchor
869
+ : {
870
+ x: legendAnchor.x,
871
+ y: legendAnchor.y + 20,
872
+ width: legendAnchor.width,
873
+ height: Math.max(1, legendAnchor.height - 20),
874
+ },
808
875
  {
809
876
  min: chart._colorMin,
810
877
  max: chart._colorMax,
@@ -813,7 +880,16 @@ function renderFacetedHeatmapChromeOverlay(chart: HeatmapChart): void {
813
880
  theme.gradientStops,
814
881
  theme,
815
882
  chart.getColumnFormatter(chart._columnSlots[0], "value"),
883
+ {
884
+ mode: floating ? "floating" : "sidebar",
885
+ legend: chart._legend,
886
+ title: chart._aggName,
887
+ sidebarGutter: floating ? undefined : grid.legendRect?.width,
888
+ opacity: chart._pluginConfig.legend_opacity,
889
+ },
816
890
  );
891
+ } else {
892
+ chart._legend.clearPainted();
817
893
  }
818
894
 
819
895
  if (chart._hoveredCell) {
@@ -22,7 +22,7 @@ import type { Canvas2D, Context2D } from "../canvas-types";
22
22
  import type { ZoomConfig } from "../../interaction/zoom-controller";
23
23
  import type { PluginConfig } from "../chart";
24
24
  import { TileLayer } from "../../map/tile-layer";
25
- import { tileSourceFor, type TileProviderId } from "../../map/tile-source";
25
+ import { TILE_SOURCES } from "../../map/tile-source";
26
26
  import { lonLatToMercator } from "../../map/mercator";
27
27
  import { getScaledContext } from "../../axis/canvas";
28
28
 
@@ -76,7 +76,7 @@ export class MapChart extends CartesianChart {
76
76
  if (this._glManager) {
77
77
  this._tileLayer.setSource(
78
78
  this._glManager.gl,
79
- tileSourceFor(cfg.map_tile_provider as TileProviderId),
79
+ TILE_SOURCES.sourceFor(cfg.map_tile_provider),
80
80
  );
81
81
  }
82
82
 
@@ -97,9 +97,7 @@ export class MapChart extends CartesianChart {
97
97
  if (!this._tileLayer.source) {
98
98
  this._tileLayer.setSource(
99
99
  glManager.gl,
100
- tileSourceFor(
101
- this._pluginConfig.map_tile_provider as TileProviderId,
102
- ),
100
+ TILE_SOURCES.sourceFor(this._pluginConfig.map_tile_provider),
103
101
  );
104
102
  this._tileLayer.setAlpha(this._pluginConfig.map_tile_alpha);
105
103
  }
@@ -336,6 +336,14 @@ export interface SeriesPipelineResult {
336
336
  * is the position. Indexed by `catIdx` (0..numCategories-1).
337
337
  */
338
338
  categoryPositions: Float64Array | null;
339
+
340
+ /**
341
+ * One entry per (aggregate × split), in `k * P + p` order. INVARIANT:
342
+ * `series.length === aggregates.length * splitPrefixes.length` —
343
+ * consumers index `series[k * P]` directly (glyph runs, the
344
+ * `domain_mode: "expand"` axis signature), so an empty `series` MUST
345
+ * be paired with empty `aggregates` / `splitPrefixes`.
346
+ */
339
347
  series: SeriesInfo[];
340
348
 
341
349
  /**
@@ -477,10 +485,14 @@ export function buildSeriesPipeline(
477
485
  );
478
486
 
479
487
  if (numCategories === 0) {
488
+ // NOT `aggregates` / `splitPrefixes`: `series` is empty here, and
489
+ // carrying the non-empty lists would break the `series.length ===
490
+ // M * P` invariant — `series[k * P]` consumers then read
491
+ // `undefined` (the `loadAndRender failed … reading 'axis'` crash).
492
+ // Same shape as the `aggregates.length === 0` return above, which
493
+ // every downstream path already renders as an empty chart.
480
494
  return {
481
495
  ...empty,
482
- aggregates,
483
- splitPrefixes,
484
496
  rowPaths,
485
497
  rowOffset,
486
498
  };