@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
@@ -29,7 +29,7 @@ import { isBinExpression } from '../../../sql/bin';
29
29
  import { isRollingAvgExpression } from '../../../sql/rolling-avg';
30
30
  import { isWindowExpression } from '../../../sql/expression-utils';
31
31
  import { identityKeyFor } from '../../../query-engine/identity-key';
32
- import { activityReads, groupIndexForResponse, NO_READS, readsFor, readsForResponse, rendererForMark } from '../../rendering/activity-reads';
32
+ import { activityReads, dimsBySelection, groupIndexForResponse, NO_READS, readsFor, readsForResponse, rendererForMark } from '../../rendering/activity-reads';
33
33
  import { AdaptiveBinRung } from '../../../lod/AdaptiveBinRung.svelte';
34
34
  /** A SQL identifier, quoted, with any double quote in it doubled. */
35
35
  function ident(name) {
@@ -42,6 +42,7 @@ import { buildQueryParts, assembleQuery, castReal } from './query-builder';
42
42
  import { setScalesReady, markDeferredScalesReady, setInitialContinuousDomains, registerDataDerivedDomains } from './domain-registration';
43
43
  import { fetchCategoricalDomains, fetchGlobalAggregates } from './domain-queries';
44
44
  import { transformScaleKey } from './transform-scale-key';
45
+ import { OtherRows } from './other-rows.svelte';
45
46
  // ---------------------------------------------------------------------------
46
47
  // MarkLifecycleV2
47
48
  // ---------------------------------------------------------------------------
@@ -142,13 +143,10 @@ export class MarkLifecycleV2 {
142
143
  // Resolve AutoBinNodes — reads #fieldStats ($state), #lastBinDomain ($state),
143
144
  // binStepMultiplier (reactive on chartContext)
144
145
  if (this.#autoBinNodes.size > 0 && this.#fieldStats) {
145
- const binStepMultiplier = this.#chartContext.binStepMultiplier || 1.0;
146
146
  for (const [ch, value] of Object.entries(resolvedSelect)) {
147
147
  if (isBinExpression(value)) {
148
- const axis = ch.startsWith('x') ? 'x' : 'y';
149
- const scaleType = axis === 'x' ? this.actualXScaleType : this.actualYScaleType;
150
- const currentDomain = this.#lastBinDomain[axis] ?? null;
151
- resolvedSelect[ch] = value.resolve(this.#fieldStats, scaleType, null, currentDomain, binStepMultiplier);
148
+ const { fieldInfo, scaleType, currentDomain, multiplier } = this.#binArgs(ch);
149
+ resolvedSelect[ch] = value.resolve(fieldInfo, scaleType, null, currentDomain, multiplier);
152
150
  }
153
151
  }
154
152
  }
@@ -185,8 +183,100 @@ export class MarkLifecycleV2 {
185
183
  this.#inlineIdentityColumns(resolvedSelect);
186
184
  this.#inlineTimeOffsets(resolvedSelect);
187
185
  const parts = buildQueryParts(resolvedSelect, this.#config._orderConfig ?? null, this.#transformCompiler.hasSQLTransforms(), passthroughChannels);
186
+ const nullDrops = this.#nullDrops(semanticSelect, resolvedSelect);
187
+ if (nullDrops.length > 0)
188
+ parts.nullDrops = nullDrops;
189
+ parts.bins = this.#binPadding();
188
190
  return parts;
189
191
  });
192
+ /**
193
+ * What a bin channel resolves with: the field's stats, the axis's scale
194
+ * type and last re-bin domain, and the chart's step multiplier. One place,
195
+ * so the bin expression and its padding cannot be resolved differently.
196
+ * Reactive reads, from the deriveds that call it.
197
+ */
198
+ #binArgs(channel) {
199
+ const axis = channel.startsWith('x') ? 'x' : 'y';
200
+ return {
201
+ axis,
202
+ fieldInfo: this.#fieldStats,
203
+ scaleType: axis === 'x' ? this.actualXScaleType : this.actualYScaleType,
204
+ currentDomain: this.#lastBinDomain[axis] ?? null,
205
+ multiplier: this.#chartContext.binStepMultiplier || 1.0
206
+ };
207
+ }
208
+ /** The bins of each numeric bin channel, for dense() to pad from; null without any. */
209
+ #binPadding() {
210
+ if (this.#autoBinNodes.size === 0 || !this.#fieldStats)
211
+ return null;
212
+ let out = null;
213
+ for (const [ch, value] of Object.entries(this.#config.select)) {
214
+ if (!isBinExpression(value))
215
+ continue;
216
+ const { axis, fieldInfo, scaleType, currentDomain, multiplier } = this.#binArgs(ch);
217
+ const padding = value.padding(fieldInfo, scaleType, currentDomain, multiplier, this.#timeOrigin(axis));
218
+ if (padding)
219
+ (out ??= {})[ch] = padding;
220
+ }
221
+ return out;
222
+ }
223
+ /**
224
+ * The mark's categorical channels, each with the source expression it draws:
225
+ * a band or point axis, an ordinal colour, a separate series, and the facet
226
+ * (`__facet`, from the chart's fx). What `nulls="drop"` filters.
227
+ */
228
+ #categoricalChannels(select) {
229
+ const out = [];
230
+ const categorical = (type) => type === 'band' || type === 'point';
231
+ if (categorical(this.actualXScaleType))
232
+ out.push(['x', select.x]);
233
+ if (categorical(this.actualYScaleType))
234
+ out.push(['y', select.y]);
235
+ const colorChannel = this.#config.colorChannel || 'fill';
236
+ if (this.#colorScaleState?.isOrdinal)
237
+ out.push([colorChannel, select[colorChannel]]);
238
+ const seriesChannel = this.#config._orderConfig?.seriesChannel ?? null;
239
+ if (seriesChannel && seriesChannel !== colorChannel) {
240
+ out.push([seriesChannel, select[seriesChannel]]);
241
+ }
242
+ if (this.#chartContext.fx)
243
+ out.push(['__facet', this.#chartContext.fx]);
244
+ // Only row-level values: an aggregate has no NULL category to drop.
245
+ return out.filter(([, source]) => source != null &&
246
+ typeof source !== 'number' &&
247
+ !(isNode(source) && isAggregateExpression(source)));
248
+ }
249
+ /**
250
+ * `nulls="drop"`, by channel (see QueryParts.nullDrops). A channel the query
251
+ * selects as its dim's id column drops the NULL category's id; one still
252
+ * carrying its raw value (identity not landed yet) drops SQL NULL. Read off
253
+ * the select itself, so the two cannot disagree: inlining an id column is
254
+ * what changes the channel's expression.
255
+ */
256
+ #nullDrops(semantic, resolved) {
257
+ if (this.#reactiveConfig.nulls !== 'drop')
258
+ return [];
259
+ const drops = [];
260
+ for (const [channel, source] of this.#categoricalChannels(semantic)) {
261
+ const encoded = channel === '__facet' ? '__facet' in resolved : resolved[channel] !== source;
262
+ if (channel === '__facet' && !encoded)
263
+ continue;
264
+ const key = encoded ? identityKeyFor(source)?.key : null;
265
+ const nullId = key ? this.#queryEngine.identityFor(key).nullId : null;
266
+ drops.push({ channel, nullId });
267
+ }
268
+ return drops;
269
+ }
270
+ /**
271
+ * `nulls="drop"` for a query over the RAW select (no id columns, no
272
+ * `__facet`): `<source> IS NOT NULL` per categorical channel, the facet's
273
+ * on the chart's fx. Passed as that query's filter.
274
+ */
275
+ #rawNullFilters(select) {
276
+ if (this.#reactiveConfig.nulls !== 'drop')
277
+ return [];
278
+ return this.#categoricalChannels(select).map(([, source]) => isNotNull((typeof source === 'string' ? sqlColumn(source) : source)));
279
+ }
190
280
  // --- Domain State ---
191
281
  #channelsNeedingDataDomain = new Set();
192
282
  #dataDerivedDomainsSet = $state(false);
@@ -216,9 +306,27 @@ export class MarkLifecycleV2 {
216
306
  // --- Tooltip ---
217
307
  #tooltipTableName;
218
308
  #tooltipTableReady = $state(false);
309
+ /**
310
+ * True once a tooltip table write has started, so there may be a table to
311
+ * drop. Without it every lifecycle torn down before its first data (a mark
312
+ * that unmounts early, a rebuild) sent DuckDB a DROP for a table that never
313
+ * existed, one more statement in the single worker's queue during load.
314
+ */
315
+ #tooltipTableWritten = false;
219
316
  #tooltipWriteTimeout = null;
220
317
  // --- fitDomain ---
221
318
  #fitDomainTimeout = null;
319
+ /**
320
+ * The data version whose fit snaps instead of springing: the version the
321
+ * chart came back into view with, or its catch-up result's. A spring means
322
+ * the user just caused the change; a return to view is not that. Tied to a
323
+ * version rather than a flag waiting for the next fit: a fit issued for any
324
+ * other reason (a driving-axis zoom, later data) or skipped clears it, and
325
+ * a superseded fit for the same version keeps it.
326
+ */
327
+ #snapFitVersion = null;
328
+ /** Debounces a refit after a data change (#refitFromData). */
329
+ #refitTimeout = null;
222
330
  #fitDomainLastTarget = [0, 0];
223
331
  #fitDomainQuerySequence = 0;
224
332
  #fitDomainSuppressed = false;
@@ -231,6 +339,14 @@ export class MarkLifecycleV2 {
231
339
  this.#chartContext = chartContext;
232
340
  this.#markId = markId;
233
341
  this.#filterGroup = filterGroup;
342
+ // The chart's entrance waits for this mark's first data, unless the
343
+ // author has said not to (a mark known to be slow). Held by mark id, so
344
+ // a lifecycle rebuilt for the same mark holds the same hold. Released on
345
+ // first data, on failure, or when the mark unmounts.
346
+ this.#entranceGate = chartContext.config?.entrance ?? null;
347
+ if (config.table && untrack(() => reactiveConfig.holdsEntrance) !== false) {
348
+ this.#entranceGate?.hold(markId);
349
+ }
234
350
  // Tooltip table name
235
351
  this.#tooltipTableName = `_tt_${this.#markId.replace(/-/g, '_')}`;
236
352
  // Transform compiler
@@ -257,6 +373,26 @@ export class MarkLifecycleV2 {
257
373
  // than rebuilding. With no sort it passes the query through.
258
374
  this.#transformCompiler = new TransformCompiler(withRanking(withDenseCategories(config.transforms || [], config.dense !== false), () => this.#rankingSort), this.#transformConfig);
259
375
  this.#detectAutoBins();
376
+ this.otherRows = new OtherRows({
377
+ queryEngine,
378
+ filterGroup,
379
+ markId,
380
+ table: () => this.#handle?.arrowTable ?? null,
381
+ dataVersion: () => this.dataVersion,
382
+ identity: () => this.#identity,
383
+ identityKey: (axis) => this.#identityKey(axis)?.key ?? null,
384
+ filtered: () => this.#reactiveConfig.selectionResponse === 'filter',
385
+ dims: () => dimsBySelection(this.#reactiveConfig.selectionResponse),
386
+ plan: () => this.#splitPlan,
387
+ colorKey: () => {
388
+ const color = this.#colorScaleState;
389
+ return color?.isOrdinal ? color.identityKey : null;
390
+ },
391
+ bandScaleKey: (axis) => {
392
+ const band = (axis === 'x' ? this.#chartContext.xScaleState : this.#chartContext.yScaleState);
393
+ return band?.identityKey ?? null;
394
+ }
395
+ });
260
396
  // AdaptiveBinRung for binned marks (watches viewport domain, triggers rebin)
261
397
  if (this.#autoBinNodes.size > 0 && options?.gpuCoordinator) {
262
398
  const binAxes = [...this.#autoBinNodes.keys()].map((ch) => (ch.startsWith('x') ? 'x' : 'y'));
@@ -342,7 +478,8 @@ export class MarkLifecycleV2 {
342
478
  return;
343
479
  this.#identityFields.clear();
344
480
  // The Other ids name rows of the dropped dims. Every holder lets go, so
345
- // the scale acquires afresh when setup re-runs.
481
+ // the scale acquires afresh when setup re-runs; so do the registered rows.
482
+ this.otherRows.unregister();
346
483
  for (const scale of [...this.#otherScales])
347
484
  this.#releaseOther(scale);
348
485
  // Every dim it names just went away — topN must not bucket into one.
@@ -422,7 +559,20 @@ export class MarkLifecycleV2 {
422
559
  const scalesReady = (!this.#config.select?.[colorKey] || !!this.#colorScaleState) &&
423
560
  (!seriesKey || seriesKey === colorKey || !!this.#seriesScaleState);
424
561
  untrack(() => {
425
- this.#runSetup(scalesReady);
562
+ // A run already in flight against these same scales would produce
563
+ // the same result: don't send its queries again. A different scale
564
+ // starts a new run, and the generation guard retires the old one.
565
+ const inputs = [
566
+ xScaleState,
567
+ yScaleState,
568
+ this.#colorScaleState,
569
+ this.#seriesScaleState,
570
+ scaleTypes.x,
571
+ scaleTypes.y
572
+ ];
573
+ if (scalesReady && this.#setupInFlight && sameInputs(this.#setupInFlight, inputs))
574
+ return;
575
+ this.#runSetup(scalesReady, inputs);
426
576
  });
427
577
  });
428
578
  // Registration: watches #resolvedQueryParts, registers with engine.
@@ -453,10 +603,37 @@ export class MarkLifecycleV2 {
453
603
  parts,
454
604
  schema,
455
605
  transformCompiler: this.#transformCompiler,
456
- route: this.#route
606
+ route: this.#route,
607
+ // holdsEntrance={false}: the author knows this mark is slow.
608
+ slow: this.#reactiveConfig.holdsEntrance === false
457
609
  }, this.#filterGroup);
458
610
  });
459
611
  });
612
+ // Off screen, the engine stops re-querying this mark (after its first
613
+ // result) and queries it once when it is back; see MarkHandle.active.
614
+ //
615
+ // The one owner of a return to view. A fitted axis may have moved
616
+ // meanwhile (a shared axis zoomed from another chart), and the fit
617
+ // effects do not run off screen. If the mark came back stale, its
618
+ // catch-up result refits like any data change (#refitFromData, or
619
+ // #onTooltipTableWritten with JS transforms). Refitting here as well
620
+ // would fit to the old data first, then to the new: two fits.
621
+ // Otherwise its data is current and nothing else will refit, so
622
+ // refit now.
623
+ $effect(() => {
624
+ const handle = this.#handle;
625
+ const visible = this.#chartContext.visible ?? true;
626
+ if (!handle)
627
+ return;
628
+ untrack(() => {
629
+ const cameBack = visible && !handle.active;
630
+ const caughtUp = handle.setActive(visible);
631
+ if (!visible)
632
+ this.#snapFitVersion = null;
633
+ if (cameBack && !caughtUp)
634
+ this.#refitOnReturn();
635
+ });
636
+ });
460
637
  // Push per-mark params and trigger query. Runs immediately after
461
638
  // registration (Svelte flushes effects in creation order), providing
462
639
  // the initial query. On subsequent changes to sort/limit,
@@ -491,32 +668,124 @@ export class MarkLifecycleV2 {
491
668
  return;
492
669
  untrack(() => {
493
670
  this.#handleDataDomains(dv);
671
+ this.otherRows.register();
672
+ // After the domains: the last release opens the gate, and the
673
+ // scales snap to every mark's extent only until then.
674
+ this.#releaseEntrance();
494
675
  });
495
676
  });
496
- // Tooltip: write tooltip table when data updates (debounced).
677
+ // A failed query has nothing to enter with; it must not hold the chart.
678
+ $effect(() => {
679
+ if (this.#handle?.error)
680
+ untrack(() => this.#releaseEntrance());
681
+ });
682
+ // Tooltip: two effects, so a data update does not take the table away.
683
+ //
684
+ // This one owns the table's lifetime: it drops it when tooltips go from
685
+ // wanted to not. It used to be one effect with the write below, and its
686
+ // cleanup then ran on every data update too: the table was dropped and
687
+ // marked not ready, the mark's tooltip unregistered, and the hovered row
688
+ // went with it. A click right after a data update found no row under the
689
+ // pointer, read as a click on empty space and cleared the selection, and
690
+ // the next click did nothing until the pointer moved.
691
+ $effect(() => {
692
+ const tooltipConfig = this.#reactiveConfig.tooltip;
693
+ const fitDomain = this.#reactiveConfig.fitDomain;
694
+ if (tooltipConfig === false && !fitDomain)
695
+ return;
696
+ return () => {
697
+ clearTimeout(this.#tooltipWriteTimeout);
698
+ this.#tooltipTableReady = false;
699
+ this.#dropTooltipTable();
700
+ };
701
+ });
702
+ // And this one rewrites it when data updates, but only when someone may
703
+ // hover it. The table is a second scan of the mark's data, often with a
704
+ // sort, and nobody hovers most charts after most changes (at load, or
705
+ // while another chart is brushed), so a write per data change was the
706
+ // largest cost after a table load.
707
+ //
708
+ // - The chart hovered: written after the data settles (debounced), as
709
+ // before. The old table stays readable until the rewrite replaces it,
710
+ // so a click right after a data update still finds its row.
711
+ // - Not hovered: the table is marked stale (not ready), which takes the
712
+ // mark out of the tooltip manager, so no lookup reads old rows. It is
713
+ // written when the pointer enters the chart, at once, as interaction:
714
+ // it is what the first hover waits for.
715
+ // - fitDomain on a mark with JS transforms reads the table to fit
716
+ // (#buildFitDomainQuery), so that mark writes eagerly, in background.
497
717
  $effect(() => {
498
718
  const tooltipConfig = this.#reactiveConfig.tooltip;
499
719
  const fitDomain = this.#reactiveConfig.fitDomain;
500
- const tooltipWanted = tooltipConfig !== false || !!fitDomain;
501
- if (!tooltipWanted)
720
+ if (tooltipConfig === false && !fitDomain)
502
721
  return;
503
722
  const dataVersion = this.dataVersion;
504
723
  if (dataVersion === 0)
505
724
  return;
725
+ const hovered = this.#chartContext.hovered ?? true;
726
+ const eager = !!fitDomain && this.#transformCompiler.hasJSTransforms();
506
727
  untrack(() => {
728
+ if (dataVersion === this.#tooltipWrittenFor)
729
+ return;
507
730
  clearTimeout(this.#tooltipWriteTimeout);
508
- this.#tooltipWriteTimeout = setTimeout(() => {
731
+ // A data change waits for the data to settle; hover arriving at a
732
+ // stale table writes at once.
733
+ const dataChanged = dataVersion !== this.#tooltipSeenVersion;
734
+ this.#tooltipSeenVersion = dataVersion;
735
+ if (!hovered && !eager) {
736
+ this.#tooltipTableReady = false;
737
+ return;
738
+ }
739
+ const cls = eager && !hovered ? 'background' : 'interaction';
740
+ const write = () => {
509
741
  const data = this.data;
510
- if (data && data.length > 0)
511
- this.#writeTooltipTable(data);
512
- }, 300);
742
+ if (!data || data.length === 0)
743
+ return;
744
+ this.#tooltipWrittenFor = dataVersion;
745
+ this.#writeTooltipTable(data, cls);
746
+ };
747
+ // Sent from this effect, not a timer, so it goes out before a preagg
748
+ // build the same pointer enter queues (see Preagg.activate).
749
+ // A catch-up result is one result, with nothing to settle.
750
+ const catchUp = this.#handle?.resultCause === 'catchUp';
751
+ if (dataChanged && !catchUp)
752
+ this.#tooltipWriteTimeout = setTimeout(write, 300);
753
+ else
754
+ write();
513
755
  });
514
- // Cleanup: when tooltip goes true→false, drop the temp table.
515
- return () => {
516
- clearTimeout(this.#tooltipWriteTimeout);
517
- this.#tooltipTableReady = false;
518
- this.#queryEngine.exec(`DROP TABLE IF EXISTS "${this.#tooltipTableName}"`).catch(() => { });
519
- };
756
+ return () => clearTimeout(this.#tooltipWriteTimeout);
757
+ });
758
+ // fitDomain after a data change (a brush on another chart, the first
759
+ // result): refit, debounced like the data settling. Marks with JS
760
+ // transforms refit after their tooltip table is written instead, which
761
+ // their fit reads (#onTooltipTableWritten). Off screen, like the mark's
762
+ // re-queries: the return to view refits (#refitOnReturn).
763
+ $effect(() => {
764
+ const fitDomain = this.#reactiveConfig.fitDomain;
765
+ if (!fitDomain)
766
+ return;
767
+ const dataVersion = this.dataVersion;
768
+ if (dataVersion === 0)
769
+ return;
770
+ // Untracked: a return to view refits from the setActive effect, which
771
+ // knows whether a catch-up result is coming.
772
+ if (!untrack(() => this.#chartContext.visible ?? true))
773
+ return;
774
+ // A catch-up result's fit snaps, and has no data to wait for.
775
+ const catchUp = untrack(() => this.#handle?.resultCause === 'catchUp');
776
+ this.#snapFitVersion = catchUp ? dataVersion : null;
777
+ // With JS transforms the fit reads the tooltip table, and a data change
778
+ // refits after its write (#onTooltipTableWritten).
779
+ if (this.#transformCompiler.hasJSTransforms())
780
+ return;
781
+ untrack(() => {
782
+ clearTimeout(this.#refitTimeout);
783
+ if (catchUp)
784
+ this.#refitFromData();
785
+ else
786
+ this.#refitTimeout = setTimeout(() => this.#refitFromData(), 300);
787
+ });
788
+ return () => clearTimeout(this.#refitTimeout);
520
789
  });
521
790
  // fitDomain — query DuckDB for fitted axis extent on driving axis change.
522
791
  $effect(() => {
@@ -538,6 +807,16 @@ export class MarkLifecycleV2 {
538
807
  const fittedSS = fittedAxis === 'x' ? this.#chartContext.xScaleState : this.#chartContext.yScaleState;
539
808
  const fittedUserDriven = fittedSS?.userDriven ?? false;
540
809
  const drivingUserDriven = drivingSS.userDriven;
810
+ // Off screen, like the mark's re-queries (MarkHandle.active): no fit.
811
+ // Read untracked: the return to view fits once (#refitOnReturn, or
812
+ // the catch-up result), at the driving axis's domain then; tracking
813
+ // it here too sent a second fit for the same return. Checked only
814
+ // AFTER the driving domain is read: an effect re-runs on what its last
815
+ // run read, so returning before that left the effect deaf to the
816
+ // driving axis after any run off screen, and brushes made once the
817
+ // chart was back on screen sent no fit at all.
818
+ if (!untrack(() => this.#chartContext.visible ?? true))
819
+ return;
541
820
  if (fittedSS) {
542
821
  fittedSS.fitDomainActive = true;
543
822
  }
@@ -548,6 +827,9 @@ export class MarkLifecycleV2 {
548
827
  if (this.#fitDomainSuppressed && !drivingUserDriven) {
549
828
  this.#fitDomainSuppressed = false;
550
829
  }
830
+ // The driving axis moved: whatever fit follows is that zoom's, and
831
+ // springs.
832
+ this.#snapFitVersion = null;
551
833
  if (this.#fitDomainSuppressed)
552
834
  return;
553
835
  clearTimeout(this.#fitDomainTimeout);
@@ -560,7 +842,11 @@ export class MarkLifecycleV2 {
560
842
  // ===========================================================================
561
843
  // PHASE 1: SETUP
562
844
  // ===========================================================================
563
- async #runSetup(colorReady) {
845
+ /** Bumped by each setup run that queries; see #runSetup. */
846
+ #setupGeneration = 0;
847
+ /** The scales the latest querying setup run started with, while it is in flight. */
848
+ #setupInFlight = null;
849
+ async #runSetup(colorReady, inputs) {
564
850
  if (this.#destroyed)
565
851
  return;
566
852
  // Wait for the colour scale — and the series scale, which takes a dim the
@@ -596,9 +882,30 @@ export class MarkLifecycleV2 {
596
882
  // a chart that was never going to get a colour scale.
597
883
  if (!colorReady)
598
884
  return;
885
+ // The setup effect re-enters whenever a scale it reads settles, and an
886
+ // earlier run may still be awaiting its queries. Only the latest run
887
+ // may finish: a superseded one stops after its await, so it cannot
888
+ // register domains on a scale that has since been replaced, nor set
889
+ // setupComplete from stale scale state. Its queries are already sent.
890
+ const generation = ++this.#setupGeneration;
891
+ const superseded = () => this.#destroyed || generation !== this.#setupGeneration;
892
+ this.#setupInFlight = inputs;
893
+ try {
894
+ await this.#runSetupQueries(superseded);
895
+ }
896
+ finally {
897
+ if (generation === this.#setupGeneration)
898
+ this.#setupInFlight = null;
899
+ }
900
+ }
901
+ /** The querying part of #runSetup; `superseded` says a later run has started. */
902
+ async #runSetupQueries(superseded) {
599
903
  // Passive scales: skip domain fetching but still need schema for CAST decisions
600
904
  if (this.#config.passiveScales) {
601
- this.#schema = buildSchemaMap(await this.#queryEngine.describe(this.#config.table));
905
+ const columns = await this.#queryEngine.describe(this.#config.table);
906
+ if (superseded())
907
+ return;
908
+ this.#schema = buildSchemaMap(columns);
602
909
  setScalesReady(this.#scaleMap, this.#channelsNeedingDataDomain, this.actualXScaleType, this.actualYScaleType, false, false);
603
910
  this.setupComplete = true;
604
911
  return;
@@ -612,12 +919,12 @@ export class MarkLifecycleV2 {
612
919
  const [schemaColumns, fetchCount, aggResult, fieldStats] = await Promise.all([
613
920
  // Fetch table schema (cached on QueryEngine, needed for CAST type decisions)
614
921
  this.#queryEngine.describe(this.#config.table),
615
- fetchCategoricalDomains(this.#queryEngine, this.#config.table, this.#config.select, this.actualXScaleType, this.actualYScaleType, this.#chartContext.xScaleState, this.#chartContext.yScaleState, this.#chartContext.facetBandScale, this.#chartContext.fx, this.#config.colorChannel || 'fill', this.#colorScaleState, this.#config.sizeChannel || 'r', this.#sizeScaleState, this.#seriesScaleState, seriesKey, this.#bandDomainFromData, this.#markId, this.#reactiveConfig.domain),
616
- fetchGlobalAggregates(this.#queryEngine, this.#config.table, this.#config.select, this.#channelsNeedingDataDomain, this.#autoBinNodes.size, this.#buildQueryForAggregates.bind(this), this.#config, this.#chartContext.xScaleState, this.#chartContext.yScaleState, this.#colorScaleState, this.#markId),
922
+ fetchCategoricalDomains(this.#queryEngine, this.#config.table, this.#config.select, this.actualXScaleType, this.actualYScaleType, this.#bandScaleForDomain('x'), this.#bandScaleForDomain('y'), this.#chartContext.facetBandScale, this.#chartContext.fx, this.#config.colorChannel || 'fill', this.#colorScaleState, this.#config.sizeChannel || 'r', this.#sizeScaleState, this.#seriesScaleState, seriesKey, this.#bandDomainFromData, this.#markId, this.#reactiveConfig.domain, this.#reactiveConfig.nulls === 'drop', superseded),
923
+ fetchGlobalAggregates(this.#queryEngine, this.#config.table, this.#config.select, this.#channelsNeedingDataDomain, this.#autoBinNodes.size, this.#buildQueryForAggregates.bind(this), this.#config, this.#chartContext.xScaleState, this.#chartContext.yScaleState, this.#colorScaleState, this.#markId, superseded),
617
924
  this.#fetchFieldStats(),
618
925
  this.#ensureIdentityColumns()
619
926
  ]);
620
- if (this.#destroyed)
927
+ if (superseded())
621
928
  return;
622
929
  this.#schema = buildSchemaMap(schemaColumns);
623
930
  if (aggResult.globalAggregatesSet)
@@ -638,8 +945,15 @@ export class MarkLifecycleV2 {
638
945
  if (error?.name === 'AbortError')
639
946
  return;
640
947
  console.error(`[MarkLifecycleV2] Setup failed for ${this.#markId}:`, error);
948
+ this.#releaseEntrance();
641
949
  }
642
950
  }
951
+ /** The chart's entrance gate, or null outside a chart (tests). */
952
+ #entranceGate = null;
953
+ /** Stop holding the chart's entrance. Idempotent: the gate ignores a second. */
954
+ #releaseEntrance() {
955
+ this.#entranceGate?.release(this.#markId);
956
+ }
643
957
  // ===========================================================================
644
958
  // QUERY HELPERS
645
959
  // ===========================================================================
@@ -735,13 +1049,10 @@ export class MarkLifecycleV2 {
735
1049
  ['x', ['x', 'x1', 'x2']],
736
1050
  ['y', ['y', 'y1', 'y2']]
737
1051
  ]) {
738
- const scaleType = axis === 'x' ? this.actualXScaleType : this.actualYScaleType;
739
- if (scaleType !== 'time')
1052
+ const t0Epoch = this.#timeOrigin(axis);
1053
+ if (t0Epoch === null)
740
1054
  continue;
741
1055
  const scaleState = this.#chartContext[`${axis}ScaleState`];
742
- if (!scaleState?.t0)
743
- continue;
744
- const t0Epoch = Math.floor(scaleState.t0.getTime() / 1000);
745
1056
  for (const ch of channels) {
746
1057
  if (!(ch in select))
747
1058
  continue;
@@ -757,6 +1068,20 @@ export class MarkLifecycleV2 {
757
1068
  }
758
1069
  }
759
1070
  }
1071
+ /**
1072
+ * The time axis's origin in epoch seconds, which its channels are drawn
1073
+ * relative to, or null when the axis is not a time scale or has no origin
1074
+ * yet. Its channels are rewritten, and its time buckets padded, only with one.
1075
+ */
1076
+ #timeOrigin(axis) {
1077
+ const scaleType = axis === 'x' ? this.actualXScaleType : this.actualYScaleType;
1078
+ if (scaleType !== 'time')
1079
+ return null;
1080
+ const scaleState = this.#chartContext[`${axis}ScaleState`];
1081
+ if (!scaleState?.t0)
1082
+ return null;
1083
+ return Math.floor(scaleState.t0.getTime() / 1000);
1084
+ }
760
1085
  /**
761
1086
  * Fetch MIN/MAX stats for continuous scale fields.
762
1087
  * Replaces Mosaic's queryFieldInfo (old Effect 5 fieldInfo path).
@@ -825,7 +1150,8 @@ export class MarkLifecycleV2 {
825
1150
  }
826
1151
  }
827
1152
  const q = Query.from(this.#config.table).select(selectDef);
828
- const result = await this.#queryEngine.query(q);
1153
+ // Shared and cached: marks on one table ask the same ranges (see QueryOpts.cache).
1154
+ const result = await this.#queryEngine.query(q, { cache: true, source: this.#config.table });
829
1155
  const row = result.get(0);
830
1156
  if (!row)
831
1157
  return null;
@@ -872,8 +1198,7 @@ export class MarkLifecycleV2 {
872
1198
  const additionalChannels = this.#transformCompiler.getAdditionalChannels();
873
1199
  const xState = this.#chartContext.xScaleState;
874
1200
  const yState = this.#chartContext.yScaleState;
875
- const domainResult = registerDataDerivedDomains(data, this.#channelsNeedingDataDomain, additionalChannels, this.#skipDomainUpdateForAxis, this.#config.zeroBaseline, this.actualXScaleType, this.actualYScaleType, xState, yState, this.#bandDomainFromData, this.#chartContext.xScaleState, this.#chartContext.yScaleState, this.#markId, false, // isRebinning
876
- this.#colorScaleState);
1201
+ const domainResult = registerDataDerivedDomains(data, this.#channelsNeedingDataDomain, additionalChannels, this.#skipDomainUpdateForAxis, this.#config.zeroBaseline, this.actualXScaleType, this.actualYScaleType, xState, yState, this.#bandDomainFromData, this.#bandScaleForDomain('x'), this.#bandScaleForDomain('y'), this.#markId, this.#instantFor, this.#colorScaleState);
877
1202
  this.#dataDerivedDomainsSet = domainResult.dataDerivedDomainsSet;
878
1203
  this.#skipDomainUpdateForAxis = domainResult.skipDomainUpdateForAxis;
879
1204
  markDeferredScalesReady(this.#scaleMap, this.#channelsNeedingDataDomain, this.actualXScaleType, this.actualYScaleType);
@@ -987,6 +1312,13 @@ export class MarkLifecycleV2 {
987
1312
  // the view. Failing here gives a message naming the mark and the fields;
988
1313
  // continuing would fail at the binder with neither.
989
1314
  await this.#queryEngine.ensureColumns(this.#config.table, { categorical });
1315
+ // Torn down while the dims were built: the scales below may have been
1316
+ // dropped with a ChartScales the chart has since replaced. Only a
1317
+ // destroyed lifecycle, not a superseded setup run: identity goes to the
1318
+ // scales read live from the chart context, so a live run handing it
1319
+ // over is harmless.
1320
+ if (this.#destroyed)
1321
+ return;
990
1322
  for (const { key } of categorical)
991
1323
  this.#identityFields.add(key);
992
1324
  // Tell the transforms which dim each categorical channel is identified by.
@@ -1007,11 +1339,15 @@ export class MarkLifecycleV2 {
1007
1339
  const identity = {};
1008
1340
  const describe = (channel, key, positional) => {
1009
1341
  const present = this.#queryEngine.presentTableFor(key, this.#config.table);
1342
+ // nulls="drop": its rows are filtered out, and dense() must not pad a
1343
+ // zero row for it either, or a sorted axis keeps a "(null)" slot.
1344
+ const nullId = this.#reactiveConfig.nulls === 'drop' ? this.#queryEngine.identityFor(key).nullId : null;
1010
1345
  identity[channel] = {
1011
1346
  dim: this.#queryEngine.dimTableFor(key),
1012
1347
  size: this.#queryEngine.identityFor(key).values.length,
1013
1348
  positional,
1014
- ...(present ? { present } : {})
1349
+ ...(present ? { present } : {}),
1350
+ ...(nullId !== null ? { dropNullId: nullId } : {})
1015
1351
  };
1016
1352
  };
1017
1353
  for (const { axis, key } of axes)
@@ -1136,6 +1472,67 @@ export class MarkLifecycleV2 {
1136
1472
  this.#releaseOther(scale);
1137
1473
  }
1138
1474
  }
1475
+ #warnedFollow = false;
1476
+ /**
1477
+ * Whether this mark follows its band axes instead of adding its categories to
1478
+ * them (`contributeDomain={false}`). It cannot also choose its own categories
1479
+ * or their order: a topN or a ranking would be what a follower exists not to
1480
+ * do, and an explicit domain is a contribution. Such a mark is refused, once,
1481
+ * and contributes as usual.
1482
+ */
1483
+ get #followsDomain() {
1484
+ if (this.#reactiveConfig.contributeDomain !== false)
1485
+ return false;
1486
+ const reason = this.#transformCompiler.hasCategoryReducingTransforms()
1487
+ ? 'a category-reducing transform (topN)'
1488
+ : this.#rankingSort
1489
+ ? 'a sort'
1490
+ : this.#reactiveConfig.domain
1491
+ ? 'an explicit domain'
1492
+ : null;
1493
+ if (!reason)
1494
+ return true;
1495
+ if (!this.#warnedFollow) {
1496
+ this.#warnedFollow = true;
1497
+ console.error(`[${this.#markId}] Unsupported: contributeDomain={false} with ${reason}. A mark that ` +
1498
+ `follows its band axis cannot choose its own categories or their order. It will ` +
1499
+ `contribute to the domain as usual.`);
1500
+ }
1501
+ return false;
1502
+ }
1503
+ /**
1504
+ * The band scale of an axis, for registering this mark's categories on it:
1505
+ * null for a mark that follows, so it registers nothing and withdraws what it
1506
+ * had (the prop can change). The scale still draws the mark's rows, only those
1507
+ * of categories it shows.
1508
+ */
1509
+ #bandScaleForDomain(axis) {
1510
+ const scale = (axis === 'x' ? this.#chartContext.xScaleState : this.#chartContext.yScaleState);
1511
+ // Only a band or point axis has categories to follow; a value axis is
1512
+ // the mark's own.
1513
+ const type = axis === 'x' ? this.actualXScaleType : this.actualYScaleType;
1514
+ if ((type !== 'band' && type !== 'point') || !this.#followsDomain)
1515
+ return scale;
1516
+ scale?.removeMark(this.#markId);
1517
+ return null;
1518
+ }
1519
+ /** What this mark's Other rows hold, and what is done with it. */
1520
+ otherRows;
1521
+ /** What the mark is, for whether its Other bar splits; null before setup completes. */
1522
+ get #splitPlan() {
1523
+ if (!this.setupComplete)
1524
+ return null;
1525
+ const markType = this.#transformConfig.markType;
1526
+ const length = markType === 'barx' ? this.#transformConfig.x : this.#transformConfig.y;
1527
+ return {
1528
+ markType,
1529
+ length,
1530
+ by: this.#transformCompiler.otherBy(),
1531
+ expr: length ? this.#config.select[length] : undefined,
1532
+ hasOther: this.#transformCompiler.hasSyntheticOtherBucket(),
1533
+ hasJsTransforms: this.#transformCompiler.jsTransforms.length > 0
1534
+ };
1535
+ }
1139
1536
  /** The band scales whose Other bucket this mark holds a claim on. */
1140
1537
  #otherScales = new Set();
1141
1538
  #releaseOther(scale) {
@@ -1168,6 +1565,11 @@ export class MarkLifecycleV2 {
1168
1565
  * never mixed, the other axis's channels morph, and the band scale owns the
1169
1566
  * clock both run on.
1170
1567
  *
1568
+ * A histogram's binned axis aligns too: one row per bin, in bin order. Its
1569
+ * channels are bin edges rather than ids, still never mixed, and with no
1570
+ * slot clock on a linear scale the mark's channels run on a clock of their
1571
+ * own.
1572
+ *
1171
1573
  * The first channel of the transforms' alignment — `dense()`'s plan, over the
1172
1574
  * same select and facts its `apply()` sees — so the conditions are written
1173
1575
  * once. Derived rather than recorded at apply time: apply runs for every
@@ -1181,10 +1583,13 @@ export class MarkLifecycleV2 {
1181
1583
  void this.#identity;
1182
1584
  // Asked exactly as assembleQuery asks when it orders the query, so this and
1183
1585
  // the rows cannot disagree about which channel they are aligned on.
1184
- const band = this.#transformCompiler.alignment(Object.keys(parts.selectDef), {
1185
- groupKeys: parts.groupKeys
1586
+ const aligned = this.#transformCompiler.alignment(Object.keys(parts.selectDef), {
1587
+ groupKeys: parts.groupKeys,
1588
+ bins: parts.bins ?? null
1186
1589
  })?.[0];
1187
- return band === 'x' || band === 'y' ? band : null;
1590
+ // A band is `x` or `y`; a binned axis aligns on its lower edge, `x1`.
1591
+ const axis = aligned?.[0];
1592
+ return axis === 'x' || axis === 'y' ? axis : null;
1188
1593
  });
1189
1594
  /**
1190
1595
  * Build a query for fetchGlobalAggregates.
@@ -1195,10 +1600,12 @@ export class MarkLifecycleV2 {
1195
1600
  // Build raw query parts directly from config.select (no identity columns,
1196
1601
  // no bin resolution — scale states aren't ready yet during Phase 1).
1197
1602
  const parts = buildQueryParts(this.#config.select, this.#config._orderConfig ?? null, this.#transformCompiler.hasSQLTransforms());
1603
+ // Domains from the rows the mark draws: without what nulls="drop" leaves
1604
+ // out. This select is raw, so the filter is on the raw values.
1198
1605
  const q = assembleQuery({
1199
1606
  table: this.#config.table,
1200
1607
  parts,
1201
- filter,
1608
+ filter: [...filter, ...this.#rawNullFilters(this.#config.select)],
1202
1609
  transformCompiler: this.#transformCompiler
1203
1610
  });
1204
1611
  if (!q)
@@ -1257,9 +1664,10 @@ export class MarkLifecycleV2 {
1257
1664
  // --- Selection routing ---
1258
1665
  /**
1259
1666
  * The identity keys of the categorical fields this mark's renderer reads
1260
- * selection activity for, sorted: its ordinal colour and its band axes, as
1261
- * activity-reads.ts says the renderer reads them. A categorical selection on
1262
- * one of these dims the mark; on any other field it filters it.
1667
+ * selection activity for, sorted: its ordinal colour, its band axes and a
1668
+ * line's or area's own `z`, as activity-reads.ts says the renderer reads
1669
+ * them. A categorical selection on one of these dims the mark; on any other
1670
+ * field it filters it.
1263
1671
  */
1264
1672
  #reads = $derived.by(() => {
1265
1673
  const renderer = rendererForMark(this.#config.markType);
@@ -1273,10 +1681,15 @@ export class MarkLifecycleV2 {
1273
1681
  const band = this.#chartContext[`${axis}ScaleState`];
1274
1682
  return band?.identityKey ?? null;
1275
1683
  };
1684
+ // The series is a field of its own only on a separate channel (`z`);
1685
+ // without one, the series channel is the colour channel, read above.
1686
+ const seriesChannel = this.#config._orderConfig?.seriesChannel ?? null;
1687
+ const ownSeries = !!seriesChannel && seriesChannel !== (this.#config.colorChannel || 'fill');
1276
1688
  return readsForResponse(this.#reactiveConfig.selectionResponse, readsFor(activityReads(renderer, this.#config.ruleExtent ?? false), {
1277
1689
  color: color?.isOrdinal ? color.identityKey : null,
1278
1690
  x: bandKey('x'),
1279
- y: bandKey('y')
1691
+ y: bandKey('y'),
1692
+ series: ownSeries ? (this.#seriesScaleState?.identityKey ?? null) : null
1280
1693
  }));
1281
1694
  });
1282
1695
  /** `#reads` as one string, so the registration effect re-runs only on a real change. */
@@ -1291,6 +1704,24 @@ export class MarkLifecycleV2 {
1291
1704
  // NO_GROUP for selectionResponse 'filter', which never dims.
1292
1705
  return groupIndexForResponse(this.#reactiveConfig.selectionResponse, this.#filterGroup.index);
1293
1706
  }
1707
+ /**
1708
+ * The data version that lands without animating, or 0: a catch-up result
1709
+ * (the chart came back into view stale), a change nobody just made. A
1710
+ * version, not a flag: the cause stays set until the next result, and a
1711
+ * renderer lands only its FIRST write of this version. Later writes of the
1712
+ * same data (an Other split on a click, a JS transform's parameter) are
1713
+ * changes someone just made, and animate. Reactive through `dataVersion`,
1714
+ * which the cause is set with.
1715
+ */
1716
+ get landsFor() {
1717
+ return this.#handle?.resultCause === 'catchUp' ? this.dataVersion : 0;
1718
+ }
1719
+ /**
1720
+ * Whether a scale's change from the current result lands at once: a
1721
+ * catch-up result, on a scale no other visible chart draws. Asked as the
1722
+ * result is applied, since visibility moves during a scroll.
1723
+ */
1724
+ #instantFor = (scale) => this.#handle?.resultCause === 'catchUp' && !this.#chartContext.shownElsewhere(scale);
1294
1725
  get dataVersion() {
1295
1726
  return this.#handle?.dataVersion ?? 0;
1296
1727
  }
@@ -1436,8 +1867,14 @@ export class MarkLifecycleV2 {
1436
1867
  // ===========================================================================
1437
1868
  // TOOLTIP WRITE-BACK
1438
1869
  // ===========================================================================
1439
- async #writeTooltipTable(data) {
1870
+ /** The data version the tooltip table was last written for. */
1871
+ #tooltipWrittenFor = -1;
1872
+ /** The data version the write effect last saw, to tell a data change from a hover. */
1873
+ #tooltipSeenVersion = -1;
1874
+ async #writeTooltipTable(data, cls) {
1440
1875
  const tableName = this.#tooltipTableName;
1876
+ // Before any statement: a write that fails part way can leave a table.
1877
+ this.#tooltipTableWritten = true;
1441
1878
  try {
1442
1879
  if (!this.#transformCompiler.hasJSTransforms()) {
1443
1880
  // Path A: Build a query with original field names (not CAST-wrapped).
@@ -1480,11 +1917,17 @@ export class MarkLifecycleV2 {
1480
1917
  replaceExprs.push(`CAST(${c} AS DOUBLE) AS ${c}`);
1481
1918
  }
1482
1919
  const replaceClause = replaceExprs.length > 0 ? ` REPLACE (${replaceExprs.join(', ')})` : '';
1483
- // Optional ORDER BY for zone-map skipping in bounding-box queries.
1920
+ // ORDER BY x, so a hover lookup's bounding box could skip row groups
1921
+ // (zone maps): only when asked (`tooltip: { sortTable: true }`). Since
1922
+ // lazy writes the sort is hover latency, and the lookups did not get
1923
+ // faster for it. Measured on a scatter, sorted against unsorted:
1924
+ // 200k rows, write 41 against 12 ms, lookup median 2.2 against 2.9 ms;
1925
+ // 2M rows (17 row groups), write 273 against 70 ms, lookup median 11.3
1926
+ // against 11.4 ms, p90 35 against 30 ms. The lookup's box is wide and
1927
+ // holds most of the cost on dense data, sorted or not.
1484
1928
  let orderClause = '';
1485
- const tooltipSortTable = typeof this.#reactiveConfig.tooltip === 'object'
1486
- ? this.#reactiveConfig.tooltip.sortTable !== false
1487
- : true;
1929
+ const tooltipSortTable = typeof this.#reactiveConfig.tooltip === 'object' &&
1930
+ this.#reactiveConfig.tooltip.sortTable === true;
1488
1931
  if (tooltipSortTable) {
1489
1932
  const orderCol = ['x', 'x1', 'x2'].find((c) => c in selectConfig);
1490
1933
  if (orderCol)
@@ -1496,10 +1939,8 @@ export class MarkLifecycleV2 {
1496
1939
  // the rest are scalars (the rank, id columns), cheap to keep, and the
1497
1940
  // lists are the only ones whose size grows with the field.
1498
1941
  const withoutMembers = withoutOtherMembers(tooltipQuery);
1499
- await this.#queryEngine.exec([
1500
- `DROP TABLE IF EXISTS "${tableName}"`,
1501
- `CREATE TEMP TABLE "${tableName}" AS SELECT *${replaceClause} FROM (${withoutMembers})${orderClause}`
1502
- ]);
1942
+ // One statement: a hover query never finds the table missing.
1943
+ await this.#queryEngine.exec(`CREATE OR REPLACE TEMP TABLE "${tableName}" AS SELECT *${replaceClause} FROM (${withoutMembers})${orderClause}`, { class: cls, connectionLocal: true });
1503
1944
  }
1504
1945
  else {
1505
1946
  // Path B: Serialize transformed data via SQL VALUES.
@@ -1541,19 +1982,22 @@ export class MarkLifecycleV2 {
1541
1982
  `DROP TABLE IF EXISTS "${tableName}"`,
1542
1983
  `CREATE TEMP TABLE "${tableName}" (${schemaDef})`,
1543
1984
  `INSERT INTO "${tableName}" VALUES ${valueRows.join(', ')}`
1544
- ]);
1985
+ ], { class: cls, connectionLocal: true });
1545
1986
  }
1546
1987
  // Merge tip expression extras (aggregates/windows) via sidecar query.
1547
1988
  const tipExtras = this.#getTipExtras();
1548
1989
  if (Object.keys(tipExtras).length > 0) {
1549
1990
  try {
1550
- await this.#mergeTipExtras(tableName, tipExtras);
1991
+ await this.#mergeTipExtras(tableName, tipExtras, cls);
1551
1992
  }
1552
1993
  catch (err) {
1553
1994
  console.warn('Failed to merge tip extras into tooltip table:', err);
1554
1995
  }
1555
1996
  }
1556
1997
  this.#tooltipTableReady = true;
1998
+ // The row under a still pointer was read from the old table: read it
1999
+ // again, so the tooltip and a click show the new data without a move.
2000
+ this.#chartContext.tooltipManager?.refresh();
1557
2001
  this.#onTooltipTableWritten();
1558
2002
  }
1559
2003
  catch (error) {
@@ -1607,7 +2051,7 @@ export class MarkLifecycleV2 {
1607
2051
  * DB query. Computes aggregates against the source table using the same
1608
2052
  * GROUP BY as the main query, then ALTER+UPDATE the temp table.
1609
2053
  */
1610
- async #mergeTipExtras(tableName, tipExtras) {
2054
+ async #mergeTipExtras(tableName, tipExtras, cls) {
1611
2055
  if (!this.#resolvedQueryParts)
1612
2056
  return;
1613
2057
  // Identify non-aggregate channel columns — these are the join keys
@@ -1624,6 +2068,9 @@ export class MarkLifecycleV2 {
1624
2068
  }
1625
2069
  Object.assign(sidecarSelect, tipExtras);
1626
2070
  const sidecarParts = buildQueryParts(sidecarSelect, undefined, this.#transformCompiler.hasSQLTransforms());
2071
+ // The same rows as the mark: it leaves out what the mark leaves out. Its
2072
+ // select holds every non-aggregate channel, so each dropped one is there.
2073
+ sidecarParts.nullDrops = this.#resolvedQueryParts.nullDrops;
1627
2074
  const filter = this.#filterGroup.selection.predicate(this.#route) ?? [];
1628
2075
  const sidecarQuery = assembleQuery({
1629
2076
  table: this.#sourceTable,
@@ -1642,7 +2089,10 @@ export class MarkLifecycleV2 {
1642
2089
  .map((ch) => `${ident(tableName)}.${ident(ch)} = _s.${ident(ch)}`)
1643
2090
  .join(' AND ');
1644
2091
  const updateStmt = `UPDATE ${ident(tableName)} SET ${setClauses} FROM (${sidecarQuery.toString()}) _s WHERE ${joinCondition}`;
1645
- await this.#queryEngine.exec([...alterStmts, updateStmt]);
2092
+ await this.#queryEngine.exec([...alterStmts, updateStmt], {
2093
+ class: cls,
2094
+ connectionLocal: true
2095
+ });
1646
2096
  }
1647
2097
  // ===========================================================================
1648
2098
  // FIT DOMAIN
@@ -1675,7 +2125,9 @@ export class MarkLifecycleV2 {
1675
2125
  }
1676
2126
  return channels;
1677
2127
  }
1678
- async #issueFitDomainQuery(fittedAxis, drivingDomain, force = false) {
2128
+ async #issueFitDomainQuery(fittedAxis, drivingDomain, force = false,
2129
+ /** A return-to-view fit (#snapFitVersion): lands without a spring. */
2130
+ snap = false) {
1679
2131
  if (!force && drivingDomain) {
1680
2132
  const [lastLo, lastHi] = this.#fitDomainLastTarget;
1681
2133
  const lastWidth = lastHi - lastLo;
@@ -1694,7 +2146,11 @@ export class MarkLifecycleV2 {
1694
2146
  return;
1695
2147
  const result = await this.#queryEngine.query(q.toString(), {
1696
2148
  callerId: `${this.#markId}:fitDomain`,
1697
- cache: false
2149
+ cache: false,
2150
+ // With JS transforms the fit reads the TEMP tooltip table;
2151
+ // otherwise the mark's base table, routed by it.
2152
+ connectionLocal: this.#transformCompiler.hasJSTransforms(),
2153
+ source: this.#config.table
1698
2154
  });
1699
2155
  if (seq !== this.#fitDomainQuerySequence)
1700
2156
  return;
@@ -1719,11 +2175,14 @@ export class MarkLifecycleV2 {
1719
2175
  }
1720
2176
  const scaleState = fittedAxis === 'x' ? this.#chartContext.xScaleState : this.#chartContext.yScaleState;
1721
2177
  const scaleType = fittedAxis === 'x' ? this.actualXScaleType : this.actualYScaleType;
2178
+ // Asked as it lands: a chart in view that shares the axis keeps the
2179
+ // spring, and everything on the axis moves together through it.
2180
+ const instant = snap && !!scaleState && !this.#chartContext.shownElsewhere(scaleState);
1722
2181
  if (scaleType === 'time' && scaleState && 'addOffsetExtent' in scaleState) {
1723
- scaleState.addOffsetExtent([lo, hi], this.#markId);
2182
+ scaleState.addOffsetExtent([lo, hi], this.#markId, { instant });
1724
2183
  }
1725
2184
  else if (scaleState && 'addMark' in scaleState) {
1726
- scaleState.addMark([lo, hi], this.#markId);
2185
+ scaleState.addMark([lo, hi], this.#markId, { instant });
1727
2186
  }
1728
2187
  if (drivingDomain)
1729
2188
  this.#fitDomainLastTarget = [...drivingDomain];
@@ -1790,15 +2249,32 @@ export class MarkLifecycleV2 {
1790
2249
  return q;
1791
2250
  }
1792
2251
  /**
1793
- * Called after tooltip table is written. If fitDomain is active, issue a
1794
- * sidecar MIN/MAX query against the fresh tooltip table.
2252
+ * A tooltip table was written. A fit over a mark with JS transforms reads that
2253
+ * table (#buildFitDomainQuery), so its refit after a data change waits for
2254
+ * the write; every other fit refits from the data itself (#refitFromData).
1795
2255
  */
1796
2256
  #onTooltipTableWritten() {
2257
+ if (this.#transformCompiler.hasJSTransforms())
2258
+ this.#refitFromData();
2259
+ }
2260
+ /**
2261
+ * Refit the fitted axis to the mark's current data, at the driving axis's
2262
+ * current domain. The data-driven trigger: the fitDomain effect reacts to the
2263
+ * driving axis, not to new data, and returns for a band driving axis, so this
2264
+ * is also a band-driven fit's only trigger, its first fit included.
2265
+ */
2266
+ #refitFromData() {
1797
2267
  const fitDomain = this.#reactiveConfig.fitDomain;
1798
2268
  if (!fitDomain)
1799
2269
  return;
2270
+ // The fit for the snap's version, if this is it; a refit for any other
2271
+ // version springs, and a skipped one gives the snap up.
2272
+ const snap = this.#snapFitVersion !== null && this.#snapFitVersion === this.dataVersion;
2273
+ this.#snapFitVersion = null;
1800
2274
  if (this.#fitDomainSuppressed)
1801
2275
  return;
2276
+ if (snap)
2277
+ this.#snapFitVersion = this.dataVersion;
1802
2278
  const fittedAxis = this.#resolveFittedAxis(fitDomain);
1803
2279
  if (!fittedAxis)
1804
2280
  return;
@@ -1807,25 +2283,45 @@ export class MarkLifecycleV2 {
1807
2283
  if (!drivingSS)
1808
2284
  return;
1809
2285
  if (drivingSS.scaleType === 'band') {
1810
- this.#issueFitDomainQuery(fittedAxis, null, true);
2286
+ this.#issueFitDomainQuery(fittedAxis, null, true, snap);
1811
2287
  return;
1812
2288
  }
1813
2289
  const target = drivingSS.domain?.target;
1814
2290
  if (!target)
1815
2291
  return;
1816
- this.#issueFitDomainQuery(fittedAxis, target, true);
2292
+ this.#issueFitDomainQuery(fittedAxis, target, true, snap);
2293
+ }
2294
+ /**
2295
+ * The chart came back into view with current data: fit to the driving
2296
+ * axis as it is now. Not debounced, since no data is settling. With JS
2297
+ * transforms the fit reads the tooltip table; if its write is still
2298
+ * pending, #onTooltipTableWritten fits after it instead.
2299
+ */
2300
+ #refitOnReturn() {
2301
+ if (!this.#reactiveConfig.fitDomain || this.dataVersion === 0)
2302
+ return;
2303
+ this.#snapFitVersion = this.dataVersion;
2304
+ if (this.#transformCompiler.hasJSTransforms() && !this.#tooltipTableReady)
2305
+ return;
2306
+ clearTimeout(this.#refitTimeout);
2307
+ this.#refitFromData();
1817
2308
  }
1818
2309
  // ===========================================================================
1819
2310
  // CLEANUP
1820
2311
  // ===========================================================================
1821
2312
  destroy() {
1822
2313
  this.#destroyed = true;
2314
+ // Not releasing the entrance here: a mark rebuilds its lifecycle when
2315
+ // its scales resolve, and the rebuilt one re-holds the same id. The
2316
+ // mark's unmount releases it (ChartConfig.unregisterMark).
1823
2317
  this.#identityResetOff?.();
1824
2318
  this.#identityResetOff = null;
2319
+ this.otherRows.unregister();
1825
2320
  for (const scale of [...this.#otherScales])
1826
2321
  this.#releaseOther(scale);
1827
2322
  this.#handle?.destroy();
1828
2323
  clearTimeout(this.#fitDomainTimeout);
2324
+ clearTimeout(this.#refitTimeout);
1829
2325
  clearTimeout(this.#tooltipWriteTimeout);
1830
2326
  if (this.#onMarkLOD) {
1831
2327
  this.#chartContext.config.unregisterMarkLOD(this.#markId);
@@ -1841,6 +2337,23 @@ export class MarkLifecycleV2 {
1841
2337
  // where destroy() is called synchronously before Svelte processes
1842
2338
  // effect teardown.
1843
2339
  this.#tooltipTableReady = false;
1844
- this.#queryEngine.exec(`DROP TABLE IF EXISTS "${this.#tooltipTableName}"`).catch(() => { });
2340
+ this.#dropTooltipTable();
1845
2341
  }
2342
+ #dropTooltipTable() {
2343
+ if (!this.#tooltipTableWritten)
2344
+ return;
2345
+ this.#tooltipTableWritten = false;
2346
+ this.#queryEngine
2347
+ .exec(`DROP TABLE IF EXISTS "${this.#tooltipTableName}"`, {
2348
+ class: 'background',
2349
+ // A TEMP table on the main connection, written and dropped only
2350
+ // there, so a drop cannot overtake the next lifecycle's write.
2351
+ connectionLocal: true
2352
+ })
2353
+ .catch(() => { });
2354
+ }
2355
+ }
2356
+ /** Same scale states and types, compared by identity. */
2357
+ function sameInputs(a, b) {
2358
+ return a.length === b.length && a.every((v, i) => v === b[i]);
1846
2359
  }