@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
@@ -55,6 +55,7 @@ export class ChartScales {
55
55
  this.gpuCoordinator = config.gpuCoordinator;
56
56
  this.#scaleRegistry = config.scaleRegistry;
57
57
  this.#getFacetChartIds = config.getFacetChartIds ?? null;
58
+ this.#entranceGateOff = config.entranceGate?.onOpen(() => this.#markScalesEntered()) ?? null;
58
59
  if (!this.#scaleRegistry) {
59
60
  console.error('ChartScales initialized without scaleRegistry!');
60
61
  }
@@ -271,14 +272,10 @@ export class ChartScales {
271
272
  xScaleConfig.__fitDomain = this.chartId;
272
273
  const neededXScale = this.#scaleRegistry.getOrCreateScale(xKey, xConfig.type, xScaleConfig, this.chartId);
273
274
  if (this.#xScaleInstance !== neededXScale) {
274
- const wasReplacement = !!this.#xScaleInstance;
275
275
  if (this.#xScaleInstance) {
276
276
  this.#scaleRegistry.deregisterChart(this.#xScaleInstance, this.chartId);
277
277
  }
278
278
  this.#xScaleInstance = neededXScale;
279
- if (wasReplacement && neededXScale instanceof LinearScaleState) {
280
- neededXScale.zoomInEffect.enabled = false;
281
- }
282
279
  }
283
280
  }
284
281
  if (yConfig) {
@@ -290,16 +287,13 @@ export class ChartScales {
290
287
  yScaleConfig.__fitDomain = this.chartId;
291
288
  const neededYScale = this.#scaleRegistry.getOrCreateScale(yKey, yConfig.type, yScaleConfig, this.chartId);
292
289
  if (this.#yScaleInstance !== neededYScale) {
293
- const wasReplacement = !!this.#yScaleInstance;
294
290
  if (this.#yScaleInstance) {
295
291
  this.#scaleRegistry.deregisterChart(this.#yScaleInstance, this.chartId);
296
292
  }
297
293
  this.#yScaleInstance = neededYScale;
298
- if (wasReplacement && neededYScale instanceof LinearScaleState) {
299
- neededYScale.zoomInEffect.enabled = false;
300
- }
301
294
  }
302
295
  }
296
+ this.#markScalesEntered();
303
297
  }
304
298
  scaleConfigurations = $derived.by(() => {
305
299
  try {
@@ -438,6 +432,7 @@ export class ChartScales {
438
432
  this.#scaleRegistry.deregisterChart(scale, this.chartId);
439
433
  }
440
434
  this.#parallelScaleInstances = next;
435
+ this.#markScalesEntered();
441
436
  this.#parallelAxisOrder = wanted.map((_, i) => i);
442
437
  if (next.size > 0)
443
438
  this.#ensureReorder().resetSprings();
@@ -764,7 +759,30 @@ export class ChartScales {
764
759
  setBinStepMultiplier: (v) => coordinator.setBinStepMultiplier(v)
765
760
  };
766
761
  }
762
+ /**
763
+ * A scale snaps every extent change until a chart drawing it has its marks'
764
+ * first data in, so the domain is that chart's whole extent when it enters,
765
+ * and its axes show no ticks before then (the domain is a placeholder).
766
+ * Marked here when the chart's entrance gate opens, and again whenever the
767
+ * scale instances change after that.
768
+ */
769
+ #entranceGateOff = null;
770
+ #markScalesEntered() {
771
+ const gate = this.config.entranceGate;
772
+ if (gate && !gate.opened)
773
+ return;
774
+ for (const scale of [
775
+ this.#xScaleInstance,
776
+ this.#yScaleInstance,
777
+ ...this.#parallelScaleInstances.values()
778
+ ]) {
779
+ if (scale instanceof LinearScaleState && !scale.entered)
780
+ scale.entered = true;
781
+ }
782
+ }
767
783
  destroy() {
784
+ this.#entranceGateOff?.();
785
+ this.#entranceGateOff = null;
768
786
  this.#reorder?.destroy();
769
787
  this.#reorder = null;
770
788
  if (this.#scaleRegistry) {
@@ -1,6 +1,6 @@
1
1
  import type { RGBA, D3ColorScale } from './color-utils';
2
2
  import type { FieldIdentity } from '../query-engine/categorical-identity';
3
- import type { ColorScaleType } from '../types';
3
+ import type { ColorDomainValue, ColorScaleType } from '../types';
4
4
  import type { ScaleRegistry } from './ScaleRegistry.svelte';
5
5
  import { type GPUCoordinator } from '../gpu/GPUCoordinator.svelte';
6
6
  import type { SlotSource } from '../gpu/identity-slot-packing';
@@ -13,6 +13,10 @@ interface ColorScaleConfig {
13
13
  midpoint?: number;
14
14
  constant?: number;
15
15
  exponent?: number;
16
+ /** Explicit palette colours, in place of the scheme's. */
17
+ range?: readonly string[] | null;
18
+ /** Values pinned to palette positions: pinnedDomain[i] draws range[i]. */
19
+ pinnedDomain?: readonly ColorDomainValue[] | null;
16
20
  }
17
21
  /**
18
22
  * Reactive state for a GPU-backed color scale.
@@ -118,9 +122,9 @@ export declare class ColorScaleState {
118
122
  * the new one and simply mean other categories.
119
123
  */
120
124
  setIdentity(identity: FieldIdentity): void;
121
- /** True once this scale's channel carries dim ids rather than palette indices. */
122
125
  /** The field's live id <-> value bijection, once adopted. */
123
126
  get identity(): FieldIdentity | null;
127
+ /** True once this scale's channel carries dim ids rather than palette indices. */
124
128
  get hasIdentity(): boolean;
125
129
  /**
126
130
  * Source value for a channel id — the decode side of the encode the query
@@ -134,6 +138,18 @@ export declare class ColorScaleState {
134
138
  * Update the scheme. Triggers GPU re-upload.
135
139
  */
136
140
  setScheme(newScheme: string): void;
141
+ /**
142
+ * Set explicit palette colours and pinned values, or clear them with nulls.
143
+ * Triggers GPU re-upload.
144
+ *
145
+ * The ranks are cleared: they were handed out under the old mapping, and a
146
+ * new pin means the author has just said which value takes which colour.
147
+ */
148
+ setRange(range: readonly string[] | null, pinnedDomain: readonly ColorDomainValue[] | null): void;
149
+ /** The explicit palette colours, or null for the scheme's. */
150
+ get range(): readonly string[] | null;
151
+ /** The pinned values, or null when ranks follow the domain's sorted order. */
152
+ get pinnedDomain(): readonly ColorDomainValue[] | null;
137
153
  /**
138
154
  * Update the normalization transform. Triggers GPU re-upload.
139
155
  * Called when the user changes the color scale type (e.g., linear → symlog)
@@ -78,6 +78,17 @@ export class ColorScaleState {
78
78
  * stop meaning anything and holding colours to them would be wrong.
79
79
  */
80
80
  #paletteRank = new Map();
81
+ /**
82
+ * Explicit palette colours (`range` on the colour prop), or null for the
83
+ * scheme's. Ordinal only.
84
+ */
85
+ #range = null;
86
+ /**
87
+ * Values pinned to palette positions (`domain` on the colour prop): value i
88
+ * takes rank i, in place of its position in the sorted domain. Values outside
89
+ * it draw the null colour. Null when ranks follow the domain, the default.
90
+ */
91
+ #pinned = null;
81
92
  #nextRank = 0;
82
93
  /**
83
94
  * id -> rank as a dense array, packed into the shared identity slot table so
@@ -179,6 +190,8 @@ export class ColorScaleState {
179
190
  this.#midpoint = config.midpoint;
180
191
  this.#constant = config.constant ?? 1;
181
192
  this.#exponent = config.exponent ?? 1;
193
+ this.#range = config.range?.length ? [...config.range] : null;
194
+ this.#pinned = config.pinnedDomain?.length ? [...config.pinnedDomain] : null;
182
195
  this.#registry = registry;
183
196
  this.#gpuCoordinator = gpuCoordinator;
184
197
  // Explicit type wins, fallback to inference from scheme
@@ -373,7 +386,9 @@ export class ColorScaleState {
373
386
  this.#refreshRanks();
374
387
  this.#d3Scale = this.#hasRanks()
375
388
  ? this.#buildRankedOrdinalScale()
376
- : createD3Scale(this.#scheme, this.domain, { reverse: this.#reverse });
389
+ : this.#range || this.#pinned
390
+ ? this.#buildExplicitOrdinalScale()
391
+ : createD3Scale(this.#scheme, this.domain, { reverse: this.#reverse });
377
392
  this.#uploadOrdinal();
378
393
  }
379
394
  else {
@@ -457,42 +472,58 @@ export class ColorScaleState {
457
472
  if (hadIdentity !== this.hasIdentity && this.domain.length > 0)
458
473
  this.#uploadToGPU();
459
474
  }
460
- /** True once this scale's channel carries dim ids rather than palette indices. */
461
475
  /** The field's live id <-> value bijection, once adopted. */
462
476
  get identity() {
463
477
  return this.#identity ?? null;
464
478
  }
479
+ /** True once this scale's channel carries dim ids rather than palette indices. */
465
480
  get hasIdentity() {
466
481
  return this.isOrdinal && (this.#identity?.present ?? false);
467
482
  }
468
- /**
469
- * Hand a rank to every domain category that does not have one, then rebuild
470
- * the dense table. Existing ranks are never touched, which is what makes a
471
- * colour survive its category disappearing.
472
- */
473
483
  /** The value -> position map, rebuilt only when the domain array changes. */
474
484
  #categoryPositions() {
475
485
  if (this.#categoryMap && this.#categoryMapFor === this.domain)
476
486
  return this.#categoryMap;
477
487
  const map = new Map();
478
- this.domain.forEach((cat, i) => map.set(cat, i));
488
+ // Pinned values index the palette directly; anything else maps to -1.
489
+ const positions = this.#pinned ?? this.domain;
490
+ positions.forEach((cat, i) => map.set(cat, i));
479
491
  this.#categoryMap = map;
480
492
  this.#categoryMapFor = this.domain;
481
493
  return map;
482
494
  }
495
+ /**
496
+ * Hand a rank to every domain category that does not have one, then rebuild
497
+ * the dense table. Existing ranks are never touched, which is what makes a
498
+ * colour survive its category disappearing.
499
+ */
483
500
  #refreshRanks() {
484
501
  const identity = this.#identity;
485
502
  if (!identity?.present)
486
503
  return;
487
504
  this.#ranksVersion = identity.version;
488
- for (const value of this.domain) {
489
- const id = identity.ids.get(value);
490
- if (id === undefined || this.#paletteRank.has(id))
491
- continue;
492
- // The NULL category takes no palette slot (see below).
493
- if (id === identity.nullId)
494
- continue;
495
- this.#paletteRank.set(id, this.#nextRank++);
505
+ const pinned = this.#pinned;
506
+ if (pinned) {
507
+ // Pinned: value i takes rank i. Values that have not appeared yet keep
508
+ // their rank reserved, the same way a filtered-out category does. A
509
+ // pinned null is the one way the NULL category takes a palette colour.
510
+ pinned.forEach((value, rank) => {
511
+ const id = ColorScaleState.#idOf(identity, value);
512
+ if (id !== undefined && !this.#paletteRank.has(id))
513
+ this.#paletteRank.set(id, rank);
514
+ });
515
+ this.#nextRank = Math.max(this.#nextRank, pinned.length);
516
+ }
517
+ else {
518
+ for (const value of this.domain) {
519
+ const id = identity.ids.get(value);
520
+ if (id === undefined || this.#paletteRank.has(id))
521
+ continue;
522
+ // The NULL category takes no palette slot (see below).
523
+ if (id === identity.nullId)
524
+ continue;
525
+ this.#paletteRank.set(id, this.#nextRank++);
526
+ }
496
527
  }
497
528
  const table = new Float32Array(identity.values.length);
498
529
  table.fill(-1);
@@ -503,9 +534,18 @@ export class ColorScaleState {
503
534
  // The NULL category and the Other buckets draw the null colour: no palette
504
535
  // slot, so they read as missing data or "the rest" and shift no category's
505
536
  // colour. Not -1, which the shader draws transparent: a category outside
506
- // this scale's domain.
507
- if (identity.nullId !== null)
537
+ // this scale's domain. With a pinned domain, so does every category the
538
+ // pin leaves out: drawn, but plainly not one of the named ones.
539
+ if (pinned) {
540
+ for (const value of this.domain) {
541
+ const id = identity.ids.get(value);
542
+ if (id !== undefined && table[id] === -1)
543
+ table[id] = NULL_COLOR_RANK;
544
+ }
545
+ }
546
+ if (identity.nullId !== null && !this.#paletteRank.has(identity.nullId)) {
508
547
  table[identity.nullId] = NULL_COLOR_RANK;
548
+ }
509
549
  for (const id of identity.synthetic)
510
550
  if (id < table.length)
511
551
  table[id] = NULL_COLOR_RANK;
@@ -519,6 +559,39 @@ export class ColorScaleState {
519
559
  #hasRanks() {
520
560
  return this.hasIdentity && this.#paletteRank.size > 0;
521
561
  }
562
+ /**
563
+ * The dim id of a pinned value. Dim values keep the driver's type (an
564
+ * INTEGER column's are numbers, a BIGINT's may be bigints), while a pinned
565
+ * domain is whatever the author wrote, or JSON produced: `0` against `0n`,
566
+ * `'1'` against `1`. An exact match wins; failing that, the first value with
567
+ * the same string form.
568
+ */
569
+ static #idOf(identity, value) {
570
+ const exact = identity.ids.get(value);
571
+ if (exact !== undefined)
572
+ return exact;
573
+ if (value === null)
574
+ return identity.nullId ?? undefined;
575
+ const key = String(value);
576
+ const i = identity.values.findIndex((v) => v !== null && String(v) === key);
577
+ return i < 0 ? undefined : i;
578
+ }
579
+ /** The palette's colours: the explicit range, else the scheme's. */
580
+ #colors() {
581
+ const colors = this.#range ?? getOrdinalColors(this.#scheme) ?? [];
582
+ return this.#reverse ? [...colors].reverse() : colors;
583
+ }
584
+ /**
585
+ * The CPU scale for an explicit range or pinned domain before identity has
586
+ * landed, indexed the way #categoryPositions indexes the GPU's palette.
587
+ */
588
+ #buildExplicitOrdinalScale() {
589
+ const colors = this.#colors();
590
+ const positions = this.#pinned ?? this.domain;
591
+ return scaleOrdinal()
592
+ .domain(positions.map(String))
593
+ .range(positions.map((_, i) => colors[i % colors.length]));
594
+ }
522
595
  /**
523
596
  * An ordinal scale whose range is permuted to match the palette ranks, so
524
597
  * `getColor(value)` returns the colour the GPU actually draws.
@@ -529,9 +602,7 @@ export class ColorScaleState {
529
602
  * happening to be domain order.
530
603
  */
531
604
  #buildRankedOrdinalScale() {
532
- let colors = getOrdinalColors(this.#scheme) ?? [];
533
- if (this.#reverse)
534
- colors = [...colors].reverse();
605
+ const colors = this.#colors();
535
606
  const identity = this.#identity;
536
607
  const range = this.domain.map((value) => {
537
608
  const id = identity.ids.get(value);
@@ -558,7 +629,7 @@ export class ColorScaleState {
558
629
  /** Called from #uploadToGPU only, which has refreshed the ranks. */
559
630
  #uploadOrdinal() {
560
631
  // Bake and upload palette
561
- const paletteData = bakePalette(this.#scheme, { reverse: this.#reverse });
632
+ const paletteData = bakePalette(this.#range ?? this.#scheme, { reverse: this.#reverse });
562
633
  this.#gpuCoordinator.writeColorPalette(this.#paletteSlot, paletteData);
563
634
  // Build and upload config struct
564
635
  // paletteSize is the modulus the shader wraps a rank with, so it has to
@@ -570,7 +641,9 @@ export class ColorScaleState {
570
641
  // Capped at 32, the palette's size, so a chart with more categories than
571
642
  // that wraps as it always has. Floored at 1: this is a u32 modulus and 0
572
643
  // is undefined behaviour in WGSL.
573
- const rankSpace = this.hasIdentity ? this.#nextRank : this.domain.length;
644
+ const rankSpace = this.hasIdentity
645
+ ? this.#nextRank
646
+ : (this.#pinned?.length ?? this.domain.length);
574
647
  const config = this.#buildColorConfig({
575
648
  type: 0, // ORDINAL
576
649
  paletteOffset: this.#paletteSlot * 32,
@@ -668,6 +741,40 @@ export class ColorScaleState {
668
741
  this.#registry.invalidateChartsUsingScale(this);
669
742
  }
670
743
  }
744
+ /**
745
+ * Set explicit palette colours and pinned values, or clear them with nulls.
746
+ * Triggers GPU re-upload.
747
+ *
748
+ * The ranks are cleared: they were handed out under the old mapping, and a
749
+ * new pin means the author has just said which value takes which colour.
750
+ */
751
+ setRange(range, pinnedDomain) {
752
+ const nextRange = range?.length ? [...range] : null;
753
+ const nextPinned = pinnedDomain?.length ? [...pinnedDomain] : null;
754
+ if (sameList(nextRange, this.#range) && sameList(nextPinned, this.#pinned))
755
+ return;
756
+ this.#range = nextRange;
757
+ this.#pinned = nextPinned;
758
+ this.#paletteRank.clear();
759
+ this.#nextRank = 0;
760
+ this.#categoryMap = null;
761
+ this._version++;
762
+ // CPU state, rebuilt here rather than left to the upload, which needs a
763
+ // coordinator: a legend reads the ranks too.
764
+ this.#refreshRanks();
765
+ if (this.domain.length > 0) {
766
+ this.#uploadToGPU();
767
+ this.#registry.invalidateChartsUsingScale(this);
768
+ }
769
+ }
770
+ /** The explicit palette colours, or null for the scheme's. */
771
+ get range() {
772
+ return this.#range;
773
+ }
774
+ /** The pinned values, or null when ranks follow the domain's sorted order. */
775
+ get pinnedDomain() {
776
+ return this.#pinned;
777
+ }
671
778
  /**
672
779
  * Update the normalization transform. Triggers GPU re-upload.
673
780
  * Called when the user changes the color scale type (e.g., linear → symlog)
@@ -759,9 +866,7 @@ export class ColorScaleState {
759
866
  const rank = id === undefined ? undefined : this.#paletteRank.get(id);
760
867
  if (rank === undefined)
761
868
  return ColorScaleState.NULL_COLOR;
762
- let colors = getOrdinalColors(this.#scheme) ?? [];
763
- if (this.#reverse)
764
- colors = [...colors].reverse();
869
+ const colors = this.#colors();
765
870
  // Same wrap the shader applies, so an over-32 palette agrees too.
766
871
  return colors.length ? colors[rank % colors.length] : ColorScaleState.NULL_COLOR;
767
872
  }
@@ -819,3 +924,10 @@ export class ColorScaleState {
819
924
  `now belong to another scale. Register the holder's chart id.`);
820
925
  }
821
926
  }
927
+ function sameList(a, b) {
928
+ if (a === b)
929
+ return true;
930
+ if (!a || !b || a.length !== b.length)
931
+ return false;
932
+ return a.every((v, i) => v === b[i]);
933
+ }
@@ -6,11 +6,6 @@ export interface LinearScaleOptions {
6
6
  chartsUsing(scale: unknown): string[];
7
7
  } | null;
8
8
  disableZoom?: boolean;
9
- zoomInEffect?: boolean;
10
- zoomInFactor?: number;
11
- zoomInSpringConfig?: {
12
- stiffness?: number;
13
- };
14
9
  }
15
10
  export interface GpuScaleState {
16
11
  domainMin: number;
@@ -68,11 +63,6 @@ export declare class LinearScaleState {
68
63
  min: number;
69
64
  };
70
65
  disableZoom: boolean;
71
- zoomInEffect: {
72
- enabled: boolean;
73
- factor: number;
74
- stiffness: number;
75
- };
76
66
  /**
77
67
  * Charts to redraw while this scale animates, asked of the registry rather
78
68
  * than written into this object.
@@ -157,6 +147,13 @@ export declare class LinearScaleState {
157
147
  * @param visualCenter - Cursor position as a visual fraction [0,1] (0=left/bottom, 1=right/top)
158
148
  */
159
149
  accumulateZoom(visualCenter: number, scaleFactor: number): void;
150
+ /**
151
+ * True once a chart drawing this scale has its marks' first data in (its
152
+ * entrance gate opened). Until then every extent change snaps, and the
153
+ * axes draw no ticks: the domain is a placeholder, [0, 100], and showing
154
+ * it, or springing out of it, is the zoom-in on load by another route.
155
+ */
156
+ entered: boolean;
160
157
  /**
161
158
  * Reset zoom to show full extent.
162
159
  */
@@ -1,6 +1,7 @@
1
1
  // =============================================================================
2
2
  // LinearScaleState.svelte.ts - Refactored with SpringPair physics
3
3
  // =============================================================================
4
+ import { noteTransition, transitionsObserved } from '../animation/transition-log';
4
5
  import { scaleLinear } from 'd3-scale';
5
6
  import { extent as d3Extent } from 'd3-array';
6
7
  import { SpringPair, SPRING_PRESETS } from '../animation';
@@ -12,7 +13,6 @@ const SCALE_SPRING_PRESETS = {
12
13
  pan: SPRING_PRESETS.pan.stiffness, // 600 - responsive dragging
13
14
  zoom: SPRING_PRESETS.zoom.stiffness, // 400 - smooth zoom
14
15
  reset: SPRING_PRESETS.reset.stiffness, // 150 - gentle reset animation
15
- zoomIn: 200, // Initial zoom-in effect
16
16
  externalDomain: 600 // Brush-driven domain tracking (snappy, like pan)
17
17
  };
18
18
  // =============================================================================
@@ -112,12 +112,6 @@ export class LinearScaleState {
112
112
  center: 0,
113
113
  hasUpdates: false
114
114
  };
115
- // --- Zoom-In Effect Config ---
116
- zoomInEffect = {
117
- enabled: true,
118
- factor: 5,
119
- stiffness: SCALE_SPRING_PRESETS.zoomIn
120
- };
121
115
  // --- Affected Charts Cache (set by GPUCoordinator) ---
122
116
  /**
123
117
  * Charts to redraw while this scale animates, asked of the registry rather
@@ -170,17 +164,6 @@ export class LinearScaleState {
170
164
  // Store zoom limits
171
165
  this.zoomLimits.max = zoomLimits[0];
172
166
  this.zoomLimits.min = zoomLimits[1];
173
- // Zoom-in effect options
174
- if (options.zoomInEffect !== undefined) {
175
- this.zoomInEffect.enabled = options.zoomInEffect;
176
- }
177
- if (options.zoomInFactor !== undefined) {
178
- this.zoomInEffect.factor = options.zoomInFactor;
179
- }
180
- if (options.zoomInSpringConfig !== undefined) {
181
- this.zoomInEffect.stiffness =
182
- options.zoomInSpringConfig.stiffness || SCALE_SPRING_PRESETS.zoomIn;
183
- }
184
167
  // Create domain accessor object
185
168
  this.domain = this.#createDomainAccessor();
186
169
  }
@@ -404,6 +387,13 @@ export class LinearScaleState {
404
387
  this.#setStiffness(SCALE_SPRING_PRESETS.zoom);
405
388
  this.#ensureAnimating();
406
389
  }
390
+ /**
391
+ * True once a chart drawing this scale has its marks' first data in (its
392
+ * entrance gate opened). Until then every extent change snaps, and the
393
+ * axes draw no ticks: the domain is a placeholder, [0, 100], and showing
394
+ * it, or springing out of it, is the zoom-in on load by another route.
395
+ */
396
+ entered = $state(false);
407
397
  /**
408
398
  * Reset zoom to show full extent.
409
399
  */
@@ -422,28 +412,20 @@ export class LinearScaleState {
422
412
  instant = true;
423
413
  }
424
414
  }
425
- if (instant || (!this.ready && !this.zoomInEffect.enabled)) {
426
- // Instant update - skip animation entirely.
427
- // Also snaps when zoom-in is disabled on a not-yet-ready scale
428
- // (e.g., column reassignment — no point animating from default domain).
429
- this.#setInstant(min, max);
415
+ if (transitionsObserved()) {
416
+ noteTransition('domain', instant || !this.entered, () => this._affectedChartsArray, this.name);
430
417
  }
431
- else if (!this.ready && this.zoomInEffect.enabled) {
432
- // First time - do zoom-in effect from expanded view
433
- this.#setStiffness(this.zoomInEffect.stiffness);
434
- const center = (min + max) / 2;
435
- const range = max - min;
436
- const factor = this.zoomInEffect.factor;
437
- const expandedMin = center - (range * factor) / 2;
438
- const expandedMax = center + (range * factor) / 2;
439
- // Set current position to expanded, target to actual
440
- this.#springPair.set(this.toSpringSpace(expandedMin), this.toSpringSpace(expandedMax));
441
- this.#setTarget(min, max); // #setTarget transforms internally
442
- // Initialize render values so first frame renders correctly
443
- this.#renderMin = expandedMin;
444
- this.#renderMax = expandedMax;
445
- this.#updateGpuState();
446
- this.#ensureAnimating();
418
+ if (instant || !this.entered) {
419
+ // Instant update - skip animation entirely. A scale's first domain
420
+ // snaps too: the viewport does not move on load. A chart's entrance
421
+ // is its data arriving (see the chart's entrance clock), not the
422
+ // camera travelling to it. So does every extent until a chart drawing
423
+ // this scale has its marks in (`entered`): the axes take the union of
424
+ // that chart's extents before it enters. Not `ready`, which several
425
+ // paths set before the first extent lands (time scales, field stats),
426
+ // and the scale then sprang from its placeholder domain. A chart that
427
+ // shares the scale and lands later springs it, as later data does.
428
+ this.#setInstant(min, max);
447
429
  }
448
430
  else {
449
431
  // Normal reset
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Counts replacements of a chart's x and y scales: a defined scale id giving
3
+ * way to a different one, as a relabelled axis or a re-resolved scale type
4
+ * does. Marks rebuild their lifecycle on it.
5
+ *
6
+ * Not the ids themselves, which marks tracked before: a mark's own lifecycle
7
+ * registers the scale requirements that make the scales resolve, so the ids
8
+ * went from undefined to defined right after every mark was built, and every
9
+ * lifecycle was rebuilt once for nothing (its scale registration redone, and a
10
+ * tooltip-table DROP sent to DuckDB). The first resolution is not a change.
11
+ *
12
+ * Owned by the chart, not by ChartScales: the container recreates its
13
+ * ChartScales when a label or the layout changes, and a counter on that
14
+ * instance would restart and miss the swap. A recreated ChartScales is itself
15
+ * a replacement: it starts with no scale requirements (the container replays
16
+ * only those queued before the first one existed), so marks must rebuild to
17
+ * register with it. Its first resolution after that is not counted again.
18
+ */
19
+ import type { ChartScales } from './ChartScales.svelte';
20
+ /** The ChartScales and last defined ids seen, and the replacements counted so far. */
21
+ export interface ScaleIds {
22
+ owner: unknown;
23
+ x: string | undefined;
24
+ y: string | undefined;
25
+ count: number;
26
+ }
27
+ /**
28
+ * Fold the current ChartScales and ids into `seen`. Counts a replacement when
29
+ * the ChartScales itself is a different one than before (it then forgets the
30
+ * ids: the new one's first resolution is part of the same replacement), or
31
+ * when an axis that had a defined id now has a different defined one.
32
+ * Undefined is neither a change nor remembered. Mutates and returns `seen`.
33
+ */
34
+ export declare function foldScaleIds(seen: ScaleIds, owner: unknown, x: string | undefined, y: string | undefined): ScaleIds;
35
+ export declare class ScaleGeneration {
36
+ #private;
37
+ constructor(scales: () => ChartScales | null | undefined);
38
+ /**
39
+ * Replacements so far. Reactive: reads the chart's current ChartScales and
40
+ * scale ids. The derived updates `#seen` as it computes, which is safe only
41
+ * because folding the same input twice changes nothing: a recompute that
42
+ * sees unchanged ids counts nothing. Reads skipped between changes can
43
+ * merge two replacements into one count, which is fine: marks only need to
44
+ * know that it changed (a recreated scale has a new id, so a scale that
45
+ * goes away and returns is still seen as different).
46
+ */
47
+ value: number;
48
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Fold the current ChartScales and ids into `seen`. Counts a replacement when
3
+ * the ChartScales itself is a different one than before (it then forgets the
4
+ * ids: the new one's first resolution is part of the same replacement), or
5
+ * when an axis that had a defined id now has a different defined one.
6
+ * Undefined is neither a change nor remembered. Mutates and returns `seen`.
7
+ */
8
+ export function foldScaleIds(seen, owner, x, y) {
9
+ if (owner && seen.owner && owner !== seen.owner) {
10
+ seen.count++;
11
+ seen.x = seen.y = undefined;
12
+ }
13
+ if (owner)
14
+ seen.owner = owner;
15
+ if ((x && seen.x && x !== seen.x) || (y && seen.y && y !== seen.y))
16
+ seen.count++;
17
+ if (x)
18
+ seen.x = x;
19
+ if (y)
20
+ seen.y = y;
21
+ return seen;
22
+ }
23
+ export class ScaleGeneration {
24
+ #scales;
25
+ #seen = { owner: undefined, x: undefined, y: undefined, count: 0 };
26
+ constructor(scales) {
27
+ this.#scales = scales;
28
+ }
29
+ /**
30
+ * Replacements so far. Reactive: reads the chart's current ChartScales and
31
+ * scale ids. The derived updates `#seen` as it computes, which is safe only
32
+ * because folding the same input twice changes nothing: a recompute that
33
+ * sees unchanged ids counts nothing. Reads skipped between changes can
34
+ * merge two replacements into one count, which is fine: marks only need to
35
+ * know that it changed (a recreated scale has a new id, so a scale that
36
+ * goes away and returns is still seen as different).
37
+ */
38
+ value = $derived.by(() => {
39
+ const scales = this.#scales();
40
+ return foldScaleIds(this.#seen, scales, scales?.xScaleState?.id, scales?.yScaleState?.id).count;
41
+ });
42
+ }
@@ -266,7 +266,9 @@ export class ScaleRegistry {
266
266
  reverse: config.reverse ?? false,
267
267
  midpoint: config.midpoint,
268
268
  constant: config.constant,
269
- exponent: config.exponent
269
+ exponent: config.exponent,
270
+ range: config.range,
271
+ pinnedDomain: config.pinnedDomain
270
272
  }, this, this.#gpuCoordinator));
271
273
  }
272
274
  else if (type === 'size') {
@@ -150,7 +150,21 @@ export declare class SlotTransition implements SlotSource {
150
150
  * one). The space is unchanged, existing ids keep their paths, and the copy
151
151
  * on resize is exactly right.
152
152
  */
153
- layout(space: 'ids' | 'positions', order: ArrayLike<number> | null, idCount: number): void;
153
+ layout(space: 'ids' | 'positions', order: ArrayLike<number> | null, idCount: number,
154
+ /**
155
+ * Land at once rather than travel: every id takes its new slot, leavers
156
+ * go and entrants appear. For a catch-up result, a change nobody just
157
+ * made. It settles the clock, which the marks on this axis share: their
158
+ * values land with it, which is what the caller wants for its chart.
159
+ */
160
+ instant?: boolean): void;
161
+ /**
162
+ * The latest layout's number if it landed at once (an instant layout), 0
163
+ * otherwise. What follows from a layout, like a band grid's line opacity,
164
+ * lands with it, once: a number, so a consumer lands only its first write
165
+ * after that layout.
166
+ */
167
+ get landedLayout(): number;
154
168
  /**
155
169
  * Start a transition without changing what is displayed.
156
170
  *