@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.
- package/LICENSE +28 -0
- package/README.md +6 -0
- package/dist/mosaic-plot.js +42313 -0
- package/dist/mosaic-plot.min.js +69 -0
- package/package.json +38 -0
- package/src/index.js +30 -0
- package/src/interactors/Highlight.js +101 -0
- package/src/interactors/Interval1D.js +90 -0
- package/src/interactors/Interval2D.js +102 -0
- package/src/interactors/Nearest.js +66 -0
- package/src/interactors/PanZoom.js +121 -0
- package/src/interactors/Toggle.js +111 -0
- package/src/interactors/util/brush.js +45 -0
- package/src/interactors/util/close-to.js +9 -0
- package/src/interactors/util/get-field.js +4 -0
- package/src/interactors/util/invert.js +3 -0
- package/src/interactors/util/patchScreenCTM.js +13 -0
- package/src/interactors/util/sanitize-styles.js +9 -0
- package/src/interactors/util/to-kebab-case.js +9 -0
- package/src/legend.js +64 -0
- package/src/marks/ConnectedMark.js +66 -0
- package/src/marks/ContourMark.js +89 -0
- package/src/marks/DenseLineMark.js +146 -0
- package/src/marks/Density1DMark.js +104 -0
- package/src/marks/Density2DMark.js +69 -0
- package/src/marks/GeoMark.js +35 -0
- package/src/marks/Grid2DMark.js +191 -0
- package/src/marks/HexbinMark.js +88 -0
- package/src/marks/Mark.js +207 -0
- package/src/marks/RasterMark.js +121 -0
- package/src/marks/RasterTileMark.js +331 -0
- package/src/marks/RegressionMark.js +117 -0
- package/src/marks/util/bin-field.js +17 -0
- package/src/marks/util/density.js +226 -0
- package/src/marks/util/extent.js +56 -0
- package/src/marks/util/grid.js +57 -0
- package/src/marks/util/handle-param.js +14 -0
- package/src/marks/util/is-arrow-table.js +3 -0
- package/src/marks/util/is-color.js +18 -0
- package/src/marks/util/is-constant-option.js +41 -0
- package/src/marks/util/is-symbol.js +20 -0
- package/src/marks/util/raster.js +44 -0
- package/src/marks/util/stats.js +133 -0
- package/src/marks/util/to-data-array.js +70 -0
- package/src/plot-attributes.js +212 -0
- package/src/plot-renderer.js +161 -0
- package/src/plot.js +136 -0
- package/src/symbols.js +3 -0
- package/src/transforms/bin.js +81 -0
- package/src/transforms/index.js +3 -0
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uwdata/mosaic-plot",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "A Mosaic-powered plotting framework based on Observable Plot.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"data",
|
|
7
|
+
"visualization",
|
|
8
|
+
"plot",
|
|
9
|
+
"duckdb",
|
|
10
|
+
"mosaic"
|
|
11
|
+
],
|
|
12
|
+
"license": "BSD-3-Clause",
|
|
13
|
+
"author": "Jeffrey Heer (http://idl.cs.washington.edu)",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "src/index.js",
|
|
16
|
+
"module": "src/index.js",
|
|
17
|
+
"jsdelivr": "dist/mosaic-plot.min.js",
|
|
18
|
+
"unpkg": "dist/mosaic-plot.min.js",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/uwdata/mosaic.git"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"prebuild": "rimraf dist && mkdir dist",
|
|
25
|
+
"build": "node ../../esbuild.js mosaic-plot",
|
|
26
|
+
"lint": "eslint src test --ext .js",
|
|
27
|
+
"test": "mocha 'test/**/*-test.js'",
|
|
28
|
+
"prepublishOnly": "npm run test && npm run lint && npm run build"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@observablehq/plot": "^0.6.13",
|
|
32
|
+
"@uwdata/mosaic-core": "^0.5.0",
|
|
33
|
+
"@uwdata/mosaic-sql": "^0.5.0",
|
|
34
|
+
"d3": "^7.8.5",
|
|
35
|
+
"isoformat": "^0.2.1"
|
|
36
|
+
},
|
|
37
|
+
"gitHead": "92886dddfb126c1439924c5a0189e4639c3519a7"
|
|
38
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export { Fixed, Transient, Transform } from './symbols.js';
|
|
2
|
+
export { Plot } from './plot.js';
|
|
3
|
+
|
|
4
|
+
// marks
|
|
5
|
+
export { Mark } from './marks/Mark.js';
|
|
6
|
+
export { ConnectedMark } from './marks/ConnectedMark.js';
|
|
7
|
+
export { ContourMark } from './marks/ContourMark.js';
|
|
8
|
+
export { DenseLineMark } from './marks/DenseLineMark.js';
|
|
9
|
+
export { Density1DMark } from './marks/Density1DMark.js';
|
|
10
|
+
export { Density2DMark } from './marks/Density2DMark.js';
|
|
11
|
+
export { GeoMark } from './marks/GeoMark.js';
|
|
12
|
+
export { Grid2DMark } from './marks/Grid2DMark.js';
|
|
13
|
+
export { HexbinMark } from './marks/HexbinMark.js';
|
|
14
|
+
export { RasterMark } from './marks/RasterMark.js';
|
|
15
|
+
export { RasterTileMark } from './marks/RasterTileMark.js';
|
|
16
|
+
export { RegressionMark } from './marks/RegressionMark.js';
|
|
17
|
+
|
|
18
|
+
// interactors
|
|
19
|
+
export { Highlight } from './interactors/Highlight.js';
|
|
20
|
+
export { Interval1D } from './interactors/Interval1D.js';
|
|
21
|
+
export { Interval2D } from './interactors/Interval2D.js';
|
|
22
|
+
export { Nearest } from './interactors/Nearest.js';
|
|
23
|
+
export { PanZoom } from './interactors/PanZoom.js';
|
|
24
|
+
export { Toggle } from './interactors/Toggle.js';
|
|
25
|
+
|
|
26
|
+
// legend
|
|
27
|
+
export { Legend } from './legend.js';
|
|
28
|
+
|
|
29
|
+
// transforms
|
|
30
|
+
export { bin } from './transforms/index.js';
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { throttle } from '@uwdata/mosaic-core';
|
|
2
|
+
import { and } from '@uwdata/mosaic-sql';
|
|
3
|
+
import { sanitizeStyles } from './util/sanitize-styles.js';
|
|
4
|
+
|
|
5
|
+
function configureMark(mark) {
|
|
6
|
+
const { channels } = mark;
|
|
7
|
+
const dims = new Set;
|
|
8
|
+
let ordered = false;
|
|
9
|
+
let aggregate = false;
|
|
10
|
+
|
|
11
|
+
for (const c of channels) {
|
|
12
|
+
const { channel, field, as } = c;
|
|
13
|
+
if (channel === 'orderby') {
|
|
14
|
+
ordered = true;
|
|
15
|
+
} else if (field) {
|
|
16
|
+
if (field.aggregate) {
|
|
17
|
+
aggregate = true;
|
|
18
|
+
} else {
|
|
19
|
+
if (dims.has(as)) continue;
|
|
20
|
+
dims.add(as);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// if orderby is defined, we're ok: nothing to do
|
|
26
|
+
// or, if there is no groupby aggregation, we're ok: nothing to do
|
|
27
|
+
// grouping may result in optimizations that change result order
|
|
28
|
+
// so we orderby the grouping dimensions to ensure stable indices
|
|
29
|
+
if (!ordered && aggregate && dims.size) {
|
|
30
|
+
mark.channels.push(({ channel: 'orderby', value: Array.from(dims) }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return mark;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class Highlight {
|
|
37
|
+
constructor(mark, {
|
|
38
|
+
selection,
|
|
39
|
+
channels = {}
|
|
40
|
+
}) {
|
|
41
|
+
this.mark = configureMark(mark);
|
|
42
|
+
this.selection = selection;
|
|
43
|
+
const c = Object.entries(sanitizeStyles(channels));
|
|
44
|
+
this.channels = c.length ? c : [['opacity', 0.2]];
|
|
45
|
+
this.selection.addEventListener('value', throttle(() => this.update()));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
init(svg) {
|
|
49
|
+
this.svg = svg;
|
|
50
|
+
const values = this.values = [];
|
|
51
|
+
const index = this.mark.index;
|
|
52
|
+
const nodes = this.nodes = svg.querySelectorAll(`[data-index="${index}"] > *`);
|
|
53
|
+
|
|
54
|
+
const { channels } = this;
|
|
55
|
+
for (let i = 0; i < nodes.length; ++i) {
|
|
56
|
+
const node = nodes[i];
|
|
57
|
+
values.push(channels.map(c => node.getAttribute(c[0])));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return this.update();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async update() {
|
|
64
|
+
const { svg, nodes, channels, values, mark, selection } = this;
|
|
65
|
+
if (!svg) return;
|
|
66
|
+
|
|
67
|
+
const test = await predicateFunction(mark, selection);
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < nodes.length; ++i) {
|
|
70
|
+
const node = nodes[i];
|
|
71
|
+
const base = values[i];
|
|
72
|
+
const t = test(node.__data__);
|
|
73
|
+
// TODO? handle inherited values / remove attributes
|
|
74
|
+
for (let j = 0; j < channels.length; ++j) {
|
|
75
|
+
const [attr, value] = channels[j];
|
|
76
|
+
node.setAttribute(attr, t ? base[j] : value);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function predicateFunction(mark, selection) {
|
|
83
|
+
const pred = selection?.predicate(mark);
|
|
84
|
+
|
|
85
|
+
if (!pred || pred.length === 0) {
|
|
86
|
+
return () => true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// set flag so we do not skip cross-filtered sources
|
|
90
|
+
const filter = mark.filterBy?.predicate(mark, true);
|
|
91
|
+
|
|
92
|
+
const s = { __: and(pred) };
|
|
93
|
+
const q = mark.query(filter);
|
|
94
|
+
const p = q.groupby().length ? q.select(s) : q.$select(s);
|
|
95
|
+
|
|
96
|
+
const data = await mark.coordinator.query(p);
|
|
97
|
+
const v = data.getChild?.('__');
|
|
98
|
+
return !(data.numRows || data.length) ? (() => false)
|
|
99
|
+
: v ? (i => v.get(i))
|
|
100
|
+
: (i => data[i].__);
|
|
101
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { select, min, max } from 'd3';
|
|
2
|
+
import { isBetween } from '@uwdata/mosaic-sql';
|
|
3
|
+
import { brushX, brushY } from './util/brush.js';
|
|
4
|
+
import { closeTo } from './util/close-to.js';
|
|
5
|
+
import { getField } from './util/get-field.js';
|
|
6
|
+
import { invert } from './util/invert.js';
|
|
7
|
+
import { patchScreenCTM } from './util/patchScreenCTM.js';
|
|
8
|
+
import { sanitizeStyles } from './util/sanitize-styles.js';
|
|
9
|
+
|
|
10
|
+
export class Interval1D {
|
|
11
|
+
constructor(mark, {
|
|
12
|
+
channel,
|
|
13
|
+
selection,
|
|
14
|
+
field,
|
|
15
|
+
pixelSize = 1,
|
|
16
|
+
peers = true,
|
|
17
|
+
brush: style
|
|
18
|
+
}) {
|
|
19
|
+
this.mark = mark;
|
|
20
|
+
this.channel = channel;
|
|
21
|
+
this.pixelSize = pixelSize || 1;
|
|
22
|
+
this.selection = selection;
|
|
23
|
+
this.peers = peers;
|
|
24
|
+
this.field = field || getField(mark, [channel, channel+'1', channel+'2']);
|
|
25
|
+
this.style = style && sanitizeStyles(style);
|
|
26
|
+
this.brush = channel === 'y' ? brushY() : brushX();
|
|
27
|
+
this.brush.on('brush end', ({ selection }) => this.publish(selection));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
reset() {
|
|
31
|
+
this.value = undefined;
|
|
32
|
+
if (this.g) this.brush.reset(this.g);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
activate() {
|
|
36
|
+
this.selection.activate(this.clause(this.value || [0, 1]));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
publish(extent) {
|
|
40
|
+
let range = undefined;
|
|
41
|
+
if (extent) {
|
|
42
|
+
range = extent
|
|
43
|
+
.map(v => invert(v, this.scale, this.pixelSize))
|
|
44
|
+
.sort((a, b) => a - b);
|
|
45
|
+
}
|
|
46
|
+
if (!closeTo(range, this.value)) {
|
|
47
|
+
this.value = range;
|
|
48
|
+
this.g.call(this.brush.moveSilent, extent);
|
|
49
|
+
this.selection.update(this.clause(range));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
clause(value) {
|
|
54
|
+
const { mark, pixelSize, field, scale } = this;
|
|
55
|
+
return {
|
|
56
|
+
source: this,
|
|
57
|
+
schema: { type: 'interval', pixelSize, scales: [scale] },
|
|
58
|
+
clients: this.peers ? mark.plot.markSet : new Set().add(mark),
|
|
59
|
+
value,
|
|
60
|
+
predicate: value ? isBetween(field, value) : null
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
init(svg) {
|
|
65
|
+
const { brush, channel, style } = this;
|
|
66
|
+
this.scale = svg.scale(channel);
|
|
67
|
+
|
|
68
|
+
const rx = svg.scale('x').range;
|
|
69
|
+
const ry = svg.scale('y').range;
|
|
70
|
+
brush.extent([[min(rx), min(ry)], [max(rx), max(ry)]]);
|
|
71
|
+
|
|
72
|
+
const facets = select(svg).selectAll('g[aria-label="facet"]');
|
|
73
|
+
const root = facets.size() ? facets : select(svg);
|
|
74
|
+
this.g = root
|
|
75
|
+
.append('g')
|
|
76
|
+
.attr('class', `interval-${channel}`)
|
|
77
|
+
.each(patchScreenCTM)
|
|
78
|
+
.call(brush)
|
|
79
|
+
.call(brush.moveSilent, this.value?.map(this.scale.apply));
|
|
80
|
+
|
|
81
|
+
if (style) {
|
|
82
|
+
const brushes = this.g.selectAll('rect.selection');
|
|
83
|
+
for (const name in style) {
|
|
84
|
+
brushes.attr(name, style[name]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
svg.addEventListener('pointerenter', () => this.activate());
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { select, min, max } from 'd3';
|
|
2
|
+
import { and, isBetween } from '@uwdata/mosaic-sql';
|
|
3
|
+
import { brush } from './util/brush.js';
|
|
4
|
+
import { closeTo } from './util/close-to.js';
|
|
5
|
+
import { getField } from './util/get-field.js';
|
|
6
|
+
import { invert } from './util/invert.js';
|
|
7
|
+
import { patchScreenCTM } from './util/patchScreenCTM.js';
|
|
8
|
+
import { sanitizeStyles } from './util/sanitize-styles.js';
|
|
9
|
+
|
|
10
|
+
const asc = (a, b) => a - b;
|
|
11
|
+
|
|
12
|
+
export class Interval2D {
|
|
13
|
+
constructor(mark, {
|
|
14
|
+
selection,
|
|
15
|
+
xfield,
|
|
16
|
+
yfield,
|
|
17
|
+
pixelSize = 1,
|
|
18
|
+
peers = true,
|
|
19
|
+
brush: style
|
|
20
|
+
}) {
|
|
21
|
+
this.mark = mark;
|
|
22
|
+
this.pixelSize = pixelSize || 1;
|
|
23
|
+
this.selection = selection;
|
|
24
|
+
this.peers = peers;
|
|
25
|
+
this.xfield = xfield || getField(mark, ['x', 'x1', 'x2']);
|
|
26
|
+
this.yfield = yfield || getField(mark, ['y', 'y1', 'y2']);
|
|
27
|
+
this.style = style && sanitizeStyles(style);
|
|
28
|
+
this.brush = brush();
|
|
29
|
+
this.brush.on('brush end', ({ selection }) => this.publish(selection));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
reset() {
|
|
33
|
+
this.value = undefined;
|
|
34
|
+
if (this.g) this.brush.reset(this.g);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
activate() {
|
|
38
|
+
this.selection.activate(this.clause(this.value || [[0, 1], [0, 1]]));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
publish(extent) {
|
|
42
|
+
const { value, pixelSize, xscale, yscale } = this;
|
|
43
|
+
let xr = undefined;
|
|
44
|
+
let yr = undefined;
|
|
45
|
+
if (extent) {
|
|
46
|
+
const [a, b] = extent;
|
|
47
|
+
xr = [a[0], b[0]].map(v => invert(v, xscale, pixelSize)).sort(asc);
|
|
48
|
+
yr = [a[1], b[1]].map(v => invert(v, yscale, pixelSize)).sort(asc);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!closeTo(xr, value?.[0]) || !closeTo(yr, value?.[1])) {
|
|
52
|
+
this.value = extent ? [xr, yr] : undefined;
|
|
53
|
+
this.g.call(this.brush.moveSilent, extent);
|
|
54
|
+
this.selection.update(this.clause(this.value));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
clause(value) {
|
|
59
|
+
const { mark, pixelSize, xfield, yfield, xscale, yscale } = this;
|
|
60
|
+
return {
|
|
61
|
+
source: this,
|
|
62
|
+
schema: { type: 'interval', pixelSize, scales: [xscale, yscale] },
|
|
63
|
+
clients: this.peers ? mark.plot.markSet : new Set().add(mark),
|
|
64
|
+
value,
|
|
65
|
+
predicate: value
|
|
66
|
+
? and(isBetween(xfield, value[0]), isBetween(yfield, value[1]))
|
|
67
|
+
: null
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
init(svg) {
|
|
72
|
+
const { brush, style } = this;
|
|
73
|
+
const xscale = this.xscale = svg.scale('x');
|
|
74
|
+
const yscale = this.yscale = svg.scale('y');
|
|
75
|
+
const rx = xscale.range;
|
|
76
|
+
const ry = yscale.range;
|
|
77
|
+
brush.extent([[min(rx), min(ry)], [max(rx), max(ry)]]);
|
|
78
|
+
|
|
79
|
+
const facets = select(svg).selectAll('g[aria-label="facet"]');
|
|
80
|
+
const root = facets.size() ? facets : select(svg);
|
|
81
|
+
this.g = root
|
|
82
|
+
.append('g')
|
|
83
|
+
.attr('class', `interval-xy`)
|
|
84
|
+
.each(patchScreenCTM)
|
|
85
|
+
.call(brush);
|
|
86
|
+
|
|
87
|
+
if (style) {
|
|
88
|
+
const brushes = this.g.selectAll('rect.selection');
|
|
89
|
+
for (const name in style) {
|
|
90
|
+
brushes.attr(name, style[name]);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (this.value) {
|
|
95
|
+
const [x1, x2] = this.value[0].map(xscale.apply).sort(asc);
|
|
96
|
+
const [y1, y2] = this.value[1].map(yscale.apply).sort(asc);
|
|
97
|
+
this.g.call(brush.moveSilent, [[x1, y1], [x2, y2]]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
svg.addEventListener('pointerenter', () => this.activate());
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { isSelection } from '@uwdata/mosaic-core';
|
|
2
|
+
import { eq, literal } from '@uwdata/mosaic-sql';
|
|
3
|
+
import { select, pointer } from 'd3';
|
|
4
|
+
import { getField } from './util/get-field.js';
|
|
5
|
+
|
|
6
|
+
export class Nearest {
|
|
7
|
+
constructor(mark, {
|
|
8
|
+
selection,
|
|
9
|
+
channel,
|
|
10
|
+
field
|
|
11
|
+
}) {
|
|
12
|
+
this.mark = mark;
|
|
13
|
+
this.selection = selection;
|
|
14
|
+
this.clients = new Set().add(mark);
|
|
15
|
+
this.channel = channel;
|
|
16
|
+
this.field = field || getField(mark, [channel]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
clause(value) {
|
|
20
|
+
const { clients, field } = this;
|
|
21
|
+
const predicate = value ? eq(field, literal(value)) : null;
|
|
22
|
+
return {
|
|
23
|
+
source: this,
|
|
24
|
+
schema: { type: 'point' },
|
|
25
|
+
clients,
|
|
26
|
+
value,
|
|
27
|
+
predicate
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
init(svg) {
|
|
32
|
+
const that = this;
|
|
33
|
+
const { mark, channel, selection } = this;
|
|
34
|
+
const { data } = mark;
|
|
35
|
+
const key = mark.channelField(channel).as;
|
|
36
|
+
|
|
37
|
+
const facets = select(svg).selectAll('g[aria-label="facet"]');
|
|
38
|
+
const root = facets.size() ? facets : select(svg);
|
|
39
|
+
const scale = svg.scale(channel);
|
|
40
|
+
const param = !isSelection(selection);
|
|
41
|
+
|
|
42
|
+
root.on('pointerdown pointermove', function(evt) {
|
|
43
|
+
const [x, y] = pointer(evt, this);
|
|
44
|
+
const z = findNearest(data, key, scale.invert(channel === 'x' ? x : y));
|
|
45
|
+
selection.update(param ? z : that.clause(z));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (param) return;
|
|
49
|
+
svg.addEventListener('pointerenter', () => {
|
|
50
|
+
this.selection.activate(this.clause(0));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findNearest(data, key, value) {
|
|
56
|
+
let dist = Infinity;
|
|
57
|
+
let v;
|
|
58
|
+
data.forEach(d => {
|
|
59
|
+
const delta = Math.abs(d[key] - value);
|
|
60
|
+
if (delta < dist) {
|
|
61
|
+
dist = delta;
|
|
62
|
+
v = d[key];
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
return v;
|
|
66
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { select, zoom, ZoomTransform } from 'd3';
|
|
2
|
+
import { Selection } from '@uwdata/mosaic-core';
|
|
3
|
+
import { isBetween } from '@uwdata/mosaic-sql';
|
|
4
|
+
import { getField } from './util/get-field.js';
|
|
5
|
+
|
|
6
|
+
const asc = (a, b) => a - b;
|
|
7
|
+
|
|
8
|
+
export class PanZoom {
|
|
9
|
+
constructor(mark, {
|
|
10
|
+
x = new Selection(),
|
|
11
|
+
y = new Selection(),
|
|
12
|
+
xfield,
|
|
13
|
+
yfield,
|
|
14
|
+
zoom = true,
|
|
15
|
+
panx = true,
|
|
16
|
+
pany = true
|
|
17
|
+
}) {
|
|
18
|
+
this.mark = mark;
|
|
19
|
+
this.xsel = x;
|
|
20
|
+
this.ysel = y;
|
|
21
|
+
this.xfield = xfield || getField(mark, ['x', 'x1', 'x2']);
|
|
22
|
+
this.yfield = yfield || getField(mark, ['y', 'y1', 'y2']);
|
|
23
|
+
this.zoom = extent(zoom, [0, Infinity], [1, 1]);
|
|
24
|
+
this.panx = this.xsel && panx;
|
|
25
|
+
this.pany = this.ysel && pany;
|
|
26
|
+
|
|
27
|
+
const { plot } = mark;
|
|
28
|
+
if (panx) {
|
|
29
|
+
this.xsel.addEventListener('value', value => {
|
|
30
|
+
if (plot.setAttribute('xDomain', value)) plot.update();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
if (pany) {
|
|
34
|
+
this.ysel.addEventListener('value', value => {
|
|
35
|
+
if (plot.setAttribute('yDomain', value)) plot.update();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
publish(transform) {
|
|
41
|
+
if (this.panx) {
|
|
42
|
+
const xdom = rescaleX(transform, this.xscale);
|
|
43
|
+
this.xsel.update(this.clause(xdom, this.xfield, this.xscale));
|
|
44
|
+
}
|
|
45
|
+
if (this.pany) {
|
|
46
|
+
const ydom = rescaleY(transform, this.yscale);
|
|
47
|
+
this.ysel.update(this.clause(ydom, this.yfield, this.yscale));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
clause(value, field, scale) {
|
|
52
|
+
return {
|
|
53
|
+
source: this,
|
|
54
|
+
schema: { type: 'interval', scales: [scale] },
|
|
55
|
+
clients: this.mark.plot.markSet,
|
|
56
|
+
value,
|
|
57
|
+
predicate: value ? isBetween(field, value) : null
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
init(svg) {
|
|
62
|
+
this.svg = svg;
|
|
63
|
+
if (this.initialized) return; else this.initialized = true;
|
|
64
|
+
|
|
65
|
+
const { panx, pany, mark: { plot: { element } }, xsel, ysel } = this;
|
|
66
|
+
|
|
67
|
+
this.xscale = svg.scale('x');
|
|
68
|
+
this.yscale = svg.scale('y');
|
|
69
|
+
const rx = this.xscale.range.slice().sort(asc);
|
|
70
|
+
const ry = this.yscale.range.slice().sort(asc);
|
|
71
|
+
const tx = extent(panx, [-Infinity, Infinity], rx);
|
|
72
|
+
const ty = extent(pany, [-Infinity, Infinity], ry);
|
|
73
|
+
|
|
74
|
+
const z = zoom()
|
|
75
|
+
.extent([[rx[0], ry[0]], [rx[1], ry[1]]])
|
|
76
|
+
.scaleExtent(this.zoom)
|
|
77
|
+
.translateExtent([[tx[0], ty[0]], [tx[1], ty[1]]])
|
|
78
|
+
.on('start', () => {
|
|
79
|
+
this.xscale = this.svg.scale('x');
|
|
80
|
+
this.yscale = this.svg.scale('y');
|
|
81
|
+
})
|
|
82
|
+
.on('end', () => element.__zoom = new ZoomTransform(1, 0, 0))
|
|
83
|
+
.on('zoom', ({ transform }) => this.publish(transform));
|
|
84
|
+
|
|
85
|
+
select(element).call(z);
|
|
86
|
+
|
|
87
|
+
if (panx || pany) {
|
|
88
|
+
let enter = false;
|
|
89
|
+
element.addEventListener('mouseenter', () => {
|
|
90
|
+
if (enter) return; else enter = true;
|
|
91
|
+
if (panx) {
|
|
92
|
+
const { xscale, xfield } = this;
|
|
93
|
+
xsel.activate(this.clause(xscale.domain, xfield, xscale));
|
|
94
|
+
}
|
|
95
|
+
if (pany) {
|
|
96
|
+
const { yscale, yfield } = this;
|
|
97
|
+
ysel.activate(this.clause(yscale.domain, yfield, yscale));
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
element.addEventListener('mouseleave', () => enter = false);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function extent(ext, defaultTrue, defaultFalse) {
|
|
106
|
+
return ext
|
|
107
|
+
? (Array.isArray(ext) ? ext : defaultTrue)
|
|
108
|
+
: defaultFalse;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function rescaleX(transform, scale) {
|
|
112
|
+
return scale.range
|
|
113
|
+
.map(transform.invertX, transform)
|
|
114
|
+
.map(scale.invert, scale);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function rescaleY(transform, scale) {
|
|
118
|
+
return scale.range
|
|
119
|
+
.map(transform.invertY, transform)
|
|
120
|
+
.map(scale.invert, scale);
|
|
121
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { and, or, isNotDistinct, literal } from '@uwdata/mosaic-sql';
|
|
2
|
+
|
|
3
|
+
export class Toggle {
|
|
4
|
+
constructor(mark, {
|
|
5
|
+
selection,
|
|
6
|
+
channels,
|
|
7
|
+
peers = true
|
|
8
|
+
}) {
|
|
9
|
+
this.value = null;
|
|
10
|
+
this.mark = mark;
|
|
11
|
+
this.selection = selection;
|
|
12
|
+
this.peers = peers;
|
|
13
|
+
this.channels = channels.map(c => {
|
|
14
|
+
const q = c === 'color' ? ['fill', 'stroke']
|
|
15
|
+
: c === 'x' ? ['x', 'x1', 'x2']
|
|
16
|
+
: c === 'y' ? ['y', 'y1', 'y2']
|
|
17
|
+
: [c];
|
|
18
|
+
for (let i = 0; i < q.length; ++i) {
|
|
19
|
+
const f = mark.channelField(q[i]);
|
|
20
|
+
if (f) return {
|
|
21
|
+
field: f.field?.basis || f.field,
|
|
22
|
+
as: f.as
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
throw new Error(`Missing channel: ${c}`);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
clause(value) {
|
|
30
|
+
const { channels, mark } = this;
|
|
31
|
+
let predicate = null;
|
|
32
|
+
|
|
33
|
+
if (value) {
|
|
34
|
+
const clauses = value.map(vals => {
|
|
35
|
+
const list = vals.map((v, i) => {
|
|
36
|
+
return isNotDistinct(channels[i].field, literal(v));
|
|
37
|
+
});
|
|
38
|
+
return list.length > 1 ? and(list) : list[0];
|
|
39
|
+
});
|
|
40
|
+
predicate = clauses.length > 1 ? or(clauses) : clauses[0];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
source: this,
|
|
45
|
+
schema: { type: 'point' },
|
|
46
|
+
clients: this.peers ? mark.plot.markSet : new Set().add(mark),
|
|
47
|
+
value,
|
|
48
|
+
predicate
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
init(svg, selector, accessor) {
|
|
53
|
+
const { mark, channels, selection } = this;
|
|
54
|
+
const { data } = mark;
|
|
55
|
+
accessor = accessor || (target => {
|
|
56
|
+
const datum = data[target.__data__];
|
|
57
|
+
return channels.map(c => datum[c.as]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
selector = selector || `[data-index="${mark.index}"]`;
|
|
61
|
+
const groups = new Set(svg.querySelectorAll(selector));
|
|
62
|
+
|
|
63
|
+
svg.addEventListener('pointerdown', evt => {
|
|
64
|
+
const state = selection.single ? selection.value : this.value;
|
|
65
|
+
const target = evt.target;
|
|
66
|
+
let value = null;
|
|
67
|
+
|
|
68
|
+
if (isTargetElement(groups, target)) {
|
|
69
|
+
const point = accessor(target);
|
|
70
|
+
if (evt.shiftKey && state?.length) {
|
|
71
|
+
value = state.filter(s => neq(s, point));
|
|
72
|
+
if (value.length === state.length) value.push(point);
|
|
73
|
+
} else if (state?.length === 1 && !neq(state[0], point)) {
|
|
74
|
+
value = null;
|
|
75
|
+
} else {
|
|
76
|
+
value = [point];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this.value = value;
|
|
81
|
+
if (neqSome(state, value)) {
|
|
82
|
+
selection.update(this.clause(value));
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
svg.addEventListener('pointerenter', () => {
|
|
87
|
+
this.selection.activate(this.clause([this.channels.map(() => 0)]));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isTargetElement(groups, node) {
|
|
93
|
+
return groups.has(node)
|
|
94
|
+
|| groups.has(node.parentNode)
|
|
95
|
+
|| groups.has(node.parentNode?.parentNode);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function neqSome(a, b) {
|
|
99
|
+
return (a == null || b == null)
|
|
100
|
+
? (a != null || b != null)
|
|
101
|
+
: (a.length !== b.length || a.some((x, i) => neq(x, b[i])));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function neq(a, b) {
|
|
105
|
+
const n = a.length;
|
|
106
|
+
if (b.length !== n) return true;
|
|
107
|
+
for (let i = 0; i < n; ++i) {
|
|
108
|
+
if (a[i] !== b[i]) return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|