@fjader/svelte 0.2.3 → 0.2.4
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/README.md +2 -0
- package/dist/components/axes/AxisBottom.svelte +23 -6
- package/dist/components/axes/AxisBottom.svelte.d.ts +10 -0
- package/dist/components/axes/AxisLeft.svelte +77 -8
- package/dist/components/axes/AxisLeft.svelte.d.ts +17 -0
- package/dist/components/axes/BandAxisTicks.svelte +1 -1
- package/dist/components/axes/axis-title.d.ts +30 -0
- package/dist/components/axes/axis-title.js +77 -0
- package/dist/components/charts/ChartCell.svelte +1 -1
- package/dist/components/charts/ChartContainer.svelte +10 -2
- package/dist/components/charts/ChartContainer.svelte.d.ts +7 -0
- package/dist/components/guides/legend/ContinuousColorLegend.svelte +7 -1
- package/dist/components/guides/legend/OrdinalColorLegend.svelte +8 -5
- package/dist/components/marks/AreaMarkX.svelte +2 -0
- package/dist/components/marks/AreaMarkY.svelte +2 -0
- package/dist/components/marks/BarMarkX.svelte +13 -1
- package/dist/components/marks/BarMarkY.svelte +13 -1
- package/dist/components/marks/DotsMark.svelte +2 -0
- package/dist/components/marks/LineMark.svelte +5 -1
- package/dist/components/marks/ParallelCoordinatesMark.svelte +4 -0
- package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +2 -0
- package/dist/components/marks/RectMark.svelte +2 -0
- package/dist/components/marks/RectXMark.svelte +2 -0
- package/dist/components/marks/RectYMark.svelte +2 -0
- package/dist/components/marks/RuleMarkX.svelte +2 -0
- package/dist/components/marks/RuleMarkY.svelte +2 -0
- package/dist/components/marks/TextMark.svelte +8 -9
- package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +6 -1
- package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +187 -18
- package/dist/components/marks/lifecycle/domain-queries.d.ts +3 -1
- package/dist/components/marks/lifecycle/domain-queries.js +11 -6
- package/dist/components/marks/lifecycle/domain-registration.js +2 -6
- package/dist/components/marks/lifecycle/other-rows.svelte.d.ts +104 -0
- package/dist/components/marks/lifecycle/other-rows.svelte.js +193 -0
- package/dist/components/marks/lifecycle/other-split.d.ts +80 -0
- package/dist/components/marks/lifecycle/other-split.js +112 -0
- package/dist/components/marks/lifecycle/query-builder.d.ts +26 -1
- package/dist/components/marks/lifecycle/query-builder.js +25 -5
- package/dist/components/marks/utils/reactive-config.d.ts +1 -1
- package/dist/components/marks/utils/reactive-config.js +6 -0
- package/dist/components/marks/utils/tooltip-helpers.d.ts +4 -1
- package/dist/components/marks/utils/tooltip-helpers.js +2 -1
- package/dist/components/rendering/BaseGPURenderer.svelte.d.ts +3 -1
- package/dist/components/rendering/BaseGPURenderer.svelte.js +4 -2
- package/dist/components/rendering/Rects.svelte +117 -23
- package/dist/components/rendering/Rects.svelte.d.ts +7 -0
- package/dist/components/rendering/activity-reads.d.ts +18 -0
- package/dist/components/rendering/activity-reads.js +19 -0
- package/dist/config/ChartConfig.svelte.d.ts +3 -1
- package/dist/gpu/MorphedChannels.d.ts +6 -32
- package/dist/gpu/MorphedChannels.js +45 -3
- package/dist/gpu/ScaleTemplateSystem.js +25 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/interactors/Interval1D.svelte +6 -1
- package/dist/interactors/Interval2D.svelte +6 -1
- package/dist/interactors/ParallelAxisSource.svelte +10 -1
- package/dist/interactors/Toggle.svelte +32 -3
- package/dist/interactors/Tooltip.svelte +138 -41
- package/dist/interactors/TooltipManager.svelte.d.ts +20 -5
- package/dist/interactors/TooltipManager.svelte.js +32 -7
- package/dist/interactors/other-tooltip.d.ts +16 -0
- package/dist/interactors/other-tooltip.js +22 -0
- package/dist/query-engine/FieldActivity.svelte.d.ts +50 -1
- package/dist/query-engine/FieldActivity.svelte.js +176 -5
- package/dist/query-engine/FilterGroup.svelte.d.ts +23 -1
- package/dist/query-engine/FilterGroup.svelte.js +43 -3
- package/dist/query-engine/FilterGroupRegistry.svelte.d.ts +31 -3
- package/dist/query-engine/FilterGroupRegistry.svelte.js +62 -15
- package/dist/query-engine/QueryEngine.js +26 -6
- package/dist/query-engine/categorical-identity.d.ts +15 -0
- package/dist/query-engine/categorical-identity.js +15 -0
- package/dist/query-engine/preagg/plan.d.ts +5 -0
- package/dist/query-engine/preagg/plan.js +31 -7
- package/dist/query-engine/toggle-predicate.d.ts +21 -0
- package/dist/query-engine/toggle-predicate.js +29 -1
- package/dist/scales/ActivityTable.d.ts +5 -1
- package/dist/scales/ActivityTable.js +12 -3
- package/dist/scales/BandScaleState.svelte.d.ts +13 -8
- package/dist/scales/BandScaleState.svelte.js +23 -16
- package/dist/spec/types.d.ts +2 -0
- package/dist/theme.css +5 -4
- package/dist/transforms/TransformCompiler.d.ts +37 -2
- package/dist/transforms/TransformCompiler.js +44 -1
- package/dist/transforms/index.js +32 -18
- package/dist/types.d.ts +41 -0
- package/package.json +3 -2
|
@@ -45,6 +45,14 @@ class GroupFade {
|
|
|
45
45
|
* `reconcile` runs from the packer and from `set`, never from a reader, so
|
|
46
46
|
* nothing is resized or retargeted while a template renders.
|
|
47
47
|
*/
|
|
48
|
+
/**
|
|
49
|
+
* THE FREE SLOT. Each group's table holds one slot past the field's ids: 1 while
|
|
50
|
+
* the group selects nothing on the field, 0 while it selects anything, faded on
|
|
51
|
+
* the same clock as every id. The shader reads it as the last slot of the
|
|
52
|
+
* group's run (identityFreeActivity): an Other bar's unselected part dims by it,
|
|
53
|
+
* so clearing a selection fades it back in with the other bars, rather than its
|
|
54
|
+
* split sliding.
|
|
55
|
+
*/
|
|
48
56
|
export class FieldActivity {
|
|
49
57
|
key;
|
|
50
58
|
#host;
|
|
@@ -63,9 +71,20 @@ export class FieldActivity {
|
|
|
63
71
|
this.key = key;
|
|
64
72
|
this.#host = host;
|
|
65
73
|
}
|
|
66
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* How many slots the tables cover: one per id of the applied identity, and
|
|
76
|
+
* one past them (FREE_SLOT); 0 without an identity.
|
|
77
|
+
*/
|
|
67
78
|
get count() {
|
|
68
|
-
return this.#identity ? this.#identity.values.length : 0;
|
|
79
|
+
return this.#identity ? this.#identity.values.length + 1 : 0;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Group `index`'s selected values, or null when it selects nothing on this
|
|
83
|
+
* field. Reactive: a reader re-runs when the selection changes.
|
|
84
|
+
*/
|
|
85
|
+
selectedValues(index) {
|
|
86
|
+
void this.#groupsVersion;
|
|
87
|
+
return this.#groups[index]?.values ?? null;
|
|
69
88
|
}
|
|
70
89
|
/** Group `index`'s fade, or null when that group never selected on this field. */
|
|
71
90
|
fadeOf(index) {
|
|
@@ -164,6 +183,113 @@ export class FieldActivity {
|
|
|
164
183
|
}
|
|
165
184
|
return values.has(identity.values[id]) ? 1 : inactiveOpacity;
|
|
166
185
|
}
|
|
186
|
+
// -----------------------------------------------------------------
|
|
187
|
+
// Other buckets: what each one holds, for a click to select
|
|
188
|
+
// -----------------------------------------------------------------
|
|
189
|
+
/**
|
|
190
|
+
* Every Other bucket's members, by its id and the mark that drew it. Each
|
|
191
|
+
* mark registers its Other rows per result (`setOther`): the folded ids its
|
|
192
|
+
* query returned, per facet cell. A click on Other reads them, so what it
|
|
193
|
+
* selects is exactly what the bar summed.
|
|
194
|
+
*/
|
|
195
|
+
#others = new Map();
|
|
196
|
+
/**
|
|
197
|
+
* What an Other click contributed to a group's selection, by `group:otherId`:
|
|
198
|
+
* how a second click on the same Other knows what to take back. Dropped with
|
|
199
|
+
* the id's last entry, so a reused id starts with no memory.
|
|
200
|
+
*/
|
|
201
|
+
#otherMemory = new Map();
|
|
202
|
+
/** Register `markId`'s Other rows under `otherId`; null removes them. */
|
|
203
|
+
setOther(otherId, markId, entry) {
|
|
204
|
+
let byMark = this.#others.get(otherId);
|
|
205
|
+
const before = byMark?.get(markId)?.group;
|
|
206
|
+
if (entry) {
|
|
207
|
+
if (!byMark)
|
|
208
|
+
this.#others.set(otherId, (byMark = new Map()));
|
|
209
|
+
byMark.set(markId, entry);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
if (!byMark?.delete(markId))
|
|
213
|
+
return;
|
|
214
|
+
if (byMark.size === 0) {
|
|
215
|
+
this.#others.delete(otherId);
|
|
216
|
+
for (const memKey of [...this.#otherMemory.keys()]) {
|
|
217
|
+
if (memKey.endsWith(`:${otherId}`))
|
|
218
|
+
this.#otherMemory.delete(memKey);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// The bucket's members moved (a new result), or it came or went: whether it
|
|
223
|
+
// is lit may have changed in the group its marks dim under. A fade it does
|
|
224
|
+
// not change costs nothing (#retarget returns early).
|
|
225
|
+
this.#reapply(before);
|
|
226
|
+
if (entry && entry.group !== before)
|
|
227
|
+
this.#reapply(entry.group);
|
|
228
|
+
}
|
|
229
|
+
#reapply(index) {
|
|
230
|
+
if (index == null)
|
|
231
|
+
return;
|
|
232
|
+
const group = this.#groups[index];
|
|
233
|
+
if (group && group.values != null)
|
|
234
|
+
this.#apply(group);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* What a click on Other `otherId` does to group `group`'s selection, given
|
|
238
|
+
* the values it holds now (`current`), and records it. Null when nothing is
|
|
239
|
+
* registered under the id (no result yet) or it holds no members.
|
|
240
|
+
*
|
|
241
|
+
* - The members: every registered row under the id, or only facet cell
|
|
242
|
+
* `facetId`'s when the click came from a cell.
|
|
243
|
+
* - Shift-click adds them, or takes back what this Other added before if it
|
|
244
|
+
* is still selected.
|
|
245
|
+
* - A plain click on an Other whose marks all filter by the selection (no
|
|
246
|
+
* group: `'filter'`) drills down, replacing the selection: such an Other is
|
|
247
|
+
* computed inside the selection, so a second click means "the rest of it".
|
|
248
|
+
* - Otherwise a plain click toggles: it takes back what this Other added if
|
|
249
|
+
* that is still selected, and replaces the selection with the members if not.
|
|
250
|
+
*/
|
|
251
|
+
otherClick(group, otherId, facetId, current, multi) {
|
|
252
|
+
const entries = this.#others.get(otherId);
|
|
253
|
+
const identity = this.#host.identityOf(this.key);
|
|
254
|
+
if (!entries || entries.size === 0 || !identity)
|
|
255
|
+
return null;
|
|
256
|
+
const members = new Set();
|
|
257
|
+
let drill = true;
|
|
258
|
+
for (const entry of entries.values()) {
|
|
259
|
+
if (entry.group !== null)
|
|
260
|
+
drill = false;
|
|
261
|
+
for (const row of entry.rows) {
|
|
262
|
+
if (facetId !== null && row.facetId !== facetId)
|
|
263
|
+
continue;
|
|
264
|
+
for (let i = 0; i < row.ids.length; i++) {
|
|
265
|
+
const id = row.ids[i];
|
|
266
|
+
if (identity.synthetic.has(id))
|
|
267
|
+
continue;
|
|
268
|
+
const value = identity.values[id];
|
|
269
|
+
if (value !== undefined)
|
|
270
|
+
members.add(value);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (members.size === 0)
|
|
275
|
+
return null;
|
|
276
|
+
const memKey = `${group}:${otherId}`;
|
|
277
|
+
const before = this.#otherMemory.get(memKey);
|
|
278
|
+
const stillSelected = !!before && before.size > 0 && [...before].every((v) => current.has(v));
|
|
279
|
+
if (stillSelected && (multi || !drill)) {
|
|
280
|
+
this.#otherMemory.delete(memKey);
|
|
281
|
+
return { action: 'remove', values: [...before] };
|
|
282
|
+
}
|
|
283
|
+
if (!multi) {
|
|
284
|
+
// A replace: what any other Other added is gone from the selection.
|
|
285
|
+
for (const key of [...this.#otherMemory.keys()]) {
|
|
286
|
+
if (key.startsWith(`${group}:`))
|
|
287
|
+
this.#otherMemory.delete(key);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
this.#otherMemory.set(memKey, members);
|
|
291
|
+
return { action: multi ? 'add' : 'replace', values: [...members] };
|
|
292
|
+
}
|
|
167
293
|
#group(index) {
|
|
168
294
|
let group = this.#groups[index];
|
|
169
295
|
if (!group) {
|
|
@@ -187,10 +313,13 @@ export class FieldActivity {
|
|
|
187
313
|
group.table.resize(0);
|
|
188
314
|
return;
|
|
189
315
|
}
|
|
190
|
-
|
|
316
|
+
// Every id, and the free slot past them: clear() sets it to 1 and
|
|
317
|
+
// setActive() leaves it out, so it fades to 0 while anything is selected
|
|
318
|
+
// and back to 1 once nothing is (see FieldActivity's FREE_SLOT note).
|
|
319
|
+
const count = identity.values.length + 1;
|
|
191
320
|
const values = group.values;
|
|
192
321
|
if (values == null) {
|
|
193
|
-
group.table.resize(count, null);
|
|
322
|
+
group.table.resize(count, null, true);
|
|
194
323
|
group.table.clear();
|
|
195
324
|
return;
|
|
196
325
|
}
|
|
@@ -201,7 +330,49 @@ export class FieldActivity {
|
|
|
201
330
|
if (id != null)
|
|
202
331
|
ids.push(id);
|
|
203
332
|
}
|
|
204
|
-
|
|
333
|
+
this.#lightOthers(this.#groups.indexOf(group), ids, count);
|
|
334
|
+
group.table.resize(count, ids, true);
|
|
205
335
|
group.table.setActive(ids);
|
|
206
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* Add to `ids` (the selected ids) every Other bucket that holds one of them,
|
|
339
|
+
* by the members its marks in group `index` registered: a bucket is lit while
|
|
340
|
+
* the selection reaches into it. In the same pass as the rest of the group's
|
|
341
|
+
* fade, so it is one retarget per update, never two. Marks filtered by the
|
|
342
|
+
* selection (no group) are skipped: they never dim.
|
|
343
|
+
*/
|
|
344
|
+
#lightOthers(index, ids, count) {
|
|
345
|
+
if (this.#others.size === 0 || index < 0)
|
|
346
|
+
return;
|
|
347
|
+
let mask = this.#selectedMask;
|
|
348
|
+
if (mask.length < count)
|
|
349
|
+
mask = this.#selectedMask = new Uint8Array(count);
|
|
350
|
+
const selected = ids.length;
|
|
351
|
+
for (let i = 0; i < selected; i++)
|
|
352
|
+
mask[ids[i]] = 1;
|
|
353
|
+
for (const [otherId, entries] of this.#others) {
|
|
354
|
+
if (otherId >= count)
|
|
355
|
+
continue;
|
|
356
|
+
let lit = false;
|
|
357
|
+
for (const entry of entries.values()) {
|
|
358
|
+
if (entry.group !== index)
|
|
359
|
+
continue;
|
|
360
|
+
for (const row of entry.rows) {
|
|
361
|
+
for (let i = 0; i < row.ids.length && !lit; i++)
|
|
362
|
+
lit = mask[row.ids[i]] === 1;
|
|
363
|
+
if (lit)
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
if (lit)
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
if (lit)
|
|
370
|
+
ids.push(otherId);
|
|
371
|
+
}
|
|
372
|
+
// Unmark only what was marked: the selected ids, not the Others pushed after.
|
|
373
|
+
for (let i = 0; i < selected; i++)
|
|
374
|
+
mask[ids[i]] = 0;
|
|
375
|
+
}
|
|
376
|
+
/** Reused by #lightOthers: 1 at each selected id, 0 elsewhere between calls. */
|
|
377
|
+
#selectedMask = new Uint8Array(0);
|
|
207
378
|
}
|
|
@@ -52,6 +52,16 @@ export interface SourceOpts extends SourceRegistration {
|
|
|
52
52
|
* once per write and stored on the clause.
|
|
53
53
|
*/
|
|
54
54
|
augment?: (value: unknown) => FilterSchema | null;
|
|
55
|
+
/**
|
|
56
|
+
* The source's clause was removed by someone other than this registration:
|
|
57
|
+
* `clearAll`, a `clearSource` from outside (a filter pill's remove button), or
|
|
58
|
+
* another registration under the same id clearing it. A control that keeps what
|
|
59
|
+
* it drew (a brush's rectangle) resets it here, because it reads no clause. Not
|
|
60
|
+
* called for the registration's own `clear()` or `destroy()`, which it initiated;
|
|
61
|
+
* and a control that clears itself from here does not loop, since the clause is
|
|
62
|
+
* already gone.
|
|
63
|
+
*/
|
|
64
|
+
onCleared?: () => void;
|
|
55
65
|
}
|
|
56
66
|
export interface SourceHandle {
|
|
57
67
|
/** Write the source's clause. */
|
|
@@ -81,6 +91,13 @@ export interface ToggleHandle extends Omit<SourceHandle, 'update'> {
|
|
|
81
91
|
toggle(tuple: unknown[], multi: boolean, total?: number): void;
|
|
82
92
|
/** Select exactly these tuples; empty clears. */
|
|
83
93
|
set(tuples: unknown[][]): void;
|
|
94
|
+
/**
|
|
95
|
+
* A click on Other bucket `otherId` (from facet cell `facetId`, or null):
|
|
96
|
+
* selects, adds or takes back the categories it holds, as its field's
|
|
97
|
+
* registry says (FieldActivity.otherClick). A one-field toggle only: a
|
|
98
|
+
* bucket names no value in a tuple of several.
|
|
99
|
+
*/
|
|
100
|
+
toggleOther(otherId: number, facetId: number | null, multi: boolean): void;
|
|
84
101
|
}
|
|
85
102
|
export declare class FilterGroup {
|
|
86
103
|
#private;
|
|
@@ -136,7 +153,12 @@ export declare class FilterGroup {
|
|
|
136
153
|
/**
|
|
137
154
|
* Remove a source's clause. Re-queries only when there was a clause to
|
|
138
155
|
* remove: an unmount or a clear with nothing selected changes no mark's data.
|
|
156
|
+
*
|
|
157
|
+
* The source's registrations are told (`onCleared`) so a control that drew the
|
|
158
|
+
* selection resets it, all of them from outside, and all but `by` when a
|
|
159
|
+
* registration is the one clearing (it has reset itself).
|
|
139
160
|
*/
|
|
140
|
-
clearSource(sourceId: string): void;
|
|
161
|
+
clearSource(sourceId: string, by?: SourceOpts): void;
|
|
162
|
+
/** Remove every clause, and tell the controls that drew them (`onCleared`). */
|
|
141
163
|
clearAll(): void;
|
|
142
164
|
}
|
|
@@ -144,7 +144,8 @@ export class FilterGroup {
|
|
|
144
144
|
};
|
|
145
145
|
this.#afterUpdate(sourceId, selection.update(sourceId, next), next);
|
|
146
146
|
},
|
|
147
|
-
clear:
|
|
147
|
+
// The registration's own clear: it already reset what it drew.
|
|
148
|
+
clear: () => this.clearSource(sourceId, opts),
|
|
148
149
|
activate: () => this.#activate(sourceId),
|
|
149
150
|
destroy: () => {
|
|
150
151
|
// Once only: a second call must not remove a newer registration
|
|
@@ -158,7 +159,8 @@ export class FilterGroup {
|
|
|
158
159
|
// while the registration is still in place, so the removal is
|
|
159
160
|
// routed by it: the marks re-queried are those the clause filtered.
|
|
160
161
|
this.#engine?.forgetSource(this, sourceId);
|
|
161
|
-
|
|
162
|
+
// Quietly: the registration is going away, nothing of it to reset.
|
|
163
|
+
this.clearSource(sourceId, opts);
|
|
162
164
|
selection.removeRegistration(sourceId, opts);
|
|
163
165
|
return;
|
|
164
166
|
}
|
|
@@ -275,6 +277,24 @@ export class FilterGroup {
|
|
|
275
277
|
write([...next.values()]);
|
|
276
278
|
},
|
|
277
279
|
set: (tuples) => write(tuples),
|
|
280
|
+
toggleOther: (otherId, facetId, multi) => {
|
|
281
|
+
if (list.length !== 1 || categoricalKey === null)
|
|
282
|
+
return;
|
|
283
|
+
const click = this.activity(categoricalKey).otherClick(this.index, otherId, facetId, keysOf(selection.get(sourceId)), multi);
|
|
284
|
+
if (!click)
|
|
285
|
+
return;
|
|
286
|
+
const next = new Map(tuplesOf(selection.get(sourceId)).map((t) => [tupleKey(t), t]));
|
|
287
|
+
if (click.action === 'replace')
|
|
288
|
+
next.clear();
|
|
289
|
+
for (const value of click.values) {
|
|
290
|
+
const tuple = [value];
|
|
291
|
+
if (click.action === 'remove')
|
|
292
|
+
next.delete(tupleKey(tuple));
|
|
293
|
+
else
|
|
294
|
+
next.set(tupleKey(tuple), tuple);
|
|
295
|
+
}
|
|
296
|
+
write([...next.values()]);
|
|
297
|
+
},
|
|
278
298
|
clear: () => write([]),
|
|
279
299
|
activate: source.activate,
|
|
280
300
|
destroy: source.destroy
|
|
@@ -322,18 +342,38 @@ export class FilterGroup {
|
|
|
322
342
|
/**
|
|
323
343
|
* Remove a source's clause. Re-queries only when there was a clause to
|
|
324
344
|
* remove: an unmount or a clear with nothing selected changes no mark's data.
|
|
345
|
+
*
|
|
346
|
+
* The source's registrations are told (`onCleared`) so a control that drew the
|
|
347
|
+
* selection resets it, all of them from outside, and all but `by` when a
|
|
348
|
+
* registration is the one clearing (it has reset itself).
|
|
325
349
|
*/
|
|
326
|
-
clearSource(sourceId) {
|
|
350
|
+
clearSource(sourceId, by) {
|
|
327
351
|
if (!this.selection.isActive(sourceId))
|
|
328
352
|
return;
|
|
329
353
|
this.#afterUpdate(sourceId, this.selection.update(sourceId, null), null);
|
|
354
|
+
this.#notifyCleared(sourceId, by);
|
|
330
355
|
}
|
|
356
|
+
/** Remove every clause, and tell the controls that drew them (`onCleared`). */
|
|
331
357
|
clearAll() {
|
|
358
|
+
const cleared = this.selection.items.map((item) => item.sourceId);
|
|
332
359
|
this.selection.clear();
|
|
333
360
|
for (const key of this.#selectedFields)
|
|
334
361
|
this.#activityOf(key).set(this.index, null);
|
|
335
362
|
this.#selectedFields.clear();
|
|
336
363
|
this.#engine?.onFilterChange(this);
|
|
364
|
+
for (const sourceId of cleared)
|
|
365
|
+
this.#notifyCleared(sourceId);
|
|
366
|
+
}
|
|
367
|
+
/** Tell a source's registrations, but `except`, that its clause is gone. */
|
|
368
|
+
#notifyCleared(sourceId, except) {
|
|
369
|
+
const registrations = this.selection.registrationsOf(sourceId);
|
|
370
|
+
if (!registrations)
|
|
371
|
+
return;
|
|
372
|
+
// A copy: a control resetting itself may register or destroy while this runs.
|
|
373
|
+
for (const opts of [...registrations]) {
|
|
374
|
+
if (opts !== except)
|
|
375
|
+
opts.onCleared?.();
|
|
376
|
+
}
|
|
337
377
|
}
|
|
338
378
|
// -----------------------------------------------------------------
|
|
339
379
|
// After a change
|
|
@@ -24,10 +24,38 @@ export declare class FilterGroupRegistry {
|
|
|
24
24
|
* at once.
|
|
25
25
|
*/
|
|
26
26
|
setAnimation(loop: ClockLoop, chartsFor: (key: string, out: string[]) => string[]): void;
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Get or create a FilterGroup by name. Undefined/null → "default".
|
|
29
|
+
*
|
|
30
|
+
* Untracked: a reader does not re-run because it called `get`. For a reactive
|
|
31
|
+
* read use `find`, `has` or `isActive`, which create nothing: a group created
|
|
32
|
+
* inside the derived that reads it is state that derived cannot track, so
|
|
33
|
+
* `$derived(get('brush').activeCount)` before any chart made the group never
|
|
34
|
+
* updates. Create groups from init, effects and handlers.
|
|
35
|
+
*/
|
|
28
36
|
get(name?: string | null): FilterGroup;
|
|
29
|
-
/**
|
|
30
|
-
|
|
37
|
+
/**
|
|
38
|
+
* The named group, or undefined if no chart has made it yet. Creates nothing.
|
|
39
|
+
* Reactive: a reader re-runs when the group is created.
|
|
40
|
+
*/
|
|
41
|
+
find(name?: string | null): FilterGroup | undefined;
|
|
42
|
+
/** Whether a named group exists, without creating it. Reactive, as `find`. */
|
|
43
|
+
has(name?: string | null): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Whether a named group has a selection (a brush, a toggle, a legend), without
|
|
46
|
+
* creating it: false for a group no chart has made yet. Reactive in both the
|
|
47
|
+
* group appearing and its clauses changing, so `$derived(groups.isActive('brush'))`
|
|
48
|
+
* is safe before any chart exists.
|
|
49
|
+
*/
|
|
50
|
+
isActive(name?: string | null): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* The names of the groups, in creation order. Reactive: a reader re-runs when a
|
|
53
|
+
* group is created. A new array on each read. For app code that must reach every group the page's marks
|
|
54
|
+
* use, such as a clause that applies to the whole page.
|
|
55
|
+
*/
|
|
56
|
+
get names(): readonly string[];
|
|
57
|
+
/** The groups, in the order of `names`. Reactive in the same way. */
|
|
58
|
+
get groups(): readonly FilterGroup[];
|
|
31
59
|
/** How many groups the page has: the packer writes one activity half per group. */
|
|
32
60
|
get groupCount(): number;
|
|
33
61
|
/**
|
|
@@ -9,10 +9,18 @@
|
|
|
9
9
|
* and one FieldActivity per categorical field, which holds every group's
|
|
10
10
|
* selection fade on that field.
|
|
11
11
|
*/
|
|
12
|
+
import { untrack } from 'svelte';
|
|
13
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
12
14
|
import { FilterGroup } from './FilterGroup.svelte';
|
|
13
15
|
import { FieldActivity } from './FieldActivity.svelte';
|
|
14
16
|
export class FilterGroupRegistry {
|
|
15
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The groups by name, in creation order. Reactive, so `has`, `find`, `isActive`,
|
|
19
|
+
* `names` and `groups` re-run when a group is created. `get` reads and writes it
|
|
20
|
+
* untracked: templates and deriveds call `get`, and a tracked write there throws
|
|
21
|
+
* (`state_unsafe_mutation`) or re-runs the reader on its own write.
|
|
22
|
+
*/
|
|
23
|
+
#groups = new SvelteMap();
|
|
16
24
|
#engine = null;
|
|
17
25
|
#fields = new Map();
|
|
18
26
|
/**
|
|
@@ -54,27 +62,66 @@ export class FilterGroupRegistry {
|
|
|
54
62
|
this.#loop = loop;
|
|
55
63
|
this.#chartsFor = chartsFor;
|
|
56
64
|
}
|
|
57
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* Get or create a FilterGroup by name. Undefined/null → "default".
|
|
67
|
+
*
|
|
68
|
+
* Untracked: a reader does not re-run because it called `get`. For a reactive
|
|
69
|
+
* read use `find`, `has` or `isActive`, which create nothing: a group created
|
|
70
|
+
* inside the derived that reads it is state that derived cannot track, so
|
|
71
|
+
* `$derived(get('brush').activeCount)` before any chart made the group never
|
|
72
|
+
* updates. Create groups from init, effects and handlers.
|
|
73
|
+
*/
|
|
58
74
|
get(name) {
|
|
59
75
|
const key = name ?? 'default';
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
76
|
+
return untrack(() => {
|
|
77
|
+
let g = this.#groups.get(key);
|
|
78
|
+
if (!g) {
|
|
79
|
+
// Indices are never reused or compacted: a mark keeps its group's
|
|
80
|
+
// index from mount, so renumbering would point it at another group.
|
|
81
|
+
g = new FilterGroup(this.#groups.size, (field) => this.field(field));
|
|
82
|
+
if (this.#engine)
|
|
83
|
+
g.setEngine(this.#engine);
|
|
84
|
+
this.#groups.set(key, g);
|
|
85
|
+
}
|
|
86
|
+
return g;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The named group, or undefined if no chart has made it yet. Creates nothing.
|
|
91
|
+
* Reactive: a reader re-runs when the group is created.
|
|
92
|
+
*/
|
|
93
|
+
find(name) {
|
|
94
|
+
return this.#groups.get(name ?? 'default');
|
|
70
95
|
}
|
|
71
|
-
/**
|
|
96
|
+
/** Whether a named group exists, without creating it. Reactive, as `find`. */
|
|
72
97
|
has(name) {
|
|
73
|
-
return this.#groups.has(name);
|
|
98
|
+
return this.#groups.has(name ?? 'default');
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Whether a named group has a selection (a brush, a toggle, a legend), without
|
|
102
|
+
* creating it: false for a group no chart has made yet. Reactive in both the
|
|
103
|
+
* group appearing and its clauses changing, so `$derived(groups.isActive('brush'))`
|
|
104
|
+
* is safe before any chart exists.
|
|
105
|
+
*/
|
|
106
|
+
isActive(name) {
|
|
107
|
+
const group = this.find(name);
|
|
108
|
+
return group ? group.activeCount > 0 : false;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The names of the groups, in creation order. Reactive: a reader re-runs when a
|
|
112
|
+
* group is created. A new array on each read. For app code that must reach every group the page's marks
|
|
113
|
+
* use, such as a clause that applies to the whole page.
|
|
114
|
+
*/
|
|
115
|
+
get names() {
|
|
116
|
+
return [...this.#groups.keys()];
|
|
117
|
+
}
|
|
118
|
+
/** The groups, in the order of `names`. Reactive in the same way. */
|
|
119
|
+
get groups() {
|
|
120
|
+
return [...this.#groups.values()];
|
|
74
121
|
}
|
|
75
122
|
/** How many groups the page has: the packer writes one activity half per group. */
|
|
76
123
|
get groupCount() {
|
|
77
|
-
return this.#groups.size;
|
|
124
|
+
return untrack(() => this.#groups.size);
|
|
78
125
|
}
|
|
79
126
|
/**
|
|
80
127
|
* The selection state of a categorical field, by identity key. Created on
|
|
@@ -97,7 +97,15 @@ export class QueryEngine {
|
|
|
97
97
|
#preagg;
|
|
98
98
|
/** When a filter last changed, in any group (performance.now()). */
|
|
99
99
|
#lastFilterChangeAt = 0;
|
|
100
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* LRU query cache: Arrow Table (or Promise<Table> while inflight), keyed by
|
|
102
|
+
* `#cacheKey`, the SQL text and the dims' version. A mark's query reads dim
|
|
103
|
+
* tables (dense padding pads every category the dim holds), and dims grow
|
|
104
|
+
* after the first queries run: a table folding in, a topN mark reserving its
|
|
105
|
+
* Other. The text does not change when they do, so keyed by text alone the
|
|
106
|
+
* cache served a result from before, with fewer rows: the row set changed
|
|
107
|
+
* between updates and the bars snapped instead of morphing.
|
|
108
|
+
*/
|
|
101
109
|
#queryCache = lruCache({ max: 1000, ttl: 3 * 60 * 60 * 1000 });
|
|
102
110
|
/**
|
|
103
111
|
* Per-instance suffix for every table and view this engine creates.
|
|
@@ -329,7 +337,7 @@ export class QueryEngine {
|
|
|
329
337
|
// Opt out with cache: false for queries against volatile temp tables.
|
|
330
338
|
const useCache = opts?.cache !== false;
|
|
331
339
|
if (useCache) {
|
|
332
|
-
const cached = this.#queryCache.get(sqlStr);
|
|
340
|
+
const cached = this.#queryCache.get(this.#cacheKey(sqlStr));
|
|
333
341
|
if (cached !== undefined) {
|
|
334
342
|
return cached instanceof Promise ? cached : Promise.resolve(cached);
|
|
335
343
|
}
|
|
@@ -366,9 +374,17 @@ export class QueryEngine {
|
|
|
366
374
|
}
|
|
367
375
|
return this.#queryRaw(sqlStr);
|
|
368
376
|
}
|
|
377
|
+
/**
|
|
378
|
+
* The cache key for a query: its text and the dims' version, so a result read
|
|
379
|
+
* against a dim that has since grown is not served again.
|
|
380
|
+
*/
|
|
381
|
+
#cacheKey(sqlStr) {
|
|
382
|
+
return `${this.#identity.version}\u0000${sqlStr}`;
|
|
383
|
+
}
|
|
369
384
|
async #executeForCaller(callerId, sqlStr, signal, cache = true) {
|
|
370
385
|
const controller = new AbortController();
|
|
371
386
|
this.#inflight.set(callerId, controller);
|
|
387
|
+
let key = null;
|
|
372
388
|
if (signal) {
|
|
373
389
|
signal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
374
390
|
}
|
|
@@ -376,24 +392,28 @@ export class QueryEngine {
|
|
|
376
392
|
if (controller.signal.aborted) {
|
|
377
393
|
throw new DOMException('Query superseded', 'AbortError');
|
|
378
394
|
}
|
|
395
|
+
// Keyed when it runs, not when it was asked for: a pending query runs
|
|
396
|
+
// later, against whatever the dims hold by then.
|
|
397
|
+
key = this.#cacheKey(sqlStr);
|
|
379
398
|
// Cache the Promise so concurrent identical queries share one inflight
|
|
380
399
|
const queryPromise = this.#queryRaw(sqlStr);
|
|
381
400
|
if (cache)
|
|
382
|
-
this.#queryCache.set(
|
|
401
|
+
this.#queryCache.set(key, queryPromise);
|
|
383
402
|
const result = await queryPromise;
|
|
384
403
|
// Replace Promise with resolved data for future lookups
|
|
385
404
|
if (cache)
|
|
386
|
-
this.#queryCache.set(
|
|
405
|
+
this.#queryCache.set(key, result);
|
|
387
406
|
if (controller.signal.aborted) {
|
|
388
407
|
// Don't cache results from aborted queries
|
|
389
|
-
this.#queryCache.delete(
|
|
408
|
+
this.#queryCache.delete(key);
|
|
390
409
|
throw new DOMException('Query superseded', 'AbortError');
|
|
391
410
|
}
|
|
392
411
|
return result;
|
|
393
412
|
}
|
|
394
413
|
catch (e) {
|
|
395
414
|
// Remove failed query from cache so we don't serve a rejected Promise
|
|
396
|
-
|
|
415
|
+
if (key !== null)
|
|
416
|
+
this.#queryCache.delete(key);
|
|
397
417
|
throw e;
|
|
398
418
|
}
|
|
399
419
|
finally {
|
|
@@ -290,6 +290,21 @@ export declare class CategoricalIdentity {
|
|
|
290
290
|
* before a reset names a row of a dropped dim, and is ignored.
|
|
291
291
|
*/
|
|
292
292
|
releaseSynthetic(synthetic: SyntheticId): void;
|
|
293
|
+
/**
|
|
294
|
+
* Re-read the dim into the field's value array, mutating in place.
|
|
295
|
+
*
|
|
296
|
+
* Re-reading the whole dim rather than only the new rows: categorical
|
|
297
|
+
* cardinality is small, this runs once per (field, table) pair at setup, and
|
|
298
|
+
* a full read cannot drift out of sync with the ids the way an incremental
|
|
299
|
+
* one can if an append is ever missed.
|
|
300
|
+
*/
|
|
301
|
+
/**
|
|
302
|
+
* Bumped whenever a dim's rows change: a table folding in, an Other bucket
|
|
303
|
+
* reserved, a reset. A query that reads a dim (dense padding, an id join)
|
|
304
|
+
* returns different rows afterwards under the same SQL text, so whatever
|
|
305
|
+
* caches results by that text must also key on this. Only ever increases.
|
|
306
|
+
*/
|
|
307
|
+
get version(): number;
|
|
293
308
|
/**
|
|
294
309
|
* Drop every view and dim and forget all state. Called on data reload, where
|
|
295
310
|
* the ids are stale by definition — the same values may not exist any more,
|
|
@@ -775,6 +775,16 @@ export class CategoricalIdentity {
|
|
|
775
775
|
* a full read cannot drift out of sync with the ids the way an incremental
|
|
776
776
|
* one can if an append is ever missed.
|
|
777
777
|
*/
|
|
778
|
+
/**
|
|
779
|
+
* Bumped whenever a dim's rows change: a table folding in, an Other bucket
|
|
780
|
+
* reserved, a reset. A query that reads a dim (dense padding, an id join)
|
|
781
|
+
* returns different rows afterwards under the same SQL text, so whatever
|
|
782
|
+
* caches results by that text must also key on this. Only ever increases.
|
|
783
|
+
*/
|
|
784
|
+
get version() {
|
|
785
|
+
return this.#version;
|
|
786
|
+
}
|
|
787
|
+
#version = 0;
|
|
778
788
|
async #refreshValues(field) {
|
|
779
789
|
const rows = await this.#exec.queryRows(`SELECT id, value, label FROM ${quote(this.dimTable(field))} ORDER BY id`);
|
|
780
790
|
// Ordered by id and dense by construction, so position is the id — and
|
|
@@ -791,6 +801,10 @@ export class CategoricalIdentity {
|
|
|
791
801
|
if (rows[i].label != null)
|
|
792
802
|
synthetic.push(i);
|
|
793
803
|
this.identity(field).reset(rows.map((row) => (row.label ?? row.value)), synthetic);
|
|
804
|
+
// After the rows are written, never before: a query keyed on the new
|
|
805
|
+
// version must see them. One keyed on the old version may also see them,
|
|
806
|
+
// which costs a re-run later, not a stale result.
|
|
807
|
+
this.#version++;
|
|
794
808
|
}
|
|
795
809
|
/** CREATE OR REPLACE the view with an id column for every field in `all`. */
|
|
796
810
|
async #createView(table, all) {
|
|
@@ -845,6 +859,7 @@ export class CategoricalIdentity {
|
|
|
845
859
|
// Before any await: work already in flight compares against this and
|
|
846
860
|
// stops rather than re-creating what is about to be dropped.
|
|
847
861
|
this.#generation++;
|
|
862
|
+
this.#version++;
|
|
848
863
|
const views = [...this.#views.keys()].map((t) => this.viewTable(t));
|
|
849
864
|
const dims = [...this.#dims.values()].flatMap((d) => [
|
|
850
865
|
d.name,
|
|
@@ -70,6 +70,11 @@ export interface PreaggCreation {
|
|
|
70
70
|
select: Record<string, ExprNode>;
|
|
71
71
|
groupby: ExprNode[];
|
|
72
72
|
orderby: ExprNode[];
|
|
73
|
+
/**
|
|
74
|
+
* The mark's own row filters (`filter()`), applied to the source rows inside the
|
|
75
|
+
* build: the table groups them away, so the read could not apply them.
|
|
76
|
+
*/
|
|
77
|
+
where: ExprNode[];
|
|
73
78
|
/**
|
|
74
79
|
* Fields grouped by value inside and mapped to their ids on the grouped
|
|
75
80
|
* result: `raw` is the inner column, `alias` the id column, `dim` the dim
|