@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.
- package/dist/cdn/perspective-viewer-charts.js +1 -2
- package/dist/cdn/perspective-viewer-charts.js.map +4 -4
- package/dist/esm/axis/legend.d.ts +53 -5
- package/dist/esm/axis/map-ticks.d.ts +15 -0
- package/dist/esm/charts/candlestick/candlestick-interact.d.ts +2 -6
- package/dist/esm/charts/candlestick/candlestick-render.d.ts +3 -0
- package/dist/esm/charts/cartesian/cartesian.d.ts +7 -0
- package/dist/esm/charts/cartesian/glyph.d.ts +2 -1
- package/dist/esm/charts/cartesian/glyphs/density.d.ts +2 -1
- package/dist/esm/charts/cartesian/glyphs/lines.d.ts +2 -1
- package/dist/esm/charts/cartesian/glyphs/points.d.ts +2 -1
- package/dist/esm/charts/cartesian/tooltip-lines.d.ts +1 -1
- package/dist/esm/charts/chart-base.d.ts +24 -9
- package/dist/esm/charts/chart.d.ts +81 -6
- package/dist/esm/charts/common/category-axis-resolver.d.ts +1 -1
- package/dist/esm/charts/common/draw-tooltip-box.d.ts +2 -1
- package/dist/esm/charts/common/tree-chart.d.ts +1 -0
- package/dist/esm/charts/common/tree-interact.d.ts +1 -1
- package/dist/esm/charts/heatmap/heatmap.d.ts +1 -0
- package/dist/esm/charts/series/series-build.d.ts +7 -0
- package/dist/esm/charts/series/series-interact.d.ts +1 -1
- package/dist/esm/charts/series/series-type.d.ts +18 -0
- package/dist/esm/charts/series/series.d.ts +10 -1
- package/dist/esm/charts/sunburst/sunburst-render.d.ts +4 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/interaction/axis-indicators.d.ts +11 -0
- package/dist/esm/interaction/host-sink-dom.d.ts +3 -2
- package/dist/esm/interaction/host-sink-message.d.ts +3 -2
- package/dist/esm/interaction/lazy-tooltip.d.ts +2 -2
- package/dist/esm/interaction/legend-controller.d.ts +246 -0
- package/dist/esm/interaction/tooltip-controller.d.ts +20 -95
- package/dist/esm/interaction/tooltip-grid.d.ts +33 -0
- package/dist/esm/layout/facet-grid.d.ts +6 -0
- package/dist/esm/layout/ticks.d.ts +1 -0
- package/dist/esm/map/tile-source.d.ts +77 -8
- package/dist/esm/perspective-viewer-charts.js +1 -2
- package/dist/esm/perspective-viewer-charts.js.map +4 -4
- package/dist/esm/plugin/charts.d.ts +2 -0
- package/dist/esm/plugin/format-defaults.d.ts +9 -0
- package/dist/esm/plugin/plugin.d.ts +28 -1
- package/dist/esm/theme/gradient.d.ts +32 -0
- package/dist/esm/theme/overrides.d.ts +19 -0
- package/dist/esm/transport/protocol.d.ts +58 -5
- package/dist/esm/transport/renderer-transport.d.ts +3 -0
- package/dist/esm/utils/css.d.ts +4 -0
- package/dist/esm/worker/renderer.worker.d.ts +46 -1
- package/package.json +1 -1
- package/src/css/perspective-viewer-charts.css +0 -1
- package/src/ts/axis/bar-axis.ts +2 -7
- package/src/ts/axis/legend.ts +351 -24
- package/src/ts/axis/map-ticks.ts +94 -0
- package/src/ts/axis/numeric-axis.ts +2 -9
- package/src/ts/charts/candlestick/candlestick-build.ts +1 -0
- package/src/ts/charts/candlestick/candlestick-interact.ts +46 -40
- package/src/ts/charts/candlestick/candlestick-render.ts +81 -10
- package/src/ts/charts/candlestick/candlestick.ts +4 -3
- package/src/ts/charts/cartesian/cartesian-interact.ts +9 -3
- package/src/ts/charts/cartesian/cartesian-render.ts +374 -75
- package/src/ts/charts/cartesian/cartesian.ts +13 -0
- package/src/ts/charts/cartesian/glyph.ts +6 -2
- package/src/ts/charts/cartesian/glyphs/density.ts +2 -2
- package/src/ts/charts/cartesian/glyphs/lines.ts +15 -11
- package/src/ts/charts/cartesian/glyphs/points.ts +9 -5
- package/src/ts/charts/cartesian/tooltip-lines.ts +8 -8
- package/src/ts/charts/chart-base.ts +76 -42
- package/src/ts/charts/chart.ts +111 -7
- package/src/ts/charts/common/category-axis-resolver.ts +44 -5
- package/src/ts/charts/common/draw-tooltip-box.ts +13 -30
- package/src/ts/charts/common/tree-chart.ts +4 -0
- package/src/ts/charts/common/tree-chrome.ts +102 -23
- package/src/ts/charts/common/tree-interact.ts +18 -17
- package/src/ts/charts/heatmap/heatmap-build.ts +7 -1
- package/src/ts/charts/heatmap/heatmap-interact.ts +75 -8
- package/src/ts/charts/heatmap/heatmap-render.ts +105 -24
- package/src/ts/charts/heatmap/heatmap.ts +8 -3
- package/src/ts/charts/map/map.ts +3 -5
- package/src/ts/charts/series/series-build.ts +15 -2
- package/src/ts/charts/series/series-interact.ts +14 -24
- package/src/ts/charts/series/series-render.ts +310 -132
- package/src/ts/charts/series/series-type.ts +65 -0
- package/src/ts/charts/series/series.ts +31 -8
- package/src/ts/charts/sunburst/sunburst-render.ts +16 -1
- package/src/ts/charts/treemap/treemap-render.ts +12 -1
- package/src/ts/data/view-reader.ts +1 -4
- package/src/ts/index.ts +2 -0
- package/src/ts/interaction/axis-indicators.ts +137 -0
- package/src/ts/interaction/host-sink-dom.ts +66 -2
- package/src/ts/interaction/host-sink-message.ts +8 -2
- package/src/ts/interaction/lazy-tooltip.ts +5 -5
- package/src/ts/interaction/legend-controller.ts +1084 -0
- package/src/ts/interaction/tooltip-controller.ts +43 -131
- package/src/ts/interaction/tooltip-grid.ts +187 -0
- package/src/ts/layout/facet-grid.ts +8 -1
- package/src/ts/layout/ticks.ts +48 -12
- package/src/ts/map/tile-loader.ts +16 -1
- package/src/ts/map/tile-source.ts +199 -53
- package/src/ts/map/tile-sources.json +18 -0
- package/src/ts/plugin/charts.ts +40 -6
- package/src/ts/plugin/format-defaults.ts +37 -0
- package/src/ts/plugin/plugin.ts +330 -76
- package/src/ts/shaders/line.vert.glsl +5 -2
- package/src/ts/shaders/scatter.vert.glsl +0 -7
- package/src/ts/theme/gradient.ts +265 -1
- package/src/ts/theme/overrides.ts +76 -0
- package/src/ts/transport/protocol.ts +60 -4
- package/src/ts/transport/renderer-transport.ts +107 -14
- package/src/ts/utils/css.ts +12 -0
- package/src/ts/worker/dispatch.ts +6 -2
- package/src/ts/worker/renderer.worker.ts +191 -7
package/src/ts/axis/legend.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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 +
|
|
117
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
196
|
-
const
|
|
197
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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[
|
|
209
|
-
palette[
|
|
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
|
-
|
|
85
|
-
return domain.isDate
|
|
86
|
-
? (v: number) => formatDateTickValue(v, step)
|
|
87
|
-
: formatTickValue;
|
|
80
|
+
return stepTickFormatter(domain.isDate, ticks);
|
|
88
81
|
}
|
|
89
82
|
|
|
90
83
|
/**
|