@fjader/svelte 0.2.2 → 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 (148) 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 +64 -25
  7. package/dist/components/axes/BandAxisTicks.svelte.d.ts +5 -5
  8. package/dist/components/axes/axis-title.d.ts +30 -0
  9. package/dist/components/axes/axis-title.js +77 -0
  10. package/dist/components/charts/ChartCell.svelte +1 -1
  11. package/dist/components/charts/ChartContainer.svelte +12 -3
  12. package/dist/components/charts/ChartContainer.svelte.d.ts +7 -0
  13. package/dist/components/guides/legend/ContinuousColorLegend.svelte +9 -2
  14. package/dist/components/guides/legend/OrdinalColorLegend.svelte +96 -35
  15. package/dist/components/marks/AreaMarkX.svelte +21 -6
  16. package/dist/components/marks/AreaMarkY.svelte +21 -6
  17. package/dist/components/marks/BarMarkX.svelte +16 -1
  18. package/dist/components/marks/BarMarkY.svelte +16 -1
  19. package/dist/components/marks/DotsMark.svelte +5 -0
  20. package/dist/components/marks/LineMark.svelte +8 -1
  21. package/dist/components/marks/ParallelCoordinatesMark.svelte +10 -0
  22. package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +5 -0
  23. package/dist/components/marks/RectMark.svelte +5 -0
  24. package/dist/components/marks/RectXMark.svelte +5 -0
  25. package/dist/components/marks/RectYMark.svelte +5 -0
  26. package/dist/components/marks/RuleMarkX.svelte +12 -2
  27. package/dist/components/marks/RuleMarkY.svelte +12 -2
  28. package/dist/components/marks/TextMark.svelte +76 -13
  29. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +21 -2
  30. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +300 -42
  31. package/dist/components/marks/lifecycle/domain-queries.d.ts +3 -1
  32. package/dist/components/marks/lifecycle/domain-queries.js +22 -15
  33. package/dist/components/marks/lifecycle/domain-registration.js +2 -6
  34. package/dist/components/marks/lifecycle/other-rows.svelte.d.ts +104 -0
  35. package/dist/components/marks/lifecycle/other-rows.svelte.js +193 -0
  36. package/dist/components/marks/lifecycle/other-split.d.ts +80 -0
  37. package/dist/components/marks/lifecycle/other-split.js +112 -0
  38. package/dist/components/marks/lifecycle/query-builder.d.ts +26 -1
  39. package/dist/components/marks/lifecycle/query-builder.js +25 -5
  40. package/dist/components/marks/lifecycle/transform-scale-key.d.ts +16 -0
  41. package/dist/components/marks/lifecycle/transform-scale-key.js +27 -0
  42. package/dist/components/marks/utils/area-outline.d.ts +24 -0
  43. package/dist/components/marks/utils/area-outline.js +10 -0
  44. package/dist/components/marks/utils/reactive-config.d.ts +1 -1
  45. package/dist/components/marks/utils/reactive-config.js +9 -0
  46. package/dist/components/marks/utils/tooltip-helpers.d.ts +4 -1
  47. package/dist/components/marks/utils/tooltip-helpers.js +2 -1
  48. package/dist/components/rendering/Areas.svelte +30 -6
  49. package/dist/components/rendering/Areas.svelte.d.ts +2 -0
  50. package/dist/components/rendering/BaseGPURenderer.svelte.d.ts +3 -1
  51. package/dist/components/rendering/BaseGPURenderer.svelte.js +8 -3
  52. package/dist/components/rendering/Dots.svelte +24 -3
  53. package/dist/components/rendering/Dots.svelte.d.ts +2 -0
  54. package/dist/components/rendering/GridLine.svelte +2 -1
  55. package/dist/components/rendering/Lines.svelte +49 -7
  56. package/dist/components/rendering/Lines.svelte.d.ts +9 -0
  57. package/dist/components/rendering/ParallelLines.svelte +26 -7
  58. package/dist/components/rendering/ParallelLines.svelte.d.ts +2 -0
  59. package/dist/components/rendering/Rects.svelte +129 -20
  60. package/dist/components/rendering/Rects.svelte.d.ts +9 -0
  61. package/dist/components/rendering/RuleX.svelte +20 -5
  62. package/dist/components/rendering/RuleX.svelte.d.ts +2 -0
  63. package/dist/components/rendering/RuleY.svelte +20 -5
  64. package/dist/components/rendering/RuleY.svelte.d.ts +2 -0
  65. package/dist/components/rendering/activity-reads.d.ts +111 -0
  66. package/dist/components/rendering/activity-reads.js +154 -0
  67. package/dist/components/rendering/uniform-sizes.d.ts +26 -0
  68. package/dist/components/rendering/uniform-sizes.js +28 -0
  69. package/dist/config/ChartConfig.svelte.d.ts +5 -1
  70. package/dist/gpu/ColorScaleShaderLib.d.ts +13 -1
  71. package/dist/gpu/ColorScaleShaderLib.js +56 -5
  72. package/dist/gpu/GPUCoordinator.svelte.d.ts +8 -0
  73. package/dist/gpu/GPUCoordinator.svelte.js +68 -32
  74. package/dist/gpu/MorphedChannels.d.ts +6 -32
  75. package/dist/gpu/MorphedChannels.js +45 -3
  76. package/dist/gpu/ResolveShader.d.ts +2 -2
  77. package/dist/gpu/ResolveShader.js +2 -2
  78. package/dist/gpu/ScaleTemplateSystem.d.ts +19 -0
  79. package/dist/gpu/ScaleTemplateSystem.js +111 -11
  80. package/dist/gpu/SizeScaleShaderLib.d.ts +4 -4
  81. package/dist/gpu/SizeScaleShaderLib.js +4 -4
  82. package/dist/gpu/identity-slot-packing.d.ts +157 -40
  83. package/dist/gpu/identity-slot-packing.js +262 -62
  84. package/dist/gpu/tracked-scales.d.ts +14 -0
  85. package/dist/gpu/tracked-scales.js +18 -0
  86. package/dist/index.d.ts +2 -0
  87. package/dist/index.js +4 -0
  88. package/dist/interactors/BandLabelToggle.svelte +5 -9
  89. package/dist/interactors/BandLabelToggle.svelte.d.ts +3 -3
  90. package/dist/interactors/DerivedInterval1D.svelte +26 -54
  91. package/dist/interactors/Interval1D.svelte +8 -2
  92. package/dist/interactors/Interval2D.svelte +8 -2
  93. package/dist/interactors/ParallelAxisSource.svelte +14 -5
  94. package/dist/interactors/ParallelAxisSource.svelte.d.ts +2 -1
  95. package/dist/interactors/ParallelBrushes.svelte +8 -9
  96. package/dist/interactors/ParallelBrushes.svelte.d.ts +3 -3
  97. package/dist/interactors/Toggle.svelte +48 -38
  98. package/dist/interactors/Toggle.svelte.d.ts +4 -3
  99. package/dist/interactors/Tooltip.svelte +140 -42
  100. package/dist/interactors/TooltipManager.svelte.d.ts +20 -5
  101. package/dist/interactors/TooltipManager.svelte.js +40 -10
  102. package/dist/interactors/other-tooltip.d.ts +16 -0
  103. package/dist/interactors/other-tooltip.js +22 -0
  104. package/dist/interactors/relay-clauses.d.ts +23 -0
  105. package/dist/interactors/relay-clauses.js +60 -0
  106. package/dist/query-engine/CrossfilterSelection.svelte.d.ts +88 -15
  107. package/dist/query-engine/CrossfilterSelection.svelte.js +94 -20
  108. package/dist/query-engine/FieldActivity.svelte.d.ts +128 -0
  109. package/dist/query-engine/FieldActivity.svelte.js +378 -0
  110. package/dist/query-engine/FilterGroup.svelte.d.ts +76 -30
  111. package/dist/query-engine/FilterGroup.svelte.js +223 -124
  112. package/dist/query-engine/FilterGroupRegistry.svelte.d.ts +73 -3
  113. package/dist/query-engine/FilterGroupRegistry.svelte.js +161 -12
  114. package/dist/query-engine/MarkHandle.svelte.d.ts +9 -1
  115. package/dist/query-engine/MarkHandle.svelte.js +12 -2
  116. package/dist/query-engine/QueryEngine.d.ts +52 -16
  117. package/dist/query-engine/QueryEngine.js +116 -34
  118. package/dist/query-engine/categorical-identity.d.ts +114 -27
  119. package/dist/query-engine/categorical-identity.js +225 -51
  120. package/dist/query-engine/index.d.ts +2 -1
  121. package/dist/query-engine/index.js +1 -0
  122. package/dist/query-engine/preagg/Preagg.d.ts +1 -1
  123. package/dist/query-engine/preagg/Preagg.js +11 -9
  124. package/dist/query-engine/preagg/plan.d.ts +14 -5
  125. package/dist/query-engine/preagg/plan.js +44 -18
  126. package/dist/query-engine/toggle-predicate.d.ts +21 -0
  127. package/dist/query-engine/toggle-predicate.js +41 -2
  128. package/dist/scales/ActivityTable.d.ts +103 -0
  129. package/dist/scales/ActivityTable.js +220 -0
  130. package/dist/scales/BandScaleState.svelte.d.ts +41 -15
  131. package/dist/scales/BandScaleState.svelte.js +78 -36
  132. package/dist/scales/ChartScales.svelte.d.ts +2 -2
  133. package/dist/scales/ColorScaleState.svelte.d.ts +36 -8
  134. package/dist/scales/ColorScaleState.svelte.js +118 -24
  135. package/dist/scales/DomainResolver.d.ts +2 -0
  136. package/dist/scales/DomainResolver.js +15 -8
  137. package/dist/scales/ScaleRegistry.svelte.d.ts +9 -0
  138. package/dist/scales/ScaleRegistry.svelte.js +22 -1
  139. package/dist/scales/TimeScaleState.svelte.js +7 -1
  140. package/dist/spec/index.js +1 -0
  141. package/dist/spec/types.d.ts +4 -0
  142. package/dist/theme.css +5 -4
  143. package/dist/transforms/TransformCompiler.d.ts +37 -2
  144. package/dist/transforms/TransformCompiler.js +44 -1
  145. package/dist/transforms/index.d.ts +27 -0
  146. package/dist/transforms/index.js +127 -19
  147. package/dist/types.d.ts +71 -0
  148. package/package.json +3 -2
@@ -11,14 +11,16 @@
11
11
  * later tables APPEND unseen values with continuing ids. It never reorders and
12
12
  * never renumbers, so an id already baked into a vertex buffer stays valid and
13
13
  * no earlier mark has to requery. `value` carries the source column's own type
14
- * and `label` is NULL, except on a synthetic row (`reserveSynthetic`), where
15
- * that is inverted.
14
+ * and `label` is NULL, except on a synthetic row (`acquireSynthetic`), where
15
+ * that is inverted, and on the NULL category's row, where both are NULL.
16
16
  *
17
17
  * 2. A **per-table view**, `<table>_v_s<session>`, selecting `*` plus one
18
18
  * `__dplot_<field>_idx` column per requested field. Always LEFT JOIN with
19
19
  * COALESCE(-1): an inner join silently drops rows whose value is not in the
20
- * dim (NULLs, values added later), which is data loss presenting as a
21
- * rendering bug. -1 is the unmapped sentinel, unified in `1ac4fd4`.
20
+ * dim (a value a later append will add), which is data loss presenting as a
21
+ * rendering bug. -1 is the unmapped sentinel, unified in `1ac4fd4`. A NULL
22
+ * value is not unmapped: the dim has a row for it (value NULL, label NULL),
23
+ * joined with IS NOT DISTINCT FROM, so NULL is a category like any other.
22
24
  *
23
25
  * 3. An **additive coalescer**, `ensureColumns()`. Concurrent callers union into
24
26
  * a pending set, one CREATE OR REPLACE VIEW runs with the full union, and a
@@ -46,6 +48,16 @@ import { IDENTITY_SLOT_CAPACITY } from '../gpu/identity-slot-packing';
46
48
  function asField(field) {
47
49
  return typeof field === 'string' ? { key: field, sql: quote(field) } : field;
48
50
  }
51
+ /**
52
+ * How a category value is shown: as itself, except the NULL category, which has
53
+ * no text of its own. The one formatter for axis ticks, legend entries, facet
54
+ * labels and tooltips, so NULL reads the same everywhere. Selections keep the
55
+ * raw `null`: this is display only.
56
+ */
57
+ export const NULL_CATEGORY_LABEL = '(null)';
58
+ export function categoryLabel(value) {
59
+ return value === null ? NULL_CATEGORY_LABEL : value;
60
+ }
49
61
  /**
50
62
  * A field's id <-> value bijection, both directions in one object.
51
63
  *
@@ -61,11 +73,33 @@ function asField(field) {
61
73
  * once and stay correct when a second table appends to the dim.
62
74
  */
63
75
  export class FieldIdentity {
64
- /** id -> source value. Dense and 0-based, so position is the id. */
76
+ /**
77
+ * id -> source value. Dense and 0-based, so position is the id. A synthetic
78
+ * id (an Other bucket) holds its display label here.
79
+ */
65
80
  values = [];
66
- /** source value -> id. The exact inverse of `values`. */
81
+ /**
82
+ * source value -> id, for real values only: the inverse of `values` with the
83
+ * synthetic ids left out. A label is not a value, and several synthetic ids
84
+ * may share one, so nothing resolves a bucket by its label.
85
+ */
67
86
  ids = new Map();
68
- constructor(values) {
87
+ /** The synthetic ids (Other buckets): rows of the dim no source row joins to. */
88
+ synthetic = new Set();
89
+ /**
90
+ * The NULL category's id, or null while the data has no NULL. An ordinary
91
+ * category: ranked, drawn, selectable; `values[nullId]` is `null`, and `ids`
92
+ * maps `null` to it, so value lookups need no special case.
93
+ */
94
+ nullId = null;
95
+ /**
96
+ * The identity key this is the bijection for, as `identityKeyFor` derives
97
+ * it: what a scale that adopted it is keyed by for selection activity.
98
+ * Empty for one built outside the engine, as tests do.
99
+ */
100
+ key;
101
+ constructor(values, key = '') {
102
+ this.key = key;
69
103
  if (values)
70
104
  this.reset(values);
71
105
  }
@@ -76,14 +110,26 @@ export class FieldIdentity {
76
110
  * comparing `values.length` is not.
77
111
  */
78
112
  version = 0;
79
- /** Replace both directions. The only writer. */
80
- reset(values) {
113
+ /**
114
+ * Replace both directions. The only writer. `synthetic` names the ids that
115
+ * are buckets rather than values: they keep their label in `values` and stay
116
+ * out of `ids`.
117
+ */
118
+ reset(values, synthetic = []) {
81
119
  this.version++;
82
120
  this.values.length = 0;
83
121
  this.ids.clear();
122
+ this.synthetic.clear();
123
+ this.nullId = null;
124
+ for (const id of synthetic)
125
+ this.synthetic.add(id);
84
126
  for (let i = 0; i < values.length; i++) {
85
127
  this.values.push(values[i]);
128
+ if (this.synthetic.has(i))
129
+ continue;
86
130
  this.ids.set(values[i], i);
131
+ if (values[i] === null)
132
+ this.nullId = i;
87
133
  }
88
134
  }
89
135
  /** True once the dim has been built and holds at least one value. */
@@ -130,10 +176,11 @@ function identityToken(field) {
130
176
  return sanitized === field ? field : `h${shortHash(field)}`;
131
177
  }
132
178
  /**
133
- * Above this many distinct values a band axis is refused outright.
134
- * The shared slot buffer holds 16384 floats across every band and point scale,
135
- * so a single axis this large cannot render whatever else is on the page — the
136
- * limit is capacity, not taste.
179
+ * Above this many distinct values a band axis is refused outright. The shared
180
+ * slot buffer (IDENTITY_SLOT_CAPACITY floats) holds every band and point
181
+ * scale's slots and every selected field's activity, one run per filter group,
182
+ * so an axis this large would crowd out the rest of the page — the limit is
183
+ * capacity, not taste.
137
184
  */
138
185
  export const MAX_IDENTITY_CARDINALITY = 10000;
139
186
  /**
@@ -201,6 +248,12 @@ export class CategoricalIdentity {
201
248
  * flight.
202
249
  */
203
250
  #identities = new Map();
251
+ /**
252
+ * Released synthetic ids, per field and label, for the next acquire. Keeps the
253
+ * dim from growing a row per remount: mark ids are random, so nothing about a
254
+ * remounted mark can name the id it held before. Cleared with the dims.
255
+ */
256
+ #freeSynthetic = new Map();
204
257
  /** key -> SQL producing the value. Set at registration, read by the drain. */
205
258
  #sqlFor = new Map();
206
259
  constructor(exec, session) {
@@ -216,9 +269,9 @@ export class CategoricalIdentity {
216
269
  return this.#sqlFor.get(field);
217
270
  }
218
271
  /**
219
- * Whether `field`'s dim has folded in `table`, so every non-NULL value the
220
- * table holds has an id and `-1` can only mean NULL. A dim another table
221
- * built may lack this table's values; a refused dim covers nothing.
272
+ * Whether `field`'s dim has folded in `table`, so every value the table holds,
273
+ * NULL included, has an id and `-1` never appears. A dim another table built
274
+ * may lack this table's values; a refused dim covers nothing.
222
275
  */
223
276
  dimCovers(field, table) {
224
277
  const dim = this.#dims.get(field);
@@ -239,6 +292,31 @@ export class CategoricalIdentity {
239
292
  dimTable(field) {
240
293
  return `__dplot_dim_${identityToken(field)}_s${this.#session}`;
241
294
  }
295
+ /**
296
+ * The table of ids `table` holds for `field`, or null before that table has
297
+ * folded into the dim. What `dense()` pads from, so a chart never pads a
298
+ * category only another table has.
299
+ */
300
+ presentTable(field, table) {
301
+ return this.#dims.get(field)?.present.get(table)?.name ?? null;
302
+ }
303
+ /** The ids `table` holds for `field`, or null before that table has folded in. */
304
+ presentIds(field, table) {
305
+ return this.#dims.get(field)?.present.get(table)?.ids ?? null;
306
+ }
307
+ /**
308
+ * Record which of the dim's ids `table` holds: the ids of its distinct values,
309
+ * joined as the view joins (NULL to a NULL row, never to a synthetic one).
310
+ * Once per table, when it folds in.
311
+ */
312
+ async #recordPresent(dim, table, expr, src) {
313
+ const name = `${dim.name}_t_${identityToken(table)}`;
314
+ await this.#exec.exec(`CREATE OR REPLACE TABLE ${quote(name)} AS ` +
315
+ `SELECT DISTINCT d.id FROM (SELECT DISTINCT ${expr} AS v FROM ${src}) s ` +
316
+ `JOIN ${quote(dim.name)} d ON s.v IS NOT DISTINCT FROM d.value AND d.label IS NULL`);
317
+ const rows = await this.#exec.queryRows(`SELECT id FROM ${quote(name)}`);
318
+ dim.present.set(table, { name, ids: new Set(rows.map((r) => Number(r.id))) });
319
+ }
242
320
  /** Name of the view that carries id columns for `table`. */
243
321
  viewTable(table) {
244
322
  return `${identityToken(table)}_v_s${this.#session}`;
@@ -277,11 +355,19 @@ export class CategoricalIdentity {
277
355
  identity(field) {
278
356
  let entry = this.#identities.get(field);
279
357
  if (!entry) {
280
- entry = new FieldIdentity();
358
+ entry = new FieldIdentity(undefined, field);
281
359
  this.#identities.set(field, entry);
282
360
  }
283
361
  return entry;
284
362
  }
363
+ /**
364
+ * The field's current bijection, or null if nothing has asked for it since
365
+ * the last reset. Unlike `identity`, never creates one: for a holder checking
366
+ * whether the identity it applied is still the live one, on every frame.
367
+ */
368
+ peekIdentity(field) {
369
+ return this.#identities.get(field) ?? null;
370
+ }
285
371
  /** Fields the view for `table` currently exposes. Empty when there is no view. */
286
372
  columnsFor(table) {
287
373
  return [...(this.#views.get(table)?.current ?? [])];
@@ -428,7 +514,8 @@ export class CategoricalIdentity {
428
514
  sql: this.#sqlFor.get(field) ?? quote(field),
429
515
  tables: new Set(),
430
516
  refusal: null,
431
- chain: Promise.resolve()
517
+ chain: Promise.resolve(),
518
+ present: new Map()
432
519
  };
433
520
  this.#dims.set(field, dim);
434
521
  }
@@ -441,38 +528,84 @@ export class CategoricalIdentity {
441
528
  return next;
442
529
  }
443
530
  /**
444
- * Reserve an id for a value that exists in no table — the synthetic "Other"
445
- * bucket a `topN({ includeOthers })` produces.
531
+ * Acquire an id for a value that exists in no table — the synthetic "Other"
532
+ * bucket a `topN({ includeOthers })` produces — and return it with the label
533
+ * it displays.
446
534
  *
447
- * Goes through the same writer as every other id, so `values(f)[otherId]` is
448
- * the label and no consumer has to know it was synthetic. Serialized on the
449
- * per-field chain, so it cannot interleave with an append.
535
+ * A released id with the same label is reused before a dim row is inserted,
536
+ * so remounting a chart does not grow the dim or the field's activity block.
537
+ * Keyed by label too, so a reused id never needs relabelling.
538
+ *
539
+ * Goes through the same writer as every other id, so `values(f)[id]` is the
540
+ * label; `synthetic` records that it is a bucket, which keeps it out of the
541
+ * value -> id map. Serialized on the per-field chain, so it cannot interleave
542
+ * with an append.
450
543
  *
451
544
  * Non-negative by construction: `scaleBand` discards `value < 0.0` before the
452
545
  * slot lookup, so a negative sentinel could never render.
453
546
  *
454
- * **Reuses the id if a real category already carries this label**, rather
455
- * than inserting a duplicate value — see the fan-out note above. Callers get
456
- * an id that may already be in the data, which the transform has to fold
457
- * into rather than append beside.
547
+ * **A real category carrying the label stays a category.** The bucket takes
548
+ * `"<label> (rest)"` instead, so two bars never share a label and the real
549
+ * one stays selectable. The check runs at acquire; a reload re-acquires.
458
550
  *
459
551
  * Requires the dim to exist: call after `ensureColumns` has resolved.
460
552
  */
461
- reserveSynthetic(field, label) {
553
+ acquireSynthetic(field, label) {
462
554
  const dim = this.#dims.get(field);
463
555
  if (!dim) {
464
- return Promise.reject(new Error(`No dim for "${field}" — reserveSynthetic must follow ensureColumns.`));
556
+ return Promise.reject(new Error(`No dim for "${field}" — acquireSynthetic must follow ensureColumns.`));
465
557
  }
466
- const next = dim.chain.then(() => this.#reserveSyntheticNow(dim, field, label));
558
+ const generation = this.#generation;
559
+ const next = dim.chain.then(() => this.#acquireSyntheticNow(dim, field, label, generation));
467
560
  // Drop both the value and any rejection: the chain only sequences work,
468
561
  // and one failure must not poison every later append.
469
562
  dim.chain = next.then(() => { }, () => { });
470
563
  return next;
471
564
  }
472
- async #reserveSyntheticNow(dim, field, label) {
473
- const existing = this.identity(field).ids.get(label);
474
- if (existing !== undefined)
475
- return existing;
565
+ /**
566
+ * Give an acquired id back for the next acquire of the same label. One from
567
+ * before a reset names a row of a dropped dim, and is ignored.
568
+ */
569
+ releaseSynthetic(synthetic) {
570
+ if (synthetic.generation !== this.#generation)
571
+ return;
572
+ let byLabel = this.#freeSynthetic.get(synthetic.field);
573
+ if (!byLabel)
574
+ this.#freeSynthetic.set(synthetic.field, (byLabel = new Map()));
575
+ let free = byLabel.get(synthetic.label);
576
+ if (!free)
577
+ byLabel.set(synthetic.label, (free = []));
578
+ if (!free.includes(synthetic.id))
579
+ free.push(synthetic.id);
580
+ }
581
+ /**
582
+ * Abort an acquire that a reset overtook. An AbortError, which a mark's setup
583
+ * treats as superseded rather than failed: the reset re-runs that setup, and
584
+ * it acquires again from the new dim.
585
+ */
586
+ #throwIfSuperseded(generation, field, label) {
587
+ if (this.#generation === generation)
588
+ return;
589
+ const superseded = new Error(`Acquiring "${label}" for "${field}" was superseded by a reset.`);
590
+ superseded.name = 'AbortError';
591
+ throw superseded;
592
+ }
593
+ async #acquireSyntheticNow(dim, field, requested, generation) {
594
+ // Queued behind work that outlived a reset, as #ensureDimNow checks too.
595
+ // Without this the INSERT would write a bucket row into the NEW dim (same
596
+ // name) that no one holds and no release can return: releases from an
597
+ // older generation are ignored.
598
+ // Checked again after each await: a reset landing mid-flight drops the dim
599
+ // the row went into, and the identity read afterwards is a new one.
600
+ this.#throwIfSuperseded(generation, field, requested);
601
+ const identity = this.identity(field);
602
+ let label = requested;
603
+ while (identity.ids.has(label))
604
+ label = `${label} (rest)`;
605
+ const free = this.#freeSynthetic.get(field)?.get(label);
606
+ const reused = free?.pop();
607
+ if (reused !== undefined)
608
+ return { field, id: reused, label, generation };
476
609
  const dimName = quote(dim.name);
477
610
  // value NULL, label carrying the text. The label cannot go in `value`:
478
611
  // that column has the source column's type, so 'Other' against a year or
@@ -484,15 +617,21 @@ export class CategoricalIdentity {
484
617
  // joins ON t.key = d.value. Any in-range sentinel would collide with a
485
618
  // real category the moment the data contained it; NULL cannot, since
486
619
  // NULL = anything is never true.
487
- await this.#exec.exec(`INSERT INTO ${dimName} (value, label, id) ` +
620
+ //
621
+ // RETURNING, not a lookup by label afterwards: several buckets may carry
622
+ // one label, and labels are kept out of the value -> id map.
623
+ const rows = await this.#exec.queryRows(`INSERT INTO ${dimName} (value, label, id) ` +
488
624
  `SELECT NULL, ${literal(label)}, ` +
489
- `(SELECT COALESCE(MAX(id), -1) + 1 FROM ${dimName})::INT`);
625
+ `(SELECT COALESCE(MAX(id), -1) + 1 FROM ${dimName})::INT ` +
626
+ `RETURNING id`);
627
+ this.#throwIfSuperseded(generation, field, requested);
490
628
  await this.#refreshValues(field);
491
- const id = this.identity(field).ids.get(label);
492
- if (id === undefined) {
493
- throw new Error(`Reserved "${label}" for "${field}" but it is absent from the dim.`);
629
+ this.#throwIfSuperseded(generation, field, requested);
630
+ const id = Number(rows[0]?.id);
631
+ if (!Number.isInteger(id) || !identity.synthetic.has(id)) {
632
+ throw new Error(`Acquired "${label}" for "${field}" but it is absent from the dim.`);
494
633
  }
495
- return id;
634
+ return { field, id, label, generation };
496
635
  }
497
636
  async #ensureDimNow(dim, field, table, generation) {
498
637
  // BEFORE the tables check, not after: a mark whose setup re-runs would
@@ -520,8 +659,8 @@ export class CategoricalIdentity {
520
659
  // that column is a conversion error, not a fallback.
521
660
  await this.#exec.exec(`CREATE TABLE IF NOT EXISTS ${dimName} AS ` +
522
661
  `SELECT value, NULL::VARCHAR AS label, ` +
523
- `(ROW_NUMBER() OVER (ORDER BY value) - 1)::INT AS id ` +
524
- `FROM (SELECT DISTINCT ${expr} AS value FROM ${src} WHERE ${expr} IS NOT NULL)`);
662
+ `(ROW_NUMBER() OVER (ORDER BY value NULLS LAST) - 1)::INT AS id ` +
663
+ `FROM (SELECT DISTINCT ${expr} AS value FROM ${src})`);
525
664
  // A second statement: CTAS cannot carry a constraint. `value` is unique
526
665
  // by construction here (DISTINCT) and on append (WHERE NOT EXISTS), but
527
666
  // reserveSynthetic is a writer that could break it, and a duplicate
@@ -539,12 +678,18 @@ export class CategoricalIdentity {
539
678
  // a positional INSERT would silently put the id into `label`.
540
679
  await this.#exec.exec(`INSERT INTO ${dimName} (value, label, id) ` +
541
680
  `SELECT s.v, NULL, ((SELECT COALESCE(MAX(id), -1) FROM ${dimName}) + ` +
542
- `ROW_NUMBER() OVER (ORDER BY s.v))::INT ` +
543
- `FROM (SELECT DISTINCT ${expr} AS v FROM ${src} WHERE ${expr} IS NOT NULL) s ` +
544
- `WHERE NOT EXISTS (SELECT 1 FROM ${dimName} d WHERE d.value = s.v)`);
681
+ `ROW_NUMBER() OVER (ORDER BY s.v NULLS LAST))::INT ` +
682
+ `FROM (SELECT DISTINCT ${expr} AS v FROM ${src}) s ` +
683
+ // IS NOT DISTINCT FROM, so a second table's NULLs find the NULL row
684
+ // (NULL = NULL is never true, and `=` appended one per table). The
685
+ // label test keeps a synthetic row, also value NULL, from passing
686
+ // for it.
687
+ `WHERE NOT EXISTS (SELECT 1 FROM ${dimName} d ` +
688
+ `WHERE d.value IS NOT DISTINCT FROM s.v AND d.label IS NULL)`);
545
689
  }
546
690
  dim.tables.add(table);
547
691
  await this.#refreshValues(field);
692
+ await this.#recordPresent(dim, table, expr, src);
548
693
  // The gate above saw ONE table. The dim spans every table that registers
549
694
  // the field, so two tables of 6k each pass it individually and produce a
550
695
  // 12k dim — and the limit is a property of the dim, since that is what
@@ -630,6 +775,16 @@ export class CategoricalIdentity {
630
775
  * a full read cannot drift out of sync with the ids the way an incremental
631
776
  * one can if an append is ever missed.
632
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;
633
788
  async #refreshValues(field) {
634
789
  const rows = await this.#exec.queryRows(`SELECT id, value, label FROM ${quote(this.dimTable(field))} ORDER BY id`);
635
790
  // Ordered by id and dense by construction, so position is the id — and
@@ -638,10 +793,18 @@ export class CategoricalIdentity {
638
793
  //
639
794
  // `label` wins where it is set, which is only ever a synthetic row. Every
640
795
  // real row has label NULL and keeps its native value, so this does not
641
- // stringify anything the dim read from the source (see DimValue). It is
642
- // the one place the two columns collapse back into one space, and it is
643
- // why no consumer has to know a bucket was synthetic.
644
- this.identity(field).reset(rows.map((row) => (row.label ?? row.value)));
796
+ // stringify anything the dim read from the source (see DimValue). A row
797
+ // with a label is recorded as synthetic, which keeps it out of the
798
+ // value -> id map: a label is not a value.
799
+ const synthetic = [];
800
+ for (let i = 0; i < rows.length; i++)
801
+ if (rows[i].label != null)
802
+ synthetic.push(i);
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++;
645
808
  }
646
809
  /** CREATE OR REPLACE the view with an id column for every field in `all`. */
647
810
  async #createView(table, all) {
@@ -657,7 +820,12 @@ export class CategoricalIdentity {
657
820
  keyExprs.push(`(${this.#dims.get(field).sql}) AS ${quote(keyCol)}`);
658
821
  keyCols.push(quote(keyCol));
659
822
  columns.push(`COALESCE(${alias}.id, -1) AS ${quote(this.idColumn(field))}`);
660
- joins.push(`LEFT JOIN ${dimName} ${alias} ON t.${quote(keyCol)} = ${alias}.value`);
823
+ // IS NOT DISTINCT FROM, so a NULL source value joins the NULL row; the
824
+ // label test keeps synthetic rows (also value NULL) out. Measured free:
825
+ // it keeps the hash join. An `= OR (both NULL)` form falls back to a
826
+ // nested loop, 73 s on 2 M rows x 10 000 categories — never write it.
827
+ joins.push(`LEFT JOIN ${dimName} ${alias} ON t.${quote(keyCol)} IS NOT DISTINCT FROM ${alias}.value ` +
828
+ `AND ${alias}.label IS NULL`);
661
829
  });
662
830
  // The value expression is computed in an inner SELECT, where only the
663
831
  // source table is in scope, and joined on the qualified helper column.
@@ -691,14 +859,20 @@ export class CategoricalIdentity {
691
859
  // Before any await: work already in flight compares against this and
692
860
  // stops rather than re-creating what is about to be dropped.
693
861
  this.#generation++;
862
+ this.#version++;
694
863
  const views = [...this.#views.keys()].map((t) => this.viewTable(t));
695
- const dims = [...this.#dims.values()].map((d) => d.name);
864
+ const dims = [...this.#dims.values()].flatMap((d) => [
865
+ d.name,
866
+ ...[...d.present.values()].map((p) => p.name)
867
+ ]);
696
868
  this.#views.clear();
697
869
  this.#dims.clear();
698
870
  // In place, so any scale still holding the reference empties with it.
699
871
  for (const identity of this.#identities.values())
700
872
  identity.reset([]);
701
873
  this.#identities.clear();
874
+ // Their ids name rows of the dims being dropped.
875
+ this.#freeSynthetic.clear();
702
876
  for (const view of views) {
703
877
  await this.#exec.exec(`DROP VIEW IF EXISTS ${quote(view)}`);
704
878
  }
@@ -41,6 +41,7 @@ export { MarkHandle } from './MarkHandle.svelte';
41
41
  export type { QueryParams } from './MarkHandle.svelte';
42
42
  export { FilterGroupRegistry } from './FilterGroupRegistry.svelte';
43
43
  export { FilterGroup } from './FilterGroup.svelte';
44
- export type { SourceOpts, SourceHandle, ToggleHandle, FilterItem, ActiveValueScale } from './FilterGroup.svelte';
44
+ export type { SourceOpts, SourceHandle, ToggleHandle, FilterItem } from './FilterGroup.svelte';
45
+ export { FieldActivity } from './FieldActivity.svelte';
45
46
  export { CrossfilterSelection } from './CrossfilterSelection.svelte';
46
47
  export type { FilterExpr, FilterSchema, Clause, ClauseEntry } from './CrossfilterSelection.svelte';
@@ -47,4 +47,5 @@ export { mapSqlTypeToJs, buildSchemaMap } from './QueryEngine';
47
47
  export { MarkHandle } from './MarkHandle.svelte';
48
48
  export { FilterGroupRegistry } from './FilterGroupRegistry.svelte';
49
49
  export { FilterGroup } from './FilterGroup.svelte';
50
+ export { FieldActivity } from './FieldActivity.svelte';
50
51
  export { CrossfilterSelection } from './CrossfilterSelection.svelte';
@@ -59,7 +59,7 @@ export declare class Preagg {
59
59
  *
60
60
  * The source is named by its ID rather than inferred: before the first drag
61
61
  * it has no clause yet, and other clauses on the same chart (a Toggle)
62
- * exclude the same marks a brush does.
62
+ * skip the same chart a brush does.
63
63
  */
64
64
  activate(filterGroup: FilterGroup, source: ActivationSource): Promise<void>;
65
65
  /**
@@ -15,6 +15,7 @@
15
15
  * 3-Clause License; see NOTICE.
16
16
  */
17
17
  import { binsFineEnough } from '../sufficient-statistics';
18
+ import { NO_ROUTE } from '../CrossfilterSelection.svelte';
18
19
  import { planPreagg, preaggSelectSQL, preaggQuerySQL, bakedClausesHold, markKeys, estimateGroups, activeDimsKeyOf, binFitsView, fnvHash } from './plan';
19
20
  import { BuildQueue } from './BuildQueue';
20
21
  /**
@@ -95,10 +96,10 @@ export class Preagg {
95
96
  *
96
97
  * The source is named by its ID rather than inferred: before the first drag
97
98
  * it has no clause yet, and other clauses on the same chart (a Toggle)
98
- * exclude the same marks a brush does.
99
+ * skip the same chart a brush does.
99
100
  */
100
101
  async activate(filterGroup, source) {
101
- const { sourceId: activeSourceId, excludedByAll, dims } = source;
102
+ const { sourceId: activeSourceId, categoricalKey, dims } = source;
102
103
  if (this.#destroyed || dims.length === 0)
103
104
  return;
104
105
  // An activation outside batch() (a legend's hover) is a batch of one.
@@ -111,7 +112,8 @@ export class Preagg {
111
112
  continue;
112
113
  // A mark the source never filters gets no table for it, and keeps
113
114
  // the tables it holds for other sources.
114
- if (excludedByAll.has(markId))
115
+ const route = reg.descriptor.route ?? NO_ROUTE;
116
+ if (!selection.filters(route, activeSourceId, categoricalKey))
115
117
  continue;
116
118
  const own = this.#of(markId);
117
119
  // Skip if the latest build, finished or in flight, still matches
@@ -122,7 +124,7 @@ export class Preagg {
122
124
  basis.activeSourceId === activeSourceId &&
123
125
  basis.dimsKey === dimsKey &&
124
126
  this.#binsFitDims(latest, dims) &&
125
- bakedClausesHold(markId, selection, latest)) {
127
+ bakedClausesHold(route, selection, latest)) {
126
128
  continue;
127
129
  }
128
130
  }
@@ -136,8 +138,8 @@ export class Preagg {
136
138
  continue;
137
139
  // Bake every other clause into the preagg table. Sorted by source ID,
138
140
  // so the same filters always give the same SQL, and so the same table.
139
- const bakedClauses = selection.bakedClauses(markId, activeSourceId);
140
- const staticFilters = selection.bakedPredicates(markId, activeSourceId);
141
+ const bakedClauses = selection.bakedClauses(route, activeSourceId);
142
+ const staticFilters = selection.bakedPredicates(route, activeSourceId);
141
143
  // Hash-based naming: identical preagg SQL reuses the same table (like Mosaic).
142
144
  // CREATE TABLE IF NOT EXISTS avoids rebuilding when switching charts.
143
145
  const preaggSQL = preaggSelectSQL(plan.creation, staticFilters);
@@ -194,7 +196,7 @@ export class Preagg {
194
196
  const preagg = this.#usable(markId, reg);
195
197
  if (!preagg)
196
198
  return null;
197
- const sql = preaggQuerySQL(markId, reg.descriptor, reg.filterGroup.selection, preagg, reg.handle.params, this.#host.identity);
199
+ const sql = preaggQuerySQL(reg.descriptor.route ?? NO_ROUTE, reg.descriptor, reg.filterGroup.selection, preagg, reg.handle.params, this.#host.identity);
198
200
  return sql ? { sql, table: preagg.table } : null;
199
201
  }
200
202
  /**
@@ -308,7 +310,7 @@ export class Preagg {
308
310
  this.#marks.get(markId)?.building.get(sourceId) === build &&
309
311
  reg.descriptor.parts === build.basis.parts &&
310
312
  this.#binsFitCurrentView(build) &&
311
- bakedClausesHold(markId, reg.filterGroup.selection, build));
313
+ bakedClausesHold(reg.descriptor.route ?? NO_ROUTE, reg.filterGroup.selection, build));
312
314
  }
313
315
  /**
314
316
  * The size guard: whether a planned table would hold more than a quarter of
@@ -371,7 +373,7 @@ export class Preagg {
371
373
  for (const [sourceId, state] of own.tables) {
372
374
  if (state.basis.parts === reg.descriptor.parts &&
373
375
  this.#binsFineEnough(state) &&
374
- bakedClausesHold(markId, selection, state)) {
376
+ bakedClausesHold(reg.descriptor.route ?? NO_ROUTE, selection, state)) {
375
377
  // A Map keeps the first insertion position of a key, so listing
376
378
  // this entry first and spreading the rest moves it to the front.
377
379
  if (!first)
@@ -16,7 +16,7 @@ import { type BrushActivation } from '../sufficient-statistics';
16
16
  import { type QueryParts } from '../../components/marks/lifecycle/query-builder';
17
17
  import type { MarkHandle, QueryParams } from '../MarkHandle.svelte';
18
18
  import type { CategoricalIdentity } from '../categorical-identity';
19
- import type { Clause, CrossfilterSelection, FilterExpr } from '../CrossfilterSelection.svelte';
19
+ import type { Clause, CrossfilterSelection, FilterExpr, MarkRoute } from '../CrossfilterSelection.svelte';
20
20
  import type { QueryDescriptor } from '../QueryEngine';
21
21
  /** What planning needs from the categorical identity. */
22
22
  export type PreaggIdentity = Pick<CategoricalIdentity, 'idColumn' | 'fieldOfIdColumn' | 'values' | 'dimCovers' | 'viewHas' | 'sqlFor' | 'dimTable' | 'identity'>;
@@ -35,8 +35,12 @@ export type ActiveDim = ({
35
35
  /** What activate() needs to know about a source, as FilterGroup sees it at hover time. */
36
36
  export interface ActivationSource {
37
37
  sourceId: string;
38
- /** Marks every registration of the source excludes: it filters none of them. */
39
- excludedByAll: Set<string>;
38
+ /**
39
+ * The source's categorical field (identity key), or null for a brush or a
40
+ * tuple. With the registrations, decides which marks the source would
41
+ * filter: a mark that reads this field dims by it and needs no table.
42
+ */
43
+ categoricalKey: string | null;
40
44
  dims: ActiveDim[];
41
45
  /**
42
46
  * For a source with bin dims: writes its current view, `[d0, d1, pixel
@@ -66,6 +70,11 @@ export interface PreaggCreation {
66
70
  select: Record<string, ExprNode>;
67
71
  groupby: ExprNode[];
68
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[];
69
78
  /**
70
79
  * Fields grouped by value inside and mapped to their ids on the grouped
71
80
  * result: `raw` is the inner column, `alias` the id column, `dim` the dim
@@ -172,14 +181,14 @@ export declare function estimateGroups(markKeys: GroupKey[], activeKeys: GroupKe
172
181
  * mismatch the table's SQL is rebuilt from the current clauses and compared.
173
182
  * Once that fails, frames that still see the same clauses skip the rebuild.
174
183
  */
175
- export declare function bakedClausesHold(markId: string, selection: CrossfilterSelection, state: PreaggState): boolean;
184
+ export declare function bakedClausesHold(route: MarkRoute, selection: CrossfilterSelection, state: PreaggState): boolean;
176
185
  /**
177
186
  * The query a mark runs on a preagg table, or null when the table cannot
178
187
  * answer it (a brush end past its window, a toggled value with no id).
179
188
  * Builds modified QueryParts from the stored outputSelect + bin filters,
180
189
  * then delegates to assembleQuery().
181
190
  */
182
- export declare function preaggQuerySQL(markId: string, descriptor: QueryDescriptor, selection: CrossfilterSelection, preagg: PreaggState, params: QueryParams, identity: PreaggIdentity): string | null;
191
+ export declare function preaggQuerySQL(route: MarkRoute, descriptor: QueryDescriptor, selection: CrossfilterSelection, preagg: PreaggState, params: QueryParams, identity: PreaggIdentity): string | null;
183
192
  /** A preagg table's SELECT. Build and validity check share it, so equal SQL means an equal table. */
184
193
  export declare function preaggSelectSQL(creation: PreaggCreation, filters: FilterExpr[]): string;
185
194
  /**