@uwdata/mosaic-plot 0.5.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 (50) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +6 -0
  3. package/dist/mosaic-plot.js +42313 -0
  4. package/dist/mosaic-plot.min.js +69 -0
  5. package/package.json +38 -0
  6. package/src/index.js +30 -0
  7. package/src/interactors/Highlight.js +101 -0
  8. package/src/interactors/Interval1D.js +90 -0
  9. package/src/interactors/Interval2D.js +102 -0
  10. package/src/interactors/Nearest.js +66 -0
  11. package/src/interactors/PanZoom.js +121 -0
  12. package/src/interactors/Toggle.js +111 -0
  13. package/src/interactors/util/brush.js +45 -0
  14. package/src/interactors/util/close-to.js +9 -0
  15. package/src/interactors/util/get-field.js +4 -0
  16. package/src/interactors/util/invert.js +3 -0
  17. package/src/interactors/util/patchScreenCTM.js +13 -0
  18. package/src/interactors/util/sanitize-styles.js +9 -0
  19. package/src/interactors/util/to-kebab-case.js +9 -0
  20. package/src/legend.js +64 -0
  21. package/src/marks/ConnectedMark.js +66 -0
  22. package/src/marks/ContourMark.js +89 -0
  23. package/src/marks/DenseLineMark.js +146 -0
  24. package/src/marks/Density1DMark.js +104 -0
  25. package/src/marks/Density2DMark.js +69 -0
  26. package/src/marks/GeoMark.js +35 -0
  27. package/src/marks/Grid2DMark.js +191 -0
  28. package/src/marks/HexbinMark.js +88 -0
  29. package/src/marks/Mark.js +207 -0
  30. package/src/marks/RasterMark.js +121 -0
  31. package/src/marks/RasterTileMark.js +331 -0
  32. package/src/marks/RegressionMark.js +117 -0
  33. package/src/marks/util/bin-field.js +17 -0
  34. package/src/marks/util/density.js +226 -0
  35. package/src/marks/util/extent.js +56 -0
  36. package/src/marks/util/grid.js +57 -0
  37. package/src/marks/util/handle-param.js +14 -0
  38. package/src/marks/util/is-arrow-table.js +3 -0
  39. package/src/marks/util/is-color.js +18 -0
  40. package/src/marks/util/is-constant-option.js +41 -0
  41. package/src/marks/util/is-symbol.js +20 -0
  42. package/src/marks/util/raster.js +44 -0
  43. package/src/marks/util/stats.js +133 -0
  44. package/src/marks/util/to-data-array.js +70 -0
  45. package/src/plot-attributes.js +212 -0
  46. package/src/plot-renderer.js +161 -0
  47. package/src/plot.js +136 -0
  48. package/src/symbols.js +3 -0
  49. package/src/transforms/bin.js +81 -0
  50. package/src/transforms/index.js +3 -0
@@ -0,0 +1,191 @@
1
+ import { Query, count, gt, isBetween, lt, lte, sql, sum } from '@uwdata/mosaic-sql';
2
+ import { Transient } from '../symbols.js';
3
+ import { binField } from './util/bin-field.js';
4
+ import { dericheConfig, dericheConv2d } from './util/density.js';
5
+ import { extentX, extentY, xyext } from './util/extent.js';
6
+ import { grid2d } from './util/grid.js';
7
+ import { handleParam } from './util/handle-param.js';
8
+ import { Mark } from './Mark.js';
9
+
10
+ export class Grid2DMark extends Mark {
11
+ constructor(type, source, options) {
12
+ const {
13
+ bandwidth = 20,
14
+ binType = 'linear',
15
+ binWidth = 2,
16
+ binPad = 1,
17
+ ...channels
18
+ } = options;
19
+
20
+ const densityMap = createDensityMap(channels);
21
+ super(type, source, channels, xyext);
22
+ this.densityMap = densityMap;
23
+
24
+ handleParam(this, 'bandwidth', bandwidth, () => {
25
+ return this.grids ? this.convolve().update() : null;
26
+ });
27
+ handleParam(this, 'binWidth', binWidth);
28
+ handleParam(this, 'binType', binType);
29
+ handleParam(this, 'binPad', binPad);
30
+ }
31
+
32
+ setPlot(plot, index) {
33
+ const update = () => { if (this.stats) this.requestUpdate(); };
34
+ plot.addAttributeListener('domainX', update);
35
+ plot.addAttributeListener('domainY', update);
36
+ return super.setPlot(plot, index);
37
+ }
38
+
39
+ get filterIndexable() {
40
+ const xdom = this.plot.getAttribute('xDomain');
41
+ const ydom = this.plot.getAttribute('yDomain');
42
+ return xdom && ydom && !xdom[Transient] && !ydom[Transient];
43
+ }
44
+
45
+ query(filter = []) {
46
+ const { plot, binType, binPad, channels, densityMap, source } = this;
47
+ const [x0, x1] = this.extentX = extentX(this, filter);
48
+ const [y0, y1] = this.extentY = extentY(this, filter);
49
+ const [nx, ny] = this.bins = this.binDimensions(this);
50
+ const bx = binField(this, 'x');
51
+ const by = binField(this, 'y');
52
+ const rx = !!plot.getAttribute('xReverse');
53
+ const ry = !!plot.getAttribute('yReverse');
54
+ const x = bin1d(bx, x0, x1, nx, rx, this.binPad);
55
+ const y = bin1d(by, y0, y1, ny, ry, this.binPad);
56
+
57
+ // with padded bins, include the entire domain extent
58
+ // if the bins are flush, exclude the extent max
59
+ const bounds = binPad
60
+ ? [isBetween(bx, [x0, x1]), isBetween(by, [y0, y1])]
61
+ : [lte(x0, bx), lt(bx, x1), lte(y0, by), lt(by, y1)];
62
+
63
+ const q = Query
64
+ .from(source.table)
65
+ .where(filter.concat(bounds));
66
+
67
+ const groupby = this.groupby = [];
68
+ let agg = count();
69
+ for (const c of channels) {
70
+ if (Object.hasOwn(c, 'field')) {
71
+ const { as, channel, field } = c;
72
+ if (field.aggregate) {
73
+ agg = field;
74
+ densityMap[channel] = true;
75
+ } else if (channel === 'weight') {
76
+ agg = sum(field);
77
+ } else if (channel !== 'x' && channel !== 'y') {
78
+ q.select({ [as]: field });
79
+ groupby.push(as);
80
+ }
81
+ }
82
+ }
83
+
84
+ return binType === 'linear'
85
+ ? binLinear2d(q, x, y, agg, nx, groupby)
86
+ : bin2d(q, x, y, agg, nx, groupby);
87
+ }
88
+
89
+ binDimensions() {
90
+ const { plot, binWidth } = this;
91
+ return [
92
+ Math.round(plot.innerWidth() / binWidth),
93
+ Math.round(plot.innerHeight() / binWidth)
94
+ ];
95
+ }
96
+
97
+ queryResult(data) {
98
+ const [nx, ny] = this.bins;
99
+ this.grids = grid2d(nx, ny, data, this.groupby);
100
+ return this.convolve();
101
+ }
102
+
103
+ convolve() {
104
+ const { bandwidth, bins, grids, plot } = this;
105
+
106
+ if (bandwidth <= 0) {
107
+ this.kde = this.grids.map(({ key, grid }) => {
108
+ return (grid.key = key, grid);
109
+ });
110
+ } else {
111
+ const w = plot.innerWidth();
112
+ const h = plot.innerHeight();
113
+ const [nx, ny] = bins;
114
+ const neg = grids.some(({ grid }) => grid.some(v => v < 0));
115
+ const configX = dericheConfig(bandwidth * (nx - 1) / w, neg);
116
+ const configY = dericheConfig(bandwidth * (ny - 1) / h, neg);
117
+ this.kde = this.grids.map(({ key, grid }) => {
118
+ const k = dericheConv2d(configX, configY, grid, bins);
119
+ return (k.key = key, k);
120
+ });
121
+ }
122
+ return this;
123
+ }
124
+
125
+ plotSpecs() {
126
+ throw new Error('Unimplemented. Use a Grid2D mark subclass.');
127
+ }
128
+ }
129
+
130
+ function createDensityMap(channels) {
131
+ const densityMap = {};
132
+ for (const key in channels) {
133
+ if (channels[key] === 'density') {
134
+ delete channels[key];
135
+ densityMap[key] = true;
136
+ }
137
+ }
138
+ return densityMap;
139
+ }
140
+
141
+ function bin1d(x, x0, x1, n, reverse, pad) {
142
+ const d = (n - pad) / (x1 - x0);
143
+ const f = d !== 1 ? ` * ${d}::DOUBLE` : '';
144
+ return reverse
145
+ ? sql`(${x1} - ${x}::DOUBLE)${f}`
146
+ : sql`(${x}::DOUBLE - ${x0})${f}`;
147
+ }
148
+
149
+ function bin2d(q, xp, yp, value, xn, groupby) {
150
+ return q
151
+ .select({
152
+ index: sql`FLOOR(${xp})::INTEGER + FLOOR(${yp})::INTEGER * ${xn}`,
153
+ value
154
+ })
155
+ .groupby('index', groupby);
156
+ }
157
+
158
+ function binLinear2d(q, xp, yp, value, xn, groupby) {
159
+ const w = value.column ? `* ${value.column}` : '';
160
+ const subq = (i, w) => q.clone().select({ xp, yp, i, w });
161
+
162
+ // grid[xu + yu * xn] += (xv - xp) * (yv - yp) * wi;
163
+ const a = subq(
164
+ sql`FLOOR(xp)::INTEGER + FLOOR(yp)::INTEGER * ${xn}`,
165
+ sql`(FLOOR(xp)::INTEGER + 1 - xp) * (FLOOR(yp)::INTEGER + 1 - yp)${w}`
166
+ );
167
+
168
+ // grid[xu + yv * xn] += (xv - xp) * (yp - yu) * wi;
169
+ const b = subq(
170
+ sql`FLOOR(xp)::INTEGER + (FLOOR(yp)::INTEGER + 1) * ${xn}`,
171
+ sql`(FLOOR(xp)::INTEGER + 1 - xp) * (yp - FLOOR(yp)::INTEGER)${w}`
172
+ );
173
+
174
+ // grid[xv + yu * xn] += (xp - xu) * (yv - yp) * wi;
175
+ const c = subq(
176
+ sql`FLOOR(xp)::INTEGER + 1 + FLOOR(yp)::INTEGER * ${xn}`,
177
+ sql`(xp - FLOOR(xp)::INTEGER) * (FLOOR(yp)::INTEGER + 1 - yp)${w}`
178
+ );
179
+
180
+ // grid[xv + yv * xn] += (xp - xu) * (yp - yu) * wi;
181
+ const d = subq(
182
+ sql`FLOOR(xp)::INTEGER + 1 + (FLOOR(yp)::INTEGER + 1) * ${xn}`,
183
+ sql`(xp - FLOOR(xp)::INTEGER) * (yp - FLOOR(yp)::INTEGER)${w}`
184
+ );
185
+
186
+ return Query
187
+ .from(Query.unionAll(a, b, c, d))
188
+ .select({ index: 'i', value: sum('w') }, groupby)
189
+ .groupby('index', groupby)
190
+ .having(gt('value', 0));
191
+ }
@@ -0,0 +1,88 @@
1
+ import { Query, isNotNull, sql } from '@uwdata/mosaic-sql';
2
+ import { Transient } from '../symbols.js';
3
+ import { extentX, extentY, xyext } from './util/extent.js';
4
+ import { Mark } from './Mark.js';
5
+
6
+ export class HexbinMark extends Mark {
7
+ constructor(source, options) {
8
+ const { type = 'hexagon', binWidth = 20, ...channels } = options;
9
+ super(type, source, { r: binWidth / 2, clip: true, ...channels }, xyext);
10
+ this.binWidth = binWidth;
11
+ }
12
+
13
+ get filterIndexable() {
14
+ const xdom = this.plot.getAttribute('xDomain');
15
+ const ydom = this.plot.getAttribute('yDomain');
16
+ return xdom && ydom && !xdom[Transient] && !ydom[Transient];
17
+ }
18
+
19
+ query(filter = []) {
20
+ if (this.hasOwnData()) return null;
21
+ const { plot, binWidth, channels, source } = this;
22
+
23
+ // get x / y extents, may update plot domainX / domainY
24
+ const [x1, x2] = extentX(this, filter);
25
+ const [y1, y2] = extentY(this, filter);
26
+
27
+ // Adjust screen-space coordinates by top/left
28
+ // margins as this is what Observable Plot does.
29
+ // TODO use zero margins when faceted?
30
+ const ox = 0.5 - plot.getAttribute('marginLeft');
31
+ const oy = 0 - plot.getAttribute('marginTop');
32
+ const dx = `${binWidth}::DOUBLE`;
33
+ const dy = `${binWidth * (1.5 / Math.sqrt(3))}::DOUBLE`;
34
+ const xr = `${plot.innerWidth() / (x2 - x1)}::DOUBLE`;
35
+ const yr = `${plot.innerHeight() / (y2 - y1)}::DOUBLE`;
36
+
37
+ // Extract channel information, update top-level query
38
+ // and extract dependent columns for aggregates
39
+ let x, y;
40
+ const aggr = new Set;
41
+ const cols = {};
42
+ for (const c of channels) {
43
+ if (c.channel === 'orderby') {
44
+ q.orderby(c.value); // TODO revisit once groupby is added
45
+ } else if (c.channel === 'x') {
46
+ x = c;
47
+ } else if (c.channel === 'y') {
48
+ y = c;
49
+ } else if (Object.hasOwn(c, 'field')) {
50
+ cols[c.as] = c.field;
51
+ if (c.field.aggregate) {
52
+ c.field.columns.forEach(col => aggr.add(col));
53
+ }
54
+ }
55
+ }
56
+
57
+ // Top-level query; we add a hex binning subquery below
58
+ // Maps binned screen space coordinates back to data
59
+ // values to ensure we get correct data-driven scales
60
+ const q = Query.select({
61
+ [x.as]: sql`${x1}::DOUBLE + ((x + 0.5 * (y & 1)) * ${dx} + ${ox})::DOUBLE / ${xr}`,
62
+ [y.as]: sql`${y2}::DOUBLE - (y * ${dy} + ${oy})::DOUBLE / ${yr}`,
63
+ ...cols
64
+ }).groupby('x', 'y');
65
+
66
+ // Map x/y channels to screen space
67
+ const xx = `${xr} * (${x.field} - ${x1}::DOUBLE)`;
68
+ const yy = `${yr} * (${y2}::DOUBLE - ${y.field})`;
69
+
70
+ // Perform hex binning of x/y coordinates
71
+ // TODO add groupby dims
72
+ const hex = Query
73
+ .select({
74
+ py: sql`(${yy} - ${oy}) / ${dy}`,
75
+ pj: sql`ROUND(py)::INTEGER`,
76
+ px: sql`(${xx} - ${ox}) / ${dx} - 0.5 * (pj & 1)`,
77
+ pi: sql`ROUND(px)::INTEGER`,
78
+ tt: sql`ABS(py-pj) * 3 > 1 AND (px-pi)**2 + (py-pj)**2 > (px - pi - 0.5 * CASE WHEN px < pi THEN -1 ELSE 1 END)**2 + (py - pj - CASE WHEN py < pj THEN -1 ELSE 1 END)**2`,
79
+ x: sql`CASE WHEN tt THEN (pi + (CASE WHEN px < pi THEN -0.5 ELSE 0.5 END) + (CASE WHEN pj & 1 <> 0 THEN 0.5 ELSE -0.5 END))::INTEGER ELSE pi END`,
80
+ y: sql`CASE WHEN tt THEN (pj + CASE WHEN py < pj THEN -1 ELSE 1 END)::INTEGER ELSE pj END`
81
+ })
82
+ .select(Array.from(aggr))
83
+ .from(source.table)
84
+ .where(isNotNull(x.field), isNotNull(y.field), filter)
85
+
86
+ return q.from(hex);
87
+ }
88
+ }
@@ -0,0 +1,207 @@
1
+ import { MosaicClient } from '@uwdata/mosaic-core';
2
+ import { Query, Ref, column, isParamLike } from '@uwdata/mosaic-sql';
3
+ import { isColor } from './util/is-color.js';
4
+ import { isConstantOption } from './util/is-constant-option.js';
5
+ import { isSymbol } from './util/is-symbol.js';
6
+ import { toDataArray } from './util/to-data-array.js';
7
+ import { Transform } from '../symbols.js';
8
+
9
+ const isColorChannel = channel => channel === 'stroke' || channel === 'fill';
10
+ const isSymbolChannel = channel => channel === 'symbol';
11
+ const isFieldObject = (channel, field) => {
12
+ return channel !== 'sort' && channel !== 'tip'
13
+ && field != null && !Array.isArray(field);
14
+ };
15
+ const fieldEntry = (channel, field) => ({
16
+ channel,
17
+ field,
18
+ as: field instanceof Ref ? field.column : channel
19
+ });
20
+ const valueEntry = (channel, value) => ({ channel, value });
21
+
22
+ export const isDataArray = source => Array.isArray(source);
23
+
24
+ export class Mark extends MosaicClient {
25
+ constructor(type, source, encodings, reqs = {}) {
26
+ super(source?.options?.filterBy);
27
+ this.type = type;
28
+ this.reqs = reqs;
29
+
30
+ this.source = source;
31
+ if (isDataArray(this.source)) {
32
+ this.data = this.source;
33
+ }
34
+
35
+ const channels = this.channels = [];
36
+ const detail = this.detail = new Set;
37
+ const params = this.params = new Set;
38
+
39
+ const process = (channel, entry) => {
40
+ const type = typeof entry;
41
+ if (channel === 'channels') {
42
+ for (const name in entry) {
43
+ detail.add(name);
44
+ process(name, entry[name]);
45
+ }
46
+ } else if (type === 'function' && entry[Transform]) {
47
+ const enc = entry(this, channel);
48
+ for (const key in enc) {
49
+ process(key, enc[key]);
50
+ }
51
+ } else if (type === 'string') {
52
+ if (
53
+ isConstantOption(channel) ||
54
+ isColorChannel(channel) && isColor(entry) ||
55
+ isSymbolChannel(channel) && isSymbol(entry)
56
+ ) {
57
+ // interpret constants and color/symbol names as values, not fields
58
+ channels.push(valueEntry(channel, entry));
59
+ } else {
60
+ channels.push(fieldEntry(channel, column(entry)));
61
+ }
62
+ } else if (isParamLike(entry)) {
63
+ if (Array.isArray(entry.columns)) {
64
+ channels.push(fieldEntry(channel, entry));
65
+ params.add(entry);
66
+ } else {
67
+ const c = valueEntry(channel, entry.value);
68
+ channels.push(c);
69
+ entry.addEventListener('value', value => {
70
+ c.value = value;
71
+ return this.update();
72
+ });
73
+ }
74
+ } else if (type === 'object' && isFieldObject(channel, entry)) {
75
+ channels.push(fieldEntry(channel, entry));
76
+ } else if (entry !== undefined) {
77
+ channels.push(valueEntry(channel, entry));
78
+ }
79
+ };
80
+
81
+ for (const channel in encodings) {
82
+ process(channel, encodings[channel]);
83
+ }
84
+ }
85
+
86
+ setPlot(plot, index) {
87
+ this.plot = plot;
88
+ this.index = index;
89
+ plot.addParams(this, this.params);
90
+ if (this.source?.table) this.queryPending();
91
+ }
92
+
93
+ hasOwnData() {
94
+ return this.source == null || isDataArray(this.source);
95
+ }
96
+
97
+ channel(channel) {
98
+ return this.channels.find(c => c.channel === channel);
99
+ }
100
+
101
+ channelField(...channels) {
102
+ const list = channels.flat();
103
+ for (const channel of list) {
104
+ const c = this.channel(channel);
105
+ if (c?.field) return c;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ fields() {
111
+ if (this.hasOwnData()) return null;
112
+ const { source: { table }, channels, reqs } = this;
113
+
114
+ const fields = new Map;
115
+ for (const { channel, field } of channels) {
116
+ const column = field?.column;
117
+ if (!column) {
118
+ continue; // no column to lookup
119
+ } else if (field.stats?.length || reqs[channel]) {
120
+ if (!fields.has(column)) fields.set(column, new Set);
121
+ const entry = fields.get(column);
122
+ reqs[channel]?.forEach(s => entry.add(s));
123
+ field.stats?.forEach(s => entry.add(s));
124
+ }
125
+ }
126
+ return Array.from(fields, ([column, stats]) => {
127
+ return { table, column, stats: Array.from(stats) };
128
+ });
129
+ }
130
+
131
+ fieldInfo(info) {
132
+ this.stats = info.reduce(
133
+ (o, d) => (o[d.column] = d, o),
134
+ Object.create(null)
135
+ );
136
+ return this;
137
+ }
138
+
139
+ query(filter = []) {
140
+ if (this.hasOwnData()) return null;
141
+ const { channels, source: { table } } = this;
142
+ return markQuery(channels, table).where(filter);
143
+ }
144
+
145
+ queryPending() {
146
+ this.plot.pending(this);
147
+ return this;
148
+ }
149
+
150
+ queryResult(data) {
151
+ this.data = toDataArray(data);
152
+ return this;
153
+ }
154
+
155
+ update() {
156
+ return this.plot.update(this);
157
+ }
158
+
159
+ plotSpecs() {
160
+ const { type, data, detail, channels } = this;
161
+ const options = {};
162
+ const side = {};
163
+ for (const c of channels) {
164
+ const obj = detail.has(c.channel) ? side : options;
165
+ obj[c.channel] = channelOption(c)
166
+ }
167
+ if (detail.size) options.channels = side;
168
+ return [{ type, data, options }];
169
+ }
170
+ }
171
+
172
+ export function channelOption(c) {
173
+ // use a scale override for color channels to sidestep
174
+ // https://github.com/observablehq/plot/issues/1593
175
+ return Object.hasOwn(c, 'value') ? c.value
176
+ : isColorChannel(c.channel) ? { value: c.as, scale: 'color' }
177
+ : c.as;
178
+ }
179
+
180
+ export function markQuery(channels, table, skip = []) {
181
+ const q = Query.from({ source: table });
182
+ const dims = new Set;
183
+ let aggr = false;
184
+
185
+ for (const c of channels) {
186
+ const { channel, field, as } = c;
187
+ if (skip.includes(channel)) continue;
188
+
189
+ if (channel === 'orderby') {
190
+ q.orderby(c.value);
191
+ } else if (field) {
192
+ if (field.aggregate) {
193
+ aggr = true;
194
+ } else {
195
+ if (dims.has(as)) continue;
196
+ dims.add(as);
197
+ }
198
+ q.select({ [as]: field });
199
+ }
200
+ }
201
+
202
+ if (aggr) {
203
+ q.groupby(Array.from(dims));
204
+ }
205
+
206
+ return q;
207
+ }
@@ -0,0 +1,121 @@
1
+ import { scale } from '@observablehq/plot';
2
+ import { isColor } from './util/is-color.js';
3
+ import { createCanvas, raster, opacityMap, palette } from './util/raster.js';
4
+ import { Grid2DMark } from './Grid2DMark.js';
5
+
6
+ export class RasterMark extends Grid2DMark {
7
+ constructor(source, options) {
8
+ super('image', source, options);
9
+ }
10
+
11
+ setPlot(plot, index) {
12
+ const update = () => { if (this.stats) this.rasterize(); };
13
+ plot.addAttributeListener('schemeColor', update);
14
+ super.setPlot(plot, index);
15
+ }
16
+
17
+ convolve() {
18
+ return super.convolve().rasterize();
19
+ }
20
+
21
+ rasterize() {
22
+ const { bins, kde, groupby } = this;
23
+ const [ w, h ] = bins;
24
+
25
+ // raster data
26
+ const { canvas, ctx, img } = imageData(this, w, h);
27
+
28
+ // scale function to map densities to [0, 1]
29
+ const s = imageScale(this);
30
+
31
+ // gather color domain as needed
32
+ const idx = groupby.indexOf(this.channelField('fill')?.as);
33
+ const domain = idx < 0 ? [] : kde.map(({ key }) => key[idx]);
34
+
35
+ // generate raster images
36
+ this.data = kde.map(grid => {
37
+ const palette = imagePalette(this, domain, grid.key?.[idx]);
38
+ raster(grid, img.data, w, h, s, palette);
39
+ ctx.putImageData(img, 0, 0);
40
+ return { src: canvas.toDataURL() };
41
+ });
42
+
43
+ return this;
44
+ }
45
+
46
+ plotSpecs() {
47
+ const { type, plot, data } = this;
48
+ const options = {
49
+ src: 'src',
50
+ width: plot.innerWidth(),
51
+ height: plot.innerHeight(),
52
+ preserveAspectRatio: 'none',
53
+ imageRendering: this.channel('imageRendering')?.value,
54
+ frameAnchor: 'middle'
55
+ };
56
+ return [{ type, data, options }];
57
+ }
58
+ }
59
+
60
+ function imageData(mark, w, h) {
61
+ if (!mark.image || mark.image.w !== w || mark.image.h !== h) {
62
+ const canvas = createCanvas(w, h);
63
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
64
+ const img = ctx.getImageData(0, 0, w, h);
65
+ mark.image = { canvas, ctx, img, w, h };
66
+ }
67
+ return mark.image;
68
+ }
69
+
70
+ function imageScale(mark) {
71
+ const { densityMap, kde, plot } = mark;
72
+ let domain = densityMap.fill && plot.getAttribute('colorDomain');
73
+
74
+ // compute kde grid extents if no explicit domain
75
+ if (!domain) {
76
+ let lo = 0, hi = 0;
77
+ kde.forEach(grid => {
78
+ for (const v of grid) {
79
+ if (v < lo) lo = v;
80
+ if (v > hi) hi = v;
81
+ }
82
+ });
83
+ domain = (lo === 0 && hi === 0) ? [0, 1] : [lo, hi];
84
+ }
85
+
86
+ const type = plot.getAttribute('colorScale');
87
+ return scale({ x: { type, domain, range: [0, 1] } }).apply;
88
+ }
89
+
90
+ function imagePalette(mark, domain, value, steps = 1024) {
91
+ const { densityMap, plot } = mark;
92
+ const scheme = plot.getAttribute('colorScheme');
93
+
94
+ // initialize color to constant fill, if specified
95
+ const fill = mark.channel('fill');
96
+ let color = isColor(fill?.value) ? fill.value : undefined;
97
+
98
+ if (densityMap.fill || (scheme && !color)) {
99
+ if (scheme) {
100
+ try {
101
+ return palette(
102
+ steps,
103
+ scale({color: { scheme, domain: [0, 1] }}).interpolate
104
+ );
105
+ } catch (err) {
106
+ console.warn(err);
107
+ }
108
+ }
109
+ } else if (domain.length) {
110
+ // fill is based on data values
111
+ const range = plot.getAttribute('colorRange');
112
+ const spec = {
113
+ domain,
114
+ range,
115
+ scheme: scheme || (range ? undefined : 'tableau10')
116
+ };
117
+ color = scale({ color: spec }).apply(value);
118
+ }
119
+
120
+ return palette(steps, opacityMap(color));
121
+ }