@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.
- package/LICENSE +21 -0
- package/README.md +234 -0
- package/package.json +64 -0
- package/src/DotGLMark.js +490 -0
- package/src/color.js +86 -0
- package/src/index.d.ts +51 -0
- package/src/index.js +1 -0
- package/src/painters/gl.js +216 -0
- package/src/painters/rect2d.js +61 -0
- package/src/pick.js +149 -0
- package/src/prepare.js +300 -0
- package/src/scale-map.js +129 -0
- package/src/shaders.js +66 -0
- package/src/shared-gl.js +202 -0
- package/src/tip.js +438 -0
package/src/DotGLMark.js
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import { Mark } from '@uwdata/mosaic-plot';
|
|
2
|
+
import { toDataColumns } from '@uwdata/mosaic-core';
|
|
3
|
+
import { Query, cast, coalesce, epoch_ms, float64, isColumnRef, literal, verbatim } from '@uwdata/mosaic-sql';
|
|
4
|
+
import { prepare } from './prepare.js';
|
|
5
|
+
import { paletteFromValues, paletteFromScale, parseColor } from './color.js';
|
|
6
|
+
import { categoryAxis } from './scale-map.js';
|
|
7
|
+
import { getSharedGL } from './shared-gl.js';
|
|
8
|
+
import { paintGL, freeGPU } from './painters/gl.js';
|
|
9
|
+
import { paintRect2D } from './painters/rect2d.js';
|
|
10
|
+
import { DotGLTip, KEY_AS } from './tip.js';
|
|
11
|
+
|
|
12
|
+
const SVG = 'http://www.w3.org/2000/svg';
|
|
13
|
+
|
|
14
|
+
/** Options that are ours. They never become Mosaic channels or Plot options; the mark adds `key`, `groupby` and `orderby` to its query itself. */
|
|
15
|
+
const OWN_OPTIONS = ['blit', 'sort', 'orderby', 'maxCategories', 'benchmark', 'fragmentBudget', 'key', 'groupby', 'tip'];
|
|
16
|
+
|
|
17
|
+
/** Options that used to exist. Without this they would be read as column names, and the error would not say why. */
|
|
18
|
+
const REMOVED_OPTIONS = {
|
|
19
|
+
painter: 'dotGL always draws with WebGL2 now, and falls back to a plain canvas on its own',
|
|
20
|
+
fallback: 'dotGL always draws with WebGL2 now, and falls back to a plain canvas on its own'
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** vg.dot options we accept as constants but can't draw. We warn once per mark. */
|
|
24
|
+
const IGNORED_OPTIONS = ['stroke', 'strokeWidth', 'strokeOpacity', 'symbol', 'rotate', 'dx', 'dy', 'title', 'href', 'select', 'frameAnchor'];
|
|
25
|
+
|
|
26
|
+
/** The only options that can be a column. */
|
|
27
|
+
const COLUMN_CHANNELS = ['x', 'y', 'r', 'fill'];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The extra channels that give Plot the hints, and their scales. Their names differ from the dot's own x, y, r
|
|
31
|
+
* and fill, which would replace extra channels of the same name.
|
|
32
|
+
*/
|
|
33
|
+
const HINTS = { x: ['dotglX', 'x'], y: ['dotglY', 'y'], r: ['dotglR', 'r'], fill: ['dotglFill', 'color'] };
|
|
34
|
+
|
|
35
|
+
/** Most categories an x or y column may have. It is Plot's own limit for an axis whose categories it works out itself. */
|
|
36
|
+
const MAX_AXIS_CATEGORIES = 10000;
|
|
37
|
+
|
|
38
|
+
/** Most categories a fill column may have. Codes are 16 bits, and 65535 hides a dot. */
|
|
39
|
+
const MAX_FILL_CATEGORIES = 65535;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The local Mosaic server turns away requests over 4 MiB, so the category lists in one query must stay under this.
|
|
43
|
+
* Mosaic can combine the unfiltered queries of several plots on one table into one request, and their lists then add up.
|
|
44
|
+
*/
|
|
45
|
+
const MAX_CATEGORY_BYTES = 3.5 * 1024 * 1024;
|
|
46
|
+
|
|
47
|
+
/** Sort the same way Plot sorts the values of a category axis or legend: ascending, with null last. */
|
|
48
|
+
const ascendingDefined = (a, b) => (a == null) - (b == null) || (a < b ? -1 : a > b ? 1 : 0);
|
|
49
|
+
|
|
50
|
+
/** A number as a DOUBLE, with NaN for null, so the column arrives as a Float64Array. (`literal(NaN)` would print NULL.) */
|
|
51
|
+
const asDouble = e => coalesce(float64(e), verbatim("'NaN'::DOUBLE"));
|
|
52
|
+
|
|
53
|
+
/** How long the plot has to hold still before a lower-resolution frame is drawn again at full resolution. */
|
|
54
|
+
const REFINE_DELAY_MS = 150;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* SQL that gives each row the position of its value in `cats` (sorted, null last). Up to 254 categories
|
|
58
|
+
* come back as UTINYINT with 255 for "hidden", more as USMALLINT with 65535. Values outside the list get the
|
|
59
|
+
* hidden code. The column is cast to VARCHAR on both sides, because TRY_CAST from UUID or JSON straight to an
|
|
60
|
+
* ENUM gives NULL for every row.
|
|
61
|
+
*/
|
|
62
|
+
function categorySQL(col, cats) {
|
|
63
|
+
const small = cats.length <= 254;
|
|
64
|
+
const hidden = small ? 255 : 65535;
|
|
65
|
+
const nullCode = cats[cats.length - 1] === null ? cats.length - 1 : hidden;
|
|
66
|
+
const values = cats.filter(v => v !== null).map(v => String(literal(v)));
|
|
67
|
+
// DuckDB has no empty ENUM, so a column with no values besides null skips the lookup.
|
|
68
|
+
const known = values.length ? `COALESCE(enum_code(TRY_CAST(CAST(${col} AS VARCHAR) AS ENUM(${values.join(', ')}))), ${hidden})` : hidden;
|
|
69
|
+
return `CAST(CASE WHEN ${col} IS NULL THEN ${nullCode} ELSE ${known} END AS ${small ? 'UTINYINT' : 'USMALLINT'})`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A dot mark that draws its points with WebGL instead of as SVG circles.
|
|
74
|
+
*
|
|
75
|
+
* It is a mosaic-plot Mark, so it gets the database query, the data decoding and
|
|
76
|
+
* the option handling for free. Observable Plot still makes the axes, scales and
|
|
77
|
+
* legend. Instead of the rows, the mark gives Plot a few numbers (the lowest and
|
|
78
|
+
* highest values, the categories in the result), and Plot works out the same scales
|
|
79
|
+
* from those. The mark then draws the real rows into a canvas placed inside the
|
|
80
|
+
* plot's SVG.
|
|
81
|
+
*
|
|
82
|
+
* WebGL2 does the drawing. On the rare browser without it the mark falls back on
|
|
83
|
+
* its own to plain canvas squares, which is also how the tests draw, since jsdom
|
|
84
|
+
* has no graphics card.
|
|
85
|
+
*
|
|
86
|
+
* Options are the ones vg.dot has for x, y, r, fill, opacity and clip, plus:
|
|
87
|
+
* - blit: 'drawImage' (default) or 'bitmaprenderer', how the picture is copied into the plot
|
|
88
|
+
* - sort: '-r' (default, big dots first when r is a column) or null
|
|
89
|
+
* - orderby: what the query sorts rows by (a column name, `column()`, `desc()` or a `sql` fragment);
|
|
90
|
+
* rows are drawn in that order, later rows on top
|
|
91
|
+
* - maxCategories: how many different fill values a database column may have (65,535 at most; array data allows 254)
|
|
92
|
+
* - benchmark: true waits for the graphics card after each draw so the timings are real
|
|
93
|
+
* - fragmentBudget: how much painting one frame may do before the mark draws at a
|
|
94
|
+
* lower resolution while you zoom and draws again at full resolution once you stop (default 4e7;
|
|
95
|
+
* Infinity turns it off)
|
|
96
|
+
* - key: an expression for a unique row id, added to the query under a private name
|
|
97
|
+
* so the tooltip can look up more fields for one row
|
|
98
|
+
* - groupby: a column name, `column()` or expression, or an array of them, that the query
|
|
99
|
+
* groups by, for marks whose x and y are aggregates; a column comes back under its own name
|
|
100
|
+
* unless a channel already uses it, and the tooltip shows each one
|
|
101
|
+
* - tip: true, or `{ fields, maxRadius }`, shows a tooltip for the dot under the pointer;
|
|
102
|
+
* `fields` (an array of column names, or a Param holding one) are looked up by key
|
|
103
|
+
*
|
|
104
|
+
* The x, y and fill columns are handled by their database type. Number and date
|
|
105
|
+
* columns come back as doubles (dates as epoch milliseconds). Text and boolean
|
|
106
|
+
* columns are categories: the distinct values are fetched once per table, and the
|
|
107
|
+
* query returns each row's position in the sorted list, which the graphics card
|
|
108
|
+
* draws. Plot gets the categories that are in each result, so axes and legends show
|
|
109
|
+
* the text, and a filter that removes every row of a category removes it from the
|
|
110
|
+
* axis or legend too, as with vg.dot. A number or
|
|
111
|
+
* date fill gets a color ramp: the values are split into 254 steps colored from
|
|
112
|
+
* the plot's color scale, and Plot draws a ramp legend. Array data takes number
|
|
113
|
+
* and date x and y, and up to 254 fill values.
|
|
114
|
+
*/
|
|
115
|
+
export class DotGLMark extends Mark {
|
|
116
|
+
constructor(source, options = {}) {
|
|
117
|
+
const own = {};
|
|
118
|
+
const rest = {};
|
|
119
|
+
for (const key in options) (OWN_OPTIONS.includes(key) ? own : rest)[key] = options[key];
|
|
120
|
+
if (rest.fx != null || rest.fy != null) {
|
|
121
|
+
throw new Error('dotGL: faceting (fx/fy) is not supported');
|
|
122
|
+
}
|
|
123
|
+
for (const name in REMOVED_OPTIONS) {
|
|
124
|
+
if (name in options) throw new Error(`dotGL: the "${name}" option was removed. ${REMOVED_OPTIONS[name]}.`);
|
|
125
|
+
}
|
|
126
|
+
if (own.sort !== undefined && own.sort !== null && own.sort !== '-r') {
|
|
127
|
+
throw new Error("dotGL: sort must be '-r' or null (use orderby to set the draw order)");
|
|
128
|
+
}
|
|
129
|
+
if (own.tip?.fields && own.key == null) throw new Error('dotGL: tip.fields needs a key column');
|
|
130
|
+
const groupby = own.groupby == null ? [] : [own.groupby].flat();
|
|
131
|
+
super('dot', source, rest);
|
|
132
|
+
if (own.tip?.fields && this.hasOwnData()) throw new Error('dotGL: tip.fields needs a database table');
|
|
133
|
+
if (groupby.length && this.hasOwnData()) throw new Error('dotGL: groupby needs a database table');
|
|
134
|
+
for (const c of this.channels) {
|
|
135
|
+
if (c.field && !COLUMN_CHANNELS.includes(c.channel)) {
|
|
136
|
+
throw new Error(`dotGL: the "${c.channel}" option cannot be bound to a column (only x, y, r and fill can)`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const ignored = IGNORED_OPTIONS.filter(name => this.channel(name));
|
|
140
|
+
if (ignored.length) console.warn(`dotGL: ignoring unsupported option(s) ${ignored.join(', ')}`);
|
|
141
|
+
this.blit = own.blit ?? 'drawImage';
|
|
142
|
+
this.sortMode = own.sort === undefined ? '-r' : own.sort;
|
|
143
|
+
this.orderby = own.orderby ?? null;
|
|
144
|
+
this.maxCategories = Math.min(MAX_FILL_CATEGORIES, own.maxCategories ?? MAX_FILL_CATEGORIES);
|
|
145
|
+
this.benchmark = !!own.benchmark;
|
|
146
|
+
this.fragmentBudget = own.fragmentBudget ?? 4e7;
|
|
147
|
+
this.key = own.key ?? null;
|
|
148
|
+
// A group column comes back under its own name. When a selection filters the mark, Mosaic queries a pre-aggregated
|
|
149
|
+
// table that has only the query's aliases, and an `orderby` on the column finds it there. An expression, or a name a
|
|
150
|
+
// column channel, the key or an earlier group already has, gets a private name.
|
|
151
|
+
const taken = new Set([KEY_AS, ...this.channels.filter(c => c.field).map(c => c.as)].map(n => n.toLowerCase()));
|
|
152
|
+
/** Group columns: each comes back under the name `as`, and the tooltip labels it `name`. */
|
|
153
|
+
this.groups = groupby.map((field, i) => {
|
|
154
|
+
const plain = typeof field === 'string' || isColumnRef(field);
|
|
155
|
+
const name = isColumnRef(field) ? field.column : String(field);
|
|
156
|
+
const as = plain && !taken.has(name.toLowerCase()) ? name : `__dotgl_group_${i}`;
|
|
157
|
+
taken.add(as.toLowerCase());
|
|
158
|
+
return { field, name, as };
|
|
159
|
+
});
|
|
160
|
+
this.tip = own.tip ? (own.tip === true ? {} : own.tip) : null;
|
|
161
|
+
/** Extra tooltip fields by key, filled in by the tooltip. */
|
|
162
|
+
this.tipRows = new Map();
|
|
163
|
+
this.refineTimer = null;
|
|
164
|
+
this.lastPaint = null;
|
|
165
|
+
this.prep = null;
|
|
166
|
+
this.gpu = null;
|
|
167
|
+
this.canvas = null;
|
|
168
|
+
this.stats = null;
|
|
169
|
+
this.destroyed = false;
|
|
170
|
+
this.resultRef = null;
|
|
171
|
+
/** Category columns by query alias: `{ cats, fragment }`. Channels on the same column share one entry. Filled in prepare(). */
|
|
172
|
+
this.categories = new Map();
|
|
173
|
+
this.render = this.render.bind(this);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Mosaic calls this when the mark joins a plot. With `tip` set, the mark brings its own tooltip interactor. */
|
|
177
|
+
setPlot(plot, index) {
|
|
178
|
+
super.setPlot(plot, index);
|
|
179
|
+
if (this.tip) plot.addInteractor(new DotGLTip(this, this.tip));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Runs once per table, before the first query. Mosaic looks up each column's
|
|
184
|
+
* type. For text and boolean columns on x, y and fill this fetches the distinct
|
|
185
|
+
* values and builds the SQL that turns each row into its category's position.
|
|
186
|
+
*/
|
|
187
|
+
async prepare() {
|
|
188
|
+
await super.prepare();
|
|
189
|
+
// A new table or a changed field expression can change the rows behind each key.
|
|
190
|
+
this.tipRows = new Map();
|
|
191
|
+
// A result for the old query can still arrive while this waits. It is read with the old lists, which its
|
|
192
|
+
// codes point into, so the new lists replace them only once they are complete.
|
|
193
|
+
const categories = new Map();
|
|
194
|
+
if (this.hasOwnData() || !this.coordinator) {
|
|
195
|
+
this.categories = categories;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Channels on the same column share one alias. A shared column gets the smaller limit.
|
|
200
|
+
// Mosaic reports an unnested column's list type, so unnested columns keep Mosaic's plain select.
|
|
201
|
+
const wanted = new Map();
|
|
202
|
+
for (const name of ['x', 'y', 'fill']) {
|
|
203
|
+
const c = this.channelField(name, { exact: true });
|
|
204
|
+
if (!c || this.isUnnested(c.field)) continue;
|
|
205
|
+
if (c.type === 'array' || c.type === 'object') {
|
|
206
|
+
throw new Error(`dotGL: the ${name} column "${isColumnRef(c.field) ? c.field.column : c.field}" has type ${c.sqlType}, which can't be drawn`);
|
|
207
|
+
}
|
|
208
|
+
if (c.type !== 'string' && c.type !== 'boolean') continue;
|
|
209
|
+
if (!isColumnRef(c.field)) throw new Error(`dotGL: ${name} must be a plain column to be drawn as categories`);
|
|
210
|
+
const limit = name === 'fill' ? this.maxCategories : MAX_AXIS_CATEGORIES;
|
|
211
|
+
const entry = wanted.get(c.as);
|
|
212
|
+
if (!entry) wanted.set(c.as, { c, name, limit });
|
|
213
|
+
else if (limit < entry.limit) Object.assign(entry, { name, limit });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const entries = Array.from(wanted.values());
|
|
217
|
+
const results = await Promise.all(entries.map(({ c, limit }) => this.coordinator.query(
|
|
218
|
+
Query.from(this.sourceTable()).select({ v: cast(c.field, 'VARCHAR') }).distinct().limit(limit + 1)
|
|
219
|
+
)));
|
|
220
|
+
const encoder = new TextEncoder();
|
|
221
|
+
let bytes = 0;
|
|
222
|
+
let largest = null;
|
|
223
|
+
entries.forEach(({ c, name, limit }, i) => {
|
|
224
|
+
const col = c.field.column;
|
|
225
|
+
const text = Array.from(toDataColumns(results[i]).columns.v).sort(ascendingDefined);
|
|
226
|
+
if (text.length > limit) {
|
|
227
|
+
throw new Error(`dotGL: the ${name} column "${col}" has more than ${limit} distinct values`);
|
|
228
|
+
}
|
|
229
|
+
// A boolean column's list is true and false themselves, as vg.dot gives Plot, so Plot colors them the
|
|
230
|
+
// way it colors booleans. 'false' sorts before 'true' as false does before true.
|
|
231
|
+
const cats = c.type === 'boolean' ? text.map(v => (v == null ? v : v === 'true')) : text;
|
|
232
|
+
const sql = categorySQL(String(c.field), text);
|
|
233
|
+
const size = encoder.encode(JSON.stringify(sql)).length;
|
|
234
|
+
bytes += size;
|
|
235
|
+
if (!largest || size > largest.size) largest = { col, size };
|
|
236
|
+
categories.set(c.as, { cats, fragment: verbatim(sql) });
|
|
237
|
+
});
|
|
238
|
+
if (bytes > MAX_CATEGORY_BYTES) {
|
|
239
|
+
throw new Error(`dotGL: the categories of "${largest.col}" are too large to send (${(bytes / 1048576).toFixed(1)} MB)`);
|
|
240
|
+
}
|
|
241
|
+
this.categories = categories;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The mark's data query. Column channels come back as numbers the painters can
|
|
246
|
+
* use directly: category codes, doubles, or dates as epoch milliseconds. The key
|
|
247
|
+
* and the group columns come back as they are, under names Plot never sees.
|
|
248
|
+
*/
|
|
249
|
+
query(filter) {
|
|
250
|
+
const q = super.query(filter);
|
|
251
|
+
if (!q) return q;
|
|
252
|
+
if (this.orderby != null) q.orderby(this.orderby);
|
|
253
|
+
if (this.key != null) q.select({ [KEY_AS]: this.key });
|
|
254
|
+
// GROUP BY names each group's alias. A group alias never matches a channel's alias: when Mosaic combines queries,
|
|
255
|
+
// it reads GROUP BY "x" next to `avg(price) AS "x"` as the avg expression.
|
|
256
|
+
for (const { field, as } of this.groups) q.select({ [as]: field }).groupby(as);
|
|
257
|
+
for (const name of COLUMN_CHANNELS) {
|
|
258
|
+
const c = this.channelField(name, { exact: true });
|
|
259
|
+
if (!c || this.isUnnested(c.field)) continue;
|
|
260
|
+
const category = this.categories.get(c.as);
|
|
261
|
+
if (category) q.select({ [c.as]: category.fragment });
|
|
262
|
+
else if (c.type === 'number') q.select({ [c.as]: asDouble(c.field) });
|
|
263
|
+
else if (c.type === 'date') q.select({ [c.as]: asDouble(epoch_ms(c.field)) });
|
|
264
|
+
}
|
|
265
|
+
return q;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
queryResult(data) {
|
|
269
|
+
// A settled resize asks for the same query again and Mosaic's cache hands back the same table,
|
|
270
|
+
// so the prepared rows and the GPU buffers still fit it.
|
|
271
|
+
if (this.resultRef?.deref() === data) return this;
|
|
272
|
+
this.resultRef = new WeakRef(data);
|
|
273
|
+
super.queryResult(data);
|
|
274
|
+
clearTimeout(this.refineTimer);
|
|
275
|
+
this.lastPaint = null;
|
|
276
|
+
this.prep = null;
|
|
277
|
+
return this;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* A Param option (opacity, say) asks the plot to draw as soon as it changes. Before the first result
|
|
282
|
+
* that draw has no x and y scales, and mosaic's pan/zoom sets itself up once, on the first draw, so it
|
|
283
|
+
* would fail for good. The mark waits for its data; the draw after the result uses the latest values.
|
|
284
|
+
*/
|
|
285
|
+
update() {
|
|
286
|
+
return this.data ? super.update() : this.plot?.synch.promise;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The value of a constant option such as r: 2.5 or opacity: 0.6. */
|
|
290
|
+
constant(name) {
|
|
291
|
+
const c = this.channel(name);
|
|
292
|
+
return c && Object.hasOwn(c, 'value') ? c.value : undefined;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Which painter draws this time. WebGL2 when the browser has it, plain canvas squares when it doesn't. */
|
|
296
|
+
activePainter() {
|
|
297
|
+
return getSharedGL(this.blit) ? 'gl' : 'rect2d';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
prepareData() {
|
|
301
|
+
const columns = this.data?.columns;
|
|
302
|
+
if (!columns) throw new Error('dotGL: expected columnar data');
|
|
303
|
+
const field = name => this.channelField(name, { exact: true });
|
|
304
|
+
const column = name => (field(name) ? columns[field(name).as] : null);
|
|
305
|
+
const cats = name => (field(name) ? this.categories.get(field(name).as)?.cats ?? null : null);
|
|
306
|
+
const type = name => field(name)?.type;
|
|
307
|
+
// The SQL type for a date column, so the tooltip can tell a date from a timestamp from a time.
|
|
308
|
+
// Array data has no field info, so `prepare` falls back to looking for Date objects.
|
|
309
|
+
const dateType = name => (type(name) === 'date' ? field(name).sqlType || true : false);
|
|
310
|
+
const x = column('x');
|
|
311
|
+
const y = column('y');
|
|
312
|
+
if (!x || !y) throw new Error('dotGL: x and y must be columns');
|
|
313
|
+
// With array data nothing has looked at the values yet. A text x or y would read as NaN
|
|
314
|
+
// and every row would be dropped, leaving an empty plot and no reason for it.
|
|
315
|
+
if (this.hasOwnData()) {
|
|
316
|
+
for (const name of ['x', 'y']) {
|
|
317
|
+
const first = column(name).find(v => v != null);
|
|
318
|
+
if (first != null && typeof first !== 'number' && !(first instanceof Date)) {
|
|
319
|
+
throw new Error(`dotGL: the ${name} values are ${typeof first}; array data draws numbers and dates only, so read this column from a database table to plot it as categories`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const r = column('r');
|
|
324
|
+
const fillCats = cats('fill');
|
|
325
|
+
return prepare({
|
|
326
|
+
x,
|
|
327
|
+
y,
|
|
328
|
+
r,
|
|
329
|
+
fill: column('fill'),
|
|
330
|
+
xCats: cats('x'),
|
|
331
|
+
yCats: cats('y'),
|
|
332
|
+
fillCats,
|
|
333
|
+
continuous: !fillCats && (type('fill') === 'number' || type('fill') === 'date'),
|
|
334
|
+
dates: { x: dateType('x'), y: dateType('y'), r: dateType('r'), fill: dateType('fill') },
|
|
335
|
+
sort: this.sortMode,
|
|
336
|
+
maxCategories: this.maxCategories,
|
|
337
|
+
wantP25: !!r && this.plot?.getAttribute('rRange') == null
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
plotSpecs() {
|
|
342
|
+
if (!this.data || this.destroyed) return [];
|
|
343
|
+
const prep = (this.prep ??= this.prepareData());
|
|
344
|
+
// Plot calls a mark's render only when some of its rows pass Plot's row filter (inside the domain, r above 0).
|
|
345
|
+
// A hint row pairs entries from separate lists, so with a domain that leaves values out every hint row could
|
|
346
|
+
// fail while real rows pass. The hints go in as extra channels with that filter turned off. frameAnchor
|
|
347
|
+
// keeps Plot's dot from reading x and y out of the data.
|
|
348
|
+
const options = { sort: null, render: this.render, frameAnchor: 'middle', channels: {} };
|
|
349
|
+
for (const c of this.channels) {
|
|
350
|
+
if (Object.hasOwn(c, 'value')) {
|
|
351
|
+
options[c.channel] = c.value;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (!HINTS[c.channel]) throw new Error(`dotGL: the "${c.channel}" option cannot be bound to a column`);
|
|
355
|
+
const [name, scale] = HINTS[c.channel];
|
|
356
|
+
options.channels[name] = { value: prep.hints[c.channel], scale, filter: null };
|
|
357
|
+
}
|
|
358
|
+
return [{ type: 'dot', data: { length: prep.k }, options }];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Observable Plot calls this once per redraw and passes its scales. The points
|
|
363
|
+
* are drawn into a canvas that we keep between redraws and move into each new
|
|
364
|
+
* SVG, inside a foreignObject the size of the plot frame (or the whole plot when clip is off).
|
|
365
|
+
*/
|
|
366
|
+
render(index, scales, values, dimensions, context) {
|
|
367
|
+
const doc = context.document;
|
|
368
|
+
const g = doc.createElementNS(SVG, 'g');
|
|
369
|
+
g.setAttribute('aria-label', 'dot');
|
|
370
|
+
const prep = this.prep;
|
|
371
|
+
if (this.destroyed || !prep || prep.n === 0) return g;
|
|
372
|
+
|
|
373
|
+
const sx = scales.scales.x;
|
|
374
|
+
const sy = scales.scales.y;
|
|
375
|
+
// Only our own radius column uses the r scale. Another mark in the plot may have made one.
|
|
376
|
+
const sr = this.channelField('r', { exact: true }) ? scales.scales.r : undefined;
|
|
377
|
+
if (!sx || !sy) throw new Error('dotGL: the plot must have x and y scales (projections are not supported)');
|
|
378
|
+
// A category axis places each code where its category is in the scale's domain. Number and date
|
|
379
|
+
// values go through the scale's formula, and a point or band scale has none, so their dots would miss the ticks.
|
|
380
|
+
const line = (name, scale, cats) => {
|
|
381
|
+
if (cats) return categoryAxis(scale, cats);
|
|
382
|
+
if (scale.type === 'point' || scale.type === 'band') {
|
|
383
|
+
throw new Error(`dotGL: the ${name} scale type "${scale.type}" is not supported for a number or date column`);
|
|
384
|
+
}
|
|
385
|
+
return null;
|
|
386
|
+
};
|
|
387
|
+
const lines = { x: line('x', sx, prep.xCats), y: line('y', sy, prep.yCats) };
|
|
388
|
+
|
|
389
|
+
const { width: W, height: H, marginLeft: ml, marginTop: mt, marginRight: mr, marginBottom: mb } = dimensions;
|
|
390
|
+
const clip = !!this.constant('clip');
|
|
391
|
+
const [fx, fy, fw, fh] = clip ? [ml, mt, W - ml - mr, H - mt - mb] : [0, 0, W, H];
|
|
392
|
+
const dpr = globalThis.devicePixelRatio || 1;
|
|
393
|
+
const pw = Math.max(1, Math.round(fw * dpr));
|
|
394
|
+
const ph = Math.max(1, Math.round(fh * dpr));
|
|
395
|
+
const frame = { fx, fy, fw, fh, pw, ph, dpr, offset: dpr > 1 ? 0 : 0.5 };
|
|
396
|
+
|
|
397
|
+
const canvas = (this.canvas ??= doc.createElement('canvas'));
|
|
398
|
+
// The painters set the canvas size; the WebGL painter may pick a lower resolution.
|
|
399
|
+
canvas.style.cssText = `display:block;width:${fw}px;height:${fh}px;pointer-events:none`;
|
|
400
|
+
|
|
401
|
+
// Plot's dot would draw hollow rings in the text color. This mark always fills.
|
|
402
|
+
const fill = this.constant('fill') ?? 'currentColor';
|
|
403
|
+
const missingColors = [];
|
|
404
|
+
const fillColors = values[HINTS.fill[0]];
|
|
405
|
+
const style = {
|
|
406
|
+
opacity: +(this.constant('opacity') ?? 1) * +(this.constant('fillOpacity') ?? 1),
|
|
407
|
+
fill,
|
|
408
|
+
fillRGBA: parseColor(fill, this.plot?.element),
|
|
409
|
+
r: +(this.constant('r') ?? 3),
|
|
410
|
+
palette: !fillColors ? null
|
|
411
|
+
: prep.continuous ? paletteFromScale(scales.scales.color, prep.extent.fill, prep.levels)
|
|
412
|
+
: paletteFromValues(fillColors, prep.cats.length, missingColors, prep.fillRows)
|
|
413
|
+
};
|
|
414
|
+
this.warnMissingColors(missingColors, prep.cats);
|
|
415
|
+
|
|
416
|
+
clearTimeout(this.refineTimer);
|
|
417
|
+
const painter = this.activePainter();
|
|
418
|
+
// Everything the tooltip needs to find the dots on screen again, and when they were painted.
|
|
419
|
+
// A tick format the page set for an axis. Only a function is used: turning a d3 format
|
|
420
|
+
// string into a function would need d3-time-format, which this package does not depend on,
|
|
421
|
+
// so a string falls through to the tooltip's own formatting instead of throwing.
|
|
422
|
+
const tickFormat = name => {
|
|
423
|
+
const f = this.plot?.getAttribute?.(`${name}TickFormat`);
|
|
424
|
+
return typeof f === 'function' ? f : null;
|
|
425
|
+
};
|
|
426
|
+
const params = {
|
|
427
|
+
sx, sy, sr, lines, frame, style, prep, painter,
|
|
428
|
+
labels: { x: scales.x?.label, y: scales.y?.label },
|
|
429
|
+
formats: { x: tickFormat('x'), y: tickFormat('y'), fill: tickFormat('color') },
|
|
430
|
+
at: performance.now()
|
|
431
|
+
};
|
|
432
|
+
this.stats = painter === 'gl' ? paintGL(this, canvas, params, { allowReduce: true }) : paintRect2D(this, canvas, params);
|
|
433
|
+
this.lastPaint = this.stats.skipped ? null : params;
|
|
434
|
+
if (this.stats.reduced) this.refineTimer = setTimeout(() => this.refine(params), REFINE_DELAY_MS);
|
|
435
|
+
|
|
436
|
+
const fo = doc.createElementNS(SVG, 'foreignObject');
|
|
437
|
+
fo.setAttribute('x', fx);
|
|
438
|
+
fo.setAttribute('y', fy);
|
|
439
|
+
fo.setAttribute('width', fw);
|
|
440
|
+
fo.setAttribute('height', fh);
|
|
441
|
+
fo.style.pointerEvents = 'none';
|
|
442
|
+
fo.appendChild(canvas);
|
|
443
|
+
g.appendChild(fo);
|
|
444
|
+
return g;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Says once when the color domain has no color for some of the fill column's values.
|
|
449
|
+
* Those dots are drawn fully transparent, so without this they just aren't there.
|
|
450
|
+
*/
|
|
451
|
+
warnMissingColors(missing, cats) {
|
|
452
|
+
if (this.warnedColors || !missing.length) return;
|
|
453
|
+
this.warnedColors = true;
|
|
454
|
+
const names = missing.slice(0, 5).map(i => JSON.stringify(cats[i])).join(', ');
|
|
455
|
+
const rest = missing.length > 5 ? `, and ${missing.length - 5} more` : '';
|
|
456
|
+
console.warn(`dotGL: the color domain has no color for ${missing.length} of the fill column's values (${names}${rest}), so those dots are drawn invisible`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Draw the last frame again at full resolution, once zooming has stopped. */
|
|
460
|
+
refine(params) {
|
|
461
|
+
if (this.destroyed || this.lastPaint !== params || this.prep !== params.prep || !this.canvas) return;
|
|
462
|
+
if (params.painter !== 'gl') return;
|
|
463
|
+
const stats = paintGL(this, this.canvas, params);
|
|
464
|
+
// The context went away while the timer waited. Nothing was drawn, so leave the last
|
|
465
|
+
// real stats alone and say nothing; the restore handler redraws the plot.
|
|
466
|
+
if (stats.skipped) return;
|
|
467
|
+
this.stats = { ...stats, refined: true };
|
|
468
|
+
// Tell the page the full-resolution frame is on screen (the demo shows its timings).
|
|
469
|
+
this.plot?.element?.dispatchEvent(new CustomEvent('dotgl-refine', { detail: { mark: this, stats: this.stats } }));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
destroy() {
|
|
473
|
+
this.destroyed = true;
|
|
474
|
+
clearTimeout(this.refineTimer);
|
|
475
|
+
this.lastPaint = null;
|
|
476
|
+
super.destroy?.();
|
|
477
|
+
freeGPU(this);
|
|
478
|
+
if (this.canvas) {
|
|
479
|
+
this.canvas.remove();
|
|
480
|
+
this.canvas.width = 0;
|
|
481
|
+
this.canvas.height = 0;
|
|
482
|
+
this.canvas = null;
|
|
483
|
+
}
|
|
484
|
+
this.prep = null;
|
|
485
|
+
this.data = null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Use inside vg.plot() in place of vg.dot. */
|
|
490
|
+
export const dotGL = (source, options) => plot => plot.addMark(new DotGLMark(source, options));
|
package/src/color.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { color as parse } from 'd3-color';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Colors reach us from Observable Plot as CSS strings: constants as you wrote
|
|
5
|
+
* them, categories as the plot's color scale mapped them. This file only turns
|
|
6
|
+
* those strings into red, green, blue and alpha.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const BLACK = [0, 0, 0, 1];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A CSS color as [r, g, b, a] between 0 and 1. Strings d3-color can't read, such
|
|
13
|
+
* as `var(--accent)` or `currentColor`, are handed to the browser to work out;
|
|
14
|
+
* `context` (an element on the page) is where the browser looks them up. With
|
|
15
|
+
* no page at all they come out black.
|
|
16
|
+
*/
|
|
17
|
+
export function parseColor(str, context) {
|
|
18
|
+
if (str == null || str === 'none') return [0, 0, 0, 0];
|
|
19
|
+
const c = (parse(str) ?? parse(resolveCSS(str, context)))?.rgb();
|
|
20
|
+
if (!c) return BLACK;
|
|
21
|
+
return [c.r / 255, c.g / 255, c.b / 255, c.opacity];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Ask the browser what a color string means. Returns an rgb()/rgba() string, or null. */
|
|
25
|
+
function resolveCSS(str, context) {
|
|
26
|
+
if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') return null;
|
|
27
|
+
const host = context?.isConnected ? context : document.body;
|
|
28
|
+
if (!host) return null;
|
|
29
|
+
const probe = document.createElement('span');
|
|
30
|
+
probe.style.color = str;
|
|
31
|
+
host.appendChild(probe);
|
|
32
|
+
const resolved = getComputedStyle(probe).color;
|
|
33
|
+
probe.remove();
|
|
34
|
+
return resolved || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A palette from the colors Plot gave the category rows, for up to 65,535
|
|
39
|
+
* categories. Entry i is the color of category i. It is laid out in rows of 256
|
|
40
|
+
* (row i / 256 of a 256×256 texture), and has as many rows as the categories
|
|
41
|
+
* need. `hintRows[i]` is the hint row that has category i, or -1 when the data
|
|
42
|
+
* doesn't have it; without `hintRows`, row i has category i. Unused entries stay
|
|
43
|
+
* transparent, which also hides rows whose category is not in the color domain;
|
|
44
|
+
* those category numbers are pushed onto `missing` so the caller can say so,
|
|
45
|
+
* since dots drawn invisible give no other sign.
|
|
46
|
+
*/
|
|
47
|
+
export function paletteFromValues(fillValues, count, missing, hintRows = null) {
|
|
48
|
+
const n = Math.min(count, 65535);
|
|
49
|
+
const rows = Math.max(1, Math.ceil(n / 256));
|
|
50
|
+
const out = new Uint8Array(256 * rows * 4);
|
|
51
|
+
for (let i = 0; i < n; ++i) {
|
|
52
|
+
const row = hintRows ? hintRows[i] : i;
|
|
53
|
+
if (!(row >= 0 && row < fillValues.length)) continue;
|
|
54
|
+
const c = parse(fillValues[row])?.rgb();
|
|
55
|
+
if (!c) {
|
|
56
|
+
missing?.push(i);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
out[i * 4] = c.r;
|
|
60
|
+
out[i * 4 + 1] = c.g;
|
|
61
|
+
out[i * 4 + 2] = c.b;
|
|
62
|
+
out[i * 4 + 3] = Math.round(c.opacity * 255);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A palette for a number column: `levels` colors read off the plot's color scale
|
|
69
|
+
* between the lowest and highest value, so a step number looks up the color of
|
|
70
|
+
* its value. Any kind of color scale works, because the scale itself is asked
|
|
71
|
+
* for each color.
|
|
72
|
+
*/
|
|
73
|
+
export function paletteFromScale(colorScale, [min, max], levels) {
|
|
74
|
+
const out = new Uint8Array(256 * 4);
|
|
75
|
+
const span = max - min;
|
|
76
|
+
for (let i = 0; i < levels; ++i) {
|
|
77
|
+
const v = span > 0 ? min + ((i + 0.5) / levels) * span : min;
|
|
78
|
+
const c = parse(colorScale.apply(v))?.rgb();
|
|
79
|
+
if (!c) continue;
|
|
80
|
+
out[i * 4] = c.r;
|
|
81
|
+
out[i * 4 + 1] = c.g;
|
|
82
|
+
out[i * 4 + 2] = c.b;
|
|
83
|
+
out[i * 4 + 3] = Math.round(c.opacity * 255);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Mark } from '@uwdata/mosaic-plot';
|
|
2
|
+
import type { GroupByExpr, OrderByExpr } from '@uwdata/mosaic-sql';
|
|
3
|
+
|
|
4
|
+
/** The vg.dot options (x, y, r, fill, opacity, fillOpacity, clip, ...) plus the options only this mark has. */
|
|
5
|
+
export interface DotGLOptions {
|
|
6
|
+
/** How the picture is copied into the plot. Default 'drawImage', which is the fast path everywhere; 'bitmaprenderer' is for measuring. */
|
|
7
|
+
blit?: 'drawImage' | 'bitmaprenderer';
|
|
8
|
+
/** '-r' draws big dots first when r is a column; null keeps the row order. Default '-r'. */
|
|
9
|
+
sort?: '-r' | null;
|
|
10
|
+
/** What the query sorts rows by: a column name, `column()`, `desc()` or a `sql` fragment. Rows are drawn in that order. */
|
|
11
|
+
orderby?: OrderByExpr | null;
|
|
12
|
+
/** How many different fill values a database column may have. Default and maximum 65,535; array data allows 254. */
|
|
13
|
+
maxCategories?: number;
|
|
14
|
+
/** Wait for the graphics card after each draw so `stats` shows real times. */
|
|
15
|
+
benchmark?: boolean;
|
|
16
|
+
/** How much painting one frame may do before the mark draws at a lower resolution while you zoom. Default 4e7. */
|
|
17
|
+
fragmentBudget?: number;
|
|
18
|
+
/** A unique row id: a column name or an expression such as `vg.int32('id')`. The tooltip looks up `tip.fields` by it. */
|
|
19
|
+
key?: unknown;
|
|
20
|
+
/** Columns the query groups by, for x and y aggregates: a column name, `column()` or expression, or an array of them. The tooltip shows each one. */
|
|
21
|
+
groupby?: GroupByExpr | null;
|
|
22
|
+
/** Show a tooltip for the dot under the pointer. `fields` (column names, or a Param holding them) need `key` and a database table; `maxRadius` defaults to 40 px. */
|
|
23
|
+
tip?: boolean | { fields?: string[] | { value: string[] }; maxRadius?: number };
|
|
24
|
+
[option: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Timings and counts from the last draw. Without WebGL2 the mark draws squares on a plain canvas and fills in only painter, drawn and drawMs. */
|
|
28
|
+
export interface DotGLStats {
|
|
29
|
+
painter: 'gl' | 'rect2d';
|
|
30
|
+
drawn?: number;
|
|
31
|
+
uploadMs?: number;
|
|
32
|
+
drawMs?: number;
|
|
33
|
+
blitMs?: number;
|
|
34
|
+
dpr?: number;
|
|
35
|
+
/** The frame was drawn at a lower resolution while zooming. */
|
|
36
|
+
reduced?: boolean;
|
|
37
|
+
/** The full-resolution redraw after zooming stopped. */
|
|
38
|
+
refined?: boolean;
|
|
39
|
+
/** Estimated pixels painted for the frame. */
|
|
40
|
+
estimate?: number;
|
|
41
|
+
/** Set when nothing was drawn, for example 'context lost'. */
|
|
42
|
+
skipped?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export declare class DotGLMark extends Mark {
|
|
46
|
+
constructor(source: unknown, options?: DotGLOptions);
|
|
47
|
+
stats: DotGLStats | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Use inside vg.plot() in place of vg.dot. */
|
|
51
|
+
export declare function dotGL(source: unknown, options?: DotGLOptions): (plot: any) => void;
|
package/src/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { dotGL, DotGLMark } from './DotGLMark.js';
|