@uwdata/mosaic-core 0.8.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uwdata/mosaic-core",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Scalable and extensible linked data views.",
5
5
  "keywords": [
6
6
  "mosaic",
@@ -28,12 +28,12 @@
28
28
  "prepublishOnly": "npm run test && npm run lint && npm run build"
29
29
  },
30
30
  "dependencies": {
31
- "@duckdb/duckdb-wasm": "^1.28.1-dev194.0",
32
- "@uwdata/mosaic-sql": "^0.8.0",
31
+ "@duckdb/duckdb-wasm": "^1.28.1-dev195.0",
32
+ "@uwdata/mosaic-sql": "^0.9.0",
33
33
  "apache-arrow": "^15.0.2"
34
34
  },
35
35
  "devDependencies": {
36
- "@uwdata/mosaic-duckdb": "^0.8.0"
36
+ "@uwdata/mosaic-duckdb": "^0.9.0"
37
37
  },
38
- "gitHead": "a24b4c9f7dfa1c38c6af96ec17e075326c1af9b0"
38
+ "gitHead": "89bb9b0dfa747aed691eaeba35379525a6764c61"
39
39
  }
@@ -24,7 +24,7 @@ export class Coordinator {
24
24
  constructor(db = socketConnector(), options = {}) {
25
25
  const {
26
26
  logger = console,
27
- manager = QueryManager()
27
+ manager = new QueryManager()
28
28
  } = options;
29
29
  this.manager = manager;
30
30
  this.logger(logger);
@@ -1,5 +1,6 @@
1
- import { Query, and, create, isBetween, scaleTransform, sql } from '@uwdata/mosaic-sql';
1
+ import { Query, and, asColumn, create, isBetween, scaleTransform, sql } from '@uwdata/mosaic-sql';
2
2
  import { fnv_hash } from './util/hash.js';
3
+ import { indexColumns } from './util/index-columns.js';
3
4
 
4
5
  /**
5
6
  * Build and query optimized indices ("data cubes") for fast computation of
@@ -9,7 +10,7 @@ import { fnv_hash } from './util/hash.js';
9
10
  * as temporary database tables that can be queried for rapid updates.
10
11
  * Compatible client queries must pull data from the same backing table and
11
12
  * must consist of only groupby dimensions and supported aggregates.
12
- * Compatible selections must contain an active clause that exposes a schema
13
+ * Compatible selections must contain an active clause that exposes metadata
13
14
  * for an interval or point value predicate.
14
15
  */
15
16
  export class DataCubeIndexer {
@@ -30,39 +31,40 @@ export class DataCubeIndexer {
30
31
  this.enabled = false;
31
32
  this.clients = null;
32
33
  this.indices = null;
33
- this.activeView = null;
34
+ this.active = null;
34
35
  }
35
36
 
36
37
  clear() {
37
38
  if (this.indices) {
38
- this.mc.cancel(Array.from(this.indices.values(), index => index.result));
39
+ this.mc.cancel(Array.from(this.indices.values(), index => index?.result));
39
40
  this.indices = null;
40
41
  }
41
42
  }
42
43
 
43
- index(clients, active) {
44
+ index(clients, activeClause) {
44
45
  if (this.clients !== clients) {
45
46
  // test client views for compatibility
46
- const cols = Array.from(clients, getIndexColumns);
47
+ const cols = Array.from(clients, indexColumns).filter(x => x);
47
48
  const from = cols[0]?.from;
48
- this.enabled = cols.every(c => c && c.from === from);
49
+ this.enabled = cols.length && cols.every(c => c.from === from);
49
50
  this.clients = clients;
50
- this.activeView = null;
51
+ this.active = null;
51
52
  this.clear();
52
53
  }
53
54
  if (!this.enabled) return false; // client views are not indexable
54
55
 
55
- active = active || this.selection.active;
56
- const { source } = active;
56
+ activeClause = activeClause || this.selection.active;
57
+ const { source } = activeClause;
57
58
  // exit early if indexes already set up for active view
58
- if (source && source === this.activeView?.source) return true;
59
+ if (source && source === this.active?.source) return true;
59
60
 
60
61
  this.clear();
61
62
  if (!source) return false; // nothing to work with
62
- const activeView = this.activeView = getActiveView(active);
63
- if (!activeView) return false; // active selection clause not compatible
63
+ const active = this.active = activeColumns(activeClause);
64
+ if (!active) return false; // active selection clause not compatible
64
65
 
65
- this.mc.logger().warn('DATA CUBE INDEX CONSTRUCTION');
66
+ const logger = this.mc.logger();
67
+ logger.warn('DATA CUBE INDEX CONSTRUCTION');
66
68
 
67
69
  // create a selection with the active source removed
68
70
  const sel = this.selection.remove(source);
@@ -71,18 +73,29 @@ export class DataCubeIndexer {
71
73
  const indices = this.indices = new Map;
72
74
  const { mc, temp } = this;
73
75
  for (const client of clients) {
74
- if (sel.skip(client, active)) continue;
75
- const index = getIndexColumns(client);
76
+ // determine if client should be skipped due to cross-filtering
77
+ if (sel.skip(client, activeClause)) {
78
+ indices.set(client, null);
79
+ continue;
80
+ }
81
+
82
+ // generate column definitions for data cube and cube queries
83
+ const index = indexColumns(client);
84
+
85
+ // skip if client is not indexable
86
+ if (!index) {
87
+ continue;
88
+ }
76
89
 
77
- // build index construction query
90
+ // build index table construction query
78
91
  const query = client.query(sel.predicate(client))
79
- .select({ ...activeView.columns, ...index.aux })
80
- .groupby(Object.keys(activeView.columns));
92
+ .select({ ...active.columns, ...index.aux })
93
+ .groupby(Object.keys(active.columns));
81
94
 
82
95
  // ensure active view columns are selected by subqueries
83
96
  const [subq] = query.subqueries;
84
97
  if (subq) {
85
- const cols = Object.values(activeView.columns).map(c => c.columns[0]);
98
+ const cols = Object.values(active.columns).flatMap(c => c.columns);
86
99
  subqueryPushdown(subq, cols);
87
100
  }
88
101
 
@@ -94,6 +107,7 @@ export class DataCubeIndexer {
94
107
  const id = (fnv_hash(sql) >>> 0).toString(16);
95
108
  const table = `cube_index_${id}`;
96
109
  const result = mc.exec(create(table, sql, { temp }));
110
+ result.catch(e => logger.error(e));
97
111
  indices.set(client, { table, result, order, ...index });
98
112
  }
99
113
 
@@ -102,21 +116,28 @@ export class DataCubeIndexer {
102
116
  }
103
117
 
104
118
  async update() {
105
- const { clients, selection, activeView } = this;
106
- const filter = activeView.predicate(selection.active.predicate);
119
+ const { clients, selection, active } = this;
120
+ const filter = active.predicate(selection.active.predicate);
107
121
  return Promise.all(
108
122
  Array.from(clients).map(client => this.updateClient(client, filter))
109
123
  );
110
124
  }
111
125
 
112
126
  async updateClient(client, filter) {
127
+ const { mc, indices, selection } = this;
128
+
129
+ // if client has no index, perform a standard update
130
+ if (!indices.has(client)) {
131
+ filter = selection.predicate(client);
132
+ return mc.updateClient(client, client.query(filter));
133
+ };
134
+
113
135
  const index = this.indices.get(client);
114
- if (!index) return;
115
136
 
116
- if (!filter) {
117
- filter = this.activeView.predicate(this.selection.active.predicate);
118
- }
137
+ // skip update if cross-filtered
138
+ if (!index) return;
119
139
 
140
+ // otherwise, query a data cube index table
120
141
  const { table, dims, aggr, order = [] } = index;
121
142
  const query = Query
122
143
  .select(dims, aggr)
@@ -124,25 +145,30 @@ export class DataCubeIndexer {
124
145
  .groupby(dims)
125
146
  .where(filter)
126
147
  .orderby(order);
127
- return this.mc.updateClient(client, query);
148
+ return mc.updateClient(client, query);
128
149
  }
129
150
  }
130
151
 
131
- function getActiveView(clause) {
132
- const { source, schema } = clause;
152
+ function activeColumns(clause) {
153
+ const { source, meta } = clause;
133
154
  let columns = clause.predicate?.columns;
134
- if (!schema || !columns) return null;
135
- const { type, scales, pixelSize = 1 } = schema;
155
+ if (!meta || !columns) return null;
156
+ const { type, scales, bin, pixelSize = 1 } = meta;
136
157
  let predicate;
137
158
 
138
159
  if (type === 'interval' && scales) {
139
- const bins = scales.map(s => binInterval(s, pixelSize));
140
- if (bins.some(b => b == null)) return null; // unsupported scale type
160
+ // determine pixel-level binning
161
+ const bins = scales.map(s => binInterval(s, pixelSize, bin));
162
+
163
+ // bail if the scale type is unsupported
164
+ if (bins.some(b => b == null)) return null;
141
165
 
142
166
  if (bins.length === 1) {
167
+ // single interval selection
143
168
  predicate = p => p ? isBetween('active0', p.range.map(bins[0])) : [];
144
169
  columns = { active0: bins[0](clause.predicate.field) };
145
170
  } else {
171
+ // multiple interval selection
146
172
  predicate = p => p
147
173
  ? and(p.children.map(({ range }, i) => isBetween(`active${i}`, range.map(bins[i]))))
148
174
  : [];
@@ -152,99 +178,27 @@ function getActiveView(clause) {
152
178
  }
153
179
  } else if (type === 'point') {
154
180
  predicate = x => x;
155
- columns = Object.fromEntries(columns.map(col => [col.toString(), col]));
181
+ columns = Object.fromEntries(columns.map(col => [`${col}`, asColumn(col)]));
156
182
  } else {
157
- return null; // unsupported type
183
+ // unsupported selection type
184
+ return null;
158
185
  }
159
186
 
160
187
  return { source, columns, predicate };
161
188
  }
162
189
 
163
- function binInterval(scale, pixelSize) {
164
- const { apply, sqlApply } = scaleTransform(scale);
165
- if (apply) {
166
- const { domain, range } = scale;
167
- const lo = apply(Math.min(...domain));
168
- const hi = apply(Math.max(...domain));
169
- const a = (Math.abs(range[1] - range[0]) / (hi - lo)) / pixelSize;
170
- const s = pixelSize === 1 ? '' : `${pixelSize}::INTEGER * `;
171
- return value => sql`${s}FLOOR(${a}::DOUBLE * (${sqlApply(value)} - ${lo}::DOUBLE))::INTEGER`;
172
- }
173
- }
174
-
175
- const NO_INDEX = { from: NaN };
176
-
177
- function getIndexColumns(client) {
178
- if (!client.filterIndexable) return NO_INDEX;
179
- const q = client.query();
180
- const from = getBaseTable(q);
181
- if (!from || !q.groupby) return NO_INDEX;
182
- const g = new Set(q.groupby().map(c => c.column));
183
-
184
- const aggr = [];
185
- const dims = [];
186
- const aux = {}; // auxiliary columns needed by aggregates
187
- let auxAs;
188
-
189
- for (const entry of q.select()) {
190
- const { as, expr: { aggregate, args } } = entry;
191
- const op = aggregate?.toUpperCase?.();
192
- switch (op) {
193
- case 'COUNT':
194
- case 'SUM':
195
- aggr.push({ [as]: sql`SUM("${as}")::DOUBLE` });
196
- break;
197
- case 'AVG':
198
- aux[auxAs = '__count__'] = sql`COUNT(*)`;
199
- aggr.push({ [as]: sql`(SUM("${as}" * ${auxAs}) / SUM(${auxAs}))::DOUBLE` });
200
- break;
201
- case 'ARG_MAX':
202
- aux[auxAs = `__max_${as}__`] = sql`MAX(${args[1]})`;
203
- aggr.push({ [as]: sql`ARG_MAX("${as}", ${auxAs})` });
204
- break;
205
- case 'ARG_MIN':
206
- aux[auxAs = `__min_${as}__`] = sql`MIN(${args[1]})`;
207
- aggr.push({ [as]: sql`ARG_MIN("${as}", ${auxAs})` });
208
- break;
209
-
210
- // aggregates that commute directly
211
- case 'MAX':
212
- case 'MIN':
213
- case 'BIT_AND':
214
- case 'BIT_OR':
215
- case 'BIT_XOR':
216
- case 'BOOL_AND':
217
- case 'BOOL_OR':
218
- case 'PRODUCT':
219
- aggr.push({ [as]: sql`${op}("${as}")` });
220
- break;
221
- default:
222
- if (g.has(as)) dims.push(as);
223
- else return null;
224
- }
225
- }
226
-
227
- return { aggr, dims, aux, from };
228
- }
229
-
230
- function getBaseTable(query) {
231
- const subq = query.subqueries;
232
-
233
- // select query
234
- if (query.select) {
235
- const from = query.from();
236
- if (!from.length) return undefined;
237
- if (subq.length === 0) return from[0].from.table;
238
- }
239
-
240
- // handle set operations / subqueries
241
- const base = getBaseTable(subq[0]);
242
- for (let i = 1; i < subq.length; ++i) {
243
- const from = getBaseTable(subq[i]);
244
- if (from === undefined) continue;
245
- if (from !== base) return NaN;
246
- }
247
- return base;
190
+ const BIN = { ceil: 'CEIL', round: 'ROUND' };
191
+
192
+ function binInterval(scale, pixelSize, bin) {
193
+ const { type, domain, range, apply, sqlApply } = scaleTransform(scale);
194
+ if (!apply) return; // unsupported scale type
195
+ const fn = BIN[`${bin}`.toLowerCase()] || 'FLOOR';
196
+ const lo = apply(Math.min(...domain));
197
+ const hi = apply(Math.max(...domain));
198
+ const a = type === 'identity' ? 1 : Math.abs(range[1] - range[0]) / (hi - lo);
199
+ const s = a / pixelSize === 1 ? '' : `${a / pixelSize}::DOUBLE * `;
200
+ const d = lo === 0 ? '' : ` - ${lo}::DOUBLE`;
201
+ return value => sql`${fn}(${s}(${sqlApply(value)}${d}))::INTEGER`;
248
202
  }
249
203
 
250
204
  function subqueryPushdown(query, cols) {
@@ -1,24 +1,27 @@
1
+ import { Coordinator } from './Coordinator.js';
1
2
  import { DataCubeIndexer } from './DataCubeIndexer.js';
3
+ import { MosaicClient } from './MosaicClient.js';
4
+ import { Selection } from './Selection.js';
2
5
 
3
6
  export class FilterGroup {
4
7
  /**
5
- * @param {import('./Coordinator.js').Coordinator} coordinator The Mosaic coordinator.
6
- * @param {*} selection The shared filter selection.
7
- * @param {*} index Boolean flag or options hash for data cube indexer.
8
- * Falsy values disable indexing.
8
+ * @param {Coordinator} coordinator The Mosaic coordinator.
9
+ * @param {Selection} selection The shared filter selection.
10
+ * @param {object|boolean} index Boolean flag or options hash for
11
+ * a data cube indexer. Falsy values disable indexing.
9
12
  */
10
13
  constructor(coordinator, selection, index = true) {
11
- /** @type import('./Coordinator.js').Coordinator */
12
14
  this.mc = coordinator;
13
15
  this.selection = selection;
16
+ /** @type {Set<MosaicClient>} */
14
17
  this.clients = new Set();
15
- this.indexer = index
16
- ? new DataCubeIndexer(this.mc, { ...index, selection })
17
- : null;
18
+ /** @type {DataCubeIndexer | null} */
19
+ this.indexer = null;
20
+ this.index(index);
18
21
 
19
22
  const { value, activate } = this.handlers = {
20
23
  value: () => this.update(),
21
- activate: clause => this.indexer?.index(this.clients, clause)
24
+ activate: clause => { this.indexer?.index(this.clients, clause); }
22
25
  };
23
26
  selection.addEventListener('value', value);
24
27
  selection.addEventListener('activate', activate);
@@ -30,6 +33,14 @@ export class FilterGroup {
30
33
  this.selection.removeEventListener('activate', activate);
31
34
  }
32
35
 
36
+ index(state) {
37
+ const { selection } = this;
38
+ const { resolver } = selection;
39
+ this.indexer = state && (resolver.single || !resolver.union)
40
+ ? new DataCubeIndexer(this.mc, { ...state, selection })
41
+ : null;
42
+ }
43
+
33
44
  reset() {
34
45
  this.indexer?.reset();
35
46
  }
@@ -5,48 +5,51 @@ import { queryResult } from './util/query-result.js';
5
5
 
6
6
  export const Priority = { High: 0, Normal: 1, Low: 2 };
7
7
 
8
- export function QueryManager() {
9
- const queue = priorityQueue(3);
10
- let db;
11
- let clientCache;
12
- let logger;
13
- let recorders = [];
14
- let pending = null;
15
- let consolidate;
16
-
17
- function next() {
18
- if (pending || queue.isEmpty()) return;
19
- const { request, result } = queue.next();
20
- pending = submit(request, result);
21
- pending.finally(() => { pending = null; next(); });
8
+ export class QueryManager {
9
+ constructor() {
10
+ this.queue = priorityQueue(3);
11
+ this.db = null;
12
+ this.clientCache = null;
13
+ this._logger = null;
14
+ this._logQueries = false;
15
+ this.recorders = [];
16
+ this.pending = null;
17
+ this._consolidate = null;
22
18
  }
23
19
 
24
- function enqueue(entry, priority = Priority.Normal) {
25
- queue.insert(entry, priority);
26
- next();
20
+ next() {
21
+ if (this.pending || this.queue.isEmpty()) return;
22
+ const { request, result } = this.queue.next();
23
+ this.pending = this.submit(request, result);
24
+ this.pending.finally(() => { this.pending = null; this.next(); });
27
25
  }
28
26
 
29
- function recordQuery(sql) {
30
- if (recorders.length && sql) {
31
- recorders.forEach(rec => rec.add(sql));
27
+ enqueue(entry, priority = Priority.Normal) {
28
+ this.queue.insert(entry, priority);
29
+ this.next();
30
+ }
31
+
32
+ recordQuery(sql) {
33
+ if (this.recorders.length && sql) {
34
+ this.recorders.forEach(rec => rec.add(sql));
32
35
  }
33
36
  }
34
37
 
35
- async function submit(request, result) {
38
+ async submit(request, result) {
36
39
  try {
37
40
  const { query, type, cache = false, record = true, options } = request;
38
41
  const sql = query ? `${query}` : null;
39
42
 
40
43
  // update recorders
41
44
  if (record) {
42
- recordQuery(sql);
45
+ this.recordQuery(sql);
43
46
  }
44
47
 
45
48
  // check query cache
46
49
  if (cache) {
47
- const cached = clientCache.get(sql);
50
+ const cached = this.clientCache.get(sql);
48
51
  if (cached) {
49
- logger.debug('Cache');
52
+ this._logger.debug('Cache');
50
53
  result.fulfill(cached);
51
54
  return;
52
55
  }
@@ -54,80 +57,85 @@ export function QueryManager() {
54
57
 
55
58
  // issue query, potentially cache result
56
59
  const t0 = performance.now();
57
- const data = await db.query({ type, sql, ...options });
58
- if (cache) clientCache.set(sql, data);
59
- logger.debug(`Request: ${(performance.now() - t0).toFixed(1)}`);
60
+ if (this._logQueries) {
61
+ this._logger.debug('Query', { type, sql, ...options });
62
+ }
63
+ const data = await this.db.query({ type, sql, ...options });
64
+ if (cache) this.clientCache.set(sql, data);
65
+ this._logger.debug(`Request: ${(performance.now() - t0).toFixed(1)}`);
60
66
  result.fulfill(data);
61
67
  } catch (err) {
62
68
  result.reject(err);
63
69
  }
64
70
  }
65
71
 
66
- return {
67
- cache(value) {
68
- return value !== undefined
69
- ? (clientCache = value === true ? lruCache() : (value || voidCache()))
70
- : clientCache;
71
- },
72
-
73
- logger(value) {
74
- return value ? (logger = value) : logger;
75
- },
76
-
77
- connector(connector) {
78
- return connector ? (db = connector) : db;
79
- },
80
-
81
- consolidate(flag) {
82
- if (flag && !consolidate) {
83
- consolidate = consolidator(enqueue, clientCache, recordQuery);
84
- } else if (!flag && consolidate) {
85
- consolidate = null;
86
- }
87
- },
88
-
89
- request(request, priority = Priority.Normal) {
90
- const result = queryResult();
91
- const entry = { request, result };
92
- if (consolidate) {
93
- consolidate.add(entry, priority);
94
- } else {
95
- enqueue(entry, priority);
96
- }
97
- return result;
98
- },
99
-
100
- cancel(requests) {
101
- const set = new Set(requests);
102
- queue.remove(({ result }) => set.has(result));
103
- },
104
-
105
- clear() {
106
- queue.remove(({ result }) => {
107
- result.reject('Cleared');
108
- return true;
109
- });
110
- },
111
-
112
- record() {
113
- let state = [];
114
- const recorder = {
115
- add(query) {
116
- state.push(query);
117
- },
118
- reset() {
119
- state = [];
120
- },
121
- snapshot() {
122
- return state.slice();
123
- },
124
- stop() {
125
- recorders = recorders.filter(x => x !== recorder);
126
- return state;
127
- }
128
- };
129
- recorders.push(recorder);
130
- return recorder;
72
+ cache(value) {
73
+ return value !== undefined
74
+ ? (this.clientCache = value === true ? lruCache() : (value || voidCache()))
75
+ : this.clientCache;
76
+ }
77
+
78
+ logger(value) {
79
+ return value ? (this._logger = value) : this._logger;
80
+ }
81
+
82
+ logQueries(value) {
83
+ return value !== undefined ? this._logQueries = !!value : this._logQueries;
84
+ }
85
+
86
+ connector(connector) {
87
+ return connector ? (this.db = connector) : this.db;
88
+ }
89
+
90
+ consolidate(flag) {
91
+ if (flag && !this._consolidate) {
92
+ this._consolidate = consolidator(this.enqueue.bind(this), this.clientCache, this.recordQuery.bind(this));
93
+ } else if (!flag && this._consolidate) {
94
+ this._consolidate = null;
95
+ }
96
+ }
97
+
98
+ request(request, priority = Priority.Normal) {
99
+ const result = queryResult();
100
+ const entry = { request, result };
101
+ if (this._consolidate) {
102
+ this._consolidate.add(entry, priority);
103
+ } else {
104
+ this.enqueue(entry, priority);
131
105
  }
132
- };
106
+ return result;
107
+ }
108
+
109
+ cancel(requests) {
110
+ const set = new Set(requests);
111
+ this.queue.remove(({ result }) => set.has(result));
112
+ }
113
+
114
+ clear() {
115
+ this.queue.remove(({ result }) => {
116
+ result.reject('Cleared');
117
+ return true;
118
+ });
119
+ }
120
+
121
+ record() {
122
+ let state = [];
123
+ const recorder = {
124
+ add(query) {
125
+ state.push(query);
126
+ },
127
+ reset() {
128
+ state = [];
129
+ },
130
+ snapshot() {
131
+ return state.slice();
132
+ },
133
+ stop() {
134
+ this.recorders = this.recorders.filter(x => x !== recorder);
135
+ return state;
136
+ }
137
+ };
138
+ this.recorders.push(recorder);
139
+ return recorder;
140
+ }
133
141
  }
package/src/Selection.js CHANGED
@@ -98,19 +98,17 @@ export class Selection extends Param {
98
98
  }
99
99
 
100
100
  /**
101
- * The current active (most recently updated) selection clause.
101
+ * The selection clause resolver.
102
102
  */
103
- get active() {
104
- return this.clauses.active;
103
+ get resolver() {
104
+ return this._resolver;
105
105
  }
106
106
 
107
107
  /**
108
- * The value corresponding to the current active selection clause.
109
- * This method ensures compatibility where a normal Param is expected.
108
+ * Indicate if this selection has a single resolution strategy.
110
109
  */
111
- get value() {
112
- // return value of the active clause
113
- return this.active?.value;
110
+ get single() {
111
+ return this._resolver.single;
114
112
  }
115
113
 
116
114
  /**
@@ -121,10 +119,27 @@ export class Selection extends Param {
121
119
  }
122
120
 
123
121
  /**
124
- * Indicate if this selection has a single resolution strategy.
122
+ * The current active (most recently updated) selection clause.
125
123
  */
126
- get single() {
127
- return this._resolver.single;
124
+ get active() {
125
+ return this.clauses.active;
126
+ }
127
+
128
+ /**
129
+ * The value corresponding to the current active selection clause.
130
+ * This method ensures compatibility where a normal Param is expected.
131
+ */
132
+ get value() {
133
+ return this.active?.value;
134
+ }
135
+
136
+ /**
137
+ * The value corresponding to a given source. Returns undefined if
138
+ * this selection does not include a clause from this source.
139
+ * @param {*} source The clause source to look up the value for.
140
+ */
141
+ valueFor(source) {
142
+ return this.clauses.find(c => c.source === source)?.value;
128
143
  }
129
144
 
130
145
  /**