@uwdata/mosaic-plot 0.11.0 → 0.12.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/dist/mosaic-plot.js +3691 -2393
- package/dist/mosaic-plot.min.js +14 -14
- package/package.json +4 -4
- package/src/index.js +2 -1
- package/src/interactors/Highlight.js +8 -6
- package/src/interactors/Interval1D.js +4 -11
- package/src/interactors/Interval2D.js +8 -15
- package/src/interactors/Nearest.js +39 -8
- package/src/interactors/Region.js +108 -0
- package/src/interactors/Toggle.js +6 -23
- package/src/interactors/util/brush.js +35 -3
- package/src/interactors/util/get-datum.js +15 -0
- package/src/interactors/util/get-field.js +37 -2
- package/src/interactors/util/intersect.js +267 -0
- package/src/interactors/util/neq.js +14 -0
- package/src/interactors/util/parse-path.js +79 -0
- package/src/marks/ConnectedMark.js +10 -33
- package/src/marks/DenseLineMark.js +8 -88
- package/src/marks/Density1DMark.js +4 -26
- package/src/marks/ErrorBarMark.js +7 -8
- package/src/marks/Grid2DMark.js +11 -52
- package/src/marks/HexbinMark.js +53 -23
- package/src/marks/Mark.js +29 -26
- package/src/marks/RasterMark.js +1 -0
- package/src/marks/RasterTileMark.js +2 -2
- package/src/marks/RegressionMark.js +3 -3
- package/src/marks/util/bin-expr.js +4 -9
- package/src/marks/util/extent.js +9 -12
- package/src/plot-renderer.js +23 -48
- package/src/transforms/bin.js +49 -38
- package/src/transforms/index.js +0 -3
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { parsePath } from './parse-path.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Return SVG elements that intersect the provided spatial extent.
|
|
5
|
+
* @param {SVGSVGElement} svg Parent SVG element to search within.
|
|
6
|
+
* @param {SVGElement} el SVG container element to search for points.
|
|
7
|
+
* @param {[[number, number], [number, number]]} extent Rectangular brush
|
|
8
|
+
* extent within which to select elements. The extent is represented as
|
|
9
|
+
* upper-left and bottom-right (x, y) coordinates.
|
|
10
|
+
* @returns {Element[]} A list of intersecting SVG elements.
|
|
11
|
+
*/
|
|
12
|
+
export function intersect(svg, el, extent) {
|
|
13
|
+
// svg origin in viewport coordinates
|
|
14
|
+
const { x, y } = svg.getBoundingClientRect();
|
|
15
|
+
const identity = svg.createSVGMatrix();
|
|
16
|
+
|
|
17
|
+
// collect elements that intersect the extent
|
|
18
|
+
const list = [];
|
|
19
|
+
for (const child of el.children) {
|
|
20
|
+
if (child.tagName === 'g') {
|
|
21
|
+
// handle faceted mark
|
|
22
|
+
const matrix = getTransformMatrix(child) ?? identity;
|
|
23
|
+
for (const grandchild of child.children) {
|
|
24
|
+
if (intersects(extent, x, y, grandchild, matrix)) {
|
|
25
|
+
list.push(grandchild);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
} else if (intersects(extent, x, y, child, identity)) {
|
|
29
|
+
list.push(child);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return list;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function intersects(sel, ox, oy, el, matrix) {
|
|
36
|
+
let [[l, t], [r, b]] = sel;
|
|
37
|
+
|
|
38
|
+
// facet groups involve translation only
|
|
39
|
+
const { e: tx, f: ty } = matrix;
|
|
40
|
+
|
|
41
|
+
// getBoundingClientRect uses viewport coordinates
|
|
42
|
+
// so we first translate to SVG coordinates
|
|
43
|
+
const c = el.getBoundingClientRect();
|
|
44
|
+
const cl = c.left - ox;
|
|
45
|
+
const cr = c.right - ox;
|
|
46
|
+
const ct = c.top - oy;
|
|
47
|
+
const cb = c.bottom - oy;
|
|
48
|
+
|
|
49
|
+
if (cl >= l && cr <= r && ct >= t && cb <= b) {
|
|
50
|
+
// if selection encloses item bounds, we're done
|
|
51
|
+
return true;
|
|
52
|
+
} else if (cl <= r && cr >= l && ct <= b && cb >= t) {
|
|
53
|
+
// if selection intersects item bounds, test further
|
|
54
|
+
let tag = el.tagName;
|
|
55
|
+
|
|
56
|
+
// if a hyperlink, use enclosed element
|
|
57
|
+
if (tag === 'a') {
|
|
58
|
+
el = el.children[0];
|
|
59
|
+
tag = el.tagName;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// handle marks that rely solely on bbox intersection
|
|
63
|
+
if (tag === 'rect' || tag === 'text' || tag === 'image') {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// translate selection relative to enclosing group
|
|
68
|
+
l -= tx;
|
|
69
|
+
t -= ty;
|
|
70
|
+
r -= tx;
|
|
71
|
+
b -= ty;
|
|
72
|
+
|
|
73
|
+
switch (tag) {
|
|
74
|
+
case 'circle':
|
|
75
|
+
return intersectCircle(l, t, r, b, $(el.cx), $(el.cy), $(el.r));
|
|
76
|
+
case 'line':
|
|
77
|
+
return intersectLine(l, t, r, b, $(el.x1), $(el.y1), $(el.x2), $(el.y2));
|
|
78
|
+
case 'path':
|
|
79
|
+
return intersectPath(l, t, r, b, el);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function $(attr) {
|
|
86
|
+
return attr.baseVal.value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getTransformMatrix(el) {
|
|
90
|
+
const transform = el.transform.baseVal;
|
|
91
|
+
const n = transform.length;
|
|
92
|
+
let m = transform[0]?.matrix;
|
|
93
|
+
for (let i = 1; i < n; ++i) {
|
|
94
|
+
m = m.multiply(transform[i].matrix);
|
|
95
|
+
}
|
|
96
|
+
return m;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function intersectCircle(l, t, r, b, cx, cy, cr) {
|
|
100
|
+
const h = l <= cx && cx <= r;
|
|
101
|
+
const v = t <= cy && cy <= b;
|
|
102
|
+
if (h && v) return true; // center is enclosed
|
|
103
|
+
|
|
104
|
+
const dx = Math.min(Math.abs(l - cx), Math.abs(r - cx));
|
|
105
|
+
if (v && dx <= cr) return true;
|
|
106
|
+
|
|
107
|
+
const dy = Math.min(Math.abs(t - cy), Math.abs(b - cy));
|
|
108
|
+
return (h && dy <= cr) || (dx * dx + dy * dy <= cr * cr);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function intersectLine(l, t, r, b, x1, y1, x2, y2) {
|
|
112
|
+
const xmin = Math.max(Math.min(x1, x2), l);
|
|
113
|
+
const xmax = Math.min(Math.max(x1, x2), r);
|
|
114
|
+
if (xmin > xmax) return false;
|
|
115
|
+
let yl1 = y1;
|
|
116
|
+
let yl2 = y2;
|
|
117
|
+
const dx = x2 - x1;
|
|
118
|
+
if (Math.abs(dx) > 1e-8) {
|
|
119
|
+
const a = (y2 - y1) / dx;
|
|
120
|
+
const b = y1 - a * x1;
|
|
121
|
+
yl1 = a * xmin + b;
|
|
122
|
+
yl2 = a * xmax + b;
|
|
123
|
+
}
|
|
124
|
+
const ymin = Math.max(Math.min(yl1, yl2), t);
|
|
125
|
+
const ymax = Math.min(Math.max(yl1, yl2), b);
|
|
126
|
+
return ymin <= ymax;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function intersectPath(l, t, r, b, el) {
|
|
130
|
+
// parse path and cache result for reuse
|
|
131
|
+
const cmds = el.__path__ || (el.__path__ = parsePath(el.getAttribute('d')));
|
|
132
|
+
|
|
133
|
+
let anchorX = 0;
|
|
134
|
+
let anchorY = 0;
|
|
135
|
+
let x = 0;
|
|
136
|
+
let y = 0;
|
|
137
|
+
let hit = false;
|
|
138
|
+
let poly = [0, 0];
|
|
139
|
+
let n = 2;
|
|
140
|
+
|
|
141
|
+
const matrix = getTransformMatrix(el);
|
|
142
|
+
|
|
143
|
+
const setAnchor = (ax, ay) => {
|
|
144
|
+
poly.length = n = 2;
|
|
145
|
+
poly[0] = x = anchorX = ax;
|
|
146
|
+
poly[1] = y = anchorY = ay;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const anchor = matrix
|
|
150
|
+
? (x, y) => setAnchor(multiplyX(matrix, x, y), multiplyY(matrix, x, y))
|
|
151
|
+
: (x, y) => setAnchor(x, y);
|
|
152
|
+
|
|
153
|
+
const test = (x2, y2) => {
|
|
154
|
+
poly[n] = x2;
|
|
155
|
+
poly[n+1] = y2;
|
|
156
|
+
n += 2;
|
|
157
|
+
return intersectLine(l, t, r, b, poly[n-4], poly[n-3], x2, y2);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const lineTo = matrix
|
|
161
|
+
? (x2, y2) => {
|
|
162
|
+
hit = test(
|
|
163
|
+
multiplyX(matrix, x = x2, y = y2),
|
|
164
|
+
multiplyY(matrix, x2, y2)
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
: (x2, y2) => { hit = test(x = x2, y = y2); };
|
|
168
|
+
|
|
169
|
+
for (let i = 0; i < cmds.length; ++i) {
|
|
170
|
+
const cmd = cmds[i];
|
|
171
|
+
switch (cmd[0]) {
|
|
172
|
+
case 'M': anchor(cmd[1], cmd[2]); break;
|
|
173
|
+
case 'm': anchor(x + cmd[1], y + cmd[2]); break;
|
|
174
|
+
case 'L':
|
|
175
|
+
case 'T': lineTo(cmd[1], cmd[2]); break;
|
|
176
|
+
case 'H': lineTo(cmd[1], y); break;
|
|
177
|
+
case 'V': lineTo(x, cmd[1]); break;
|
|
178
|
+
case 'l':
|
|
179
|
+
case 't': lineTo(x + cmd[1], y + cmd[2]); break;
|
|
180
|
+
case 'h': lineTo(x + cmd[1], y); break;
|
|
181
|
+
case 'v': lineTo(x, y + cmd[1]); break;
|
|
182
|
+
|
|
183
|
+
// approximate bezier curve as line for now
|
|
184
|
+
case 'C': lineTo(cmd[5], cmd[6]); break;
|
|
185
|
+
case 'c': lineTo(x + cmd[5], y + cmd[6]); break;
|
|
186
|
+
case 'S':
|
|
187
|
+
case 'Q': lineTo(cmd[3], cmd[4]); break;
|
|
188
|
+
case 's':
|
|
189
|
+
case 'q': lineTo(x + cmd[3], y + cmd[4]); break;
|
|
190
|
+
|
|
191
|
+
// we don't expect to see arcs other than geo point circles
|
|
192
|
+
// but just in case, approximate via straight line for now
|
|
193
|
+
case 'A': lineTo(cmd[6], cmd[7]); break;
|
|
194
|
+
case 'a':
|
|
195
|
+
if (isCircle(cmds, i)) {
|
|
196
|
+
// special case for geo point circle
|
|
197
|
+
return intersectCircle(l, t, r, b, x, y - cmd[2], cmd[2]);
|
|
198
|
+
} else {
|
|
199
|
+
lineTo(x + cmd[6], x + cmd[7]);
|
|
200
|
+
}
|
|
201
|
+
break;
|
|
202
|
+
|
|
203
|
+
case 'z':
|
|
204
|
+
case 'Z':
|
|
205
|
+
lineTo(anchorX, anchorY);
|
|
206
|
+
if (pointInPolygon(l, t, poly) > 0) return true;
|
|
207
|
+
anchor(anchorX, anchorY);
|
|
208
|
+
break;
|
|
209
|
+
default:
|
|
210
|
+
// bail for now
|
|
211
|
+
console.warn('SVG path command not supported: ', cmd[0]);
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
if (hit) return true;
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function multiplyX(m, x, y) {
|
|
220
|
+
return m.a * x + m.c * y + m.e;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function multiplyY(m, x, y) {
|
|
224
|
+
return m.b * x + m.d * y + m.f;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isCircle(cmds, i) {
|
|
228
|
+
const a = cmds[i];
|
|
229
|
+
const b = cmds[i+1];
|
|
230
|
+
return b && b[0] === 'a'
|
|
231
|
+
&& cmds[i+2]?.[0] === 'z'
|
|
232
|
+
&& a[1] === a[2]
|
|
233
|
+
&& b[1] === b[2]
|
|
234
|
+
&& a[1] === b[1]
|
|
235
|
+
&& a[7] === -b[7];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Point in polygon test, based on Dan Sunday's winding number algorithm.
|
|
240
|
+
* https://web.archive.org/web/20130126163405/http://geomalgorithms.com/a03-_inclusion.html
|
|
241
|
+
* @param {number} x The x-coordinate to test for inclusion
|
|
242
|
+
* @param {number} y The y-coordinate to test for inclusion
|
|
243
|
+
* @param {number[]} poly Polygon vertices as a flat array of numbers
|
|
244
|
+
* @returns {number} The winding number. Non-zero values indicate inclusion.
|
|
245
|
+
*/
|
|
246
|
+
function pointInPolygon(x, y, poly) {
|
|
247
|
+
let wn = 0;
|
|
248
|
+
const n = poly.length - 2;
|
|
249
|
+
|
|
250
|
+
for (let i = 0; i < n; i += 2) {
|
|
251
|
+
if (poly[i + 1] <= y) {
|
|
252
|
+
// an upward crossing and (x,y) left of edge
|
|
253
|
+
if (poly[i + 3] > y && isLeft(x, y, poly, i) > 0)
|
|
254
|
+
++wn; // valid up intersect
|
|
255
|
+
}
|
|
256
|
+
// a downward crossing and (x,y) right of edge
|
|
257
|
+
else if (poly[i + 3] <= y && isLeft(x, y, poly[i]) < 0) {
|
|
258
|
+
--wn; // valid down intersect
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return wn;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function isLeft(x, y, p, i) {
|
|
266
|
+
return (p[i+2] - p[i]) * (y - p[i+1]) - (x - p[i]) * (p[i+3] - p[i+1]);
|
|
267
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function neqSome(a, b) {
|
|
2
|
+
return (a == null || b == null)
|
|
3
|
+
? (a != null || b != null)
|
|
4
|
+
: (a.length !== b.length || a.some((x, i) => neq(x, b[i])));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function neq(a, b) {
|
|
8
|
+
const n = a.length;
|
|
9
|
+
if (b.length !== n) return true;
|
|
10
|
+
for (let i = 0; i < n; ++i) {
|
|
11
|
+
if (a[i] !== b[i]) return true;
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const paramCounts = { m:2, l:2, h:1, v:1, z:0, c:6, s:4, q:4, t:2, a:7 };
|
|
2
|
+
const commandPattern = /[mlhvzcsqta]([^mlhvzcsqta]+|$)/gi;
|
|
3
|
+
const numberPattern = /^[+-]?(([0-9]*\.[0-9]+)|([0-9]+\.)|([0-9]+))([eE][+-]?[0-9]+)?/;
|
|
4
|
+
const spacePattern = /^((\s+,?\s*)|(,\s*))/;
|
|
5
|
+
const flagPattern = /^[01]/;
|
|
6
|
+
|
|
7
|
+
const errmsg = attr => `Invalid SVG path, incorrect parameter ${attr}`;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse an SVG path into a list of drawing commands.
|
|
11
|
+
* @param {string} path The SVG path string to parse
|
|
12
|
+
* @returns {[string, ...number][]} A list of drawing commands.
|
|
13
|
+
* Each command has a single letter as the first entry. All subsequent
|
|
14
|
+
* entries are numeric parameter values.
|
|
15
|
+
*/
|
|
16
|
+
export function parsePath(path) {
|
|
17
|
+
const commands = [];
|
|
18
|
+
const matches = path.match(commandPattern) || [];
|
|
19
|
+
|
|
20
|
+
matches.forEach(str => {
|
|
21
|
+
let cmd = str[0];
|
|
22
|
+
const type = cmd.toLowerCase();
|
|
23
|
+
|
|
24
|
+
// parse parameters
|
|
25
|
+
const paramCount = paramCounts[type];
|
|
26
|
+
const params = parseParams(type, paramCount, str.slice(1).trim());
|
|
27
|
+
const count = params.length;
|
|
28
|
+
|
|
29
|
+
// error checking based on parameter count
|
|
30
|
+
if (count < paramCount || (count && count % paramCount !== 0)) {
|
|
31
|
+
throw new Error(errmsg('count'));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// register the command
|
|
35
|
+
commands.push([cmd, ...params.slice(0, paramCount)]);
|
|
36
|
+
|
|
37
|
+
// exit now if we're done, also handles zero-param 'z'
|
|
38
|
+
if (count === paramCount) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// handle implicit line-to
|
|
43
|
+
if (type === 'm') {
|
|
44
|
+
cmd = (cmd === 'M') ? 'L' : 'l';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// repeat command when given extended param list
|
|
48
|
+
for (let i = paramCount; i < count; i += paramCount) {
|
|
49
|
+
commands.push([cmd, ...params.slice(i, i + paramCount)]);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return commands;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseParams(type, paramCount, segment) {
|
|
57
|
+
const params = [];
|
|
58
|
+
|
|
59
|
+
for (let index = 0; paramCount && index < segment.length; ) {
|
|
60
|
+
for (let i = 0; i < paramCount; ++i) {
|
|
61
|
+
const pattern = type === 'a' && (i === 3 || i === 4) ? flagPattern : numberPattern;
|
|
62
|
+
const match = segment.slice(index).match(pattern);
|
|
63
|
+
|
|
64
|
+
if (match === null) {
|
|
65
|
+
throw new Error(errmsg('type'));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
index += match[0].length;
|
|
69
|
+
params.push(+match[0]);
|
|
70
|
+
|
|
71
|
+
const ws = segment.slice(index).match(spacePattern);
|
|
72
|
+
if (ws !== null) {
|
|
73
|
+
index += ws[0].length;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return params;
|
|
79
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { m4 } from '@uwdata/mosaic-sql';
|
|
2
2
|
import { binExpr } from './util/bin-expr.js';
|
|
3
3
|
import { filteredExtent } from './util/extent.js';
|
|
4
4
|
import { Mark } from './Mark.js';
|
|
@@ -6,7 +6,7 @@ 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 ? { [dim]: ['min', 'max'] } : undefined;
|
|
9
|
+
const req = dim ? { [dim]: ['count', 'min', 'max'] } : undefined;
|
|
10
10
|
super(type, source, encodings, req);
|
|
11
11
|
this.dim = dim;
|
|
12
12
|
}
|
|
@@ -18,23 +18,24 @@ export class ConnectedMark extends Mark {
|
|
|
18
18
|
*/
|
|
19
19
|
query(filter = []) {
|
|
20
20
|
const { plot, dim, source } = this;
|
|
21
|
-
|
|
21
|
+
let optimize = source.options?.optimize;
|
|
22
22
|
const q = super.query(filter);
|
|
23
23
|
if (!dim) return q;
|
|
24
24
|
|
|
25
25
|
const ortho = dim === 'x' ? 'y' : 'x';
|
|
26
26
|
const value = this.channelField(ortho, { exact: true })?.as;
|
|
27
|
-
const { field, as, type, min, max } = this.channelField(dim);
|
|
27
|
+
const { field, as, type, count, min, max } = this.channelField(dim);
|
|
28
28
|
const isContinuous = type === 'date' || type === 'number';
|
|
29
29
|
|
|
30
|
+
const size = dim === 'x' ? plot.innerWidth() : plot.innerHeight();
|
|
31
|
+
optimize ??= (count / size) > 10; // threshold for applying M4
|
|
32
|
+
|
|
30
33
|
if (optimize && isContinuous && value) {
|
|
31
|
-
// TODO: handle stacked data
|
|
32
|
-
const size = dim === 'x' ? plot.innerWidth() : plot.innerHeight();
|
|
34
|
+
// TODO: handle stacked data
|
|
33
35
|
const [lo, hi] = filteredExtent(filter, field) || [min, max];
|
|
34
36
|
const [expr] = binExpr(this, dim, size, [lo, hi], 1, as);
|
|
35
|
-
const cols = q.
|
|
36
|
-
|
|
37
|
-
.map(c => c.as)
|
|
37
|
+
const cols = q._select
|
|
38
|
+
.map(c => c.alias)
|
|
38
39
|
.filter(c => c !== as && c !== value);
|
|
39
40
|
return m4(q, expr, as, value, cols);
|
|
40
41
|
} else {
|
|
@@ -42,27 +43,3 @@ export class ConnectedMark extends Mark {
|
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* M4 is an optimization for value-preserving time-series aggregation
|
|
48
|
-
* (https://www.vldb.org/pvldb/vol7/p797-jugel.pdf). This implementation uses
|
|
49
|
-
* an efficient version with a single scan and the aggregate function
|
|
50
|
-
* argmin and argmax, following https://arxiv.org/pdf/2306.03714.pdf.
|
|
51
|
-
*/
|
|
52
|
-
function m4(input, bin, x, y, cols = []) {
|
|
53
|
-
const pixel = sql`FLOOR(${bin})::INTEGER`;
|
|
54
|
-
|
|
55
|
-
const q = (sel) => Query
|
|
56
|
-
.from(input)
|
|
57
|
-
.select(sel)
|
|
58
|
-
.groupby(pixel, cols);
|
|
59
|
-
|
|
60
|
-
return Query
|
|
61
|
-
.union(
|
|
62
|
-
q([{ [x]: min(x), [y]: argmin(y, x) }, ...cols]),
|
|
63
|
-
q([{ [x]: max(x), [y]: argmax(y, x) }, ...cols]),
|
|
64
|
-
q([{ [x]: argmin(x, y), [y]: min(y) }, ...cols]),
|
|
65
|
-
q([{ [x]: argmax(x, y), [y]: max(y) }, ...cols])
|
|
66
|
-
)
|
|
67
|
-
.orderby(cols, x);
|
|
68
|
-
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Query, and,
|
|
1
|
+
import { Query, and, lineDensity } from '@uwdata/mosaic-sql';
|
|
2
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';
|
|
@@ -16,13 +16,13 @@ export class DenseLineMark extends RasterMark {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
query(filter = []) {
|
|
19
|
-
const { channels, normalize,
|
|
19
|
+
const { channels, normalize, pad } = this;
|
|
20
20
|
const [nx, ny] = this.bins = this.binDimensions();
|
|
21
21
|
const [x] = binExpr(this, 'x', nx, extentX(this, filter), pad);
|
|
22
22
|
const [y] = binExpr(this, 'y', ny, extentY(this, filter), pad);
|
|
23
23
|
|
|
24
24
|
const q = Query
|
|
25
|
-
.from(
|
|
25
|
+
.from(this.sourceTable())
|
|
26
26
|
.where(stripXY(this, filter));
|
|
27
27
|
|
|
28
28
|
this.aggr = ['density'];
|
|
@@ -52,101 +52,21 @@ function stripXY(mark, filter) {
|
|
|
52
52
|
if (Array.isArray(filter) && !filter.length) return filter;
|
|
53
53
|
|
|
54
54
|
// get column expressions for x and y encoding channels
|
|
55
|
-
const
|
|
56
|
-
const
|
|
55
|
+
const xc = mark.channelField('x').column;
|
|
56
|
+
const yc = mark.channelField('y').column;
|
|
57
57
|
|
|
58
58
|
// test if a range predicate filters the x or y channels
|
|
59
59
|
const test = p => {
|
|
60
|
-
const col = `${p.
|
|
61
|
-
return p.
|
|
60
|
+
const col = `${p.expr}`;
|
|
61
|
+
return p.type !== 'BETWEEN' || (col !== xc && col !== yc);
|
|
62
62
|
};
|
|
63
63
|
|
|
64
64
|
// filter boolean 'and' operations
|
|
65
65
|
const filterAnd = p => p.op === 'AND'
|
|
66
|
-
? and(p.
|
|
66
|
+
? and(p.clauses.filter(c => test(c)))
|
|
67
67
|
: p;
|
|
68
68
|
|
|
69
69
|
return Array.isArray(filter)
|
|
70
70
|
? filter.filter(p => test(p)).map(p => filterAnd(p))
|
|
71
71
|
: filterAnd(filter);
|
|
72
72
|
}
|
|
73
|
-
|
|
74
|
-
function lineDensity(
|
|
75
|
-
q, x, y, z, xn, yn,
|
|
76
|
-
groupby = [], normalize = true
|
|
77
|
-
) {
|
|
78
|
-
// select x, y points binned to the grid
|
|
79
|
-
q.select({
|
|
80
|
-
x: sql`FLOOR(${x})::INTEGER`,
|
|
81
|
-
y: sql`FLOOR(${y})::INTEGER`
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
// select line segment end point pairs
|
|
85
|
-
const groups = groupby.concat(z);
|
|
86
|
-
const pairPart = groups.length ? `PARTITION BY ${groups.join(', ')} ` : '';
|
|
87
|
-
const pairs = Query
|
|
88
|
-
.from(q)
|
|
89
|
-
.select(groups, {
|
|
90
|
-
x0: 'x',
|
|
91
|
-
y0: 'y',
|
|
92
|
-
dx: sql`(lead(x) OVER sw - x)`,
|
|
93
|
-
dy: sql`(lead(y) OVER sw - y)`
|
|
94
|
-
})
|
|
95
|
-
.window({ sw: sql`${pairPart}ORDER BY x ASC` })
|
|
96
|
-
.qualify(and(
|
|
97
|
-
sql`(x0 < ${xn} OR x0 + dx < ${xn})`,
|
|
98
|
-
sql`(y0 < ${yn} OR y0 + dy < ${yn})`,
|
|
99
|
-
sql`(x0 > 0 OR x0 + dx > 0)`,
|
|
100
|
-
sql`(y0 > 0 OR y0 + dy > 0)`
|
|
101
|
-
));
|
|
102
|
-
|
|
103
|
-
// indices to join against for rasterization
|
|
104
|
-
// generate the maximum number of indices needed
|
|
105
|
-
const num = Query
|
|
106
|
-
.select({ x: sql`GREATEST(MAX(ABS(dx)), MAX(ABS(dy)))` })
|
|
107
|
-
.from('pairs');
|
|
108
|
-
const indices = Query.select({ i: sql`UNNEST(range((${num})))::INTEGER` });
|
|
109
|
-
|
|
110
|
-
// rasterize line segments
|
|
111
|
-
const raster = Query.unionAll(
|
|
112
|
-
Query
|
|
113
|
-
.select(groups, {
|
|
114
|
-
x: sql`x0 + i`,
|
|
115
|
-
y: sql`y0 + ROUND(i * dy / dx::FLOAT)::INTEGER`
|
|
116
|
-
})
|
|
117
|
-
.from('pairs', 'indices')
|
|
118
|
-
.where(sql`ABS(dy) <= ABS(dx) AND i < ABS(dx)`),
|
|
119
|
-
Query
|
|
120
|
-
.select(groups, {
|
|
121
|
-
x: sql`x0 + ROUND(SIGN(dy) * i * dx / dy::FLOAT)::INTEGER`,
|
|
122
|
-
y: sql`y0 + SIGN(dy) * i`
|
|
123
|
-
})
|
|
124
|
-
.from('pairs', 'indices')
|
|
125
|
-
.where(sql`ABS(dy) > ABS(dx) AND i < ABS(dy)`),
|
|
126
|
-
Query
|
|
127
|
-
.select(groups, { x: 'x0', y: 'y0' })
|
|
128
|
-
.from('pairs')
|
|
129
|
-
.where(isNull('dx'))
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
// filter raster, normalize columns for each series
|
|
133
|
-
const pointPart = ['x'].concat(groups).join(', ');
|
|
134
|
-
const points = Query
|
|
135
|
-
.from('raster')
|
|
136
|
-
.select(groups, 'x', 'y',
|
|
137
|
-
normalize
|
|
138
|
-
? { w: sql`1.0 / COUNT(*) OVER (PARTITION BY ${pointPart})` }
|
|
139
|
-
: null
|
|
140
|
-
)
|
|
141
|
-
.where(and(isBetween('x', [0, xn], true), isBetween('y', [0, yn], true)));
|
|
142
|
-
|
|
143
|
-
// sum normalized, rasterized series into output grids
|
|
144
|
-
return Query
|
|
145
|
-
.with({ pairs, indices, raster, points })
|
|
146
|
-
.from('points')
|
|
147
|
-
.select(groupby, {
|
|
148
|
-
index: sql`x + y * ${xn}::INTEGER`,
|
|
149
|
-
density: normalize ? sum('w') : count()
|
|
150
|
-
})
|
|
151
|
-
.groupby('index', groupby);
|
|
152
|
-
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { toDataColumns } from '@uwdata/mosaic-core';
|
|
2
|
-
import {
|
|
2
|
+
import { binLinear1d, isBetween } from '@uwdata/mosaic-sql';
|
|
3
3
|
import { Transient } from '../symbols.js';
|
|
4
4
|
import { binExpr } from './util/bin-expr.js';
|
|
5
5
|
import { dericheConfig, dericheConv1d } from './util/density.js';
|
|
@@ -28,7 +28,7 @@ export class Density1DMark extends Mark {
|
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
get
|
|
31
|
+
get filterStable() {
|
|
32
32
|
const name = this.dim === 'x' ? 'xDomain' : 'yDomain';
|
|
33
33
|
const dom = this.plot.getAttribute(name);
|
|
34
34
|
return dom && !dom[Transient];
|
|
@@ -36,10 +36,10 @@ export class Density1DMark extends Mark {
|
|
|
36
36
|
|
|
37
37
|
query(filter = []) {
|
|
38
38
|
if (this.hasOwnData()) throw new Error('Density1DMark requires a data source');
|
|
39
|
-
const { bins, channels, dim
|
|
39
|
+
const { bins, channels, dim } = this;
|
|
40
40
|
const extent = this.extent = (dim === 'x' ? extentX : extentY)(this, filter);
|
|
41
41
|
const [x, bx] = binExpr(this, dim, bins, extent);
|
|
42
|
-
const q = markQuery(channels,
|
|
42
|
+
const q = markQuery(channels, this.sourceTable(), [dim])
|
|
43
43
|
.where(filter.concat(isBetween(bx, extent)));
|
|
44
44
|
const v = this.channelField('weight') ? 'weight' : null;
|
|
45
45
|
return binLinear1d(q, x, v);
|
|
@@ -87,25 +87,3 @@ export class Density1DMark extends Mark {
|
|
|
87
87
|
return [{ type, data: { length }, options }];
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
|
-
|
|
91
|
-
function binLinear1d(q, p, density) {
|
|
92
|
-
const w = density ? `* ${density}` : '';
|
|
93
|
-
|
|
94
|
-
const u = q.clone().select({
|
|
95
|
-
p,
|
|
96
|
-
i: sql`FLOOR(p)::INTEGER`,
|
|
97
|
-
w: sql`(FLOOR(p) + 1 - p)${w}`
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
const v = q.clone().select({
|
|
101
|
-
p,
|
|
102
|
-
i: sql`FLOOR(p)::INTEGER + 1`,
|
|
103
|
-
w: sql`(p - FLOOR(p))${w}`
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
return Query
|
|
107
|
-
.from(Query.unionAll(u, v))
|
|
108
|
-
.select({ index: 'i', density: sum('w') })
|
|
109
|
-
.groupby('index')
|
|
110
|
-
.having(gt('density', 0));
|
|
111
|
-
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { toDataColumns } from '@uwdata/mosaic-core';
|
|
2
|
-
import { avg, count, stddev } from '@uwdata/mosaic-sql';
|
|
2
|
+
import { avg, count, div, sqrt, stddev } from '@uwdata/mosaic-sql';
|
|
3
3
|
import { erfinv } from './util/stats.js';
|
|
4
4
|
import { Mark, markPlotSpec, markQuery } from './Mark.js';
|
|
5
5
|
import { handleParam } from './util/handle-param.js';
|
|
@@ -20,13 +20,12 @@ export class ErrorBarMark extends Mark {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
query(filter = []) {
|
|
23
|
-
const { channels, field
|
|
23
|
+
const { channels, field } = this;
|
|
24
24
|
const fields = channels.concat([
|
|
25
25
|
{ field: avg(field), as: '__avg__' },
|
|
26
|
-
{ field: count(field), as: '
|
|
27
|
-
{ field: stddev(field), as: '__sd__' }
|
|
26
|
+
{ field: div(stddev(field), sqrt(count(field))), as: '__se__', }
|
|
28
27
|
]);
|
|
29
|
-
return markQuery(fields,
|
|
28
|
+
return markQuery(fields, this.sourceTable()).where(filter);
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
queryResult(data) {
|
|
@@ -39,10 +38,10 @@ export class ErrorBarMark extends Mark {
|
|
|
39
38
|
|
|
40
39
|
// compute confidence interval channels
|
|
41
40
|
const p = Math.SQRT2 * erfinv(ci);
|
|
42
|
-
const { columns: { __avg__: u,
|
|
41
|
+
const { columns: { __avg__: u, __se__: s } } = data;
|
|
43
42
|
const options = {
|
|
44
|
-
[`${dim}1`]: u.map((u, i) => u - p * s[i]
|
|
45
|
-
[`${dim}2`]: u.map((u, i) => u + p * s[i]
|
|
43
|
+
[`${dim}1`]: u.map((u, i) => u - p * s[i]),
|
|
44
|
+
[`${dim}2`]: u.map((u, i) => u + p * s[i])
|
|
46
45
|
};
|
|
47
46
|
|
|
48
47
|
return markPlotSpec(type, detail, channels, data, options);
|