@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,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
brush as d3_brush, brushX as d3_brushX, brushY as d3_brushY
|
|
3
|
+
} from 'd3';
|
|
4
|
+
|
|
5
|
+
function wrap(brush) {
|
|
6
|
+
const brushOn = brush.on;
|
|
7
|
+
let enabled = true;
|
|
8
|
+
|
|
9
|
+
function silence(callback) {
|
|
10
|
+
enabled = false;
|
|
11
|
+
callback();
|
|
12
|
+
enabled = true;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
brush.reset = (...args) => {
|
|
16
|
+
silence(() => brush.clear(...args));
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
brush.moveSilent = (...args) => {
|
|
20
|
+
silence(() => brush.move(...args));
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
brush.on = (...args) => {
|
|
24
|
+
if (args.length > 1 && args[1]) {
|
|
25
|
+
// wrap callback to respect enabled flag
|
|
26
|
+
const callback = args[1];
|
|
27
|
+
args[1] = (...event) => enabled && callback(...event);
|
|
28
|
+
}
|
|
29
|
+
return brushOn(...args);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return brush;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function brush() {
|
|
36
|
+
return wrap(d3_brush());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function brushX() {
|
|
40
|
+
return wrap(d3_brushX());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function brushY() {
|
|
44
|
+
return wrap(d3_brushY());
|
|
45
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patch the getScreenCTM method to memoize the last non-null
|
|
3
|
+
* result seen. This will let the method continue to function
|
|
4
|
+
* even after the node is removed from the DOM.
|
|
5
|
+
*/
|
|
6
|
+
export function patchScreenCTM() {
|
|
7
|
+
const node = this;
|
|
8
|
+
const getScreenCTM = node.getScreenCTM;
|
|
9
|
+
let memo;
|
|
10
|
+
node.getScreenCTM = () => {
|
|
11
|
+
return node.isConnected ? (memo = getScreenCTM.call(node)) : memo;
|
|
12
|
+
};
|
|
13
|
+
}
|
package/src/legend.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Toggle } from './interactors/Toggle.js';
|
|
2
|
+
|
|
3
|
+
export class Legend {
|
|
4
|
+
constructor(channel, options) {
|
|
5
|
+
const { as, ...rest } = options;
|
|
6
|
+
this.channel = channel;
|
|
7
|
+
this.options = { label: null, ...rest };
|
|
8
|
+
this.selection = as;
|
|
9
|
+
|
|
10
|
+
this.element = document.createElement('div');
|
|
11
|
+
this.element.setAttribute('class', 'legend');
|
|
12
|
+
this.element.value = this;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
setPlot(plot) {
|
|
16
|
+
const { channel, selection } = this;
|
|
17
|
+
const mark = findMark(plot, channel);
|
|
18
|
+
if (this.selection && mark) {
|
|
19
|
+
this.handler = new Toggle(mark, { selection, channels: [channel] });
|
|
20
|
+
this.selection.addEventListener('value', () => this.update());
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
init(svg) {
|
|
25
|
+
const { channel, options, handler } = this;
|
|
26
|
+
const scale = svg.scale(channel);
|
|
27
|
+
const opt = scale.type === 'ordinal'
|
|
28
|
+
? options
|
|
29
|
+
: { marginTop: 1, tickSize: 2, height: 28, ...options };
|
|
30
|
+
this.legend = svg.legend(channel, opt);
|
|
31
|
+
|
|
32
|
+
if (handler) {
|
|
33
|
+
handler.init(this.legend, ':scope > div', el => [el.__data__]);
|
|
34
|
+
this.update();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.element.replaceChildren(this.legend);
|
|
38
|
+
return this.element;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
update() {
|
|
42
|
+
if (!this.legend) return;
|
|
43
|
+
const { value } = this.selection;
|
|
44
|
+
const curr = value && value.length ? new Set(value.map(v => v[0])) : null;
|
|
45
|
+
const nodes = this.legend.querySelectorAll(':scope > div');
|
|
46
|
+
for (const node of nodes) {
|
|
47
|
+
const selected = curr ? curr.has(node.__data__) : true;
|
|
48
|
+
node.style.opacity = selected ? 1 : 0.2;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function findMark({ marks }, channel) {
|
|
54
|
+
const channels = channel === 'color' ? ['fill', 'stroke']
|
|
55
|
+
: channel === 'opacity' ? ['opacity', 'fillOpacity', 'strokeOpacity']
|
|
56
|
+
: null;
|
|
57
|
+
if (channels == null) return null;
|
|
58
|
+
for (let i = marks.length - 1; i > -1; --i) {
|
|
59
|
+
if (marks[i].channelField(channels)) {
|
|
60
|
+
return marks[i];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Query, argmax, argmin, max, min, sql } from '@uwdata/mosaic-sql';
|
|
2
|
+
import { binField } from './util/bin-field.js';
|
|
3
|
+
import { filteredExtent } from './util/extent.js';
|
|
4
|
+
import { Mark } from './Mark.js';
|
|
5
|
+
|
|
6
|
+
export class ConnectedMark extends Mark {
|
|
7
|
+
constructor(type, source, encodings) {
|
|
8
|
+
const dim = type.endsWith('X') ? 'y' : type.endsWith('Y') ? 'x' : null;
|
|
9
|
+
const req = { [dim]: ['count', 'min', 'max'] };
|
|
10
|
+
|
|
11
|
+
super(type, source, encodings, req);
|
|
12
|
+
this.dim = dim;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
query(filter = []) {
|
|
16
|
+
const { plot, dim, source, stats } = this;
|
|
17
|
+
const { optimize = true } = source.options || {};
|
|
18
|
+
const q = super.query(filter);
|
|
19
|
+
|
|
20
|
+
if (optimize && dim) {
|
|
21
|
+
const { field, as } = this.channelField(dim);
|
|
22
|
+
|
|
23
|
+
// TODO: handle stacked data
|
|
24
|
+
const { column } = field;
|
|
25
|
+
const { count, max, min } = stats[column];
|
|
26
|
+
const size = dim === 'x' ? plot.innerWidth() : plot.innerHeight();
|
|
27
|
+
|
|
28
|
+
const [lo, hi] = filteredExtent(filter, column) || [min, max];
|
|
29
|
+
const scale = (hi - lo) / (max - min);
|
|
30
|
+
if (count * scale > size * 4) {
|
|
31
|
+
const dd = binField(this, dim, as);
|
|
32
|
+
const val = this.channelField(dim === 'x' ? 'y' : 'x').as;
|
|
33
|
+
const cols = q.select().map(c => c.as).filter(c => c !== as && c !== val);
|
|
34
|
+
return m4(q, dd, as, val, lo, hi, size, cols);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
q.orderby(as);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return q;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* M4 is an optimization for value-preserving time-series aggregation
|
|
46
|
+
* (http://www.vldb.org/pvldb/vol7/p797-jugel.pdf). This implementation uses
|
|
47
|
+
* an efficient version with a single scan and the aggregate function
|
|
48
|
+
* argmin and argmax, following https://arxiv.org/pdf/2306.03714.pdf.
|
|
49
|
+
*/
|
|
50
|
+
function m4(input, bx, x, y, lo, hi, width, cols = []) {
|
|
51
|
+
const bins = sql`FLOOR(${width / (hi - lo)}::DOUBLE * (${bx} - ${+lo}::DOUBLE))::INTEGER`;
|
|
52
|
+
|
|
53
|
+
const q = (sel) => Query
|
|
54
|
+
.from(input)
|
|
55
|
+
.select(sel)
|
|
56
|
+
.groupby(bins, cols);
|
|
57
|
+
|
|
58
|
+
return Query
|
|
59
|
+
.union(
|
|
60
|
+
q([{ [x]: min(x), [y]: argmin(y, x) }, ...cols]),
|
|
61
|
+
q([{ [x]: max(x), [y]: argmax(y, x) }, ...cols]),
|
|
62
|
+
q([{ [x]: argmin(x, y), [y]: min(y) }, ...cols]),
|
|
63
|
+
q([{ [x]: argmax(x, y), [y]: max(y) }, ...cols])
|
|
64
|
+
)
|
|
65
|
+
.orderby(cols, x);
|
|
66
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { contours, max } from 'd3';
|
|
2
|
+
import { handleParam } from './util/handle-param.js';
|
|
3
|
+
import { Grid2DMark } from './Grid2DMark.js';
|
|
4
|
+
import { channelOption } from './Mark.js';
|
|
5
|
+
|
|
6
|
+
export class ContourMark extends Grid2DMark {
|
|
7
|
+
constructor(source, options) {
|
|
8
|
+
const { thresholds = 10, ...channels } = options;
|
|
9
|
+
super('geo', source, channels);
|
|
10
|
+
handleParam(this, 'thresholds', thresholds, () => {
|
|
11
|
+
return this.grids ? this.contours().update() : null
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
convolve() {
|
|
16
|
+
return super.convolve().contours();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
contours() {
|
|
20
|
+
const { bins, densityMap, kde, thresholds, groupby, plot } = this;
|
|
21
|
+
|
|
22
|
+
let tz = thresholds;
|
|
23
|
+
if (!Array.isArray(tz)) {
|
|
24
|
+
const scale = max(kde.map(k => max(k)));
|
|
25
|
+
tz = Array.from({length: tz - 1}, (_, i) => (scale * (i + 1)) / tz);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (densityMap.fill || densityMap.stroke) {
|
|
29
|
+
if (this.plot.getAttribute('colorScale') !== 'log') {
|
|
30
|
+
this.plot.setAttribute('colorZero', true);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// transform contours into data space coordinates
|
|
35
|
+
// so we play nice with scale domains & axes
|
|
36
|
+
const [nx, ny] = bins;
|
|
37
|
+
const [x0, x1] = plot.getAttribute('xDomain');
|
|
38
|
+
const [y0, y1] = plot.getAttribute('yDomain');
|
|
39
|
+
const sx = (x1 - x0) / nx;
|
|
40
|
+
const sy = (y1 - y0) / ny;
|
|
41
|
+
const xo = +x0;
|
|
42
|
+
const yo = +y0;
|
|
43
|
+
const x = v => xo + v * sx;
|
|
44
|
+
const y = v => yo + v * sy;
|
|
45
|
+
const contour = contours().size(bins);
|
|
46
|
+
|
|
47
|
+
// generate contours
|
|
48
|
+
this.data = kde.flatMap(k => tz.map(t => {
|
|
49
|
+
const c = transform(contour.contour(k, t), x, y);
|
|
50
|
+
groupby.forEach((name, i) => c[name] = k.key[i]);
|
|
51
|
+
c.density = t;
|
|
52
|
+
return c;
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
return this;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
plotSpecs() {
|
|
59
|
+
const { type, channels, densityMap, data } = this;
|
|
60
|
+
const options = {};
|
|
61
|
+
for (const c of channels) {
|
|
62
|
+
const { channel } = c;
|
|
63
|
+
if (channel !== 'x' && channel !== 'y') {
|
|
64
|
+
options[channel] = channelOption(c);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (densityMap.fill) options.fill = 'density';
|
|
68
|
+
if (densityMap.stroke) options.stroke = 'density';
|
|
69
|
+
return [{ type, data, options }];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function transform(geometry, x, y) {
|
|
74
|
+
function transformPolygon(coordinates) {
|
|
75
|
+
coordinates.forEach(transformRing);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function transformRing(coordinates) {
|
|
79
|
+
coordinates.forEach(transformPoint);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function transformPoint(coordinates) {
|
|
83
|
+
coordinates[0] = x(coordinates[0]);
|
|
84
|
+
coordinates[1] = y(coordinates[1]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
geometry.coordinates.forEach(transformPolygon);
|
|
88
|
+
return geometry;
|
|
89
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Query, and, count, isNull, isBetween, sql, sum } from '@uwdata/mosaic-sql';
|
|
2
|
+
import { binField, bin1d } from './util/bin-field.js';
|
|
3
|
+
import { extentX, extentY } from './util/extent.js';
|
|
4
|
+
import { handleParam } from './util/handle-param.js';
|
|
5
|
+
import { RasterMark } from './RasterMark.js';
|
|
6
|
+
|
|
7
|
+
export class DenseLineMark extends RasterMark {
|
|
8
|
+
constructor(source, options) {
|
|
9
|
+
const { normalize = true, ...rest } = options;
|
|
10
|
+
super(source, { bandwidth: 0, ...rest });
|
|
11
|
+
handleParam(this, 'normalize', normalize);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
query(filter = []) {
|
|
15
|
+
const { plot, channels, normalize, source } = this;
|
|
16
|
+
const [x0, x1] = extentX(this, filter);
|
|
17
|
+
const [y0, y1] = extentY(this, filter);
|
|
18
|
+
const [nx, ny] = this.bins = this.binDimensions(this);
|
|
19
|
+
const bx = binField(this, 'x');
|
|
20
|
+
const by = binField(this, 'y');
|
|
21
|
+
const rx = !!plot.getAttribute('xReverse');
|
|
22
|
+
const ry = !!plot.getAttribute('yReverse');
|
|
23
|
+
const x = bin1d(bx, x0, x1, nx, rx, this.binPad);
|
|
24
|
+
const y = bin1d(by, y0, y1, ny, ry, this.binPad);
|
|
25
|
+
|
|
26
|
+
const q = Query
|
|
27
|
+
.from(source.table)
|
|
28
|
+
.where(stripXY(this, filter));
|
|
29
|
+
|
|
30
|
+
const groupby = this.groupby = [];
|
|
31
|
+
const z = [];
|
|
32
|
+
for (const c of channels) {
|
|
33
|
+
if (Object.hasOwn(c, 'field')) {
|
|
34
|
+
const { channel, field } = c;
|
|
35
|
+
if (channel === 'z') {
|
|
36
|
+
q.select({ [channel]: field });
|
|
37
|
+
z.push('z');
|
|
38
|
+
} else if (channel !== 'x' && channel !== 'y') {
|
|
39
|
+
q.select({ [channel]: field });
|
|
40
|
+
groupby.push(channel);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return lineDensity(q, x, y, z, nx, ny, groupby, normalize);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// strip x, y fields from filter predicate
|
|
50
|
+
// to prevent improper clipping of line segments
|
|
51
|
+
// TODO: improve, perhaps with supporting query utilities
|
|
52
|
+
function stripXY(mark, filter) {
|
|
53
|
+
if (Array.isArray(filter) && !filter.length) return filter;
|
|
54
|
+
|
|
55
|
+
const xc = mark.channelField('x').field.column;
|
|
56
|
+
const yc = mark.channelField('y').field.column;
|
|
57
|
+
const test = p => p.op !== 'BETWEEN'
|
|
58
|
+
|| p.field.column !== xc && p.field.column !== yc;
|
|
59
|
+
const filterAnd = p => p.op === 'AND'
|
|
60
|
+
? and(p.children.filter(c => test(c)))
|
|
61
|
+
: p;
|
|
62
|
+
|
|
63
|
+
return Array.isArray(filter)
|
|
64
|
+
? filter.filter(p => test(p)).map(p => filterAnd(p))
|
|
65
|
+
: filterAnd(filter);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function lineDensity(
|
|
69
|
+
q, x, y, z, xn, yn,
|
|
70
|
+
groupby = [], normalize = true
|
|
71
|
+
) {
|
|
72
|
+
// select x, y points binned to the grid
|
|
73
|
+
q.select({
|
|
74
|
+
x: sql`FLOOR(${x})::INTEGER`,
|
|
75
|
+
y: sql`FLOOR(${y})::INTEGER`
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// select line segment end point pairs
|
|
79
|
+
const groups = groupby.concat(z);
|
|
80
|
+
const pairPart = groups.length ? `PARTITION BY ${groups.join(', ')} ` : '';
|
|
81
|
+
const pairs = Query
|
|
82
|
+
.from(q)
|
|
83
|
+
.select(groups, {
|
|
84
|
+
x0: 'x',
|
|
85
|
+
y0: 'y',
|
|
86
|
+
dx: sql`(lead(x) OVER sw - x)`,
|
|
87
|
+
dy: sql`(lead(y) OVER sw - y)`
|
|
88
|
+
})
|
|
89
|
+
.window({ sw: sql`${pairPart}ORDER BY x ASC` })
|
|
90
|
+
.qualify(and(
|
|
91
|
+
sql`(x0 < ${xn} OR x0 + dx < ${xn})`,
|
|
92
|
+
sql`(y0 < ${yn} OR y0 + dy < ${yn})`,
|
|
93
|
+
sql`(x0 > 0 OR x0 + dx > 0)`,
|
|
94
|
+
sql`(y0 > 0 OR y0 + dy > 0)`
|
|
95
|
+
));
|
|
96
|
+
|
|
97
|
+
// indices to join against for rasterization
|
|
98
|
+
// generate the maximum number of indices needed
|
|
99
|
+
const num = Query
|
|
100
|
+
.select({ x: sql`GREATEST(MAX(ABS(dx)), MAX(ABS(dy)))` })
|
|
101
|
+
.from('pairs');
|
|
102
|
+
const indices = Query.select({ i: sql`UNNEST(range((${num})))::INTEGER` });
|
|
103
|
+
|
|
104
|
+
// rasterize line segments
|
|
105
|
+
const raster = Query.unionAll(
|
|
106
|
+
Query
|
|
107
|
+
.select(groups, {
|
|
108
|
+
x: sql`x0 + i`,
|
|
109
|
+
y: sql`y0 + ROUND(i * dy / dx::FLOAT)::INTEGER`
|
|
110
|
+
})
|
|
111
|
+
.from('pairs', 'indices')
|
|
112
|
+
.where(sql`ABS(dy) <= ABS(dx) AND i < ABS(dx)`),
|
|
113
|
+
Query
|
|
114
|
+
.select(groups, {
|
|
115
|
+
x: sql`x0 + ROUND(SIGN(dy) * i * dx / dy::FLOAT)::INTEGER`,
|
|
116
|
+
y: sql`y0 + SIGN(dy) * i`
|
|
117
|
+
})
|
|
118
|
+
.from('pairs', 'indices')
|
|
119
|
+
.where(sql`ABS(dy) > ABS(dx) AND i < ABS(dy)`),
|
|
120
|
+
Query
|
|
121
|
+
.select(groups, { x: 'x0', y: 'y0' })
|
|
122
|
+
.from('pairs')
|
|
123
|
+
.where(isNull('dx'))
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// filter raster, normalize columns for each series
|
|
127
|
+
const pointPart = ['x'].concat(groups).join(', ');
|
|
128
|
+
const points = Query
|
|
129
|
+
.from('raster')
|
|
130
|
+
.select(groups, 'x', 'y',
|
|
131
|
+
normalize
|
|
132
|
+
? { w: sql`1.0 / COUNT(*) OVER (PARTITION BY ${pointPart})` }
|
|
133
|
+
: null
|
|
134
|
+
)
|
|
135
|
+
.where(and(isBetween('x', [0, xn]), isBetween('y', [0, yn])));
|
|
136
|
+
|
|
137
|
+
// sum normalized, rasterized series into output grids
|
|
138
|
+
return Query
|
|
139
|
+
.with({ pairs, indices, raster, points })
|
|
140
|
+
.from('points')
|
|
141
|
+
.select(groupby, {
|
|
142
|
+
index: sql`x + y * ${xn}::INTEGER`,
|
|
143
|
+
value: normalize ? sum('w') : count()
|
|
144
|
+
})
|
|
145
|
+
.groupby('index', groupby);
|
|
146
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Query, gt, isBetween, sql, sum } from '@uwdata/mosaic-sql';
|
|
2
|
+
import { Transient } from '../symbols.js';
|
|
3
|
+
import { binField, bin1d } from './util/bin-field.js';
|
|
4
|
+
import { dericheConfig, dericheConv1d } from './util/density.js';
|
|
5
|
+
import { extentX, extentY, xext, yext } from './util/extent.js';
|
|
6
|
+
import { grid1d } from './util/grid.js';
|
|
7
|
+
import { handleParam } from './util/handle-param.js';
|
|
8
|
+
import { Mark, channelOption, markQuery } from './Mark.js';
|
|
9
|
+
|
|
10
|
+
export class Density1DMark extends Mark {
|
|
11
|
+
constructor(type, source, options) {
|
|
12
|
+
const { bins = 1024, bandwidth = 20, ...channels } = options;
|
|
13
|
+
const dim = type.endsWith('X') ? 'y' : 'x';
|
|
14
|
+
|
|
15
|
+
super(type, source, channels, dim === 'x' ? xext : yext);
|
|
16
|
+
this.dim = dim;
|
|
17
|
+
|
|
18
|
+
handleParam(this, 'bins', bins);
|
|
19
|
+
handleParam(this, 'bandwidth', bandwidth, () => {
|
|
20
|
+
return this.grid ? this.convolve().update() : null
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get filterIndexable() {
|
|
25
|
+
const name = this.dim === 'x' ? 'xDomain' : 'yDomain';
|
|
26
|
+
const dom = this.plot.getAttribute(name);
|
|
27
|
+
return dom && !dom[Transient];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
query(filter = []) {
|
|
31
|
+
if (this.hasOwnData()) throw new Error('Density1DMark requires a data source');
|
|
32
|
+
const { bins, channels, dim, source: { table } } = this;
|
|
33
|
+
const [lo, hi] = this.extent = (dim === 'x' ? extentX : extentY)(this, filter);
|
|
34
|
+
const bx = binField(this, dim);
|
|
35
|
+
return binLinear1d(
|
|
36
|
+
markQuery(channels, table, [dim])
|
|
37
|
+
.where(filter.concat(isBetween(bx, [lo, hi]))),
|
|
38
|
+
bin1d(bx, lo, hi, bins),
|
|
39
|
+
this.channelField('weight') ? 'weight' : null
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
queryResult(data) {
|
|
44
|
+
this.grid = grid1d(this.bins, data);
|
|
45
|
+
return this.convolve();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
convolve() {
|
|
49
|
+
const { bins, bandwidth, dim, grid, plot, extent: [lo, hi] } = this;
|
|
50
|
+
|
|
51
|
+
// perform smoothing
|
|
52
|
+
const neg = grid.some(v => v < 0);
|
|
53
|
+
const size = dim === 'x' ? plot.innerWidth() : plot.innerHeight();
|
|
54
|
+
const config = dericheConfig(bandwidth * (bins - 1) / size, neg);
|
|
55
|
+
const result = dericheConv1d(config, grid, bins);
|
|
56
|
+
|
|
57
|
+
// map smoothed grid values to sample data points
|
|
58
|
+
const points = this.data = [];
|
|
59
|
+
const v = dim === 'x' ? 'y' : 'x';
|
|
60
|
+
const b = this.channelField(dim).as;
|
|
61
|
+
const b0 = +lo;
|
|
62
|
+
const delta = (hi - b0) / (bins - 1);
|
|
63
|
+
const scale = 1 / delta;
|
|
64
|
+
for (let i = 0; i < bins; ++i) {
|
|
65
|
+
points.push({
|
|
66
|
+
[b]: b0 + i * delta,
|
|
67
|
+
[v]: result[i] * scale
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return this;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
plotSpecs() {
|
|
75
|
+
const { type, data, channels, dim } = this;
|
|
76
|
+
const options = dim === 'x' ? { y: 'y' } : { x: 'x' };
|
|
77
|
+
for (const c of channels) {
|
|
78
|
+
options[c.channel] = channelOption(c);
|
|
79
|
+
}
|
|
80
|
+
return [{ type, data, options }];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function binLinear1d(q, p, value) {
|
|
85
|
+
const w = value ? `* ${value}` : '';
|
|
86
|
+
|
|
87
|
+
const u = q.clone().select({
|
|
88
|
+
p,
|
|
89
|
+
i: sql`FLOOR(p)::INTEGER`,
|
|
90
|
+
w: sql`(FLOOR(p) + 1 - p)${w}`
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const v = q.clone().select({
|
|
94
|
+
p,
|
|
95
|
+
i: sql`FLOOR(p)::INTEGER + 1`,
|
|
96
|
+
w: sql`(p - FLOOR(p))${w}`
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return Query
|
|
100
|
+
.from(Query.unionAll(u, v))
|
|
101
|
+
.select({ index: 'i', value: sum('w') })
|
|
102
|
+
.groupby('index')
|
|
103
|
+
.having(gt('value', 0));
|
|
104
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { handleParam } from './util/handle-param.js';
|
|
2
|
+
import { Grid2DMark } from './Grid2DMark.js';
|
|
3
|
+
import { channelOption } from './Mark.js';
|
|
4
|
+
|
|
5
|
+
export class Density2DMark extends Grid2DMark {
|
|
6
|
+
constructor(source, options) {
|
|
7
|
+
const { type = 'dot', binsX, binsY, ...channels } = options;
|
|
8
|
+
channels.binPad = channels.binPad ?? 0;
|
|
9
|
+
super(type, source, channels);
|
|
10
|
+
handleParam(this, 'binsX', binsX);
|
|
11
|
+
handleParam(this, 'binsY', binsY);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
convolve() {
|
|
15
|
+
super.convolve();
|
|
16
|
+
const { bins, binPad, extentX, extentY } = this;
|
|
17
|
+
const [nx, ny] = bins;
|
|
18
|
+
const [x0, x1] = extentX;
|
|
19
|
+
const [y0, y1] = extentY;
|
|
20
|
+
const deltaX = (x1 - x0) / (nx - binPad);
|
|
21
|
+
const deltaY = (y1 - y0) / (ny - binPad);
|
|
22
|
+
const offset = binPad ? 0 : 0.5;
|
|
23
|
+
this.data = points(this.kde, bins, x0, y0, deltaX, deltaY, offset);
|
|
24
|
+
return this;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
binDimensions() {
|
|
28
|
+
const { plot, binWidth, binsX, binsY } = this;
|
|
29
|
+
return [
|
|
30
|
+
binsX ?? Math.round(plot.innerWidth() / binWidth),
|
|
31
|
+
binsY ?? Math.round(plot.innerHeight() / binWidth)
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
plotSpecs() {
|
|
36
|
+
const { type, channels, densityMap, data } = this;
|
|
37
|
+
const options = {};
|
|
38
|
+
for (const c of channels) {
|
|
39
|
+
const { channel } = c;
|
|
40
|
+
options[channel] = (channel === 'x' || channel === 'y')
|
|
41
|
+
? channel // use generated x/y data fields
|
|
42
|
+
: channelOption(c);
|
|
43
|
+
}
|
|
44
|
+
for (const channel in densityMap) {
|
|
45
|
+
if (densityMap[channel]) {
|
|
46
|
+
options[channel] = 'density';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return [{ type, data, options }];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function points(kde, bins, x0, y0, deltaX, deltaY, offset) {
|
|
54
|
+
const scale = 1 / (deltaX * deltaY);
|
|
55
|
+
const [nx, ny] = bins;
|
|
56
|
+
const data = [];
|
|
57
|
+
for (const grid of kde) {
|
|
58
|
+
for (let k = 0, j = 0; j < ny; ++j) {
|
|
59
|
+
for (let i = 0; i < nx; ++i, ++k) {
|
|
60
|
+
data.push({
|
|
61
|
+
x: x0 + (i + offset) * deltaX,
|
|
62
|
+
y: y0 + (j + offset) * deltaY,
|
|
63
|
+
density: grid[k] * scale
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return data;
|
|
69
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { geojson } from '@uwdata/mosaic-sql';
|
|
2
|
+
import { Mark, isDataArray } from './Mark.js';
|
|
3
|
+
|
|
4
|
+
// default geometry column created by st_read
|
|
5
|
+
// warning: if another "geom" column exists this default
|
|
6
|
+
// will shift, for example to "geom_1" and so on.
|
|
7
|
+
const DEFAULT_GEOMETRY_COLUMN = 'geom';
|
|
8
|
+
|
|
9
|
+
export class GeoMark extends Mark {
|
|
10
|
+
constructor(source, encodings = {}, reqs) {
|
|
11
|
+
if (!isDataArray(source) && !encodings?.geometry) {
|
|
12
|
+
// if issuing queries and no geometry channel specified
|
|
13
|
+
// then request default geometry column as GeoJSON
|
|
14
|
+
encodings.geometry = geojson(DEFAULT_GEOMETRY_COLUMN);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
super('geo', source, encodings, reqs);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
queryResult(data) {
|
|
21
|
+
super.queryResult(data);
|
|
22
|
+
|
|
23
|
+
// parse GeoJSON strings to JSON objects
|
|
24
|
+
const geom = this.channelField('geometry')?.as;
|
|
25
|
+
if (geom && this.data) {
|
|
26
|
+
this.data.forEach(data => {
|
|
27
|
+
if (typeof data[geom] === 'string') {
|
|
28
|
+
data[geom] = JSON.parse(data[geom]);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
}
|