@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/prepare.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One pass over a query result. Works out everything the drawing needs that
|
|
3
|
+
* doesn't depend on the plot's scales:
|
|
4
|
+
*
|
|
5
|
+
* - which rows can be drawn (x, y and radius are numbers or known category codes, the fill isn't missing),
|
|
6
|
+
* - the lowest and highest values, which Plot uses to set up the scales,
|
|
7
|
+
* - which categories occur, so axes and legends list only those, as they do for Plot's own dot,
|
|
8
|
+
* - a small integer per row for the fill color,
|
|
9
|
+
* - the draw order (biggest dots first, like Plot's dot mark).
|
|
10
|
+
*
|
|
11
|
+
* The lowest and highest values come from every usable value in a column, not
|
|
12
|
+
* only from drawable rows, because that is how Plot sets a scale from a column.
|
|
13
|
+
*
|
|
14
|
+
* Columns from the database arrive already turned into numbers: doubles with NaN
|
|
15
|
+
* for null, epoch milliseconds for dates (the `dates` flags say which), and
|
|
16
|
+
* category codes for text and boolean columns (the `*Cats` lists say which). The
|
|
17
|
+
* lists come from the whole table, so a filtered result can use only some of them.
|
|
18
|
+
* Array data arrives as it was given, and dates are then Date objects. Everything
|
|
19
|
+
* here takes both.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Sort the same way Plot sorts a list of categories. */
|
|
23
|
+
const ascending = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
24
|
+
|
|
25
|
+
const BINS = 1024;
|
|
26
|
+
|
|
27
|
+
/** True when a column holds Date objects. The hints then stay Dates, so Plot picks a time scale. */
|
|
28
|
+
function holdsDates(column) {
|
|
29
|
+
for (let i = 0; i < column.length; ++i) {
|
|
30
|
+
const v = column[i];
|
|
31
|
+
if (v != null) return v instanceof Date;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The categories that occur in a column of category codes, in list order, and for each code its row in
|
|
38
|
+
* that shorter list (-1 when it doesn't occur). Plot works a scale's domain out from every value of a
|
|
39
|
+
* column, drawn or not, so every row counts. The loop stops once every category has turned up, and then
|
|
40
|
+
* the full list comes back with no rows.
|
|
41
|
+
*/
|
|
42
|
+
function occurring(codes, cats) {
|
|
43
|
+
const n = cats.length;
|
|
44
|
+
const seen = new Uint8Array(n);
|
|
45
|
+
let left = n;
|
|
46
|
+
for (let i = 0; i < codes.length && left > 0; ++i) {
|
|
47
|
+
const c = codes[i];
|
|
48
|
+
if (c < n && !seen[c]) {
|
|
49
|
+
seen[c] = 1;
|
|
50
|
+
--left;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (left === 0) return { list: cats, rows: null };
|
|
54
|
+
const rows = new Int32Array(n).fill(-1);
|
|
55
|
+
const list = [];
|
|
56
|
+
for (let c = 0; c < n; ++c) if (seen[c]) rows[c] = list.push(cats[c]) - 1;
|
|
57
|
+
return { list, rows };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Number columns are split into this many colors. Codes 254 and 255 are not colors: 255 marks a hidden dot. */
|
|
61
|
+
export const CONTINUOUS_LEVELS = 254;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {object} input
|
|
65
|
+
* @param {ArrayLike<any>} input.x
|
|
66
|
+
* @param {ArrayLike<any>} input.y
|
|
67
|
+
* @param {ArrayLike<any>} [input.r] radius column, or leave out for a fixed radius
|
|
68
|
+
* @param {ArrayLike<any>} [input.fill] fill column: category codes with `fillCats`, numbers with `continuous`,
|
|
69
|
+
* otherwise values that are grouped into categories here
|
|
70
|
+
* @param {any[]} [input.xCats] x is codes into this category list (sorted the way Plot sorts, null last)
|
|
71
|
+
* @param {any[]} [input.yCats] y is codes into this category list
|
|
72
|
+
* @param {any[]} [input.fillCats] fill is codes into this category list
|
|
73
|
+
* @param {boolean} [input.continuous] treat `fill` as numbers and split them into CONTINUOUS_LEVELS steps
|
|
74
|
+
* @param {{x?: string|boolean, y?: string|boolean, r?: string|boolean, fill?: string|boolean}} [input.dates]
|
|
75
|
+
* these columns hold epoch milliseconds that stand for dates. The value is
|
|
76
|
+
* the SQL type ('DATE', 'TIMESTAMP', 'TIME', ...) when it is known, so the
|
|
77
|
+
* tooltip can format each kind its own way, or just true when it isn't.
|
|
78
|
+
* @param {'-r'|null} [input.sort] draw order: '-r' draws big dots first
|
|
79
|
+
* @param {number} [input.maxCategories] most categories grouped here from plain values (254 at most, one byte)
|
|
80
|
+
* @param {boolean} [input.wantP25] also pass along the 25th percentile of the radii, so Plot's
|
|
81
|
+
* default dot size range comes out the same as with all rows
|
|
82
|
+
*/
|
|
83
|
+
export function prepare({ x, y, r = null, fill = null, xCats = null, yCats = null, fillCats = null, continuous = false, dates = {}, sort = '-r', maxCategories = 254, wantP25 = false }) {
|
|
84
|
+
const total = x.length;
|
|
85
|
+
const valid = new Uint8Array(total);
|
|
86
|
+
const factorize = !!fill && !fillCats && !continuous;
|
|
87
|
+
const temp = factorize ? new Uint16Array(total) : null;
|
|
88
|
+
const seen = factorize ? new Map() : null;
|
|
89
|
+
let fmin = Infinity, fmax = -Infinity;
|
|
90
|
+
// Array data has no SQL type, so a column of Date objects counts as dates with no kind.
|
|
91
|
+
const xDates = dates.x || holdsDates(x);
|
|
92
|
+
const yDates = dates.y || holdsDates(y);
|
|
93
|
+
const rDates = r ? dates.r || holdsDates(r) : false;
|
|
94
|
+
maxCategories = Math.min(254, maxCategories);
|
|
95
|
+
|
|
96
|
+
let count = 0;
|
|
97
|
+
let xmin = Infinity, xmax = -Infinity, xpos = Infinity;
|
|
98
|
+
let ymin = Infinity, ymax = -Infinity, ypos = Infinity;
|
|
99
|
+
let rmin = Infinity, rmax = -Infinity;
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < total; ++i) {
|
|
102
|
+
let xok, yok;
|
|
103
|
+
if (xCats) {
|
|
104
|
+
xok = x[i] < xCats.length;
|
|
105
|
+
} else {
|
|
106
|
+
const xv = x[i];
|
|
107
|
+
const xn = xv == null ? NaN : +xv;
|
|
108
|
+
xok = Number.isFinite(xn);
|
|
109
|
+
if (xok) {
|
|
110
|
+
if (xn < xmin) xmin = xn;
|
|
111
|
+
if (xn > xmax) xmax = xn;
|
|
112
|
+
if (xn > 0 && xn < xpos) xpos = xn;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (yCats) {
|
|
116
|
+
yok = y[i] < yCats.length;
|
|
117
|
+
} else {
|
|
118
|
+
const yv = y[i];
|
|
119
|
+
const yn = yv == null ? NaN : +yv;
|
|
120
|
+
yok = Number.isFinite(yn);
|
|
121
|
+
if (yok) {
|
|
122
|
+
if (yn < ymin) ymin = yn;
|
|
123
|
+
if (yn > ymax) ymax = yn;
|
|
124
|
+
if (yn > 0 && yn < ypos) ypos = yn;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
let rok = true;
|
|
128
|
+
if (r) {
|
|
129
|
+
const rv = r[i];
|
|
130
|
+
const rn = rv == null ? NaN : +rv;
|
|
131
|
+
rok = Number.isFinite(rn);
|
|
132
|
+
if (rok) {
|
|
133
|
+
if (rn < rmin) rmin = rn;
|
|
134
|
+
if (rn > rmax) rmax = rn;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// The fill's range and categories count rows that aren't drawn too, as the other columns' ranges do.
|
|
138
|
+
let fok = true;
|
|
139
|
+
if (fillCats) {
|
|
140
|
+
fok = fill[i] < fillCats.length;
|
|
141
|
+
} else if (continuous) {
|
|
142
|
+
const fv = fill[i];
|
|
143
|
+
const fn = fv == null ? NaN : +fv;
|
|
144
|
+
fok = Number.isFinite(fn);
|
|
145
|
+
if (fok) {
|
|
146
|
+
if (fn < fmin) fmin = fn;
|
|
147
|
+
if (fn > fmax) fmax = fn;
|
|
148
|
+
}
|
|
149
|
+
} else if (fill) {
|
|
150
|
+
const fv = fill[i];
|
|
151
|
+
fok = fv != null;
|
|
152
|
+
if (fok) {
|
|
153
|
+
let code = seen.get(fv);
|
|
154
|
+
if (code === undefined) {
|
|
155
|
+
code = seen.size;
|
|
156
|
+
if (code >= maxCategories) {
|
|
157
|
+
throw new Error(`dotGL: the fill column has more than ${maxCategories} distinct values`);
|
|
158
|
+
}
|
|
159
|
+
seen.set(fv, code);
|
|
160
|
+
}
|
|
161
|
+
temp[i] = code;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!xok || !yok || !rok || !fok) continue;
|
|
165
|
+
valid[i] = 1;
|
|
166
|
+
++count;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Each category's number is its place in the sorted list (the table's whole list when `fillCats` is given).
|
|
170
|
+
// When every category is in the result, number i is also hint row i; otherwise `fillRows` gives each number's row.
|
|
171
|
+
// Number columns get a step number over their range instead.
|
|
172
|
+
// Up to 254 colors fit one byte with 255 as the hidden code; more take two bytes with 65535 as the hidden code.
|
|
173
|
+
const cats = fillCats ? fillCats : seen ? Array.from(seen.keys()).sort(ascending) : [];
|
|
174
|
+
const levels = continuous ? CONTINUOUS_LEVELS : cats.length;
|
|
175
|
+
const hidden = levels > 254 ? 65535 : 255;
|
|
176
|
+
const codes = (hidden === 255 ? new Uint8Array(total) : new Uint16Array(total)).fill(hidden);
|
|
177
|
+
if (fillCats) {
|
|
178
|
+
for (let i = 0; i < total; ++i) if (valid[i]) codes[i] = fill[i];
|
|
179
|
+
} else if (continuous) {
|
|
180
|
+
const span = fmax - fmin;
|
|
181
|
+
for (let i = 0; i < total; ++i) {
|
|
182
|
+
if (!valid[i]) continue;
|
|
183
|
+
codes[i] = span > 0 ? Math.min(CONTINUOUS_LEVELS - 1, Math.floor(((+fill[i] - fmin) / span) * CONTINUOUS_LEVELS)) : 0;
|
|
184
|
+
}
|
|
185
|
+
} else if (fill) {
|
|
186
|
+
const remap = new Uint8Array(seen.size);
|
|
187
|
+
cats.forEach((c, i) => { remap[seen.get(c)] = i; });
|
|
188
|
+
for (let i = 0; i < total; ++i) if (valid[i]) codes[i] = remap[temp[i]];
|
|
189
|
+
} else {
|
|
190
|
+
for (let i = 0; i < total; ++i) if (valid[i]) codes[i] = 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Axes and legends list only the categories in this result. Codes keep pointing into the full lists.
|
|
194
|
+
const xShown = xCats ? occurring(x, xCats).list : null;
|
|
195
|
+
const yShown = yCats ? occurring(y, yCats).list : null;
|
|
196
|
+
const fillShown = fillCats ? occurring(fill, fillCats) : { list: cats, rows: null };
|
|
197
|
+
|
|
198
|
+
// Radius histogram. It gives the draw order (a stable counting sort, biggest
|
|
199
|
+
// first) and the 25th percentile of the positive radii, which Plot's default
|
|
200
|
+
// dot size range depends on.
|
|
201
|
+
const perm = new Uint32Array(count);
|
|
202
|
+
let p25 = rmin;
|
|
203
|
+
const histogram = r && rmax > rmin && (sort === '-r' || wantP25);
|
|
204
|
+
if (histogram) {
|
|
205
|
+
const scale = (BINS - 1) / (rmax - rmin);
|
|
206
|
+
const bin = new Uint16Array(total);
|
|
207
|
+
const counts = new Uint32Array(BINS + 1);
|
|
208
|
+
const positive = new Uint32Array(BINS);
|
|
209
|
+
let positives = 0;
|
|
210
|
+
for (let i = 0; i < total; ++i) {
|
|
211
|
+
const rv = r[i];
|
|
212
|
+
const rn = rv == null ? NaN : +rv;
|
|
213
|
+
if (!Number.isFinite(rn)) continue;
|
|
214
|
+
const b = Math.round((rmax - rn) * scale); // 0 = largest radius
|
|
215
|
+
if (rn > 0) { ++positive[b]; ++positives; }
|
|
216
|
+
if (!valid[i]) continue;
|
|
217
|
+
bin[i] = b;
|
|
218
|
+
++counts[b + 1];
|
|
219
|
+
}
|
|
220
|
+
let seenSoFar = 0;
|
|
221
|
+
for (let b = BINS - 1; b >= 0; --b) {
|
|
222
|
+
seenSoFar += positive[b];
|
|
223
|
+
if (seenSoFar >= positives * 0.25) { p25 = Math.min(rmax, Math.max(rmin, rmax - b / scale)); break; }
|
|
224
|
+
}
|
|
225
|
+
if (sort === '-r') {
|
|
226
|
+
for (let b = 0; b < BINS; ++b) counts[b + 1] += counts[b];
|
|
227
|
+
for (let i = 0; i < total; ++i) if (valid[i]) perm[counts[bin[i]]++] = i;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!histogram || sort !== '-r') {
|
|
231
|
+
let k = 0;
|
|
232
|
+
for (let i = 0; i < total; ++i) if (valid[i]) perm[k++] = i;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Hints: short arrays of length k (or empty) that make Plot set up the same
|
|
236
|
+
// scales it would from the full columns. For x and y the smallest positive value
|
|
237
|
+
// goes first and the true minimum last: Plot's log scale looks at the first
|
|
238
|
+
// non-zero value and then keeps only values with that sign, while a linear scale
|
|
239
|
+
// just takes the lowest and highest. A category axis or legend gets the
|
|
240
|
+
// categories in the result, in list order, which is the domain Plot works out
|
|
241
|
+
// from the text column itself. For r, the spare hint slots are all the
|
|
242
|
+
// 25th-percentile radius, because Plot reads that value to pick its default
|
|
243
|
+
// dot size range.
|
|
244
|
+
const needSlot = (!xCats && xmin <= 0) || (!yCats && ymin <= 0);
|
|
245
|
+
const k = Math.max(2, fillShown.list.length, xShown?.length ?? 0, yShown?.length ?? 0, wantP25 && r ? 8 : 0, needSlot ? 3 : 0);
|
|
246
|
+
const asDate = (flag, v) => (flag ? new Date(v) : v);
|
|
247
|
+
const position = (min, max, pos, isDate) => {
|
|
248
|
+
// No usable value at all: pass `undefined`, which Plot treats like an empty column.
|
|
249
|
+
if (!Number.isFinite(min)) return new Array(k).fill(undefined);
|
|
250
|
+
const out = new Array(k).fill(asDate(isDate, max));
|
|
251
|
+
out[0] = asDate(isDate, pos < Infinity ? pos : min);
|
|
252
|
+
if (k > 2) out[k - 1] = asDate(isDate, min);
|
|
253
|
+
return out;
|
|
254
|
+
};
|
|
255
|
+
// The list padded to length k by repeating its last entry. An empty list stays empty: Plot would add
|
|
256
|
+
// `undefined` to a category axis another mark shares.
|
|
257
|
+
const padded = list => (list.length ? Array.from({ length: k }, (_, i) => list[Math.min(i, list.length - 1)]) : []);
|
|
258
|
+
const radius = () => {
|
|
259
|
+
if (!Number.isFinite(rmin)) return new Array(k).fill(undefined);
|
|
260
|
+
const out = new Array(k).fill(wantP25 ? p25 : rmax);
|
|
261
|
+
out[0] = rmin;
|
|
262
|
+
out[1] = rmax;
|
|
263
|
+
return out;
|
|
264
|
+
};
|
|
265
|
+
const hints = {
|
|
266
|
+
x: xCats ? padded(xShown) : position(xmin, xmax, xpos, xDates),
|
|
267
|
+
y: yCats ? padded(yShown) : position(ymin, ymax, ypos, yDates),
|
|
268
|
+
r: r ? radius() : null,
|
|
269
|
+
fill: !fill ? null
|
|
270
|
+
: continuous ? position(fmin, fmax, Infinity, !!dates.fill)
|
|
271
|
+
: padded(fillShown.list)
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
n: count,
|
|
276
|
+
total,
|
|
277
|
+
perm,
|
|
278
|
+
codes,
|
|
279
|
+
hidden,
|
|
280
|
+
cats,
|
|
281
|
+
// For each fill code, its row in the fill hints (-1 when the result doesn't have it); null when every row is its code.
|
|
282
|
+
fillRows: fillShown.rows,
|
|
283
|
+
xCats,
|
|
284
|
+
yCats,
|
|
285
|
+
continuous,
|
|
286
|
+
dates: { x: xDates, y: yDates, r: rDates, fill: dates.fill || false },
|
|
287
|
+
levels,
|
|
288
|
+
hints,
|
|
289
|
+
k,
|
|
290
|
+
p25,
|
|
291
|
+
extent: {
|
|
292
|
+
x: xCats ? null : [xmin, xmax],
|
|
293
|
+
y: yCats ? null : [ymin, ymax],
|
|
294
|
+
r: r ? [rmin, rmax] : null,
|
|
295
|
+
fill: continuous ? [fmin, fmax] : null,
|
|
296
|
+
xpos,
|
|
297
|
+
ypos
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
}
|
package/src/scale-map.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns one of Observable Plot's scale objects (what `svg.scale('x')` returns,
|
|
3
|
+
* and what a render function gets as `scales.scales.x`) into two parts:
|
|
4
|
+
*
|
|
5
|
+
* 1. `transformFor(scale)`: the curved part `T` (log, square root, power,
|
|
6
|
+
* symlog; nothing for linear and time scales). Applied once per value when
|
|
7
|
+
* the data is sent to the graphics card.
|
|
8
|
+
* 2. `affine(scale, center, shift)`: the straight-line part, worked out again
|
|
9
|
+
* for every frame: `px = a * (T(v) - center) + b`. Panning and zooming only
|
|
10
|
+
* change `a` and `b`.
|
|
11
|
+
*
|
|
12
|
+
* Both depend only on the scale object, so the tests compare them with the d3
|
|
13
|
+
* scales Plot builds from the same domain and range.
|
|
14
|
+
*
|
|
15
|
+
* A category axis (a point or band scale) is uploaded as category codes.
|
|
16
|
+
* `categoryAxis` gives each code its place in the scale's domain and the straight
|
|
17
|
+
* line through those places, `axisTransform` looks the places up, and `axisAffine`
|
|
18
|
+
* picks between the two kinds of line.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const identity = v => v;
|
|
22
|
+
|
|
23
|
+
/** Power that keeps the sign, like d3's pow and sqrt scales do for negative numbers. */
|
|
24
|
+
const power = e => v => (v < 0 ? -Math.pow(-v, e) : Math.pow(v, e));
|
|
25
|
+
|
|
26
|
+
/** The curved part of a scale, or a clear error for scale types the mark can't draw. */
|
|
27
|
+
export function transformFor(scale, name = 'position') {
|
|
28
|
+
switch (scale?.type) {
|
|
29
|
+
case undefined:
|
|
30
|
+
case 'identity':
|
|
31
|
+
case 'linear':
|
|
32
|
+
case 'time':
|
|
33
|
+
case 'utc':
|
|
34
|
+
return identity;
|
|
35
|
+
case 'point':
|
|
36
|
+
case 'band':
|
|
37
|
+
// The uploaded values are category codes; `categoryAxis` places them.
|
|
38
|
+
return identity;
|
|
39
|
+
case 'log':
|
|
40
|
+
// The base only multiplies log values by a constant, and `affine` divides it out.
|
|
41
|
+
return Math.log;
|
|
42
|
+
case 'sqrt':
|
|
43
|
+
return power(0.5);
|
|
44
|
+
case 'pow':
|
|
45
|
+
return power(scale.exponent ?? 1);
|
|
46
|
+
case 'symlog': {
|
|
47
|
+
const c = scale.constant ?? 1;
|
|
48
|
+
return v => (v < 0 ? -Math.log1p(-v / c) : Math.log1p(v / c));
|
|
49
|
+
}
|
|
50
|
+
default:
|
|
51
|
+
throw new Error(`dotGL: the ${name} scale type "${scale.type}" is not supported`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The straight-line part. It takes a value that has been through `T` and had `center` subtracted, and gives the plot pixel.
|
|
57
|
+
* `shift` moves the origin (for example to the top-left of the clip frame).
|
|
58
|
+
*/
|
|
59
|
+
export function affine(scale, center = 0, shift = 0, name = 'position') {
|
|
60
|
+
if (!scale || scale.type === 'identity') return { a: 1, b: shift - center };
|
|
61
|
+
const { domain, range } = scale;
|
|
62
|
+
if (!domain || domain.length !== 2 || !range || range.length !== 2) {
|
|
63
|
+
throw new Error(`dotGL: the ${name} scale must have a two-value domain and range`);
|
|
64
|
+
}
|
|
65
|
+
const T = transformFor(scale, name);
|
|
66
|
+
const t0 = T(+domain[0]);
|
|
67
|
+
const t1 = T(+domain[1]);
|
|
68
|
+
const [r0, r1] = range;
|
|
69
|
+
// When both ends of the domain are equal, d3 puts every value in the middle of the range.
|
|
70
|
+
if (t1 === t0) return { a: 0, b: (r0 + r1) / 2 + shift };
|
|
71
|
+
const a = (r1 - r0) / (t1 - t0);
|
|
72
|
+
return { a, b: r0 + (center - t0) * a + shift };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Where a point or band scale puts each category of a column drawn as category codes. `pos[code]` is the
|
|
77
|
+
* category's place in the scale's domain, and place i is at pixel `a * i + b`, because these scales space
|
|
78
|
+
* their domain evenly. A band scale's dots sit in the middle of the band. The domain is usually the
|
|
79
|
+
* categories in the data, but it can list more (an explicit domain, `vg.Fixed`, or another mark on the
|
|
80
|
+
* same axis) or leave some out; a category that isn't in the domain gets NaN and its dots aren't drawn,
|
|
81
|
+
* as with Plot's dot. A number scale places no categories. Plot picks one when the only category left is
|
|
82
|
+
* the empty value, and Plot's dot then draws nothing too.
|
|
83
|
+
*/
|
|
84
|
+
export function categoryAxis(scale, cats) {
|
|
85
|
+
const pos = new Float64Array(cats.length).fill(NaN);
|
|
86
|
+
if (scale.type !== 'point' && scale.type !== 'band') return { a: 0, b: 0, pos };
|
|
87
|
+
// d3 keeps the first of repeated values in a domain, so places count distinct values.
|
|
88
|
+
const place = new Map();
|
|
89
|
+
for (const v of scale.domain) if (!place.has(v)) place.set(v, place.size);
|
|
90
|
+
for (let i = 0; i < cats.length; ++i) pos[i] = place.get(cats[i]) ?? NaN;
|
|
91
|
+
const values = Array.from(place.keys());
|
|
92
|
+
const n = values.length;
|
|
93
|
+
if (n === 0) return { a: 0, b: 0, pos };
|
|
94
|
+
const first = scale.apply(values[0]);
|
|
95
|
+
const a = n > 1 ? (scale.apply(values[n - 1]) - first) / (n - 1) : 0;
|
|
96
|
+
return { a, b: first + (scale.bandwidth ?? 0) / 2, pos };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The function the painters and the pick index run on an axis value before its line: the place lookup
|
|
101
|
+
* for a category axis, the scale's curve otherwise. Codes past the end of the list (the hidden code) give NaN.
|
|
102
|
+
*/
|
|
103
|
+
export function axisTransform(scale, line, name = 'position') {
|
|
104
|
+
if (!line) return transformFor(scale, name);
|
|
105
|
+
const { pos } = line;
|
|
106
|
+
return code => (code < pos.length ? pos[code] : NaN);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** True when two category place tables are the same, or both absent. */
|
|
110
|
+
export function samePlaces(a, b) {
|
|
111
|
+
if (a === b) return true;
|
|
112
|
+
if (!a || !b || a.length !== b.length) return false;
|
|
113
|
+
for (let i = 0; i < a.length; ++i) if (!Object.is(a[i], b[i])) return false;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The per-frame line for an x or y axis, in the same form as `affine`: from the category axis when
|
|
119
|
+
* the axis is drawn from category codes, otherwise from the scale.
|
|
120
|
+
*/
|
|
121
|
+
export function axisAffine(scale, line, center = 0, shift = 0, name = 'position') {
|
|
122
|
+
return line ? { a: line.a, b: line.b + line.a * center + shift } : affine(scale, center, shift, name);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The pixel for one data value, worked out the same way the shader does it. */
|
|
126
|
+
export function project(scale, v, center = 0, shift = 0) {
|
|
127
|
+
const { a, b } = affine(scale, center, shift);
|
|
128
|
+
return a * (transformFor(scale)(+v) - center) + b;
|
|
129
|
+
}
|
package/src/shaders.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Each dot is a small square made of two triangles, and the fragment shader cuts
|
|
3
|
+
* a circle out of it with a one-pixel soft edge. The vertex shader places the
|
|
4
|
+
* square in screen pixels from the per-dot values plus the per-frame scale
|
|
5
|
+
* numbers. Colors are written with the alpha already multiplied in, so
|
|
6
|
+
* overlapping dots add up the way see-through SVG circles do.
|
|
7
|
+
*
|
|
8
|
+
* Attribute slots: 0 corner (per vertex), 1 x, 2 y, 3 r, 4 color code (per dot).
|
|
9
|
+
* The color code picks a texel of the 256×256 palette: column code % 256, row
|
|
10
|
+
* code / 256. The code `u_hidden` (255 for one-byte codes, 65535 for two-byte
|
|
11
|
+
* codes) hides the dot, and so does a transparent palette entry (a category the
|
|
12
|
+
* color scale leaves out).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const VERTEX = `#version 300 es
|
|
16
|
+
precision highp float;
|
|
17
|
+
layout(location = 0) in vec2 a_corner;
|
|
18
|
+
layout(location = 1) in float a_x;
|
|
19
|
+
layout(location = 2) in float a_y;
|
|
20
|
+
layout(location = 3) in float a_r;
|
|
21
|
+
layout(location = 4) in float a_cat;
|
|
22
|
+
|
|
23
|
+
uniform vec2 u_ax; // px = a_x * u_ax.x + u_ax.y (CSS pixels, measured from the frame's top-left)
|
|
24
|
+
uniform vec2 u_ay;
|
|
25
|
+
uniform vec2 u_res; // canvas size in device pixels
|
|
26
|
+
uniform float u_dpr;
|
|
27
|
+
uniform float u_offset; // Plot's half-pixel nudge on 1x displays
|
|
28
|
+
uniform vec4 u_r; // x: 0 = one radius for all, 1 = per dot; y: a, z: b; w: the shared radius (CSS pixels)
|
|
29
|
+
uniform int u_colorMode; // 0 = one color for all, 1 = look the color up in the palette
|
|
30
|
+
uniform vec4 u_color;
|
|
31
|
+
uniform sampler2D u_palette;
|
|
32
|
+
uniform float u_hidden; // the color code that hides a dot
|
|
33
|
+
|
|
34
|
+
out vec2 v_p;
|
|
35
|
+
out float v_rpx;
|
|
36
|
+
out vec4 v_color;
|
|
37
|
+
|
|
38
|
+
void main() {
|
|
39
|
+
float px = a_x * u_ax.x + u_ax.y + u_offset;
|
|
40
|
+
float py = a_y * u_ay.x + u_ay.y + u_offset;
|
|
41
|
+
float r = u_r.x < 0.5 ? u_r.w : (u_r.y * a_r + u_r.z);
|
|
42
|
+
v_rpx = r * u_dpr;
|
|
43
|
+
v_p = a_corner * (v_rpx + 1.0);
|
|
44
|
+
vec2 c = vec2(px, py) * u_dpr + v_p;
|
|
45
|
+
v_color = u_colorMode == 1 ? texelFetch(u_palette, ivec2(int(mod(a_cat, 256.0)), int(a_cat / 256.0)), 0) : u_color;
|
|
46
|
+
bool hide = r <= 0.0 || a_cat >= u_hidden - 0.5 || v_color.a <= 0.0;
|
|
47
|
+
gl_Position = hide
|
|
48
|
+
? vec4(2.0, 2.0, 0.0, 1.0)
|
|
49
|
+
: vec4(c.x / u_res.x * 2.0 - 1.0, 1.0 - c.y / u_res.y * 2.0, 0.0, 1.0);
|
|
50
|
+
}`;
|
|
51
|
+
|
|
52
|
+
export const FRAGMENT = `#version 300 es
|
|
53
|
+
precision mediump float;
|
|
54
|
+
in vec2 v_p;
|
|
55
|
+
in float v_rpx;
|
|
56
|
+
in vec4 v_color;
|
|
57
|
+
uniform float u_opacity;
|
|
58
|
+
out vec4 o;
|
|
59
|
+
|
|
60
|
+
void main() {
|
|
61
|
+
float d = length(v_p);
|
|
62
|
+
float a = (1.0 - smoothstep(v_rpx - 0.5, v_rpx + 0.5, d)) * u_opacity * v_color.a;
|
|
63
|
+
o = vec4(v_color.rgb * a, a);
|
|
64
|
+
}`;
|
|
65
|
+
|
|
66
|
+
export const UNIFORMS = ['u_ax', 'u_ay', 'u_res', 'u_dpr', 'u_offset', 'u_r', 'u_colorMode', 'u_color', 'u_palette', 'u_opacity', 'u_hidden'];
|