@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,216 @@
1
+ import { getSharedGL } from '../shared-gl.js';
2
+ import { transformFor, affine, axisAffine, axisTransform, samePlaces } from '../scale-map.js';
3
+
4
+ const SCRATCH = new Uint8Array(4);
5
+
6
+ /** The lowest pixel ratio a lower-resolution frame may use while you zoom. */
7
+ const MIN_REDUCED_DPR = 1;
8
+
9
+ /**
10
+ * The WebGL painter. The point data is sent to the graphics card once per query
11
+ * result, and again if a scale changes type, a category axis moves its categories,
12
+ * the center moves after a deep zoom, or the context is lost. After that each frame is a few numbers, one draw call,
13
+ * and a copy into the mark's canvas.
14
+ */
15
+
16
+ function scaleKey(sx, sy, sr) {
17
+ const part = s => (s ? `${s.type}:${s.exponent ?? ''}:${s.constant ?? ''}` : '-');
18
+ return `${part(sx)}|${part(sy)}|${part(sr)}`;
19
+ }
20
+
21
+ /** Middle of the data range after the scale's curve (log, sqrt, ...) is applied. Subtracting it keeps the 32-bit float values small and precise. */
22
+ function center(T, extent) {
23
+ const a = T(+extent[0]);
24
+ const b = T(+extent[1]);
25
+ if (Number.isFinite(a) && Number.isFinite(b)) return (a + b) / 2;
26
+ if (Number.isFinite(b)) return b;
27
+ if (Number.isFinite(a)) return a;
28
+ return 0;
29
+ }
30
+
31
+ export function freeGPU(mark) {
32
+ const gpu = mark.gpu;
33
+ mark.gpu = null;
34
+ if (!gpu) return;
35
+ const { shared } = gpu;
36
+ shared.refs.delete(mark);
37
+ if (gpu.generation !== shared.generation || shared.lost) return; // the data died with the old context
38
+ const { gl } = shared;
39
+ for (const b of gpu.buffers) gl.deleteBuffer(b);
40
+ gl.deleteVertexArray(gpu.vao);
41
+ }
42
+
43
+ /** 32-bit floats have about seven digits. When rounding would move dots by more than this many pixels, we re-center. */
44
+ const MAX_DRIFT_PX = 0.1;
45
+ const FLOAT32_EPS = 6e-8;
46
+
47
+ /**
48
+ * Which center to subtract before sending data up. Normally the middle of the
49
+ * data range. When you have zoomed in so far that 32-bit rounding of the centered
50
+ * values would move dots by a visible fraction of a pixel, the middle of what is
51
+ * on screen is used instead. That costs one more upload. A category axis uses 0:
52
+ * its places are small integers, which 32-bit floats store exactly.
53
+ */
54
+ function centersFor(mark, sx, sy) {
55
+ const { prep, gpu } = mark;
56
+ const tx = transformFor(sx, 'x');
57
+ const ty = transformFor(sy, 'y');
58
+ let cx = prep.xCats ? 0 : gpu ? gpu.cx : center(tx, prep.extent.x);
59
+ let cy = prep.yCats ? 0 : gpu ? gpu.cy : center(ty, prep.extent.y);
60
+ if (gpu) {
61
+ const drift = (T, s, c) => {
62
+ const mid = (T(+s.domain[0]) + T(+s.domain[1])) / 2;
63
+ if (!Number.isFinite(mid)) return null;
64
+ const { a } = affine(s, 0, 0);
65
+ return Math.abs(mid - c) * FLOAT32_EPS * Math.abs(a) > MAX_DRIFT_PX ? mid : null;
66
+ };
67
+ const nx = prep.xCats ? null : drift(tx, sx, cx);
68
+ const ny = prep.yCats ? null : drift(ty, sy, cy);
69
+ if (nx != null) cx = nx;
70
+ if (ny != null) cy = ny;
71
+ }
72
+ return { cx, cy };
73
+ }
74
+
75
+ function upload(mark, shared, sx, sy, sr, lines) {
76
+ const { cx, cy } = centersFor(mark, sx, sy);
77
+ const key = `${scaleKey(sx, sy, sr)}|${cx}|${cy}`;
78
+ const { gpu, prep, data } = mark;
79
+ const places = { x: lines.x?.pos ?? null, y: lines.y?.pos ?? null };
80
+ if (
81
+ gpu && gpu.shared === shared && gpu.data === data && gpu.prep === prep && gpu.key === key && gpu.generation === shared.generation &&
82
+ samePlaces(gpu.places.x, places.x) && samePlaces(gpu.places.y, places.y)
83
+ ) return gpu;
84
+ freeGPU(mark);
85
+
86
+ const t0 = performance.now();
87
+ const { gl } = shared;
88
+ const columns = data.columns;
89
+ const column = name => {
90
+ const f = mark.channelField(name, { exact: true });
91
+ return f ? columns[f.as] : null;
92
+ };
93
+ const X = column('x');
94
+ const Y = column('y');
95
+ const R = sr ? column('r') : null;
96
+ const tx = axisTransform(sx, lines.x, 'x');
97
+ const ty = axisTransform(sy, lines.y, 'y');
98
+ const tr = R ? transformFor(sr, 'r') : null;
99
+
100
+ const { n, perm, codes, hidden } = prep;
101
+ const fx = new Float32Array(n);
102
+ const fy = new Float32Array(n);
103
+ const fr = R ? new Float32Array(n) : null;
104
+ const cat = new codes.constructor(n);
105
+ let sumR = 0;
106
+ for (let i = 0; i < n; ++i) {
107
+ const j = perm[i];
108
+ const vx = tx(+X[j]) - cx;
109
+ const vy = ty(+Y[j]) - cy;
110
+ let ok = Number.isFinite(vx) && Number.isFinite(vy);
111
+ if (fr) {
112
+ const vr = tr(+R[j]);
113
+ ok = ok && Number.isFinite(vr);
114
+ fr[i] = ok ? vr : 0;
115
+ if (ok) sumR += vr;
116
+ }
117
+ fx[i] = ok ? vx : 0;
118
+ fy[i] = ok ? vy : 0;
119
+ cat[i] = ok ? codes[j] : hidden;
120
+ }
121
+
122
+ const vao = gl.createVertexArray();
123
+ gl.bindVertexArray(vao);
124
+ shared.bindQuad();
125
+ const buffers = [shared.attrib(1, fx), shared.attrib(2, fy), shared.attrib(4, cat, cat instanceof Uint16Array ? gl.UNSIGNED_SHORT : gl.UNSIGNED_BYTE)];
126
+ if (fr) buffers.push(shared.attrib(3, fr));
127
+ else {
128
+ gl.disableVertexAttribArray(3);
129
+ gl.vertexAttrib1f(3, 0);
130
+ }
131
+ gl.bindVertexArray(null);
132
+
133
+ mark.gpu = { shared, vao, buffers, n, cx, cy, hasR: !!fr, meanT: n ? sumR / n : 0, key, places, data, prep, generation: shared.generation, uploadMs: performance.now() - t0 };
134
+ shared.refs.add(mark);
135
+ return mark.gpu;
136
+ }
137
+
138
+ /**
139
+ * How many pixels a frame will paint: dots times the area of their squares.
140
+ * This number decides whether the mark draws at a lower resolution while you zoom, which keeps dense plots smooth.
141
+ */
142
+ function estimateFragments(gpu, sr, style, dpr) {
143
+ let r = style.r;
144
+ if (gpu.hasR) {
145
+ const ar = affine(sr, 0, 0, 'r');
146
+ r = Math.max(0, ar.a * gpu.meanT + ar.b);
147
+ }
148
+ const side = 2 * r * dpr + 2;
149
+ return gpu.n * side * side;
150
+ }
151
+
152
+ /**
153
+ * @param {object} options
154
+ * @param {boolean} [options.allowReduce] draw at a lower resolution when the
155
+ * estimated painting work is over the mark's budget (the mark then schedules
156
+ * a full-resolution redraw once zooming stops)
157
+ */
158
+ export function paintGL(mark, canvas, { sx, sy, sr, lines, frame, style }, { allowReduce = false } = {}) {
159
+ const shared = getSharedGL(mark.blit);
160
+ // No WebGL2 at all. The mark picks the canvas painter instead, so this only happens
161
+ // if something calls the WebGL painter directly.
162
+ if (!shared) return { painter: 'gl', skipped: 'no webgl2' };
163
+ if (shared.lost) {
164
+ shared.refs.add(mark); // so the plot is redrawn too when the context comes back
165
+ return { painter: 'gl', skipped: 'context lost' };
166
+ }
167
+ const t0 = performance.now();
168
+ const gpu = upload(mark, shared, sx, sy, sr, lines);
169
+ const t1 = performance.now();
170
+ const { gl, uniforms: u } = shared;
171
+ const { fw, fh, offset, fx, fy } = frame;
172
+ let { pw, ph, dpr } = frame;
173
+ const estimate = estimateFragments(gpu, sr, style, dpr);
174
+ if (allowReduce && estimate > mark.fragmentBudget) {
175
+ dpr = Math.max(MIN_REDUCED_DPR, dpr * Math.sqrt(mark.fragmentBudget / estimate));
176
+ pw = Math.max(1, Math.round(fw * dpr));
177
+ ph = Math.max(1, Math.round(fh * dpr));
178
+ }
179
+ if (canvas.width !== pw || canvas.height !== ph) {
180
+ canvas.width = pw;
181
+ canvas.height = ph;
182
+ }
183
+
184
+ const ax = axisAffine(sx, lines.x, gpu.cx, -fx, 'x');
185
+ const ay = axisAffine(sy, lines.y, gpu.cy, -fy, 'y');
186
+ shared.beginPlot(pw, ph);
187
+ gl.bindVertexArray(gpu.vao);
188
+ gl.uniform2f(u.ax, ax.a, ax.b);
189
+ gl.uniform2f(u.ay, ay.a, ay.b);
190
+ gl.uniform2f(u.res, pw, ph);
191
+ gl.uniform1f(u.dpr, dpr);
192
+ gl.uniform1f(u.offset, offset);
193
+ gl.uniform1f(u.opacity, style.opacity);
194
+ gl.uniform1f(u.hidden, gpu.prep.hidden);
195
+ if (gpu.hasR) {
196
+ const ar = affine(sr, 0, 0, 'r');
197
+ gl.uniform4f(u.r, 1, ar.a, ar.b, 0);
198
+ } else {
199
+ gl.uniform4f(u.r, 0, 0, 0, style.r);
200
+ }
201
+ if (style.palette) {
202
+ shared.setPalette(style.palette, style.palette.length / (256 * 4));
203
+ gl.uniform1i(u.colorMode, 1);
204
+ } else {
205
+ gl.uniform1i(u.colorMode, 0);
206
+ gl.uniform4fv(u.color, style.fillRGBA);
207
+ }
208
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, gpu.n);
209
+ gl.bindVertexArray(null);
210
+ // gl.finish() doesn't reliably wait in Chrome; reading one pixel back does.
211
+ if (mark.benchmark) gl.readPixels(0, shared.height - 1, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, SCRATCH);
212
+ const t2 = performance.now();
213
+ shared.blitTo(canvas, pw, ph);
214
+ const t3 = performance.now();
215
+ return { painter: 'gl', drawn: gpu.n, uploadMs: t1 - t0, drawMs: t2 - t1, blitMs: t3 - t2, dpr, reduced: dpr < frame.dpr, estimate };
216
+ }
@@ -0,0 +1,61 @@
1
+ import { transformFor, affine, axisAffine, axisTransform } from '../scale-map.js';
2
+
3
+ /**
4
+ * A plain 2D canvas painter: one filled square per dot, in the same draw order
5
+ * as the WebGL painter. The mark picks it on its own when the browser has no
6
+ * WebGL2, and it is how the tests draw, since jsdom has no graphics card. Either
7
+ * way it has to put dots exactly where the WebGL painter does.
8
+ */
9
+ export function paintRect2D(mark, canvas, { sx, sy, sr, lines, frame, style }) {
10
+ const t0 = performance.now();
11
+ const { prep, data } = mark;
12
+ const { pw, ph, dpr, offset, fx, fy } = frame;
13
+ if (canvas.width !== pw || canvas.height !== ph) {
14
+ canvas.width = pw;
15
+ canvas.height = ph;
16
+ }
17
+ const ctx = canvas.getContext('2d');
18
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
19
+ ctx.clearRect(0, 0, pw, ph);
20
+
21
+ const column = name => {
22
+ const f = mark.channelField(name, { exact: true });
23
+ return f ? data.columns[f.as] : null;
24
+ };
25
+ const X = column('x');
26
+ const Y = column('y');
27
+ const R = sr ? column('r') : null;
28
+ const tx = axisTransform(sx, lines.x, 'x');
29
+ const ty = axisTransform(sy, lines.y, 'y');
30
+ const tr = sr ? transformFor(sr, 'r') : null;
31
+ const ax = axisAffine(sx, lines.x, 0, -fx, 'x');
32
+ const ay = axisAffine(sy, lines.y, 0, -fy, 'y');
33
+ const ar = sr ? affine(sr, 0, 0, 'r') : null;
34
+ const rConst = style.r;
35
+
36
+ const rgba = c => `rgba(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)},${c[3]})`;
37
+ const palette = style.palette
38
+ ? Array.from({ length: prep.levels }, (_, i) => rgba([style.palette[i * 4] / 255, style.palette[i * 4 + 1] / 255, style.palette[i * 4 + 2] / 255, style.palette[i * 4 + 3] / 255]))
39
+ : null;
40
+ const constant = rgba(style.fillRGBA);
41
+
42
+ ctx.globalAlpha = style.opacity;
43
+ let current = null;
44
+ const { perm, codes, hidden, n } = prep;
45
+ let drawn = 0;
46
+ for (let i = 0; i < n; ++i) {
47
+ const j = perm[i];
48
+ const code = codes[j];
49
+ if (code === hidden) continue;
50
+ const px = ax.a * tx(+X[j]) + ax.b + offset;
51
+ const py = ay.a * ty(+Y[j]) + ay.b + offset;
52
+ const r = R ? ar.a * tr(+R[j]) + ar.b : rConst;
53
+ if (!(r > 0) || !Number.isFinite(px) || !Number.isFinite(py)) continue;
54
+ const fill = palette ? palette[code] : constant;
55
+ if (fill !== current) ctx.fillStyle = current = fill;
56
+ const s = r * dpr;
57
+ ctx.fillRect(px * dpr - s, py * dpr - s, 2 * s, 2 * s);
58
+ ++drawn;
59
+ }
60
+ return { painter: 'rect2d', drawn, drawMs: performance.now() - t0 };
61
+ }
package/src/pick.js ADDED
@@ -0,0 +1,149 @@
1
+ import { transformFor, affine, axisAffine, axisTransform } from './scale-map.js';
2
+
3
+ /**
4
+ * Finds the dot under a point from what the mark painted, without the database.
5
+ *
6
+ * `buildPickIndex` sorts every visible dot of one paint into small screen cells,
7
+ * using the same scales, hide rules, clip frame and half-pixel offset as the
8
+ * painters. `pickDot` then looks only at the cells around the pointer, ring by
9
+ * ring, and stops as soon as no farther cell can hold a better dot.
10
+ */
11
+
12
+ /** Side of one grid cell, in CSS pixels. */
13
+ const CELL = 2;
14
+
15
+ /** Most dots kept out of the grid for their size. `pickDot` checks each of them on every pick, about 0.1 ms for all of them. */
16
+ const LARGE_DOTS = 4096;
17
+
18
+ /**
19
+ * The pick index for one paint (the mark's `lastPaint`). Cells cover the frame. A dot
20
+ * centered outside the frame that pokes into it goes in the nearest edge cell, which
21
+ * is closer to any pointer in the frame than the dot's center is. The few largest dots
22
+ * go in a list after the last cell, so the walk around the pointer only has to reach
23
+ * as far as the largest dot left in the grid. Within a cell and the list, draw indices rise.
24
+ */
25
+ export function buildPickIndex(mark, paint) {
26
+ const { sx, sy, sr, lines, frame, style, prep } = paint;
27
+ const empty = { paint, idx: new Uint32Array(0) };
28
+ if (style.opacity <= 0 || (!style.palette && style.fillRGBA[3] <= 0)) return empty;
29
+
30
+ const { fw, fh, offset } = frame;
31
+ const column = name => mark.data.columns[mark.channelField(name, { exact: true }).as];
32
+ const X = column('x');
33
+ const Y = column('y');
34
+ const R = sr ? column('r') : null;
35
+ const tx = axisTransform(sx, lines.x, 'x');
36
+ const ty = axisTransform(sy, lines.y, 'y');
37
+ const tr = sr ? transformFor(sr, 'r') : null;
38
+ const ax = axisAffine(sx, lines.x, 0, -frame.fx, 'x');
39
+ const ay = axisAffine(sy, lines.y, 0, -frame.fy, 'y');
40
+ const ar = sr ? affine(sr, 0, 0, 'r') : null;
41
+
42
+ const W = Math.max(1, Math.ceil(fw / CELL));
43
+ const H = Math.max(1, Math.ceil(fh / CELL));
44
+ const { perm, codes, hidden, n, total } = prep;
45
+ const palette = style.palette;
46
+ const cellOf = new Int32Array(total).fill(-1);
47
+ // Radii are counted in whole pixels up to the frame's diagonal. A walk that far already reaches every cell, so larger radii share the last count.
48
+ const bins = Math.ceil(Math.hypot(fw, fh));
49
+ const radii = R ? new Uint32Array(bins + 1) : null;
50
+ let rmax = 0;
51
+ // Rows go in row order, which reads the columns straight through memory. Rows left out of the draw order have the hidden code.
52
+ for (let j = 0; j < total; ++j) {
53
+ const code = codes[j];
54
+ if (code === hidden || (palette && palette[code * 4 + 3] === 0)) continue;
55
+ const px = ax.a * tx(+X[j]) + ax.b + offset;
56
+ const py = ay.a * ty(+Y[j]) + ay.b + offset;
57
+ const r = R ? ar.a * tr(+R[j]) + ar.b : style.r;
58
+ // Written as "not inside" so NaN positions and radii are skipped too. Neither painter draws an infinite radius.
59
+ if (!(r > 0 && r < Infinity && px + r >= 0 && px - r <= fw && py + r >= 0 && py - r <= fh)) continue;
60
+ cellOf[j] = Math.min(H - 1, Math.max(0, Math.floor(py / CELL))) * W + Math.min(W - 1, Math.max(0, Math.floor(px / CELL)));
61
+ if (r > rmax) rmax = r;
62
+ if (radii) ++radii[Math.min(bins, Math.ceil(r))];
63
+ }
64
+
65
+ // `cap` is the smallest whole-pixel radius that at most LARGE_DOTS dots exceed. When more than that
66
+ // many are bigger than the diagonal, every dot stays in the grid and `cap` is the largest radius.
67
+ let cap = rmax;
68
+ if (radii && radii[bins] <= LARGE_DOTS) {
69
+ let above = radii[bins];
70
+ cap = bins - 1;
71
+ while (cap > 0 && above + radii[cap] <= LARGE_DOTS) above += radii[cap--];
72
+ }
73
+ const large = W * H;
74
+ const start = new Uint32Array(large + 2);
75
+ for (let j = 0; j < total; ++j) {
76
+ if (cellOf[j] < 0) continue;
77
+ if (cap < rmax && ar.a * tr(+R[j]) + ar.b > cap) cellOf[j] = large;
78
+ ++start[cellOf[j]];
79
+ }
80
+ // Counting sort: after the running sum, start[c] is where cell c ends. Placing dots from the
81
+ // last draw index down moves it back to where cell c begins and leaves the indices rising.
82
+ for (let c = 1; c <= large + 1; ++c) start[c] += start[c - 1];
83
+ const idx = new Uint32Array(start[large + 1]);
84
+ for (let i = n - 1; i >= 0; --i) {
85
+ const c = cellOf[perm[i]];
86
+ if (c >= 0) idx[--start[c]] = i;
87
+ }
88
+ return { paint, cap, W, H, start, idx, X, Y, R, tx, ty, tr, ax, ay, ar };
89
+ }
90
+
91
+ /**
92
+ * The dot at (x, y), in CSS pixels from the top left of the paint's frame: the dot
93
+ * drawn last among those covering the point, or else the dot whose edge is
94
+ * closest, up to `maxRadius` away. Distances are to a circle for the 'gl' painter
95
+ * and to a square for 'rect2d'. Returns `{ i, j, px, py, r, key }` (draw index, row,
96
+ * center in frame pixels, radius, distance to the edge with 0 inside) or null.
97
+ */
98
+ export function pickDot(index, x, y, maxRadius) {
99
+ const { paint, idx } = index;
100
+ const { fw, fh, offset } = paint.frame;
101
+ if (!idx.length || !(x >= 0 && x <= fw && y >= 0 && y <= fh)) return null;
102
+
103
+ const { cap, W, H, start, X, Y, R, tx, ty, tr, ax, ay, ar } = index;
104
+ const { perm } = paint.prep;
105
+ const square = paint.painter === 'rect2d';
106
+ let best = -1;
107
+ let bestKey = Infinity;
108
+ let hit = null;
109
+ const scan = c => {
110
+ for (let s = start[c + 1] - 1; s >= start[c]; --s) {
111
+ const i = idx[s];
112
+ // Indices fall from here on, so none of them can beat a covering dot drawn later.
113
+ if (bestKey === 0 && i <= best) break;
114
+ const j = perm[i];
115
+ const px = ax.a * tx(+X[j]) + ax.b + offset;
116
+ const py = ay.a * ty(+Y[j]) + ay.b + offset;
117
+ const r = R ? ar.a * tr(+R[j]) + ar.b : paint.style.r;
118
+ const dx = Math.abs(px - x);
119
+ const dy = Math.abs(py - y);
120
+ const key = Math.max(0, (square ? Math.max(dx, dy) : Math.sqrt(dx * dx + dy * dy)) - r);
121
+ if (key > maxRadius || key > bestKey || (key === bestKey && i < best)) continue;
122
+ best = i;
123
+ bestKey = key;
124
+ hit = { i, j, px, py, r, key };
125
+ }
126
+ };
127
+
128
+ // The dots too large for the grid, one by one.
129
+ scan(W * H);
130
+ // Rings of cells around the pointer's cell. A center in ring k is at least (k - 1) * CELL pixels from the
131
+ // pointer, so its edge is at least that minus cap away; past the best edge distance so far, or past the grid, the walk stops.
132
+ const gx = Math.min(W - 1, Math.floor(x / CELL));
133
+ const gy = Math.min(H - 1, Math.floor(y / CELL));
134
+ const kmax = Math.min((cap + maxRadius) / CELL + 1, Math.max(gx, W - 1 - gx, gy, H - 1 - gy));
135
+ for (let k = 0; k <= kmax && (k - 1) * CELL - cap <= bestKey; ++k) {
136
+ const x0 = Math.max(0, gx - k);
137
+ const x1 = Math.min(W - 1, gx + k);
138
+ for (let cx = x0; cx <= x1; ++cx) {
139
+ if (gy - k >= 0) scan((gy - k) * W + cx);
140
+ if (k > 0 && gy + k < H) scan((gy + k) * W + cx);
141
+ }
142
+ const y1 = Math.min(H - 1, gy + k - 1);
143
+ for (let cy = Math.max(0, gy - k + 1); cy <= y1; ++cy) {
144
+ if (gx - k >= 0) scan(cy * W + gx - k);
145
+ if (k > 0 && gx + k < W) scan(cy * W + gx + k);
146
+ }
147
+ }
148
+ return hit;
149
+ }