@uwdata/mosaic-plot 0.7.1 → 0.9.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 (37) hide show
  1. package/dist/mosaic-plot.js +4702 -5648
  2. package/dist/mosaic-plot.min.js +14 -14
  3. package/package.json +5 -5
  4. package/src/index.js +1 -0
  5. package/src/interactors/Highlight.js +6 -3
  6. package/src/interactors/Interval1D.js +14 -12
  7. package/src/interactors/Interval2D.js +13 -16
  8. package/src/interactors/Nearest.js +80 -36
  9. package/src/interactors/PanZoom.js +7 -9
  10. package/src/interactors/Toggle.js +29 -37
  11. package/src/interactors/util/patchScreenCTM.js +2 -0
  12. package/src/legend.js +150 -29
  13. package/src/marks/ConnectedMark.js +6 -0
  14. package/src/marks/ContourMark.js +36 -16
  15. package/src/marks/DenseLineMark.js +9 -5
  16. package/src/marks/Density1DMark.js +22 -13
  17. package/src/marks/Density2DMark.js +33 -18
  18. package/src/marks/ErrorBarMark.js +50 -0
  19. package/src/marks/GeoMark.js +7 -8
  20. package/src/marks/Grid2DMark.js +58 -28
  21. package/src/marks/HexbinMark.js +10 -2
  22. package/src/marks/Mark.js +56 -16
  23. package/src/marks/RasterMark.js +61 -23
  24. package/src/marks/RasterTileMark.js +39 -20
  25. package/src/marks/RegressionMark.js +69 -34
  26. package/src/marks/util/grid.js +94 -86
  27. package/src/marks/util/handle-param.js +10 -11
  28. package/src/marks/util/is-constant-option.js +2 -1
  29. package/src/marks/util/permute.js +10 -0
  30. package/src/marks/util/stats.js +121 -1
  31. package/src/marks/util/to-data-columns.js +71 -0
  32. package/src/plot-attributes.js +11 -3
  33. package/src/plot-renderer.js +28 -9
  34. package/src/plot.js +20 -0
  35. package/src/transforms/bin.js +3 -1
  36. package/src/marks/util/interpolate.js +0 -205
  37. package/src/marks/util/to-data-array.js +0 -50
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uwdata/mosaic-plot",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "A Mosaic-powered plotting framework based on Observable Plot.",
5
5
  "keywords": [
6
6
  "data",
@@ -23,16 +23,16 @@
23
23
  "scripts": {
24
24
  "prebuild": "rimraf dist && mkdir dist",
25
25
  "build": "node ../../esbuild.js mosaic-plot",
26
- "lint": "eslint src test --ext .js",
26
+ "lint": "eslint src test",
27
27
  "test": "mocha 'test/**/*-test.js'",
28
28
  "prepublishOnly": "npm run test && npm run lint && npm run build"
29
29
  },
30
30
  "dependencies": {
31
31
  "@observablehq/plot": "^0.6.14",
32
- "@uwdata/mosaic-core": "^0.7.1",
33
- "@uwdata/mosaic-sql": "^0.7.0",
32
+ "@uwdata/mosaic-core": "^0.9.0",
33
+ "@uwdata/mosaic-sql": "^0.9.0",
34
34
  "d3": "^7.9.0",
35
35
  "isoformat": "^0.2.1"
36
36
  },
37
- "gitHead": "7e6f3ea9b3011ea2c9201c1aa16e8e5664621a4c"
37
+ "gitHead": "89bb9b0dfa747aed691eaeba35379525a6764c61"
38
38
  }
package/src/index.js CHANGED
@@ -8,6 +8,7 @@ export { ContourMark } from './marks/ContourMark.js';
8
8
  export { DenseLineMark } from './marks/DenseLineMark.js';
9
9
  export { Density1DMark } from './marks/Density1DMark.js';
10
10
  export { Density2DMark } from './marks/Density2DMark.js';
11
+ export { ErrorBarMark } from './marks/ErrorBarMark.js';
11
12
  export { GeoMark } from './marks/GeoMark.js';
12
13
  export { Grid2DMark } from './marks/Grid2DMark.js';
13
14
  export { HexbinMark } from './marks/HexbinMark.js';
@@ -69,7 +69,8 @@ export class Highlight {
69
69
  for (let i = 0; i < nodes.length; ++i) {
70
70
  const node = nodes[i];
71
71
  const base = values[i];
72
- const t = test(node.__data__);
72
+ const data = node.__data__;
73
+ const t = test(Array.isArray(data) ? data[0] : data);
73
74
  // TODO? handle inherited values / remove attributes
74
75
  for (let j = 0; j < channels.length; ++j) {
75
76
  const [attr, value] = channels[j];
@@ -91,9 +92,11 @@ async function predicateFunction(mark, selection) {
91
92
 
92
93
  const s = { __: and(pred) };
93
94
  const q = mark.query(filter);
94
- const p = q.groupby().length ? q.select(s) : q.$select(s);
95
+ (q.queries || [q]).forEach(q => {
96
+ q.groupby().length ? q.select(s) : q.$select(s);
97
+ });
95
98
 
96
- const data = await mark.coordinator.query(p);
99
+ const data = await mark.coordinator.query(q);
97
100
  const v = data.getChild?.('__');
98
101
  return !(data.numRows || data.length) ? (() => false)
99
102
  : v ? (i => v.get(i))
@@ -1,5 +1,5 @@
1
- import { select, min, max } from 'd3';
2
- import { isBetween } from '@uwdata/mosaic-sql';
1
+ import { interval } from '@uwdata/mosaic-core';
2
+ import { ascending, min, max, select } from 'd3';
3
3
  import { brushX, brushY } from './util/brush.js';
4
4
  import { closeTo } from './util/close-to.js';
5
5
  import { getField } from './util/get-field.js';
@@ -11,7 +11,7 @@ export class Interval1D {
11
11
  constructor(mark, {
12
12
  channel,
13
13
  selection,
14
- field,
14
+ field = undefined,
15
15
  pixelSize = 1,
16
16
  peers = true,
17
17
  brush: style
@@ -52,16 +52,15 @@ export class Interval1D {
52
52
 
53
53
  clause(value) {
54
54
  const { mark, pixelSize, field, scale } = this;
55
- return {
55
+ return interval(field, value, {
56
56
  source: this,
57
- schema: { type: 'interval', pixelSize, scales: [scale] },
58
57
  clients: this.peers ? mark.plot.markSet : new Set().add(mark),
59
- value,
60
- predicate: value ? isBetween(field, value) : null
61
- };
58
+ scale,
59
+ pixelSize
60
+ });
62
61
  }
63
62
 
64
- init(svg) {
63
+ init(svg, root) {
65
64
  const { brush, channel, style } = this;
66
65
  this.scale = svg.scale(channel);
67
66
 
@@ -69,14 +68,15 @@ export class Interval1D {
69
68
  const ry = svg.scale('y').range;
70
69
  brush.extent([[min(rx), min(ry)], [max(rx), max(ry)]]);
71
70
 
71
+ const range = this.value?.map(this.scale.apply).sort(ascending);
72
72
  const facets = select(svg).selectAll('g[aria-label="facet"]');
73
- const root = facets.size() ? facets : select(svg);
73
+ root = facets.size() ? facets : select(root ?? svg);
74
74
  this.g = root
75
75
  .append('g')
76
76
  .attr('class', `interval-${channel}`)
77
77
  .each(patchScreenCTM)
78
78
  .call(brush)
79
- .call(brush.moveSilent, this.value?.map(this.scale.apply));
79
+ .call(brush.moveSilent, range);
80
80
 
81
81
  if (style) {
82
82
  const brushes = this.g.selectAll('rect.selection');
@@ -85,6 +85,8 @@ export class Interval1D {
85
85
  }
86
86
  }
87
87
 
88
- svg.addEventListener('pointerenter', () => this.activate());
88
+ svg.addEventListener('pointerenter', evt => {
89
+ if (!evt.buttons) this.activate();
90
+ });
89
91
  }
90
92
  }
@@ -1,5 +1,5 @@
1
- import { select, min, max } from 'd3';
2
- import { and, isBetween } from '@uwdata/mosaic-sql';
1
+ import { intervals } from '@uwdata/mosaic-core';
2
+ import { ascending, min, max, select } from 'd3';
3
3
  import { brush } from './util/brush.js';
4
4
  import { closeTo } from './util/close-to.js';
5
5
  import { getField } from './util/get-field.js';
@@ -7,8 +7,6 @@ import { invert } from './util/invert.js';
7
7
  import { patchScreenCTM } from './util/patchScreenCTM.js';
8
8
  import { sanitizeStyles } from './util/sanitize-styles.js';
9
9
 
10
- const asc = (a, b) => a - b;
11
-
12
10
  export class Interval2D {
13
11
  constructor(mark, {
14
12
  selection,
@@ -44,8 +42,8 @@ export class Interval2D {
44
42
  let yr = undefined;
45
43
  if (extent) {
46
44
  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);
45
+ xr = [a[0], b[0]].map(v => invert(v, xscale, pixelSize)).sort(ascending);
46
+ yr = [a[1], b[1]].map(v => invert(v, yscale, pixelSize)).sort(ascending);
49
47
  }
50
48
 
51
49
  if (!closeTo(xr, value?.[0]) || !closeTo(yr, value?.[1])) {
@@ -57,15 +55,12 @@ export class Interval2D {
57
55
 
58
56
  clause(value) {
59
57
  const { mark, pixelSize, xfield, yfield, xscale, yscale } = this;
60
- return {
58
+ return intervals([xfield, yfield], value, {
61
59
  source: this,
62
- schema: { type: 'interval', pixelSize, scales: [xscale, yscale] },
63
60
  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
- };
61
+ scales: [xscale, yscale],
62
+ pixelSize
63
+ });
69
64
  }
70
65
 
71
66
  init(svg) {
@@ -92,11 +87,13 @@ export class Interval2D {
92
87
  }
93
88
 
94
89
  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);
90
+ const [x1, x2] = this.value[0].map(xscale.apply).sort(ascending);
91
+ const [y1, y2] = this.value[1].map(yscale.apply).sort(ascending);
97
92
  this.g.call(brush.moveSilent, [[x1, y1], [x2, y2]]);
98
93
  }
99
94
 
100
- svg.addEventListener('pointerenter', () => this.activate());
95
+ svg.addEventListener('pointerenter', evt => {
96
+ if (!evt.buttons) this.activate();
97
+ });
101
98
  }
102
99
  }
@@ -1,66 +1,110 @@
1
- import { isSelection } from '@uwdata/mosaic-core';
2
- import { eq, literal } from '@uwdata/mosaic-sql';
1
+ import { isSelection, points } from '@uwdata/mosaic-core';
3
2
  import { select, pointer } from 'd3';
4
3
  import { getField } from './util/get-field.js';
5
4
 
6
5
  export class Nearest {
7
6
  constructor(mark, {
8
7
  selection,
9
- channel,
10
- field
8
+ pointer,
9
+ channels,
10
+ fields,
11
+ maxRadius = 40
11
12
  }) {
12
13
  this.mark = mark;
13
14
  this.selection = selection;
14
15
  this.clients = new Set().add(mark);
15
- this.channel = channel;
16
- this.field = field || getField(mark, [channel]);
16
+ this.pointer = pointer;
17
+ this.channels = channels || (
18
+ pointer === 'x' ? ['x'] : pointer === 'y' ? ['y'] : ['x', 'y']
19
+ );
20
+ this.fields = fields || this.channels.map(c => getField(mark, [c]));
21
+ this.maxRadius = maxRadius;
22
+ this.valueIndex = -1;
17
23
  }
18
24
 
19
25
  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
- };
26
+ const { clients, fields } = this;
27
+ return points(fields, value ? [value] : value, { source: this, clients });
29
28
  }
30
29
 
31
30
  init(svg) {
32
31
  const that = this;
33
- const { mark, channel, selection } = this;
34
- const { data } = mark;
35
- const key = mark.channelField(channel).as;
32
+ const { mark, channels, selection, maxRadius } = this;
33
+ const { data: { columns } } = mark;
34
+ const keys = channels.map(c => mark.channelField(c).as);
35
+ const param = !isSelection(selection);
36
36
 
37
37
  const facets = select(svg).selectAll('g[aria-label="facet"]');
38
38
  const root = facets.size() ? facets : select(svg);
39
- const scale = svg.scale(channel);
40
- const param = !isSelection(selection);
41
39
 
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));
40
+ // extract x, y coordinates for data values and determine scale factors
41
+ const xscale = svg.scale('x').apply;
42
+ const yscale = svg.scale('y').apply;
43
+ const X = Array.from(columns[mark.channelField('x').as], xscale);
44
+ const Y = Array.from(columns[mark.channelField('y').as], yscale);
45
+ const sx = this.pointer === 'y' ? 0.01 : 1;
46
+ const sy = this.pointer === 'x' ? 0.01 : 1;
47
+
48
+ // find value nearest to pointer and update param or selection
49
+ // we don't pass undefined values to params, but do allow empty selections
50
+ root.on('pointerenter pointerdown pointermove', function(evt) {
51
+ const [px, py] = pointer(evt, this);
52
+ const i = findNearest(X, Y, px, py, sx, sy, maxRadius);
53
+ if (i !== this.valueIndex) {
54
+ this.valueIndex = i;
55
+ const v = i < 0 ? undefined : keys.map(k => columns[k][i]);
56
+ if (param) {
57
+ if (i > -1) selection.update(v.length > 1 ? v : v[0]);
58
+ } else {
59
+ selection.update(that.clause(v));
60
+ }
61
+ }
46
62
  });
47
63
 
64
+ // if not a selection, we're done
48
65
  if (param) return;
49
- svg.addEventListener('pointerenter', () => {
50
- this.selection.activate(this.clause(0));
66
+
67
+ // clear selection upon pointer exit
68
+ root.on('pointerleave', () => {
69
+ selection.update(that.clause(undefined));
70
+ });
71
+
72
+ // trigger activation updates
73
+ svg.addEventListener('pointerenter', evt => {
74
+ if (!evt.buttons) {
75
+ const v = this.channels.map(() => 0);
76
+ selection.activate(this.clause(v));
77
+ }
51
78
  });
52
79
  }
53
80
  }
54
81
 
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];
82
+ /**
83
+ * Find the nearest data point to the pointer. The nearest point
84
+ * is found via Euclidean distance, but with scale factors *sx* and
85
+ * *sy* applied to the x and y distances. For example, to prioritize
86
+ * selection along the x-axis, use *sx* = 1, *sy* = 0.01.
87
+ * @param {number[]} x Array of data point x coordinate values.
88
+ * @param {number[]} y Array of data point y coordinate values.
89
+ * @param {number} px The x coordinate of the pointer.
90
+ * @param {number} py The y coordinate of the pointer.
91
+ * @param {number} sx A scale factor for x coordinate spans.
92
+ * @param {number} sy A scale factor for y coordinate spans.
93
+ * @param {number} maxRadius The maximum pointer distance for selection.
94
+ * @returns {number} An integer index into the data array corresponding
95
+ * to the nearest data point, or -1 if no nearest point is found.
96
+ */
97
+ function findNearest(x, y, px, py, sx, sy, maxRadius) {
98
+ let dist = maxRadius * maxRadius;
99
+ let nearest = -1;
100
+ for (let i = 0; i < x.length; ++i) {
101
+ const dx = sx * (x[i] - px);
102
+ const dy = sy * (y[i] - py);
103
+ const dd = dx * dx + dy * dy;
104
+ if (dd <= dist) {
105
+ dist = dd;
106
+ nearest = i;
63
107
  }
64
- });
65
- return v;
108
+ }
109
+ return nearest;
66
110
  }
@@ -1,6 +1,5 @@
1
+ import { interval, Selection } from '@uwdata/mosaic-core';
1
2
  import { select, zoom, ZoomTransform } from 'd3';
2
- import { Selection } from '@uwdata/mosaic-core';
3
- import { isBetween } from '@uwdata/mosaic-sql';
4
3
  import { getField } from './util/get-field.js';
5
4
 
6
5
  const asc = (a, b) => a - b;
@@ -49,13 +48,11 @@ export class PanZoom {
49
48
  }
50
49
 
51
50
  clause(value, field, scale) {
52
- return {
51
+ return interval(field, value, {
53
52
  source: this,
54
- schema: { type: 'interval', scales: [scale] },
55
53
  clients: this.mark.plot.markSet,
56
- value,
57
- predicate: value ? isBetween(field, value) : null
58
- };
54
+ scale
55
+ });
59
56
  }
60
57
 
61
58
  init(svg) {
@@ -86,8 +83,9 @@ export class PanZoom {
86
83
 
87
84
  if (panx || pany) {
88
85
  let enter = false;
89
- element.addEventListener('mouseenter', () => {
86
+ element.addEventListener('pointerenter', evt => {
90
87
  if (enter) return; else enter = true;
88
+ if (evt.buttons) return; // don't activate if mouse down
91
89
  if (panx) {
92
90
  const { xscale, xfield } = this;
93
91
  xsel.activate(this.clause(xscale.domain, xfield, xscale));
@@ -97,7 +95,7 @@ export class PanZoom {
97
95
  ysel.activate(this.clause(yscale.domain, yfield, yscale));
98
96
  }
99
97
  });
100
- element.addEventListener('mouseleave', () => enter = false);
98
+ element.addEventListener('pointerleave', () => enter = false);
101
99
  }
102
100
  }
103
101
  }
@@ -1,6 +1,10 @@
1
- import { and, or, isNotDistinct, literal } from '@uwdata/mosaic-sql';
1
+ import { points } from '@uwdata/mosaic-core';
2
2
 
3
3
  export class Toggle {
4
+ /**
5
+ * @param {*} mark The mark to interact with.
6
+ * @param {*} options The interactor options.
7
+ */
4
8
  constructor(mark, {
5
9
  selection,
6
10
  channels,
@@ -10,54 +14,41 @@ export class Toggle {
10
14
  this.mark = mark;
11
15
  this.selection = selection;
12
16
  this.peers = peers;
13
- this.channels = channels.map(c => {
14
- const q = c === 'color' ? ['fill', 'stroke']
17
+ const fields = this.fields = [];
18
+ const as = this.as = [];
19
+ channels.forEach(c => {
20
+ const q = c === 'color' ? ['color', 'fill', 'stroke']
15
21
  : c === 'x' ? ['x', 'x1', 'x2']
16
22
  : c === 'y' ? ['y', 'y1', 'y2']
17
23
  : [c];
18
24
  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
- };
25
+ const f = mark.channelField(q[i], { exact: true });
26
+ if (f) {
27
+ fields.push(f.field?.basis || f.field);
28
+ as.push(f.as);
29
+ return;
30
+ }
24
31
  }
25
32
  throw new Error(`Missing channel: ${c}`);
26
33
  });
27
34
  }
28
35
 
29
36
  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 {
37
+ const { fields, mark } = this;
38
+ return points(fields, value, {
44
39
  source: this,
45
- schema: { type: 'point' },
46
- clients: this.peers ? mark.plot.markSet : new Set().add(mark),
47
- value,
48
- predicate
49
- };
40
+ clients: this.peers ? mark.plot.markSet : new Set().add(mark)
41
+ });
50
42
  }
51
43
 
52
44
  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]);
45
+ const { mark, as, selection } = this;
46
+ const { data: { columns = {} } = {} } = mark;
47
+ accessor ??= target => as.map(name => {
48
+ const data = target.__data__;
49
+ return columns[name][Array.isArray(data) ? data[0] : data];
58
50
  });
59
-
60
- selector = selector || `[data-index="${mark.index}"]`;
51
+ selector ??= `[data-index="${mark.index}"]`;
61
52
  const groups = new Set(svg.querySelectorAll(selector));
62
53
 
63
54
  svg.addEventListener('pointerdown', evt => {
@@ -67,7 +58,7 @@ export class Toggle {
67
58
 
68
59
  if (isTargetElement(groups, target)) {
69
60
  const point = accessor(target);
70
- if (evt.shiftKey && state?.length) {
61
+ if ((evt.shiftKey || evt.metaKey) && state?.length) {
71
62
  value = state.filter(s => neq(s, point));
72
63
  if (value.length === state.length) value.push(point);
73
64
  } else if (state?.length === 1 && !neq(state[0], point)) {
@@ -83,8 +74,9 @@ export class Toggle {
83
74
  }
84
75
  });
85
76
 
86
- svg.addEventListener('pointerenter', () => {
87
- this.selection.activate(this.clause([this.channels.map(() => 0)]));
77
+ svg.addEventListener('pointerenter', evt => {
78
+ if (evt.buttons) return;
79
+ this.selection.activate(this.clause([this.fields.map(() => 0)]));
88
80
  });
89
81
  }
90
82
  }
@@ -4,6 +4,8 @@
4
4
  * even after the node is removed from the DOM.
5
5
  */
6
6
  export function patchScreenCTM() {
7
+ /** @type {SVGGraphicsElement} */
8
+ // @ts-ignore
7
9
  const node = this;
8
10
  const getScreenCTM = node.getScreenCTM;
9
11
  let memo;