@fjader/svelte 0.2.4 → 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 (139) hide show
  1. package/README.md +1 -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 +4 -2
  9. package/dist/components/axes/AxisCenter.svelte +4 -2
  10. package/dist/components/axes/AxisLeft.svelte +4 -2
  11. package/dist/components/axes/BandAxisTicks.svelte +3 -2
  12. package/dist/components/charts/ChartCell.svelte +32 -5
  13. package/dist/components/charts/ChartCell.svelte.d.ts +9 -0
  14. package/dist/components/charts/ChartContainer.svelte +45 -3
  15. package/dist/components/charts/ChartContainer.svelte.d.ts +7 -1
  16. package/dist/components/charts/ChartHover.svelte.d.ts +14 -0
  17. package/dist/components/charts/ChartHover.svelte.js +22 -0
  18. package/dist/components/charts/ChartVisibility.svelte.d.ts +43 -0
  19. package/dist/components/charts/ChartVisibility.svelte.js +67 -0
  20. package/dist/components/guides/Grid.svelte +12 -1
  21. package/dist/components/marks/AreaMarkX.svelte +16 -4
  22. package/dist/components/marks/AreaMarkY.svelte +16 -4
  23. package/dist/components/marks/BarMarkX.svelte +7 -3
  24. package/dist/components/marks/BarMarkY.svelte +7 -3
  25. package/dist/components/marks/DotsMark.svelte +6 -3
  26. package/dist/components/marks/LineMark.svelte +11 -8
  27. package/dist/components/marks/ParallelCoordinatesMark.svelte +7 -2
  28. package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +2 -0
  29. package/dist/components/marks/RectMark.svelte +6 -3
  30. package/dist/components/marks/RectXMark.svelte +7 -3
  31. package/dist/components/marks/RectYMark.svelte +7 -3
  32. package/dist/components/marks/RuleMarkX.svelte +30 -26
  33. package/dist/components/marks/RuleMarkY.svelte +30 -26
  34. package/dist/components/marks/TextMark.svelte +44 -5
  35. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +16 -0
  36. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +398 -54
  37. package/dist/components/marks/lifecycle/domain-queries.d.ts +11 -2
  38. package/dist/components/marks/lifecycle/domain-queries.js +33 -6
  39. package/dist/components/marks/lifecycle/domain-registration.d.ts +6 -1
  40. package/dist/components/marks/lifecycle/domain-registration.js +15 -14
  41. package/dist/components/marks/lifecycle/other-rows.svelte.d.ts +9 -1
  42. package/dist/components/marks/lifecycle/other-rows.svelte.js +2 -2
  43. package/dist/components/marks/lifecycle/query-builder.d.ts +27 -2
  44. package/dist/components/marks/lifecycle/query-builder.js +23 -20
  45. package/dist/components/marks/utils/MarkColor.svelte.d.ts +3 -1
  46. package/dist/components/marks/utils/MarkColor.svelte.js +33 -3
  47. package/dist/components/marks/utils/MarkSeries.svelte.d.ts +14 -1
  48. package/dist/components/marks/utils/MarkSeries.svelte.js +25 -1
  49. package/dist/components/marks/utils/MarkSize.svelte.js +4 -0
  50. package/dist/components/marks/utils/reactive-config.d.ts +1 -1
  51. package/dist/components/marks/utils/reactive-config.js +3 -0
  52. package/dist/components/rendering/Areas.svelte +57 -19
  53. package/dist/components/rendering/Areas.svelte.d.ts +12 -0
  54. package/dist/components/rendering/BaseGPURenderer.svelte.d.ts +1 -1
  55. package/dist/components/rendering/Dots.svelte +23 -3
  56. package/dist/components/rendering/Dots.svelte.d.ts +6 -0
  57. package/dist/components/rendering/GridLine.svelte +13 -1
  58. package/dist/components/rendering/GridLine.svelte.d.ts +5 -0
  59. package/dist/components/rendering/Lines.svelte +48 -13
  60. package/dist/components/rendering/Lines.svelte.d.ts +12 -0
  61. package/dist/components/rendering/ParallelLines.svelte +5 -5
  62. package/dist/components/rendering/Rects.svelte +51 -11
  63. package/dist/components/rendering/Rects.svelte.d.ts +16 -2
  64. package/dist/components/rendering/RuleX.svelte +1 -1
  65. package/dist/components/rendering/RuleY.svelte +1 -1
  66. package/dist/components/rendering/WebGPUCanvas.svelte +4 -2
  67. package/dist/components/rendering/activity-reads.d.ts +41 -24
  68. package/dist/components/rendering/activity-reads.js +54 -31
  69. package/dist/components/rendering/uniform-sizes.d.ts +1 -1
  70. package/dist/components/rendering/uniform-sizes.js +1 -1
  71. package/dist/config/ChartConfig.svelte.d.ts +7 -0
  72. package/dist/config/ChartConfig.svelte.js +17 -0
  73. package/dist/gpu/GPUCoordinator.svelte.d.ts +31 -1
  74. package/dist/gpu/GPUCoordinator.svelte.js +164 -7
  75. package/dist/gpu/MorphedChannels.d.ts +11 -2
  76. package/dist/gpu/MorphedChannels.js +33 -11
  77. package/dist/gpu/ScaleTemplateSystem.d.ts +20 -2
  78. package/dist/gpu/ScaleTemplateSystem.js +58 -2
  79. package/dist/index.d.ts +17 -4
  80. package/dist/index.js +19 -4
  81. package/dist/interactors/TooltipManager.svelte.js +14 -5
  82. package/dist/query-engine/CrossfilterSelection.svelte.d.ts +4 -2
  83. package/dist/query-engine/CrossfilterSelection.svelte.js +2 -0
  84. package/dist/query-engine/FieldActivity.svelte.d.ts +2 -3
  85. package/dist/query-engine/FieldActivity.svelte.js +1 -2
  86. package/dist/query-engine/MarkHandle.svelte.d.ts +28 -1
  87. package/dist/query-engine/MarkHandle.svelte.js +33 -1
  88. package/dist/query-engine/QueryEngine.d.ts +225 -5
  89. package/dist/query-engine/QueryEngine.js +671 -41
  90. package/dist/query-engine/categorical-identity.d.ts +34 -8
  91. package/dist/query-engine/categorical-identity.js +138 -73
  92. package/dist/query-engine/debug/query-log.d.ts +39 -0
  93. package/dist/query-engine/debug/query-log.js +90 -0
  94. package/dist/query-engine/duckdb.d.ts +55 -8
  95. package/dist/query-engine/duckdb.js +82 -30
  96. package/dist/query-engine/index.d.ts +7 -4
  97. package/dist/query-engine/index.js +3 -3
  98. package/dist/query-engine/preagg/BuildQueue.d.ts +33 -13
  99. package/dist/query-engine/preagg/BuildQueue.js +51 -15
  100. package/dist/query-engine/preagg/Preagg.d.ts +46 -3
  101. package/dist/query-engine/preagg/Preagg.js +159 -31
  102. package/dist/query-engine/preagg/plan.d.ts +28 -1
  103. package/dist/query-engine/preagg/plan.js +128 -8
  104. package/dist/query-engine/sliced-lane.d.ts +108 -0
  105. package/dist/query-engine/sliced-lane.js +299 -0
  106. package/dist/query-engine/source-routing.d.ts +106 -0
  107. package/dist/query-engine/source-routing.js +189 -0
  108. package/dist/scales/BandScaleState.svelte.d.ts +9 -1
  109. package/dist/scales/BandScaleState.svelte.js +16 -5
  110. package/dist/scales/ChartScales.svelte.d.ts +3 -0
  111. package/dist/scales/ChartScales.svelte.js +26 -8
  112. package/dist/scales/ColorScaleState.svelte.d.ts +18 -2
  113. package/dist/scales/ColorScaleState.svelte.js +138 -26
  114. package/dist/scales/LinearScaleState.svelte.d.ts +7 -10
  115. package/dist/scales/LinearScaleState.svelte.js +21 -39
  116. package/dist/scales/ScaleGeneration.svelte.d.ts +48 -0
  117. package/dist/scales/ScaleGeneration.svelte.js +42 -0
  118. package/dist/scales/ScaleRegistry.svelte.js +3 -1
  119. package/dist/scales/SlotTransition.d.ts +15 -1
  120. package/dist/scales/SlotTransition.js +33 -4
  121. package/dist/scales/TimeScaleState.svelte.d.ts +7 -3
  122. package/dist/scales/TimeScaleState.svelte.js +7 -7
  123. package/dist/scales/color-utils.d.ts +0 -3
  124. package/dist/scales/color-utils.js +0 -3
  125. package/dist/scales/entered.d.ts +8 -0
  126. package/dist/scales/entered.js +10 -0
  127. package/dist/spec/LazyQueryEngine.js +2 -19
  128. package/dist/spec/MarkRegistry.d.ts +3 -1
  129. package/dist/spec/MarkRegistry.js +13 -1
  130. package/dist/spec/PlotFromSpec.svelte +1 -0
  131. package/dist/spec/export.js +11 -2
  132. package/dist/spec/types.d.ts +41 -13
  133. package/dist/sql/bin.d.ts +25 -0
  134. package/dist/sql/bin.js +108 -5
  135. package/dist/transforms/TransformCompiler.d.ts +10 -0
  136. package/dist/transforms/TransformCompiler.js +26 -0
  137. package/dist/transforms/index.js +144 -24
  138. package/dist/types.d.ts +136 -2
  139. package/package.json +2 -2
@@ -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) {
@@ -85,7 +85,22 @@ export interface PreaggCreation {
85
85
  alias: string;
86
86
  dim: string;
87
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;
88
101
  }
102
+ /** The column marking a preagg table's padding rows; real rows hold false. */
103
+ export declare const PAD_COLUMN = "__pad";
89
104
  /**
90
105
  * How each active dim appears in a preagg table. A `bin` dim is a pixel-bin
91
106
  * index over a window (`buildBinExpr`): bins 0 .. bins - 1 from d0, with -1
@@ -112,6 +127,12 @@ export type ActiveMeta = {
112
127
  export interface GroupKey {
113
128
  column: string | null;
114
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>;
115
136
  }
116
137
  export interface PreaggState {
117
138
  table: string;
@@ -170,7 +191,13 @@ export declare function markKeys(descriptor: QueryDescriptor, handle: Pick<MarkH
170
191
  * mark with x and fill on the same field has that field's groups, not their
171
192
  * square, and a bin dim on a column the mark already groups by adds its bins
172
193
  * to that key's (each pixel bin falls in about one of the mark's bins)
173
- * 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).
174
201
  */
175
202
  export declare function estimateGroups(markKeys: GroupKey[], activeKeys: GroupKey[]): number | null;
176
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';
14
+ import { isAggregateExpression, isNode, collectColumns, column, and, isBetween, literal, Query, collectAggregates, sql as sqlTag } from '@uwdata/mosaic-sql';
15
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
@@ -104,6 +106,7 @@ export function planPreagg(descriptor, dims, identity) {
104
106
  activeMeta.push({ kind: 'value', field, column: alias, byId: false });
105
107
  activeKeys.push({ column: null, groups: null });
106
108
  });
109
+ const pad = analysis.windowChannels ? null : padPlan(descriptor, preaggCols);
107
110
  // CAST-wrap dimension preaggCols (bake CAST into the preagg table).
108
111
  // Partial-aggregate and active columns stay raw for recombination.
109
112
  for (const [ch, expr] of Object.entries(preaggCols)) {
@@ -120,8 +123,9 @@ export function planPreagg(descriptor, dims, identity) {
120
123
  groupby: [...preaggGroupBy, ...activeGroupBy],
121
124
  // Sorted by bin columns for fast range scans
122
125
  orderby: binOrder,
123
- where: descriptor.transformCompiler.rowFilters(),
124
- joins
126
+ where: [...descriptor.transformCompiler.rowFilters(), ...(pad?.where ?? [])],
127
+ joins,
128
+ pad: pad?.pad ?? null
125
129
  },
126
130
  outputSelect,
127
131
  activeMeta,
@@ -129,6 +133,46 @@ export function planPreagg(descriptor, dims, identity) {
129
133
  activeKeys
130
134
  };
131
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
+ }
132
176
  /** The mark's channel that is `field`'s id channel (see idFieldOf), or null. */
133
177
  function idChannelFor(selectDef, field, identity) {
134
178
  for (const [channel, expr] of Object.entries(selectDef)) {
@@ -180,6 +224,14 @@ export function markKeys(descriptor, handle, identity) {
180
224
  else
181
225
  fields.add(field);
182
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
+ }
183
235
  if (allIds) {
184
236
  return [...fields].map((field) => ({
185
237
  column: null,
@@ -187,7 +239,35 @@ export function markKeys(descriptor, handle, identity) {
187
239
  }));
188
240
  }
189
241
  const rows = handle.params.limit == null ? handle.arrowTable?.numRows : undefined;
190
- 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;
191
271
  }
192
272
  /**
193
273
  * The groups a planned table would hold: the mark's own keys (markKeys) and
@@ -196,7 +276,13 @@ export function markKeys(descriptor, handle, identity) {
196
276
  * mark with x and fill on the same field has that field's groups, not their
197
277
  * square, and a bin dim on a column the mark already groups by adds its bins
198
278
  * to that key's (each pixel bin falls in about one of the mark's bins)
199
- * 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).
200
286
  */
201
287
  export function estimateGroups(markKeys, activeKeys) {
202
288
  if (activeKeys.some((key) => key.groups == null))
@@ -204,6 +290,15 @@ export function estimateGroups(markKeys, activeKeys) {
204
290
  const merged = new Set();
205
291
  let groups = 1;
206
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
+ }
207
302
  const same = key.column == null
208
303
  ? undefined
209
304
  : activeKeys.find((active) => active.column === key.column && !merged.has(active));
@@ -332,11 +427,20 @@ export function preaggQuerySQL(route, descriptor, selection, preagg, params, ide
332
427
  castSelect[ch] = castReal(expr);
333
428
  }
334
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
+ }
335
435
  const preaggParts = {
336
436
  selectDef: castSelect,
337
437
  nonAggregateGroupBy: preaggGroupBy,
338
438
  hasAggregates: true,
339
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,
340
444
  hasWindowAggregates: false,
341
445
  hasSQLTransforms: parts.hasSQLTransforms,
342
446
  orderChannels: parts.orderChannels,
@@ -360,12 +464,28 @@ export function preaggQuerySQL(route, descriptor, selection, preagg, params, ide
360
464
  }
361
465
  /** A preagg table's SELECT. Build and validity check share it, so equal SQL means an equal table. */
362
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) {
363
482
  const query = Query.from(creation.table).select(creation.select).groupby(creation.groupby);
364
483
  const where = [...creation.where, ...filters];
365
484
  if (where.length > 0)
366
485
  query.where(and(where));
367
- if (creation.joins.length === 0)
368
- return query.orderby(creation.orderby).toString();
486
+ if (creation.joins.length === 0) {
487
+ return (ordered ? query.orderby(creation.orderby) : query).toString();
488
+ }
369
489
  // Map fields grouped by value to their ids on the grouped result: a join
370
490
  // over the table's groups, not over every source row. The filters stay in
371
491
  // the inner query, where the source's columns are in scope.
@@ -377,7 +497,7 @@ export function preaggSelectSQL(creation, filters) {
377
497
  const joins = creation.joins
378
498
  .map((j, k) => `LEFT JOIN ${quote(j.dim)} AS d${k} ON p.${quote(j.raw)} = d${k}.value`)
379
499
  .join(' ');
380
- const order = creation.orderby.length ? ` ORDER BY ${creation.orderby.join(', ')}` : '';
500
+ const order = ordered && creation.orderby.length ? ` ORDER BY ${creation.orderby.join(', ')}` : '';
381
501
  return `SELECT p.* EXCLUDE (${raws}), ${ids} FROM (${query}) AS p ${joins}${order}`;
382
502
  }
383
503
  /**
@@ -0,0 +1,108 @@
1
+ /**
2
+ * A sliced lane: one DuckDB-WASM connection whose statements run through the
3
+ * pending-query API, so the worker executes them in slices and serves other
4
+ * connections' messages in between. A slow read on a lane no longer holds up a
5
+ * quick one on another connection.
6
+ *
7
+ * ONE STATEMENT AT A TIME, enforced here. A connection holds one pending query,
8
+ * and a second one, or a `runQuery`, on the same connection silently resets the
9
+ * first (measured in Phase 0). So the lane queues: first in, first out.
10
+ *
11
+ * ABORT (an AbortSignal per statement) has three cases:
12
+ * - queued: dropped, rejected with AbortError, never sent;
13
+ * - running, before the result header: `cancelPendingQuery`, rejected with
14
+ * AbortError. Only an abort we asked for becomes AbortError; any other error
15
+ * passes through unchanged;
16
+ * - after the header, fetching chunks: the query has finished executing, so
17
+ * there is nothing to cancel. Fetching stops and the statement rejects; the
18
+ * next `startPendingQuery` resets the leftover result.
19
+ * A cancel that loses the race (the query finished as it arrived, so the next
20
+ * poll returns the header) still rejects with AbortError: the caller sees one
21
+ * outcome for one abort.
22
+ *
23
+ * SLICE LENGTH is the database's `queryPollingInterval`, set when it is opened
24
+ * (`initDuckDB` uses 5 ms). At the default, ~100 ms, a quick statement on
25
+ * another connection can wait ~50 ms for a slice to end. It cannot be read
26
+ * back. When the opener knows it (`connectionFrom`'s `pollingIntervalMs`),
27
+ * the lane trusts that; otherwise it measures, and warns once per page when
28
+ * the MEDIAN slice over its first sliced statements runs long. A median, not
29
+ * one reading: each poll waits for a main-thread turn, and during load a busy
30
+ * main thread stretches round trips past the threshold with short slices.
31
+ */
32
+ /** The DuckDB-WASM bindings a lane drives; `AsyncDuckDB` has them. */
33
+ export interface PendingQueryBindings {
34
+ startPendingQuery(conn: number, text: string, allowStreamResult?: boolean): Promise<Uint8Array | null>;
35
+ pollPendingQuery(conn: number): Promise<Uint8Array | null>;
36
+ cancelPendingQuery(conn: number): Promise<boolean>;
37
+ fetchQueryResults(conn: number): Promise<Uint8Array | null>;
38
+ }
39
+ /** A sliced query's result, and what it cost in the worker. */
40
+ export interface LaneResult {
41
+ /** The Arrow IPC stream, `[header, ...record batches]`. */
42
+ ipc: Uint8Array[];
43
+ /** Slices it took past the first: 0 or 1, a quick read. */
44
+ polls: number;
45
+ /** From start to its result header: execution, without the chunked fetch. */
46
+ execMs: number;
47
+ }
48
+ export interface SlicedLane {
49
+ /**
50
+ * Run a query sliced. `ipc` is the Arrow IPC stream as the worker returned
51
+ * it, `[header, ...record batches]`, for flechette's `tableFromIPC` (each
52
+ * buffer holds whole messages). `polls`: how many slices it took past the
53
+ * first, the cost signal (0 or 1: a quick read).
54
+ */
55
+ query(sql: string, signal?: AbortSignal): Promise<LaneResult>;
56
+ /**
57
+ * Run a statement sliced, discarding any result. The result is left
58
+ * pending on the connection, unfetched, until the next statement resets
59
+ * it (as after an abort past the header): the connection is NOT idle
60
+ * between statements, so never send a `runQuery` on it from outside.
61
+ */
62
+ exec(sql: string, signal?: AbortSignal): Promise<{
63
+ polls: number;
64
+ }>;
65
+ /**
66
+ * Reject what is queued (AbortError), cancel what runs, then close the
67
+ * connection. Statements sent after it reject with AbortError.
68
+ */
69
+ close(): Promise<void>;
70
+ }
71
+ /**
72
+ * Asked before each call that executes (start, poll): a promise that
73
+ * resolves when this lane may run its next slice, or null to run it now. The
74
+ * engine decides, across all its lanes (see QueryEngine #sliceGrant): freely
75
+ * while its main connection is quiet, a share of the worker while it works.
76
+ */
77
+ export type LaneHold = () => Promise<void> | null;
78
+ export declare class PendingQueryLane implements SlicedLane {
79
+ #private;
80
+ constructor(bindings: PendingQueryBindings, conn: number, closeConnection: () => Promise<void>,
81
+ /** The database's polling interval, when the opener knows it. */
82
+ options?: {
83
+ pollingIntervalMs?: number;
84
+ hold?: LaneHold;
85
+ });
86
+ query(sql: string, signal?: AbortSignal): Promise<LaneResult>;
87
+ exec(sql: string, signal?: AbortSignal): Promise<{
88
+ polls: number;
89
+ }>;
90
+ close(): Promise<void>;
91
+ }
92
+ /** For tests: the once-per-page slice warning, rearmed. */
93
+ export declare function _resetSliceWarning(): void;
94
+ /**
95
+ * One lane, opened on first use. `get()` answers in the same tick once the
96
+ * lane's state is known (open, or none to be had), and returns a promise only
97
+ * while it opens: a statement sent while there is no lane goes out with no
98
+ * extra microtask, so nothing sent after it in the same tick can overtake it.
99
+ */
100
+ export declare class LaneSlot {
101
+ #private;
102
+ /** `open` returns null when no lane can be had (no capability). */
103
+ constructor(open: () => Promise<SlicedLane> | null, onFail: (e: unknown) => void);
104
+ get isOpen(): boolean;
105
+ get(): SlicedLane | null | Promise<SlicedLane | null>;
106
+ /** Close the lane if it was opened (or is opening); none can open after. */
107
+ close(): Promise<void>;
108
+ }