@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.
Files changed (50) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +6 -0
  3. package/dist/mosaic-plot.js +42313 -0
  4. package/dist/mosaic-plot.min.js +69 -0
  5. package/package.json +38 -0
  6. package/src/index.js +30 -0
  7. package/src/interactors/Highlight.js +101 -0
  8. package/src/interactors/Interval1D.js +90 -0
  9. package/src/interactors/Interval2D.js +102 -0
  10. package/src/interactors/Nearest.js +66 -0
  11. package/src/interactors/PanZoom.js +121 -0
  12. package/src/interactors/Toggle.js +111 -0
  13. package/src/interactors/util/brush.js +45 -0
  14. package/src/interactors/util/close-to.js +9 -0
  15. package/src/interactors/util/get-field.js +4 -0
  16. package/src/interactors/util/invert.js +3 -0
  17. package/src/interactors/util/patchScreenCTM.js +13 -0
  18. package/src/interactors/util/sanitize-styles.js +9 -0
  19. package/src/interactors/util/to-kebab-case.js +9 -0
  20. package/src/legend.js +64 -0
  21. package/src/marks/ConnectedMark.js +66 -0
  22. package/src/marks/ContourMark.js +89 -0
  23. package/src/marks/DenseLineMark.js +146 -0
  24. package/src/marks/Density1DMark.js +104 -0
  25. package/src/marks/Density2DMark.js +69 -0
  26. package/src/marks/GeoMark.js +35 -0
  27. package/src/marks/Grid2DMark.js +191 -0
  28. package/src/marks/HexbinMark.js +88 -0
  29. package/src/marks/Mark.js +207 -0
  30. package/src/marks/RasterMark.js +121 -0
  31. package/src/marks/RasterTileMark.js +331 -0
  32. package/src/marks/RegressionMark.js +117 -0
  33. package/src/marks/util/bin-field.js +17 -0
  34. package/src/marks/util/density.js +226 -0
  35. package/src/marks/util/extent.js +56 -0
  36. package/src/marks/util/grid.js +57 -0
  37. package/src/marks/util/handle-param.js +14 -0
  38. package/src/marks/util/is-arrow-table.js +3 -0
  39. package/src/marks/util/is-color.js +18 -0
  40. package/src/marks/util/is-constant-option.js +41 -0
  41. package/src/marks/util/is-symbol.js +20 -0
  42. package/src/marks/util/raster.js +44 -0
  43. package/src/marks/util/stats.js +133 -0
  44. package/src/marks/util/to-data-array.js +70 -0
  45. package/src/plot-attributes.js +212 -0
  46. package/src/plot-renderer.js +161 -0
  47. package/src/plot.js +136 -0
  48. package/src/symbols.js +3 -0
  49. package/src/transforms/bin.js +81 -0
  50. 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
+ }