@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
@@ -13,6 +13,17 @@
13
13
  import type { Canvas2D, Context2D } from "../charts/canvas-types";
14
14
  import type { PlotLayout, PlotRect } from "../layout/plot-layout";
15
15
  import { formatTickValue } from "../layout/ticks";
16
+ import {
17
+ LEGEND_ENTRY_LEADING,
18
+ LEGEND_FRAME_H,
19
+ LEGEND_FRAME_PAD_L,
20
+ LEGEND_FRAME_W,
21
+ LEGEND_HEADER_H,
22
+ LEGEND_LINE_HEIGHT,
23
+ LEGEND_TITLE_PAD,
24
+ type LegendAutoFit,
25
+ type LegendController,
26
+ } from "../interaction/legend-controller";
16
27
  import {
17
28
  colorValueToT,
18
29
  sampleGradient,
@@ -20,10 +31,219 @@ import {
20
31
  } from "../theme/gradient";
21
32
  import type { Theme } from "../theme/theme";
22
33
 
34
+ export { LEGEND_LINE_HEIGHT };
35
+
36
+ /** Painted scrollbar thumb width (the hit zone is wider). */
37
+ const SCROLLBAR_W = 4;
38
+
39
+ const LEGEND_BAR_W = 16;
40
+ const LEGEND_BAR_GAP = 5;
41
+
23
42
  function rgbCss(c: [number, number, number, number]): string {
24
43
  return `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`;
25
44
  }
26
45
 
46
+ /**
47
+ * Presentation context threaded into every legend painter. Carries the
48
+ * resolved `legend_mode` branch and the chart's {@link LegendController}
49
+ * — the painter reads its scroll offset and reports the painted
50
+ * geometry back so hit-testing always resolves against what is on
51
+ * screen. `"none"` mode never reaches a painter (call sites skip and
52
+ * `clearPainted()` instead).
53
+ */
54
+ export interface LegendPaintView {
55
+ mode: "sidebar" | "floating";
56
+ legend: LegendController;
57
+ title?: string;
58
+ sidebarGutter?: number;
59
+
60
+ /**
61
+ * Floating background-fill opacity (`plugin_config.legend_opacity`,
62
+ * default 1). Border, header text, and entries stay opaque.
63
+ */
64
+ opacity?: number;
65
+ }
66
+
67
+ const AUTO_WIDTH_MAX_SAMPLES = 64;
68
+
69
+ export function legendAutoFit(
70
+ canvas: Canvas2D | null | undefined,
71
+ theme: Theme,
72
+ entryCount: number,
73
+ labels: () => Iterable<string>,
74
+ opts: { title?: string; leading?: number; fontPx?: number } = {},
75
+ ): LegendAutoFit {
76
+ return {
77
+ entryCount,
78
+ boxWidth: () => {
79
+ const ctx = canvas?.getContext("2d") as Context2D | null;
80
+ if (!ctx) {
81
+ return 0;
82
+ }
83
+
84
+ ctx.save();
85
+ let text = 0;
86
+ let n = 0;
87
+ ctx.font = `${opts.fontPx ?? 11}px ${theme.fontFamily}`;
88
+ for (const label of labels()) {
89
+ text = Math.max(text, ctx.measureText(label).width);
90
+ if (++n >= AUTO_WIDTH_MAX_SAMPLES) {
91
+ break;
92
+ }
93
+ }
94
+
95
+ let title = 0;
96
+ if (opts.title) {
97
+ ctx.font = `bold 10px ${theme.fontFamily}`;
98
+ title = ctx.measureText(opts.title).width + LEGEND_TITLE_PAD;
99
+ }
100
+
101
+ ctx.restore();
102
+ const leading = opts.leading ?? LEGEND_ENTRY_LEADING;
103
+ return Math.ceil(Math.max(title, leading + text + LEGEND_FRAME_W));
104
+ },
105
+ };
106
+ }
107
+
108
+ export function gradientLegendAutoFit(
109
+ canvas: Canvas2D | null | undefined,
110
+ theme: Theme,
111
+ colorDomain: { min: number; max: number },
112
+ formatter: (v: number) => string = formatTickValue,
113
+ title?: string,
114
+ ): LegendAutoFit {
115
+ return legendAutoFit(
116
+ canvas,
117
+ theme,
118
+ 0,
119
+ () => [
120
+ formatter(colorDomain.max),
121
+ formatter((colorDomain.min + colorDomain.max) / 2),
122
+ formatter(colorDomain.min),
123
+ ],
124
+ {
125
+ title,
126
+ leading: LEGEND_BAR_W + LEGEND_BAR_GAP,
127
+ fontPx: 10,
128
+ },
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Paint the floating panel's chrome — themed background, border, and
134
+ * header strip — and return the content rect inside it. The header
135
+ * carries `title` and doubles as the move grip. `opacity` applies to
136
+ * the background fill only.
137
+ */
138
+ export function paintFloatingLegendFrame(
139
+ ctx: Context2D,
140
+ box: PlotRect,
141
+ theme: Theme,
142
+ title: string | undefined,
143
+ opacity: number = 1,
144
+ ): PlotRect {
145
+ ctx.save();
146
+ ctx.fillStyle = theme.backgroundColor;
147
+ ctx.strokeStyle = theme.legendBorder;
148
+ ctx.lineWidth = 1;
149
+ ctx.beginPath();
150
+ ctx.rect(box.x + 0.5, box.y + 0.5, box.width - 1, box.height - 1);
151
+ ctx.globalAlpha = Math.min(1, Math.max(0, opacity));
152
+ ctx.fill();
153
+ ctx.globalAlpha = 1;
154
+ ctx.stroke();
155
+
156
+ // Header separator line under the grip strip.
157
+ ctx.beginPath();
158
+ ctx.moveTo(box.x + 0.5, box.y + LEGEND_HEADER_H + 0.5);
159
+ ctx.lineTo(box.x + box.width - 0.5, box.y + LEGEND_HEADER_H + 0.5);
160
+ ctx.stroke();
161
+
162
+ if (title) {
163
+ ctx.fillStyle = theme.legendText;
164
+ ctx.font = `bold 10px ${theme.fontFamily}`;
165
+ ctx.textAlign = "left";
166
+ ctx.textBaseline = "middle";
167
+ ctx.fillText(
168
+ truncateText(ctx, title, Math.max(0, box.width - LEGEND_TITLE_PAD)),
169
+ box.x + 8,
170
+ box.y + LEGEND_HEADER_H / 2 + 0.5,
171
+ );
172
+ }
173
+
174
+ ctx.restore();
175
+ return {
176
+ x: box.x + LEGEND_FRAME_PAD_L,
177
+ y: box.y + LEGEND_HEADER_H + LEGEND_FRAME_H / 2,
178
+ width: Math.max(0, box.width - LEGEND_FRAME_W),
179
+ height: Math.max(0, box.height - LEGEND_HEADER_H - LEGEND_FRAME_H),
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Paint the scroll thumb on the content rect's right edge. No-op when
185
+ * the content fits.
186
+ */
187
+ export function paintLegendScrollbar(
188
+ ctx: Context2D,
189
+ content: PlotRect,
190
+ scroll: number,
191
+ contentHeight: number,
192
+ theme: Theme,
193
+ ): void {
194
+ if (contentHeight <= content.height) {
195
+ return;
196
+ }
197
+
198
+ const trackX = content.x + content.width - SCROLLBAR_W;
199
+ const thumbH = Math.max(
200
+ 20,
201
+ (content.height / contentHeight) * content.height,
202
+ );
203
+ const travel = content.height - thumbH;
204
+ const maxScroll = contentHeight - content.height;
205
+ const thumbY =
206
+ content.y + (maxScroll > 0 ? (scroll / maxScroll) * travel : 0);
207
+ ctx.save();
208
+ ctx.fillStyle = theme.legendBorder;
209
+ ctx.fillRect(trackX, content.y, SCROLLBAR_W, content.height);
210
+ ctx.fillStyle = theme.legendText;
211
+ ctx.fillRect(trackX, thumbY, SCROLLBAR_W, thumbH);
212
+ ctx.restore();
213
+ }
214
+
215
+ /**
216
+ * Clip `text` to `maxWidth` with a trailing ellipsis. Only called for
217
+ * entries inside the visible window, so the `measureText` cost is
218
+ * bounded by the box height, not the entry count.
219
+ */
220
+ export function truncateText(
221
+ ctx: Context2D,
222
+ text: string,
223
+ maxWidth: number,
224
+ ): string {
225
+ if (maxWidth <= 0) {
226
+ return "";
227
+ }
228
+
229
+ if (ctx.measureText(text).width <= maxWidth) {
230
+ return text;
231
+ }
232
+
233
+ let lo = 0;
234
+ let hi = text.length;
235
+ while (lo < hi) {
236
+ const mid = Math.ceil((lo + hi) / 2);
237
+ if (ctx.measureText(text.slice(0, mid) + "…").width <= maxWidth) {
238
+ lo = mid;
239
+ } else {
240
+ hi = mid - 1;
241
+ }
242
+ }
243
+
244
+ return lo > 0 ? text.slice(0, lo) + "…" : "…";
245
+ }
246
+
27
247
  /**
28
248
  * Render a vertical color gradient legend on the Canvas2D overlay.
29
249
  * Only call when a color column is active. When `colorDomain` crosses
@@ -40,6 +260,7 @@ export function renderLegend(
40
260
  stops: GradientStop[],
41
261
  theme: Theme,
42
262
  formatter?: (v: number) => string,
263
+ view?: LegendPaintView,
43
264
  ): void {
44
265
  const rect: PlotRect = {
45
266
  x: layout.plotRect.x + layout.plotRect.width + 12,
@@ -50,7 +271,15 @@ export function renderLegend(
50
271
  ),
51
272
  height: Math.max(1, layout.plotRect.height),
52
273
  };
53
- renderLegendAt(canvas, rect, colorDomain, stops, theme, formatter);
274
+ renderLegendAt(
275
+ canvas,
276
+ rect,
277
+ colorDomain,
278
+ stops,
279
+ theme,
280
+ formatter,
281
+ view && { ...view, sidebarGutter: layout.margins.right },
282
+ );
54
283
  }
55
284
 
56
285
  /**
@@ -65,6 +294,7 @@ export function renderLegendAt(
65
294
  stops: GradientStop[],
66
295
  theme: Theme,
67
296
  formatter: (v: number) => string = formatTickValue,
297
+ view?: LegendPaintView,
68
298
  ): void {
69
299
  const ctx = canvas.getContext("2d") as Context2D | null;
70
300
  if (!ctx) {
@@ -74,17 +304,34 @@ export function renderLegendAt(
74
304
  const textColor = theme.legendText;
75
305
  const borderColor = theme.legendBorder;
76
306
  const fontFamily = theme.fontFamily;
307
+ const floating = view?.mode === "floating";
77
308
 
78
- const barWidth = 16;
79
- const barHeight = Math.min(120, rect.height * 0.4);
80
- const x = rect.x;
81
- const y = rect.y;
82
-
83
- ctx.fillStyle = textColor;
84
- ctx.font = `9px ${fontFamily}`;
85
- ctx.textAlign = "left";
86
- ctx.textBaseline = "bottom";
87
- ctx.fillText(colorDomain.label, x, y - 4);
309
+ let x: number;
310
+ let y: number;
311
+ let barHeight: number;
312
+ let content: PlotRect = rect;
313
+ const barWidth = LEGEND_BAR_W;
314
+ if (floating) {
315
+ content = paintFloatingLegendFrame(
316
+ ctx,
317
+ rect,
318
+ theme,
319
+ colorDomain.label,
320
+ view?.opacity,
321
+ );
322
+ x = content.x;
323
+ y = content.y + 4;
324
+ barHeight = Math.max(8, content.height - 12);
325
+ } else {
326
+ x = rect.x;
327
+ y = rect.y;
328
+ barHeight = Math.min(120, rect.height * 0.4);
329
+ ctx.fillStyle = textColor;
330
+ ctx.font = `9px ${fontFamily}`;
331
+ ctx.textAlign = "left";
332
+ ctx.textBaseline = "bottom";
333
+ ctx.fillText(colorDomain.label, x, y - 4);
334
+ }
88
335
 
89
336
  // Paint the gradient by walking `colorDomain.min..max` top→bottom and
90
337
  // feeding each value through `colorValueToT` so the legend matches the
@@ -113,14 +360,27 @@ export function renderLegendAt(
113
360
  ctx.textAlign = "left";
114
361
  ctx.textBaseline = "middle";
115
362
 
116
- const labelX = x + barWidth + 5;
117
- ctx.fillText(formatter(colorDomain.max), labelX, y + 2);
363
+ const labelX = x + barWidth + LEGEND_BAR_GAP;
364
+ const labelW = Math.max(0, content.x + content.width - labelX);
118
365
  ctx.fillText(
119
- formatter((colorDomain.min + colorDomain.max) / 2),
366
+ truncateText(ctx, formatter(colorDomain.max), labelW),
367
+ labelX,
368
+ y + 2,
369
+ );
370
+ ctx.fillText(
371
+ truncateText(
372
+ ctx,
373
+ formatter((colorDomain.min + colorDomain.max) / 2),
374
+ labelW,
375
+ ),
120
376
  labelX,
121
377
  y + barHeight / 2,
122
378
  );
123
- ctx.fillText(formatter(colorDomain.min), labelX, y + barHeight - 2);
379
+ ctx.fillText(
380
+ truncateText(ctx, formatter(colorDomain.min), labelW),
381
+ labelX,
382
+ y + barHeight - 2,
383
+ );
124
384
 
125
385
  // Sign-pivot marker when the data crosses zero: a small tick on the
126
386
  // right edge of the bar + a "0" label.
@@ -137,6 +397,16 @@ export function renderLegendAt(
137
397
  ctx.fillStyle = textColor;
138
398
  ctx.fillText("0", labelX, zeroY);
139
399
  }
400
+
401
+ if (view) {
402
+ view.legend.setPainted({
403
+ mode: view.mode,
404
+ box: rect,
405
+ content,
406
+ contentHeight: Math.min(content.height, barHeight + 8),
407
+ sidebarGutter: view.sidebarGutter,
408
+ });
409
+ }
140
410
  }
141
411
 
142
412
  /**
@@ -154,6 +424,7 @@ export function renderCategoricalLegend(
154
424
  labels: Map<string, number>,
155
425
  palette: [number, number, number][],
156
426
  theme: Theme,
427
+ view?: LegendPaintView,
157
428
  ): void {
158
429
  const rect: PlotRect = {
159
430
  x: layout.plotRect.x + layout.plotRect.width + 12,
@@ -164,7 +435,14 @@ export function renderCategoricalLegend(
164
435
  ),
165
436
  height: Math.max(1, layout.plotRect.height),
166
437
  };
167
- renderCategoricalLegendAt(canvas, rect, labels, palette, theme);
438
+ renderCategoricalLegendAt(
439
+ canvas,
440
+ rect,
441
+ labels,
442
+ palette,
443
+ theme,
444
+ view && { ...view, sidebarGutter: layout.margins.right },
445
+ );
168
446
  }
169
447
 
170
448
  /**
@@ -178,6 +456,7 @@ export function renderCategoricalLegendAt(
178
456
  labels: Map<string, number>,
179
457
  palette: [number, number, number][],
180
458
  theme: Theme,
459
+ view?: LegendPaintView,
181
460
  ): void {
182
461
  const ctx = canvas.getContext("2d") as Context2D | null;
183
462
  if (!ctx) {
@@ -185,34 +464,82 @@ export function renderCategoricalLegendAt(
185
464
  }
186
465
 
187
466
  if (labels.size === 0) {
467
+ view?.legend.clearPainted();
188
468
  return;
189
469
  }
190
470
 
191
471
  const textColor = theme.legendText;
192
472
  const fontFamily = theme.fontFamily;
473
+ const floating = view?.mode === "floating";
474
+
475
+ let content: PlotRect = rect;
476
+ if (floating) {
477
+ content = paintFloatingLegendFrame(
478
+ ctx,
479
+ rect,
480
+ theme,
481
+ view?.title ?? "Legend",
482
+ view?.opacity,
483
+ );
484
+ }
193
485
 
194
486
  const swatchSize = 10;
195
- const lineHeight = 18;
196
- const x = rect.x;
197
- let y = rect.y + lineHeight / 2;
487
+ const lineHeight = LEGEND_LINE_HEIGHT;
488
+ const contentHeight = labels.size * lineHeight;
489
+ const scroll = view
490
+ ? view.legend.clampScroll(content.height, contentHeight)
491
+ : 0;
492
+ const scrollable = contentHeight > content.height;
493
+ const textMax = Math.max(
494
+ 0,
495
+ content.width - swatchSize - 6 - (scrollable ? SCROLLBAR_W + 4 : 0),
496
+ );
497
+
498
+ ctx.save();
499
+ ctx.beginPath();
500
+ ctx.rect(content.x, content.y, content.width, content.height);
501
+ ctx.clip();
198
502
 
199
503
  ctx.font = `11px ${fontFamily}`;
200
504
  ctx.textAlign = "left";
201
505
  ctx.textBaseline = "middle";
202
506
 
203
- for (const [label, idx] of labels) {
204
- if (y + swatchSize / 2 > rect.y + rect.height) {
507
+ const start = Math.floor(scroll / lineHeight);
508
+ const x = content.x;
509
+ let idx = 0;
510
+ let y = content.y + lineHeight / 2 + start * lineHeight - scroll;
511
+ for (const [label, palIdx] of labels) {
512
+ if (idx < start) {
513
+ idx++;
514
+ continue;
515
+ }
516
+
517
+ if (y - lineHeight / 2 >= content.y + content.height) {
205
518
  break;
206
519
  }
207
520
 
208
- const color = palette[idx] ??
209
- palette[idx % palette.length] ?? [0, 0, 0];
521
+ const color = palette[palIdx] ??
522
+ palette[palIdx % palette.length] ?? [0, 0, 0];
210
523
  ctx.fillStyle = `rgb(${Math.round(color[0] * 255)},${Math.round(color[1] * 255)},${Math.round(color[2] * 255)})`;
211
524
  ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize);
212
525
 
213
526
  ctx.fillStyle = textColor;
214
- ctx.fillText(label, x + swatchSize + 6, y);
527
+ ctx.fillText(truncateText(ctx, label, textMax), x + swatchSize + 6, y);
215
528
 
216
529
  y += lineHeight;
530
+ idx++;
531
+ }
532
+
533
+ ctx.restore();
534
+ paintLegendScrollbar(ctx, content, scroll, contentHeight, theme);
535
+
536
+ if (view) {
537
+ view.legend.setPainted({
538
+ mode: view.mode,
539
+ box: rect,
540
+ content,
541
+ contentHeight,
542
+ sidebarGutter: view.sidebarGutter,
543
+ });
217
544
  }
218
545
  }
@@ -0,0 +1,94 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ import type { PlotLayout } from "../layout/plot-layout";
14
+ import { computeNiceTicks } from "../layout/ticks";
15
+ import {
16
+ lonLatToMercator,
17
+ mercatorToLonLat,
18
+ MAX_LAT,
19
+ WORLD_HALF,
20
+ } from "../map/mercator";
21
+
22
+ export interface MapDegreeTicks {
23
+ xTicks: number[];
24
+ yTicks: number[];
25
+ formatX: (meters: number) => string;
26
+ formatY: (meters: number) => string;
27
+ }
28
+
29
+ /**
30
+ * Compute "nice" longitude/latitude ticks for the visible window of a
31
+ * map layout. Must be called AFTER `buildProjectionMatrix` has seeded
32
+ * the layout's padded-domain fields — the ticks are derived from the
33
+ * exact meter domain the projection maps, or labels would misalign
34
+ * with glyphs.
35
+ */
36
+ export function computeMapDegreeTicks(layout: PlotLayout): MapDegreeTicks {
37
+ const plot = layout.plotRect;
38
+ const targetX = Math.max(2, Math.floor(plot.width / 90));
39
+ const targetY = Math.max(2, Math.floor(plot.height / 60));
40
+
41
+ const lonMin = metersToLon(layout.paddedXMin);
42
+ const lonMax = metersToLon(layout.paddedXMax);
43
+ const latMin = clampLat(mercatorToLonLat(0, layout.paddedYMin)[1]);
44
+ const latMax = clampLat(mercatorToLonLat(0, layout.paddedYMax)[1]);
45
+
46
+ const lonTicks = safeNiceTicks(lonMin, lonMax, targetX);
47
+ const latTicks = safeNiceTicks(latMin, latMax, targetY);
48
+ const lonStep = tickStep(lonTicks);
49
+ const latStep = tickStep(latTicks);
50
+
51
+ return {
52
+ xTicks: lonTicks.map((deg) => (deg / 180) * WORLD_HALF),
53
+ yTicks: latTicks.map((deg) => lonLatToMercator(0, deg)[1]),
54
+ formatX: (m) => formatDegrees(metersToLon(m), lonStep, "E", "W"),
55
+ formatY: (m) =>
56
+ formatDegrees(mercatorToLonLat(0, m)[1], latStep, "N", "S"),
57
+ };
58
+ }
59
+
60
+ function metersToLon(m: number): number {
61
+ return (m / WORLD_HALF) * 180;
62
+ }
63
+
64
+ function clampLat(lat: number): number {
65
+ return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
66
+ }
67
+
68
+ function safeNiceTicks(min: number, max: number, target: number): number[] {
69
+ if (!isFinite(min) || !isFinite(max) || max <= min) {
70
+ return [];
71
+ }
72
+
73
+ return computeNiceTicks(min, max, target);
74
+ }
75
+
76
+ function tickStep(ticks: number[]): number {
77
+ return ticks.length > 1 ? Math.abs(ticks[1] - ticks[0]) : 1;
78
+ }
79
+
80
+ function formatDegrees(
81
+ deg: number,
82
+ step: number,
83
+ pos: string,
84
+ neg: string,
85
+ ): string {
86
+ const decimals =
87
+ step >= 1 ? 0 : Math.min(6, Math.ceil(-Math.log10(step) + 1e-9));
88
+ const magnitude = String(parseFloat(Math.abs(deg).toFixed(decimals)));
89
+ if (parseFloat(magnitude) === 0) {
90
+ return "0°";
91
+ }
92
+
93
+ return `${magnitude}°${deg > 0 ? pos : neg}`;
94
+ }
@@ -12,11 +12,7 @@
12
12
 
13
13
  import type { Canvas2D, Context2D } from "../charts/canvas-types";
14
14
  import { PlotLayout, type PlotRect } from "../layout/plot-layout";
15
- import {
16
- computeNiceTicks,
17
- formatTickValue,
18
- formatDateTickValue,
19
- } from "../layout/ticks";
15
+ import { computeNiceTicks, stepTickFormatter } from "../layout/ticks";
20
16
  import { getScaledContext } from "./canvas";
21
17
  import {
22
18
  drawGridlinesX,
@@ -81,10 +77,7 @@ function tickFmt(
81
77
  return override;
82
78
  }
83
79
 
84
- const step = ticks.length > 1 ? ticks[1] - ticks[0] : 0;
85
- return domain.isDate
86
- ? (v: number) => formatDateTickValue(v, step)
87
- : formatTickValue;
80
+ return stepTickFormatter(domain.isDate, ticks);
88
81
  }
89
82
 
90
83
  /**