@fjader/svelte 0.2.2 → 0.2.3

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 (118) hide show
  1. package/dist/components/axes/BandAxisTicks.svelte +63 -24
  2. package/dist/components/axes/BandAxisTicks.svelte.d.ts +5 -5
  3. package/dist/components/charts/ChartContainer.svelte +2 -1
  4. package/dist/components/guides/legend/ContinuousColorLegend.svelte +2 -1
  5. package/dist/components/guides/legend/OrdinalColorLegend.svelte +92 -34
  6. package/dist/components/marks/AreaMarkX.svelte +19 -6
  7. package/dist/components/marks/AreaMarkY.svelte +19 -6
  8. package/dist/components/marks/BarMarkX.svelte +3 -0
  9. package/dist/components/marks/BarMarkY.svelte +3 -0
  10. package/dist/components/marks/DotsMark.svelte +3 -0
  11. package/dist/components/marks/LineMark.svelte +3 -0
  12. package/dist/components/marks/ParallelCoordinatesMark.svelte +6 -0
  13. package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +3 -0
  14. package/dist/components/marks/RectMark.svelte +3 -0
  15. package/dist/components/marks/RectXMark.svelte +3 -0
  16. package/dist/components/marks/RectYMark.svelte +3 -0
  17. package/dist/components/marks/RuleMarkX.svelte +10 -2
  18. package/dist/components/marks/RuleMarkY.svelte +10 -2
  19. package/dist/components/marks/TextMark.svelte +68 -4
  20. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +15 -1
  21. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +116 -27
  22. package/dist/components/marks/lifecycle/domain-queries.js +13 -11
  23. package/dist/components/marks/lifecycle/transform-scale-key.d.ts +16 -0
  24. package/dist/components/marks/lifecycle/transform-scale-key.js +27 -0
  25. package/dist/components/marks/utils/area-outline.d.ts +24 -0
  26. package/dist/components/marks/utils/area-outline.js +10 -0
  27. package/dist/components/marks/utils/reactive-config.d.ts +1 -1
  28. package/dist/components/marks/utils/reactive-config.js +3 -0
  29. package/dist/components/rendering/Areas.svelte +30 -6
  30. package/dist/components/rendering/Areas.svelte.d.ts +2 -0
  31. package/dist/components/rendering/BaseGPURenderer.svelte.js +4 -1
  32. package/dist/components/rendering/Dots.svelte +24 -3
  33. package/dist/components/rendering/Dots.svelte.d.ts +2 -0
  34. package/dist/components/rendering/GridLine.svelte +2 -1
  35. package/dist/components/rendering/Lines.svelte +49 -7
  36. package/dist/components/rendering/Lines.svelte.d.ts +9 -0
  37. package/dist/components/rendering/ParallelLines.svelte +26 -7
  38. package/dist/components/rendering/ParallelLines.svelte.d.ts +2 -0
  39. package/dist/components/rendering/Rects.svelte +20 -5
  40. package/dist/components/rendering/Rects.svelte.d.ts +2 -0
  41. package/dist/components/rendering/RuleX.svelte +20 -5
  42. package/dist/components/rendering/RuleX.svelte.d.ts +2 -0
  43. package/dist/components/rendering/RuleY.svelte +20 -5
  44. package/dist/components/rendering/RuleY.svelte.d.ts +2 -0
  45. package/dist/components/rendering/activity-reads.d.ts +93 -0
  46. package/dist/components/rendering/activity-reads.js +135 -0
  47. package/dist/components/rendering/uniform-sizes.d.ts +26 -0
  48. package/dist/components/rendering/uniform-sizes.js +28 -0
  49. package/dist/config/ChartConfig.svelte.d.ts +2 -0
  50. package/dist/gpu/ColorScaleShaderLib.d.ts +13 -1
  51. package/dist/gpu/ColorScaleShaderLib.js +56 -5
  52. package/dist/gpu/GPUCoordinator.svelte.d.ts +8 -0
  53. package/dist/gpu/GPUCoordinator.svelte.js +68 -32
  54. package/dist/gpu/ResolveShader.d.ts +2 -2
  55. package/dist/gpu/ResolveShader.js +2 -2
  56. package/dist/gpu/ScaleTemplateSystem.d.ts +19 -0
  57. package/dist/gpu/ScaleTemplateSystem.js +86 -7
  58. package/dist/gpu/SizeScaleShaderLib.d.ts +4 -4
  59. package/dist/gpu/SizeScaleShaderLib.js +4 -4
  60. package/dist/gpu/identity-slot-packing.d.ts +157 -40
  61. package/dist/gpu/identity-slot-packing.js +262 -62
  62. package/dist/gpu/tracked-scales.d.ts +14 -0
  63. package/dist/gpu/tracked-scales.js +18 -0
  64. package/dist/index.js +1 -0
  65. package/dist/interactors/BandLabelToggle.svelte +5 -9
  66. package/dist/interactors/BandLabelToggle.svelte.d.ts +3 -3
  67. package/dist/interactors/DerivedInterval1D.svelte +26 -54
  68. package/dist/interactors/Interval1D.svelte +2 -1
  69. package/dist/interactors/Interval2D.svelte +2 -1
  70. package/dist/interactors/ParallelAxisSource.svelte +4 -4
  71. package/dist/interactors/ParallelAxisSource.svelte.d.ts +2 -1
  72. package/dist/interactors/ParallelBrushes.svelte +8 -9
  73. package/dist/interactors/ParallelBrushes.svelte.d.ts +3 -3
  74. package/dist/interactors/Toggle.svelte +17 -36
  75. package/dist/interactors/Toggle.svelte.d.ts +4 -3
  76. package/dist/interactors/Tooltip.svelte +2 -1
  77. package/dist/interactors/TooltipManager.svelte.js +8 -3
  78. package/dist/interactors/relay-clauses.d.ts +23 -0
  79. package/dist/interactors/relay-clauses.js +60 -0
  80. package/dist/query-engine/CrossfilterSelection.svelte.d.ts +88 -15
  81. package/dist/query-engine/CrossfilterSelection.svelte.js +94 -20
  82. package/dist/query-engine/FieldActivity.svelte.d.ts +79 -0
  83. package/dist/query-engine/FieldActivity.svelte.js +207 -0
  84. package/dist/query-engine/FilterGroup.svelte.d.ts +53 -29
  85. package/dist/query-engine/FilterGroup.svelte.js +181 -122
  86. package/dist/query-engine/FilterGroupRegistry.svelte.d.ts +42 -0
  87. package/dist/query-engine/FilterGroupRegistry.svelte.js +103 -1
  88. package/dist/query-engine/MarkHandle.svelte.d.ts +9 -1
  89. package/dist/query-engine/MarkHandle.svelte.js +12 -2
  90. package/dist/query-engine/QueryEngine.d.ts +52 -16
  91. package/dist/query-engine/QueryEngine.js +90 -28
  92. package/dist/query-engine/categorical-identity.d.ts +99 -27
  93. package/dist/query-engine/categorical-identity.js +210 -51
  94. package/dist/query-engine/index.d.ts +2 -1
  95. package/dist/query-engine/index.js +1 -0
  96. package/dist/query-engine/preagg/Preagg.d.ts +1 -1
  97. package/dist/query-engine/preagg/Preagg.js +11 -9
  98. package/dist/query-engine/preagg/plan.d.ts +9 -5
  99. package/dist/query-engine/preagg/plan.js +13 -11
  100. package/dist/query-engine/toggle-predicate.js +12 -1
  101. package/dist/scales/ActivityTable.d.ts +99 -0
  102. package/dist/scales/ActivityTable.js +211 -0
  103. package/dist/scales/BandScaleState.svelte.d.ts +28 -7
  104. package/dist/scales/BandScaleState.svelte.js +55 -20
  105. package/dist/scales/ChartScales.svelte.d.ts +2 -2
  106. package/dist/scales/ColorScaleState.svelte.d.ts +36 -8
  107. package/dist/scales/ColorScaleState.svelte.js +118 -24
  108. package/dist/scales/DomainResolver.d.ts +2 -0
  109. package/dist/scales/DomainResolver.js +15 -8
  110. package/dist/scales/ScaleRegistry.svelte.d.ts +9 -0
  111. package/dist/scales/ScaleRegistry.svelte.js +22 -1
  112. package/dist/scales/TimeScaleState.svelte.js +7 -1
  113. package/dist/spec/index.js +1 -0
  114. package/dist/spec/types.d.ts +2 -0
  115. package/dist/transforms/index.d.ts +27 -0
  116. package/dist/transforms/index.js +97 -3
  117. package/dist/types.d.ts +30 -0
  118. package/package.json +1 -1
@@ -2,7 +2,7 @@
2
2
  import { scaleOrdinal } from 'd3-scale';
3
3
  import { createD3Scale, getOrdinalColors, bakeGradient, bakeInterpolator, bakePalette, getSchemeType, parseColorToRGBA, resolveColorScaleTypes, getResolvedInterpolator, transformTypeToGPU, applyTransform, colorScaleTicks } from './color-utils';
4
4
  import { COLOR_RAMP_RESOLUTION } from '../gpu/GPUCoordinator.svelte';
5
- import { COLOR_CONFIG_BYTES, COLOR_CONFIG_SLOT_BASE_OFFSET, NO_SLOT_TABLE } from '../gpu/ColorScaleShaderLib';
5
+ import { COLOR_CONFIG_BYTES, COLOR_CONFIG_SLOT_BASE_OFFSET, NO_SLOT_TABLE, NULL_COLOR_RANK } from '../gpu/ColorScaleShaderLib';
6
6
  /**
7
7
  * Reactive state for a GPU-backed color scale.
8
8
  *
@@ -14,7 +14,7 @@ export class ColorScaleState {
14
14
  #field;
15
15
  #registry;
16
16
  #gpuCoordinator;
17
- #scaleId = crypto.randomUUID(); // Unique ID for ViewManager registration
17
+ #id = crypto.randomUUID();
18
18
  // GPU slot indices (allocated from registry)
19
19
  #configSlot = -1;
20
20
  #rampSlot = -1; // Used by continuous scales
@@ -31,7 +31,6 @@ export class ColorScaleState {
31
31
  // Reactive state
32
32
  domain = $state([]);
33
33
  ready = $state(false);
34
- #activeValues = $state(null); // null = all active, Set = only these active
35
34
  // Bumped on any visual-config change (transform type, constant, exponent, scheme).
36
35
  // Legend components read this to know when to re-render the gradient.
37
36
  _version = $state(0);
@@ -90,9 +89,37 @@ export class ColorScaleState {
90
89
  * a change.
91
90
  */
92
91
  #slots = { count: 0, from: new Float32Array(0), to: new Float32Array(0), t: 1, version: 0 };
92
+ /**
93
+ * Read by the packer every frame. The rank table covers the ids the field had
94
+ * when it was built, and the field's identity grows in place: another table
95
+ * appends categories, and a chart acquiring an Other bucket appends its id,
96
+ * any chart, since this scale is shared per field. An id past the table's end
97
+ * reads as unranked and draws transparent, so a grown identity rebuilds it
98
+ * here, once per growth.
99
+ */
93
100
  get slots() {
101
+ const identity = this.#identity;
102
+ if (identity?.present && identity.version !== this.#ranksVersion)
103
+ this.#refreshRanks();
94
104
  return this.#slots;
95
105
  }
106
+ /** The identity version the rank table was last built from. */
107
+ #ranksVersion = -1;
108
+ /**
109
+ * Bumped whenever the ranks change, so a reader of getColor (a legend's
110
+ * swatches) re-reads once identity lands. The ranks are a plain Map, and the
111
+ * domain usually arrives first: without this a legend kept the colours it
112
+ * computed before there were ranks.
113
+ */
114
+ #ranksRevision = $state(0);
115
+ /**
116
+ * Reactive: changes whenever the palette ranks are rebuilt, which is also when
117
+ * identity is adopted. A reader of `identityKey` or `getColor` in a derived
118
+ * reads this to re-run then.
119
+ */
120
+ get ranksRevision() {
121
+ return this.#ranksRevision;
122
+ }
96
123
  /**
97
124
  * Where this scale's block sits in the shared identity slot table. Assigned
98
125
  * by the coordinator's packer.
@@ -132,9 +159,18 @@ export class ColorScaleState {
132
159
  // Before the scale has a config slot there is nothing to patch; whichever
133
160
  // upload path claims one writes the base with the rest of the struct.
134
161
  if (this.#configSlot >= 0) {
135
- this.#gpuCoordinator?.writeColorConfigSlotBase(this.#configSlot, base);
162
+ this.#gpuCoordinator?.writeColorConfigSlotBase(this.#configSlot, this.#configSlotBase);
136
163
  }
137
164
  }
165
+ /**
166
+ * The slot base the config carries: this scale's block while its channel
167
+ * carries ids, NO_SLOT_TABLE while it carries palette indices. The one rule
168
+ * for both writers (setSlotBase's patch and the full upload), so which wrote
169
+ * last cannot change what the GPU reads.
170
+ */
171
+ get #configSlotBase() {
172
+ return this.hasIdentity && this.#slotBase >= 0 ? this.#slotBase : NO_SLOT_TABLE;
173
+ }
138
174
  // ViewManager integration - tracks tables this scale has registered columns for
139
175
  constructor(config, registry, gpuCoordinator) {
140
176
  this.#field = config.field;
@@ -170,15 +206,22 @@ export class ColorScaleState {
170
206
  get field() {
171
207
  return this.#field;
172
208
  }
209
+ /**
210
+ * The identity key of the dim this scale adopted, or null before it has one.
211
+ * Selection activity is kept per identity key, so this is what the packer
212
+ * points the scale's block at.
213
+ */
214
+ get identityKey() {
215
+ // Only while the channel carries ids: a key without them would route a
216
+ // mark to dim by activity the shader cannot read.
217
+ return this.hasIdentity ? this.#identity.key || null : null;
218
+ }
173
219
  get scheme() {
174
220
  return this.#scheme;
175
221
  }
176
222
  get scaleType() {
177
223
  return this.#scaleType;
178
224
  }
179
- get activeValues() {
180
- return this.#activeValues;
181
- }
182
225
  get isOrdinal() {
183
226
  return this.#renderType === 'ordinal';
184
227
  }
@@ -208,10 +251,15 @@ export class ColorScaleState {
208
251
  return this.#d3Scale;
209
252
  }
210
253
  /**
211
- * Unique scale ID for ViewManager registration.
254
+ * The id the coordinator tracks this scale by, as it does every scale.
255
+ *
256
+ * Tracking is what puts the scale in front of the identity slot packer
257
+ * (see tracked-scales.ts). Without an id, its rank table and selection
258
+ * activity were never packed: the shader read the channel's dim id as a
259
+ * palette index, and selection dimming had nothing to read.
212
260
  */
213
- get scaleId() {
214
- return this.#scaleId;
261
+ get id() {
262
+ return this.#id;
215
263
  }
216
264
  // ============================================================
217
265
  // SLOT ALLOCATION
@@ -300,6 +348,10 @@ export class ColorScaleState {
300
348
  return [lo, hi];
301
349
  }
302
350
  #uploadToGPU() {
351
+ // A destroyed scale's slots may already belong to another scale: a late
352
+ // upload (identity landing after the chart unmounted) would overwrite them.
353
+ if (this.#destroyed)
354
+ return;
303
355
  if (!this.#gpuCoordinator) {
304
356
  console.warn('[ColorScaleState] No GPU coordinator, skipping upload');
305
357
  return;
@@ -388,15 +440,28 @@ export class ColorScaleState {
388
440
  */
389
441
  setIdentity(identity) {
390
442
  this.#warnIfDestroyed('setIdentity');
443
+ const hadIdentity = this.hasIdentity;
391
444
  const previous = this.#identity;
392
445
  this.#identity = identity;
393
446
  if (previous && previous !== identity && !previous.present) {
394
447
  this.#paletteRank.clear();
395
448
  this.#nextRank = 0;
396
449
  }
450
+ // Identity landing after the config was uploaded, or going (a reload
451
+ // empties it). The domain fetch and the identity columns are fetched in
452
+ // parallel, so either can arrive first; the config's slot base, its palette
453
+ // size and the CPU scale all depend on whether the channel carries ids,
454
+ // so a change of that uploads again. The ranks are refreshed here either
455
+ // way: they are CPU state, needed without a coordinator too.
397
456
  this.#refreshRanks();
457
+ if (hadIdentity !== this.hasIdentity && this.domain.length > 0)
458
+ this.#uploadToGPU();
398
459
  }
399
460
  /** True once this scale's channel carries dim ids rather than palette indices. */
461
+ /** The field's live id <-> value bijection, once adopted. */
462
+ get identity() {
463
+ return this.#identity ?? null;
464
+ }
400
465
  get hasIdentity() {
401
466
  return this.isOrdinal && (this.#identity?.present ?? false);
402
467
  }
@@ -419,10 +484,14 @@ export class ColorScaleState {
419
484
  const identity = this.#identity;
420
485
  if (!identity?.present)
421
486
  return;
487
+ this.#ranksVersion = identity.version;
422
488
  for (const value of this.domain) {
423
489
  const id = identity.ids.get(value);
424
490
  if (id === undefined || this.#paletteRank.has(id))
425
491
  continue;
492
+ // The NULL category takes no palette slot (see below).
493
+ if (id === identity.nullId)
494
+ continue;
426
495
  this.#paletteRank.set(id, this.#nextRank++);
427
496
  }
428
497
  const table = new Float32Array(identity.values.length);
@@ -431,6 +500,16 @@ export class ColorScaleState {
431
500
  if (id >= 0 && id < table.length)
432
501
  table[id] = rank;
433
502
  }
503
+ // The NULL category and the Other buckets draw the null colour: no palette
504
+ // slot, so they read as missing data or "the rest" and shift no category's
505
+ // colour. Not -1, which the shader draws transparent: a category outside
506
+ // this scale's domain.
507
+ if (identity.nullId !== null)
508
+ table[identity.nullId] = NULL_COLOR_RANK;
509
+ for (const id of identity.synthetic)
510
+ if (id < table.length)
511
+ table[id] = NULL_COLOR_RANK;
512
+ this.#ranksRevision++;
434
513
  const slots = this.#slots;
435
514
  slots.from = table;
436
515
  slots.to = table;
@@ -476,9 +555,8 @@ export class ColorScaleState {
476
555
  return undefined;
477
556
  return this.#identity.values[id];
478
557
  }
558
+ /** Called from #uploadToGPU only, which has refreshed the ranks. */
479
559
  #uploadOrdinal() {
480
- // New categories in the domain need ranks before the palette is sized.
481
- this.#refreshRanks();
482
560
  // Bake and upload palette
483
561
  const paletteData = bakePalette(this.#scheme, { reverse: this.#reverse });
484
562
  this.#gpuCoordinator.writeColorPalette(this.#paletteSlot, paletteData);
@@ -497,7 +575,7 @@ export class ColorScaleState {
497
575
  type: 0, // ORDINAL
498
576
  paletteOffset: this.#paletteSlot * 32,
499
577
  paletteSize: Math.min(Math.max(rankSpace, 1), 32),
500
- slotBase: this.hasIdentity ? this.#slotBase : undefined
578
+ slotBase: this.#configSlotBase
501
579
  });
502
580
  this.#gpuCoordinator.writeColorConfig(this.#configSlot, config);
503
581
  }
@@ -660,6 +738,14 @@ export class ColorScaleState {
660
738
  * transform (e.g. non-positive values under log), mirroring the GPU shader.
661
739
  */
662
740
  getColor(value) {
741
+ // With palette ranks, the colour is exactly the shader's: value -> id ->
742
+ // rank -> palette, and the null colour for the NULL category and the Other
743
+ // buckets. Not through the d3 scale, whose domain is stringified: a null
744
+ // (or a number) would miss it, and d3 would hand the value the next
745
+ // palette colour.
746
+ void this.#ranksRevision;
747
+ if (this.#hasRanks())
748
+ return this.#rankedColor(value);
663
749
  if (!this.#d3Scale)
664
750
  return '#000000';
665
751
  if (this.#transformType === 'log' && typeof value === 'number' && value <= 0) {
@@ -667,6 +753,18 @@ export class ColorScaleState {
667
753
  }
668
754
  return this.#d3Scale(value);
669
755
  }
756
+ #rankedColor(value) {
757
+ const identity = this.#identity;
758
+ const id = identity.ids.get(value);
759
+ const rank = id === undefined ? undefined : this.#paletteRank.get(id);
760
+ if (rank === undefined)
761
+ return ColorScaleState.NULL_COLOR;
762
+ let colors = getOrdinalColors(this.#scheme) ?? [];
763
+ if (this.#reverse)
764
+ colors = [...colors].reverse();
765
+ // Same wrap the shader applies, so an over-32 palette agrees too.
766
+ return colors.length ? colors[rank % colors.length] : ColorScaleState.NULL_COLOR;
767
+ }
670
768
  /**
671
769
  * Get color as RGBA array (0-1 range).
672
770
  */
@@ -674,18 +772,14 @@ export class ColorScaleState {
674
772
  const cssColor = this.getColor(value);
675
773
  return parseColorToRGBA(cssColor);
676
774
  }
677
- // ============================================================
678
- // ACTIVE VALUES (selection feedback)
679
- // ============================================================
680
- setActiveValues(values) {
681
- this.#activeValues = values instanceof Set ? values : new Set(values);
682
- }
683
- clearActiveValues() {
684
- this.#activeValues = null;
685
- }
686
- isActive(value) {
687
- return this.#activeValues == null || this.#activeValues.has(value);
775
+ /**
776
+ * Charts drawing this scale. Asked of the registry, as BandScaleState does,
777
+ * so it is current when charts come and go.
778
+ */
779
+ get _affectedChartsArray() {
780
+ return this.#registry?.chartsUsing(this) ?? ColorScaleState.#noCharts;
688
781
  }
782
+ static #noCharts = [];
689
783
  // ============================================================
690
784
  // CLEANUP
691
785
  // ============================================================
@@ -141,6 +141,8 @@ export declare class DomainResolver {
141
141
  channelValueFor(value: DimValue, displayed: readonly DimValue[]): number | null;
142
142
  /** True once this field's dim exists and holds at least one value. */
143
143
  get hasIdentity(): boolean;
144
+ /** The dim adopted last; a different object after a reload. */
145
+ get identity(): FieldIdentity | null;
144
146
  /** How many ids the dim holds — the width of a slot table over it. */
145
147
  get idCount(): number;
146
148
  /** How many marks have registered. */
@@ -188,17 +188,18 @@ export class DomainResolver {
188
188
  // #extents encodes at the boundary, which is the whole point of the two
189
189
  // registration methods, so its input was already ids that had passed the
190
190
  // same check, and the list it was given had just been deduplicated. Its
191
- // range check is subsumed by the `value == null` test below, which drops
192
- // an id past the end, a negative one and a non-integer one alike, because
193
- // all three index the value array to undefined.
191
+ // range check is subsumed by the `value === undefined` test below, which
192
+ // drops an id past the end, a negative one and a non-integer one alike,
193
+ // because all three index the value array to undefined. Not `== null`:
194
+ // the NULL category's value IS null, and it is a category like any other.
194
195
  //
195
196
  // An id with no value is dropped from the IDS, before either projection.
196
197
  // Filtering only the values would shorten the value list without
197
198
  // shortening the permutation, and the two would disagree with the band
198
199
  // index space d3 and the shader's categoryCount use — by one, with
199
- // nothing failing. Unreachable today (the dim has no NULL values and
200
- // synthetic rows carry a label), which is why the two are built together
201
- // rather than checked against each other.
200
+ // nothing failing. Unreachable today (every dim row has a value: a real
201
+ // one, a synthetic row's label, or the NULL category's null), which is
202
+ // why the two are built together rather than checked against each other.
202
203
  const seen = new Set();
203
204
  const orderedIds = [];
204
205
  const resolved = [];
@@ -214,7 +215,7 @@ export class DomainResolver {
214
215
  continue;
215
216
  }
216
217
  const value = values[category];
217
- if (value == null)
218
+ if (value === undefined)
218
219
  continue;
219
220
  orderedIds.push(category);
220
221
  resolved.push(value);
@@ -293,7 +294,9 @@ export class DomainResolver {
293
294
  const out = [];
294
295
  const seen = new Set();
295
296
  for (const item of items) {
296
- if (item == null)
297
+ // A value of null is the NULL category, which `ids` maps to its id; an
298
+ // encoded null is no id at all.
299
+ if (item === undefined || (encoded && item === null))
297
300
  continue;
298
301
  const id = encoded ? item : identity.ids.get(item);
299
302
  if (id === undefined || !Number.isInteger(id) || id < 0 || id >= limit)
@@ -371,6 +374,10 @@ export class DomainResolver {
371
374
  get hasIdentity() {
372
375
  return this.#identity?.present ?? false;
373
376
  }
377
+ /** The dim adopted last; a different object after a reload. */
378
+ get identity() {
379
+ return this.#identity;
380
+ }
374
381
  /** How many ids the dim holds — the width of a slot table over it. */
375
382
  get idCount() {
376
383
  return this.#identity?.values.length ?? 0;
@@ -73,6 +73,15 @@ export declare class ScaleRegistry {
73
73
  * a scale this registry has never seen.
74
74
  */
75
75
  chartsUsing(scale: AnyScaleState): string[];
76
+ /**
77
+ * Fill `out` with every chart drawing a scale on the categorical field
78
+ * `key` (by identity key), each once: the charts a selection fade on that
79
+ * field redraws. A superset of the charts whose marks read the field's
80
+ * activity, since every such mark draws through a scale on it; the extra
81
+ * charts (other groups, marks holding the scale without reading it) redraw
82
+ * for the 0.25 s of a fade. Read on every frame a fade runs.
83
+ */
84
+ chartsDrawingField(key: string, out: string[]): string[];
76
85
  deregisterChart(scale: AnyScaleState, chartId: string): void;
77
86
  removeChartUsage(chartId: string): void;
78
87
  _removeScaleFromStorage(scaleInstance: AnyScaleState): void;
@@ -9,6 +9,7 @@ import { BandScaleState } from './BandScaleState.svelte';
9
9
  import { ColorScaleState } from './ColorScaleState.svelte';
10
10
  import { SizeScaleState } from './SizeScaleState.svelte';
11
11
  import { scaleOrdinal } from 'd3-scale';
12
+ import { MAX_COLOR_SLOTS } from '../gpu/ColorScaleShaderLib';
12
13
  import { isTransformType } from './color-utils';
13
14
  // Helper to stringify sorted
14
15
  const stableStringify = (obj) => {
@@ -50,7 +51,8 @@ export class ScaleRegistry {
50
51
  #nextColorSlot = 0;
51
52
  #nextRampSlot = 0;
52
53
  #nextPaletteSlot = 0;
53
- #maxColorSlots = 64;
54
+ // The shader's array length and the coordinator's buffer size, stated once.
55
+ #maxColorSlots = MAX_COLOR_SLOTS;
54
56
  #maxRampSlots = 64;
55
57
  #maxPaletteSlots = 64;
56
58
  // Size scale slot management
@@ -348,6 +350,25 @@ export class ScaleRegistry {
348
350
  chartsUsing(scale) {
349
351
  return this.#chartsFor.get(scale) ?? NO_CHARTS;
350
352
  }
353
+ /**
354
+ * Fill `out` with every chart drawing a scale on the categorical field
355
+ * `key` (by identity key), each once: the charts a selection fade on that
356
+ * field redraws. A superset of the charts whose marks read the field's
357
+ * activity, since every such mark draws through a scale on it; the extra
358
+ * charts (other groups, marks holding the scale without reading it) redraw
359
+ * for the 0.25 s of a fade. Read on every frame a fade runs.
360
+ */
361
+ chartsDrawingField(key, out) {
362
+ for (const [scale, charts] of this.#chartsFor) {
363
+ if (scale.identityKey !== key)
364
+ continue;
365
+ for (let i = 0; i < charts.length; i++) {
366
+ if (!out.includes(charts[i]))
367
+ out.push(charts[i]);
368
+ }
369
+ }
370
+ return out;
371
+ }
351
372
  /** Re-derive one scale's redraw list. The only writer. */
352
373
  #publishCharts(scale, chartIds) {
353
374
  this.#chartsFor.set(scale, Array.from(chartIds));
@@ -20,7 +20,13 @@ export class TimeScaleState extends LinearScaleState {
20
20
  return 'time';
21
21
  }
22
22
  // --- Time Metadata ---
23
- #t0 = null; // Origin Date (raw global min datetime)
23
+ /**
24
+ * Origin Date (raw global min datetime). State, because SQL is built from
25
+ * it: a mark's offset select and a brush's predicate are $derived, and one
26
+ * computed before the first datetime extent landed must rebuild when it
27
+ * does, or it keeps comparing the raw column with offsets.
28
+ */
29
+ #t0 = $state.raw(null);
24
30
  #unit = 1; // Seconds per offset unit (1 = seconds)
25
31
  // --- Datetime Extent Tracking ---
26
32
  #datetimeExtents = new Map(); // Map<markId, [minDate, maxDate]>
@@ -47,6 +47,7 @@ export async function render(target, spec, options = {}) {
47
47
  // 4. Build context map — injected into Svelte mount() so child components
48
48
  // find these via getContext() calls (same keys as initFjader())
49
49
  const { filterGroups } = createQueryServices(queryEngine);
50
+ gpuCoordinator.setFilterGroups(filterGroups);
50
51
  const context = new Map();
51
52
  context.set('query-engine', queryEngine);
52
53
  context.set('filter-groups', filterGroups);
@@ -133,6 +133,8 @@ export type MarkSpec = DotMarkSpec | BarXMarkSpec | BarYMarkSpec | LineMarkSpec
133
133
  interface BaseMarkSpec {
134
134
  table: string;
135
135
  selection?: string;
136
+ /** How the mark responds to selections in its group; see BaseMarkProps. Default 'auto'. */
137
+ selectionResponse?: 'auto' | 'filter';
136
138
  scheme?: ColorScheme;
137
139
  limit?: number;
138
140
  transforms?: TransformSpec[];
@@ -45,6 +45,33 @@ export declare function topN(n: number | (() => number), options?: TopNOptions):
45
45
  * the identity index columns in the same result.
46
46
  */
47
47
  export declare const RANK_COLUMN = "__dplot_rank";
48
+ /**
49
+ * The Other row's members: the ids of the categories folded into it, and each
50
+ * one's measure, as two aligned lists ordered by id. NULL on every other row.
51
+ *
52
+ * Returned by the query rather than worked out on the client, because only the
53
+ * query knows exactly what Other summed: under every filter the chart receives,
54
+ * NULL (`-1`) included when it was folded. Clicking Other selects these, and
55
+ * its selected share is computed from them.
56
+ *
57
+ * `__dplot_`-prefixed, like the rank, so tooltips never show them; and the SQL
58
+ * tooltip table leaves out every `OTHER_MEMBER_PREFIX` column, so a hover never
59
+ * ships them back.
60
+ */
61
+ export declare const OTHER_MEMBER_PREFIX = "__dplot_other_";
62
+ export declare const OTHER_IDS_COLUMN = "__dplot_other_ids";
63
+ export declare const OTHER_VALUES_COLUMN = "__dplot_other_values";
64
+ /**
65
+ * `sql` without Other's member lists, for tables queried on every hover. A
66
+ * COLUMNS filter rather than EXCLUDE, which errors on a column that is absent,
67
+ * as it is on every mark without an Other bucket.
68
+ *
69
+ * The `lambda c:` form, not `c ->`: DuckDB deprecated the arrow lambda in 1.3,
70
+ * and every SQL tooltip table goes through this, so an upgrade dropping it would
71
+ * break tooltips on every mark. Both DuckDB-WASM 1.30 (DuckDB 1.3.2) and the
72
+ * DuckDB the tests run accept it.
73
+ */
74
+ export declare function withoutOtherMembers(sql: string): string;
48
75
  /**
49
76
  * Most padded rows `dense()` will produce before refusing.
50
77
  *
@@ -135,6 +135,37 @@ function resolveTopNColumns(aliases, options, config) {
135
135
  * the identity index columns in the same result.
136
136
  */
137
137
  export const RANK_COLUMN = '__dplot_rank';
138
+ /**
139
+ * The Other row's members: the ids of the categories folded into it, and each
140
+ * one's measure, as two aligned lists ordered by id. NULL on every other row.
141
+ *
142
+ * Returned by the query rather than worked out on the client, because only the
143
+ * query knows exactly what Other summed: under every filter the chart receives,
144
+ * NULL (`-1`) included when it was folded. Clicking Other selects these, and
145
+ * its selected share is computed from them.
146
+ *
147
+ * `__dplot_`-prefixed, like the rank, so tooltips never show them; and the SQL
148
+ * tooltip table leaves out every `OTHER_MEMBER_PREFIX` column, so a hover never
149
+ * ships them back.
150
+ */
151
+ export const OTHER_MEMBER_PREFIX = '__dplot_other_';
152
+ export const OTHER_IDS_COLUMN = `${OTHER_MEMBER_PREFIX}ids`;
153
+ export const OTHER_VALUES_COLUMN = `${OTHER_MEMBER_PREFIX}values`;
154
+ /**
155
+ * `sql` without Other's member lists, for tables queried on every hover. A
156
+ * COLUMNS filter rather than EXCLUDE, which errors on a column that is absent,
157
+ * as it is on every mark without an Other bucket.
158
+ *
159
+ * The `lambda c:` form, not `c ->`: DuckDB deprecated the arrow lambda in 1.3,
160
+ * and every SQL tooltip table goes through this, so an upgrade dropping it would
161
+ * break tooltips on every mark. Both DuckDB-WASM 1.30 (DuckDB 1.3.2) and the
162
+ * DuckDB the tests run accept it.
163
+ */
164
+ export function withoutOtherMembers(sql) {
165
+ return `SELECT COLUMNS(lambda c: NOT starts_with(c, '${OTHER_MEMBER_PREFIX}')) FROM (${sql})`;
166
+ }
167
+ /** The original band id, carried through bucketing, which rewrites the band column. */
168
+ const BAND_SOURCE_COLUMN = '__dplot_band_src';
138
169
  /**
139
170
  * Give every category in the dim a row, at zero past the cutoff.
140
171
  *
@@ -189,6 +220,12 @@ function densify(inner, aliases, plan) {
189
220
  // missing (category, series) pair carries that series' real id.
190
221
  select[alias] = rawSql(`${crossed}."id"`);
191
222
  }
223
+ else if (alias.startsWith(OTHER_MEMBER_PREFIX)) {
224
+ // Other's member lists: NULL on a padded row, as on every row but
225
+ // Other's. Never COALESCEd to 0 like a measure, which is a type error on
226
+ // a list.
227
+ select[alias] = rawSql(`a.${column(alias)}`);
228
+ }
192
229
  else if (plan.identity[alias]?.dim === dim) {
193
230
  // Same dim as the band, so this channel IS the category — the ordinary
194
231
  // "colour the bars by their own category" chart. Its value for a row
@@ -235,13 +272,13 @@ function densify(inner, aliases, plan) {
235
272
  // a missing category. Each key's ids get -1 appended: a row whose series is
236
273
  // NULL carries -1, and a join over the dim's ids alone would drop it.
237
274
  const joins = cross
238
- .map(([, keyDim], i) => ` CROSS JOIN (SELECT "id" FROM ${quoteTable(keyDim)} UNION ALL SELECT -1) AS k${i}`)
275
+ .map(([key, keyDim], i) => ` CROSS JOIN (SELECT "id" FROM ${padIds(plan.identity[key], keyDim)} UNION ALL SELECT -1) AS k${i}`)
239
276
  .join('');
240
277
  const on = [
241
278
  `a.${column(band)} = d."id"`,
242
279
  ...cross.map(([key], i) => `a.${column(key)} = k${i}."id"`)
243
280
  ].join(' AND ');
244
- return Query.from(rawSql(`${quoteTable(dim)} AS d${joins} LEFT JOIN (${inner}) AS a ON ${on}`)).select(select);
281
+ return Query.from(rawSql(`${padIds(plan.identity[band], dim)} AS d${joins} LEFT JOIN (${inner}) AS a ON ${on}`)).select(select);
245
282
  }
246
283
  /**
247
284
  * Most padded rows `dense()` will produce before refusing.
@@ -254,6 +291,23 @@ function densify(inner, aliases, plan) {
254
291
  * before any of this existed.
255
292
  */
256
293
  export const DENSE_ROW_LIMIT = 10_000;
294
+ /**
295
+ * The ids a channel pads from: the ids its mark's own table holds, plus every
296
+ * Other bucket of the dim, when the table's ids are known; the whole dim before.
297
+ *
298
+ * The dim spans every table registering the field, so padding from all of it
299
+ * gave a chart a zero row, and a slot on a sorted axis, for every category only
300
+ * another table has. Other buckets stay in: an Other row must find its padding
301
+ * row, and another mark's bucket is kept off the axis by its NaN rank, as
302
+ * before.
303
+ */
304
+ function padIds(id, dim) {
305
+ const present = id?.present;
306
+ if (!present)
307
+ return `(SELECT "id" FROM ${quoteTable(dim)})`;
308
+ return (`(SELECT "id" FROM ${quoteTable(present)} UNION ALL ` +
309
+ `SELECT "id" FROM ${quoteTable(dim)} WHERE "label" IS NOT NULL)`);
310
+ }
257
311
  /**
258
312
  * Whether `dense()` applies to a query with these aliases, and how.
259
313
  *
@@ -680,7 +734,37 @@ function collapseIntoOther(ranked, aliases, o) {
680
734
  }
681
735
  }
682
736
  bucket[RANK_COLUMN] = sqlTag `CASE WHEN ${kept} THEN ${rank} ELSE ${n + 1} END`;
737
+ // The band column is rewritten to the Other id above, so the category each
738
+ // folded row came from is carried beside it for the member lists.
739
+ bucket[BAND_SOURCE_COLUMN] = column(band);
683
740
  const bucketed = Query.from({ __topn_ranked: ranked }).select(bucket);
741
+ // PER MEMBER first: one row per group key and folded category. A grouped mark
742
+ // already has one row per (category, series), so this changes nothing there;
743
+ // an ungrouped one (raw rows, no groupKeys) would otherwise list one entry per
744
+ // RAW ROW past the cutoff — ids repeated, and a list that grows with the table
745
+ // rather than the field. Every aggregate below (SUM, MAX, MIN) gives the same
746
+ // answer over these rows as over the raw ones, so Other's values are unchanged.
747
+ const src = column(BAND_SOURCE_COLUMN);
748
+ const member = {};
749
+ const memberKeys = [src];
750
+ for (const alias of aliases) {
751
+ const col = column(alias);
752
+ if (isKey(alias)) {
753
+ member[alias] = col;
754
+ memberKeys.push(col);
755
+ }
756
+ else if (alias === by) {
757
+ member[alias] = sqlTag `SUM(${col})`;
758
+ }
759
+ else {
760
+ member[alias] = sqlTag `MAX(${col})`;
761
+ }
762
+ }
763
+ member[RANK_COLUMN] = sqlTag `MIN(${rank})`;
764
+ member[BAND_SOURCE_COLUMN] = src;
765
+ const members = Query.from({ __topn_bucketed: bucketed })
766
+ .select(member)
767
+ .groupby(...memberKeys);
684
768
  const collapse = {};
685
769
  const keys = [];
686
770
  for (const alias of aliases) {
@@ -697,7 +781,17 @@ function collapseIntoOther(ranked, aliases, o) {
697
781
  }
698
782
  }
699
783
  collapse[RANK_COLUMN] = sqlTag `MIN(${rank})`;
700
- return Query.from({ __topn_bucketed: bucketed })
784
+ // Other's members, from the rows the bucket step moved past the cutoff (their
785
+ // rank is n + 1 there): one entry per folded category, from the step above. A
786
+ // kept row's group has none, so its lists are NULL. Cast so they arrive as
787
+ // Int32Array and Float64Array children: ids are REAL in the channel, and a
788
+ // COUNT measure is BIGINT, which would arrive as BigInt64Array.
789
+ const past = sqlTag `${rank} > ${n}`;
790
+ collapse[OTHER_IDS_COLUMN] =
791
+ sqlTag `list(CAST(${src} AS INTEGER) ORDER BY ${src}) FILTER (WHERE ${past})`;
792
+ collapse[OTHER_VALUES_COLUMN] =
793
+ sqlTag `list(CAST(${column(by)} AS DOUBLE) ORDER BY ${src}) FILTER (WHERE ${past})`;
794
+ return Query.from({ __topn_members: members })
701
795
  .select(collapse)
702
796
  .groupby(...keys);
703
797
  }