@uwdata/mosaic-plot 0.7.1 → 0.9.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/dist/mosaic-plot.js +4702 -5648
- package/dist/mosaic-plot.min.js +14 -14
- package/package.json +5 -5
- package/src/index.js +1 -0
- package/src/interactors/Highlight.js +6 -3
- package/src/interactors/Interval1D.js +14 -12
- package/src/interactors/Interval2D.js +13 -16
- package/src/interactors/Nearest.js +80 -36
- package/src/interactors/PanZoom.js +7 -9
- package/src/interactors/Toggle.js +29 -37
- package/src/interactors/util/patchScreenCTM.js +2 -0
- package/src/legend.js +150 -29
- package/src/marks/ConnectedMark.js +6 -0
- package/src/marks/ContourMark.js +36 -16
- package/src/marks/DenseLineMark.js +9 -5
- package/src/marks/Density1DMark.js +22 -13
- package/src/marks/Density2DMark.js +33 -18
- package/src/marks/ErrorBarMark.js +50 -0
- package/src/marks/GeoMark.js +7 -8
- package/src/marks/Grid2DMark.js +58 -28
- package/src/marks/HexbinMark.js +10 -2
- package/src/marks/Mark.js +56 -16
- package/src/marks/RasterMark.js +61 -23
- package/src/marks/RasterTileMark.js +39 -20
- package/src/marks/RegressionMark.js +69 -34
- package/src/marks/util/grid.js +94 -86
- package/src/marks/util/handle-param.js +10 -11
- package/src/marks/util/is-constant-option.js +2 -1
- package/src/marks/util/permute.js +10 -0
- package/src/marks/util/stats.js +121 -1
- package/src/marks/util/to-data-columns.js +71 -0
- package/src/plot-attributes.js +11 -3
- package/src/plot-renderer.js +28 -9
- package/src/plot.js +20 -0
- package/src/transforms/bin.js +3 -1
- package/src/marks/util/interpolate.js +0 -205
- package/src/marks/util/to-data-array.js +0 -50
package/src/marks/Grid2DMark.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { interpolatorBarycentric, interpolateNearest, interpolatorRandomWalk } from '@observablehq/plot';
|
|
1
2
|
import { Query, count, isBetween, lt, lte, neq, sql, sum } from '@uwdata/mosaic-sql';
|
|
2
3
|
import { Transient } from '../symbols.js';
|
|
3
4
|
import { binExpr } from './util/bin-expr.js';
|
|
@@ -5,9 +6,7 @@ import { dericheConfig, dericheConv2d } from './util/density.js';
|
|
|
5
6
|
import { extentX, extentY, xyext } from './util/extent.js';
|
|
6
7
|
import { grid2d } from './util/grid.js';
|
|
7
8
|
import { handleParam } from './util/handle-param.js';
|
|
8
|
-
import {
|
|
9
|
-
interpolateNearest, interpolatorBarycentric, interpolatorRandomWalk
|
|
10
|
-
} from './util/interpolate.js';
|
|
9
|
+
import { toDataColumns } from './util/to-data-columns.js';
|
|
11
10
|
import { Mark } from './Mark.js';
|
|
12
11
|
|
|
13
12
|
export const DENSITY = 'density';
|
|
@@ -28,21 +27,47 @@ export class Grid2DMark extends Mark {
|
|
|
28
27
|
super(type, source, channels, xyext);
|
|
29
28
|
this.densityMap = densityMap;
|
|
30
29
|
|
|
31
|
-
|
|
30
|
+
/** @type {number} */
|
|
31
|
+
this.bandwidth = handleParam(bandwidth, value => {
|
|
32
|
+
this.bandwidth = value;
|
|
32
33
|
return this.grids ? this.convolve().update() : null;
|
|
33
34
|
});
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
handleParam(
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
|
|
36
|
+
/** @type {string} */
|
|
37
|
+
this.interpolate = handleParam(interpolate, value => {
|
|
38
|
+
return (this.interpolate = value, this.requestUpdate());
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** @type {number} */
|
|
42
|
+
this.pixelSize = handleParam(pixelSize, value => {
|
|
43
|
+
return (this.pixelSize = value, this.requestUpdate());
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** @type {number} */
|
|
47
|
+
this.pad = handleParam(pad, value => {
|
|
48
|
+
return (this.pad = value, this.requestUpdate());
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** @type {number|undefined} */
|
|
52
|
+
this.width = handleParam(width, value => {
|
|
53
|
+
return (this.width = value, this.requestUpdate());
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** @type {number|undefined} */
|
|
57
|
+
this.height = handleParam(height, value => {
|
|
58
|
+
return (this.height = value, this.requestUpdate());
|
|
59
|
+
});
|
|
39
60
|
}
|
|
40
61
|
|
|
62
|
+
/**
|
|
63
|
+
* @param {import('../plot.js').Plot} plot The plot.
|
|
64
|
+
* @param {number} index
|
|
65
|
+
*/
|
|
41
66
|
setPlot(plot, index) {
|
|
42
67
|
const update = () => { if (this.hasFieldInfo()) this.requestUpdate(); };
|
|
43
|
-
plot.addAttributeListener('
|
|
44
|
-
plot.addAttributeListener('
|
|
45
|
-
|
|
68
|
+
plot.addAttributeListener('xDomain', update);
|
|
69
|
+
plot.addAttributeListener('yDomain', update);
|
|
70
|
+
super.setPlot(plot, index);
|
|
46
71
|
}
|
|
47
72
|
|
|
48
73
|
get filterIndexable() {
|
|
@@ -55,7 +80,7 @@ export class Grid2DMark extends Mark {
|
|
|
55
80
|
const { interpolate, pad, channels, densityMap, source } = this;
|
|
56
81
|
const [x0, x1] = this.extentX = extentX(this, filter);
|
|
57
82
|
const [y0, y1] = this.extentY = extentY(this, filter);
|
|
58
|
-
const [nx, ny] = this.bins = this.binDimensions(
|
|
83
|
+
const [nx, ny] = this.bins = this.binDimensions();
|
|
59
84
|
const [x, bx] = binExpr(this, 'x', nx, [x0, x1], pad);
|
|
60
85
|
const [y, by] = binExpr(this, 'y', ny, [y0, y1], pad);
|
|
61
86
|
|
|
@@ -115,6 +140,9 @@ export class Grid2DMark extends Mark {
|
|
|
115
140
|
}
|
|
116
141
|
}
|
|
117
142
|
|
|
143
|
+
/**
|
|
144
|
+
* @returns {[number, number]} The bin dimensions.
|
|
145
|
+
*/
|
|
118
146
|
binDimensions() {
|
|
119
147
|
const { plot, pixelSize, width, height } = this;
|
|
120
148
|
return [
|
|
@@ -126,47 +154,49 @@ export class Grid2DMark extends Mark {
|
|
|
126
154
|
queryResult(data) {
|
|
127
155
|
const [w, h] = this.bins;
|
|
128
156
|
const interp = maybeInterpolate(this.interpolate);
|
|
129
|
-
|
|
157
|
+
const { columns } = toDataColumns(data);
|
|
158
|
+
this.grids0 = grid2d(w, h, columns.index, columns, this.aggr, this.groupby, interp);
|
|
130
159
|
return this.convolve();
|
|
131
160
|
}
|
|
132
161
|
|
|
133
162
|
convolve() {
|
|
134
|
-
const { aggr, bandwidth, bins,
|
|
163
|
+
const { aggr, bandwidth, bins, grids0, plot } = this;
|
|
135
164
|
|
|
136
165
|
// no smoothing as default fallback
|
|
137
|
-
this.
|
|
166
|
+
this.grids = grids0;
|
|
138
167
|
|
|
139
168
|
if (bandwidth > 0) {
|
|
140
169
|
// determine which grid to smooth
|
|
141
|
-
const
|
|
170
|
+
const prop = aggr.length === 1 ? aggr[0]
|
|
142
171
|
: aggr.includes(DENSITY) ? DENSITY
|
|
143
172
|
: null;
|
|
144
173
|
|
|
145
174
|
// bail if no compatible grid found
|
|
146
|
-
if (!
|
|
175
|
+
if (!prop) {
|
|
147
176
|
console.warn('No compatible grid found for smoothing.');
|
|
148
177
|
return this;
|
|
149
178
|
}
|
|
179
|
+
const g = grids0.columns[prop];
|
|
150
180
|
|
|
151
181
|
// apply smoothing, bandwidth uses units of screen pixels
|
|
152
182
|
const w = plot.innerWidth();
|
|
153
183
|
const h = plot.innerHeight();
|
|
154
184
|
const [nx, ny] = bins;
|
|
155
|
-
const neg =
|
|
185
|
+
const neg = g.some(grid => grid.some(v => v < 0));
|
|
156
186
|
const configX = dericheConfig(bandwidth * (nx - 1) / w, neg);
|
|
157
187
|
const configY = dericheConfig(bandwidth * (ny - 1) / h, neg);
|
|
158
|
-
this.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
188
|
+
this.grids = {
|
|
189
|
+
numRows: grids0.numRows,
|
|
190
|
+
columns: {
|
|
191
|
+
...grids0.columns,
|
|
192
|
+
// @ts-ignore
|
|
193
|
+
[prop]: g.map(grid => dericheConv2d(configX, configY, grid, bins))
|
|
194
|
+
}
|
|
195
|
+
};
|
|
162
196
|
}
|
|
163
197
|
|
|
164
198
|
return this;
|
|
165
199
|
}
|
|
166
|
-
|
|
167
|
-
plotSpecs() {
|
|
168
|
-
throw new Error('Unimplemented. Use a Grid2D mark subclass.');
|
|
169
|
-
}
|
|
170
200
|
}
|
|
171
201
|
|
|
172
202
|
/**
|
|
@@ -185,7 +215,7 @@ function createDensityMap(channels) {
|
|
|
185
215
|
|
|
186
216
|
function maybeInterpolate(interpolate = 'none') {
|
|
187
217
|
if (typeof interpolate === 'function') return interpolate;
|
|
188
|
-
switch (
|
|
218
|
+
switch (interpolate.toLowerCase()) {
|
|
189
219
|
case 'none':
|
|
190
220
|
case 'linear':
|
|
191
221
|
return undefined; // no special interpolation need
|
package/src/marks/HexbinMark.js
CHANGED
|
@@ -2,12 +2,17 @@ import { Query, isNotNull, sql } from '@uwdata/mosaic-sql';
|
|
|
2
2
|
import { Transient } from '../symbols.js';
|
|
3
3
|
import { extentX, extentY, xyext } from './util/extent.js';
|
|
4
4
|
import { Mark } from './Mark.js';
|
|
5
|
+
import { handleParam } from './util/handle-param.js';
|
|
5
6
|
|
|
6
7
|
export class HexbinMark extends Mark {
|
|
7
8
|
constructor(source, options) {
|
|
8
9
|
const { type = 'hexagon', binWidth = 20, ...channels } = options;
|
|
9
10
|
super(type, source, { r: binWidth / 2, clip: true, ...channels }, xyext);
|
|
10
|
-
|
|
11
|
+
|
|
12
|
+
/** @type {number} */
|
|
13
|
+
this.binWidth = handleParam(binWidth, value => {
|
|
14
|
+
return (this.binWidth = value, this.requestUpdate());
|
|
15
|
+
});
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
get filterIndexable() {
|
|
@@ -39,9 +44,10 @@ export class HexbinMark extends Mark {
|
|
|
39
44
|
let x, y;
|
|
40
45
|
const aggr = new Set;
|
|
41
46
|
const cols = {};
|
|
47
|
+
let orderby;
|
|
42
48
|
for (const c of channels) {
|
|
43
49
|
if (c.channel === 'orderby') {
|
|
44
|
-
|
|
50
|
+
orderby = c.value; // TODO revisit once groupby is added
|
|
45
51
|
} else if (c.channel === 'x') {
|
|
46
52
|
x = c;
|
|
47
53
|
} else if (c.channel === 'y') {
|
|
@@ -63,6 +69,8 @@ export class HexbinMark extends Mark {
|
|
|
63
69
|
...cols
|
|
64
70
|
}).groupby('x', 'y');
|
|
65
71
|
|
|
72
|
+
if (orderby) q.orderby(orderby);
|
|
73
|
+
|
|
66
74
|
// Map x/y channels to screen space
|
|
67
75
|
const xx = `${xr} * (${x.field} - ${x1}::DOUBLE)`;
|
|
68
76
|
const yy = `${yr} * (${y2}::DOUBLE - ${y.field})`;
|
package/src/marks/Mark.js
CHANGED
|
@@ -3,10 +3,11 @@ import { Query, Ref, column, isParamLike } from '@uwdata/mosaic-sql';
|
|
|
3
3
|
import { isColor } from './util/is-color.js';
|
|
4
4
|
import { isConstantOption } from './util/is-constant-option.js';
|
|
5
5
|
import { isSymbol } from './util/is-symbol.js';
|
|
6
|
-
import {
|
|
6
|
+
import { toDataColumns } from './util/to-data-columns.js';
|
|
7
7
|
import { Transform } from '../symbols.js';
|
|
8
8
|
|
|
9
9
|
const isColorChannel = channel => channel === 'stroke' || channel === 'fill';
|
|
10
|
+
const isOpacityChannel = channel => /opacity$/i.test(channel);
|
|
10
11
|
const isSymbolChannel = channel => channel === 'symbol';
|
|
11
12
|
const isFieldObject = (channel, field) => {
|
|
12
13
|
return channel !== 'sort' && channel !== 'tip'
|
|
@@ -31,7 +32,7 @@ export class Mark extends MosaicClient {
|
|
|
31
32
|
|
|
32
33
|
this.source = source;
|
|
33
34
|
if (isDataArray(this.source)) {
|
|
34
|
-
this.data = this.source;
|
|
35
|
+
this.data = toDataColumns(this.source);
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
const channels = this.channels = [];
|
|
@@ -63,12 +64,15 @@ export class Mark extends MosaicClient {
|
|
|
63
64
|
}
|
|
64
65
|
} else if (isParamLike(entry)) {
|
|
65
66
|
if (Array.isArray(entry.columns)) {
|
|
67
|
+
// we currently duck-type to having a columns array
|
|
68
|
+
// as a check that this is SQLExpression-compatible
|
|
66
69
|
channels.push(fieldEntry(channel, entry));
|
|
67
70
|
params.add(entry);
|
|
68
71
|
} else {
|
|
69
72
|
const c = valueEntry(channel, entry.value);
|
|
70
73
|
channels.push(c);
|
|
71
74
|
entry.addEventListener('value', value => {
|
|
75
|
+
// update immediately, the value is simply passed to Plot
|
|
72
76
|
c.value = value;
|
|
73
77
|
return this.update();
|
|
74
78
|
});
|
|
@@ -85,6 +89,10 @@ export class Mark extends MosaicClient {
|
|
|
85
89
|
}
|
|
86
90
|
}
|
|
87
91
|
|
|
92
|
+
/**
|
|
93
|
+
* @param {import('../plot.js').Plot} plot The plot.
|
|
94
|
+
* @param {number} index
|
|
95
|
+
*/
|
|
88
96
|
setPlot(plot, index) {
|
|
89
97
|
this.plot = plot;
|
|
90
98
|
this.index = index;
|
|
@@ -104,7 +112,7 @@ export class Mark extends MosaicClient {
|
|
|
104
112
|
return this.channels.find(c => c.channel === channel);
|
|
105
113
|
}
|
|
106
114
|
|
|
107
|
-
channelField(channel, { exact } = {}) {
|
|
115
|
+
channelField(channel, { exact = false } = {}) {
|
|
108
116
|
const c = exact
|
|
109
117
|
? this.channel(channel)
|
|
110
118
|
: this.channels.find(c => c.channel.startsWith(channel));
|
|
@@ -140,6 +148,11 @@ export class Mark extends MosaicClient {
|
|
|
140
148
|
return this;
|
|
141
149
|
}
|
|
142
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Return a query specifying the data needed by this Mark client.
|
|
153
|
+
* @param {*} [filter] The filtering criteria to apply in the query.
|
|
154
|
+
* @returns {*} The client query
|
|
155
|
+
*/
|
|
143
156
|
query(filter = []) {
|
|
144
157
|
if (this.hasOwnData()) return null;
|
|
145
158
|
const { channels, source: { table } } = this;
|
|
@@ -151,8 +164,11 @@ export class Mark extends MosaicClient {
|
|
|
151
164
|
return this;
|
|
152
165
|
}
|
|
153
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Provide query result data to the mark.
|
|
169
|
+
*/
|
|
154
170
|
queryResult(data) {
|
|
155
|
-
this.data =
|
|
171
|
+
this.data = toDataColumns(data);
|
|
156
172
|
return this;
|
|
157
173
|
}
|
|
158
174
|
|
|
@@ -160,16 +176,13 @@ export class Mark extends MosaicClient {
|
|
|
160
176
|
return this.plot.update(this);
|
|
161
177
|
}
|
|
162
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Generate an array of Plot mark specifications.
|
|
181
|
+
* @returns {object[]}
|
|
182
|
+
*/
|
|
163
183
|
plotSpecs() {
|
|
164
184
|
const { type, data, detail, channels } = this;
|
|
165
|
-
|
|
166
|
-
const side = {};
|
|
167
|
-
for (const c of channels) {
|
|
168
|
-
const obj = detail.has(c.channel) ? side : options;
|
|
169
|
-
obj[c.channel] = channelOption(c)
|
|
170
|
-
}
|
|
171
|
-
if (detail.size) options.channels = side;
|
|
172
|
-
return [{ type, data, options }];
|
|
185
|
+
return markPlotSpec(type, detail, channels, data);
|
|
173
186
|
}
|
|
174
187
|
}
|
|
175
188
|
|
|
@@ -178,14 +191,17 @@ export class Mark extends MosaicClient {
|
|
|
178
191
|
* Checks if a constant value or a data field is needed.
|
|
179
192
|
* Also avoids misinterpretation of data values as color names.
|
|
180
193
|
* @param {*} c a visual encoding channel spec
|
|
194
|
+
* @param {object} columns named data column arrays
|
|
181
195
|
* @returns the Plot channel option
|
|
182
196
|
*/
|
|
183
|
-
export function channelOption(c) {
|
|
197
|
+
export function channelOption(c, columns) {
|
|
184
198
|
// use a scale override for color channels to sidestep
|
|
185
199
|
// https://github.com/observablehq/plot/issues/1593
|
|
200
|
+
const value = columns?.[c.as] ?? c.as;
|
|
186
201
|
return Object.hasOwn(c, 'value') ? c.value
|
|
187
|
-
: isColorChannel(c.channel) ? { value
|
|
188
|
-
: c.
|
|
202
|
+
: isColorChannel(c.channel) ? { value, scale: 'color' }
|
|
203
|
+
: isOpacityChannel(c.channel) ? { value, scale: 'opacity' }
|
|
204
|
+
: value;
|
|
189
205
|
}
|
|
190
206
|
|
|
191
207
|
/**
|
|
@@ -196,7 +212,7 @@ export function channelOption(c) {
|
|
|
196
212
|
* @param {*} table the table to query.
|
|
197
213
|
* @param {*} skip an optional array of channels to skip.
|
|
198
214
|
* Mark subclasses can skip channels that require special handling.
|
|
199
|
-
* @returns a Query instance
|
|
215
|
+
* @returns {Query} a Query instance
|
|
200
216
|
*/
|
|
201
217
|
export function markQuery(channels, table, skip = []) {
|
|
202
218
|
const q = Query.from({ source: table });
|
|
@@ -226,3 +242,27 @@ export function markQuery(channels, table, skip = []) {
|
|
|
226
242
|
|
|
227
243
|
return q;
|
|
228
244
|
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Generate an array of Plot mark specifications.
|
|
249
|
+
* @returns {object[]}
|
|
250
|
+
*/
|
|
251
|
+
export function markPlotSpec(type, detail, channels, data, options = {}) {
|
|
252
|
+
// @ts-ignore
|
|
253
|
+
const { numRows: length, values, columns } = data ?? {};
|
|
254
|
+
|
|
255
|
+
// populate plot specification options
|
|
256
|
+
const side = {};
|
|
257
|
+
for (const c of channels) {
|
|
258
|
+
const obj = detail.has(c.channel) ? side : options;
|
|
259
|
+
obj[c.channel] = channelOption(c, columns);
|
|
260
|
+
}
|
|
261
|
+
if (detail.size) options.channels = side;
|
|
262
|
+
|
|
263
|
+
// if provided raw source values (not objects) pass as-is
|
|
264
|
+
// otherwise we pass columnar data directy in the options
|
|
265
|
+
const specData = values ?? (data ? { length } : null);
|
|
266
|
+
const spec = [{ type, data: specData, options }];
|
|
267
|
+
return spec;
|
|
268
|
+
}
|
package/src/marks/RasterMark.js
CHANGED
|
@@ -2,6 +2,7 @@ import { ascending } from 'd3';
|
|
|
2
2
|
import { scale } from '@observablehq/plot';
|
|
3
3
|
import { gridDomainContinuous, gridDomainDiscrete } from './util/grid.js';
|
|
4
4
|
import { isColor } from './util/is-color.js';
|
|
5
|
+
import { indices, permute } from './util/permute.js';
|
|
5
6
|
import { alphaScheme, alphaConstant, colorConstant, colorCategory, colorScheme, createCanvas } from './util/raster.js';
|
|
6
7
|
import { DENSITY, Grid2DMark } from './Grid2DMark.js';
|
|
7
8
|
import { Fixed, Transient } from '../symbols.js';
|
|
@@ -20,6 +21,7 @@ import { Fixed, Transient } from '../symbols.js';
|
|
|
20
21
|
export class RasterMark extends Grid2DMark {
|
|
21
22
|
constructor(source, options) {
|
|
22
23
|
super('image', source, options);
|
|
24
|
+
this.image = null;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
setPlot(plot, index) {
|
|
@@ -33,37 +35,50 @@ export class RasterMark extends Grid2DMark {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
rasterize() {
|
|
36
|
-
const { bins,
|
|
38
|
+
const { bins, grids } = this;
|
|
37
39
|
const [ w, h ] = bins;
|
|
40
|
+
const { numRows, columns } = grids;
|
|
38
41
|
|
|
39
42
|
// raster data
|
|
40
43
|
const { canvas, ctx, img } = imageData(this, w, h);
|
|
41
44
|
|
|
42
45
|
// color + opacity encodings
|
|
43
46
|
const { alpha, alphaProp, color, colorProp } = rasterEncoding(this);
|
|
47
|
+
const alphaData = columns[alphaProp] ?? [];
|
|
48
|
+
const colorData = columns[colorProp] ?? [];
|
|
49
|
+
|
|
50
|
+
// determine raster order
|
|
51
|
+
const idx = numRows > 1 && colorProp && this.groupby?.includes(colorProp)
|
|
52
|
+
? permute(colorData, this.plot.getAttribute('colorDomain'))
|
|
53
|
+
: indices(numRows);
|
|
44
54
|
|
|
45
55
|
// generate rasters
|
|
46
|
-
this.data =
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
this.data = {
|
|
57
|
+
numRows,
|
|
58
|
+
columns: {
|
|
59
|
+
src: Array.from({ length: numRows }, (_, i) => {
|
|
60
|
+
color?.(img.data, w, h, colorData[idx[i]]);
|
|
61
|
+
alpha?.(img.data, w, h, alphaData[idx[i]]);
|
|
62
|
+
ctx.putImageData(img, 0, 0);
|
|
63
|
+
return canvas.toDataURL();
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
};
|
|
52
67
|
|
|
53
68
|
return this;
|
|
54
69
|
}
|
|
55
70
|
|
|
56
71
|
plotSpecs() {
|
|
57
|
-
const { type, plot, data } = this;
|
|
72
|
+
const { type, plot, data: { numRows: length, columns } } = this;
|
|
58
73
|
const options = {
|
|
59
|
-
src:
|
|
74
|
+
src: columns.src,
|
|
60
75
|
width: plot.innerWidth(),
|
|
61
76
|
height: plot.innerHeight(),
|
|
62
77
|
preserveAspectRatio: 'none',
|
|
63
78
|
imageRendering: this.channel('imageRendering')?.value,
|
|
64
79
|
frameAnchor: 'middle'
|
|
65
80
|
};
|
|
66
|
-
return [{ type, data, options }];
|
|
81
|
+
return [{ type, data: { length }, options }];
|
|
67
82
|
}
|
|
68
83
|
}
|
|
69
84
|
|
|
@@ -86,6 +101,7 @@ export class HeatmapMark extends RasterMark {
|
|
|
86
101
|
/**
|
|
87
102
|
* Utility method to generate color and alpha encoding helpers.
|
|
88
103
|
* The returned methods can write directly to a pixel raster.
|
|
104
|
+
* @param {RasterMark} mark
|
|
89
105
|
*/
|
|
90
106
|
export function rasterEncoding(mark) {
|
|
91
107
|
const { aggr, densityMap, groupby, plot } = mark;
|
|
@@ -132,18 +148,24 @@ export function rasterEncoding(mark) {
|
|
|
132
148
|
return { alphaProp, colorProp, alpha, color };
|
|
133
149
|
}
|
|
134
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Generate an opacity rasterizer for a bitmap alpha channel.
|
|
153
|
+
* @param {RasterMark} mark The mark instance
|
|
154
|
+
* @param {string} prop The data property name
|
|
155
|
+
* @returns A bitmap rasterizer function.
|
|
156
|
+
*/
|
|
135
157
|
function alphaScale(mark, prop) {
|
|
136
|
-
const { plot,
|
|
158
|
+
const { plot, grids } = mark;
|
|
137
159
|
|
|
138
160
|
// determine scale domain
|
|
139
161
|
const domainAttr = plot.getAttribute('opacityDomain');
|
|
140
162
|
const domainFixed = domainAttr === Fixed;
|
|
141
163
|
const domainTransient = domainAttr?.[Transient];
|
|
142
164
|
const domain = (!domainFixed && !domainTransient && domainAttr)
|
|
143
|
-
|| gridDomainContinuous(grids
|
|
144
|
-
if (domainFixed || domainTransient) {
|
|
145
|
-
if (
|
|
146
|
-
plot.setAttribute('
|
|
165
|
+
|| gridDomainContinuous(grids.columns[prop]);
|
|
166
|
+
if (domainFixed || domainTransient || !domainAttr) {
|
|
167
|
+
if (!domainFixed) domain[Transient] = true;
|
|
168
|
+
plot.setAttribute('opacityDomain', domain);
|
|
147
169
|
}
|
|
148
170
|
|
|
149
171
|
// generate opacity scale
|
|
@@ -163,22 +185,29 @@ function alphaScale(mark, prop) {
|
|
|
163
185
|
return alphaScheme(s);
|
|
164
186
|
}
|
|
165
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Generate an color rasterizer for bitmap r, g, b channels.
|
|
190
|
+
* @param {RasterMark} mark The mark instance
|
|
191
|
+
* @param {string} prop
|
|
192
|
+
* @returns A bitmap rasterizer function.
|
|
193
|
+
*/
|
|
166
194
|
function colorScale(mark, prop) {
|
|
167
|
-
const { plot,
|
|
168
|
-
const
|
|
169
|
-
const
|
|
195
|
+
const { plot, grids } = mark;
|
|
196
|
+
const data = grids.columns[prop];
|
|
197
|
+
const flat = !data[0]?.map; // not array-like
|
|
198
|
+
const discrete = flat || Array.isArray(data[0]);
|
|
170
199
|
|
|
171
200
|
// determine scale domain
|
|
172
201
|
const domainAttr = plot.getAttribute('colorDomain');
|
|
173
202
|
const domainFixed = domainAttr === Fixed;
|
|
174
203
|
const domainTransient = domainAttr?.[Transient];
|
|
175
204
|
const domain = (!domainFixed && !domainTransient && domainAttr) || (
|
|
176
|
-
flat ?
|
|
177
|
-
: discrete ? gridDomainDiscrete(
|
|
178
|
-
: gridDomainContinuous(
|
|
205
|
+
flat ? data.slice().sort(ascending)
|
|
206
|
+
: discrete ? gridDomainDiscrete(data)
|
|
207
|
+
: gridDomainContinuous(data)
|
|
179
208
|
);
|
|
180
|
-
if (domainFixed || domainTransient) {
|
|
181
|
-
if (
|
|
209
|
+
if (domainFixed || domainTransient || !domainAttr) {
|
|
210
|
+
if (!domainFixed) domain[Transient] = true;
|
|
182
211
|
plot.setAttribute('colorDomain', domain);
|
|
183
212
|
}
|
|
184
213
|
|
|
@@ -234,6 +263,15 @@ function inferScaleType(type) {
|
|
|
234
263
|
return type;
|
|
235
264
|
}
|
|
236
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Retrieve canvas image data for a 2D raster bitmap.
|
|
268
|
+
* The resulting data is cached in the mark.image property.
|
|
269
|
+
* If the canvas dimensions change, a new canvas is created.
|
|
270
|
+
* @param {RasterMark} mark The mark instance
|
|
271
|
+
* @param {number} w The canvas width.
|
|
272
|
+
* @param {number} h The canvas height.
|
|
273
|
+
* @returns An object with a canvas, context, image data, and dimensions.
|
|
274
|
+
*/
|
|
237
275
|
export function imageData(mark, w, h) {
|
|
238
276
|
if (!mark.image || mark.image.w !== w || mark.image.h !== h) {
|
|
239
277
|
const canvas = createCanvas(w, h);
|
|
@@ -2,6 +2,7 @@ import { coordinator } from '@uwdata/mosaic-core';
|
|
|
2
2
|
import { Query, count, isBetween, lt, lte, neq, sql, sum } from '@uwdata/mosaic-sql';
|
|
3
3
|
import { binExpr } from './util/bin-expr.js';
|
|
4
4
|
import { extentX, extentY } from './util/extent.js';
|
|
5
|
+
import { indices, permute } from './util/permute.js';
|
|
5
6
|
import { createCanvas } from './util/raster.js';
|
|
6
7
|
import { Grid2DMark } from './Grid2DMark.js';
|
|
7
8
|
import { rasterEncoding } from './RasterMark.js';
|
|
@@ -10,6 +11,7 @@ export class RasterTileMark extends Grid2DMark {
|
|
|
10
11
|
constructor(source, options) {
|
|
11
12
|
const { origin = [0, 0], dim = 'xy', ...markOptions } = options;
|
|
12
13
|
super('image', source, markOptions);
|
|
14
|
+
this.image = null;
|
|
13
15
|
|
|
14
16
|
// TODO: make part of data source instead of options?
|
|
15
17
|
this.origin = origin;
|
|
@@ -34,15 +36,15 @@ export class RasterTileMark extends Grid2DMark {
|
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
tileQuery(extent) {
|
|
37
|
-
const {
|
|
39
|
+
const { interpolate, pad, channels, densityMap, source } = this;
|
|
38
40
|
const [[x0, x1], [y0, y1]] = extent;
|
|
39
41
|
const [nx, ny] = this.bins;
|
|
40
|
-
const [x, bx] = binExpr(this, 'x', nx, [x0, x1],
|
|
41
|
-
const [y, by] = binExpr(this, 'y', ny, [y0, y1],
|
|
42
|
+
const [x, bx] = binExpr(this, 'x', nx, [x0, x1], pad);
|
|
43
|
+
const [y, by] = binExpr(this, 'y', ny, [y0, y1], pad);
|
|
42
44
|
|
|
43
45
|
// with padded bins, include the entire domain extent
|
|
44
46
|
// if the bins are flush, exclude the extent max
|
|
45
|
-
const bounds =
|
|
47
|
+
const bounds = pad
|
|
46
48
|
? [isBetween(bx, [+x0, +x1]), isBetween(by, [+y0, +y1])]
|
|
47
49
|
: [lte(+x0, bx), lt(bx, +x1), lte(+y0, by), lt(by, +y1)];
|
|
48
50
|
|
|
@@ -83,7 +85,7 @@ export class RasterTileMark extends Grid2DMark {
|
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
// generate grid binning query
|
|
86
|
-
if (
|
|
88
|
+
if (interpolate === 'linear') {
|
|
87
89
|
if (aggr.length > 1) {
|
|
88
90
|
throw new Error('Linear binning not applicable to multiple aggregates.');
|
|
89
91
|
}
|
|
@@ -102,14 +104,14 @@ export class RasterTileMark extends Grid2DMark {
|
|
|
102
104
|
if (this.prefetch) mc.cancel(this.prefetch);
|
|
103
105
|
|
|
104
106
|
// get view extent info
|
|
105
|
-
const {
|
|
106
|
-
const [m, n] = this.bins = this.binDimensions(
|
|
107
|
+
const { pad, tileX, tileY, origin: [tx, ty] } = this;
|
|
108
|
+
const [m, n] = this.bins = this.binDimensions();
|
|
107
109
|
const [x0, x1] = extentX(this, this._filter);
|
|
108
110
|
const [y0, y1] = extentY(this, this._filter);
|
|
109
111
|
const xspan = x1 - x0;
|
|
110
112
|
const yspan = y1 - y0;
|
|
111
|
-
const xx = Math.floor((x0 - tx) * (m -
|
|
112
|
-
const yy = Math.floor((y0 - ty) * (n -
|
|
113
|
+
const xx = Math.floor((x0 - tx) * (m - pad) / xspan);
|
|
114
|
+
const yy = Math.floor((y0 - ty) * (n - pad) / yspan);
|
|
113
115
|
|
|
114
116
|
const tileExtent = (i, j) => [
|
|
115
117
|
[tx + i * xspan, tx + (i + 1) * xspan],
|
|
@@ -155,7 +157,11 @@ export class RasterTileMark extends Grid2DMark {
|
|
|
155
157
|
|
|
156
158
|
// wait for tile queries to complete, then update
|
|
157
159
|
const tiles = await Promise.all(queries);
|
|
158
|
-
|
|
160
|
+
const density = processTiles(m, n, xx, yy, coords, tiles);
|
|
161
|
+
this.grids0 = {
|
|
162
|
+
numRows: density.length,
|
|
163
|
+
columns: { density: [density] }
|
|
164
|
+
};
|
|
159
165
|
this.convolve().update();
|
|
160
166
|
}
|
|
161
167
|
|
|
@@ -164,37 +170,50 @@ export class RasterTileMark extends Grid2DMark {
|
|
|
164
170
|
}
|
|
165
171
|
|
|
166
172
|
rasterize() {
|
|
167
|
-
const { bins,
|
|
173
|
+
const { bins, grids } = this;
|
|
168
174
|
const [ w, h ] = bins;
|
|
175
|
+
const { numRows, columns } = grids;
|
|
169
176
|
|
|
170
177
|
// raster data
|
|
171
178
|
const { canvas, ctx, img } = imageData(this, w, h);
|
|
172
179
|
|
|
173
180
|
// color + opacity encodings
|
|
174
181
|
const { alpha, alphaProp, color, colorProp } = rasterEncoding(this);
|
|
182
|
+
const alphaData = columns[alphaProp] ?? [];
|
|
183
|
+
const colorData = columns[colorProp] ?? [];
|
|
184
|
+
|
|
185
|
+
// determine raster order
|
|
186
|
+
const idx = numRows > 1 && colorProp && this.groupby?.includes(colorProp)
|
|
187
|
+
? permute(colorData, this.plot.getAttribute('colorDomain'))
|
|
188
|
+
: indices(numRows);
|
|
175
189
|
|
|
176
190
|
// generate rasters
|
|
177
|
-
this.data =
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
191
|
+
this.data = {
|
|
192
|
+
numRows,
|
|
193
|
+
columns: {
|
|
194
|
+
src: Array.from({ length: numRows }, (_, i) => {
|
|
195
|
+
color?.(img.data, w, h, colorData[idx[i]]);
|
|
196
|
+
alpha?.(img.data, w, h, alphaData[idx[i]]);
|
|
197
|
+
ctx.putImageData(img, 0, 0);
|
|
198
|
+
return canvas.toDataURL();
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
};
|
|
183
202
|
|
|
184
203
|
return this;
|
|
185
204
|
}
|
|
186
205
|
|
|
187
206
|
plotSpecs() {
|
|
188
|
-
const { type, data,
|
|
207
|
+
const { type, plot, data: { numRows: length, columns } } = this;
|
|
189
208
|
const options = {
|
|
190
|
-
src:
|
|
209
|
+
src: columns.src,
|
|
191
210
|
width: plot.innerWidth(),
|
|
192
211
|
height: plot.innerHeight(),
|
|
193
212
|
preserveAspectRatio: 'none',
|
|
194
213
|
imageRendering: this.channel('imageRendering')?.value,
|
|
195
214
|
frameAnchor: 'middle'
|
|
196
215
|
};
|
|
197
|
-
return [{ type, data, options }];
|
|
216
|
+
return [{ type, data: { length }, options }];
|
|
198
217
|
}
|
|
199
218
|
}
|
|
200
219
|
|