@uwdata/mosaic-inputs 0.8.0 → 0.10.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,13 +1,13 @@
1
1
  {
2
2
  "name": "@uwdata/mosaic-inputs",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Mosaic input components.",
5
5
  "keywords": [
6
6
  "inputs",
7
7
  "mosaic"
8
8
  ],
9
9
  "license": "BSD-3-Clause",
10
- "author": "Jeffrey Heer (http://idl.cs.washington.edu)",
10
+ "author": "Jeffrey Heer (https://idl.uw.edu)",
11
11
  "type": "module",
12
12
  "main": "src/index.js",
13
13
  "module": "src/index.js",
@@ -25,9 +25,9 @@
25
25
  "prepublishOnly": "npm run test && npm run lint && npm run build"
26
26
  },
27
27
  "dependencies": {
28
- "@uwdata/mosaic-core": "^0.8.0",
29
- "@uwdata/mosaic-sql": "^0.8.0",
28
+ "@uwdata/mosaic-core": "^0.10.0",
29
+ "@uwdata/mosaic-sql": "^0.10.0",
30
30
  "isoformat": "^0.2.1"
31
31
  },
32
- "gitHead": "a24b4c9f7dfa1c38c6af96ec17e075326c1af9b0"
32
+ "gitHead": "94fc4f0d4efc622001f6afd6714d1e9dda745be2"
33
33
  }
package/src/Menu.js CHANGED
@@ -1,5 +1,5 @@
1
- import { MosaicClient, isParam, isSelection } from '@uwdata/mosaic-core';
2
- import { Query, eq, literal } from '@uwdata/mosaic-sql';
1
+ import { MosaicClient, Param, isParam, isSelection, clausePoint } from '@uwdata/mosaic-core';
2
+ import { Query } from '@uwdata/mosaic-sql';
3
3
  import { input } from './input.js';
4
4
 
5
5
  const isObject = v => {
@@ -10,8 +10,31 @@ export const menu = options => input(Menu, options);
10
10
 
11
11
  export class Menu extends MosaicClient {
12
12
  /**
13
- * Create a new Menu instance.
14
- * @param {object} options Options object
13
+ * Create a new menu input.
14
+ * @param {object} [options] Options object
15
+ * @param {HTMLElement} [options.element] The parent DOM element in which to
16
+ * place the menu elements. If undefined, a new `div` element is created.
17
+ * @param {Selection} [options.filterBy] A selection to filter the database
18
+ * table indicated by the *from* option.
19
+ * @param {Param} [options.as] The output param or selection. A selection
20
+ * clause is added for the currently selected menu option.
21
+ * @param {string} [options.field] The database column name to use within
22
+ * generated selection clause predicates. Defaults to the *column* option.
23
+ * @param {(any | { value: any, label?: string })[]} [options.options] An
24
+ * array of menu options, as literal values or option objects. Option
25
+ * objects have a `value` property and an optional `label` property. If no
26
+ * label or *format* function is provided, the string-coerced value is used.
27
+ * @param {(value: any) => string} [options.format] A format function that
28
+ * takes an option value as input and generates a string label. The format
29
+ * function is not applied when an explicit label is provided in an option
30
+ * object.
31
+ * @param {*} [options.value] The initial selected menu value.
32
+ * @param {string} [options.from] The name of a database table to use as a data
33
+ * source for this widget. Used in conjunction with the *column* option.
34
+ * @param {string} [options.column] The name of a database column from which
35
+ * to pull menu options. The unique column values are used as menu options.
36
+ * Used in conjunction with the *from* option.
37
+ * @param {string} [options.label] A text label for this input.
15
38
  */
16
39
  constructor({
17
40
  element,
@@ -22,36 +45,52 @@ export class Menu extends MosaicClient {
22
45
  format = x => x, // TODO
23
46
  options,
24
47
  value,
48
+ field = column,
25
49
  as
26
50
  } = {}) {
27
51
  super(filterBy);
28
52
  this.from = from;
29
53
  this.column = column;
30
- this.selection = as;
31
54
  this.format = format;
55
+ this.field = field;
56
+ const selection = this.selection = as;
32
57
 
33
58
  this.element = element ?? document.createElement('div');
34
59
  this.element.setAttribute('class', 'input');
35
- this.element.value = this;
60
+ Object.defineProperty(this.element, 'value', { value: this });
36
61
 
37
62
  const lab = document.createElement('label');
38
63
  lab.innerText = label || column;
39
64
  this.element.appendChild(lab);
40
65
 
41
66
  this.select = document.createElement('select');
67
+ this.element.appendChild(this.select);
68
+
69
+ // if provided, populate menu options
42
70
  if (options) {
43
71
  this.data = options.map(value => isObject(value) ? value : { value });
72
+ this.selectedValue(value ?? '');
44
73
  this.update();
45
74
  }
46
- value = value ?? this.selection?.value ?? this.data?.[0]?.value;
47
- if (this.selection?.value === undefined) this.publish(value);
48
- this.element.appendChild(this.select);
49
75
 
50
- if (this.selection) {
76
+ // initialize selection or param bindings
77
+ if (selection) {
78
+ const isParam = !isSelection(selection);
79
+
80
+ // publish any initial menu value to the selection/param
81
+ // later updates propagate this back to the menu element
82
+ // do not publish if using a param that already has a value
83
+ if (value != null && (!isParam || selection.value === undefined)) {
84
+ this.publish(value);
85
+ }
86
+
87
+ // publish selected value upon menu change
51
88
  this.select.addEventListener('input', () => {
52
89
  this.publish(this.selectedValue() ?? null);
53
90
  });
54
- if (!isSelection(this.selection)) {
91
+
92
+ // if bound to a scalar param, respond to value updates
93
+ if (isParam) {
55
94
  this.selection.addEventListener('value', value => {
56
95
  if (value !== this.select.value) {
57
96
  this.selectedValue(value);
@@ -80,14 +119,11 @@ export class Menu extends MosaicClient {
80
119
  }
81
120
 
82
121
  publish(value) {
83
- const { selection, column } = this;
122
+ const { selection, field } = this;
84
123
  if (isSelection(selection)) {
85
- selection.update({
86
- source: this,
87
- schema: { type: 'point' },
88
- value,
89
- predicate: (value !== '' && value !== undefined) ? eq(column, literal(value)) : null
90
- });
124
+ if (value === '') value = undefined; // 'All' option
125
+ const clause = clausePoint(field, value, { source: this });
126
+ selection.update(clause);
91
127
  } else if (isParam(selection)) {
92
128
  selection.update(value);
93
129
  }
@@ -105,12 +141,15 @@ export class Menu extends MosaicClient {
105
141
  }
106
142
 
107
143
  queryResult(data) {
144
+ // column option values, with an inserted 'All' value
108
145
  this.data = [{ value: '', label: 'All' }, ...data];
109
146
  return this;
110
147
  }
111
148
 
112
149
  update() {
113
- const { data, format, select } = this;
150
+ const { data, format, select, selection } = this;
151
+
152
+ // generate menu item options
114
153
  select.replaceChildren();
115
154
  for (const { value, label } of data) {
116
155
  const opt = document.createElement('option');
@@ -118,9 +157,15 @@ export class Menu extends MosaicClient {
118
157
  opt.innerText = label ?? format(value);
119
158
  this.select.appendChild(opt);
120
159
  }
121
- if (this.selection) {
122
- this.selectedValue(this.selection?.value ?? '');
160
+
161
+ // update menu value based on param/selection
162
+ if (selection) {
163
+ const value = isSelection(selection)
164
+ ? selection.valueFor(this)
165
+ : selection.value;
166
+ this.selectedValue(value ?? '');
123
167
  }
168
+
124
169
  return this;
125
170
  }
126
171
  }
package/src/Search.js CHANGED
@@ -1,18 +1,36 @@
1
- import { MosaicClient, isParam, isSelection } from '@uwdata/mosaic-core';
2
- import {
3
- Query, regexp_matches, contains, prefix, suffix, literal
4
- } from '@uwdata/mosaic-sql';
1
+ import { MosaicClient, Param, isParam, isSelection, clauseMatch } from '@uwdata/mosaic-core';
2
+ import { Query } from '@uwdata/mosaic-sql';
5
3
  import { input } from './input.js';
6
4
 
7
- const FUNCTIONS = { contains, prefix, suffix, regexp: regexp_matches };
8
5
  let _id = 0;
9
6
 
10
7
  export const search = options => input(Search, options);
11
8
 
12
9
  export class Search extends MosaicClient {
13
10
  /**
14
- * Create a new Search instance.
15
- * @param {object} options Options object
11
+ * Create a new text search input.
12
+ * @param {object} [options] Options object
13
+ * @param {HTMLElement} [options.element] The parent DOM element in which to
14
+ * place the search elements. If undefined, a new `div` element is created.
15
+ * @param {Selection} [options.filterBy] A selection to filter the database
16
+ * table indicated by the *from* option.
17
+ * @param {Param} [options.as] The output param or selection. A selection
18
+ * clause is added based on the current text search query.
19
+ * @param {string} [options.field] The database column name to use within
20
+ * generated selection clause predicates. Defaults to the *column* option.
21
+ * @param {'contains' | 'prefix' | 'suffix' | 'regexp'} [options.type] The
22
+ * type of text search query to perform. One of:
23
+ * - `"contains"` (default): the query string may appear anywhere in the text
24
+ * - `"prefix"`: the query string must appear at the start of the text
25
+ * - `"suffix"`: the query string must appear at the end of the text
26
+ * - `"regexp"`: the query string is a regular expression the text must match
27
+ * @param {string} [options.from] The name of a database table to use as an
28
+ * autocomplete data source for this widget. Used in conjunction with the
29
+ * *column* option.
30
+ * @param {string} [options.column] The name of a database column from which
31
+ * to pull valid search results. The unique column values are used as search
32
+ * autocomplete values. Used in conjunction with the *from* option.
33
+ * @param {string} [options.label] A text label for this input.
16
34
  */
17
35
  constructor({
18
36
  element,
@@ -21,6 +39,7 @@ export class Search extends MosaicClient {
21
39
  column,
22
40
  label,
23
41
  type = 'contains',
42
+ field = column,
24
43
  as
25
44
  } = {}) {
26
45
  super(filterBy);
@@ -29,10 +48,11 @@ export class Search extends MosaicClient {
29
48
  this.from = from;
30
49
  this.column = column;
31
50
  this.selection = as;
51
+ this.field = field;
32
52
 
33
53
  this.element = element ?? document.createElement('div');
34
54
  this.element.setAttribute('class', 'input');
35
- this.element.value = this;
55
+ Object.defineProperty(this.element, 'value', { value: this });
36
56
 
37
57
  if (label) {
38
58
  const lab = document.createElement('label');
@@ -66,14 +86,10 @@ export class Search extends MosaicClient {
66
86
  }
67
87
 
68
88
  publish(value) {
69
- const { selection, column, type } = this;
89
+ const { selection, field, type } = this;
70
90
  if (isSelection(selection)) {
71
- selection.update({
72
- source: this,
73
- schema: { type },
74
- value,
75
- predicate: value ? FUNCTIONS[type](column, literal(value)) : null
76
- });
91
+ const clause = clauseMatch(field, value, { source: this, method: type });
92
+ selection.update(clause);
77
93
  } else if (isParam(selection)) {
78
94
  selection.update(value);
79
95
  }
package/src/Slider.js CHANGED
@@ -1,5 +1,5 @@
1
- import { MosaicClient, isParam, isSelection } from '@uwdata/mosaic-core';
2
- import { Query, eq, literal, max, min } from '@uwdata/mosaic-sql';
1
+ import { MosaicClient, Param, clauseInterval, clausePoint, isParam, isSelection } from '@uwdata/mosaic-core';
2
+ import { Query, max, min } from '@uwdata/mosaic-sql';
3
3
  import { input } from './input.js';
4
4
 
5
5
  let _id = 0;
@@ -8,8 +8,34 @@ export const slider = options => input(Slider, options);
8
8
 
9
9
  export class Slider extends MosaicClient {
10
10
  /**
11
- * Create a new Slider instance.
12
- * @param {object} options Options object
11
+ * Create a new slider input.
12
+ * @param {object} [options] Options object
13
+ * @param {HTMLElement} [options.element] The parent DOM element in which to
14
+ * place the slider elements. If undefined, a new `div` element is created.
15
+ * @param {Selection} [options.filterBy] A selection to filter the database
16
+ * table indicated by the *from* option.
17
+ * @param {Param} [options.as] The output param or selection. A selection
18
+ * clause is added based on the currently selected slider option.
19
+ * @param {string} [options.field] The database column name to use within
20
+ * generated selection clause predicates. Defaults to the *column* option.
21
+ * @param {'point' | 'interval'} [options.select] The type of selection clause
22
+ * predicate to generate if the **as** option is a Selection. If `'point'`
23
+ * (the default), the selection predicate is an equality check for the slider
24
+ * value. If `'interval'`, the predicate checks an interval from the minimum
25
+ * to the current slider value.
26
+ * @param {number} [options.min] The minimum slider value.
27
+ * @param {number} [options.max] The maximum slider value.
28
+ * @param {number} [options.step] The slider step, the amount to increment
29
+ * between consecutive values.
30
+ * @param {number} [options.value] The initial slider value.
31
+ * @param {string} [options.from] The name of a database table to use as a data
32
+ * source for this widget. Used in conjunction with the *column* option.
33
+ * The minimum and maximum values of the column determine the slider range.
34
+ * @param {string} [options.column] The name of a database column whose values
35
+ * determine the slider range. Used in conjunction with the *from* option.
36
+ * The minimum and maximum values of the column determine the slider range.
37
+ * @param {string} [options.label] A text label for this input.
38
+ * @param {number} [options.width] The width of the slider in screen pixels.
13
39
  */
14
40
  constructor({
15
41
  element,
@@ -22,6 +48,8 @@ export class Slider extends MosaicClient {
22
48
  column,
23
49
  label = column,
24
50
  value = as?.value,
51
+ select = 'point',
52
+ field = column,
25
53
  width
26
54
  } = {}) {
27
55
  super(filterBy);
@@ -29,45 +57,59 @@ export class Slider extends MosaicClient {
29
57
  this.from = from;
30
58
  this.column = column || 'value';
31
59
  this.selection = as;
60
+ this.selectionType = select;
61
+ this.field = field;
32
62
  this.min = min;
33
63
  this.max = max;
34
64
  this.step = step;
35
65
 
36
66
  this.element = element || document.createElement('div');
37
67
  this.element.setAttribute('class', 'input');
38
- this.element.value = this;
68
+ Object.defineProperty(this.element, 'value', { value: this });
39
69
 
40
70
  if (label) {
41
- const lab = document.createElement('label');
42
- lab.setAttribute('for', this.id);
43
- lab.innerText = label;
44
- this.element.appendChild(lab);
71
+ const desc = document.createElement('label');
72
+ desc.setAttribute('for', this.id);
73
+ desc.innerText = label;
74
+ this.element.appendChild(desc);
45
75
  }
46
76
 
47
77
  this.slider = document.createElement('input');
48
78
  this.slider.setAttribute('id', this.id);
49
79
  this.slider.setAttribute('type', 'range');
50
80
  if (width != null) this.slider.style.width = `${+width}px`;
51
- if (min != null) this.slider.setAttribute('min', min);
52
- if (max != null) this.slider.setAttribute('max', max);
53
- if (step != null) this.slider.setAttribute('step', step);
81
+ if (min != null) this.slider.setAttribute('min', `${min}`);
82
+ if (max != null) this.slider.setAttribute('max', `${max}`);
83
+ if (step != null) this.slider.setAttribute('step', `${step}`);
84
+ this.element.appendChild(this.slider);
85
+
86
+ this.curval = document.createElement('label');
87
+ this.curval.setAttribute('for', this.id);
88
+ this.curval.setAttribute('class', 'value');
89
+ this.element.appendChild(this.curval);
90
+
91
+ // handle initial value
54
92
  if (value != null) {
55
- this.slider.setAttribute('value', value);
93
+ this.slider.setAttribute('value', `${value}`);
56
94
  if (this.selection?.value === undefined) this.publish(value);
57
95
  }
58
- this.element.appendChild(this.slider);
96
+ this.curval.innerText = this.slider.value;
97
+
98
+ // respond to slider input
99
+ this.slider.addEventListener('input', () => {
100
+ const { value } = this.slider;
101
+ this.curval.innerText = value;
102
+ if (this.selection) this.publish(+value);
103
+ });
59
104
 
60
- if (this.selection) {
61
- this.slider.addEventListener('input', () => {
62
- this.publish(+this.slider.value);
105
+ // track param updates
106
+ if (this.selection && !isSelection(this.selection)) {
107
+ this.selection.addEventListener('value', value => {
108
+ if (value !== +this.slider.value) {
109
+ this.slider.value = value;
110
+ this.curval.innerText = value;
111
+ }
63
112
  });
64
- if (!isSelection(this.selection)) {
65
- this.selection.addEventListener('value', value => {
66
- if (value !== +this.slider.value) {
67
- this.slider.value = value;
68
- }
69
- });
70
- }
71
113
  }
72
114
  }
73
115
 
@@ -82,21 +124,36 @@ export class Slider extends MosaicClient {
82
124
 
83
125
  queryResult(data) {
84
126
  const { min, max } = Array.from(data)[0];
85
- if (this.min == null) this.slider.setAttribute('min', min);
86
- if (this.max == null) this.slider.setAttribute('max', max);
87
- if (this.step == null) this.slider.setAttribute('step', String((max - min) / 500));
127
+ if (this.min == null) {
128
+ this.min = min;
129
+ this.slider.setAttribute('min', `${min}`);
130
+ }
131
+ if (this.max == null) {
132
+ this.max = max;
133
+ this.slider.setAttribute('max', `${max}`);
134
+ }
135
+ if (this.step == null) {
136
+ this.step = (max - min) / 500;
137
+ this.slider.setAttribute('step', `${this.step}`);
138
+ }
88
139
  return this;
89
140
  }
90
141
 
91
142
  publish(value) {
92
- const { selection, column } = this;
143
+ const { field, selectionType, selection } = this;
93
144
  if (isSelection(selection)) {
94
- selection.update({
95
- source: this,
96
- schema: { type: 'point' },
97
- value,
98
- predicate: eq(column, literal(value))
99
- });
145
+ if (selectionType === 'interval') {
146
+ /** @type {[number, number]} */
147
+ const domain = [this.min ?? 0, value];
148
+ selection.update(clauseInterval(field, domain, {
149
+ source: this,
150
+ bin: 'ceil',
151
+ scale: { type: 'identity', domain },
152
+ pixelSize: this.step
153
+ }));
154
+ } else {
155
+ selection.update(clausePoint(field, value, { source: this }));
156
+ }
100
157
  } else if (isParam(this.selection)) {
101
158
  selection.update(value);
102
159
  }
package/src/Table.js CHANGED
@@ -1,4 +1,4 @@
1
- import { MosaicClient, coordinator } from '@uwdata/mosaic-core';
1
+ import { MosaicClient, clausePoints, coordinator, toDataColumns } from '@uwdata/mosaic-core';
2
2
  import { Query, column, desc } from '@uwdata/mosaic-sql';
3
3
  import { formatDate, formatLocaleAuto, formatLocaleNumber } from './util/format.js';
4
4
  import { input } from './input.js';
@@ -23,6 +23,7 @@ export class Table extends MosaicClient {
23
23
  maxWidth,
24
24
  height = 500,
25
25
  rowBatch = 100,
26
+ as
26
27
  } = {}) {
27
28
  super(filterBy);
28
29
  this.id = `table-${++_id}`;
@@ -36,13 +37,16 @@ export class Table extends MosaicClient {
36
37
  this.limit = +rowBatch;
37
38
  this.pending = false;
38
39
 
40
+ this.selection = as;
41
+ this.currentRow = -1;
42
+
39
43
  this.sortHeader = null;
40
44
  this.sortColumn = null;
41
45
  this.sortDesc = false;
42
46
 
43
47
  this.element = element || document.createElement('div');
44
48
  this.element.setAttribute('id', this.id);
45
- this.element.value = this;
49
+ Object.defineProperty(this.element, 'value', { value: this });
46
50
  if (typeof width === 'number') this.element.style.width = `${width}px`;
47
51
  if (maxWidth) this.element.style.maxWidth = `${maxWidth}px`;
48
52
  this.element.style.maxHeight = `${height}px`;
@@ -72,10 +76,34 @@ export class Table extends MosaicClient {
72
76
  this.body = document.createElement('tbody');
73
77
  this.tbl.appendChild(this.body);
74
78
 
79
+ if (this.selection) {
80
+ this.body.addEventListener('pointerover', evt => {
81
+ const row = resolveRow(evt.target);
82
+ if (row > -1 && row !== this.currentRow) {
83
+ this.currentRow = row;
84
+ this.selection.update(this.clause([row]));
85
+ }
86
+ });
87
+ this.body.addEventListener('pointerleave', () => {
88
+ this.currentRow = -1;
89
+ this.selection.update(this.clause());
90
+ });
91
+ }
92
+
75
93
  this.style = document.createElement('style');
76
94
  this.element.appendChild(this.style);
77
95
  }
78
96
 
97
+ clause(rows = []) {
98
+ const { data, limit, schema } = this;
99
+ const fields = schema.map(s => s.column);
100
+ const values = rows.map(row => {
101
+ const { columns } = data[~~(row / limit)];
102
+ return fields.map(f => columns[f][row % limit]);
103
+ });
104
+ return clausePoints(fields, values, { source: this });
105
+ }
106
+
79
107
  requestData(offset = 0) {
80
108
  this.offset = offset;
81
109
 
@@ -133,30 +161,35 @@ export class Table extends MosaicClient {
133
161
  if (!this.pending) {
134
162
  // data is not from an internal request, so reset table
135
163
  this.loaded = false;
164
+ this.data = [];
136
165
  this.body.replaceChildren();
166
+ this.offset = 0;
137
167
  }
138
- this.data = data;
168
+ this.data.push(toDataColumns(data));
139
169
  return this;
140
170
  }
141
171
 
142
172
  update() {
143
173
  const { body, formats, data, schema, limit } = this;
144
174
  const nf = schema.length;
175
+ const n = data.length - 1;
176
+ const rowCount = limit * n;
145
177
 
146
- let count = 0;
147
- for (const row of data) {
148
- ++count;
178
+ const { numRows, columns } = data[n];
179
+ const cols = schema.map(s => columns[s.column]);
180
+ for (let i = 0; i < numRows; ++i) {
149
181
  const tr = document.createElement('tr');
150
- for (let i = 0; i < nf; ++i) {
151
- const value = row[schema[i].column];
182
+ Object.assign(tr, { __row__: rowCount + i });
183
+ for (let j = 0; j < nf; ++j) {
184
+ const value = cols[j][i];
152
185
  const td = document.createElement('td');
153
- td.innerText = value == null ? '' : formats[i](value);
186
+ td.innerText = value == null ? '' : formats[j](value);
154
187
  tr.appendChild(td);
155
188
  }
156
189
  body.appendChild(tr);
157
190
  }
158
191
 
159
- if (count < limit) {
192
+ if (numRows < limit) {
160
193
  // data table has been fully loaded
161
194
  this.loaded = true;
162
195
  }
@@ -190,6 +223,16 @@ export class Table extends MosaicClient {
190
223
  }
191
224
  }
192
225
 
226
+ /**
227
+ * Resolve a table row number from a table cell element.
228
+ * @param {any} element An HTML element.
229
+ * @returns {number} The resolved row, or -1 if not a row.
230
+ */
231
+ function resolveRow(element) {
232
+ const p = element.parentElement;
233
+ return Object.hasOwn(p, '__row__') ? +p.__row__ : -1;
234
+ }
235
+
193
236
  function formatof(base = {}, schema, locale) {
194
237
  return schema.map(({ column, type }) => {
195
238
  if (column in base) {