@uwdata/mosaic-plot 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uwdata/mosaic-plot",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "A Mosaic-powered plotting framework based on Observable Plot.",
5
5
  "keywords": [
6
6
  "data",
@@ -29,10 +29,10 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@observablehq/plot": "^0.6.16",
32
- "@uwdata/mosaic-core": "^0.11.0",
33
- "@uwdata/mosaic-sql": "^0.11.0",
32
+ "@uwdata/mosaic-core": "^0.12.1",
33
+ "@uwdata/mosaic-sql": "^0.12.1",
34
34
  "d3": "^7.9.0",
35
35
  "isoformat": "^0.2.1"
36
36
  },
37
- "gitHead": "861d616f39926a1d2aee83b59dbdd70b0b3caf12"
37
+ "gitHead": "fe3a7c34352da54ec36a1ebf557846f46a649782"
38
38
  }
package/src/index.js CHANGED
@@ -22,10 +22,11 @@ export { Interval1D } from './interactors/Interval1D.js';
22
22
  export { Interval2D } from './interactors/Interval2D.js';
23
23
  export { Nearest } from './interactors/Nearest.js';
24
24
  export { PanZoom } from './interactors/PanZoom.js';
25
+ export { Region } from './interactors/Region.js';
25
26
  export { Toggle } from './interactors/Toggle.js';
26
27
 
27
28
  // legend
28
29
  export { Legend } from './legend.js';
29
30
 
30
31
  // transforms
31
- export { bin } from './transforms/index.js';
32
+ export { bin } from './transforms/bin.js';
@@ -1,5 +1,6 @@
1
1
  import { throttle } from '@uwdata/mosaic-core';
2
- import { and } from '@uwdata/mosaic-sql';
2
+ import { and, isAggregateExpression } from '@uwdata/mosaic-sql';
3
+ import { getDatum } from './util/get-datum.js';
3
4
  import { sanitizeStyles } from './util/sanitize-styles.js';
4
5
 
5
6
  function configureMark(mark) {
@@ -13,7 +14,7 @@ function configureMark(mark) {
13
14
  if (channel === 'orderby') {
14
15
  ordered = true;
15
16
  } else if (field) {
16
- if (field.aggregate) {
17
+ if (isAggregateExpression(field)) {
17
18
  aggregate = true;
18
19
  } else {
19
20
  if (dims.has(as)) continue;
@@ -49,7 +50,9 @@ export class Highlight {
49
50
  this.svg = svg;
50
51
  const values = this.values = [];
51
52
  const index = this.mark.index;
52
- const nodes = this.nodes = svg.querySelectorAll(`[data-index="${index}"] > *`);
53
+ const g = `g[data-index="${index}"]`;
54
+ const selector = `${g} > *:not(g), ${g} > g > *`;
55
+ const nodes = this.nodes = svg.querySelectorAll(selector);
53
56
 
54
57
  const { channels } = this;
55
58
  for (let i = 0; i < nodes.length; ++i) {
@@ -69,8 +72,7 @@ export class Highlight {
69
72
  for (let i = 0; i < nodes.length; ++i) {
70
73
  const node = nodes[i];
71
74
  const base = values[i];
72
- const data = node.__data__;
73
- const t = test(Array.isArray(data) ? data[0] : data);
75
+ const t = test(getDatum(node));
74
76
  // TODO? handle inherited values / remove attributes
75
77
  for (let j = 0; j < channels.length; ++j) {
76
78
  const [attr, value] = channels[j];
@@ -93,7 +95,7 @@ async function predicateFunction(mark, selection) {
93
95
  const s = { __: and(pred) };
94
96
  const q = mark.query(filter);
95
97
  (q.queries || [q]).forEach(q => {
96
- q.groupby().length ? q.select(s) : q.$select(s);
98
+ q._groupby.length ? q.select(s) : q.setSelect(s);
97
99
  });
98
100
 
99
101
  const data = await mark.coordinator.query(q);
@@ -1,10 +1,9 @@
1
1
  import { clauseInterval } from '@uwdata/mosaic-core';
2
- import { ascending, min, max, select } from 'd3';
3
- import { brushX, brushY } from './util/brush.js';
2
+ import { ascending, min, max } from 'd3';
3
+ import { brushGroups, brushX, brushY } from './util/brush.js';
4
4
  import { closeTo } from './util/close-to.js';
5
5
  import { getField } from './util/get-field.js';
6
6
  import { invert } from './util/invert.js';
7
- import { patchScreenCTM } from './util/patchScreenCTM.js';
8
7
  import { sanitizeStyles } from './util/sanitize-styles.js';
9
8
 
10
9
  export class Interval1D {
@@ -63,18 +62,12 @@ export class Interval1D {
63
62
  init(svg, root) {
64
63
  const { brush, channel, style } = this;
65
64
  this.scale = svg.scale(channel);
65
+ const range = this.value?.map(this.scale.apply).sort(ascending);
66
66
 
67
67
  const rx = svg.scale('x').range;
68
68
  const ry = svg.scale('y').range;
69
69
  brush.extent([[min(rx), min(ry)], [max(rx), max(ry)]]);
70
-
71
- const range = this.value?.map(this.scale.apply).sort(ascending);
72
- const facets = select(svg).selectAll('g[aria-label="facet"]');
73
- root = facets.size() ? facets : select(root ?? svg);
74
- this.g = root
75
- .append('g')
76
- .attr('class', `interval-${channel}`)
77
- .each(patchScreenCTM)
70
+ this.g = brushGroups(svg, root, min(rx), min(ry), `interval-${channel}`)
78
71
  .call(brush)
79
72
  .call(brush.moveSilent, range);
80
73
 
@@ -1,10 +1,9 @@
1
1
  import { clauseIntervals } from '@uwdata/mosaic-core';
2
- import { ascending, min, max, select } from 'd3';
3
- import { brush } from './util/brush.js';
2
+ import { ascending, min, max } from 'd3';
3
+ import { brush, brushGroups } from './util/brush.js';
4
4
  import { closeTo } from './util/close-to.js';
5
5
  import { getField } from './util/get-field.js';
6
6
  import { invert } from './util/invert.js';
7
- import { patchScreenCTM } from './util/patchScreenCTM.js';
8
7
  import { sanitizeStyles } from './util/sanitize-styles.js';
9
8
 
10
9
  export class Interval2D {
@@ -38,6 +37,7 @@ export class Interval2D {
38
37
 
39
38
  publish(extent) {
40
39
  const { value, pixelSize, xscale, yscale } = this;
40
+
41
41
  let xr = undefined;
42
42
  let yr = undefined;
43
43
  if (extent) {
@@ -64,20 +64,13 @@ export class Interval2D {
64
64
  }
65
65
 
66
66
  init(svg) {
67
- const { brush, style } = this;
67
+ const { brush, style, value } = this;
68
68
  const xscale = this.xscale = svg.scale('x');
69
69
  const yscale = this.yscale = svg.scale('y');
70
70
  const rx = xscale.range;
71
71
  const ry = yscale.range;
72
72
  brush.extent([[min(rx), min(ry)], [max(rx), max(ry)]]);
73
-
74
- const facets = select(svg).selectAll('g[aria-label="facet"]');
75
- const root = facets.size() ? facets : select(svg);
76
- this.g = root
77
- .append('g')
78
- .attr('class', `interval-xy`)
79
- .each(patchScreenCTM)
80
- .call(brush);
73
+ this.g = brushGroups(svg, null, min(rx), min(ry), 'interval-xy').call(brush);
81
74
 
82
75
  if (style) {
83
76
  const brushes = this.g.selectAll('rect.selection');
@@ -86,9 +79,9 @@ export class Interval2D {
86
79
  }
87
80
  }
88
81
 
89
- if (this.value) {
90
- const [x1, x2] = this.value[0].map(xscale.apply).sort(ascending);
91
- const [y1, y2] = this.value[1].map(yscale.apply).sort(ascending);
82
+ if (value) {
83
+ const [x1, x2] = value[0].map(xscale.apply).sort(ascending);
84
+ const [y1, y2] = value[1].map(yscale.apply).sort(ascending);
92
85
  this.g.call(brush.moveSilent, [[x1, y1], [x2, y2]]);
93
86
  }
94
87
 
@@ -1,5 +1,5 @@
1
1
  import { clausePoint, clausePoints, isSelection } from '@uwdata/mosaic-core';
2
- import { select, pointer } from 'd3';
2
+ import { select, pointer, min } from 'd3';
3
3
  import { getField } from './util/get-field.js';
4
4
 
5
5
  export class Nearest {
@@ -39,17 +39,13 @@ export class Nearest {
39
39
  const keys = channels.map(c => mark.channelField(c).as);
40
40
  const param = !isSelection(selection);
41
41
 
42
- const facets = select(svg).selectAll('g[aria-label="facet"]');
43
- const root = facets.size() ? facets : select(svg);
44
-
45
42
  // extract x, y coordinates for data values and determine scale factors
46
- const xscale = svg.scale('x').apply;
47
- const yscale = svg.scale('y').apply;
48
- const X = Array.from(columns[mark.channelField('x').as], xscale);
49
- const Y = Array.from(columns[mark.channelField('y').as], yscale);
43
+ const [X, Y] = calculateXY(svg, mark);
50
44
  const sx = this.pointer === 'y' ? 0.01 : 1;
51
45
  const sy = this.pointer === 'x' ? 0.01 : 1;
52
46
 
47
+ const root = select(svg);
48
+
53
49
  // find value nearest to pointer and update param or selection
54
50
  // we don't pass undefined values to params, but do allow empty selections
55
51
  root.on('pointerenter pointerdown pointermove', function(evt) {
@@ -83,6 +79,41 @@ export class Nearest {
83
79
  }
84
80
  }
85
81
 
82
+ /**
83
+ * Extract x, y coordinates for data values.
84
+ */
85
+ function calculateXY(svg, mark) {
86
+ const { data: { columns } } = mark;
87
+ const data = c => columns[mark.channelField(c)?.as];
88
+ const scale = c => svg.scale(c);
89
+
90
+ const sx = svg.scale('x');
91
+ const sy = svg.scale('y');
92
+ const sfx = scale('fx')?.apply;
93
+ const sfy = scale('fy')?.apply;
94
+
95
+ const X = Array.from(data('x'), sx.apply);
96
+ const Y = Array.from(data('y'), sy.apply);
97
+
98
+ // as needed, adjust coordinates by facets
99
+ if (sfx) {
100
+ const dx = min(sx.range);
101
+ const FX = data('fx');
102
+ for (let i = 0; i < FX.length; ++i) {
103
+ X[i] += sfx(FX[i]) - dx;
104
+ }
105
+ }
106
+ if (sfy) {
107
+ const dy = min(sy.range);
108
+ const FY = data('fy');
109
+ for (let i = 0; i < FY.length; ++i) {
110
+ Y[i] += sfy(FY[i]) - dy;
111
+ }
112
+ }
113
+
114
+ return [X, Y];
115
+ }
116
+
86
117
  /**
87
118
  * Find the nearest data point to the pointer. The nearest point
88
119
  * is found via Euclidean distance, but with scale factors *sx* and
@@ -0,0 +1,108 @@
1
+ import { clausePoints } from '@uwdata/mosaic-core';
2
+ import { select } from 'd3';
3
+ import { brush } from './util/brush.js';
4
+ import { getFields } from './util/get-field.js';
5
+ import { intersect } from './util/intersect.js';
6
+ import { patchScreenCTM } from './util/patchScreenCTM.js';
7
+ import { sanitizeStyles } from './util/sanitize-styles.js';
8
+ import { neqSome } from './util/neq.js';
9
+ import { getDatum } from './util/get-datum.js';
10
+
11
+ export class Region {
12
+ constructor(mark, {
13
+ channels,
14
+ selection,
15
+ peers = true,
16
+ brush: style = {
17
+ fill: 'none',
18
+ stroke: 'currentColor',
19
+ strokeDasharray: '1,1'
20
+ }
21
+ }) {
22
+ this.mark = mark;
23
+ this.selection = selection;
24
+ this.peers = peers;
25
+
26
+ this.style = style && sanitizeStyles(style);
27
+ this.brush = brush();
28
+ this.brush.on('brush end', evt => this.publish(evt.selection));
29
+ this.extent = null;
30
+ this.groups = null;
31
+
32
+ const { fields, as } = getFields(mark, channels);
33
+ this.fields = fields;
34
+ this.as = as;
35
+ }
36
+
37
+ reset() {
38
+ this.value = undefined;
39
+ this.extent = null;
40
+ if (this.g) this.brush.reset(this.g);
41
+ }
42
+
43
+ activate() {
44
+ this.selection.activate(this.clause([this.fields.map(() => 0)]));
45
+ }
46
+
47
+ clause(value) {
48
+ const { fields, mark } = this;
49
+ return clausePoints(fields, value, {
50
+ source: this,
51
+ clients: this.peers ? mark.plot.markSet : new Set().add(mark)
52
+ });
53
+ }
54
+
55
+ publish(extent) {
56
+ const { as, group, mark, svg } = this;
57
+ let value;
58
+
59
+ // extract channel values for points
60
+ if (extent) {
61
+ const { data: { columns = {} } = {} } = mark;
62
+ const map = new Map;
63
+ intersect(svg, group, extent).forEach(el => {
64
+ const index = getDatum(el);
65
+ const vals = as.map(name => columns[name][index]);
66
+ map.set(vals.join('|'), vals); // deduplicate values
67
+ });
68
+ value = Array.from(map.values());
69
+ }
70
+ this.extent = extent;
71
+
72
+ if (neqSome(value, this.value)) {
73
+ this.value = value;
74
+ this.selection.update(this.clause(value));
75
+ }
76
+ }
77
+
78
+ init(svg) {
79
+ const { brush, extent, mark, style } = this;
80
+ this.svg = svg;
81
+
82
+ const w = svg.width.baseVal.value;
83
+ const h = svg.height.baseVal.value;
84
+ brush.extent([[0, 0], [w, h]]);
85
+
86
+ // isolate eligible mark group
87
+ this.group = svg.querySelector(`[data-index="${mark.index}"]`);
88
+
89
+ // create a single brush, regardless of facets
90
+ this.g = select(svg)
91
+ .append('g')
92
+ .attr('class', `region-xy`)
93
+ .each(patchScreenCTM)
94
+ .call(brush)
95
+ .call(brush.moveSilent, extent);
96
+
97
+ if (style) {
98
+ const brushes = this.g.selectAll('rect.selection');
99
+ for (const name in style) {
100
+ brushes.attr(name, style[name]);
101
+ }
102
+ }
103
+
104
+ svg.addEventListener('pointerenter', evt => {
105
+ if (!evt.buttons) this.activate();
106
+ });
107
+ }
108
+ }
@@ -1,4 +1,6 @@
1
1
  import { clausePoints } from '@uwdata/mosaic-core';
2
+ import { getDatum } from './util/get-datum.js';
3
+ import { neq, neqSome } from './util/neq.js';
2
4
 
3
5
  export class Toggle {
4
6
  /**
@@ -44,12 +46,10 @@ export class Toggle {
44
46
  init(svg, selector, accessor) {
45
47
  const { mark, as, selection } = this;
46
48
  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];
50
- });
49
+ accessor ??= target => as.map(name => columns[name][getDatum(target)]);
50
+
51
51
  selector ??= `[data-index="${mark.index}"]`;
52
- const groups = new Set(svg.querySelectorAll(selector));
52
+ const groups = Array.from(svg.querySelectorAll(selector));
53
53
 
54
54
  svg.addEventListener('pointerdown', evt => {
55
55
  const state = selection.single ? selection.value : this.value;
@@ -82,22 +82,5 @@ export class Toggle {
82
82
  }
83
83
 
84
84
  function isTargetElement(groups, node) {
85
- return groups.has(node)
86
- || groups.has(node.parentNode)
87
- || groups.has(node.parentNode?.parentNode);
88
- }
89
-
90
- function neqSome(a, b) {
91
- return (a == null || b == null)
92
- ? (a != null || b != null)
93
- : (a.length !== b.length || a.some((x, i) => neq(x, b[i])));
94
- }
95
-
96
- function neq(a, b) {
97
- const n = a.length;
98
- if (b.length !== n) return true;
99
- for (let i = 0; i < n; ++i) {
100
- if (a[i] !== b[i]) return true;
101
- }
102
- return false;
85
+ return groups.some(g => g.contains(node));
103
86
  }
@@ -1,6 +1,5 @@
1
- import {
2
- brush as d3_brush, brushX as d3_brushX, brushY as d3_brushY
3
- } from 'd3';
1
+ import { brush as d3_brush, brushX as d3_brushX, brushY as d3_brushY, select } from 'd3';
2
+ import { patchScreenCTM } from './patchScreenCTM.js';
4
3
 
5
4
  function wrap(brush) {
6
5
  const brushOn = brush.on;
@@ -43,3 +42,36 @@ export function brushX() {
43
42
  export function brushY() {
44
43
  return wrap(d3_brushY());
45
44
  }
45
+
46
+ export function brushGroups(svg, root, dx, dy, className) {
47
+ let groups = select(root ?? svg)
48
+ .append('g')
49
+ .attr('class', className)
50
+
51
+ // if the plot is faceted, create per-facet brush groups
52
+ const fx = svg.scale('fx');
53
+ const fy = svg.scale('fy');
54
+ if (fx || fy) {
55
+ const X = fx?.domain.map(v => fx.apply(v) - dx);
56
+ const Y = fy?.domain.map(v => fy.apply(v) - dy);
57
+ if (X && Y) {
58
+ for (let i = 0; i < X.length; ++i) {
59
+ for (let j = 0; j < Y.length; ++j) {
60
+ groups.append('g').attr('transform', `translate(${X[i]}, ${Y[j]})`);
61
+ }
62
+ }
63
+ } else if (X) {
64
+ for (let i = 0; i < X.length; ++i) {
65
+ groups.append('g').attr('transform', `translate(${X[i]}, 0})`);
66
+ }
67
+ } else if (Y) {
68
+ for (let j = 0; j < Y.length; ++j) {
69
+ groups.append('g').attr('transform', `translate(0, ${Y[j]})`);
70
+ }
71
+ }
72
+ groups = groups.selectAll('g');
73
+ }
74
+
75
+ // return brush groups, with screen transform fix
76
+ return groups.each(patchScreenCTM);
77
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Return the bound datum value on a Plot-generated SVG element.
3
+ * Following D3, bound data is assigned to the `__data__` property.
4
+ * However, the target mark may be wrapped within a hyperlink `a` tag.
5
+ * @param {Element} el A DOM element.
6
+ * @returns {*} The bound datum.
7
+ */
8
+ export function getDatum(el) {
9
+ if (el.tagName === 'a') {
10
+ el = el.children[0];
11
+ }
12
+ // @ts-ignore
13
+ const data = el.__data__;
14
+ return Array.isArray(data) ? data[0] : data;
15
+ }
@@ -1,4 +1,39 @@
1
+ import { isNode } from '@uwdata/mosaic-sql';
2
+
3
+ function extractField(field) {
4
+ if (isNode(field)) {
5
+ if (field.type === 'COLUMN_REF') {
6
+ // @ts-ignore
7
+ return field.column;
8
+ } else if (field.type === 'AGGREGATE') {
9
+ // @ts-ignore
10
+ return field.args[0] ?? field;
11
+ }
12
+ }
13
+ return field;
14
+ }
15
+
1
16
  export function getField(mark, channel) {
2
- const field = mark.channelField(channel)?.field;
3
- return field?.basis || field;
17
+ return extractField(mark.channelField(channel)?.field);
18
+ }
19
+
20
+ export function getFields(mark, channels) {
21
+ const fields = [];
22
+ const as = [];
23
+ channels.forEach(c => {
24
+ const q = c === 'color' ? ['color', 'fill', 'stroke']
25
+ : c === 'x' ? ['x', 'x1', 'x2']
26
+ : c === 'y' ? ['y', 'y1', 'y2']
27
+ : [c];
28
+ for (let i = 0; i < q.length; ++i) {
29
+ const f = mark.channelField(q[i], { exact: true });
30
+ if (f) {
31
+ fields.push(extractField(f.field));
32
+ as.push(f.as);
33
+ return;
34
+ }
35
+ }
36
+ throw new Error(`Missing channel: ${c}`);
37
+ });
38
+ return { fields, as };
4
39
  }