@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
@@ -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) {
@@ -143,13 +143,10 @@ export class MarkLifecycleV2 {
143
143
  // Resolve AutoBinNodes — reads #fieldStats ($state), #lastBinDomain ($state),
144
144
  // binStepMultiplier (reactive on chartContext)
145
145
  if (this.#autoBinNodes.size > 0 && this.#fieldStats) {
146
- const binStepMultiplier = this.#chartContext.binStepMultiplier || 1.0;
147
146
  for (const [ch, value] of Object.entries(resolvedSelect)) {
148
147
  if (isBinExpression(value)) {
149
- const axis = ch.startsWith('x') ? 'x' : 'y';
150
- const scaleType = axis === 'x' ? this.actualXScaleType : this.actualYScaleType;
151
- const currentDomain = this.#lastBinDomain[axis] ?? null;
152
- 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);
153
150
  }
154
151
  }
155
152
  }
@@ -189,8 +186,40 @@ export class MarkLifecycleV2 {
189
186
  const nullDrops = this.#nullDrops(semanticSelect, resolvedSelect);
190
187
  if (nullDrops.length > 0)
191
188
  parts.nullDrops = nullDrops;
189
+ parts.bins = this.#binPadding();
192
190
  return parts;
193
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
+ }
194
223
  /**
195
224
  * The mark's categorical channels, each with the source expression it draws:
196
225
  * a band or point axis, an ordinal colour, a separate series, and the facet
@@ -277,9 +306,27 @@ export class MarkLifecycleV2 {
277
306
  // --- Tooltip ---
278
307
  #tooltipTableName;
279
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;
280
316
  #tooltipWriteTimeout = null;
281
317
  // --- fitDomain ---
282
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;
283
330
  #fitDomainLastTarget = [0, 0];
284
331
  #fitDomainQuerySequence = 0;
285
332
  #fitDomainSuppressed = false;
@@ -292,6 +339,14 @@ export class MarkLifecycleV2 {
292
339
  this.#chartContext = chartContext;
293
340
  this.#markId = markId;
294
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
+ }
295
350
  // Tooltip table name
296
351
  this.#tooltipTableName = `_tt_${this.#markId.replace(/-/g, '_')}`;
297
352
  // Transform compiler
@@ -327,6 +382,7 @@ export class MarkLifecycleV2 {
327
382
  identity: () => this.#identity,
328
383
  identityKey: (axis) => this.#identityKey(axis)?.key ?? null,
329
384
  filtered: () => this.#reactiveConfig.selectionResponse === 'filter',
385
+ dims: () => dimsBySelection(this.#reactiveConfig.selectionResponse),
330
386
  plan: () => this.#splitPlan,
331
387
  colorKey: () => {
332
388
  const color = this.#colorScaleState;
@@ -503,7 +559,20 @@ export class MarkLifecycleV2 {
503
559
  const scalesReady = (!this.#config.select?.[colorKey] || !!this.#colorScaleState) &&
504
560
  (!seriesKey || seriesKey === colorKey || !!this.#seriesScaleState);
505
561
  untrack(() => {
506
- 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);
507
576
  });
508
577
  });
509
578
  // Registration: watches #resolvedQueryParts, registers with engine.
@@ -534,10 +603,37 @@ export class MarkLifecycleV2 {
534
603
  parts,
535
604
  schema,
536
605
  transformCompiler: this.#transformCompiler,
537
- route: this.#route
606
+ route: this.#route,
607
+ // holdsEntrance={false}: the author knows this mark is slow.
608
+ slow: this.#reactiveConfig.holdsEntrance === false
538
609
  }, this.#filterGroup);
539
610
  });
540
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
+ });
541
637
  // Push per-mark params and trigger query. Runs immediately after
542
638
  // registration (Svelte flushes effects in creation order), providing
543
639
  // the initial query. On subsequent changes to sort/limit,
@@ -573,8 +669,16 @@ export class MarkLifecycleV2 {
573
669
  untrack(() => {
574
670
  this.#handleDataDomains(dv);
575
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();
576
675
  });
577
676
  });
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
+ });
578
682
  // Tooltip: two effects, so a data update does not take the table away.
579
683
  //
580
684
  // This one owns the table's lifetime: it drops it when tooltips go from
@@ -592,11 +696,24 @@ export class MarkLifecycleV2 {
592
696
  return () => {
593
697
  clearTimeout(this.#tooltipWriteTimeout);
594
698
  this.#tooltipTableReady = false;
595
- this.#queryEngine.exec(`DROP TABLE IF EXISTS "${this.#tooltipTableName}"`).catch(() => { });
699
+ this.#dropTooltipTable();
596
700
  };
597
701
  });
598
- // And this one rewrites it when data updates (debounced). The old table
599
- // stays readable until the rewrite replaces it.
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.
600
717
  $effect(() => {
601
718
  const tooltipConfig = this.#reactiveConfig.tooltip;
602
719
  const fitDomain = this.#reactiveConfig.fitDomain;
@@ -605,16 +722,71 @@ export class MarkLifecycleV2 {
605
722
  const dataVersion = this.dataVersion;
606
723
  if (dataVersion === 0)
607
724
  return;
725
+ const hovered = this.#chartContext.hovered ?? true;
726
+ const eager = !!fitDomain && this.#transformCompiler.hasJSTransforms();
608
727
  untrack(() => {
728
+ if (dataVersion === this.#tooltipWrittenFor)
729
+ return;
609
730
  clearTimeout(this.#tooltipWriteTimeout);
610
- 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 = () => {
611
741
  const data = this.data;
612
- if (data && data.length > 0)
613
- this.#writeTooltipTable(data);
614
- }, 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();
615
755
  });
616
756
  return () => clearTimeout(this.#tooltipWriteTimeout);
617
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);
789
+ });
618
790
  // fitDomain — query DuckDB for fitted axis extent on driving axis change.
619
791
  $effect(() => {
620
792
  const fitDomain = this.#reactiveConfig.fitDomain;
@@ -635,6 +807,16 @@ export class MarkLifecycleV2 {
635
807
  const fittedSS = fittedAxis === 'x' ? this.#chartContext.xScaleState : this.#chartContext.yScaleState;
636
808
  const fittedUserDriven = fittedSS?.userDriven ?? false;
637
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;
638
820
  if (fittedSS) {
639
821
  fittedSS.fitDomainActive = true;
640
822
  }
@@ -645,6 +827,9 @@ export class MarkLifecycleV2 {
645
827
  if (this.#fitDomainSuppressed && !drivingUserDriven) {
646
828
  this.#fitDomainSuppressed = false;
647
829
  }
830
+ // The driving axis moved: whatever fit follows is that zoom's, and
831
+ // springs.
832
+ this.#snapFitVersion = null;
648
833
  if (this.#fitDomainSuppressed)
649
834
  return;
650
835
  clearTimeout(this.#fitDomainTimeout);
@@ -657,7 +842,11 @@ export class MarkLifecycleV2 {
657
842
  // ===========================================================================
658
843
  // PHASE 1: SETUP
659
844
  // ===========================================================================
660
- 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) {
661
850
  if (this.#destroyed)
662
851
  return;
663
852
  // Wait for the colour scale — and the series scale, which takes a dim the
@@ -693,9 +882,30 @@ export class MarkLifecycleV2 {
693
882
  // a chart that was never going to get a colour scale.
694
883
  if (!colorReady)
695
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) {
696
903
  // Passive scales: skip domain fetching but still need schema for CAST decisions
697
904
  if (this.#config.passiveScales) {
698
- 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);
699
909
  setScalesReady(this.#scaleMap, this.#channelsNeedingDataDomain, this.actualXScaleType, this.actualYScaleType, false, false);
700
910
  this.setupComplete = true;
701
911
  return;
@@ -709,12 +919,12 @@ export class MarkLifecycleV2 {
709
919
  const [schemaColumns, fetchCount, aggResult, fieldStats] = await Promise.all([
710
920
  // Fetch table schema (cached on QueryEngine, needed for CAST type decisions)
711
921
  this.#queryEngine.describe(this.#config.table),
712
- 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'),
713
- 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),
714
924
  this.#fetchFieldStats(),
715
925
  this.#ensureIdentityColumns()
716
926
  ]);
717
- if (this.#destroyed)
927
+ if (superseded())
718
928
  return;
719
929
  this.#schema = buildSchemaMap(schemaColumns);
720
930
  if (aggResult.globalAggregatesSet)
@@ -735,8 +945,15 @@ export class MarkLifecycleV2 {
735
945
  if (error?.name === 'AbortError')
736
946
  return;
737
947
  console.error(`[MarkLifecycleV2] Setup failed for ${this.#markId}:`, error);
948
+ this.#releaseEntrance();
738
949
  }
739
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
+ }
740
957
  // ===========================================================================
741
958
  // QUERY HELPERS
742
959
  // ===========================================================================
@@ -832,13 +1049,10 @@ export class MarkLifecycleV2 {
832
1049
  ['x', ['x', 'x1', 'x2']],
833
1050
  ['y', ['y', 'y1', 'y2']]
834
1051
  ]) {
835
- const scaleType = axis === 'x' ? this.actualXScaleType : this.actualYScaleType;
836
- if (scaleType !== 'time')
1052
+ const t0Epoch = this.#timeOrigin(axis);
1053
+ if (t0Epoch === null)
837
1054
  continue;
838
1055
  const scaleState = this.#chartContext[`${axis}ScaleState`];
839
- if (!scaleState?.t0)
840
- continue;
841
- const t0Epoch = Math.floor(scaleState.t0.getTime() / 1000);
842
1056
  for (const ch of channels) {
843
1057
  if (!(ch in select))
844
1058
  continue;
@@ -854,6 +1068,20 @@ export class MarkLifecycleV2 {
854
1068
  }
855
1069
  }
856
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
+ }
857
1085
  /**
858
1086
  * Fetch MIN/MAX stats for continuous scale fields.
859
1087
  * Replaces Mosaic's queryFieldInfo (old Effect 5 fieldInfo path).
@@ -922,7 +1150,8 @@ export class MarkLifecycleV2 {
922
1150
  }
923
1151
  }
924
1152
  const q = Query.from(this.#config.table).select(selectDef);
925
- 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 });
926
1155
  const row = result.get(0);
927
1156
  if (!row)
928
1157
  return null;
@@ -969,8 +1198,7 @@ export class MarkLifecycleV2 {
969
1198
  const additionalChannels = this.#transformCompiler.getAdditionalChannels();
970
1199
  const xState = this.#chartContext.xScaleState;
971
1200
  const yState = this.#chartContext.yScaleState;
972
- 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, false, // isRebinning
973
- 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);
974
1202
  this.#dataDerivedDomainsSet = domainResult.dataDerivedDomainsSet;
975
1203
  this.#skipDomainUpdateForAxis = domainResult.skipDomainUpdateForAxis;
976
1204
  markDeferredScalesReady(this.#scaleMap, this.#channelsNeedingDataDomain, this.actualXScaleType, this.actualYScaleType);
@@ -1084,6 +1312,13 @@ export class MarkLifecycleV2 {
1084
1312
  // the view. Failing here gives a message naming the mark and the fields;
1085
1313
  // continuing would fail at the binder with neither.
1086
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;
1087
1322
  for (const { key } of categorical)
1088
1323
  this.#identityFields.add(key);
1089
1324
  // Tell the transforms which dim each categorical channel is identified by.
@@ -1330,6 +1565,11 @@ export class MarkLifecycleV2 {
1330
1565
  * never mixed, the other axis's channels morph, and the band scale owns the
1331
1566
  * clock both run on.
1332
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
+ *
1333
1573
  * The first channel of the transforms' alignment — `dense()`'s plan, over the
1334
1574
  * same select and facts its `apply()` sees — so the conditions are written
1335
1575
  * once. Derived rather than recorded at apply time: apply runs for every
@@ -1343,10 +1583,13 @@ export class MarkLifecycleV2 {
1343
1583
  void this.#identity;
1344
1584
  // Asked exactly as assembleQuery asks when it orders the query, so this and
1345
1585
  // the rows cannot disagree about which channel they are aligned on.
1346
- const band = this.#transformCompiler.alignment(Object.keys(parts.selectDef), {
1347
- groupKeys: parts.groupKeys
1586
+ const aligned = this.#transformCompiler.alignment(Object.keys(parts.selectDef), {
1587
+ groupKeys: parts.groupKeys,
1588
+ bins: parts.bins ?? null
1348
1589
  })?.[0];
1349
- 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;
1350
1593
  });
1351
1594
  /**
1352
1595
  * Build a query for fetchGlobalAggregates.
@@ -1421,9 +1664,10 @@ export class MarkLifecycleV2 {
1421
1664
  // --- Selection routing ---
1422
1665
  /**
1423
1666
  * The identity keys of the categorical fields this mark's renderer reads
1424
- * selection activity for, sorted: its ordinal colour and its band axes, as
1425
- * activity-reads.ts says the renderer reads them. A categorical selection on
1426
- * 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.
1427
1671
  */
1428
1672
  #reads = $derived.by(() => {
1429
1673
  const renderer = rendererForMark(this.#config.markType);
@@ -1437,10 +1681,15 @@ export class MarkLifecycleV2 {
1437
1681
  const band = this.#chartContext[`${axis}ScaleState`];
1438
1682
  return band?.identityKey ?? null;
1439
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');
1440
1688
  return readsForResponse(this.#reactiveConfig.selectionResponse, readsFor(activityReads(renderer, this.#config.ruleExtent ?? false), {
1441
1689
  color: color?.isOrdinal ? color.identityKey : null,
1442
1690
  x: bandKey('x'),
1443
- y: bandKey('y')
1691
+ y: bandKey('y'),
1692
+ series: ownSeries ? (this.#seriesScaleState?.identityKey ?? null) : null
1444
1693
  }));
1445
1694
  });
1446
1695
  /** `#reads` as one string, so the registration effect re-runs only on a real change. */
@@ -1455,6 +1704,24 @@ export class MarkLifecycleV2 {
1455
1704
  // NO_GROUP for selectionResponse 'filter', which never dims.
1456
1705
  return groupIndexForResponse(this.#reactiveConfig.selectionResponse, this.#filterGroup.index);
1457
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);
1458
1725
  get dataVersion() {
1459
1726
  return this.#handle?.dataVersion ?? 0;
1460
1727
  }
@@ -1600,8 +1867,14 @@ export class MarkLifecycleV2 {
1600
1867
  // ===========================================================================
1601
1868
  // TOOLTIP WRITE-BACK
1602
1869
  // ===========================================================================
1603
- 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) {
1604
1875
  const tableName = this.#tooltipTableName;
1876
+ // Before any statement: a write that fails part way can leave a table.
1877
+ this.#tooltipTableWritten = true;
1605
1878
  try {
1606
1879
  if (!this.#transformCompiler.hasJSTransforms()) {
1607
1880
  // Path A: Build a query with original field names (not CAST-wrapped).
@@ -1644,11 +1917,17 @@ export class MarkLifecycleV2 {
1644
1917
  replaceExprs.push(`CAST(${c} AS DOUBLE) AS ${c}`);
1645
1918
  }
1646
1919
  const replaceClause = replaceExprs.length > 0 ? ` REPLACE (${replaceExprs.join(', ')})` : '';
1647
- // 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.
1648
1928
  let orderClause = '';
1649
- const tooltipSortTable = typeof this.#reactiveConfig.tooltip === 'object'
1650
- ? this.#reactiveConfig.tooltip.sortTable !== false
1651
- : true;
1929
+ const tooltipSortTable = typeof this.#reactiveConfig.tooltip === 'object' &&
1930
+ this.#reactiveConfig.tooltip.sortTable === true;
1652
1931
  if (tooltipSortTable) {
1653
1932
  const orderCol = ['x', 'x1', 'x2'].find((c) => c in selectConfig);
1654
1933
  if (orderCol)
@@ -1661,7 +1940,7 @@ export class MarkLifecycleV2 {
1661
1940
  // lists are the only ones whose size grows with the field.
1662
1941
  const withoutMembers = withoutOtherMembers(tooltipQuery);
1663
1942
  // One statement: a hover query never finds the table missing.
1664
- await this.#queryEngine.exec(`CREATE OR REPLACE TEMP TABLE "${tableName}" AS SELECT *${replaceClause} FROM (${withoutMembers})${orderClause}`);
1943
+ await this.#queryEngine.exec(`CREATE OR REPLACE TEMP TABLE "${tableName}" AS SELECT *${replaceClause} FROM (${withoutMembers})${orderClause}`, { class: cls, connectionLocal: true });
1665
1944
  }
1666
1945
  else {
1667
1946
  // Path B: Serialize transformed data via SQL VALUES.
@@ -1703,13 +1982,13 @@ export class MarkLifecycleV2 {
1703
1982
  `DROP TABLE IF EXISTS "${tableName}"`,
1704
1983
  `CREATE TEMP TABLE "${tableName}" (${schemaDef})`,
1705
1984
  `INSERT INTO "${tableName}" VALUES ${valueRows.join(', ')}`
1706
- ]);
1985
+ ], { class: cls, connectionLocal: true });
1707
1986
  }
1708
1987
  // Merge tip expression extras (aggregates/windows) via sidecar query.
1709
1988
  const tipExtras = this.#getTipExtras();
1710
1989
  if (Object.keys(tipExtras).length > 0) {
1711
1990
  try {
1712
- await this.#mergeTipExtras(tableName, tipExtras);
1991
+ await this.#mergeTipExtras(tableName, tipExtras, cls);
1713
1992
  }
1714
1993
  catch (err) {
1715
1994
  console.warn('Failed to merge tip extras into tooltip table:', err);
@@ -1772,7 +2051,7 @@ export class MarkLifecycleV2 {
1772
2051
  * DB query. Computes aggregates against the source table using the same
1773
2052
  * GROUP BY as the main query, then ALTER+UPDATE the temp table.
1774
2053
  */
1775
- async #mergeTipExtras(tableName, tipExtras) {
2054
+ async #mergeTipExtras(tableName, tipExtras, cls) {
1776
2055
  if (!this.#resolvedQueryParts)
1777
2056
  return;
1778
2057
  // Identify non-aggregate channel columns — these are the join keys
@@ -1810,7 +2089,10 @@ export class MarkLifecycleV2 {
1810
2089
  .map((ch) => `${ident(tableName)}.${ident(ch)} = _s.${ident(ch)}`)
1811
2090
  .join(' AND ');
1812
2091
  const updateStmt = `UPDATE ${ident(tableName)} SET ${setClauses} FROM (${sidecarQuery.toString()}) _s WHERE ${joinCondition}`;
1813
- await this.#queryEngine.exec([...alterStmts, updateStmt]);
2092
+ await this.#queryEngine.exec([...alterStmts, updateStmt], {
2093
+ class: cls,
2094
+ connectionLocal: true
2095
+ });
1814
2096
  }
1815
2097
  // ===========================================================================
1816
2098
  // FIT DOMAIN
@@ -1843,7 +2125,9 @@ export class MarkLifecycleV2 {
1843
2125
  }
1844
2126
  return channels;
1845
2127
  }
1846
- 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) {
1847
2131
  if (!force && drivingDomain) {
1848
2132
  const [lastLo, lastHi] = this.#fitDomainLastTarget;
1849
2133
  const lastWidth = lastHi - lastLo;
@@ -1862,7 +2146,11 @@ export class MarkLifecycleV2 {
1862
2146
  return;
1863
2147
  const result = await this.#queryEngine.query(q.toString(), {
1864
2148
  callerId: `${this.#markId}:fitDomain`,
1865
- 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
1866
2154
  });
1867
2155
  if (seq !== this.#fitDomainQuerySequence)
1868
2156
  return;
@@ -1887,11 +2175,14 @@ export class MarkLifecycleV2 {
1887
2175
  }
1888
2176
  const scaleState = fittedAxis === 'x' ? this.#chartContext.xScaleState : this.#chartContext.yScaleState;
1889
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);
1890
2181
  if (scaleType === 'time' && scaleState && 'addOffsetExtent' in scaleState) {
1891
- scaleState.addOffsetExtent([lo, hi], this.#markId);
2182
+ scaleState.addOffsetExtent([lo, hi], this.#markId, { instant });
1892
2183
  }
1893
2184
  else if (scaleState && 'addMark' in scaleState) {
1894
- scaleState.addMark([lo, hi], this.#markId);
2185
+ scaleState.addMark([lo, hi], this.#markId, { instant });
1895
2186
  }
1896
2187
  if (drivingDomain)
1897
2188
  this.#fitDomainLastTarget = [...drivingDomain];
@@ -1958,15 +2249,32 @@ export class MarkLifecycleV2 {
1958
2249
  return q;
1959
2250
  }
1960
2251
  /**
1961
- * Called after tooltip table is written. If fitDomain is active, issue a
1962
- * 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).
1963
2255
  */
1964
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() {
1965
2267
  const fitDomain = this.#reactiveConfig.fitDomain;
1966
2268
  if (!fitDomain)
1967
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;
1968
2274
  if (this.#fitDomainSuppressed)
1969
2275
  return;
2276
+ if (snap)
2277
+ this.#snapFitVersion = this.dataVersion;
1970
2278
  const fittedAxis = this.#resolveFittedAxis(fitDomain);
1971
2279
  if (!fittedAxis)
1972
2280
  return;
@@ -1975,19 +2283,37 @@ export class MarkLifecycleV2 {
1975
2283
  if (!drivingSS)
1976
2284
  return;
1977
2285
  if (drivingSS.scaleType === 'band') {
1978
- this.#issueFitDomainQuery(fittedAxis, null, true);
2286
+ this.#issueFitDomainQuery(fittedAxis, null, true, snap);
1979
2287
  return;
1980
2288
  }
1981
2289
  const target = drivingSS.domain?.target;
1982
2290
  if (!target)
1983
2291
  return;
1984
- 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();
1985
2308
  }
1986
2309
  // ===========================================================================
1987
2310
  // CLEANUP
1988
2311
  // ===========================================================================
1989
2312
  destroy() {
1990
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).
1991
2317
  this.#identityResetOff?.();
1992
2318
  this.#identityResetOff = null;
1993
2319
  this.otherRows.unregister();
@@ -1995,6 +2321,7 @@ export class MarkLifecycleV2 {
1995
2321
  this.#releaseOther(scale);
1996
2322
  this.#handle?.destroy();
1997
2323
  clearTimeout(this.#fitDomainTimeout);
2324
+ clearTimeout(this.#refitTimeout);
1998
2325
  clearTimeout(this.#tooltipWriteTimeout);
1999
2326
  if (this.#onMarkLOD) {
2000
2327
  this.#chartContext.config.unregisterMarkLOD(this.#markId);
@@ -2010,6 +2337,23 @@ export class MarkLifecycleV2 {
2010
2337
  // where destroy() is called synchronously before Svelte processes
2011
2338
  // effect teardown.
2012
2339
  this.#tooltipTableReady = false;
2013
- this.#queryEngine.exec(`DROP TABLE IF EXISTS "${this.#tooltipTableName}"`).catch(() => { });
2340
+ this.#dropTooltipTable();
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(() => { });
2014
2354
  }
2015
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]);
2359
+ }