@fjader/svelte 0.2.3 → 0.2.5

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 (167) hide show
  1. package/README.md +3 -1
  2. package/dist/animation/EntranceGate.d.ts +52 -0
  3. package/dist/animation/EntranceGate.js +93 -0
  4. package/dist/animation/TransitionClock.d.ts +61 -30
  5. package/dist/animation/TransitionClock.js +67 -29
  6. package/dist/animation/transition-log.d.ts +41 -0
  7. package/dist/animation/transition-log.js +57 -0
  8. package/dist/components/axes/AxisBottom.svelte +27 -8
  9. package/dist/components/axes/AxisBottom.svelte.d.ts +10 -0
  10. package/dist/components/axes/AxisCenter.svelte +4 -2
  11. package/dist/components/axes/AxisLeft.svelte +81 -10
  12. package/dist/components/axes/AxisLeft.svelte.d.ts +17 -0
  13. package/dist/components/axes/BandAxisTicks.svelte +4 -3
  14. package/dist/components/axes/axis-title.d.ts +30 -0
  15. package/dist/components/axes/axis-title.js +77 -0
  16. package/dist/components/charts/ChartCell.svelte +33 -6
  17. package/dist/components/charts/ChartCell.svelte.d.ts +9 -0
  18. package/dist/components/charts/ChartContainer.svelte +55 -5
  19. package/dist/components/charts/ChartContainer.svelte.d.ts +14 -1
  20. package/dist/components/charts/ChartHover.svelte.d.ts +14 -0
  21. package/dist/components/charts/ChartHover.svelte.js +22 -0
  22. package/dist/components/charts/ChartVisibility.svelte.d.ts +43 -0
  23. package/dist/components/charts/ChartVisibility.svelte.js +67 -0
  24. package/dist/components/guides/Grid.svelte +12 -1
  25. package/dist/components/guides/legend/ContinuousColorLegend.svelte +7 -1
  26. package/dist/components/guides/legend/OrdinalColorLegend.svelte +8 -5
  27. package/dist/components/marks/AreaMarkX.svelte +18 -4
  28. package/dist/components/marks/AreaMarkY.svelte +18 -4
  29. package/dist/components/marks/BarMarkX.svelte +20 -4
  30. package/dist/components/marks/BarMarkY.svelte +20 -4
  31. package/dist/components/marks/DotsMark.svelte +8 -3
  32. package/dist/components/marks/LineMark.svelte +13 -6
  33. package/dist/components/marks/ParallelCoordinatesMark.svelte +11 -2
  34. package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +4 -0
  35. package/dist/components/marks/RectMark.svelte +8 -3
  36. package/dist/components/marks/RectXMark.svelte +9 -3
  37. package/dist/components/marks/RectYMark.svelte +9 -3
  38. package/dist/components/marks/RuleMarkX.svelte +31 -25
  39. package/dist/components/marks/RuleMarkY.svelte +31 -25
  40. package/dist/components/marks/TextMark.svelte +52 -14
  41. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +22 -1
  42. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +579 -66
  43. package/dist/components/marks/lifecycle/domain-queries.d.ts +13 -2
  44. package/dist/components/marks/lifecycle/domain-queries.js +43 -11
  45. package/dist/components/marks/lifecycle/domain-registration.d.ts +6 -1
  46. package/dist/components/marks/lifecycle/domain-registration.js +17 -20
  47. package/dist/components/marks/lifecycle/other-rows.svelte.d.ts +112 -0
  48. package/dist/components/marks/lifecycle/other-rows.svelte.js +193 -0
  49. package/dist/components/marks/lifecycle/other-split.d.ts +80 -0
  50. package/dist/components/marks/lifecycle/other-split.js +112 -0
  51. package/dist/components/marks/lifecycle/query-builder.d.ts +53 -3
  52. package/dist/components/marks/lifecycle/query-builder.js +46 -23
  53. package/dist/components/marks/utils/MarkColor.svelte.d.ts +3 -1
  54. package/dist/components/marks/utils/MarkColor.svelte.js +33 -3
  55. package/dist/components/marks/utils/MarkSeries.svelte.d.ts +14 -1
  56. package/dist/components/marks/utils/MarkSeries.svelte.js +25 -1
  57. package/dist/components/marks/utils/MarkSize.svelte.js +4 -0
  58. package/dist/components/marks/utils/reactive-config.d.ts +1 -1
  59. package/dist/components/marks/utils/reactive-config.js +9 -0
  60. package/dist/components/marks/utils/tooltip-helpers.d.ts +4 -1
  61. package/dist/components/marks/utils/tooltip-helpers.js +2 -1
  62. package/dist/components/rendering/Areas.svelte +57 -19
  63. package/dist/components/rendering/Areas.svelte.d.ts +12 -0
  64. package/dist/components/rendering/BaseGPURenderer.svelte.d.ts +3 -1
  65. package/dist/components/rendering/BaseGPURenderer.svelte.js +4 -2
  66. package/dist/components/rendering/Dots.svelte +23 -3
  67. package/dist/components/rendering/Dots.svelte.d.ts +6 -0
  68. package/dist/components/rendering/GridLine.svelte +13 -1
  69. package/dist/components/rendering/GridLine.svelte.d.ts +5 -0
  70. package/dist/components/rendering/Lines.svelte +48 -13
  71. package/dist/components/rendering/Lines.svelte.d.ts +12 -0
  72. package/dist/components/rendering/ParallelLines.svelte +5 -5
  73. package/dist/components/rendering/Rects.svelte +164 -30
  74. package/dist/components/rendering/Rects.svelte.d.ts +23 -2
  75. package/dist/components/rendering/RuleX.svelte +1 -1
  76. package/dist/components/rendering/RuleY.svelte +1 -1
  77. package/dist/components/rendering/WebGPUCanvas.svelte +4 -2
  78. package/dist/components/rendering/activity-reads.d.ts +47 -12
  79. package/dist/components/rendering/activity-reads.js +61 -19
  80. package/dist/components/rendering/uniform-sizes.d.ts +1 -1
  81. package/dist/components/rendering/uniform-sizes.js +1 -1
  82. package/dist/config/ChartConfig.svelte.d.ts +10 -1
  83. package/dist/config/ChartConfig.svelte.js +17 -0
  84. package/dist/gpu/GPUCoordinator.svelte.d.ts +31 -1
  85. package/dist/gpu/GPUCoordinator.svelte.js +164 -7
  86. package/dist/gpu/MorphedChannels.d.ts +11 -28
  87. package/dist/gpu/MorphedChannels.js +72 -8
  88. package/dist/gpu/ScaleTemplateSystem.d.ts +20 -2
  89. package/dist/gpu/ScaleTemplateSystem.js +83 -6
  90. package/dist/index.d.ts +19 -4
  91. package/dist/index.js +22 -4
  92. package/dist/interactors/Interval1D.svelte +6 -1
  93. package/dist/interactors/Interval2D.svelte +6 -1
  94. package/dist/interactors/ParallelAxisSource.svelte +10 -1
  95. package/dist/interactors/Toggle.svelte +32 -3
  96. package/dist/interactors/Tooltip.svelte +138 -41
  97. package/dist/interactors/TooltipManager.svelte.d.ts +20 -5
  98. package/dist/interactors/TooltipManager.svelte.js +46 -12
  99. package/dist/interactors/other-tooltip.d.ts +16 -0
  100. package/dist/interactors/other-tooltip.js +22 -0
  101. package/dist/query-engine/CrossfilterSelection.svelte.d.ts +4 -2
  102. package/dist/query-engine/CrossfilterSelection.svelte.js +2 -0
  103. package/dist/query-engine/FieldActivity.svelte.d.ts +49 -1
  104. package/dist/query-engine/FieldActivity.svelte.js +175 -5
  105. package/dist/query-engine/FilterGroup.svelte.d.ts +23 -1
  106. package/dist/query-engine/FilterGroup.svelte.js +43 -3
  107. package/dist/query-engine/FilterGroupRegistry.svelte.d.ts +31 -3
  108. package/dist/query-engine/FilterGroupRegistry.svelte.js +62 -15
  109. package/dist/query-engine/MarkHandle.svelte.d.ts +28 -1
  110. package/dist/query-engine/MarkHandle.svelte.js +33 -1
  111. package/dist/query-engine/QueryEngine.d.ts +225 -5
  112. package/dist/query-engine/QueryEngine.js +697 -47
  113. package/dist/query-engine/categorical-identity.d.ts +41 -0
  114. package/dist/query-engine/categorical-identity.js +145 -65
  115. package/dist/query-engine/debug/query-log.d.ts +39 -0
  116. package/dist/query-engine/debug/query-log.js +90 -0
  117. package/dist/query-engine/duckdb.d.ts +55 -8
  118. package/dist/query-engine/duckdb.js +82 -30
  119. package/dist/query-engine/index.d.ts +7 -4
  120. package/dist/query-engine/index.js +3 -3
  121. package/dist/query-engine/preagg/BuildQueue.d.ts +33 -13
  122. package/dist/query-engine/preagg/BuildQueue.js +51 -15
  123. package/dist/query-engine/preagg/Preagg.d.ts +46 -3
  124. package/dist/query-engine/preagg/Preagg.js +159 -31
  125. package/dist/query-engine/preagg/plan.d.ts +33 -1
  126. package/dist/query-engine/preagg/plan.js +158 -14
  127. package/dist/query-engine/sliced-lane.d.ts +108 -0
  128. package/dist/query-engine/sliced-lane.js +299 -0
  129. package/dist/query-engine/source-routing.d.ts +106 -0
  130. package/dist/query-engine/source-routing.js +189 -0
  131. package/dist/query-engine/toggle-predicate.d.ts +21 -0
  132. package/dist/query-engine/toggle-predicate.js +29 -1
  133. package/dist/scales/ActivityTable.d.ts +5 -1
  134. package/dist/scales/ActivityTable.js +12 -3
  135. package/dist/scales/BandScaleState.svelte.d.ts +22 -9
  136. package/dist/scales/BandScaleState.svelte.js +39 -21
  137. package/dist/scales/ChartScales.svelte.d.ts +3 -0
  138. package/dist/scales/ChartScales.svelte.js +26 -8
  139. package/dist/scales/ColorScaleState.svelte.d.ts +18 -2
  140. package/dist/scales/ColorScaleState.svelte.js +138 -26
  141. package/dist/scales/LinearScaleState.svelte.d.ts +7 -10
  142. package/dist/scales/LinearScaleState.svelte.js +21 -39
  143. package/dist/scales/ScaleGeneration.svelte.d.ts +48 -0
  144. package/dist/scales/ScaleGeneration.svelte.js +42 -0
  145. package/dist/scales/ScaleRegistry.svelte.js +3 -1
  146. package/dist/scales/SlotTransition.d.ts +15 -1
  147. package/dist/scales/SlotTransition.js +33 -4
  148. package/dist/scales/TimeScaleState.svelte.d.ts +7 -3
  149. package/dist/scales/TimeScaleState.svelte.js +7 -7
  150. package/dist/scales/color-utils.d.ts +0 -3
  151. package/dist/scales/color-utils.js +0 -3
  152. package/dist/scales/entered.d.ts +8 -0
  153. package/dist/scales/entered.js +10 -0
  154. package/dist/spec/LazyQueryEngine.js +2 -19
  155. package/dist/spec/MarkRegistry.d.ts +3 -1
  156. package/dist/spec/MarkRegistry.js +13 -1
  157. package/dist/spec/PlotFromSpec.svelte +1 -0
  158. package/dist/spec/export.js +11 -2
  159. package/dist/spec/types.d.ts +43 -13
  160. package/dist/sql/bin.d.ts +25 -0
  161. package/dist/sql/bin.js +108 -5
  162. package/dist/theme.css +5 -4
  163. package/dist/transforms/TransformCompiler.d.ts +47 -2
  164. package/dist/transforms/TransformCompiler.js +70 -1
  165. package/dist/transforms/index.js +176 -42
  166. package/dist/types.d.ts +177 -2
  167. package/package.json +4 -3
@@ -51,23 +51,46 @@ export class Preagg {
51
51
  constructor(host, buildMaxWaitMs) {
52
52
  this.#host = host;
53
53
  this.#builds = new BuildQueue({
54
- exec: (sql) => host.exec(sql),
55
- busy: () => host.busy(),
56
- stillWanted: (item) => this.#stillWanted(item),
57
- dropped: ({ markId, sourceId, build }) => {
54
+ exec: (sql, signal) => host.exec(sql, signal),
55
+ // Builds wait for interaction only on a shared connection. On a lane
56
+ // they run at once, and interaction still comes first on its own:
57
+ // each slice of a build is a poll, a message at the back of the
58
+ // worker's queue, so mark queries already waiting there are served
59
+ // before the next slice. One query waits at most a slice (~5 ms)
60
+ // behind a build, not the whole build: measured on 2M rows, an
61
+ // update's first queries 3-9 ms during a build, 52-57 ms on one
62
+ // connection. A burst of queries still stretches: the build takes a
63
+ // slice between two of them, and its polls need main-thread turns
64
+ // while results decode (+~65 ms on that burst, still under the one
65
+ // connection's 195 ms median); the build is the one that waits.
66
+ busy: () => !host.sliced() && host.busy(),
67
+ unwanted: (item) => this.#unwanted(item),
68
+ started: (item) => this.#emitFor('started', item),
69
+ dropped: (item, reason) => {
70
+ // Before the bookkeeping below: the detail reads it.
71
+ if (this.#observing()) {
72
+ this.#emitFor('dropped', item, { reason, detail: this.#dropDetail(item, reason) });
73
+ }
74
+ const { markId, sourceId, build } = item;
58
75
  const own = this.#marks.get(markId);
59
76
  if (own?.building.get(sourceId) === build)
60
77
  own.building.delete(sourceId);
61
78
  },
62
- built: ({ markId, sourceId, build }) => {
79
+ built: (item) => {
80
+ this.#emitFor('built', item);
81
+ const { markId, sourceId, build } = item;
63
82
  const own = this.#marks.get(markId);
64
83
  if (own?.building.get(sourceId) !== build)
65
84
  return;
66
85
  own.building.delete(sourceId);
67
86
  own.tables.set(sourceId, build);
68
87
  },
69
- failed: ({ markId, sourceId, build }, e) => {
70
- console.warn(`[QueryEngine] preagg failed for ${markId}:`, e);
88
+ failed: (item, e) => {
89
+ this.#emitFor('failed', item, { error: e });
90
+ const { markId, sourceId, build } = item;
91
+ if (!(e instanceof DOMException && e.name === 'AbortError')) {
92
+ console.warn(`[QueryEngine] preagg failed for ${markId}:`, e);
93
+ }
71
94
  const own = this.#marks.get(markId);
72
95
  if (own?.building.get(sourceId) === build)
73
96
  own.building.delete(sourceId);
@@ -82,6 +105,8 @@ export class Preagg {
82
105
  * tables — hash-named tables are reused via CREATE TABLE IF NOT EXISTS. */
83
106
  dropMark(markId) {
84
107
  this.#marks.delete(markId);
108
+ // Its running build is no longer wanted: cancelled, it rolls back.
109
+ this.#builds.abortRunning((item) => item.markId === markId);
85
110
  }
86
111
  /**
87
112
  * Pre-aggregate marks for a filter group before a source is used.
@@ -110,6 +135,10 @@ export class Preagg {
110
135
  for (const [markId, reg] of this.#host.marks()) {
111
136
  if (reg.filterGroup !== filterGroup)
112
137
  continue;
138
+ // A mark off screen is not re-queried, so a table for it would only
139
+ // take the connection from the ones that are.
140
+ if (!reg.handle.active)
141
+ continue;
113
142
  // A mark the source never filters gets no table for it, and keeps
114
143
  // the tables it holds for other sources.
115
144
  const route = reg.descriptor.route ?? NO_ROUTE;
@@ -132,10 +161,25 @@ export class Preagg {
132
161
  own.tables.delete(activeSourceId);
133
162
  own.building.delete(activeSourceId);
134
163
  const plan = planPreagg(reg.descriptor, dims, this.#host.identity);
135
- if (!plan)
164
+ if (!plan) {
165
+ if (this.#observing()) {
166
+ this.#emit({ kind: 'skipped', markId, sourceId: activeSourceId, reason: 'unplannable' });
167
+ }
136
168
  continue;
137
- if (this.#tooLarge(reg, plan.activeKeys))
169
+ }
170
+ const size = this.#size(reg, plan.activeKeys);
171
+ if (size.groups != null && size.groups > size.cap) {
172
+ if (this.#observing()) {
173
+ this.#emit({
174
+ kind: 'skipped',
175
+ markId,
176
+ sourceId: activeSourceId,
177
+ reason: 'too large',
178
+ detail: { ...size, markKeys: size.markKeys, activeKeys: plan.activeKeys }
179
+ });
180
+ }
138
181
  continue;
182
+ }
139
183
  // Bake every other clause into the preagg table. Sorted by source ID,
140
184
  // so the same filters always give the same SQL, and so the same table.
141
185
  const bakedClauses = selection.bakedClauses(route, activeSourceId);
@@ -167,9 +211,19 @@ export class Preagg {
167
211
  // Most recently planned last, for the cap on unused tables.
168
212
  this.#tables.delete(preaggTable);
169
213
  this.#tables.add(preaggTable);
214
+ if (this.#observing()) {
215
+ const event = { markId, sourceId: activeSourceId, table: preaggTable };
216
+ this.#emit({ kind: 'planned', ...event, detail: { groups: size.groups, cap: size.cap } });
217
+ this.#emit({ kind: 'queued', ...event, batch: this.#builds.batch });
218
+ }
170
219
  promises.push(this.#builds.push({ markId, sourceId: activeSourceId, build, createSQL }));
171
220
  }
172
- this.#builds.pump();
221
+ // From a microtask, not now: activation runs in the pointer-enter handler,
222
+ // which first marks the chart hovered, and the effect that writes the
223
+ // hovered marks' tooltip tables (what the first hover waits for) flushes
224
+ // in an earlier microtask. That write is then in flight, `busy()` holds
225
+ // the build back, and the tooltip does not wait behind it.
226
+ queueMicrotask(() => this.#builds.pump());
173
227
  this.#evictUnused();
174
228
  await Promise.all(promises);
175
229
  }
@@ -233,6 +287,11 @@ export class Preagg {
233
287
  * A data reload: drop every queued build, every mark's tables and the tables
234
288
  * themselves (CREATE TABLE IF NOT EXISTS would otherwise keep serving
235
289
  * pre-reload aggregates), and the row counts.
290
+ *
291
+ * The running build is cancelled too (`clear`). On a lane it runs beside the
292
+ * reload, reading the old rows through its snapshot; should it finish anyway
293
+ * (the cancel lost the race), `built` finds its mark gone from `#marks` and
294
+ * does not register it, and the DROP below follows it on the same lane.
236
295
  */
237
296
  reset() {
238
297
  this.#builds.clear();
@@ -246,8 +305,8 @@ export class Preagg {
246
305
  /**
247
306
  * The engine is going: settle waiting activations and drop this session's
248
307
  * tables. Resolves once the drops have run; call it while the engine can
249
- * still exec. A build already running is ahead of the drops on the one
250
- * connection, so its table is dropped too.
308
+ * still exec. A build already running is cancelled, and is ahead of the
309
+ * drops on its connection either way, so its table is dropped too.
251
310
  */
252
311
  async destroy() {
253
312
  this.#destroyed = true;
@@ -298,23 +357,93 @@ export class Preagg {
298
357
  return own;
299
358
  }
300
359
  /**
301
- * Whether a queued build is still wanted when it would be sent: checked
302
- * against the mark's current registration, which a re-setup replaces,
303
- * against the view now (a zoom in or a long pan while it waited means a
304
- * table settling would only rebuild), and against the filters now: a clause
305
- * baked into the table may have changed.
360
+ * Why a queued build is no longer wanted when it would be sent, or null if
361
+ * it still is: checked against the mark's current registration, which a
362
+ * re-setup replaces, against the view now (a zoom in or a long pan while it
363
+ * waited means a table settling would only rebuild), and against the
364
+ * filters now: a clause baked into the table may have changed. The reason
365
+ * goes into the drop's build record.
306
366
  */
307
- #stillWanted({ markId, sourceId, build }) {
367
+ #unwanted({ markId, sourceId, build }) {
308
368
  const reg = this.#host.mark(markId);
309
- return (!!reg &&
310
- this.#marks.get(markId)?.building.get(sourceId) === build &&
311
- reg.descriptor.parts === build.basis.parts &&
312
- this.#binsFitCurrentView(build) &&
313
- bakedClausesHold(reg.descriptor.route ?? NO_ROUTE, reg.filterGroup.selection, build));
369
+ if (!reg)
370
+ return 'unregistered';
371
+ // Gone off screen while the build waited.
372
+ if (!reg.handle.active)
373
+ return 'inactive';
374
+ if (this.#marks.get(markId)?.building.get(sourceId) !== build)
375
+ return 'superseded';
376
+ if (reg.descriptor.parts !== build.basis.parts)
377
+ return 'parts changed';
378
+ if (!this.#binsFitCurrentView(build))
379
+ return 'bins do not fit the view';
380
+ if (!bakedClausesHold(reg.descriptor.route ?? NO_ROUTE, reg.filterGroup.selection, build)) {
381
+ return 'baked clauses changed';
382
+ }
383
+ return null;
384
+ }
385
+ // --- Build records (observeBuilds) ---
386
+ #observing() {
387
+ return !!this.#host.observingBuilds?.();
388
+ }
389
+ #emit(event) {
390
+ this.#host.onBuild?.({ table: null, ...event, time: performance.now() });
391
+ }
392
+ #emitFor(kind, item, extra = {}) {
393
+ if (!this.#observing())
394
+ return;
395
+ this.#emit({
396
+ kind,
397
+ markId: item.markId,
398
+ sourceId: item.sourceId,
399
+ table: item.build.table,
400
+ batch: item.batch,
401
+ ...extra
402
+ });
403
+ }
404
+ /**
405
+ * The values a failed check compared, for a drop's record. For the bin
406
+ * check, each bin dim's built window against the view it was checked
407
+ * against: the domain read now, through the source's view callback.
408
+ */
409
+ #dropDetail(item, reason) {
410
+ const { build } = item;
411
+ if (reason === 'bins do not fit the view') {
412
+ const out = this.#viewScratch;
413
+ out.fill(NaN);
414
+ build.basis.view?.(out);
415
+ const dims = [];
416
+ let bin = 0;
417
+ for (const meta of build.activeMeta) {
418
+ if (meta.kind !== 'bin')
419
+ continue;
420
+ const i = 3 * bin++;
421
+ const width = meta.bins / meta.scaleFactor;
422
+ dims.push({
423
+ built: {
424
+ d0: meta.d0,
425
+ d1: meta.d0 + width,
426
+ bins: meta.bins,
427
+ scaleFactor: meta.scaleFactor
428
+ },
429
+ view: { v0: out[i], v1: out[i + 1], pixelExtent: out[i + 2] },
430
+ fits: binFitsView(meta, out[i], out[i + 1], out[i + 2]),
431
+ fineEnough: binsFineEnough(meta.scaleFactor, out[i + 1] - out[i], out[i + 2])
432
+ });
433
+ }
434
+ return { dims };
435
+ }
436
+ if (reason === 'parts changed')
437
+ return { sameParts: false };
438
+ if (reason === 'baked clauses changed')
439
+ return { baked: build.basis.bakedClauses.length };
440
+ return undefined;
314
441
  }
315
442
  /**
316
- * The size guard: whether a planned table would hold more than a quarter of
317
- * its base table's rows (a fixed 100,000 until the row count has been read).
443
+ * The size guard's numbers: the groups a planned table would hold, and the
444
+ * cap it must not exceed, a quarter of its base table's rows (a fixed
445
+ * 100,000 until the row count has been read). activate() skips a table
446
+ * over the cap.
318
447
  *
319
448
  * Measured on 1M rows (DuckDB-WASM, one thread): a toggle click on a table a
320
449
  * quarter the size of the base table is still ~3.7× faster than on the base
@@ -323,15 +452,14 @@ export class Preagg {
323
452
  * when the connection is idle, so their cost shows only as the wait of a
324
453
  * click that lands mid-build, and as memory; past a quarter that outweighs
325
454
  * the gain. The groups are estimated by estimateGroups; an unknown estimate
326
- * builds.
455
+ * (null) builds.
327
456
  */
328
- #tooLarge(reg, activeKeys) {
329
- const groups = estimateGroups(markKeys(reg.descriptor, reg.handle, this.#host.identity), activeKeys);
330
- if (groups == null)
331
- return false;
457
+ #size(reg, activeKeys) {
458
+ const keys = markKeys(reg.descriptor, reg.handle, this.#host.identity);
459
+ const groups = estimateGroups(keys, activeKeys);
332
460
  const rows = this.#rowCounts.get(reg.descriptor.baseTable);
333
461
  const cap = typeof rows === 'number' ? rows / 4 : 100_000;
334
- return groups > cap;
462
+ return { groups, cap, markKeys: keys };
335
463
  }
336
464
  /** Read a base table's row count once, for the size guard. */
337
465
  #countRows(table) {
@@ -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
@@ -80,7 +85,22 @@ export interface PreaggCreation {
80
85
  alias: string;
81
86
  dim: string;
82
87
  }>;
88
+ /**
89
+ * The mark's padding rows, held in the table so its reads need no padding
90
+ * (see TransformCompiler.padRows), or null. `rows` is their SELECT over
91
+ * every channel; the table takes its group channels (`dims`), 0 for every
92
+ * partial (`partials`: count and sum only, for which 0 adds nothing), and
93
+ * `__pad` true. Source rows whose bin is NULL are left out (in `where`):
94
+ * those bins are never drawn, and padding a base read drops them too.
95
+ */
96
+ pad: {
97
+ rows: string;
98
+ dims: string[];
99
+ partials: string[];
100
+ } | null;
83
101
  }
102
+ /** The column marking a preagg table's padding rows; real rows hold false. */
103
+ export declare const PAD_COLUMN = "__pad";
84
104
  /**
85
105
  * How each active dim appears in a preagg table. A `bin` dim is a pixel-bin
86
106
  * index over a window (`buildBinExpr`): bins 0 .. bins - 1 from d0, with -1
@@ -107,6 +127,12 @@ export type ActiveMeta = {
107
127
  export interface GroupKey {
108
128
  column: string | null;
109
129
  groups: number | null;
130
+ /**
131
+ * For a mark's key over several columns: the mark's own bins per binned
132
+ * source column (from its query parts, see binCounts), so a bin dim on one
133
+ * of them merges into the key instead of multiplying it.
134
+ */
135
+ columns?: ReadonlyMap<string, number>;
110
136
  }
111
137
  export interface PreaggState {
112
138
  table: string;
@@ -165,7 +191,13 @@ export declare function markKeys(descriptor: QueryDescriptor, handle: Pick<MarkH
165
191
  * mark with x and fill on the same field has that field's groups, not their
166
192
  * square, and a bin dim on a column the mark already groups by adds its bins
167
193
  * to that key's (each pixel bin falls in about one of the mark's bins)
168
- * instead of multiplying.
194
+ * instead of multiplying. For a mark key over several columns (a histogram
195
+ * coloured by a category) the same holds per value of the other columns: a
196
+ * bin dim on a column the mark bins into `d` bins makes each group of the
197
+ * rest hold about `d + bins` groups instead of `d`, so the key's groups grow
198
+ * by `1 + bins / d`. Only one bin dim merges into a mark key, on purpose: a
199
+ * 2D brush over two binned columns of the same mark multiplies the second,
200
+ * which overestimates (the guard then skips rather than builds too much).
169
201
  */
170
202
  export declare function estimateGroups(markKeys: GroupKey[], activeKeys: GroupKey[]): number | null;
171
203
  /**
@@ -11,10 +11,12 @@
11
11
  * the query. Copyright (c) 2023-2025, UW Interactive Data Lab. BSD 3-Clause
12
12
  * License; see NOTICE.
13
13
  */
14
- import { isAggregateExpression, isNode, collectColumns, column, and, isBetween, literal, Query, sql as sqlTag } from '@uwdata/mosaic-sql';
15
- import { togglePredicate } from '../toggle-predicate';
14
+ import { isAggregateExpression, isNode, collectColumns, column, and, isBetween, literal, Query, collectAggregates, sql as sqlTag } from '@uwdata/mosaic-sql';
15
+ import { BITMAP_MIN_IDS, idBitmapPredicate, togglePredicate } from '../toggle-predicate';
16
16
  import { rewriteForPreagg, buildBinExpr, binsFineEnough, BIN_WINDOW_MARGIN } from '../sufficient-statistics';
17
17
  import { assembleQuery, castNumeric, castReal } from '../../components/marks/lifecycle/query-builder';
18
+ /** The column marking a preagg table's padding rows; real rows hold false. */
19
+ export const PAD_COLUMN = '__pad';
18
20
  /**
19
21
  * The preagg table a mark would build for a source's active dims, or null
20
22
  * when the mark cannot be pre-aggregated (no aggregates, or an aggregate that
@@ -37,6 +39,11 @@ export function planPreagg(descriptor, dims, identity) {
37
39
  const { parts, table, baseTable, schema } = descriptor;
38
40
  if (!parts.hasAggregates && !parts.hasWindowAggregates)
39
41
  return null;
42
+ // A having() on a preagg table would count its groups, not the source rows, and
43
+ // return the wrong rows without an error. The built-in transforms add none; see
44
+ // TransformCompiler.hasHaving for why, and for what supporting it would take.
45
+ if (descriptor.transformCompiler.hasHaving())
46
+ return null;
40
47
  // Analyze selectDef for preagg compatibility
41
48
  const analysis = rewriteForPreagg(parts.selectDef, parts.passthroughChannels);
42
49
  if (!analysis)
@@ -99,6 +106,7 @@ export function planPreagg(descriptor, dims, identity) {
99
106
  activeMeta.push({ kind: 'value', field, column: alias, byId: false });
100
107
  activeKeys.push({ column: null, groups: null });
101
108
  });
109
+ const pad = analysis.windowChannels ? null : padPlan(descriptor, preaggCols);
102
110
  // CAST-wrap dimension preaggCols (bake CAST into the preagg table).
103
111
  // Partial-aggregate and active columns stay raw for recombination.
104
112
  for (const [ch, expr] of Object.entries(preaggCols)) {
@@ -115,7 +123,9 @@ export function planPreagg(descriptor, dims, identity) {
115
123
  groupby: [...preaggGroupBy, ...activeGroupBy],
116
124
  // Sorted by bin columns for fast range scans
117
125
  orderby: binOrder,
118
- joins
126
+ where: [...descriptor.transformCompiler.rowFilters(), ...(pad?.where ?? [])],
127
+ joins,
128
+ pad: pad?.pad ?? null
119
129
  },
120
130
  outputSelect,
121
131
  activeMeta,
@@ -123,6 +133,46 @@ export function planPreagg(descriptor, dims, identity) {
123
133
  activeKeys
124
134
  };
125
135
  }
136
+ /**
137
+ * The padding rows a mark's table can hold, or null when its reads must pad
138
+ * themselves: the mark pads nothing, a transform would see the rows differently
139
+ * (TransformCompiler.padRows), or a measure is not a count or a sum. A mean of
140
+ * an empty bin would read 0 / 0 from a padding row, where padding gives 0.
141
+ */
142
+ function padPlan(descriptor, preaggCols) {
143
+ const { parts, transformCompiler } = descriptor;
144
+ for (const expr of Object.values(parts.selectDef)) {
145
+ if (!isNode(expr) || !isAggregateExpression(expr))
146
+ continue;
147
+ for (const agg of collectAggregates(expr)) {
148
+ const name = agg.name.toLowerCase();
149
+ if (agg.isDistinct || (name !== 'count' && name !== 'sum'))
150
+ return null;
151
+ }
152
+ }
153
+ const rows = transformCompiler.padRows(Object.keys(parts.selectDef), {
154
+ groupKeys: parts.groupKeys,
155
+ bins: parts.bins ?? null
156
+ });
157
+ if (!rows)
158
+ return null;
159
+ const dims = [];
160
+ const partials = [];
161
+ for (const [col, expr] of Object.entries(preaggCols)) {
162
+ if (col.startsWith('__ps'))
163
+ partials.push(col);
164
+ else if (col.startsWith('__active') || col.startsWith('__raw'))
165
+ continue;
166
+ else if (!isAggregateExpression(expr))
167
+ dims.push(col);
168
+ }
169
+ // A filter on the source column, not on the bin: on the bin it would be
170
+ // pushed below the GROUP BY and compute every row's bin twice (measured:
171
+ // half again the build time), where a NULL test on the column is free.
172
+ const sources = new Set(dims.flatMap((col) => parts.bins?.[col]?.source ?? []));
173
+ const where = [...sources].map((source) => sqlTag([`(${source}) IS NOT NULL`]));
174
+ return { pad: { rows, dims, partials }, where };
175
+ }
126
176
  /** The mark's channel that is `field`'s id channel (see idFieldOf), or null. */
127
177
  function idChannelFor(selectDef, field, identity) {
128
178
  for (const [channel, expr] of Object.entries(selectDef)) {
@@ -174,6 +224,14 @@ export function markKeys(descriptor, handle, identity) {
174
224
  else
175
225
  fields.add(field);
176
226
  }
227
+ // A bin() channel resolves to SQL text that collectColumns cannot see into,
228
+ // so its column comes from the plan: the mark groups by it too.
229
+ const bins = binCounts(parts);
230
+ if (bins) {
231
+ allIds = false;
232
+ for (const name of bins.keys())
233
+ columns.add(name);
234
+ }
177
235
  if (allIds) {
178
236
  return [...fields].map((field) => ({
179
237
  column: null,
@@ -181,7 +239,35 @@ export function markKeys(descriptor, handle, identity) {
181
239
  }));
182
240
  }
183
241
  const rows = handle.params.limit == null ? handle.arrowTable?.numRows : undefined;
184
- return [{ column: columns.size === 1 ? [...columns][0] : null, groups: rows ?? 1 }];
242
+ if (columns.size === 1)
243
+ return [{ column: [...columns][0], groups: rows ?? 1 }];
244
+ return [{ column: null, groups: rows ?? 1, columns: bins }];
245
+ }
246
+ /**
247
+ * The mark's own bins per source column, from its query parts: each `bin()`
248
+ * channel records its binned source and how many bins it has (`parts.bins`,
249
+ * what `dense()` pads by). Read from the plan, never from the data. Only
250
+ * plain columns count; an expression's bins, or a mark without bin metadata,
251
+ * give no entry, and the estimate then multiplies as before.
252
+ */
253
+ function binCounts(parts) {
254
+ if (!parts.bins)
255
+ return undefined;
256
+ const counts = new Map();
257
+ for (const padding of Object.values(parts.bins)) {
258
+ const name = plainColumn(padding.source);
259
+ if (name == null || !(padding.count > 0))
260
+ continue;
261
+ counts.set(name, Math.max(counts.get(name) ?? 0, padding.count));
262
+ }
263
+ return counts.size > 0 ? counts : undefined;
264
+ }
265
+ /** The column a bin's source SQL names, when it is a plain (optionally quoted) column. */
266
+ function plainColumn(source) {
267
+ const quoted = /^"((?:[^"]|"")+)"$/.exec(source);
268
+ if (quoted)
269
+ return quoted[1].replaceAll('""', '"');
270
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(source) ? source : null;
185
271
  }
186
272
  /**
187
273
  * The groups a planned table would hold: the mark's own keys (markKeys) and
@@ -190,7 +276,13 @@ export function markKeys(descriptor, handle, identity) {
190
276
  * mark with x and fill on the same field has that field's groups, not their
191
277
  * square, and a bin dim on a column the mark already groups by adds its bins
192
278
  * to that key's (each pixel bin falls in about one of the mark's bins)
193
- * instead of multiplying.
279
+ * instead of multiplying. For a mark key over several columns (a histogram
280
+ * coloured by a category) the same holds per value of the other columns: a
281
+ * bin dim on a column the mark bins into `d` bins makes each group of the
282
+ * rest hold about `d + bins` groups instead of `d`, so the key's groups grow
283
+ * by `1 + bins / d`. Only one bin dim merges into a mark key, on purpose: a
284
+ * 2D brush over two binned columns of the same mark multiplies the second,
285
+ * which overestimates (the guard then skips rather than builds too much).
194
286
  */
195
287
  export function estimateGroups(markKeys, activeKeys) {
196
288
  if (activeKeys.some((key) => key.groups == null))
@@ -198,6 +290,15 @@ export function estimateGroups(markKeys, activeKeys) {
198
290
  const merged = new Set();
199
291
  let groups = 1;
200
292
  for (const key of markKeys) {
293
+ if (key.column == null && key.columns) {
294
+ const within = activeKeys.find((active) => active.column != null && key.columns.has(active.column) && !merged.has(active));
295
+ if (within) {
296
+ merged.add(within);
297
+ const d = Math.max(1, key.columns.get(within.column));
298
+ groups *= Math.ceil(key.groups * (1 + within.groups / d));
299
+ continue;
300
+ }
301
+ }
201
302
  const same = key.column == null
202
303
  ? undefined
203
304
  : activeKeys.find((active) => active.column === key.column && !merged.has(active));
@@ -280,8 +381,9 @@ export function preaggQuerySQL(route, descriptor, selection, preagg, params, ide
280
381
  }
281
382
  if (valueExprs && valueMeta) {
282
383
  // A toggle's clause holds tuples in its field order. Id columns
283
- // filter by id; a value with no id (NULL, or not in the dim) cannot
284
- // be expressed, so this query goes to the base table instead.
384
+ // filter by id (the NULL category has one); a value with no id (not in
385
+ // the dim yet) cannot be expressed, so this query goes to the base
386
+ // table instead.
285
387
  const tuples = [];
286
388
  for (const tuple of clause.value) {
287
389
  const mapped = [];
@@ -294,7 +396,17 @@ export function preaggQuerySQL(route, descriptor, selection, preagg, params, ide
294
396
  }
295
397
  tuples.push(mapped);
296
398
  }
297
- filters.push(togglePredicate(valueExprs, tuples));
399
+ // One field by id, and many of them (a click on a large Other): a
400
+ // constant-size bitmap rather than a long IN list, which DuckDB spends
401
+ // most of its time parsing. Measured on a preagg table: 1 ms against
402
+ // 20 ms for IN of 9 000 ids.
403
+ const only = valueMeta.length === 1 ? valueMeta[0] : null;
404
+ if (only?.byId && tuples.length > BITMAP_MIN_IDS) {
405
+ filters.push(idBitmapPredicate(valueExprs[0], tuples.map((t) => t[0]), identity.identity(only.field).values.length));
406
+ }
407
+ else {
408
+ filters.push(togglePredicate(valueExprs, tuples));
409
+ }
298
410
  }
299
411
  }
300
412
  // --- Build selectDef: CAST recombined aggregates; dimensions are already
@@ -315,33 +427,65 @@ export function preaggQuerySQL(route, descriptor, selection, preagg, params, ide
315
427
  castSelect[ch] = castReal(expr);
316
428
  }
317
429
  }
430
+ // A table holding its padding: those rows pass whatever the brush says, and
431
+ // the read's GROUP BY folds them into the real rows of the same group.
432
+ if (preagg.creation.pad && filters.length > 0) {
433
+ filters.splice(0, filters.length, sqlTag `(${column(PAD_COLUMN)} OR ${and(filters)})`);
434
+ }
318
435
  const preaggParts = {
319
436
  selectDef: castSelect,
320
437
  nonAggregateGroupBy: preaggGroupBy,
321
438
  hasAggregates: true,
322
439
  groupKeys: preaggGroupKeys,
440
+ // The table keeps the bin channels as columns of the same bins, so the
441
+ // read pads exactly as the base read does, or is padded already.
442
+ bins: parts.bins,
443
+ padded: !!preagg.creation.pad,
323
444
  hasWindowAggregates: false,
324
445
  hasSQLTransforms: parts.hasSQLTransforms,
325
446
  orderChannels: parts.orderChannels,
326
447
  aliasMap: parts.aliasMap,
327
- passthroughChannels: parts.passthroughChannels
448
+ passthroughChannels: parts.passthroughChannels,
449
+ // nulls="drop", read through this select: each dropped channel is a group
450
+ // key, so the table keeps NULL as its own group, and dropping that group
451
+ // here removes exactly what the base scan would have left out.
452
+ nullDrops: parts.nullDrops
328
453
  };
329
454
  return assembleQuery({
330
455
  table: preagg.table,
331
456
  parts: preaggParts,
332
457
  filter: filters,
333
458
  transformCompiler,
459
+ // The table was built with them.
460
+ skipRowFilters: true,
334
461
  limit: params.limit,
335
462
  window: windowChannels ? { channels: windowChannels, cteName: '__preagg_cte' } : null
336
463
  }).toString();
337
464
  }
338
465
  /** A preagg table's SELECT. Build and validity check share it, so equal SQL means an equal table. */
339
466
  export function preaggSelectSQL(creation, filters) {
467
+ const pad = creation.pad;
468
+ if (!pad)
469
+ return realRowsSQL(creation, filters, true);
470
+ // By name: the padding rows name only what they set, and the brush and
471
+ // value columns they leave out come back NULL. Ordered once, outside.
472
+ const quote = (name) => `"${name.replaceAll('"', '""')}"`;
473
+ const padRows = `SELECT ${pad.dims.map((col) => `p.${quote(col)}`).join(', ')}` +
474
+ pad.partials.map((col) => `, 0 AS ${quote(col)}`).join('') +
475
+ `, true AS ${quote(PAD_COLUMN)} FROM (${pad.rows}) AS p`;
476
+ const order = creation.orderby.length ? ` ORDER BY ${creation.orderby.join(', ')}` : '';
477
+ return (`SELECT * FROM (SELECT *, false AS ${quote(PAD_COLUMN)} FROM (${realRowsSQL(creation, filters, false)}) AS r` +
478
+ ` UNION ALL BY NAME ${padRows})${order}`);
479
+ }
480
+ /** The table's real rows: the mark's groups over the source rows. */
481
+ function realRowsSQL(creation, filters, ordered) {
340
482
  const query = Query.from(creation.table).select(creation.select).groupby(creation.groupby);
341
- if (filters.length > 0)
342
- query.where(and(filters));
343
- if (creation.joins.length === 0)
344
- return query.orderby(creation.orderby).toString();
483
+ const where = [...creation.where, ...filters];
484
+ if (where.length > 0)
485
+ query.where(and(where));
486
+ if (creation.joins.length === 0) {
487
+ return (ordered ? query.orderby(creation.orderby) : query).toString();
488
+ }
345
489
  // Map fields grouped by value to their ids on the grouped result: a join
346
490
  // over the table's groups, not over every source row. The filters stay in
347
491
  // the inner query, where the source's columns are in scope.
@@ -353,7 +497,7 @@ export function preaggSelectSQL(creation, filters) {
353
497
  const joins = creation.joins
354
498
  .map((j, k) => `LEFT JOIN ${quote(j.dim)} AS d${k} ON p.${quote(j.raw)} = d${k}.value`)
355
499
  .join(' ');
356
- const order = creation.orderby.length ? ` ORDER BY ${creation.orderby.join(', ')}` : '';
500
+ const order = ordered && creation.orderby.length ? ` ORDER BY ${creation.orderby.join(', ')}` : '';
357
501
  return `SELECT p.* EXCLUDE (${raws}), ${ids} FROM (${query}) AS p ${joins}${order}`;
358
502
  }
359
503
  /**