@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.
Files changed (37) hide show
  1. package/dist/mosaic-plot.js +4702 -5648
  2. package/dist/mosaic-plot.min.js +14 -14
  3. package/package.json +5 -5
  4. package/src/index.js +1 -0
  5. package/src/interactors/Highlight.js +6 -3
  6. package/src/interactors/Interval1D.js +14 -12
  7. package/src/interactors/Interval2D.js +13 -16
  8. package/src/interactors/Nearest.js +80 -36
  9. package/src/interactors/PanZoom.js +7 -9
  10. package/src/interactors/Toggle.js +29 -37
  11. package/src/interactors/util/patchScreenCTM.js +2 -0
  12. package/src/legend.js +150 -29
  13. package/src/marks/ConnectedMark.js +6 -0
  14. package/src/marks/ContourMark.js +36 -16
  15. package/src/marks/DenseLineMark.js +9 -5
  16. package/src/marks/Density1DMark.js +22 -13
  17. package/src/marks/Density2DMark.js +33 -18
  18. package/src/marks/ErrorBarMark.js +50 -0
  19. package/src/marks/GeoMark.js +7 -8
  20. package/src/marks/Grid2DMark.js +58 -28
  21. package/src/marks/HexbinMark.js +10 -2
  22. package/src/marks/Mark.js +56 -16
  23. package/src/marks/RasterMark.js +61 -23
  24. package/src/marks/RasterTileMark.js +39 -20
  25. package/src/marks/RegressionMark.js +69 -34
  26. package/src/marks/util/grid.js +94 -86
  27. package/src/marks/util/handle-param.js +10 -11
  28. package/src/marks/util/is-constant-option.js +2 -1
  29. package/src/marks/util/permute.js +10 -0
  30. package/src/marks/util/stats.js +121 -1
  31. package/src/marks/util/to-data-columns.js +71 -0
  32. package/src/plot-attributes.js +11 -3
  33. package/src/plot-renderer.js +28 -9
  34. package/src/plot.js +20 -0
  35. package/src/transforms/bin.js +3 -1
  36. package/src/marks/util/interpolate.js +0 -205
  37. package/src/marks/util/to-data-array.js +0 -50
@@ -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
- handleParam(this, 'bandwidth', bandwidth, () => {
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
- handleParam(this, 'pixelSize', pixelSize);
35
- handleParam(this, 'interpolate', interpolate);
36
- handleParam(this, 'pad', pad);
37
- handleParam(this, 'width', width);
38
- handleParam(this, 'height', height);
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('domainX', update);
44
- plot.addAttributeListener('domainY', update);
45
- return super.setPlot(plot, index);
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(this);
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
- this.grids = grid2d(w, h, data, this.aggr, this.groupby, interp);
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, grids, plot } = this;
163
+ const { aggr, bandwidth, bins, grids0, plot } = this;
135
164
 
136
165
  // no smoothing as default fallback
137
- this.kde = this.grids;
166
+ this.grids = grids0;
138
167
 
139
168
  if (bandwidth > 0) {
140
169
  // determine which grid to smooth
141
- const gridProp = aggr.length === 1 ? aggr[0]
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 (!gridProp) {
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 = grids.some(cell => cell[gridProp].some(v => v < 0));
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.kde = this.grids.map(grid => {
159
- const density = dericheConv2d(configX, configY, grid[gridProp], bins);
160
- return { ...grid, [gridProp]: density };
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 (`${interpolate}`.toLowerCase()) {
218
+ switch (interpolate.toLowerCase()) {
189
219
  case 'none':
190
220
  case 'linear':
191
221
  return undefined; // no special interpolation need
@@ -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
- this.binWidth = binWidth;
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
- q.orderby(c.value); // TODO revisit once groupby is added
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 { toDataArray } from './util/to-data-array.js';
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 = toDataArray(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
- const options = {};
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: c.as, scale: 'color' }
188
- : c.as;
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
+ }
@@ -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, kde } = this;
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 = kde.map(cell => {
47
- color?.(img.data, w, h, cell[colorProp]);
48
- alpha?.(img.data, w, h, cell[alphaProp]);
49
- ctx.putImageData(img, 0, 0);
50
- return { src: canvas.toDataURL() };
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: '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, kde: grids } = mark;
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, prop);
144
- if (domainFixed || domainTransient) {
145
- if (domainTransient) domain[Transient] = true;
146
- plot.setAttribute('colorDomain', domain);
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, kde: grids } = mark;
168
- const flat = !grids[0][prop]?.map; // not array-like
169
- const discrete = flat || Array.isArray(grids[0][prop]);
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 ? grids.map(cell => cell[prop]).sort(ascending)
177
- : discrete ? gridDomainDiscrete(grids, prop)
178
- : gridDomainContinuous(grids, prop)
205
+ flat ? data.slice().sort(ascending)
206
+ : discrete ? gridDomainDiscrete(data)
207
+ : gridDomainContinuous(data)
179
208
  );
180
- if (domainFixed || domainTransient) {
181
- if (domainTransient) domain[Transient] = true;
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 { binType, binPad, channels, densityMap, source } = this;
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], binPad);
41
- const [y, by] = binExpr(this, 'y', ny, [y0, y1], binPad);
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 = binPad
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 (binType === 'linear') {
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 { binPad, tileX, tileY, origin: [tx, ty] } = this;
106
- const [m, n] = this.bins = this.binDimensions(this);
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 - binPad) / xspan);
112
- const yy = Math.floor((y0 - ty) * (n - binPad) / yspan);
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
- this.grids = [{ density: processTiles(m, n, xx, yy, coords, tiles) }];
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, kde } = this;
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 = kde.map(cell => {
178
- color?.(img.data, w, h, cell[colorProp]);
179
- alpha?.(img.data, w, h, cell[alphaProp]);
180
- ctx.putImageData(img, 0, 0);
181
- return { src: canvas.toDataURL() };
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, plot } = this;
207
+ const { type, plot, data: { numRows: length, columns } } = this;
189
208
  const options = {
190
- src: '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