@uwdata/mosaic-plot 0.6.0 → 0.6.1

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.6.1",
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",
32
+ "@uwdata/mosaic-core": "^0.6.1",
33
33
  "@uwdata/mosaic-sql": "^0.6.0",
34
34
  "d3": "^7.8.5",
35
35
  "isoformat": "^0.2.1"
36
36
  },
37
- "gitHead": "51517b28e916e355f4ce0dc6e98aef3a1db3f7b2"
37
+ "gitHead": "9e788e6dc5241fa1c54967a25fd9599f97da1a41"
38
38
  }
@@ -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';
@@ -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({
@@ -7,6 +7,17 @@ export function isArrowTable(values) {
7
7
  return typeof values?.getChild === 'function';
8
8
  }
9
9
 
10
+ export function convertArrowType(type) {
11
+ switch (type.typeId) {
12
+ case INTEGER:
13
+ case FLOAT:
14
+ case DECIMAL:
15
+ return Float64Array;
16
+ default:
17
+ return Array;
18
+ }
19
+ }
20
+
10
21
  export function convertArrow(type) {
11
22
  const { typeId } = type;
12
23
 
@@ -23,3 +34,22 @@ export function convertArrow(type) {
23
34
  // otherwise use Arrow JS defaults
24
35
  return v => v;
25
36
  }
37
+
38
+ export function convertArrowColumn(column) {
39
+ const { type } = column;
40
+ const { typeId } = type;
41
+
42
+ // map bignum to number
43
+ if (typeId === INTEGER && type.bitWidth >= 64) {
44
+ const size = column.length;
45
+ const array = new Float64Array(size);
46
+ for (let row = 0; row < size; ++row) {
47
+ const v = column.get(row);
48
+ array[row] = v == null ? NaN : Number(v);
49
+ }
50
+ return array;
51
+ }
52
+
53
+ // otherwise use Arrow JS defaults
54
+ return column.toArray();
55
+ }
@@ -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;
@@ -1,17 +1,9 @@
1
1
  import { InternSet, ascending } from 'd3';
2
- import { DECIMAL, FLOAT, INTEGER, isArrowTable } from './arrow.js';
2
+ import { convertArrowColumn, convertArrowType, isArrowTable } from './arrow.js';
3
3
 
4
4
  function arrayType(values, name = 'density') {
5
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
- }
6
+ return convertArrowType(values.getChild(name).type);
15
7
  } else {
16
8
  return typeof values[0][name] === 'number' ? Float64Array : Array;
17
9
  }
@@ -22,27 +14,13 @@ export function grid1d(n, values) {
22
14
  return valuesToGrid(new Type(n), values);
23
15
  }
24
16
 
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
17
  function valuesToGrid(grid, values, name = 'density') {
40
18
  if (isArrowTable(values)) {
41
19
  // optimize access for Arrow tables
42
20
  const numRows = values.numRows;
43
21
  if (numRows === 0) return grid;
44
- const index = values.getChild('index').toArray();
45
- const value = values.getChild(name).toArray();
22
+ const index = convertArrowColumn(values.getChild('index'));
23
+ const value = convertArrowColumn(values.getChild(name));
46
24
  for (let row = 0; row < numRows; ++row) {
47
25
  grid[index[row]] = value[row];
48
26
  }
@@ -55,30 +33,35 @@ function valuesToGrid(grid, values, name = 'density') {
55
33
  return grid;
56
34
  }
57
35
 
58
- function groupedValuesToGrids(size, values, aggr, groupby) {
36
+ export function grid2d(w, h, values, aggr, groupby = [], interpolate) {
37
+ const size = w * h;
59
38
  const Types = aggr.map(name => arrayType(values, name));
60
39
  const numAggr = aggr.length;
61
40
 
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
- }
41
+ // grid data tuples
42
+ const createCell = (key) => {
43
+ const cell = {};
44
+ groupby.forEach((name, i) => cell[name] = key[i]);
45
+ aggr.forEach((name, i) => cell[name] = new Types[i](size));
70
46
  return cell;
71
47
  };
48
+ const cellMap = {};
49
+ const baseCell = groupby.length ? null : (cellMap[[]] = createCell([]));
50
+ const getCell = groupby.length
51
+ ? key => cellMap[key] ?? (cellMap[key] = createCell(key))
52
+ : () => baseCell;
72
53
 
73
- if (isArrowTable(values)) {
74
- // optimize access for Arrow tables
75
- const numRows = values.numRows;
76
- if (numRows === 0) return [];
54
+ // early exit if empty query result
55
+ const numRows = values.numRows;
56
+ if (numRows === 0) return Object.values(cellMap);
77
57
 
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));
58
+ // extract arrays from arrow table
59
+ const index = convertArrowColumn(values.getChild('index'));
60
+ const value = aggr.map(name => convertArrowColumn(values.getChild(name)));
61
+ const groups = groupby.map(name => values.getChild(name));
81
62
 
63
+ if (!interpolate) {
64
+ // if no interpolation, copy values over
82
65
  for (let row = 0; row < numRows; ++row) {
83
66
  const key = groups.map(vec => vec.get(row));
84
67
  const cell = getCell(key);
@@ -87,14 +70,25 @@ function groupedValuesToGrids(size, values, aggr, groupby) {
87
70
  }
88
71
  }
89
72
  } 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]];
73
+ // prepare index arrays, then interpolate grid values
74
+ const X = index.map(k => k % w);
75
+ const Y = index.map(k => Math.floor(k / w));
76
+ if (groupby.length) {
77
+ for (let row = 0; row < numRows; ++row) {
78
+ const key = groups.map(vec => vec.get(row));
79
+ const cell = getCell(key);
80
+ if (!cell.index) { cell.index = []; }
81
+ cell.index.push(row);
96
82
  }
83
+ } else {
84
+ baseCell.index = index.map((_, i) => i);
97
85
  }
86
+ Object.values(cellMap).forEach(cell => {
87
+ for (let i = 0; i < numAggr; ++i) {
88
+ interpolate(cell.index, w, h, X, Y, value[i], cell[aggr[i]]);
89
+ }
90
+ delete cell.index;
91
+ })
98
92
  }
99
93
 
100
94
  return Object.values(cellMap);
@@ -0,0 +1,196 @@
1
+ import { Delaunay, randomLcg } from 'd3';
2
+
3
+ export function interpolatorBarycentric({random = randomLcg(42)} = {}) {
4
+ return (index, width, height, X, Y, V, W) => {
5
+ // Interpolate the interior of all triangles with barycentric coordinates
6
+ const {points, triangles, hull} = Delaunay.from(index, (i) => X[i], (i) => Y[i]);
7
+ const S = new Uint8Array(width * height); // 1 if pixel has been seen.
8
+ const mix = mixer(V, random);
9
+
10
+ for (let i = 0; i < triangles.length; i += 3) {
11
+ const ta = triangles[i];
12
+ const tb = triangles[i + 1];
13
+ const tc = triangles[i + 2];
14
+ const Ax = points[2 * ta];
15
+ const Bx = points[2 * tb];
16
+ const Cx = points[2 * tc];
17
+ const Ay = points[2 * ta + 1];
18
+ const By = points[2 * tb + 1];
19
+ const Cy = points[2 * tc + 1];
20
+ const x1 = Math.min(Ax, Bx, Cx);
21
+ const x2 = Math.max(Ax, Bx, Cx);
22
+ const y1 = Math.min(Ay, By, Cy);
23
+ const y2 = Math.max(Ay, By, Cy);
24
+ const z = (By - Cy) * (Ax - Cx) + (Ay - Cy) * (Cx - Bx);
25
+ if (!z) continue;
26
+ const va = V[index[ta]];
27
+ const vb = V[index[tb]];
28
+ const vc = V[index[tc]];
29
+ for (let x = Math.floor(x1); x < x2; ++x) {
30
+ for (let y = Math.floor(y1); y < y2; ++y) {
31
+ if (x < 0 || x >= width || y < 0 || y >= height) continue;
32
+ const xp = x + 0.5; // sample pixel centroids
33
+ const yp = y + 0.5;
34
+ const ga = ((By - Cy) * (xp - Cx) + (yp - Cy) * (Cx - Bx)) / z;
35
+ if (ga < 0) continue;
36
+ const gb = ((Cy - Ay) * (xp - Cx) + (yp - Cy) * (Ax - Cx)) / z;
37
+ if (gb < 0) continue;
38
+ const gc = 1 - ga - gb;
39
+ if (gc < 0) continue;
40
+ const i = x + width * y;
41
+ W[i] = mix(va, ga, vb, gb, vc, gc, x, y);
42
+ S[i] = 1;
43
+ }
44
+ }
45
+ }
46
+ extrapolateBarycentric(W, S, X, Y, V, width, height, hull, index, mix);
47
+ return W;
48
+ };
49
+ }
50
+
51
+ // Extrapolate by finding the closest point on the hull.
52
+ function extrapolateBarycentric(W, S, X, Y, V, width, height, hull, index, mix) {
53
+ X = Float64Array.from(hull, (i) => X[index[i]]);
54
+ Y = Float64Array.from(hull, (i) => Y[index[i]]);
55
+ V = Array.from(hull, (i) => V[index[i]]);
56
+ const n = X.length;
57
+ const rays = Array.from({length: n}, (_, j) => ray(j, X, Y));
58
+ let k = 0;
59
+ for (let y = 0; y < height; ++y) {
60
+ const yp = y + 0.5;
61
+ for (let x = 0; x < width; ++x) {
62
+ const i = x + width * y;
63
+ if (!S[i]) {
64
+ const xp = x + 0.5;
65
+ for (let l = 0; l < n; ++l) {
66
+ const j = (n + k + (l % 2 ? (l + 1) / 2 : -l / 2)) % n;
67
+ if (rays[j](xp, yp)) {
68
+ const t = segmentProject(X.at(j - 1), Y.at(j - 1), X[j], Y[j], xp, yp);
69
+ W[i] = mix(V.at(j - 1), t, V[j], 1 - t, V[j], 0, x, y);
70
+ k = j;
71
+ break;
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ // Projects a point p = [x, y] onto the line segment [p1, p2], returning the
80
+ // projected coordinates p’ as t in [0, 1] with p’ = t p1 + (1 - t) p2.
81
+ function segmentProject(x1, y1, x2, y2, x, y) {
82
+ const dx = x2 - x1;
83
+ const dy = y2 - y1;
84
+ const a = dx * (x2 - x) + dy * (y2 - y);
85
+ const b = dx * (x - x1) + dy * (y - y1);
86
+ return a > 0 && b > 0 ? a / (a + b) : +(a > b);
87
+ }
88
+
89
+ function cross(xa, ya, xb, yb) {
90
+ return xa * yb - xb * ya;
91
+ }
92
+
93
+ function ray(j, X, Y) {
94
+ const n = X.length;
95
+ const xc = X.at(j - 2);
96
+ const yc = Y.at(j - 2);
97
+ const xa = X.at(j - 1);
98
+ const ya = Y.at(j - 1);
99
+ const xb = X[j];
100
+ const yb = Y[j];
101
+ const xd = X.at(j + 1 - n);
102
+ const yd = Y.at(j + 1 - n);
103
+ const dxab = xa - xb;
104
+ const dyab = ya - yb;
105
+ const dxca = xc - xa;
106
+ const dyca = yc - ya;
107
+ const dxbd = xb - xd;
108
+ const dybd = yb - yd;
109
+ const hab = Math.hypot(dxab, dyab);
110
+ const hca = Math.hypot(dxca, dyca);
111
+ const hbd = Math.hypot(dxbd, dybd);
112
+ return (x, y) => {
113
+ const dxa = x - xa;
114
+ const dya = y - ya;
115
+ const dxb = x - xb;
116
+ const dyb = y - yb;
117
+ return (
118
+ cross(dxa, dya, dxb, dyb) > -1e-6 &&
119
+ cross(dxa, dya, dxab, dyab) * hca - cross(dxa, dya, dxca, dyca) * hab > -1e-6 &&
120
+ cross(dxb, dyb, dxbd, dybd) * hab - cross(dxb, dyb, dxab, dyab) * hbd <= 0
121
+ );
122
+ };
123
+ }
124
+
125
+ export function interpolateNearest(index, width, height, X, Y, V, W) {
126
+ const delaunay = Delaunay.from(index, (i) => X[i], (i) => Y[i]);
127
+ // memoization of delaunay.find for the line start (iy) and pixel (ix)
128
+ let iy, ix;
129
+ for (let y = 0.5, k = 0; y < height; ++y) {
130
+ ix = iy;
131
+ for (let x = 0.5; x < width; ++x, ++k) {
132
+ ix = delaunay.find(x, y, ix);
133
+ if (x === 0.5) iy = ix;
134
+ W[k] = V[index[ix]];
135
+ }
136
+ }
137
+ return W;
138
+ }
139
+
140
+ // https://observablehq.com/@observablehq/walk-on-spheres-precision
141
+ export function interpolatorRandomWalk({random = randomLcg(42), minDistance = 0.5, maxSteps = 2} = {}) {
142
+ return (index, width, height, X, Y, V, W) => {
143
+ const delaunay = Delaunay.from(index, (i) => X[i], (i) => Y[i]);
144
+ // memoization of delaunay.find for the line start (iy), pixel (ix), and wos step (iw)
145
+ let iy, ix, iw;
146
+ for (let y = 0.5, k = 0; y < height; ++y) {
147
+ ix = iy;
148
+ for (let x = 0.5; x < width; ++x, ++k) {
149
+ let cx = x;
150
+ let cy = y;
151
+ iw = ix = delaunay.find(cx, cy, ix);
152
+ if (x === 0.5) iy = ix;
153
+ let distance; // distance to closest sample
154
+ let step = 0; // count of steps for this walk
155
+ while ((distance = Math.hypot(X[index[iw]] - cx, Y[index[iw]] - cy)) > minDistance && step < maxSteps) {
156
+ const angle = random(x, y, step) * 2 * Math.PI;
157
+ cx += Math.cos(angle) * distance;
158
+ cy += Math.sin(angle) * distance;
159
+ iw = delaunay.find(cx, cy, iw);
160
+ ++step;
161
+ }
162
+ W[k] = V[index[iw]];
163
+ }
164
+ }
165
+ return W;
166
+ };
167
+ }
168
+
169
+ function blend(a, ca, b, cb, c, cc) {
170
+ return ca * a + cb * b + cc * c;
171
+ }
172
+
173
+ function pick(random) {
174
+ return (a, ca, b, cb, c, cc, x, y) => {
175
+ const u = random(x, y);
176
+ return u < ca ? a : u < ca + cb ? b : c;
177
+ };
178
+ }
179
+
180
+ function mixer(F, random) {
181
+ return isNumeric(F) || isTemporal(F) ? blend : pick(random);
182
+ }
183
+
184
+ function isNumeric(values) {
185
+ for (const value of values) {
186
+ if (value == null) continue;
187
+ return typeof value === "number";
188
+ }
189
+ }
190
+
191
+ function isTemporal(values) {
192
+ for (const value of values) {
193
+ if (value == null) continue;
194
+ return value instanceof Date;
195
+ }
196
+ }
package/src/plot.js CHANGED
@@ -39,9 +39,14 @@ export class Plot {
39
39
  return this.getAttribute('width') - left - right;
40
40
  }
41
41
 
42
- innerHeight() {
42
+ innerHeight(defaultValue = 400) {
43
43
  const { top, bottom } = this.margins();
44
- return this.getAttribute('height') - top - bottom;
44
+ let h = this.getAttribute('height');
45
+ if (h == null && defaultValue != null) {
46
+ h = defaultValue; // TODO could apply more nuanced logic here
47
+ this.setAttribute('height', h, { silent: true });
48
+ }
49
+ return h - top - bottom;
45
50
  }
46
51
 
47
52
  pending(mark) {