@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.
- package/LICENSE +28 -0
- package/README.md +6 -0
- package/dist/mosaic-plot.js +42313 -0
- package/dist/mosaic-plot.min.js +69 -0
- package/package.json +38 -0
- package/src/index.js +30 -0
- package/src/interactors/Highlight.js +101 -0
- package/src/interactors/Interval1D.js +90 -0
- package/src/interactors/Interval2D.js +102 -0
- package/src/interactors/Nearest.js +66 -0
- package/src/interactors/PanZoom.js +121 -0
- package/src/interactors/Toggle.js +111 -0
- package/src/interactors/util/brush.js +45 -0
- package/src/interactors/util/close-to.js +9 -0
- package/src/interactors/util/get-field.js +4 -0
- package/src/interactors/util/invert.js +3 -0
- package/src/interactors/util/patchScreenCTM.js +13 -0
- package/src/interactors/util/sanitize-styles.js +9 -0
- package/src/interactors/util/to-kebab-case.js +9 -0
- package/src/legend.js +64 -0
- package/src/marks/ConnectedMark.js +66 -0
- package/src/marks/ContourMark.js +89 -0
- package/src/marks/DenseLineMark.js +146 -0
- package/src/marks/Density1DMark.js +104 -0
- package/src/marks/Density2DMark.js +69 -0
- package/src/marks/GeoMark.js +35 -0
- package/src/marks/Grid2DMark.js +191 -0
- package/src/marks/HexbinMark.js +88 -0
- package/src/marks/Mark.js +207 -0
- package/src/marks/RasterMark.js +121 -0
- package/src/marks/RasterTileMark.js +331 -0
- package/src/marks/RegressionMark.js +117 -0
- package/src/marks/util/bin-field.js +17 -0
- package/src/marks/util/density.js +226 -0
- package/src/marks/util/extent.js +56 -0
- package/src/marks/util/grid.js +57 -0
- package/src/marks/util/handle-param.js +14 -0
- package/src/marks/util/is-arrow-table.js +3 -0
- package/src/marks/util/is-color.js +18 -0
- package/src/marks/util/is-constant-option.js +41 -0
- package/src/marks/util/is-symbol.js +20 -0
- package/src/marks/util/raster.js +44 -0
- package/src/marks/util/stats.js +133 -0
- package/src/marks/util/to-data-array.js +70 -0
- package/src/plot-attributes.js +212 -0
- package/src/plot-renderer.js +161 -0
- package/src/plot.js +136 -0
- package/src/symbols.js +3 -0
- package/src/transforms/bin.js +81 -0
- 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
|
+
}
|