@uwdata/mosaic-plot 0.5.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uwdata/mosaic-plot",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A Mosaic-powered plotting framework based on Observable Plot.",
5
5
  "keywords": [
6
6
  "data",
@@ -29,10 +29,10 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@observablehq/plot": "^0.6.13",
32
- "@uwdata/mosaic-core": "^0.5.0",
33
- "@uwdata/mosaic-sql": "^0.5.0",
32
+ "@uwdata/mosaic-core": "^0.6.0",
33
+ "@uwdata/mosaic-sql": "^0.6.0",
34
34
  "d3": "^7.8.5",
35
35
  "isoformat": "^0.2.1"
36
36
  },
37
- "gitHead": "92886dddfb126c1439924c5a0189e4639c3519a7"
37
+ "gitHead": "51517b28e916e355f4ce0dc6e98aef3a1db3f7b2"
38
38
  }
package/src/index.js CHANGED
@@ -11,7 +11,7 @@ export { Density2DMark } from './marks/Density2DMark.js';
11
11
  export { GeoMark } from './marks/GeoMark.js';
12
12
  export { Grid2DMark } from './marks/Grid2DMark.js';
13
13
  export { HexbinMark } from './marks/HexbinMark.js';
14
- export { RasterMark } from './marks/RasterMark.js';
14
+ export { RasterMark, HeatmapMark } from './marks/RasterMark.js';
15
15
  export { RasterTileMark } from './marks/RasterTileMark.js';
16
16
  export { RegressionMark } from './marks/RegressionMark.js';
17
17
 
@@ -1,13 +1,12 @@
1
1
  import { Query, argmax, argmin, max, min, sql } from '@uwdata/mosaic-sql';
2
- import { binField } from './util/bin-field.js';
2
+ import { binExpr } from './util/bin-expr.js';
3
3
  import { filteredExtent } from './util/extent.js';
4
4
  import { Mark } from './Mark.js';
5
5
 
6
6
  export class ConnectedMark extends Mark {
7
7
  constructor(type, source, encodings) {
8
8
  const dim = type.endsWith('X') ? 'y' : type.endsWith('Y') ? 'x' : null;
9
- const req = { [dim]: ['count', 'min', 'max'] };
10
-
9
+ const req = { [dim]: ['min', 'max'] };
11
10
  super(type, source, encodings, req);
12
11
  this.dim = dim;
13
12
  }
@@ -16,28 +15,28 @@ export class ConnectedMark extends Mark {
16
15
  const { plot, dim, source, stats } = this;
17
16
  const { optimize = true } = source.options || {};
18
17
  const q = super.query(filter);
18
+ if (!dim) return q;
19
19
 
20
- if (optimize && dim) {
21
- const { field, as } = this.channelField(dim);
20
+ const ortho = dim === 'x' ? 'y' : 'x';
21
+ const value = this.channelField(ortho)?.as;
22
+ const { field, as } = this.channelField(dim);
23
+ const { type } = stats[field.column];
24
+ const isContinuous = type === 'date' || type === 'number';
22
25
 
23
- // TODO: handle stacked data
26
+ if (optimize && isContinuous && value) {
27
+ // TODO: handle stacked data!
24
28
  const { column } = field;
25
- const { count, max, min } = stats[column];
29
+ const { max, min } = stats[column];
26
30
  const size = dim === 'x' ? plot.innerWidth() : plot.innerHeight();
27
-
28
31
  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);
32
+ const [expr] = binExpr(this, dim, size, [lo, hi], 1, as);
33
+ const cols = q.select()
34
+ .map(c => c.as)
35
+ .filter(c => c !== as && c !== value);
36
+ return m4(q, expr, as, value, cols);
37
+ } else {
38
+ return q.orderby(field);
38
39
  }
39
-
40
- return q;
41
40
  }
42
41
  }
43
42
 
@@ -47,13 +46,13 @@ export class ConnectedMark extends Mark {
47
46
  * an efficient version with a single scan and the aggregate function
48
47
  * argmin and argmax, following https://arxiv.org/pdf/2306.03714.pdf.
49
48
  */
50
- function m4(input, bx, x, y, lo, hi, width, cols = []) {
51
- const bins = sql`FLOOR(${width / (hi - lo)}::DOUBLE * (${bx} - ${+lo}::DOUBLE))::INTEGER`;
49
+ function m4(input, bin, x, y, cols = []) {
50
+ const pixel = sql`FLOOR(${bin})::INTEGER`;
52
51
 
53
52
  const q = (sel) => Query
54
53
  .from(input)
55
54
  .select(sel)
56
- .groupby(bins, cols);
55
+ .groupby(pixel, cols);
57
56
 
58
57
  return Query
59
58
  .union(
@@ -1,4 +1,5 @@
1
- import { contours, max } from 'd3';
1
+ import { contours } from 'd3';
2
+ import { gridDomainContinuous } from './util/grid.js';
2
3
  import { handleParam } from './util/handle-param.js';
3
4
  import { Grid2DMark } from './Grid2DMark.js';
4
5
  import { channelOption } from './Mark.js';
@@ -6,7 +7,12 @@ import { channelOption } from './Mark.js';
6
7
  export class ContourMark extends Grid2DMark {
7
8
  constructor(source, options) {
8
9
  const { thresholds = 10, ...channels } = options;
9
- super('geo', source, channels);
10
+ super('geo', source, {
11
+ bandwidth: 20,
12
+ interpolate: 'linear',
13
+ pixelSize: 2,
14
+ ...channels
15
+ });
10
16
  handleParam(this, 'thresholds', thresholds, () => {
11
17
  return this.grids ? this.contours().update() : null
12
18
  });
@@ -17,12 +23,12 @@ export class ContourMark extends Grid2DMark {
17
23
  }
18
24
 
19
25
  contours() {
20
- const { bins, densityMap, kde, thresholds, groupby, plot } = this;
26
+ const { bins, densityMap, kde, thresholds, plot } = this;
21
27
 
22
28
  let tz = thresholds;
23
29
  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);
30
+ const [, hi] = gridDomainContinuous(kde, 'density');
31
+ tz = Array.from({length: tz - 1}, (_, i) => (hi * (i + 1)) / tz);
26
32
  }
27
33
 
28
34
  if (densityMap.fill || densityMap.stroke) {
@@ -45,11 +51,11 @@ export class ContourMark extends Grid2DMark {
45
51
  const contour = contours().size(bins);
46
52
 
47
53
  // 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;
54
+ this.data = kde.flatMap(cell => tz.map(t => {
55
+ return Object.assign(
56
+ transform(contour.contour(cell.density, t), x, y),
57
+ { ...cell, density: t }
58
+ );
53
59
  }));
54
60
 
55
61
  return this;
@@ -1,5 +1,5 @@
1
1
  import { Query, and, count, isNull, isBetween, sql, sum } from '@uwdata/mosaic-sql';
2
- import { binField, bin1d } from './util/bin-field.js';
2
+ import { binExpr } from './util/bin-expr.js';
3
3
  import { extentX, extentY } from './util/extent.js';
4
4
  import { handleParam } from './util/handle-param.js';
5
5
  import { RasterMark } from './RasterMark.js';
@@ -7,26 +7,21 @@ import { RasterMark } from './RasterMark.js';
7
7
  export class DenseLineMark extends RasterMark {
8
8
  constructor(source, options) {
9
9
  const { normalize = true, ...rest } = options;
10
- super(source, { bandwidth: 0, ...rest });
10
+ super(source, rest);
11
11
  handleParam(this, 'normalize', normalize);
12
12
  }
13
13
 
14
14
  query(filter = []) {
15
- const { plot, channels, normalize, source } = this;
16
- const [x0, x1] = extentX(this, filter);
17
- const [y0, y1] = extentY(this, filter);
15
+ const { channels, normalize, source, binPad } = this;
18
16
  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);
17
+ const [x] = binExpr(this, 'x', nx, extentX(this, filter), binPad);
18
+ const [y] = binExpr(this, 'y', ny, extentY(this, filter), binPad);
25
19
 
26
20
  const q = Query
27
21
  .from(source.table)
28
22
  .where(stripXY(this, filter));
29
23
 
24
+ this.aggr = ['density'];
30
25
  const groupby = this.groupby = [];
31
26
  const z = [];
32
27
  for (const c of channels) {
@@ -132,7 +127,7 @@ function lineDensity(
132
127
  ? { w: sql`1.0 / COUNT(*) OVER (PARTITION BY ${pointPart})` }
133
128
  : null
134
129
  )
135
- .where(and(isBetween('x', [0, xn]), isBetween('y', [0, yn])));
130
+ .where(and(isBetween('x', [0, xn], true), isBetween('y', [0, yn], true)));
136
131
 
137
132
  // sum normalized, rasterized series into output grids
138
133
  return Query
@@ -140,7 +135,7 @@ function lineDensity(
140
135
  .from('points')
141
136
  .select(groupby, {
142
137
  index: sql`x + y * ${xn}::INTEGER`,
143
- value: normalize ? sum('w') : count()
138
+ density: normalize ? sum('w') : count()
144
139
  })
145
140
  .groupby('index', groupby);
146
141
  }
@@ -1,6 +1,6 @@
1
1
  import { Query, gt, isBetween, sql, sum } from '@uwdata/mosaic-sql';
2
2
  import { Transient } from '../symbols.js';
3
- import { binField, bin1d } from './util/bin-field.js';
3
+ import { binExpr } from './util/bin-expr.js';
4
4
  import { dericheConfig, dericheConv1d } from './util/density.js';
5
5
  import { extentX, extentY, xext, yext } from './util/extent.js';
6
6
  import { grid1d } from './util/grid.js';
@@ -30,14 +30,12 @@ export class Density1DMark extends Mark {
30
30
  query(filter = []) {
31
31
  if (this.hasOwnData()) throw new Error('Density1DMark requires a data source');
32
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
- );
33
+ const extent = this.extent = (dim === 'x' ? extentX : extentY)(this, filter);
34
+ const [x, bx] = binExpr(this, dim, bins, extent);
35
+ const q = markQuery(channels, table, [dim])
36
+ .where(filter.concat(isBetween(bx, extent)));
37
+ const v = this.channelField('weight') ? 'weight' : null;
38
+ return binLinear1d(q, x, v);
41
39
  }
42
40
 
43
41
  queryResult(data) {
@@ -81,8 +79,8 @@ export class Density1DMark extends Mark {
81
79
  }
82
80
  }
83
81
 
84
- function binLinear1d(q, p, value) {
85
- const w = value ? `* ${value}` : '';
82
+ function binLinear1d(q, p, density) {
83
+ const w = density ? `* ${density}` : '';
86
84
 
87
85
  const u = q.clone().select({
88
86
  p,
@@ -98,7 +96,7 @@ function binLinear1d(q, p, value) {
98
96
 
99
97
  return Query
100
98
  .from(Query.unionAll(u, v))
101
- .select({ index: 'i', value: sum('w') })
99
+ .select({ index: 'i', density: sum('w') })
102
100
  .groupby('index')
103
- .having(gt('value', 0));
101
+ .having(gt('density', 0));
104
102
  }
@@ -1,37 +1,37 @@
1
- import { handleParam } from './util/handle-param.js';
1
+ import { channelScale } from './util/channel-scale.js';
2
2
  import { Grid2DMark } from './Grid2DMark.js';
3
3
  import { channelOption } from './Mark.js';
4
4
 
5
5
  export class Density2DMark extends Grid2DMark {
6
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);
7
+ const { type = 'dot', ...channels } = options;
8
+ super(type, source, {
9
+ bandwidth: 20,
10
+ interpolate: 'linear',
11
+ pad: 0,
12
+ pixelSize: 2,
13
+ ...channels
14
+ });
12
15
  }
13
16
 
14
17
  convolve() {
15
18
  super.convolve();
16
- const { bins, binPad, extentX, extentY } = this;
19
+ const { bins, pad, extentX, extentY } = this;
17
20
  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);
21
+ const scaleX = channelScale(this, 'x');
22
+ const scaleY = channelScale(this, 'y');
23
+ const [x0, x1] = extentX.map(v => scaleX.apply(v));
24
+ const [y0, y1] = extentY.map(v => scaleY.apply(v));
25
+ const deltaX = (x1 - x0) / (nx - pad);
26
+ const deltaY = (y1 - y0) / (ny - pad);
27
+ const offset = pad ? 0 : 0.5;
28
+ this.data = points(
29
+ this.kde, bins, x0, y0, deltaX, deltaY,
30
+ scaleX.invert, scaleY.invert, offset
31
+ );
24
32
  return this;
25
33
  }
26
34
 
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
35
  plotSpecs() {
36
36
  const { type, channels, densityMap, data } = this;
37
37
  const options = {};
@@ -50,16 +50,18 @@ export class Density2DMark extends Grid2DMark {
50
50
  }
51
51
  }
52
52
 
53
- function points(kde, bins, x0, y0, deltaX, deltaY, offset) {
53
+ function points(kde, bins, x0, y0, deltaX, deltaY, invertX, invertY, offset) {
54
54
  const scale = 1 / (deltaX * deltaY);
55
55
  const [nx, ny] = bins;
56
56
  const data = [];
57
- for (const grid of kde) {
57
+ for (const cell of kde) {
58
+ const grid = cell.density;
58
59
  for (let k = 0, j = 0; j < ny; ++j) {
59
60
  for (let i = 0; i < nx; ++i, ++k) {
60
61
  data.push({
61
- x: x0 + (i + offset) * deltaX,
62
- y: y0 + (j + offset) * deltaY,
62
+ ...cell,
63
+ x: invertX(x0 + (i + offset) * deltaX),
64
+ y: invertY(y0 + (j + offset) * deltaY),
63
65
  density: grid[k] * scale
64
66
  });
65
67
  }
@@ -1,19 +1,23 @@
1
- import { Query, count, gt, isBetween, lt, lte, sql, sum } from '@uwdata/mosaic-sql';
1
+ import { Query, count, isBetween, lt, lte, neq, sql, sum } from '@uwdata/mosaic-sql';
2
2
  import { Transient } from '../symbols.js';
3
- import { binField } from './util/bin-field.js';
3
+ import { binExpr } from './util/bin-expr.js';
4
4
  import { dericheConfig, dericheConv2d } from './util/density.js';
5
5
  import { extentX, extentY, xyext } from './util/extent.js';
6
6
  import { grid2d } from './util/grid.js';
7
7
  import { handleParam } from './util/handle-param.js';
8
8
  import { Mark } from './Mark.js';
9
9
 
10
+ export const DENSITY = 'density';
11
+
10
12
  export class Grid2DMark extends Mark {
11
13
  constructor(type, source, options) {
12
14
  const {
13
- bandwidth = 20,
14
- binType = 'linear',
15
- binWidth = 2,
16
- binPad = 1,
15
+ bandwidth = 0,
16
+ interpolate = 'none',
17
+ pixelSize = 1,
18
+ pad = 1,
19
+ width,
20
+ height,
17
21
  ...channels
18
22
  } = options;
19
23
 
@@ -24,9 +28,11 @@ export class Grid2DMark extends Mark {
24
28
  handleParam(this, 'bandwidth', bandwidth, () => {
25
29
  return this.grids ? this.convolve().update() : null;
26
30
  });
27
- handleParam(this, 'binWidth', binWidth);
28
- handleParam(this, 'binType', binType);
29
- handleParam(this, 'binPad', binPad);
31
+ handleParam(this, 'pixelSize', pixelSize);
32
+ handleParam(this, 'interpolate', interpolate);
33
+ handleParam(this, 'pad', pad);
34
+ handleParam(this, 'width', width);
35
+ handleParam(this, 'height', height);
30
36
  }
31
37
 
32
38
  setPlot(plot, index) {
@@ -43,82 +49,114 @@ export class Grid2DMark extends Mark {
43
49
  }
44
50
 
45
51
  query(filter = []) {
46
- const { plot, binType, binPad, channels, densityMap, source } = this;
52
+ const { interpolate, pad, channels, densityMap, source } = this;
47
53
  const [x0, x1] = this.extentX = extentX(this, filter);
48
54
  const [y0, y1] = this.extentY = extentY(this, filter);
49
55
  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
+ const [x, bx] = binExpr(this, 'x', nx, [x0, x1], pad);
57
+ const [y, by] = binExpr(this, 'y', ny, [y0, y1], pad);
56
58
 
57
59
  // with padded bins, include the entire domain extent
58
60
  // 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)];
61
+ const bounds = pad
62
+ ? [isBetween(bx, [+x0, +x1]), isBetween(by, [+y0, +y1])]
63
+ : [lte(+x0, bx), lt(bx, +x1), lte(+y0, by), lt(by, +y1)];
62
64
 
63
65
  const q = Query
64
66
  .from(source.table)
65
67
  .where(filter.concat(bounds));
66
68
 
67
69
  const groupby = this.groupby = [];
68
- let agg = count();
70
+ const aggrMap = {};
69
71
  for (const c of channels) {
70
72
  if (Object.hasOwn(c, 'field')) {
71
73
  const { as, channel, field } = c;
72
74
  if (field.aggregate) {
73
- agg = field;
75
+ // include custom aggregate
76
+ aggrMap[channel] = field;
74
77
  densityMap[channel] = true;
75
78
  } else if (channel === 'weight') {
76
- agg = sum(field);
79
+ // compute weighted density
80
+ aggrMap[DENSITY] = sum(field);
77
81
  } else if (channel !== 'x' && channel !== 'y') {
82
+ // add groupby field
78
83
  q.select({ [as]: field });
79
84
  groupby.push(as);
80
85
  }
81
86
  }
82
87
  }
88
+ const aggr = this.aggr = Object.keys(aggrMap);
89
+
90
+ // check for incompatible encodings
91
+ if (aggrMap.density && aggr.length > 1) {
92
+ throw new Error('Weight option can not be used with custom aggregates.');
93
+ }
94
+
95
+ // if no aggregates, default to count density
96
+ if (!aggr.length) {
97
+ aggr.push(DENSITY);
98
+ aggrMap.density = count();
99
+ }
83
100
 
84
- return binType === 'linear'
85
- ? binLinear2d(q, x, y, agg, nx, groupby)
86
- : bin2d(q, x, y, agg, nx, groupby);
101
+ // generate grid binning query
102
+ if (interpolate === 'linear') {
103
+ if (aggr.length > 1) {
104
+ throw new Error('Linear binning not applicable to multiple aggregates.');
105
+ }
106
+ if (!aggrMap.density) {
107
+ throw new Error('Linear binning not applicable to custom aggregates.');
108
+ }
109
+ return binLinear2d(q, x, y, aggrMap[DENSITY], nx, groupby);
110
+ } else {
111
+ return bin2d(q, x, y, aggrMap, nx, groupby);
112
+ }
87
113
  }
88
114
 
89
115
  binDimensions() {
90
- const { plot, binWidth } = this;
116
+ const { plot, pixelSize, width, height } = this;
91
117
  return [
92
- Math.round(plot.innerWidth() / binWidth),
93
- Math.round(plot.innerHeight() / binWidth)
118
+ width ?? Math.round(plot.innerWidth() / pixelSize),
119
+ height ?? Math.round(plot.innerHeight() / pixelSize)
94
120
  ];
95
121
  }
96
122
 
97
123
  queryResult(data) {
98
124
  const [nx, ny] = this.bins;
99
- this.grids = grid2d(nx, ny, data, this.groupby);
125
+ this.grids = grid2d(nx, ny, data, this.aggr, this.groupby);
100
126
  return this.convolve();
101
127
  }
102
128
 
103
129
  convolve() {
104
- const { bandwidth, bins, grids, plot } = this;
130
+ const { aggr, bandwidth, bins, grids, plot } = this;
105
131
 
106
- if (bandwidth <= 0) {
107
- this.kde = this.grids.map(({ key, grid }) => {
108
- return (grid.key = key, grid);
109
- });
110
- } else {
132
+ // no smoothing as default fallback
133
+ this.kde = this.grids;
134
+
135
+ if (bandwidth > 0) {
136
+ // determine which grid to smooth
137
+ const gridProp = aggr.length === 1 ? aggr[0]
138
+ : aggr.includes(DENSITY) ? DENSITY
139
+ : null;
140
+
141
+ // bail if no compatible grid found
142
+ if (!gridProp) {
143
+ console.warn('No compatible grid found for smoothing.');
144
+ return this;
145
+ }
146
+
147
+ // apply smoothing, bandwidth uses units of screen pixels
111
148
  const w = plot.innerWidth();
112
149
  const h = plot.innerHeight();
113
150
  const [nx, ny] = bins;
114
- const neg = grids.some(({ grid }) => grid.some(v => v < 0));
151
+ const neg = grids.some(cell => cell[gridProp].some(v => v < 0));
115
152
  const configX = dericheConfig(bandwidth * (nx - 1) / w, neg);
116
153
  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);
154
+ this.kde = this.grids.map(grid => {
155
+ const density = dericheConv2d(configX, configY, grid[gridProp], bins);
156
+ return { ...grid, [gridProp]: density };
120
157
  });
121
158
  }
159
+
122
160
  return this;
123
161
  }
124
162
 
@@ -127,6 +165,9 @@ export class Grid2DMark extends Mark {
127
165
  }
128
166
  }
129
167
 
168
+ /**
169
+ * Extract channels that explicitly encode computed densities.
170
+ */
130
171
  function createDensityMap(channels) {
131
172
  const densityMap = {};
132
173
  for (const key in channels) {
@@ -138,25 +179,17 @@ function createDensityMap(channels) {
138
179
  return densityMap;
139
180
  }
140
181
 
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) {
182
+ function bin2d(q, xp, yp, aggs, xn, groupby) {
150
183
  return q
151
184
  .select({
152
185
  index: sql`FLOOR(${xp})::INTEGER + FLOOR(${yp})::INTEGER * ${xn}`,
153
- value
186
+ ...aggs
154
187
  })
155
188
  .groupby('index', groupby);
156
189
  }
157
190
 
158
- function binLinear2d(q, xp, yp, value, xn, groupby) {
159
- const w = value.column ? `* ${value.column}` : '';
191
+ function binLinear2d(q, xp, yp, density, xn, groupby) {
192
+ const w = density?.column ? `* ${density.column}` : '';
160
193
  const subq = (i, w) => q.clone().select({ xp, yp, i, w });
161
194
 
162
195
  // grid[xu + yu * xn] += (xv - xp) * (yv - yp) * wi;
@@ -185,7 +218,7 @@ function binLinear2d(q, xp, yp, value, xn, groupby) {
185
218
 
186
219
  return Query
187
220
  .from(Query.unionAll(a, b, c, d))
188
- .select({ index: 'i', value: sum('w') }, groupby)
221
+ .select({ index: 'i', density: sum('w') }, groupby)
189
222
  .groupby('index', groupby)
190
- .having(gt('value', 0));
223
+ .having(neq('density', 0));
191
224
  }