@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.
Files changed (87) hide show
  1. package/README.md +2 -0
  2. package/dist/components/axes/AxisBottom.svelte +23 -6
  3. package/dist/components/axes/AxisBottom.svelte.d.ts +10 -0
  4. package/dist/components/axes/AxisLeft.svelte +77 -8
  5. package/dist/components/axes/AxisLeft.svelte.d.ts +17 -0
  6. package/dist/components/axes/BandAxisTicks.svelte +1 -1
  7. package/dist/components/axes/axis-title.d.ts +30 -0
  8. package/dist/components/axes/axis-title.js +77 -0
  9. package/dist/components/charts/ChartCell.svelte +1 -1
  10. package/dist/components/charts/ChartContainer.svelte +10 -2
  11. package/dist/components/charts/ChartContainer.svelte.d.ts +7 -0
  12. package/dist/components/guides/legend/ContinuousColorLegend.svelte +7 -1
  13. package/dist/components/guides/legend/OrdinalColorLegend.svelte +8 -5
  14. package/dist/components/marks/AreaMarkX.svelte +2 -0
  15. package/dist/components/marks/AreaMarkY.svelte +2 -0
  16. package/dist/components/marks/BarMarkX.svelte +13 -1
  17. package/dist/components/marks/BarMarkY.svelte +13 -1
  18. package/dist/components/marks/DotsMark.svelte +2 -0
  19. package/dist/components/marks/LineMark.svelte +5 -1
  20. package/dist/components/marks/ParallelCoordinatesMark.svelte +4 -0
  21. package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +2 -0
  22. package/dist/components/marks/RectMark.svelte +2 -0
  23. package/dist/components/marks/RectXMark.svelte +2 -0
  24. package/dist/components/marks/RectYMark.svelte +2 -0
  25. package/dist/components/marks/RuleMarkX.svelte +2 -0
  26. package/dist/components/marks/RuleMarkY.svelte +2 -0
  27. package/dist/components/marks/TextMark.svelte +8 -9
  28. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +6 -1
  29. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +187 -18
  30. package/dist/components/marks/lifecycle/domain-queries.d.ts +3 -1
  31. package/dist/components/marks/lifecycle/domain-queries.js +11 -6
  32. package/dist/components/marks/lifecycle/domain-registration.js +2 -6
  33. package/dist/components/marks/lifecycle/other-rows.svelte.d.ts +104 -0
  34. package/dist/components/marks/lifecycle/other-rows.svelte.js +193 -0
  35. package/dist/components/marks/lifecycle/other-split.d.ts +80 -0
  36. package/dist/components/marks/lifecycle/other-split.js +112 -0
  37. package/dist/components/marks/lifecycle/query-builder.d.ts +26 -1
  38. package/dist/components/marks/lifecycle/query-builder.js +25 -5
  39. package/dist/components/marks/utils/reactive-config.d.ts +1 -1
  40. package/dist/components/marks/utils/reactive-config.js +6 -0
  41. package/dist/components/marks/utils/tooltip-helpers.d.ts +4 -1
  42. package/dist/components/marks/utils/tooltip-helpers.js +2 -1
  43. package/dist/components/rendering/BaseGPURenderer.svelte.d.ts +3 -1
  44. package/dist/components/rendering/BaseGPURenderer.svelte.js +4 -2
  45. package/dist/components/rendering/Rects.svelte +117 -23
  46. package/dist/components/rendering/Rects.svelte.d.ts +7 -0
  47. package/dist/components/rendering/activity-reads.d.ts +18 -0
  48. package/dist/components/rendering/activity-reads.js +19 -0
  49. package/dist/config/ChartConfig.svelte.d.ts +3 -1
  50. package/dist/gpu/MorphedChannels.d.ts +6 -32
  51. package/dist/gpu/MorphedChannels.js +45 -3
  52. package/dist/gpu/ScaleTemplateSystem.js +25 -4
  53. package/dist/index.d.ts +2 -0
  54. package/dist/index.js +3 -0
  55. package/dist/interactors/Interval1D.svelte +6 -1
  56. package/dist/interactors/Interval2D.svelte +6 -1
  57. package/dist/interactors/ParallelAxisSource.svelte +10 -1
  58. package/dist/interactors/Toggle.svelte +32 -3
  59. package/dist/interactors/Tooltip.svelte +138 -41
  60. package/dist/interactors/TooltipManager.svelte.d.ts +20 -5
  61. package/dist/interactors/TooltipManager.svelte.js +32 -7
  62. package/dist/interactors/other-tooltip.d.ts +16 -0
  63. package/dist/interactors/other-tooltip.js +22 -0
  64. package/dist/query-engine/FieldActivity.svelte.d.ts +50 -1
  65. package/dist/query-engine/FieldActivity.svelte.js +176 -5
  66. package/dist/query-engine/FilterGroup.svelte.d.ts +23 -1
  67. package/dist/query-engine/FilterGroup.svelte.js +43 -3
  68. package/dist/query-engine/FilterGroupRegistry.svelte.d.ts +31 -3
  69. package/dist/query-engine/FilterGroupRegistry.svelte.js +62 -15
  70. package/dist/query-engine/QueryEngine.js +26 -6
  71. package/dist/query-engine/categorical-identity.d.ts +15 -0
  72. package/dist/query-engine/categorical-identity.js +15 -0
  73. package/dist/query-engine/preagg/plan.d.ts +5 -0
  74. package/dist/query-engine/preagg/plan.js +31 -7
  75. package/dist/query-engine/toggle-predicate.d.ts +21 -0
  76. package/dist/query-engine/toggle-predicate.js +29 -1
  77. package/dist/scales/ActivityTable.d.ts +5 -1
  78. package/dist/scales/ActivityTable.js +12 -3
  79. package/dist/scales/BandScaleState.svelte.d.ts +13 -8
  80. package/dist/scales/BandScaleState.svelte.js +23 -16
  81. package/dist/spec/types.d.ts +2 -0
  82. package/dist/theme.css +5 -4
  83. package/dist/transforms/TransformCompiler.d.ts +37 -2
  84. package/dist/transforms/TransformCompiler.js +44 -1
  85. package/dist/transforms/index.js +32 -18
  86. package/dist/types.d.ts +41 -0
  87. 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
- /** How many ids the tables cover: the applied identity's, 0 without one. */
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
- const count = identity.values.length;
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
- group.table.resize(count, ids);
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: () => this.clearSource(sourceId),
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
- this.clearSource(sourceId);
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
- /** Get or create a FilterGroup by name. Undefined/null → "default". */
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
- /** Check if a named group exists (without creating it). */
30
- has(name: string): boolean;
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
- #groups = new Map();
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
- /** Get or create a FilterGroup by name. Undefined/null → "default". */
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
- let g = this.#groups.get(key);
61
- if (!g) {
62
- // Indices are never reused or compacted: a mark keeps its group's
63
- // index from mount, so renumbering would point it at another group.
64
- g = new FilterGroup(this.#groups.size, (field) => this.field(field));
65
- if (this.#engine)
66
- g.setEngine(this.#engine);
67
- this.#groups.set(key, g);
68
- }
69
- return g;
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
- /** Check if a named group exists (without creating it). */
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
- /** LRU query cache: SQL string → Arrow Table (or Promise<Table> while inflight). */
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(sqlStr, queryPromise);
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(sqlStr, result);
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(sqlStr);
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
- this.#queryCache.delete(sqlStr);
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