@uwdata/mosaic-plot
Version:
A Mosaic-powered plotting framework based on Observable Plot.
305 lines (267 loc) • 9.42 kB
JavaScript
import { coordinator } from '@uwdata/mosaic-core';
import { Query, count, isBetween, lt, lte, neq, sql, sum } from '@uwdata/mosaic-sql';
import { binExpr } from './util/bin-expr.js';
import { extentX, extentY } from './util/extent.js';
import { indices, permute } from './util/permute.js';
import { createCanvas } from './util/raster.js';
import { Grid2DMark } from './Grid2DMark.js';
import { rasterEncoding } from './RasterMark.js';
export class RasterTileMark extends Grid2DMark {
constructor(source, options) {
const { origin = [0, 0], dim = 'xy', ...markOptions } = options;
super('image', source, markOptions);
this.image = null;
// TODO: make part of data source instead of options?
this.origin = origin;
this.tileX = dim.toLowerCase().includes('x');
this.tileY = dim.toLowerCase().includes('y');
}
setPlot(plot, index) {
const update = () => { if (this.hasFieldInfo()) this.rasterize(); };
plot.addAttributeListener('schemeColor', update);
super.setPlot(plot, index);
}
requestQuery() {
return this.requestTiles();
}
query(filter = []) {
this._filter = filter;
// we will submit our own queries
return null;
}
tileQuery(extent) {
const { interpolate, pad, channels, densityMap } = this;
const [[x0, x1], [y0, y1]] = extent;
const [nx, ny] = this.bins;
const [x, bx] = binExpr(this, 'x', nx, [x0, x1], pad);
const [y, by] = binExpr(this, 'y', ny, [y0, y1], pad);
// with padded bins, include the entire domain extent
// if the bins are flush, exclude the extent max
const bounds = pad
? [isBetween(bx, [+x0, +x1]), isBetween(by, [+y0, +y1])]
: [lte(+x0, bx), lt(bx, +x1), lte(+y0, by), lt(by, +y1)];
const q = Query
.from(this.sourceTable())
.where(bounds);
const groupby = this.groupby = [];
const aggrMap = {};
for (const c of channels) {
if (Object.hasOwn(c, 'field')) {
const { as, channel, field } = c;
if (field.aggregate) {
// include custom aggregate
aggrMap[channel] = field;
densityMap[channel] = true;
} else if (channel === 'weight') {
// compute weighted density
aggrMap.density = sum(field);
} else if (channel !== 'x' && channel !== 'y') {
// add groupby field
q.select({ [as]: field });
groupby.push(as);
}
}
}
const aggr = this.aggr = Object.keys(aggrMap);
// check for incompatible encodings
if (aggrMap.density && aggr.length > 1) {
throw new Error('Weight option can not be used with custom aggregates.');
}
// if no aggregates, default to count density
if (!aggr.length) {
aggr.push('density');
aggrMap.density = count();
}
// generate grid binning query
if (interpolate === 'linear') {
if (aggr.length > 1) {
throw new Error('Linear binning not applicable to multiple aggregates.');
}
if (!aggrMap.density) {
throw new Error('Linear binning not applicable to custom aggregates.');
}
return binLinear2d(q, x, y, aggrMap.density, nx, groupby);
} else {
return bin2d(q, x, y, aggrMap, nx, groupby);
}
}
async requestTiles() {
// get coordinator, cancel prior prefetch queries
const mc = coordinator();
if (this.prefetch) mc.cancel(this.prefetch);
// get view extent info
const { pad, tileX, tileY, origin: [tx, ty] } = this;
const [m, n] = this.bins = this.binDimensions();
const [x0, x1] = extentX(this, this._filter);
const [y0, y1] = extentY(this, this._filter);
const xspan = x1 - x0;
const yspan = y1 - y0;
const xx = Math.floor((x0 - tx) * (m - pad) / xspan);
const yy = Math.floor((y0 - ty) * (n - pad) / yspan);
const tileExtent = (i, j) => [
[tx + i * xspan, tx + (i + 1) * xspan],
[ty + j * yspan, ty + (j + 1) * yspan]
];
// get tile coords that overlap current view extent
const i0 = Math.floor((x0 - tx) / xspan);
const i1 = tileX ? tileFloor((x1 - tx) / xspan) : i0;
const j0 = Math.floor((y0 - ty) / yspan);
const j1 = tileY ? tileFloor((y1 - ty) / yspan) : j0;
// query for currently needed data tiles
const coords = [];
for (let i = i0; i <= i1; ++i) {
for (let j = j0; j <= j1; ++j) {
coords.push([i, j]);
}
}
const queries = coords.map(
([i, j]) => mc.query(this.tileQuery(tileExtent(i, j)))
);
// prefetch tiles along periphery of current tiles
const prefetchCoords = [];
if (tileX) {
for (let j = j0; j <= j1; ++j) {
prefetchCoords.push([i1 + 1, j]);
prefetchCoords.push([i0 - 1, j]);
}
}
if (tileY) {
const x0 = tileX ? i0 - 1 : i0;
const x1 = tileX ? i1 + 1 : i1;
for (let i = x0; i <= x1; ++i) {
prefetchCoords.push([i, j1 + 1]);
prefetchCoords.push([i, j0 - 1]);
}
}
this.prefetch = prefetchCoords.map(
([i, j]) => mc.prefetch(this.tileQuery(tileExtent(i, j)))
);
// wait for tile queries to complete, then update
const tiles = await Promise.all(queries);
const density = processTiles(m, n, xx, yy, coords, tiles);
this.grids0 = {
numRows: density.length,
columns: { density: [density] }
};
this.convolve().update();
}
convolve() {
return super.convolve().rasterize();
}
rasterize() {
const { bins, grids } = this;
const [ w, h ] = bins;
const { numRows, columns } = grids;
// raster data
const { canvas, ctx, img } = imageData(this, w, h);
// color + opacity encodings
const { alpha, alphaProp, color, colorProp } = rasterEncoding(this);
const alphaData = columns[alphaProp] ?? [];
const colorData = columns[colorProp] ?? [];
// determine raster order
const idx = numRows > 1 && colorProp && this.groupby?.includes(colorProp)
? permute(colorData, this.plot.getAttribute('colorDomain'))
: indices(numRows);
// generate rasters
this.data = {
numRows,
columns: {
src: Array.from({ length: numRows }, (_, i) => {
color?.(img.data, w, h, colorData[idx[i]]);
alpha?.(img.data, w, h, alphaData[idx[i]]);
ctx.putImageData(img, 0, 0);
return canvas.toDataURL();
})
}
};
return this;
}
plotSpecs() {
// @ts-expect-error Correct the data column type
const { type, plot, data: { numRows: length, columns } } = this;
const options = {
src: columns.src,
width: plot.innerWidth(),
height: plot.innerHeight(),
preserveAspectRatio: 'none',
imageRendering: this.channel('imageRendering')?.value,
frameAnchor: 'middle'
};
return [{ type, data: { length }, options }];
}
}
function processTiles(m, n, x, y, coords, tiles) {
const grid = new Float64Array(m * n);
tiles.forEach((data, index) => {
const [i, j] = coords[index];
const tx = i * m - x;
const ty = j * n - y;
copy(m, n, grid, data, tx, ty);
});
return grid;
}
function copy(m, n, grid, values, tx, ty) {
// index = row + col * width
const num = values.numRows;
if (num === 0) return;
const index = values.getChild('index').toArray();
const value = values.getChild('density').toArray();
for (let row = 0; row < num; ++row) {
const idx = index[row];
const i = tx + (idx % m);
const j = ty + Math.floor(idx / m);
if (0 <= i && i < m && 0 <= j && j < n) {
grid[i + j * m] = value[row];
}
}
}
function imageData(mark, w, h) {
if (!mark.image || mark.image.w !== w || mark.image.h !== h) {
const canvas = createCanvas(w, h);
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const img = ctx.getImageData(0, 0, w, h);
mark.image = { canvas, ctx, img, w, h };
}
return mark.image;
}
function bin2d(q, xp, yp, aggs, xn, groupby) {
return q
.select({
index: sql`FLOOR(${xp})::INTEGER + FLOOR(${yp})::INTEGER * ${xn}`,
...aggs
})
.groupby('index', groupby);
}
function binLinear2d(q, xp, yp, value, xn, groupby) {
const w = value.column ? `* ${value.column}` : '';
const subq = (i, w) => q.clone().select({ xp, yp, i, w });
// grid[xu + yu * xn] += (xv - xp) * (yv - yp) * wi;
const a = subq(
sql`FLOOR(xp)::INTEGER + FLOOR(yp)::INTEGER * ${xn}`,
sql`(FLOOR(xp)::INTEGER + 1 - xp) * (FLOOR(yp)::INTEGER + 1 - yp)${w}`
);
// grid[xu + yv * xn] += (xv - xp) * (yp - yu) * wi;
const b = subq(
sql`FLOOR(xp)::INTEGER + (FLOOR(yp)::INTEGER + 1) * ${xn}`,
sql`(FLOOR(xp)::INTEGER + 1 - xp) * (yp - FLOOR(yp)::INTEGER)${w}`
);
// grid[xv + yu * xn] += (xp - xu) * (yv - yp) * wi;
const c = subq(
sql`FLOOR(xp)::INTEGER + 1 + FLOOR(yp)::INTEGER * ${xn}`,
sql`(xp - FLOOR(xp)::INTEGER) * (FLOOR(yp)::INTEGER + 1 - yp)${w}`
);
// grid[xv + yv * xn] += (xp - xu) * (yp - yu) * wi;
const d = subq(
sql`FLOOR(xp)::INTEGER + 1 + (FLOOR(yp)::INTEGER + 1) * ${xn}`,
sql`(xp - FLOOR(xp)::INTEGER) * (yp - FLOOR(yp)::INTEGER)${w}`
);
return Query
.from(Query.unionAll(a, b, c, d))
.select({ index: 'i', density: sum('w') }, groupby)
.groupby('index', groupby)
.having(neq('density', 0));
}
function tileFloor(value) {
const floored = Math.floor(value);
return floored === value ? floored - 1 : floored;
}