@uwdata/mosaic-plot 0.6.0 → 0.7.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.6.0",
3
+ "version": "0.7.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.6.0",
33
- "@uwdata/mosaic-sql": "^0.6.0",
32
+ "@uwdata/mosaic-core": "^0.7.0",
33
+ "@uwdata/mosaic-sql": "^0.7.0",
34
34
  "d3": "^7.8.5",
35
35
  "isoformat": "^0.2.1"
36
36
  },
37
- "gitHead": "51517b28e916e355f4ce0dc6e98aef3a1db3f7b2"
37
+ "gitHead": "4680b922f15579b7b527f31507ed71a12230ec35"
38
38
  }
@@ -21,7 +21,7 @@ export class Interval1D {
21
21
  this.pixelSize = pixelSize || 1;
22
22
  this.selection = selection;
23
23
  this.peers = peers;
24
- this.field = field || getField(mark, [channel, channel+'1', channel+'2']);
24
+ this.field = field || getField(mark, channel);
25
25
  this.style = style && sanitizeStyles(style);
26
26
  this.brush = channel === 'y' ? brushY() : brushX();
27
27
  this.brush.on('brush end', ({ selection }) => this.publish(selection));
@@ -22,8 +22,8 @@ export class Interval2D {
22
22
  this.pixelSize = pixelSize || 1;
23
23
  this.selection = selection;
24
24
  this.peers = peers;
25
- this.xfield = xfield || getField(mark, ['x', 'x1', 'x2']);
26
- this.yfield = yfield || getField(mark, ['y', 'y1', 'y2']);
25
+ this.xfield = xfield || getField(mark, 'x');
26
+ this.yfield = yfield || getField(mark, 'y');
27
27
  this.style = style && sanitizeStyles(style);
28
28
  this.brush = brush();
29
29
  this.brush.on('brush end', ({ selection }) => this.publish(selection));
@@ -18,8 +18,8 @@ export class PanZoom {
18
18
  this.mark = mark;
19
19
  this.xsel = x;
20
20
  this.ysel = y;
21
- this.xfield = xfield || getField(mark, ['x', 'x1', 'x2']);
22
- this.yfield = yfield || getField(mark, ['y', 'y1', 'y2']);
21
+ this.xfield = xfield || getField(mark, 'x');
22
+ this.yfield = yfield || getField(mark, 'y');
23
23
  this.zoom = extent(zoom, [0, Infinity], [1, 1]);
24
24
  this.panx = this.xsel && panx;
25
25
  this.pany = this.ysel && pany;
@@ -1,4 +1,4 @@
1
- export function getField(mark, channels) {
2
- const field = mark.channelField(channels)?.field;
1
+ export function getField(mark, channel) {
2
+ const field = mark.channelField(channel)?.field;
3
3
  return field?.basis || field;
4
4
  }
package/src/legend.js CHANGED
@@ -56,8 +56,10 @@ function findMark({ marks }, channel) {
56
56
  : null;
57
57
  if (channels == null) return null;
58
58
  for (let i = marks.length - 1; i > -1; --i) {
59
- if (marks[i].channelField(channels)) {
60
- return marks[i];
59
+ for (const channel of channels) {
60
+ if (marks[i].channelField(channel, { exact: true })) {
61
+ return marks[i];
62
+ }
61
63
  }
62
64
  }
63
65
  return null;
@@ -6,29 +6,26 @@ import { Mark } from './Mark.js';
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]: ['min', 'max'] };
9
+ const req = dim ? { [dim]: ['min', 'max'] } : undefined;
10
10
  super(type, source, encodings, req);
11
11
  this.dim = dim;
12
12
  }
13
13
 
14
14
  query(filter = []) {
15
- const { plot, dim, source, stats } = this;
15
+ const { plot, dim, source } = this;
16
16
  const { optimize = true } = source.options || {};
17
17
  const q = super.query(filter);
18
18
  if (!dim) return q;
19
19
 
20
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];
21
+ const value = this.channelField(ortho, { exact: true })?.as;
22
+ const { field, as, type, min, max } = this.channelField(dim);
24
23
  const isContinuous = type === 'date' || type === 'number';
25
24
 
26
25
  if (optimize && isContinuous && value) {
27
26
  // TODO: handle stacked data!
28
- const { column } = field;
29
- const { max, min } = stats[column];
30
27
  const size = dim === 'x' ? plot.innerWidth() : plot.innerHeight();
31
- const [lo, hi] = filteredExtent(filter, column) || [min, max];
28
+ const [lo, hi] = filteredExtent(filter, field) || [min, max];
32
29
  const [expr] = binExpr(this, dim, size, [lo, hi], 1, as);
33
30
  const cols = q.select()
34
31
  .map(c => c.as)
@@ -47,10 +47,17 @@ export class DenseLineMark extends RasterMark {
47
47
  function stripXY(mark, filter) {
48
48
  if (Array.isArray(filter) && !filter.length) return filter;
49
49
 
50
- const xc = mark.channelField('x').field.column;
51
- const yc = mark.channelField('y').field.column;
52
- const test = p => p.op !== 'BETWEEN'
53
- || p.field.column !== xc && p.field.column !== yc;
50
+ // get column expressions for x and y encoding channels
51
+ const { column: xc } = mark.channelField('x');
52
+ const { column: yc } = mark.channelField('y');
53
+
54
+ // test if a range predicate filters the x or y channels
55
+ const test = p => {
56
+ const col = `${p.field}`;
57
+ return p.op !== 'BETWEEN' || col !== xc && col !== yc;
58
+ };
59
+
60
+ // filter boolean 'and' operations
54
61
  const filterAnd = p => p.op === 'AND'
55
62
  ? and(p.children.filter(c => test(c)))
56
63
  : p;
@@ -5,6 +5,9 @@ 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
+ import {
9
+ interpolateNearest, interpolatorBarycentric, interpolatorRandomWalk
10
+ } from './util/interpolate.js';
8
11
  import { Mark } from './Mark.js';
9
12
 
10
13
  export const DENSITY = 'density';
@@ -36,7 +39,7 @@ export class Grid2DMark extends Mark {
36
39
  }
37
40
 
38
41
  setPlot(plot, index) {
39
- const update = () => { if (this.stats) this.requestUpdate(); };
42
+ const update = () => { if (this.hasFieldInfo()) this.requestUpdate(); };
40
43
  plot.addAttributeListener('domainX', update);
41
44
  plot.addAttributeListener('domainY', update);
42
45
  return super.setPlot(plot, index);
@@ -121,8 +124,9 @@ export class Grid2DMark extends Mark {
121
124
  }
122
125
 
123
126
  queryResult(data) {
124
- const [nx, ny] = this.bins;
125
- this.grids = grid2d(nx, ny, data, this.aggr, this.groupby);
127
+ const [w, h] = this.bins;
128
+ const interp = maybeInterpolate(this.interpolate);
129
+ this.grids = grid2d(w, h, data, this.aggr, this.groupby, interp);
126
130
  return this.convolve();
127
131
  }
128
132
 
@@ -179,6 +183,22 @@ function createDensityMap(channels) {
179
183
  return densityMap;
180
184
  }
181
185
 
186
+ function maybeInterpolate(interpolate = 'none') {
187
+ if (typeof interpolate === 'function') return interpolate;
188
+ switch (`${interpolate}`.toLowerCase()) {
189
+ case 'none':
190
+ case 'linear':
191
+ return undefined; // no special interpolation need
192
+ case 'nearest':
193
+ return interpolateNearest;
194
+ case 'barycentric':
195
+ return interpolatorBarycentric();
196
+ case 'random-walk':
197
+ return interpolatorRandomWalk();
198
+ }
199
+ throw new Error(`invalid interpolate: ${interpolate}`);
200
+ }
201
+
182
202
  function bin2d(q, xp, yp, aggs, xn, groupby) {
183
203
  return q
184
204
  .select({
package/src/marks/Mark.js CHANGED
@@ -19,6 +19,8 @@ const fieldEntry = (channel, field) => ({
19
19
  });
20
20
  const valueEntry = (channel, value) => ({ channel, value });
21
21
 
22
+ // checks if a data source is an explicit array of values
23
+ // as opposed to a database table refernece
22
24
  export const isDataArray = source => Array.isArray(source);
23
25
 
24
26
  export class Mark extends MosaicClient {
@@ -94,45 +96,47 @@ export class Mark extends MosaicClient {
94
96
  return this.source == null || isDataArray(this.source);
95
97
  }
96
98
 
99
+ hasFieldInfo() {
100
+ return !!this._fieldInfo;
101
+ }
102
+
97
103
  channel(channel) {
98
104
  return this.channels.find(c => c.channel === channel);
99
105
  }
100
106
 
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;
107
+ channelField(channel, { exact } = {}) {
108
+ const c = exact
109
+ ? this.channel(channel)
110
+ : this.channels.find(c => c.channel.startsWith(channel));
111
+ return c?.field ? c : null;
108
112
  }
109
113
 
110
114
  fields() {
111
115
  if (this.hasOwnData()) return null;
112
- const { source: { table }, channels, reqs } = this;
113
116
 
117
+ const { source: { table }, channels, reqs } = this;
114
118
  const fields = new Map;
115
119
  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
- }
120
+ if (!field) continue;
121
+ const stats = field.stats?.stats || [];
122
+ const key = field.stats?.column ?? field;
123
+ const entry = fields.get(key) ?? fields.set(key, new Set).get(key);
124
+ stats.forEach(s => entry.add(s));
125
+ reqs[channel]?.forEach(s => entry.add(s));
125
126
  }
126
- return Array.from(fields, ([column, stats]) => {
127
- return { table, column, stats: Array.from(stats) };
128
- });
127
+
128
+ return Array.from(fields, ([c, s]) => ({ table, column: c, stats: s }));
129
129
  }
130
130
 
131
131
  fieldInfo(info) {
132
- this.stats = info.reduce(
133
- (o, d) => (o[d.column] = d, o),
134
- Object.create(null)
135
- );
132
+ const lookup = Object.fromEntries(info.map(x => [x.column, x]));
133
+ for (const entry of this.channels) {
134
+ const { field } = entry;
135
+ if (field) {
136
+ Object.assign(entry, lookup[field.stats?.column ?? field]);
137
+ }
138
+ }
139
+ this._fieldInfo = true;
136
140
  return this;
137
141
  }
138
142
 
@@ -169,6 +173,13 @@ export class Mark extends MosaicClient {
169
173
  }
170
174
  }
171
175
 
176
+ /**
177
+ * Helper method for setting a channel option in a Plot specification.
178
+ * Checks if a constant value or a data field is needed.
179
+ * Also avoids misinterpretation of data values as color names.
180
+ * @param {*} c a visual encoding channel spec
181
+ * @returns the Plot channel option
182
+ */
172
183
  export function channelOption(c) {
173
184
  // use a scale override for color channels to sidestep
174
185
  // https://github.com/observablehq/plot/issues/1593
@@ -177,6 +188,16 @@ export function channelOption(c) {
177
188
  : c.as;
178
189
  }
179
190
 
191
+ /**
192
+ * Default query construction for a mark.
193
+ * Tracks aggregates by checking fields for an aggregate flag.
194
+ * If aggregates are found, groups by all non-aggregate fields.
195
+ * @param {*} channels array of visual encoding channel specs.
196
+ * @param {*} table the table to query.
197
+ * @param {*} skip an optional array of channels to skip.
198
+ * Mark subclasses can skip channels that require special handling.
199
+ * @returns a Query instance
200
+ */
180
201
  export function markQuery(channels, table, skip = []) {
181
202
  const q = Query.from({ source: table });
182
203
  const dims = new Set;
@@ -23,7 +23,7 @@ export class RasterMark extends Grid2DMark {
23
23
  }
24
24
 
25
25
  setPlot(plot, index) {
26
- const update = () => { if (this.stats) this.rasterize(); };
26
+ const update = () => { if (this.hasFieldInfo()) this.rasterize(); };
27
27
  plot.addAttributeListener('schemeColor', update);
28
28
  super.setPlot(plot, index);
29
29
  }
@@ -18,7 +18,7 @@ export class RasterTileMark extends Grid2DMark {
18
18
  }
19
19
 
20
20
  setPlot(plot, index) {
21
- const update = () => { if (this.stats) this.rasterize(); };
21
+ const update = () => { if (this.hasFieldInfo()) this.rasterize(); };
22
22
  plot.addAttributeListener('schemeColor', update);
23
23
  super.setPlot(plot, index);
24
24
  }
@@ -5,8 +5,7 @@ export function channelScale(mark, channel) {
5
5
 
6
6
  let scaleType = plot.getAttribute(`${channel}Scale`);
7
7
  if (!scaleType) {
8
- const { field } = mark.channelField(channel, `${channel}1`, `${channel}2`);
9
- const { type } = mark.stats[field.column];
8
+ const { type } = mark.channelField(channel);
10
9
  scaleType = type === 'date' ? 'time' : 'linear';
11
10
  }
12
11
 
@@ -96,7 +96,7 @@ export function dericheConv2d(cx, cy, grid, [nx, ny]) {
96
96
  // allocate buffers
97
97
  const yc = new Float64Array(Math.max(nx, ny)); // causal
98
98
  const ya = new Float64Array(Math.max(nx, ny)); // anticausal
99
- const h = new Float64Array(5);
99
+ const h = new Float64Array(5); // q + 1
100
100
  const d = new Float64Array(grid.length);
101
101
 
102
102
  // convolve rows
@@ -119,7 +119,7 @@ export function dericheConv1d(
119
119
  stride = 1,
120
120
  y_causal = new Float64Array(N),
121
121
  y_anticausal = new Float64Array(N),
122
- h = new Float64Array(5),
122
+ h = new Float64Array(5), // q + 1
123
123
  d = y_causal,
124
124
  init = dericheInitZeroPad
125
125
  ) {
@@ -153,6 +153,7 @@ export function dericheConv1d(
153
153
  }
154
154
 
155
155
  // initialize the anticausal filter on the right boundary
156
+ // dest, src, N, stride, b, p, a, q, sum, h
156
157
  init(
157
158
  y_anticausal, src, N, -stride,
158
159
  c.b_anticausal, 4, c.a, 4, c.sum_anticausal, h, c.sigma
@@ -176,7 +177,7 @@ export function dericheConv1d(
176
177
 
177
178
  // sum the causal and anticausal responses to obtain the final result
178
179
  if (c.negative) {
179
- // do not threshold if the input grid includes negatively weighted values
180
+ // do not threshold if the input grid includes negative values
180
181
  for (n = 0, i = 0; n < N; ++n, i += stride) {
181
182
  d[i] = y_causal[n] + y_anticausal[N - n - 1];
182
183
  }
@@ -190,13 +191,16 @@ export function dericheConv1d(
190
191
  return d;
191
192
  }
192
193
 
193
- export function dericheInitZeroPad(dest, src, N, stride, b, p, a, q, sum, h) {
194
+ export function dericheInitZeroPad(
195
+ dest, src, N, stride, b, p, a, q,
196
+ sum, h, sigma, tol = 0.5
197
+ ) {
194
198
  const stride_N = Math.abs(stride) * N;
195
199
  const off = stride < 0 ? stride_N + stride : 0;
196
200
  let i, n, m;
197
201
 
198
202
  // compute the first q taps of the impulse response, h_0, ..., h_{q-1}
199
- for (n = 0; n < q; ++n) {
203
+ for (n = 0; n <= q; ++n) {
200
204
  h[n] = (n <= p) ? b[n] : 0;
201
205
  for (m = 1; m <= q && m <= n; ++m) {
202
206
  h[n] -= a[m] * h[n - m];
@@ -214,12 +218,27 @@ export function dericheInitZeroPad(dest, src, N, stride, b, p, a, q, sum, h) {
214
218
  }
215
219
  }
216
220
 
217
- // dest_m = dest_m + h_{n+m} src_{-n}
218
221
  const cur = src[off];
219
- if (cur > 0) {
222
+ const max_iter = Math.ceil(sigma * 10);
223
+ for (n = 0; n < max_iter; ++n) {
224
+ /* dest_m = dest_m + h_{n+m} src_{-n} */
220
225
  for (m = 0; m < q; ++m) {
221
226
  dest[m] += h[m] * cur;
222
227
  }
228
+
229
+ sum -= Math.abs(h[0]);
230
+ if (sum <= tol) break;
231
+
232
+ /* Compute the next impulse response tap, h_{n+q} */
233
+ h[q] = (n + q <= p) ? b[n + q] : 0;
234
+ for (m = 1; m <= q; ++m) {
235
+ h[q] -= a[m] * h[q - m];
236
+ }
237
+
238
+ /* Shift the h array for the next iteration */
239
+ for (m = 0; m < q; ++m) {
240
+ h[m] = h[m + 1];
241
+ }
223
242
  }
224
243
 
225
244
  return;
@@ -6,16 +6,14 @@ export const yext = { y: ['min', 'max'] };
6
6
  export const xyext = { ...xext, ...yext };
7
7
 
8
8
  export function plotExtent(mark, filter, channel, domainAttr, niceAttr) {
9
- const { plot, stats } = mark;
9
+ const { plot } = mark;
10
10
  const domain = plot.getAttribute(domainAttr);
11
11
  const nice = plot.getAttribute(niceAttr);
12
12
 
13
13
  if (Array.isArray(domain) && !domain[Transient]) {
14
14
  return domain;
15
15
  } else {
16
- const { field } = mark.channelField(channel);
17
- const { column } = field;
18
- const { min, max } = stats[column];
16
+ const { column, min, max } = mark.channelField(channel);
19
17
  const dom = filteredExtent(filter, column) || (nice
20
18
  ? scaleLinear().domain([min, max]).nice().domain()
21
19
  : [min, max]);
@@ -39,7 +37,7 @@ export function filteredExtent(filter, column) {
39
37
  let lo;
40
38
  let hi;
41
39
  const visitor = (type, clause) => {
42
- if (type === 'BETWEEN' && clause.field.column === column) {
40
+ if (type === 'BETWEEN' && `${clause.field}` === column) {
43
41
  const { range } = clause;
44
42
  if (range && (lo == null || range[0] < lo)) lo = range[0];
45
43
  if (range && (hi == null || range[1] > hi)) hi = range[1];
@@ -1,48 +1,24 @@
1
1
  import { InternSet, ascending } from 'd3';
2
- import { DECIMAL, FLOAT, INTEGER, isArrowTable } from './arrow.js';
2
+ import {
3
+ convertArrowArrayType,
4
+ convertArrowColumn,
5
+ isArrowTable
6
+ } from '@uwdata/mosaic-core';
3
7
 
4
8
  function arrayType(values, name = 'density') {
5
- if (isArrowTable(values)) {
6
- const type = values.getChild(name).type;
7
- switch (type.typeId) {
8
- case INTEGER:
9
- case FLOAT:
10
- case DECIMAL:
11
- return Float64Array;
12
- default:
13
- return Array;
14
- }
15
- } else {
16
- return typeof values[0][name] === 'number' ? Float64Array : Array;
17
- }
18
- }
19
-
20
- export function grid1d(n, values) {
21
- const Type = arrayType(values);
22
- return valuesToGrid(new Type(n), values);
9
+ return isArrowTable(values)
10
+ ? convertArrowArrayType(values.getChild(name).type)
11
+ : typeof values[0]?.[name] === 'number' ? Float64Array : Array;
23
12
  }
24
13
 
25
- export function grid2d(m, n, values, aggr, groupby = []) {
26
- if (groupby.length) {
27
- // generate grids per group
28
- return groupedValuesToGrids(m * n, values, aggr, groupby);
29
- } else {
30
- const cell = {};
31
- aggr.forEach(name => {
32
- const Type = arrayType(values, name);
33
- cell[name] = valuesToGrid(new Type(m * n), values, name);
34
- });
35
- return [cell];
36
- }
37
- }
38
-
39
- function valuesToGrid(grid, values, name = 'density') {
14
+ export function grid1d(n, values, name = 'density') {
15
+ const grid = new (arrayType(values))(n);
40
16
  if (isArrowTable(values)) {
41
17
  // optimize access for Arrow tables
42
18
  const numRows = values.numRows;
43
19
  if (numRows === 0) return grid;
44
- const index = values.getChild('index').toArray();
45
- const value = values.getChild(name).toArray();
20
+ const index = convertArrowColumn(values.getChild('index'));
21
+ const value = convertArrowColumn(values.getChild(name));
46
22
  for (let row = 0; row < numRows; ++row) {
47
23
  grid[index[row]] = value[row];
48
24
  }
@@ -55,30 +31,35 @@ function valuesToGrid(grid, values, name = 'density') {
55
31
  return grid;
56
32
  }
57
33
 
58
- function groupedValuesToGrids(size, values, aggr, groupby) {
34
+ export function grid2d(w, h, values, aggr, groupby = [], interpolate) {
35
+ const size = w * h;
59
36
  const Types = aggr.map(name => arrayType(values, name));
60
37
  const numAggr = aggr.length;
61
38
 
62
- const cellMap = {};
63
- const getCell = key => {
64
- let cell = cellMap[key];
65
- if (!cell) {
66
- cell = cellMap[key] = {};
67
- groupby.forEach((name, i) => cell[name] = key[i]);
68
- aggr.forEach((name, i) => cell[name] = new Types[i](size));
69
- }
39
+ // grid data tuples
40
+ const createCell = (key) => {
41
+ const cell = {};
42
+ groupby.forEach((name, i) => cell[name] = key[i]);
43
+ aggr.forEach((name, i) => cell[name] = new Types[i](size));
70
44
  return cell;
71
45
  };
46
+ const cellMap = {};
47
+ const baseCell = groupby.length ? null : (cellMap[[]] = createCell([]));
48
+ const getCell = groupby.length
49
+ ? key => cellMap[key] ?? (cellMap[key] = createCell(key))
50
+ : () => baseCell;
72
51
 
73
- if (isArrowTable(values)) {
74
- // optimize access for Arrow tables
75
- const numRows = values.numRows;
76
- if (numRows === 0) return [];
52
+ // early exit if empty query result
53
+ const numRows = values.numRows;
54
+ if (numRows === 0) return Object.values(cellMap);
77
55
 
78
- const index = values.getChild('index').toArray();
79
- const value = aggr.map(name => values.getChild(name).toArray());
80
- const groups = groupby.map(name => values.getChild(name));
56
+ // extract arrays from arrow table
57
+ const index = convertArrowColumn(values.getChild('index'));
58
+ const value = aggr.map(name => convertArrowColumn(values.getChild(name)));
59
+ const groups = groupby.map(name => values.getChild(name));
81
60
 
61
+ if (!interpolate) {
62
+ // if no interpolation, copy values over
82
63
  for (let row = 0; row < numRows; ++row) {
83
64
  const key = groups.map(vec => vec.get(row));
84
65
  const cell = getCell(key);
@@ -87,14 +68,25 @@ function groupedValuesToGrids(size, values, aggr, groupby) {
87
68
  }
88
69
  }
89
70
  } else {
90
- // fallback to iterable data
91
- for (const row of values) {
92
- const key = groupby.map(col => row[col]);
93
- const cell = getCell(key);
94
- for (let i = 0; i < numAggr; ++i) {
95
- cell[aggr[i]][row.index] = row[aggr[i]];
71
+ // prepare index arrays, then interpolate grid values
72
+ const X = index.map(k => k % w);
73
+ const Y = index.map(k => Math.floor(k / w));
74
+ if (groupby.length) {
75
+ for (let row = 0; row < numRows; ++row) {
76
+ const key = groups.map(vec => vec.get(row));
77
+ const cell = getCell(key);
78
+ if (!cell.index) { cell.index = []; }
79
+ cell.index.push(row);
96
80
  }
81
+ } else {
82
+ baseCell.index = index.map((_, i) => i);
97
83
  }
84
+ Object.values(cellMap).forEach(cell => {
85
+ for (let i = 0; i < numAggr; ++i) {
86
+ interpolate(cell.index, w, h, X, Y, value[i], cell[aggr[i]]);
87
+ }
88
+ delete cell.index;
89
+ })
98
90
  }
99
91
 
100
92
  return Object.values(cellMap);