@fjader/svelte 0.2.2 → 0.2.3

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 (118) hide show
  1. package/dist/components/axes/BandAxisTicks.svelte +63 -24
  2. package/dist/components/axes/BandAxisTicks.svelte.d.ts +5 -5
  3. package/dist/components/charts/ChartContainer.svelte +2 -1
  4. package/dist/components/guides/legend/ContinuousColorLegend.svelte +2 -1
  5. package/dist/components/guides/legend/OrdinalColorLegend.svelte +92 -34
  6. package/dist/components/marks/AreaMarkX.svelte +19 -6
  7. package/dist/components/marks/AreaMarkY.svelte +19 -6
  8. package/dist/components/marks/BarMarkX.svelte +3 -0
  9. package/dist/components/marks/BarMarkY.svelte +3 -0
  10. package/dist/components/marks/DotsMark.svelte +3 -0
  11. package/dist/components/marks/LineMark.svelte +3 -0
  12. package/dist/components/marks/ParallelCoordinatesMark.svelte +6 -0
  13. package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +3 -0
  14. package/dist/components/marks/RectMark.svelte +3 -0
  15. package/dist/components/marks/RectXMark.svelte +3 -0
  16. package/dist/components/marks/RectYMark.svelte +3 -0
  17. package/dist/components/marks/RuleMarkX.svelte +10 -2
  18. package/dist/components/marks/RuleMarkY.svelte +10 -2
  19. package/dist/components/marks/TextMark.svelte +68 -4
  20. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +15 -1
  21. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +116 -27
  22. package/dist/components/marks/lifecycle/domain-queries.js +13 -11
  23. package/dist/components/marks/lifecycle/transform-scale-key.d.ts +16 -0
  24. package/dist/components/marks/lifecycle/transform-scale-key.js +27 -0
  25. package/dist/components/marks/utils/area-outline.d.ts +24 -0
  26. package/dist/components/marks/utils/area-outline.js +10 -0
  27. package/dist/components/marks/utils/reactive-config.d.ts +1 -1
  28. package/dist/components/marks/utils/reactive-config.js +3 -0
  29. package/dist/components/rendering/Areas.svelte +30 -6
  30. package/dist/components/rendering/Areas.svelte.d.ts +2 -0
  31. package/dist/components/rendering/BaseGPURenderer.svelte.js +4 -1
  32. package/dist/components/rendering/Dots.svelte +24 -3
  33. package/dist/components/rendering/Dots.svelte.d.ts +2 -0
  34. package/dist/components/rendering/GridLine.svelte +2 -1
  35. package/dist/components/rendering/Lines.svelte +49 -7
  36. package/dist/components/rendering/Lines.svelte.d.ts +9 -0
  37. package/dist/components/rendering/ParallelLines.svelte +26 -7
  38. package/dist/components/rendering/ParallelLines.svelte.d.ts +2 -0
  39. package/dist/components/rendering/Rects.svelte +20 -5
  40. package/dist/components/rendering/Rects.svelte.d.ts +2 -0
  41. package/dist/components/rendering/RuleX.svelte +20 -5
  42. package/dist/components/rendering/RuleX.svelte.d.ts +2 -0
  43. package/dist/components/rendering/RuleY.svelte +20 -5
  44. package/dist/components/rendering/RuleY.svelte.d.ts +2 -0
  45. package/dist/components/rendering/activity-reads.d.ts +93 -0
  46. package/dist/components/rendering/activity-reads.js +135 -0
  47. package/dist/components/rendering/uniform-sizes.d.ts +26 -0
  48. package/dist/components/rendering/uniform-sizes.js +28 -0
  49. package/dist/config/ChartConfig.svelte.d.ts +2 -0
  50. package/dist/gpu/ColorScaleShaderLib.d.ts +13 -1
  51. package/dist/gpu/ColorScaleShaderLib.js +56 -5
  52. package/dist/gpu/GPUCoordinator.svelte.d.ts +8 -0
  53. package/dist/gpu/GPUCoordinator.svelte.js +68 -32
  54. package/dist/gpu/ResolveShader.d.ts +2 -2
  55. package/dist/gpu/ResolveShader.js +2 -2
  56. package/dist/gpu/ScaleTemplateSystem.d.ts +19 -0
  57. package/dist/gpu/ScaleTemplateSystem.js +86 -7
  58. package/dist/gpu/SizeScaleShaderLib.d.ts +4 -4
  59. package/dist/gpu/SizeScaleShaderLib.js +4 -4
  60. package/dist/gpu/identity-slot-packing.d.ts +157 -40
  61. package/dist/gpu/identity-slot-packing.js +262 -62
  62. package/dist/gpu/tracked-scales.d.ts +14 -0
  63. package/dist/gpu/tracked-scales.js +18 -0
  64. package/dist/index.js +1 -0
  65. package/dist/interactors/BandLabelToggle.svelte +5 -9
  66. package/dist/interactors/BandLabelToggle.svelte.d.ts +3 -3
  67. package/dist/interactors/DerivedInterval1D.svelte +26 -54
  68. package/dist/interactors/Interval1D.svelte +2 -1
  69. package/dist/interactors/Interval2D.svelte +2 -1
  70. package/dist/interactors/ParallelAxisSource.svelte +4 -4
  71. package/dist/interactors/ParallelAxisSource.svelte.d.ts +2 -1
  72. package/dist/interactors/ParallelBrushes.svelte +8 -9
  73. package/dist/interactors/ParallelBrushes.svelte.d.ts +3 -3
  74. package/dist/interactors/Toggle.svelte +17 -36
  75. package/dist/interactors/Toggle.svelte.d.ts +4 -3
  76. package/dist/interactors/Tooltip.svelte +2 -1
  77. package/dist/interactors/TooltipManager.svelte.js +8 -3
  78. package/dist/interactors/relay-clauses.d.ts +23 -0
  79. package/dist/interactors/relay-clauses.js +60 -0
  80. package/dist/query-engine/CrossfilterSelection.svelte.d.ts +88 -15
  81. package/dist/query-engine/CrossfilterSelection.svelte.js +94 -20
  82. package/dist/query-engine/FieldActivity.svelte.d.ts +79 -0
  83. package/dist/query-engine/FieldActivity.svelte.js +207 -0
  84. package/dist/query-engine/FilterGroup.svelte.d.ts +53 -29
  85. package/dist/query-engine/FilterGroup.svelte.js +181 -122
  86. package/dist/query-engine/FilterGroupRegistry.svelte.d.ts +42 -0
  87. package/dist/query-engine/FilterGroupRegistry.svelte.js +103 -1
  88. package/dist/query-engine/MarkHandle.svelte.d.ts +9 -1
  89. package/dist/query-engine/MarkHandle.svelte.js +12 -2
  90. package/dist/query-engine/QueryEngine.d.ts +52 -16
  91. package/dist/query-engine/QueryEngine.js +90 -28
  92. package/dist/query-engine/categorical-identity.d.ts +99 -27
  93. package/dist/query-engine/categorical-identity.js +210 -51
  94. package/dist/query-engine/index.d.ts +2 -1
  95. package/dist/query-engine/index.js +1 -0
  96. package/dist/query-engine/preagg/Preagg.d.ts +1 -1
  97. package/dist/query-engine/preagg/Preagg.js +11 -9
  98. package/dist/query-engine/preagg/plan.d.ts +9 -5
  99. package/dist/query-engine/preagg/plan.js +13 -11
  100. package/dist/query-engine/toggle-predicate.js +12 -1
  101. package/dist/scales/ActivityTable.d.ts +99 -0
  102. package/dist/scales/ActivityTable.js +211 -0
  103. package/dist/scales/BandScaleState.svelte.d.ts +28 -7
  104. package/dist/scales/BandScaleState.svelte.js +55 -20
  105. package/dist/scales/ChartScales.svelte.d.ts +2 -2
  106. package/dist/scales/ColorScaleState.svelte.d.ts +36 -8
  107. package/dist/scales/ColorScaleState.svelte.js +118 -24
  108. package/dist/scales/DomainResolver.d.ts +2 -0
  109. package/dist/scales/DomainResolver.js +15 -8
  110. package/dist/scales/ScaleRegistry.svelte.d.ts +9 -0
  111. package/dist/scales/ScaleRegistry.svelte.js +22 -1
  112. package/dist/scales/TimeScaleState.svelte.js +7 -1
  113. package/dist/spec/index.js +1 -0
  114. package/dist/spec/types.d.ts +2 -0
  115. package/dist/transforms/index.d.ts +27 -0
  116. package/dist/transforms/index.js +97 -3
  117. package/dist/types.d.ts +30 -0
  118. package/package.json +1 -1
@@ -2,8 +2,8 @@
2
2
  * FilterGroup — visual concerns layer on top of CrossfilterSelection.
3
3
  *
4
4
  * Manages the interactor API (register, registerToggle), schema augmentation
5
- * for UI filter pills, and scale projection (dimming unselected categories in
6
- * legends/axes).
5
+ * for UI filter pills, and this group's selection on each categorical field
6
+ * (FieldActivity), which marks, legends and band axes dim by.
7
7
  *
8
8
  * Owns clauses: a clause lives while at least one registration for its id
9
9
  * exists. Components own their registrations through their own lifecycle.
@@ -14,6 +14,34 @@
14
14
  import { column, sql as sqlTag } from '@uwdata/mosaic-sql';
15
15
  import { CrossfilterSelection } from './CrossfilterSelection.svelte';
16
16
  import { togglePredicate } from './toggle-predicate';
17
+ import { identityKeyFor } from './identity-key';
18
+ import { FieldActivity } from './FieldActivity.svelte';
19
+ /**
20
+ * A source's declaration of `key`: the writer's, or another registration's
21
+ * when the writer has none. Every registration under one id is one source, so
22
+ * any of them can say what its clause is.
23
+ */
24
+ function declared(registrations, writer, key) {
25
+ if (writer[key])
26
+ return writer[key];
27
+ for (const opts of registrations ?? [])
28
+ if (opts[key])
29
+ return opts[key];
30
+ return undefined;
31
+ }
32
+ /**
33
+ * A clause's schema, computed once when it is written rather than on every
34
+ * read of `items`, and stored on the clause, so a reader of raw clauses (the
35
+ * relay looking for a range brush) sees it too.
36
+ */
37
+ function schemaOf(registrations, writer, value) {
38
+ return declared(registrations, writer, 'augment')?.(value) ?? undefined;
39
+ }
40
+ /** A clause's categorical selection: the field's identity key and the selected values. */
41
+ function categoricalOf(registrations, writer, value) {
42
+ const categorical = declared(registrations, writer, 'categorical');
43
+ return categorical ? { key: categorical.key, values: categorical.values(value) } : null;
44
+ }
17
45
  // ---------------------------------------------------------------------------
18
46
  // Toggles
19
47
  // ---------------------------------------------------------------------------
@@ -39,33 +67,47 @@ function toggleSourceId(fields) {
39
67
  export class FilterGroup {
40
68
  selection = new CrossfilterSelection();
41
69
  #engine = null;
70
+ /**
71
+ * This group's page-wide index, assigned by FilterGroupRegistry and never
72
+ * reused: a mark stores it to read this group's half of an activity block.
73
+ */
74
+ index;
75
+ /** The selection state of a field, shared with the other groups on the page. */
76
+ #activityOf;
77
+ /** Fields this group has an active categorical selection on, for clearAll. */
78
+ #selectedFields = new Set();
79
+ /**
80
+ * A group made outside a registry (unit tests) keeps its field activity to
81
+ * itself, with no loop: its fades land at once.
82
+ */
83
+ constructor(index = 0, activityOf) {
84
+ this.index = index;
85
+ if (activityOf) {
86
+ this.#activityOf = activityOf;
87
+ }
88
+ else {
89
+ const own = new Map();
90
+ this.#activityOf = (key) => {
91
+ let activity = own.get(key);
92
+ if (!activity)
93
+ own.set(key, (activity = new FieldActivity(key)));
94
+ return activity;
95
+ };
96
+ }
97
+ }
98
+ /** The selection state of a categorical field, by identity key: this group's fade is `index`. */
99
+ activity(key) {
100
+ return this.#activityOf(key);
101
+ }
42
102
  setEngine(engine) {
43
103
  this.#engine = engine;
44
104
  }
45
- /** Registrations per source id. Several under one id are one source. */
46
- #sources = new Map();
47
- // Track which scales we projected to, so we can clear them
48
- #projectedScales = new Set();
49
- /** Active filter items with schema metadata, for UI consumption. */
105
+ /**
106
+ * Active filter items with schema metadata, for UI consumption. The schema
107
+ * is the clause's own, computed when it was written (see `update`).
108
+ */
50
109
  get items() {
51
- const clauses = this.selection.items;
52
- const result = [];
53
- for (const entry of clauses) {
54
- let schema = entry.schema;
55
- for (const opts of this.#sources.get(entry.sourceId) ?? []) {
56
- if (!opts.augment)
57
- continue;
58
- schema = opts.augment(entry.value) ?? entry.schema;
59
- break;
60
- }
61
- result.push({
62
- sourceId: entry.sourceId,
63
- predicate: entry.predicate,
64
- value: entry.value,
65
- schema
66
- });
67
- }
68
- return result;
110
+ return this.selection.items;
69
111
  }
70
112
  /** Number of active filters. */
71
113
  get activeCount() {
@@ -80,60 +122,73 @@ export class FilterGroup {
80
122
  // -----------------------------------------------------------------
81
123
  /**
82
124
  * Register a selection source. Several registrations under one id are one
83
- * source: each writes the clause with its own exclusions, and the scales the
84
- * source dims are the union of theirs.
125
+ * source and write one clause.
85
126
  */
86
127
  register(sourceId, opts) {
87
- let registrations = this.#sources.get(sourceId);
88
- if (!registrations) {
89
- registrations = new Set();
90
- this.#sources.set(sourceId, registrations);
128
+ const { selection } = this;
129
+ selection.addRegistration(sourceId, opts);
130
+ // A chart that starts skipping a selection already in force has marks
131
+ // whose filters just changed.
132
+ if (opts.chart && selection.isActive(sourceId)) {
133
+ this.#engine?.onChartRouteChange(this, opts.chart);
91
134
  }
92
- registrations.add(opts);
135
+ const registrations = () => selection.registrationsOf(sourceId);
136
+ let registered = true;
93
137
  return {
94
138
  update: (predicate, value) => {
95
- this.selection.update(sourceId, {
139
+ const next = {
96
140
  predicate,
97
- excludeMarkIds: opts.excludeMarkIds(),
98
- value
99
- });
100
- this.#afterUpdate();
141
+ value,
142
+ schema: schemaOf(registrations(), opts, value),
143
+ categorical: categoricalOf(registrations(), opts, value)
144
+ };
145
+ this.#afterUpdate(sourceId, selection.update(sourceId, next), next);
101
146
  },
102
147
  clear: () => this.clearSource(sourceId),
103
148
  activate: () => this.#activate(sourceId),
104
149
  destroy: () => {
105
- registrations.delete(opts);
106
- if (registrations.size > 0)
150
+ // Once only: a second call must not remove a newer registration
151
+ // under the same id, or clear its clause.
152
+ if (!registered)
153
+ return;
154
+ registered = false;
155
+ if (registrations()?.size === 1) {
156
+ // The last one: drop the source's preagg tables first, so the
157
+ // re-query below cannot start reading one about to go. Then clear
158
+ // while the registration is still in place, so the removal is
159
+ // routed by it: the marks re-queried are those the clause filtered.
160
+ this.#engine?.forgetSource(this, sourceId);
161
+ this.clearSource(sourceId);
162
+ selection.removeRegistration(sourceId, opts);
107
163
  return;
108
- this.#sources.delete(sourceId);
109
- this.#engine?.forgetSource(this, sourceId);
110
- this.clearSource(sourceId);
164
+ }
165
+ selection.removeRegistration(sourceId, opts);
166
+ // Another registration keeps the clause; its chart may stop skipping it.
167
+ if (opts.chart && selection.isActive(sourceId)) {
168
+ this.#engine?.onChartRouteChange(this, opts.chart);
169
+ }
111
170
  }
112
171
  };
113
172
  }
114
173
  /**
115
- * Hand the engine a snapshot of the source. It activates every mark some
116
- * registration filters: a table for the source is correct for a mark whether
117
- * or not the clause currently excludes it, so only marks that every
118
- * registration excludes are left out.
174
+ * Hand the engine a snapshot of the source: its dims, its view, and its
175
+ * categorical field, so activation can tell which marks it would filter
176
+ * before it has written a clause.
119
177
  */
120
178
  #activate(sourceId) {
121
- const registrations = this.#sources.get(sourceId);
179
+ const registrations = this.selection.registrationsOf(sourceId);
122
180
  if (!this.#engine || !registrations?.size)
123
181
  return;
124
- let excludedByAll = null;
125
182
  let dims = [];
126
183
  let view;
184
+ let categoricalKey = null;
127
185
  for (const opts of registrations) {
128
- const excluded = opts.excludeMarkIds();
129
- excludedByAll = excludedByAll
130
- ? new Set([...excludedByAll].filter((id) => excluded.has(id)))
131
- : excluded;
132
186
  if (opts.activeDims && dims.length === 0)
133
187
  dims = opts.activeDims();
134
188
  view ??= opts.view;
189
+ categoricalKey ??= opts.categorical?.key ?? null;
135
190
  }
136
- this.#engine.activate(this, { sourceId, excludedByAll: excludedByAll, dims, view });
191
+ this.#engine.activate(this, { sourceId, categoricalKey, dims, view });
137
192
  }
138
193
  // -----------------------------------------------------------------
139
194
  // Toggles (legend/axis categorical selection)
@@ -147,22 +202,24 @@ export class FilterGroup {
147
202
  * as the selection. Nothing else writes those ids (`upsertManual` prefixes
148
203
  * its own with `manual:`).
149
204
  */
150
- registerToggle(fields, opts) {
205
+ registerToggle(fields, opts = {}) {
151
206
  const list = Array.isArray(fields) ? fields : [fields];
152
207
  const sourceId = toggleSourceId(list);
153
208
  const { selection } = this;
154
209
  const schema = { kind: 'toggle', column: list[0], label: list.join(', ') };
210
+ // One field selects categories; a tuple does not (see Clause.categorical).
211
+ const categoricalKey = list.length === 1 ? (identityKeyFor(list[0])?.key ?? null) : null;
155
212
  const source = this.register(sourceId, {
156
- excludeMarkIds: opts.excludeMarkIds,
157
- activeDims: () => list.map((field) => ({ kind: 'value', field })),
158
- augment: () => schema,
159
- // Projection only makes sense for single-field toggles.
160
- projection: list.length === 1 && opts.scales
213
+ chart: opts.chart ?? null,
214
+ discrete: true,
215
+ categorical: categoricalKey
161
216
  ? {
162
- scales: opts.scales,
217
+ key: categoricalKey,
163
218
  values: (value) => new Set(value.map((t) => t[0]))
164
219
  }
165
- : undefined
220
+ : undefined,
221
+ activeDims: () => list.map((field) => ({ kind: 'value', field })),
222
+ augment: () => schema
166
223
  });
167
224
  const tuplesOf = (clause) => clause?.value ?? [];
168
225
  /**
@@ -170,11 +227,16 @@ export class FilterGroup {
170
227
  * map's version, so a toggle with nothing selected is re-read on every one;
171
228
  * returning the same set while the clause is unchanged (and one shared
172
229
  * empty set without a clause) keeps that from re-rendering its readers.
230
+ *
231
+ * One field's values are the clause's own categorical set, built once per
232
+ * write. A tuple's keys are built here, once per clause.
173
233
  */
174
234
  let last = null;
175
235
  const keysOf = (clause) => {
176
236
  if (!clause)
177
237
  return NOTHING_SELECTED;
238
+ if (clause.categorical)
239
+ return clause.categorical.values;
178
240
  if (last?.clause !== clause) {
179
241
  last = { clause, keys: new Set(tuplesOf(clause).map(tupleKey)) };
180
242
  }
@@ -233,19 +295,26 @@ export class FilterGroup {
233
295
  // -----------------------------------------------------------------
234
296
  // Manual entries (for DerivedInterval1D relay, programmatic filters)
235
297
  // -----------------------------------------------------------------
236
- upsertManual(key, predicate, value, excludeMarkIds, schema) {
237
- const resolved = typeof excludeMarkIds === 'function' ? excludeMarkIds() : excludeMarkIds;
238
- this.selection.update(`manual:${key}`, {
239
- predicate,
240
- excludeMarkIds: resolved,
241
- value,
242
- schema
243
- });
244
- this.#afterUpdate();
298
+ upsertManual(key, predicate, value, schema) {
299
+ const next = { predicate, value, schema, categorical: null };
300
+ const sourceId = `manual:${key}`;
301
+ this.#afterUpdate(sourceId, this.selection.update(sourceId, next), next);
302
+ }
303
+ /**
304
+ * Hold `clause` under a manual key, as given: the relay passes another
305
+ * group's clause on. The clause it already holds there changes nothing, so
306
+ * it neither re-projects nor re-queries: the relay runs on every change in
307
+ * its source group, on every brush frame there.
308
+ */
309
+ relayClause(key, clause) {
310
+ const sourceId = `manual:${key}`;
311
+ if (this.selection.get(sourceId) === clause)
312
+ return;
313
+ this.#afterUpdate(sourceId, this.selection.update(sourceId, clause), clause);
245
314
  }
246
315
  removeManual(key) {
247
- this.selection.update(`manual:${key}`, null);
248
- this.#afterUpdate();
316
+ const sourceId = `manual:${key}`;
317
+ this.#afterUpdate(sourceId, this.selection.update(sourceId, null), null);
249
318
  }
250
319
  // -----------------------------------------------------------------
251
320
  // Clear
@@ -257,68 +326,58 @@ export class FilterGroup {
257
326
  clearSource(sourceId) {
258
327
  if (!this.selection.isActive(sourceId))
259
328
  return;
260
- this.selection.update(sourceId, null);
261
- this.#afterUpdate();
329
+ this.#afterUpdate(sourceId, this.selection.update(sourceId, null), null);
262
330
  }
263
331
  clearAll() {
264
332
  this.selection.clear();
265
- this.#clearAllScaleProjections();
333
+ for (const key of this.#selectedFields)
334
+ this.#activityOf(key).set(this.index, null);
335
+ this.#selectedFields.clear();
266
336
  this.#engine?.onFilterChange(this);
267
337
  }
268
338
  // -----------------------------------------------------------------
269
- // Scale projection
339
+ // After a change
270
340
  // -----------------------------------------------------------------
271
341
  /**
272
- * After any filter update: project to scales (visual dimming) and notify
273
- * the engine to re-query affected marks.
342
+ * After a clause went from `prev` to `next`: update this group's selection on
343
+ * the field of either if it is categorical, and have the engine re-query the
344
+ * marks either clause applies to. A brush, which writes on every pointer
345
+ * move, touches no selection.
274
346
  */
275
- #afterUpdate() {
276
- this.#projectToScales();
277
- this.#engine?.onFilterChange(this);
347
+ #afterUpdate(sourceId, prev, next) {
348
+ if (prev?.categorical)
349
+ this.#syncActivity(prev.categorical.key);
350
+ if (next?.categorical && next.categorical.key !== prev?.categorical?.key) {
351
+ this.#syncActivity(next.categorical.key);
352
+ }
353
+ if (prev || next)
354
+ this.#engine?.onClauseChange(this, sourceId, prev, next);
278
355
  }
279
356
  /**
280
- * Project active filter values onto scales (dim unselected categories).
357
+ * Set this group's selection on field `key` from its clauses: the
358
+ * intersection of the selected values of every categorical clause on that
359
+ * field, because the filters AND, or nothing when there is none. Usually one
360
+ * clause; a native toggle and a relayed one make two. Runs on categorical
361
+ * clause changes only, never on a brush frame.
281
362
  */
282
- #projectToScales() {
283
- const activeByScale = new Map();
284
- for (const [sourceId, registrations] of this.#sources) {
285
- const clause = this.selection.get(sourceId);
286
- if (!clause)
287
- continue;
288
- for (const { projection } of registrations) {
289
- if (!projection)
290
- continue;
291
- const values = projection.values(clause.value);
292
- if (!values?.size)
293
- continue;
294
- for (const scale of projection.scales()) {
295
- const existing = activeByScale.get(scale);
296
- if (existing) {
297
- for (const v of values)
298
- existing.add(v);
299
- }
300
- else {
301
- activeByScale.set(scale, new Set(values));
302
- }
303
- }
304
- }
305
- }
306
- // Set active values on scales that have them
307
- for (const [scale, values] of activeByScale) {
308
- scale.setActiveValues(values);
309
- }
310
- // Clear scales that are no longer active
311
- for (const scale of this.#projectedScales) {
312
- if (!activeByScale.has(scale)) {
313
- scale.clearActiveValues();
363
+ #syncActivity(key) {
364
+ let active = null;
365
+ this.selection.forEach((clause) => {
366
+ const categorical = clause.categorical;
367
+ if (!categorical || categorical.key !== key)
368
+ return;
369
+ if (!active) {
370
+ active = new Set(categorical.values);
371
+ return;
314
372
  }
315
- }
316
- this.#projectedScales = new Set(activeByScale.keys());
317
- }
318
- #clearAllScaleProjections() {
319
- for (const scale of this.#projectedScales) {
320
- scale.clearActiveValues();
321
- }
322
- this.#projectedScales.clear();
373
+ for (const value of active)
374
+ if (!categorical.values.has(value))
375
+ active.delete(value);
376
+ });
377
+ if (active)
378
+ this.#selectedFields.add(key);
379
+ else
380
+ this.#selectedFields.delete(key);
381
+ this.#activityOf(key).set(this.index, active);
323
382
  }
324
383
  }
@@ -4,15 +4,57 @@
4
4
  * Marks and interactors specify which group to use via their `selection`
5
5
  * prop (e.g. selection="brush"). Groups are created on first access.
6
6
  * Routes that don't use named selections get the "default" group.
7
+ *
8
+ * Also owns what is shared between the groups: each group's page-wide index,
9
+ * and one FieldActivity per categorical field, which holds every group's
10
+ * selection fade on that field.
7
11
  */
12
+ import type { ClockLoop } from '../animation';
8
13
  import { FilterGroup } from './FilterGroup.svelte';
14
+ import { FieldActivity } from './FieldActivity.svelte';
9
15
  import type { QueryEngine } from './QueryEngine';
10
16
  export declare class FilterGroupRegistry {
11
17
  #private;
18
+ constructor();
12
19
  /** Wire the engine to all existing and future groups. */
13
20
  setEngine(engine: QueryEngine): void;
21
+ /**
22
+ * Wire the fades to the animation loop and to the charts to redraw while
23
+ * they run. Called by the GPU coordinator. A fade created before this lands
24
+ * at once.
25
+ */
26
+ setAnimation(loop: ClockLoop, chartsFor: (key: string, out: string[]) => string[]): void;
14
27
  /** Get or create a FilterGroup by name. Undefined/null → "default". */
15
28
  get(name?: string | null): FilterGroup;
16
29
  /** Check if a named group exists (without creating it). */
17
30
  has(name: string): boolean;
31
+ /** How many groups the page has: the packer writes one activity half per group. */
32
+ get groupCount(): number;
33
+ /**
34
+ * The selection state of a categorical field, by identity key. Created on
35
+ * first ask, by a selection or by the coordinator for a field a scale draws.
36
+ *
37
+ * Never removed: a session that switches through many categorical columns
38
+ * keeps every field it drew, each one step of the coordinator's per-frame
39
+ * reconcile loop. Their buffer space is freed (the packer gives a block only
40
+ * to fields a scale draws), so this costs a loop step, not memory on the GPU.
41
+ * Dropping a field once no scale draws it and no group selects on it is a
42
+ * possible follow-up.
43
+ */
44
+ field(key: string): FieldActivity;
45
+ /**
46
+ * How visible `value` of field `key` is now, for a label shown for marks of
47
+ * several groups: the least visible over `groups`, so it dims when any of
48
+ * those groups dims it. 1 for a field no group ever selected on. Pure, so
49
+ * safe in a template: it creates nothing and reconciles nothing.
50
+ */
51
+ opacityOfValue(key: string, groups: readonly number[], value: unknown): number;
52
+ /**
53
+ * How visible the category with dim id `id` of field `key` is now: the id form
54
+ * of `opacityOfValue`, and the one a label for an Other bucket must use. The
55
+ * least visible over `groups`. Pure, so safe in a template.
56
+ */
57
+ opacityOfId(key: string, groups: readonly number[], id: number): number;
58
+ /** Every field's activity, in creation order. Read every frame; do not mutate. */
59
+ get fields(): readonly FieldActivity[];
18
60
  }
@@ -4,11 +4,40 @@
4
4
  * Marks and interactors specify which group to use via their `selection`
5
5
  * prop (e.g. selection="brush"). Groups are created on first access.
6
6
  * Routes that don't use named selections get the "default" group.
7
+ *
8
+ * Also owns what is shared between the groups: each group's page-wide index,
9
+ * and one FieldActivity per categorical field, which holds every group's
10
+ * selection fade on that field.
7
11
  */
8
12
  import { FilterGroup } from './FilterGroup.svelte';
13
+ import { FieldActivity } from './FieldActivity.svelte';
9
14
  export class FilterGroupRegistry {
10
15
  #groups = new Map();
11
16
  #engine = null;
17
+ #fields = new Map();
18
+ /**
19
+ * Bumped when a field is created, so a reader that found none (and so read
20
+ * no fade) runs again once it exists. Fields are created by selections and by
21
+ * the coordinator for every field a scale draws, never while a template
22
+ * renders.
23
+ */
24
+ #fieldsVersion = $state(0);
25
+ /** The same activities as an array, for the packer's per-frame loop. */
26
+ #fieldList = [];
27
+ #loop = null;
28
+ #chartsFor = (_key, out) => out;
29
+ /** Shared by every FieldActivity: reads the registry's current wiring. */
30
+ #host;
31
+ constructor() {
32
+ const registry = this;
33
+ this.#host = {
34
+ get loop() {
35
+ return registry.#loop;
36
+ },
37
+ chartsFor: (key, out) => this.#chartsFor(key, out),
38
+ identityOf: (key) => this.#engine?.peekIdentity(key) ?? null
39
+ };
40
+ }
12
41
  /** Wire the engine to all existing and future groups. */
13
42
  setEngine(engine) {
14
43
  this.#engine = engine;
@@ -16,12 +45,23 @@ export class FilterGroupRegistry {
16
45
  group.setEngine(engine);
17
46
  }
18
47
  }
48
+ /**
49
+ * Wire the fades to the animation loop and to the charts to redraw while
50
+ * they run. Called by the GPU coordinator. A fade created before this lands
51
+ * at once.
52
+ */
53
+ setAnimation(loop, chartsFor) {
54
+ this.#loop = loop;
55
+ this.#chartsFor = chartsFor;
56
+ }
19
57
  /** Get or create a FilterGroup by name. Undefined/null → "default". */
20
58
  get(name) {
21
59
  const key = name ?? 'default';
22
60
  let g = this.#groups.get(key);
23
61
  if (!g) {
24
- g = new FilterGroup();
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));
25
65
  if (this.#engine)
26
66
  g.setEngine(this.#engine);
27
67
  this.#groups.set(key, g);
@@ -32,4 +72,66 @@ export class FilterGroupRegistry {
32
72
  has(name) {
33
73
  return this.#groups.has(name);
34
74
  }
75
+ /** How many groups the page has: the packer writes one activity half per group. */
76
+ get groupCount() {
77
+ return this.#groups.size;
78
+ }
79
+ /**
80
+ * The selection state of a categorical field, by identity key. Created on
81
+ * first ask, by a selection or by the coordinator for a field a scale draws.
82
+ *
83
+ * Never removed: a session that switches through many categorical columns
84
+ * keeps every field it drew, each one step of the coordinator's per-frame
85
+ * reconcile loop. Their buffer space is freed (the packer gives a block only
86
+ * to fields a scale draws), so this costs a loop step, not memory on the GPU.
87
+ * Dropping a field once no scale draws it and no group selects on it is a
88
+ * possible follow-up.
89
+ */
90
+ field(key) {
91
+ let activity = this.#fields.get(key);
92
+ if (!activity) {
93
+ activity = new FieldActivity(key, this.#host);
94
+ this.#fields.set(key, activity);
95
+ this.#fieldList.push(activity);
96
+ this.#fieldsVersion++;
97
+ }
98
+ return activity;
99
+ }
100
+ /**
101
+ * How visible `value` of field `key` is now, for a label shown for marks of
102
+ * several groups: the least visible over `groups`, so it dims when any of
103
+ * those groups dims it. 1 for a field no group ever selected on. Pure, so
104
+ * safe in a template: it creates nothing and reconciles nothing.
105
+ */
106
+ opacityOfValue(key, groups, value) {
107
+ void this.#fieldsVersion;
108
+ const activity = this.#fields.get(key);
109
+ if (!activity)
110
+ return 1;
111
+ let opacity = 1;
112
+ for (let i = 0; i < groups.length; i++) {
113
+ opacity = Math.min(opacity, activity.opacityOfValue(groups[i], value));
114
+ }
115
+ return opacity;
116
+ }
117
+ /**
118
+ * How visible the category with dim id `id` of field `key` is now: the id form
119
+ * of `opacityOfValue`, and the one a label for an Other bucket must use. The
120
+ * least visible over `groups`. Pure, so safe in a template.
121
+ */
122
+ opacityOfId(key, groups, id) {
123
+ void this.#fieldsVersion;
124
+ const activity = this.#fields.get(key);
125
+ if (!activity)
126
+ return 1;
127
+ let opacity = 1;
128
+ for (let i = 0; i < groups.length; i++) {
129
+ opacity = Math.min(opacity, activity.opacityOfId(groups[i], id));
130
+ }
131
+ return opacity;
132
+ }
133
+ /** Every field's activity, in creation order. Read every frame; do not mutate. */
134
+ get fields() {
135
+ return this.#fieldList;
136
+ }
35
137
  }
@@ -24,7 +24,15 @@ export declare class MarkHandle {
24
24
  /** Called by mark lifecycle when sort/limit change. */
25
25
  updateParams(params: QueryParams): void;
26
26
  _onParamsChange: (() => void) | null;
27
- /** Engine-internal: called to deliver query results. */
27
+ /**
28
+ * Engine-internal: called to deliver query results.
29
+ *
30
+ * The table it already holds changes nothing, so it bumps nothing: a cache
31
+ * hit hands back the same object, and bumping `dataVersion` for it reran
32
+ * every effect that reads it (the tooltip table, data-following domains).
33
+ * The error is cleared either way, so a success after a failure still
34
+ * clears it.
35
+ */
28
36
  _setResult(table: Table): void;
29
37
  /** Engine-internal: called on query error. */
30
38
  _setError(error: Error): void;
@@ -21,11 +21,21 @@ export class MarkHandle {
21
21
  // Engine sets this callback during registration.
22
22
  // Conventional underscore — engine needs external access.
23
23
  _onParamsChange = null;
24
- /** Engine-internal: called to deliver query results. */
24
+ /**
25
+ * Engine-internal: called to deliver query results.
26
+ *
27
+ * The table it already holds changes nothing, so it bumps nothing: a cache
28
+ * hit hands back the same object, and bumping `dataVersion` for it reran
29
+ * every effect that reads it (the tooltip table, data-following domains).
30
+ * The error is cleared either way, so a success after a failure still
31
+ * clears it.
32
+ */
25
33
  _setResult(table) {
34
+ this.error = null;
35
+ if (table === this.arrowTable)
36
+ return;
26
37
  this.arrowTable = table;
27
38
  this.dataVersion++;
28
- this.error = null;
29
39
  }
30
40
  /** Engine-internal: called on query error. */
31
41
  _setError(error) {