@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,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,9 @@
1
+ const EPS = 1e-12;
2
+
3
+ export function closeTo(a, b) {
4
+ return a === b || (
5
+ a && b &&
6
+ Math.abs(a[0] - b[0]) < EPS &&
7
+ Math.abs(a[1] - b[1]) < EPS
8
+ ) || false;
9
+ }
@@ -0,0 +1,4 @@
1
+ export function getField(mark, channels) {
2
+ const field = mark.channelField(channels)?.field;
3
+ return field?.basis || field;
4
+ }
@@ -0,0 +1,3 @@
1
+ export function invert(value, scale, pixelSize = 1) {
2
+ return scale.invert(pixelSize * Math.floor(value / pixelSize));
3
+ }
@@ -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
+ }
@@ -0,0 +1,9 @@
1
+ import { toKebabCase } from './to-kebab-case.js';
2
+
3
+ export function sanitizeStyles(styles) {
4
+ const s = {};
5
+ for (const name in styles) {
6
+ s[toKebabCase(name)] = styles[name];
7
+ }
8
+ return s;
9
+ }
@@ -0,0 +1,9 @@
1
+ export function toKebabCase(cc) {
2
+ const lc = cc.toLowerCase();
3
+ const n = cc.length;
4
+ let kc = '';
5
+ for (let i = 0; i < n; ++i) {
6
+ kc += (cc[i] !== lc[i] ? '-' : '') + lc[i];
7
+ }
8
+ return kc;
9
+ }
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
+ }