-
Notifications
You must be signed in to change notification settings - Fork 62
/
Selection.js
326 lines (295 loc) · 11 KB
/
Selection.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
import { or } from '@uwdata/mosaic-sql';
import { Param } from './Param.js';
/**
* Test if a value is a Selection instance.
* @param {*} x The value to test.
* @returns {x is Selection} True if the input is a Selection, false otherwise.
*/
export function isSelection(x) {
return x instanceof Selection;
}
/**
* Represents a dynamic set of query filter predicates.
*/
export class Selection extends Param {
/**
* Create a new Selection instance with an
* intersect (conjunction) resolution strategy.
* @param {object} [options] The selection options.
* @param {boolean} [options.cross=false] Boolean flag indicating
* cross-filtered resolution. If true, selection clauses will not
* be applied to the clients they are associated with.
* @param {boolean} [options.empty=false] Boolean flag indicating if a lack
* of clauses should correspond to an empty selection with no records. This
* setting determines the default selection state.
* @returns {Selection} The new Selection instance.
*/
static intersect({ cross = false, empty = false } = {}) {
return new Selection(new SelectionResolver({ cross, empty }));
}
/**
* Create a new Selection instance with a
* union (disjunction) resolution strategy.
* @param {object} [options] The selection options.
* @param {boolean} [options.cross=false] Boolean flag indicating
* cross-filtered resolution. If true, selection clauses will not
* be applied to the clients they are associated with.
* @param {boolean} [options.empty=false] Boolean flag indicating if a lack
* of clauses should correspond to an empty selection with no records. This
* setting determines the default selection state.
* @returns {Selection} The new Selection instance.
*/
static union({ cross = false, empty = false } = {}) {
return new Selection(new SelectionResolver({ cross, empty, union: true }));
}
/**
* Create a new Selection instance with a singular resolution strategy
* that keeps only the most recent selection clause.
* @param {object} [options] The selection options.
* @param {boolean} [options.cross=false] Boolean flag indicating
* cross-filtered resolution. If true, selection clauses will not
* be applied to the clients they are associated with.
* @param {boolean} [options.empty=false] Boolean flag indicating if a lack
* of clauses should correspond to an empty selection with no records. This
* setting determines the default selection state.
* @returns {Selection} The new Selection instance.
*/
static single({ cross = false, empty = false } = {}) {
return new Selection(new SelectionResolver({ cross, empty, single: true }));
}
/**
* Create a new Selection instance with a
* cross-filtered intersect resolution strategy.
* @param {object} [options] The selection options.
* @param {boolean} [options.empty=false] Boolean flag indicating if a lack
* of clauses should correspond to an empty selection with no records. This
* setting determines the default selection state.
* @returns {Selection} The new Selection instance.
*/
static crossfilter({ empty = false } = {}) {
return new Selection(new SelectionResolver({ cross: true, empty }));
}
/**
* Create a new Selection instance.
* @param {SelectionResolver} resolver The selection resolution
* strategy to apply.
*/
constructor(resolver = new SelectionResolver()) {
super([]);
this._resolved = this._value;
this._resolver = resolver;
}
/**
* Create a cloned copy of this Selection instance.
* @returns {Selection} A clone of this selection.
*/
clone() {
const s = new Selection(this._resolver);
s._value = s._resolved = this._value;
return s;
}
/**
* Create a clone of this Selection with clauses corresponding
* to the provided source removed.
* @param {*} source The clause source to remove.
* @returns {Selection} A cloned and updated Selection.
*/
remove(source) {
const s = this.clone();
s._value = s._resolved = s._resolver.resolve(this._resolved, { source });
s._value.active = { source };
return s;
}
/**
* The selection clause resolver.
*/
get resolver() {
return this._resolver;
}
/**
* Indicate if this selection has a single resolution strategy.
*/
get single() {
return this._resolver.single;
}
/**
* The current array of selection clauses.
*/
get clauses() {
return super.value;
}
/**
* The current active (most recently updated) selection clause.
*/
get active() {
return this.clauses.active;
}
/**
* The value corresponding to the current active selection clause.
* This method ensures compatibility where a normal Param is expected.
*/
get value() {
return this.active?.value;
}
/**
* The value corresponding to a given source. Returns undefined if
* this selection does not include a clause from this source.
* @param {*} source The clause source to look up the value for.
*/
valueFor(source) {
return this.clauses.find(c => c.source === source)?.value;
}
/**
* Emit an activate event with the given selection clause.
* @param {*} clause The clause repesenting the potential activation.
*/
activate(clause) {
this.emit('activate', clause);
}
/**
* Update the selection with a new selection clause.
* @param {*} clause The selection clause to add.
* @returns {this} This Selection instance.
*/
update(clause) {
// we maintain an up-to-date list of all resolved clauses
// this ensures consistent clause state across unemitted event values
this._resolved = this._resolver.resolve(this._resolved, clause, true);
this._resolved.active = clause;
return super.update(this._resolved);
}
/**
* Upon value-typed updates, sets the current clause list to the
* input value and returns the active clause value.
* @param {string} type The event type.
* @param {*} value The input event value.
* @returns {*} For value-typed events, returns the active clause
* values. Otherwise returns the input event value as-is.
*/
willEmit(type, value) {
if (type === 'value') {
this._value = value;
return this.value;
}
return value;
}
/**
* Upon value-typed updates, returns a dispatch queue filter function.
* The return value depends on the selection resolution strategy.
* @param {string} type The event type.
* @param {*} value The new event value that will be enqueued.
* @returns {(value: *) => boolean|null} For value-typed events,
* returns a dispatch queue filter function. Otherwise returns null.
*/
emitQueueFilter(type, value) {
return type === 'value'
? this._resolver.queueFilter(value)
: null;
}
/**
* Indicates if a selection clause should not be applied to a given client.
* The return value depends on the selection resolution strategy.
* @param {*} client The selection clause.
* @param {*} clause The client to test.
* @returns True if the client should be skipped, false otherwise.
*/
skip(client, clause) {
return this._resolver.skip(client, clause);
}
/**
* Return a selection query predicate for the given client.
* @param {*} client The client whose data may be filtered.
* @param {boolean} [noSkip=false] Disable skipping of active
* cross-filtered sources. If set true, the source of the active
* clause in a cross-filtered selection will not be skipped.
* @returns {*} The query predicate for filtering client data,
* based on the current state of this selection.
*/
predicate(client, noSkip = false) {
const { clauses } = this;
const active = noSkip ? null : clauses.active;
return this._resolver.predicate(clauses, active, client);
}
}
/**
* Implements selection clause resolution strategies.
*/
export class SelectionResolver {
/**
* Create a new selection resolved instance.
* @param {object} [options] The resolution strategy options.
* @param {boolean} [options.union=false] Boolean flag to indicate a union strategy.
* If false, an intersection strategy is used.
* @param {boolean} [options.cross=false] Boolean flag to indicate cross-filtering.
* @param {boolean} [options.single=false] Boolean flag to indicate single clauses only.
* @param {boolean} [options.empty=false] Boolean flag indicating if a lack
* of clauses should correspond to an empty selection with no records. This
* setting determines the default selection state.
*/
constructor({ union, cross, single, empty } = {}) {
this.union = !!union;
this.cross = !!cross;
this.single = !!single;
this.empty = !!empty;
}
/**
* Resolve a list of selection clauses according to the resolution strategy.
* @param {*[]} clauseList An array of selection clauses.
* @param {*} clause A new selection clause to add.
* @returns {*[]} An updated array of selection clauses.
*/
resolve(clauseList, clause, reset = false) {
const { source, predicate } = clause;
const filtered = clauseList.filter(c => source !== c.source);
const clauses = this.single ? [] : filtered;
if (this.single && reset) filtered.forEach(c => c.source?.reset?.());
if (predicate) clauses.push(clause);
return clauses;
}
/**
* Indicates if a selection clause should not be applied to a given client.
* The return value depends on the resolution strategy.
* @param {*} client The selection clause.
* @param {*} clause The client to test.
* @returns True if the client should be skipped, false otherwise.
*/
skip(client, clause) {
return this.cross && clause?.clients?.has(client);
}
/**
* Return a selection query predicate for the given client.
* @param {*[]} clauseList An array of selection clauses.
* @param {*} active The current active selection clause.
* @param {*} client The client whose data may be filtered.
* @returns {*} The query predicate for filtering client data,
* based on the current state of this selection.
*/
predicate(clauseList, active, client) {
const { empty, union } = this;
if (empty && !clauseList.length) {
return ['FALSE'];
}
// do nothing if cross-filtering and client is currently active
if (this.skip(client, active)) return undefined;
// remove client-specific predicates if cross-filtering
const predicates = clauseList
.filter(clause => !this.skip(client, clause))
.map(clause => clause.predicate);
// return appropriate conjunction or disjunction
// an array of predicates is implicitly conjunctive
return union && predicates.length > 1 ? or(predicates) : predicates;
}
/**
* Returns a filter function for queued selection updates.
* @param {*} value The new event value that will be enqueued.
* @returns {(value: *) => boolean|null} A dispatch queue filter
* function, or null if all unemitted event values should be filtered.
*/
queueFilter(value) {
if (this.cross) {
const source = value.active?.source;
return clauses => clauses.active?.source !== source;
}
return null;
}
}