@uwdata/mosaic-plot 0.12.1 → 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uwdata/mosaic-plot",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "A Mosaic-powered plotting framework based on Observable Plot.",
5
5
  "keywords": [
6
6
  "data",
@@ -12,27 +12,24 @@
12
12
  "license": "BSD-3-Clause",
13
13
  "author": "Jeffrey Heer (https://idl.uw.edu)",
14
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",
15
+ "exports": {
16
+ "default": "./src/index.js"
17
+ },
19
18
  "repository": {
20
19
  "type": "git",
21
20
  "url": "https://github.com/uwdata/mosaic.git"
22
21
  },
23
22
  "scripts": {
24
- "prebuild": "rimraf dist && mkdir dist",
25
- "build": "node ../../esbuild.js mosaic-plot",
26
23
  "lint": "eslint src test",
27
24
  "test": "vitest run",
28
- "prepublishOnly": "npm run test && npm run lint && npm run build"
25
+ "prepublishOnly": "npm run test && npm run lint"
29
26
  },
30
27
  "dependencies": {
31
- "@observablehq/plot": "^0.6.16",
32
- "@uwdata/mosaic-core": "^0.12.1",
33
- "@uwdata/mosaic-sql": "^0.12.1",
28
+ "@observablehq/plot": "^0.6.17",
29
+ "@uwdata/mosaic-core": "^0.13.0",
30
+ "@uwdata/mosaic-sql": "^0.13.0",
34
31
  "d3": "^7.9.0",
35
32
  "isoformat": "^0.2.1"
36
33
  },
37
- "gitHead": "fe3a7c34352da54ec36a1ebf557846f46a649782"
34
+ "gitHead": "b5a0e03e200c0f04c46562a288f084ffc9f6ad55"
38
35
  }
@@ -6,6 +6,10 @@ import { getField } from './util/get-field.js';
6
6
  import { invert } from './util/invert.js';
7
7
  import { sanitizeStyles } from './util/sanitize-styles.js';
8
8
 
9
+ /**
10
+ * @import {Activatable} from '@uwdata/mosaic-core'
11
+ * @implements {Activatable}
12
+ */
9
13
  export class Interval1D {
10
14
  constructor(mark, {
11
15
  channel,
@@ -6,6 +6,10 @@ import { getField } from './util/get-field.js';
6
6
  import { invert } from './util/invert.js';
7
7
  import { sanitizeStyles } from './util/sanitize-styles.js';
8
8
 
9
+ /**
10
+ * @import {Activatable} from '@uwdata/mosaic-core'
11
+ * @implements {Activatable}
12
+ */
9
13
  export class Interval2D {
10
14
  constructor(mark, {
11
15
  selection,
@@ -2,6 +2,10 @@ import { clausePoint, clausePoints, isSelection } from '@uwdata/mosaic-core';
2
2
  import { select, pointer, min } from 'd3';
3
3
  import { getField } from './util/get-field.js';
4
4
 
5
+ /**
6
+ * @import {Activatable} from '@uwdata/mosaic-core'
7
+ * @implements {Activatable}
8
+ */
5
9
  export class Nearest {
6
10
  constructor(mark, {
7
11
  selection,
@@ -71,12 +75,14 @@ export class Nearest {
71
75
 
72
76
  // trigger activation updates
73
77
  svg.addEventListener('pointerenter', evt => {
74
- if (!evt.buttons) {
75
- const v = this.channels.map(() => 0);
76
- selection.activate(this.clause(v));
77
- }
78
+ if (!evt.buttons) this.activate();
78
79
  });
79
80
  }
81
+
82
+ activate() {
83
+ const v = this.channels.map(() => 0);
84
+ this.selection.activate(this.clause(v));
85
+ }
80
86
  }
81
87
 
82
88
  /**
@@ -4,6 +4,10 @@ import { getField } from './util/get-field.js';
4
4
 
5
5
  const asc = (a, b) => a - b;
6
6
 
7
+ /**
8
+ * @import {Activatable} from '@uwdata/mosaic-core'
9
+ * @implements {Activatable}
10
+ */
7
11
  export class PanZoom {
8
12
  constructor(mark, {
9
13
  x = new Selection(),
@@ -59,7 +63,7 @@ export class PanZoom {
59
63
  this.svg = svg;
60
64
  if (this.initialized) return; else this.initialized = true;
61
65
 
62
- const { panx, pany, mark: { plot: { element } }, xsel, ysel } = this;
66
+ const { panx, pany, mark: { plot: { element } } } = this;
63
67
 
64
68
  this.xscale = svg.scale('x');
65
69
  this.yscale = svg.scale('y');
@@ -85,19 +89,22 @@ export class PanZoom {
85
89
  let enter = false;
86
90
  element.addEventListener('pointerenter', evt => {
87
91
  if (enter) return; else enter = true;
88
- if (evt.buttons) return; // don't activate if mouse down
89
- if (panx) {
90
- const { xscale, xfield } = this;
91
- xsel.activate(this.clause(xscale.domain, xfield, xscale));
92
- }
93
- if (pany) {
94
- const { yscale, yfield } = this;
95
- ysel.activate(this.clause(yscale.domain, yfield, yscale));
96
- }
92
+ if (!evt.buttons) this.activate(); // don't activate if mouse down
97
93
  });
98
94
  element.addEventListener('pointerleave', () => enter = false);
99
95
  }
100
96
  }
97
+
98
+ activate() {
99
+ if (this.panx) {
100
+ const { xscale, xfield } = this;
101
+ this.xsel.activate(this.clause(xscale.domain, xfield, xscale));
102
+ }
103
+ if (this.pany) {
104
+ const { yscale, yfield } = this;
105
+ this.ysel.activate(this.clause(yscale.domain, yfield, yscale));
106
+ }
107
+ }
101
108
  }
102
109
 
103
110
  function extent(ext, defaultTrue, defaultFalse) {
@@ -8,6 +8,10 @@ import { sanitizeStyles } from './util/sanitize-styles.js';
8
8
  import { neqSome } from './util/neq.js';
9
9
  import { getDatum } from './util/get-datum.js';
10
10
 
11
+ /**
12
+ * @import {Activatable} from '@uwdata/mosaic-core'
13
+ * @implements {Activatable}
14
+ */
11
15
  export class Region {
12
16
  constructor(mark, {
13
17
  channels,
@@ -2,6 +2,10 @@ import { clausePoints } from '@uwdata/mosaic-core';
2
2
  import { getDatum } from './util/get-datum.js';
3
3
  import { neq, neqSome } from './util/neq.js';
4
4
 
5
+ /**
6
+ * @import {Activatable} from '@uwdata/mosaic-core'
7
+ * @implements {Activatable}
8
+ */
5
9
  export class Toggle {
6
10
  /**
7
11
  * @param {*} mark The mark to interact with.
@@ -12,8 +16,8 @@ export class Toggle {
12
16
  channels,
13
17
  peers = true
14
18
  }) {
15
- this.value = null;
16
19
  this.mark = mark;
20
+ this.value = null;
17
21
  this.selection = selection;
18
22
  this.peers = peers;
19
23
  const fields = this.fields = [];
@@ -75,10 +79,13 @@ export class Toggle {
75
79
  });
76
80
 
77
81
  svg.addEventListener('pointerenter', evt => {
78
- if (evt.buttons) return;
79
- this.selection.activate(this.clause([this.fields.map(() => 0)]));
82
+ if (!evt.buttons) this.activate();
80
83
  });
81
84
  }
85
+
86
+ activate() {
87
+ this.selection.activate(this.clause([this.fields.map(() => 0)]));
88
+ }
82
89
  }
83
90
 
84
91
  function isTargetElement(groups, node) {
@@ -39,7 +39,7 @@ export class ConnectedMark extends Mark {
39
39
  .filter(c => c !== as && c !== value);
40
40
  return m4(q, expr, as, value, cols);
41
41
  } else {
42
- return q.orderby(field);
42
+ return q.orderby(as);
43
43
  }
44
44
  }
45
45
  }
@@ -1,5 +1,6 @@
1
1
  import { toDataColumns } from '@uwdata/mosaic-core';
2
2
  import { binLinear1d, isBetween } from '@uwdata/mosaic-sql';
3
+ import { max, sum } from 'd3';
3
4
  import { Transient } from '../symbols.js';
4
5
  import { binExpr } from './util/bin-expr.js';
5
6
  import { dericheConfig, dericheConv1d } from './util/density.js';
@@ -8,9 +9,17 @@ import { grid1d } from './util/grid.js';
8
9
  import { handleParam } from './util/handle-param.js';
9
10
  import { Mark, channelOption, markQuery } from './Mark.js';
10
11
 
12
+ const GROUPBY = { fill: 1, stroke: 1, z: 1 };
13
+
11
14
  export class Density1DMark extends Mark {
12
15
  constructor(type, source, options) {
13
- const { bins = 1024, bandwidth = 20, ...channels } = options;
16
+ const {
17
+ bins = 1024,
18
+ bandwidth = 20,
19
+ normalize = false,
20
+ stack = false,
21
+ ...channels
22
+ } = options;
14
23
  const dim = type.endsWith('X') ? 'y' : 'x';
15
24
 
16
25
  super(type, source, channels, dim === 'x' ? xext : yext);
@@ -24,7 +33,17 @@ export class Density1DMark extends Mark {
24
33
  /** @type {number} */
25
34
  this.bandwidth = handleParam(bandwidth, value => {
26
35
  this.bandwidth = value;
27
- return this.grid ? this.convolve().update() : null;
36
+ return this.grids ? this.convolve().update() : null;
37
+ });
38
+
39
+ /** @type {string | boolean} */
40
+ this.normalize = handleParam(normalize, value => {
41
+ return (this.normalize = value, this.convolve().update());
42
+ });
43
+
44
+ /** @type {boolean} */
45
+ this.stack = handleParam(stack, value => {
46
+ return (this.stack = value, this.update());
28
47
  });
29
48
  }
30
49
 
@@ -42,48 +61,76 @@ export class Density1DMark extends Mark {
42
61
  const q = markQuery(channels, this.sourceTable(), [dim])
43
62
  .where(filter.concat(isBetween(bx, extent)));
44
63
  const v = this.channelField('weight') ? 'weight' : null;
45
- return binLinear1d(q, x, v);
64
+ const g = this.groupby = channels.flatMap(c => {
65
+ return (GROUPBY[c.channel] && c.field) ? c.as : [];
66
+ });
67
+ return binLinear1d(q, x, v, g);
46
68
  }
47
69
 
48
70
  queryResult(data) {
49
- const { columns: { index, density } } = toDataColumns(data);
50
- this.grid = grid1d(this.bins, index, density);
71
+ const c = toDataColumns(data).columns;
72
+ this.grids = grid1d(this.bins, c.index, c.density, c, this.groupby);
51
73
  return this.convolve();
52
74
  }
53
75
 
54
76
  convolve() {
55
- const { bins, bandwidth, dim, grid, plot, extent: [lo, hi] } = this;
77
+ const {
78
+ bins, bandwidth, normalize, dim, grids, groupby, plot, extent: [lo, hi]
79
+ } = this;
80
+
81
+ const cols = grids.columns;
82
+ const numGrids = grids.numRows;
56
83
 
57
- // perform smoothing
58
- const neg = grid.some(v => v < 0);
84
+ const b = this.channelField(dim).as;
85
+ const v = dim === 'x' ? 'y' : 'x';
59
86
  const size = dim === 'x' ? plot.innerWidth() : plot.innerHeight();
87
+ const neg = cols._grid.some(grid => grid.some(v => v < 0));
60
88
  const config = dericheConfig(bandwidth * (bins - 1) / size, neg);
61
- const result = dericheConv1d(config, grid, bins);
62
89
 
63
- // map smoothed grid values to sample data points
64
- const v = dim === 'x' ? 'y' : 'x';
65
- const b = this.channelField(dim).as;
66
90
  const b0 = +lo;
67
91
  const delta = (hi - b0) / (bins - 1);
68
- const scale = 1 / delta;
69
92
 
70
- const _b = new Float64Array(bins);
71
- const _v = new Float64Array(bins);
72
- for (let i = 0; i < bins; ++i) {
73
- _b[i] = b0 + i * delta;
74
- _v[i] = result[i] * scale;
93
+ const numRows = bins * numGrids;
94
+ const _b = new Float64Array(numRows);
95
+ const _v = new Float64Array(numRows);
96
+ const _g = groupby.reduce((m, name) => (m[name] = Array(numRows), m), {});
97
+
98
+ for (let k = 0, g = 0; g < numGrids; ++g) {
99
+ // fill in groupby values
100
+ groupby.forEach(name => _g[name].fill(cols[name][g], k, k + bins));
101
+
102
+ // perform smoothing, map smoothed grid values to sample data points
103
+ const grid = cols._grid[g];
104
+ const result = dericheConv1d(config, grid, bins);
105
+ const scale = 1 / norm(grid, result, delta, normalize);
106
+ for (let i = 0; i < bins; ++i, ++k) {
107
+ _b[k] = b0 + i * delta;
108
+ _v[k] = result[i] * scale;
109
+ }
75
110
  }
76
- this.data = { numRows: bins, columns: { [b]: _b, [v]: _v } };
77
111
 
112
+ this.data = { numRows, columns: { [b]: _b, [v]: _v, ..._g } };
78
113
  return this;
79
114
  }
80
115
 
81
116
  plotSpecs() {
82
- const { type, data: { numRows: length, columns }, channels, dim } = this;
83
- const options = dim === 'x' ? { y: columns.y } : { x: columns.x };
117
+ const { type, data: { numRows: length, columns }, channels, dim, stack } = this;
118
+
119
+ // control if Plot's implicit stack transform is applied
120
+ // no stacking is done if x2/y2 are used instead of x/y
121
+ const _ = type.startsWith('area') && !stack ? '2' : '';
122
+ const options = dim === 'x' ? { [`y${_}`]: columns.y } : { [`x${_}`]: columns.x };
123
+
84
124
  for (const c of channels) {
85
125
  options[c.channel] = channelOption(c, columns);
86
126
  }
87
127
  return [{ type, data: { length }, options }];
88
128
  }
89
129
  }
130
+
131
+ function norm(grid, smoothed, delta, type) {
132
+ const value = type === true || type === 'sum' ? sum(grid)
133
+ : type === 'max' ? max(smoothed)
134
+ : delta;
135
+ return value || 1;
136
+ }
package/src/marks/Mark.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isParam, MosaicClient, toDataColumns } from '@uwdata/mosaic-core';
2
- import { Query, SelectQuery, collectParams, column, isAggregateExpression, isColumnRef, isNode, isParamLike } from '@uwdata/mosaic-sql';
2
+ import { Query, SelectQuery, collectParams, column, isAggregateExpression, isColumnParam, isColumnRef, isNode, isParamLike } from '@uwdata/mosaic-sql';
3
3
  import { isColor } from './util/is-color.js';
4
4
  import { isConstantOption } from './util/is-constant-option.js';
5
5
  import { isSymbol } from './util/is-symbol.js';
@@ -15,7 +15,7 @@ const isFieldObject = (channel, field) => {
15
15
  const fieldEntry = (channel, field) => ({
16
16
  channel,
17
17
  field,
18
- as: isColumnRef(field) ? field.column : channel
18
+ as: isColumnRef(field) && !isColumnParam(field) ? field.column : channel
19
19
  });
20
20
  const valueEntry = (channel, value) => ({ channel, value });
21
21
 
@@ -136,7 +136,11 @@ export class Mark extends MosaicClient {
136
136
  }
137
137
 
138
138
  const table = this.sourceTable();
139
- return Array.from(fields, ([c, s]) => ({ table, column: c, stats: s }));
139
+ return Array.from(fields, ([c, s]) => ({
140
+ table,
141
+ column: c,
142
+ stats: Array.from(s)
143
+ }));
140
144
  }
141
145
 
142
146
  fieldInfo(info) {
@@ -226,7 +230,7 @@ export function markQuery(channels, table, skip = []) {
226
230
  if (skip.includes(channel)) continue;
227
231
 
228
232
  if (channel === 'orderby') {
229
- q.orderby(c.value);
233
+ q.orderby(c.value ?? field);
230
234
  } else if (field) {
231
235
  if (isAggregateExpression(field)) {
232
236
  aggr = true;
@@ -24,15 +24,44 @@ export function array(size, proto = []) {
24
24
  * @param {number} size The grid size.
25
25
  * @param {Arrayish} index The grid indices for sample points.
26
26
  * @param {Arrayish} value The sample point values.
27
- * @returns {Arrayish} The generated value grid.
27
+ * @param {Record<string,Arrayish>} columns Named column arrays with groupby values.
28
+ * @param {string[]} groupby The names of columns to group by.
29
+ * @returns {{
30
+ * numRows: number;
31
+ * columns: { [key:string]: Arrayish }
32
+ * }} Named column arrays of generated grid values.
28
33
  */
29
- export function grid1d(size, index, value) {
30
- const G = array(size, value);
31
- const n = value.length;
32
- for (let i = 0; i < n; ++i) {
33
- G[index[i]] = value[i];
34
+ export function grid1d(size, index, value, columns, groupby) {
35
+ const numRows = index.length;
36
+ const result = {};
37
+ const cells = [];
38
+
39
+ // if grouped, generate per-row group indices
40
+ if (groupby?.length) {
41
+ const group = new Int32Array(numRows);
42
+ const gvalues = groupby.map(name => columns[name]);
43
+ const cellMap = {};
44
+ for (let row = 0; row < numRows; ++row) {
45
+ const key = gvalues.map(group => group[row]);
46
+ group[row] = cellMap[key] ??= cells.push(key) - 1;
47
+ }
48
+ for (let i = 0; i < groupby.length; ++i) {
49
+ result[groupby[i]] = cells.map(cell => cell[i]);
50
+ }
51
+ const G = result._grid = cells.map(() => array(size, value));
52
+ for (let row = 0; row < numRows; ++row) {
53
+ G[group[row]][index[row]] = value[row];
54
+ }
55
+ } else {
56
+ cells.push([]); // single group
57
+ const [G] = result._grid = [array(size, value)]
58
+ for (let row = 0; row < numRows; ++row) {
59
+ G[index[row]] = value[row];
60
+ }
34
61
  }
35
- return G;
62
+
63
+ // @ts-ignore
64
+ return { numRows: cells.length, columns: result };
36
65
  }
37
66
 
38
67
  /**
@@ -1,5 +1,7 @@
1
1
  const constantOptions = new Set([
2
+ 'offset',
2
3
  'order',
4
+ 'reverse',
3
5
  'sort',
4
6
  'label',
5
7
  'anchor',
package/src/plot.js CHANGED
@@ -10,19 +10,30 @@ const DEFAULT_ATTRIBUTES = {
10
10
  };
11
11
 
12
12
  export class Plot {
13
+ /**
14
+ * @param {HTMLElement} [element]
15
+ */
13
16
  constructor(element) {
17
+ /** @type {Record<string, any>} */
14
18
  this.attributes = { ...DEFAULT_ATTRIBUTES };
15
19
  this.listeners = null;
16
20
  this.interactors = [];
21
+ /** @type {{ legend: import('./legend.js').Legend, include: boolean }[]} */
17
22
  this.legends = [];
23
+ /** @type {import('./marks/Mark.js').Mark[]} */
18
24
  this.marks = [];
25
+ /** @type {Set<import('./marks/Mark.js').Mark> | null} */
19
26
  this.markset = null;
27
+ /** @type {Map<import('@uwdata/mosaic-core').Param, import('./marks/Mark.js').Mark[]>} */
28
+ this.params = new Map;
29
+ /** @type {ReturnType<synchronizer>} */
30
+ this.synch = synchronizer();
31
+
32
+ /** @type {HTMLElement} */
20
33
  this.element = element || document.createElement('div');
21
34
  this.element.setAttribute('class', 'plot');
22
35
  this.element.style.display = 'flex';
23
- this.element.value = this;
24
- this.params = new Map;
25
- this.synch = synchronizer();
36
+ Object.assign(this.element, { value: this });
26
37
  }
27
38
 
28
39
  margins() {
@@ -0,0 +1,3 @@
1
+ import { defineConfig } from 'vite';
2
+
3
+ export default defineConfig({});