@mhkeller/vgplot-dot-gl 1.0.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.
@@ -0,0 +1,202 @@
1
+ import { VERTEX, FRAGMENT, UNIFORMS } from './shaders.js';
2
+
3
+ /**
4
+ * One WebGL context for the whole page. Every dot mark draws its points here and
5
+ * then copies the picture into its own small canvas inside its plot. Sharing one
6
+ * context keeps a page full of plots well under the browser's limit on how many
7
+ * WebGL contexts can be alive at once.
8
+ *
9
+ * There are two ways to copy the picture out, and the choice is made once for the
10
+ * whole page by the first mark that asks:
11
+ * - 'drawImage': a plain canvas that isn't on the page; each plot's 2D canvas
12
+ * draws from it. This is the default and the fast path on every engine today.
13
+ * Chrome reuses the texture already on the graphics card, and Firefox 140 fixed
14
+ * the readback this used to cost (bug 1938053).
15
+ * - 'bitmaprenderer': an OffscreenCanvas; each frame is handed over as an
16
+ * ImageBitmap. In theory this hands over the pixels without copying, but both
17
+ * Firefox (bug 1788206) and Safari (WebKit 234920) still read the whole image
18
+ * back to the processor first, so it is the slower one on two engines out of
19
+ * three. It is kept for measuring, not for use.
20
+ */
21
+
22
+ // Two library copies on one page share this record, so bump the number when the shaders, the attribute layout or the state object's methods change.
23
+ const KEY = Symbol.for('vgplot-dot-gl/shared-gl@1');
24
+
25
+ /**
26
+ * The page-wide record, made the first time it is read. `generation` counts contexts and context
27
+ * losses over the life of the page, so data left over from an old context is never used as if it were still there.
28
+ */
29
+ const page = () => (globalThis[KEY] ??= { shared: null, unsupported: false, generation: 0 });
30
+
31
+ const CORNERS = new Float32Array([-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]);
32
+
33
+ function compile(gl, type, source) {
34
+ const shader = gl.createShader(type);
35
+ gl.shaderSource(shader, source);
36
+ gl.compileShader(shader);
37
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
38
+ const log = gl.getShaderInfoLog(shader);
39
+ gl.deleteShader(shader);
40
+ throw new Error(`dotGL: shader failed to compile: ${log}`);
41
+ }
42
+ return shader;
43
+ }
44
+
45
+ function buildProgram(gl) {
46
+ const program = gl.createProgram();
47
+ gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERTEX));
48
+ gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAGMENT));
49
+ gl.linkProgram(program);
50
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
51
+ throw new Error(`dotGL: program failed to link: ${gl.getProgramInfoLog(program)}`);
52
+ }
53
+ const uniforms = {};
54
+ for (const name of UNIFORMS) uniforms[name.slice(2)] = gl.getUniformLocation(program, name);
55
+ return { program, uniforms };
56
+ }
57
+
58
+ function setup(state) {
59
+ const { gl } = state;
60
+ Object.assign(state, buildProgram(gl));
61
+ state.quad = gl.createBuffer();
62
+ gl.bindBuffer(gl.ARRAY_BUFFER, state.quad);
63
+ gl.bufferData(gl.ARRAY_BUFFER, CORNERS, gl.STATIC_DRAW);
64
+ state.palette = gl.createTexture();
65
+ gl.bindTexture(gl.TEXTURE_2D, state.palette);
66
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
67
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
68
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
69
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
70
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 256, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
71
+ state.width = 0;
72
+ state.height = 0;
73
+ }
74
+
75
+ /**
76
+ * The shared context, made the first time someone asks. Returns null when the
77
+ * browser has no WebGL2, in which case the mark draws with plain canvas squares.
78
+ * @param {'drawImage'|'bitmaprenderer'} [blit]
79
+ */
80
+ export function getSharedGL(blit = 'drawImage') {
81
+ const record = page();
82
+ if (record.shared) return record.shared;
83
+ if (record.unsupported || typeof document === 'undefined') return null;
84
+ const offscreen = blit === 'bitmaprenderer' && typeof OffscreenCanvas !== 'undefined';
85
+ const canvas = offscreen ? new OffscreenCanvas(1, 1) : document.createElement('canvas');
86
+ const gl = canvas.getContext('webgl2', {
87
+ alpha: true,
88
+ premultipliedAlpha: true,
89
+ antialias: false,
90
+ depth: false,
91
+ stencil: false,
92
+ preserveDrawingBuffer: false
93
+ });
94
+ if (!gl) {
95
+ record.unsupported = true;
96
+ return null;
97
+ }
98
+ const state = {
99
+ canvas,
100
+ gl,
101
+ blit: offscreen ? 'bitmaprenderer' : 'drawImage',
102
+ /** Marks that currently have data stored here. */
103
+ refs: new Set(),
104
+ /** Goes up when the context is lost, so we know the stored data is gone. */
105
+ generation: ++record.generation,
106
+ lost: false,
107
+ maxSize: gl.getParameter(gl.MAX_RENDERBUFFER_SIZE)
108
+ };
109
+ setup(state);
110
+
111
+ canvas.addEventListener('webglcontextlost', event => {
112
+ event.preventDefault();
113
+ state.lost = true;
114
+ state.generation = ++page().generation;
115
+ });
116
+ canvas.addEventListener('webglcontextrestored', () => {
117
+ setup(state);
118
+ state.lost = false;
119
+ for (const mark of state.refs) mark.plot?.update();
120
+ });
121
+
122
+ /** Make the drawing area big enough for a plot of this size (in device pixels). */
123
+ state.ensureSize = (pw, ph) => {
124
+ const w = Math.min(state.maxSize, offscreen ? pw : Math.max(state.width, pw));
125
+ const h = Math.min(state.maxSize, offscreen ? ph : Math.max(state.height, ph));
126
+ if (w !== state.width || h !== state.height || canvas.width !== w || canvas.height !== h) {
127
+ canvas.width = w;
128
+ canvas.height = h;
129
+ state.width = w;
130
+ state.height = h;
131
+ }
132
+ };
133
+
134
+ /** Clear a plot-sized area in the top-left corner and set up blending. */
135
+ state.beginPlot = (pw, ph) => {
136
+ state.ensureSize(pw, ph);
137
+ gl.viewport(0, state.height - ph, pw, ph);
138
+ gl.scissor(0, state.height - ph, pw, ph);
139
+ gl.enable(gl.SCISSOR_TEST);
140
+ gl.clearColor(0, 0, 0, 0);
141
+ gl.clear(gl.COLOR_BUFFER_BIT);
142
+ gl.enable(gl.BLEND);
143
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
144
+ gl.disable(gl.DEPTH_TEST);
145
+ gl.useProgram(state.program);
146
+ gl.activeTexture(gl.TEXTURE0);
147
+ gl.bindTexture(gl.TEXTURE_2D, state.palette);
148
+ gl.uniform1i(state.uniforms.palette, 0);
149
+ };
150
+
151
+ /** Send up the first `rows` rows of the 256×256 palette (color i sits at column i % 256, row i / 256). */
152
+ state.setPalette = (rgba, rows) => {
153
+ gl.bindTexture(gl.TEXTURE_2D, state.palette);
154
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 256, rows, gl.RGBA, gl.UNSIGNED_BYTE, rgba);
155
+ };
156
+
157
+ /** One number per dot, stored on the graphics card and wired to `location` in the current vertex array. */
158
+ state.attrib = (location, array, type = gl.FLOAT) => {
159
+ const buffer = gl.createBuffer();
160
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
161
+ gl.bufferData(gl.ARRAY_BUFFER, array, gl.STATIC_DRAW);
162
+ gl.enableVertexAttribArray(location);
163
+ gl.vertexAttribPointer(location, 1, type, false, 0, 0);
164
+ gl.vertexAttribDivisor(location, 1);
165
+ return buffer;
166
+ };
167
+
168
+ /** Wire the square's corners to slot 0 (one per vertex, not per dot). */
169
+ state.bindQuad = () => {
170
+ gl.bindBuffer(gl.ARRAY_BUFFER, state.quad);
171
+ gl.enableVertexAttribArray(0);
172
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
173
+ gl.vertexAttribDivisor(0, 0);
174
+ };
175
+
176
+ /** Copy the plot-sized area into the mark's own canvas. */
177
+ state.blitTo = (target, pw, ph) => {
178
+ if (offscreen) {
179
+ const bitmap = canvas.transferToImageBitmap();
180
+ target.getContext('bitmaprenderer').transferFromImageBitmap(bitmap);
181
+ return;
182
+ }
183
+ const ctx = target.getContext('2d');
184
+ ctx.globalCompositeOperation = 'copy';
185
+ ctx.drawImage(canvas, 0, 0, pw, ph, 0, 0, pw, ph);
186
+ };
187
+
188
+ record.shared = state;
189
+ return state;
190
+ }
191
+
192
+ /** Let go of the shared context. For tests and page teardown; live marks send their data again on their next draw. */
193
+ export function disposeSharedGL() {
194
+ const record = page();
195
+ const state = record.shared;
196
+ if (!state) return;
197
+ for (const mark of Array.from(state.refs)) mark.gpu = null;
198
+ state.refs.clear();
199
+ state.generation = ++record.generation;
200
+ state.gl.getExtension('WEBGL_lose_context')?.loseContext();
201
+ record.shared = null;
202
+ }
package/src/tip.js ADDED
@@ -0,0 +1,438 @@
1
+ import { Query, column, eq, literal } from '@uwdata/mosaic-sql';
2
+ import { buildPickIndex, pickDot } from './pick.js';
3
+
4
+ /** The name the key column comes back under in the mark's data. */
5
+ export const KEY_AS = '__dotgl_key';
6
+
7
+ /** How far outside the dot the ring around it sits. */
8
+ const RING_PAD = 0;
9
+
10
+ /** Clear space between that ring and the tooltip, so the two don't crowd the pointer. */
11
+ const TIP_GAP = 45;
12
+
13
+ /** A paint this new doesn't get a pick index yet, so moving the pointer during a wheel zoom builds none. */
14
+ const QUIET_MS = 150;
15
+
16
+ /** How long the pointer rests on a dot before its extra fields are looked up. */
17
+ const REST_MS = 100;
18
+
19
+ const SVG = 'http://www.w3.org/2000/svg';
20
+ const NUMBER = new Intl.NumberFormat('en-US');
21
+ const NO_FIELDS = [];
22
+
23
+ /** Default looks. `:where()` gives them no specificity, so any page CSS wins. */
24
+ const STYLE = `
25
+ :where(.dotgl-tip) { z-index: 10; pointer-events: none; background: #fff; color: #222; border: 1px solid #ccc; border-radius: 3px; padding: 4px 6px; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; }
26
+ :where(.dotgl-tip th) { color: #555; font-weight: normal; text-align: left; padding: 0 8px 0 0; }
27
+ :where(.dotgl-tip td) { padding: 0; }
28
+ :where(.dotgl-swatch) { display: inline-block; width: 8px; height: 8px; margin-right: 4px; border-radius: 2px; }
29
+ `;
30
+
31
+ /**
32
+ * Arrow's type number for a time of day, from the Arrow format itself, and what to
33
+ * multiply each of its units by to get milliseconds. A time has no JavaScript
34
+ * equivalent, so it arrives as a plain integer counting whatever unit the column
35
+ * uses, unlike a date or a timestamp which arrive as Date objects.
36
+ */
37
+ const ARROW_TIME = 9;
38
+ const TO_MS = [1e3, 1, 1e-3, 1e-6];
39
+
40
+ /** The SQL types that carry a time zone, so their values really are instants in UTC. */
41
+ const ZONED = new Set(['TIMESTAMPTZ', 'TIMESTAMP WITH TIME ZONE']);
42
+
43
+ /** The time of day of an epoch time, with a trailing zero seconds or milliseconds left off. */
44
+ function timeOfDay(ms) {
45
+ return new Date(ms)
46
+ .toISOString()
47
+ .slice(11, 23)
48
+ .replace(/\.000$/, '')
49
+ .replace(/:00$/, '');
50
+ }
51
+
52
+ /**
53
+ * An epoch time as ISO, with a trailing zero seconds or milliseconds left off. A
54
+ * timestamp column keeps its time even at midnight, so every row of one column reads the
55
+ * same way; a bare Date, whose column type isn't known, drops it the way Plot does.
56
+ * `zone` is 'Z' only for a type that carries one: a plain DuckDB TIMESTAMP says nothing
57
+ * about where it is from.
58
+ */
59
+ function isoStamp(ms, zone, keepTime) {
60
+ const iso = new Date(ms).toISOString();
61
+ const time = timeOfDay(ms);
62
+ return !keepTime && time === '00:00'
63
+ ? iso.slice(0, 10)
64
+ : `${iso.slice(0, 10)}T${time}${zone}`;
65
+ }
66
+
67
+ /**
68
+ * A value as tooltip text.
69
+ *
70
+ * `kind` says how to read it: the column's SQL type when the column holds epoch
71
+ * milliseconds that stand for a date, or true for a Date object whose type isn't known.
72
+ * `fmt` is a tick format the page set for this scale, which wins when there is one, so
73
+ * the tooltip and the axis read the same.
74
+ */
75
+ function format(value, kind, fmt) {
76
+ const stamp = kind && typeof value === 'number';
77
+ const isDate = value instanceof Date || stamp;
78
+ if (isDate && !Number.isFinite(+value)) return '';
79
+ if (typeof value === 'number' && Number.isNaN(value)) return '';
80
+ // The axis hands a time scale's format a Date, so this does too.
81
+ if (fmt && value != null)
82
+ return String(fmt(stamp ? new Date(+value) : value));
83
+ if (isDate) {
84
+ const ms = +value;
85
+ if (kind === 'DATE') return new Date(ms).toISOString().slice(0, 10);
86
+ if (kind === 'TIME') return timeOfDay(ms);
87
+ return isoStamp(ms, ZONED.has(kind) ? 'Z' : '', typeof kind === 'string');
88
+ }
89
+ if (typeof value === 'number') return NUMBER.format(value);
90
+ return value == null ? '' : String(value);
91
+ }
92
+
93
+ /**
94
+ * The tooltip of a DotGLMark, added to its plot as a Mosaic interactor. It finds the
95
+ * dot under the pointer in what the mark last painted, rings it, and shows its group
96
+ * columns, x, y, fill and r next to it. Extra fields are looked up by key, one row at a time, once
97
+ * the pointer rests, and kept in `mark.tipRows`.
98
+ */
99
+ export class DotGLTip {
100
+ constructor(mark, { fields = null, maxRadius = 40 } = {}) {
101
+ this.mark = mark;
102
+ this.fields = fields;
103
+ this.maxRadius = maxRadius;
104
+ /** Columns the tip shows from the mark's own data. Extra fields with these names are left out. */
105
+ this.names = ['x', 'y', 'fill', 'r']
106
+ .map(name => mark.channelField(name, { exact: true })?.as)
107
+ .filter(Boolean);
108
+ this.svg = null;
109
+ this.index = null;
110
+ this.ring = null;
111
+ this.tip = null;
112
+ /** The pick on screen, and its key when it has extra fields. */
113
+ this.shown = null;
114
+ this.shownId = null;
115
+ /** The key whose extra fields are looked up next. */
116
+ this.wanted = null;
117
+ /** The field list `mark.tipRows` was filled for, and the fields in it the tip looks up. */
118
+ this.fieldsFor = null;
119
+ /** The kind of each extra field whose values need one to read right, filled in as rows arrive. */
120
+ this.extraKinds = {};
121
+ this.extras = NO_FIELDS;
122
+ this.clientX = 0;
123
+ this.clientY = 0;
124
+ /** Whether the pointer is over the plot with no button held. */
125
+ this.over = false;
126
+ this.raf = 0;
127
+ /** What the first tip does when the pointer leaves the plot. */
128
+ this.leave = null;
129
+ this.quietTimer = null;
130
+ this.restTimer = null;
131
+ this.busy = false;
132
+ this.warned = false;
133
+ }
134
+
135
+ /**
136
+ * Mosaic calls this on every interactor with each new SVG, before it replaces the old one. The plot's
137
+ * first DotGLTip listens to the pointer and picks for all of them, so the plot shows one tip at a time.
138
+ */
139
+ init(svg) {
140
+ this.hide();
141
+ this.svg = svg;
142
+ const tips = this.mark.plot.interactors.filter(i => i instanceof DotGLTip);
143
+ if (tips[0] !== this) return;
144
+ const el = this.mark.plot.element;
145
+ // A redraw comes with no pointer event, so a pointer still over the plot is picked again from the new paint.
146
+ // A plot taken out of the page and put back hears no leave, so `:hover` confirms the pointer is still there.
147
+ if (this.over && el.matches(':hover'))
148
+ this.raf ||= requestAnimationFrame(() => this.update(tips));
149
+ svg.addEventListener('pointermove', e => {
150
+ this.over = !e.buttons;
151
+ if (e.buttons) return this.stop(tips);
152
+ this.clientX = e.clientX;
153
+ this.clientY = e.clientY;
154
+ this.raf ||= requestAnimationFrame(() => this.update(tips));
155
+ });
156
+ // A pointer that leaves right after a redraw gets no leave event from the new SVG, which it never entered, so the plot element that holds every SVG listens too.
157
+ if (!this.leave) {
158
+ this.leave = () => {
159
+ this.over = false;
160
+ this.stop(tips);
161
+ for (const tip of tips) tip.index = null;
162
+ };
163
+ el.addEventListener('pointerleave', this.leave);
164
+ }
165
+ svg.addEventListener('pointerleave', this.leave);
166
+ }
167
+
168
+ /** Hides every tip on the plot and drops the pick waiting to run. */
169
+ stop(tips) {
170
+ for (const tip of tips) tip.hide();
171
+ cancelAnimationFrame(this.raf);
172
+ clearTimeout(this.quietTimer);
173
+ this.raf = 0;
174
+ }
175
+
176
+ /** Picks the dot under the last pointer position in each tip's mark and shows the closest. On a tie the later mark, drawn on top, wins. */
177
+ update(tips) {
178
+ this.raf = 0;
179
+ const at = new DOMPoint(this.clientX, this.clientY).matrixTransform(
180
+ this.svg.getScreenCTM().inverse(),
181
+ );
182
+ let best = null;
183
+ let owner = null;
184
+ for (const tip of tips) {
185
+ const { mark } = tip;
186
+ const paint = mark.lastPaint;
187
+ if (mark.destroyed || !paint || paint.prep !== mark.prep) continue;
188
+ if (tip.index?.paint !== paint) {
189
+ const wait = QUIET_MS - (performance.now() - paint.at);
190
+ if (wait > 0) {
191
+ clearTimeout(this.quietTimer);
192
+ this.quietTimer = setTimeout(() => this.update(tips), wait);
193
+ return;
194
+ }
195
+ tip.index = buildPickIndex(mark, paint);
196
+ }
197
+ const hit = pickDot(
198
+ tip.index,
199
+ at.x - paint.frame.fx,
200
+ at.y - paint.frame.fy,
201
+ tip.maxRadius,
202
+ );
203
+ if (hit && (!best || hit.key <= best.key)) {
204
+ best = hit;
205
+ owner = tip;
206
+ }
207
+ }
208
+ for (const tip of tips) if (tip !== owner) tip.hide();
209
+ if (owner && best.j !== owner.shown?.j) owner.show(best);
210
+ }
211
+
212
+ /** Rings the picked dot, shows its tip, and starts the rest timer for its extra fields when they aren't known yet. */
213
+ show(hit) {
214
+ const { mark, svg } = this;
215
+ const { frame } = this.index.paint;
216
+ const doc = svg.ownerDocument;
217
+ this.hide();
218
+ this.shown = hit;
219
+
220
+ if (!this.ring) {
221
+ this.ring = doc.createElementNS(SVG, 'circle');
222
+ this.ring.setAttribute('class', 'dotgl-ring');
223
+ this.ring.setAttribute('pointer-events', 'none');
224
+ this.ring.setAttribute('fill', 'none');
225
+ this.ring.setAttribute('stroke', 'currentColor');
226
+ this.tip = doc.createElement('div');
227
+ this.tip.className = 'dotgl-tip';
228
+ this.tip.setAttribute('aria-hidden', 'true');
229
+ if (!doc.head.querySelector('style[data-dotgl-tip]')) {
230
+ const style = doc.head.appendChild(doc.createElement('style'));
231
+ style.dataset.dotglTip = '';
232
+ style.textContent = STYLE;
233
+ }
234
+ }
235
+ this.ring.setAttribute('cx', hit.px + frame.fx);
236
+ this.ring.setAttribute('cy', hit.py + frame.fy);
237
+ this.ring.setAttribute('r', hit.r + RING_PAD);
238
+ svg.appendChild(this.ring);
239
+
240
+ // Fields are read now, so the page can change a Param holding them without rebuilding the plot.
241
+ const list = Array.isArray(this.fields)
242
+ ? this.fields
243
+ : (this.fields?.value ?? NO_FIELDS);
244
+ if (list !== this.fieldsFor) {
245
+ mark.tipRows = new Map();
246
+ this.fieldsFor = list;
247
+ this.extraKinds = {};
248
+ this.extras = list.filter(name => !this.names.includes(name));
249
+ }
250
+ if (this.extras.length) {
251
+ const id = mark.data.columns[KEY_AS][hit.j];
252
+ this.shownId = id;
253
+ // The row becomes wanted only once the pointer rests, so a lookup that finishes mid-sweep doesn't
254
+ // start one for the row the pointer is passing. A row without a key is never looked up.
255
+ if (!mark.tipRows.has(id)) {
256
+ this.restTimer = setTimeout(() => {
257
+ this.wanted = id;
258
+ this.fetch();
259
+ }, REST_MS);
260
+ }
261
+ }
262
+ this.draw();
263
+ }
264
+
265
+ /** Fills the tip with the shown dot's values and places it next to the dot. Extra fields not looked up yet show '…'. */
266
+ draw() {
267
+ const {
268
+ mark,
269
+ svg,
270
+ tip,
271
+ shown: { j, px, py, r },
272
+ } = this;
273
+ const { frame, prep, style, labels, formats = {} } = this.index.paint;
274
+ const doc = tip.ownerDocument;
275
+ const table = doc.createElement('table');
276
+ // Values are user data, so they only ever go in as text.
277
+ const row = (label, text, color) => {
278
+ const tr = table.appendChild(doc.createElement('tr'));
279
+ tr.appendChild(doc.createElement('th')).textContent = label;
280
+ const td = tr.appendChild(doc.createElement('td'));
281
+ if (color) {
282
+ const swatch = td.appendChild(doc.createElement('span'));
283
+ swatch.className = 'dotgl-swatch';
284
+ swatch.style.background = color;
285
+ }
286
+ td.append(text);
287
+ };
288
+ const channel = name => mark.channelField(name, { exact: true });
289
+ const value = name => mark.data.columns[channel(name).as][j];
290
+ const fill = channel('fill');
291
+ const code = prep.codes[j];
292
+ const p = style.palette;
293
+ const color =
294
+ fill &&
295
+ `rgba(${p[code * 4]}, ${p[code * 4 + 1]}, ${p[code * 4 + 2]}, ${p[code * 4 + 3] / 255})`;
296
+ // A group column that is also the fill column shows once, as that group's row with the swatch.
297
+ const fillGroup =
298
+ fill && mark.groups.find(g => g.name === fill.field.column);
299
+ // The group columns come first: they say which group the dot is.
300
+ for (const group of mark.groups)
301
+ row(
302
+ group.name,
303
+ format(mark.data.columns[group.as][j]),
304
+ group === fillGroup && color,
305
+ );
306
+ for (const name of ['x', 'y']) {
307
+ const cats = prep[`${name}Cats`];
308
+ row(
309
+ labels[name] ?? channel(name).as,
310
+ format(
311
+ cats ? cats[value(name)] : value(name),
312
+ prep.dates[name],
313
+ formats[name],
314
+ ),
315
+ );
316
+ }
317
+ if (fill && !fillGroup) {
318
+ row(
319
+ fill.as,
320
+ prep.continuous
321
+ ? format(value('fill'), prep.dates.fill, formats.fill)
322
+ : format(prep.cats[code], false, formats.fill),
323
+ color,
324
+ );
325
+ }
326
+ if (channel('r')) row(channel('r').as, format(value('r'), prep.dates.r));
327
+ const pending = this.shownId != null && !mark.tipRows.has(this.shownId);
328
+ const extra = mark.tipRows.get(this.shownId);
329
+ for (const name of this.extras)
330
+ row(name, pending ? '…' : format(extra?.[name], this.extraKinds[name]));
331
+ tip.replaceChildren(table);
332
+
333
+ // Right of the dot, or left of it when there is no room; kept inside the plot element vertically.
334
+ const el = mark.plot.element;
335
+ if (getComputedStyle(el).position === 'static')
336
+ el.style.position = 'relative';
337
+ tip.style.cssText = 'position:absolute;left:0;top:0';
338
+ el.appendChild(tip);
339
+ const ctm = svg.getScreenCTM();
340
+ const box = el.getBoundingClientRect();
341
+ const gap = r + RING_PAD + TIP_GAP;
342
+ const rightOf = new DOMPoint(
343
+ px + frame.fx + gap,
344
+ py + frame.fy,
345
+ ).matrixTransform(ctm);
346
+ let left = rightOf.x - box.left;
347
+ if (left + tip.offsetWidth > el.clientWidth) {
348
+ left =
349
+ new DOMPoint(px + frame.fx - gap, 0).matrixTransform(ctm).x -
350
+ box.left -
351
+ tip.offsetWidth;
352
+ }
353
+ const top = Math.max(
354
+ 0,
355
+ Math.min(
356
+ rightOf.y - box.top - tip.offsetHeight / 2,
357
+ el.clientHeight - tip.offsetHeight,
358
+ ),
359
+ );
360
+ tip.style.left = `${left}px`;
361
+ tip.style.top = `${top}px`;
362
+ }
363
+
364
+ hide() {
365
+ this.ring?.remove();
366
+ this.tip?.remove();
367
+ this.shown = null;
368
+ this.shownId = null;
369
+ this.wanted = null;
370
+ clearTimeout(this.restTimer);
371
+ }
372
+
373
+ /** Looks up the extra fields of the wanted row, one query at a time, until the wanted row is known. */
374
+ async fetch() {
375
+ if (this.busy) return;
376
+ this.busy = true;
377
+ const { mark } = this;
378
+ try {
379
+ while (
380
+ this.wanted != null &&
381
+ mark.coordinator &&
382
+ !mark.tipRows.has(this.wanted)
383
+ ) {
384
+ const id = this.wanted;
385
+ const rows = mark.tipRows;
386
+ const fields = this.fieldsFor;
387
+ const select = Object.fromEntries(
388
+ fields.map(name => [name, column(name)]),
389
+ );
390
+ const query = Query.from({ source: mark.sourceTable() })
391
+ .select(select)
392
+ .where(eq(mark.key, literal(id)))
393
+ .limit(1);
394
+ const table = await mark.coordinator.query(query, { cache: false });
395
+ // Columns are read in select order: DuckDB names a column the way the table spells it, which can differ in case from the field.
396
+ // Each value is read on its own, so one that can't be read (a 64-bit integer past 2^53) leaves only its own cell empty.
397
+ // A time column is turned into milliseconds since midnight and remembered as a time, so it
398
+ // shows as one; the result carries its own Arrow type, so this costs no extra query.
399
+ const read = (k, name) => {
400
+ const child = table.getChildAt(k);
401
+ try {
402
+ const value = child.at(0);
403
+ if (child.type?.typeId !== ARROW_TIME) return value;
404
+ this.extraKinds[name] = 'TIME';
405
+ return typeof value === 'number'
406
+ ? value * (TO_MS[child.type.unit] ?? 1)
407
+ : value;
408
+ } catch {
409
+ return null;
410
+ }
411
+ };
412
+ rows.set(
413
+ id,
414
+ table.numRows
415
+ ? Object.fromEntries(
416
+ Object.keys(select).map((name, k) => [name, read(k, name)]),
417
+ )
418
+ : null,
419
+ );
420
+ // Skip the redraw when the table, the fields or the painted dots changed while the query ran.
421
+ if (
422
+ rows === mark.tipRows &&
423
+ this.shownId === id &&
424
+ mark.lastPaint === this.index?.paint
425
+ )
426
+ this.draw();
427
+ }
428
+ } catch (err) {
429
+ if (!this.warned)
430
+ console.warn(
431
+ `dotGL: tooltip fields lookup failed: ${err?.message ?? err}`,
432
+ );
433
+ this.warned = true;
434
+ } finally {
435
+ this.busy = false;
436
+ }
437
+ }
438
+ }