@fjader/svelte 0.2.3 → 0.2.4

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 (87) hide show
  1. package/README.md +2 -0
  2. package/dist/components/axes/AxisBottom.svelte +23 -6
  3. package/dist/components/axes/AxisBottom.svelte.d.ts +10 -0
  4. package/dist/components/axes/AxisLeft.svelte +77 -8
  5. package/dist/components/axes/AxisLeft.svelte.d.ts +17 -0
  6. package/dist/components/axes/BandAxisTicks.svelte +1 -1
  7. package/dist/components/axes/axis-title.d.ts +30 -0
  8. package/dist/components/axes/axis-title.js +77 -0
  9. package/dist/components/charts/ChartCell.svelte +1 -1
  10. package/dist/components/charts/ChartContainer.svelte +10 -2
  11. package/dist/components/charts/ChartContainer.svelte.d.ts +7 -0
  12. package/dist/components/guides/legend/ContinuousColorLegend.svelte +7 -1
  13. package/dist/components/guides/legend/OrdinalColorLegend.svelte +8 -5
  14. package/dist/components/marks/AreaMarkX.svelte +2 -0
  15. package/dist/components/marks/AreaMarkY.svelte +2 -0
  16. package/dist/components/marks/BarMarkX.svelte +13 -1
  17. package/dist/components/marks/BarMarkY.svelte +13 -1
  18. package/dist/components/marks/DotsMark.svelte +2 -0
  19. package/dist/components/marks/LineMark.svelte +5 -1
  20. package/dist/components/marks/ParallelCoordinatesMark.svelte +4 -0
  21. package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +2 -0
  22. package/dist/components/marks/RectMark.svelte +2 -0
  23. package/dist/components/marks/RectXMark.svelte +2 -0
  24. package/dist/components/marks/RectYMark.svelte +2 -0
  25. package/dist/components/marks/RuleMarkX.svelte +2 -0
  26. package/dist/components/marks/RuleMarkY.svelte +2 -0
  27. package/dist/components/marks/TextMark.svelte +8 -9
  28. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +6 -1
  29. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +187 -18
  30. package/dist/components/marks/lifecycle/domain-queries.d.ts +3 -1
  31. package/dist/components/marks/lifecycle/domain-queries.js +11 -6
  32. package/dist/components/marks/lifecycle/domain-registration.js +2 -6
  33. package/dist/components/marks/lifecycle/other-rows.svelte.d.ts +104 -0
  34. package/dist/components/marks/lifecycle/other-rows.svelte.js +193 -0
  35. package/dist/components/marks/lifecycle/other-split.d.ts +80 -0
  36. package/dist/components/marks/lifecycle/other-split.js +112 -0
  37. package/dist/components/marks/lifecycle/query-builder.d.ts +26 -1
  38. package/dist/components/marks/lifecycle/query-builder.js +25 -5
  39. package/dist/components/marks/utils/reactive-config.d.ts +1 -1
  40. package/dist/components/marks/utils/reactive-config.js +6 -0
  41. package/dist/components/marks/utils/tooltip-helpers.d.ts +4 -1
  42. package/dist/components/marks/utils/tooltip-helpers.js +2 -1
  43. package/dist/components/rendering/BaseGPURenderer.svelte.d.ts +3 -1
  44. package/dist/components/rendering/BaseGPURenderer.svelte.js +4 -2
  45. package/dist/components/rendering/Rects.svelte +117 -23
  46. package/dist/components/rendering/Rects.svelte.d.ts +7 -0
  47. package/dist/components/rendering/activity-reads.d.ts +18 -0
  48. package/dist/components/rendering/activity-reads.js +19 -0
  49. package/dist/config/ChartConfig.svelte.d.ts +3 -1
  50. package/dist/gpu/MorphedChannels.d.ts +6 -32
  51. package/dist/gpu/MorphedChannels.js +45 -3
  52. package/dist/gpu/ScaleTemplateSystem.js +25 -4
  53. package/dist/index.d.ts +2 -0
  54. package/dist/index.js +3 -0
  55. package/dist/interactors/Interval1D.svelte +6 -1
  56. package/dist/interactors/Interval2D.svelte +6 -1
  57. package/dist/interactors/ParallelAxisSource.svelte +10 -1
  58. package/dist/interactors/Toggle.svelte +32 -3
  59. package/dist/interactors/Tooltip.svelte +138 -41
  60. package/dist/interactors/TooltipManager.svelte.d.ts +20 -5
  61. package/dist/interactors/TooltipManager.svelte.js +32 -7
  62. package/dist/interactors/other-tooltip.d.ts +16 -0
  63. package/dist/interactors/other-tooltip.js +22 -0
  64. package/dist/query-engine/FieldActivity.svelte.d.ts +50 -1
  65. package/dist/query-engine/FieldActivity.svelte.js +176 -5
  66. package/dist/query-engine/FilterGroup.svelte.d.ts +23 -1
  67. package/dist/query-engine/FilterGroup.svelte.js +43 -3
  68. package/dist/query-engine/FilterGroupRegistry.svelte.d.ts +31 -3
  69. package/dist/query-engine/FilterGroupRegistry.svelte.js +62 -15
  70. package/dist/query-engine/QueryEngine.js +26 -6
  71. package/dist/query-engine/categorical-identity.d.ts +15 -0
  72. package/dist/query-engine/categorical-identity.js +15 -0
  73. package/dist/query-engine/preagg/plan.d.ts +5 -0
  74. package/dist/query-engine/preagg/plan.js +31 -7
  75. package/dist/query-engine/toggle-predicate.d.ts +21 -0
  76. package/dist/query-engine/toggle-predicate.js +29 -1
  77. package/dist/scales/ActivityTable.d.ts +5 -1
  78. package/dist/scales/ActivityTable.js +12 -3
  79. package/dist/scales/BandScaleState.svelte.d.ts +13 -8
  80. package/dist/scales/BandScaleState.svelte.js +23 -16
  81. package/dist/spec/types.d.ts +2 -0
  82. package/dist/theme.css +5 -4
  83. package/dist/transforms/TransformCompiler.d.ts +37 -2
  84. package/dist/transforms/TransformCompiler.js +44 -1
  85. package/dist/transforms/index.js +32 -18
  86. package/dist/types.d.ts +41 -0
  87. package/package.json +3 -2
@@ -48,6 +48,7 @@
48
48
  table,
49
49
  selection = undefined,
50
50
  selectionResponse = undefined,
51
+ nulls = undefined,
51
52
  x = null,
52
53
  x1: x1Prop = null,
53
54
  x2: x2Prop = null,
@@ -97,6 +98,7 @@
97
98
  transforms: () => transforms,
98
99
  selection: () => selection,
99
100
  selectionResponse: () => selectionResponse,
101
+ nulls: () => nulls,
100
102
  limit: () => limit,
101
103
  preferredXScaleType: () => preferredXScaleType,
102
104
  preferredYScaleType: () => preferredYScaleType,
@@ -48,6 +48,7 @@
48
48
  table,
49
49
  selection = undefined,
50
50
  selectionResponse = undefined,
51
+ nulls = undefined,
51
52
  x,
52
53
  x1: x1Prop,
53
54
  x2: x2Prop,
@@ -92,6 +93,7 @@
92
93
  transforms: () => transforms,
93
94
  selection: () => selection,
94
95
  selectionResponse: () => selectionResponse,
96
+ nulls: () => nulls,
95
97
  limit: () => limit,
96
98
  preferredYScaleType: () => preferredYScaleType,
97
99
  fitDomain: () => fitDomain,
@@ -49,6 +49,7 @@
49
49
  table,
50
50
  selection = undefined,
51
51
  selectionResponse = undefined,
52
+ nulls = undefined,
52
53
  x = null,
53
54
  x1: x1Prop = null,
54
55
  x2: x2Prop = null,
@@ -94,6 +95,7 @@
94
95
  transforms: () => transforms,
95
96
  selection: () => selection,
96
97
  selectionResponse: () => selectionResponse,
98
+ nulls: () => nulls,
97
99
  limit: () => limit,
98
100
  preferredXScaleType: () => preferredXScaleType,
99
101
  fitDomain: () => fitDomain,
@@ -36,6 +36,7 @@
36
36
  table = undefined,
37
37
  selection = undefined,
38
38
  selectionResponse = undefined,
39
+ nulls = undefined,
39
40
  x,
40
41
  y: yShorthand = undefined,
41
42
  y1: y1Prop = undefined,
@@ -124,6 +125,7 @@
124
125
  transforms: () => transforms,
125
126
  selection: () => selection,
126
127
  selectionResponse: () => selectionResponse,
128
+ nulls: () => nulls,
127
129
  limit: () => limit,
128
130
  sort: () => sort,
129
131
  domain: () => domain,
@@ -36,6 +36,7 @@
36
36
  table = undefined,
37
37
  selection = undefined,
38
38
  selectionResponse = undefined,
39
+ nulls = undefined,
39
40
  y,
40
41
  x: xShorthand = undefined,
41
42
  x1: x1Prop = undefined,
@@ -122,6 +123,7 @@
122
123
  transforms: () => transforms,
123
124
  selection: () => selection,
124
125
  selectionResponse: () => selectionResponse,
126
+ nulls: () => nulls,
125
127
  limit: () => limit,
126
128
  sort: () => sort,
127
129
  domain: () => domain,
@@ -43,6 +43,7 @@
43
43
  table,
44
44
  selection = undefined,
45
45
  selectionResponse = undefined,
46
+ nulls = undefined,
46
47
  x,
47
48
  y,
48
49
  text,
@@ -131,6 +132,9 @@
131
132
  get selectionResponse() {
132
133
  return selectionResponse;
133
134
  },
135
+ get nulls() {
136
+ return nulls;
137
+ },
134
138
  get limit() {
135
139
  return limit;
136
140
  },
@@ -387,17 +391,12 @@
387
391
  // range: a label off the left edge, or below the axis, where the shader
388
392
  // discards the bar it belongs to. The GPU states this discard explicitly
389
393
  // rather than leaving it to the scissor rect, and so does this.
390
- const slotted = (
391
- band: BandScaleState,
392
- raw: string | number,
393
- range: number,
394
- axis: 'x' | 'y'
395
- ) => {
394
+ const slotted = (band: BandScaleState, raw: string | number, range: number) => {
396
395
  const key = bandSlotKey(band, raw);
397
396
  if (key == null) return null;
398
397
  // Asked once and handed on: tickPosition would look it up again.
399
398
  const slot = band.tickSlot(key);
400
- return slot < 0 ? null : band.slotPosition(slot, range, axis);
399
+ return slot < 0 ? null : band.slotPosition(slot, range);
401
400
  };
402
401
 
403
402
  const newPos = new Array(items.length);
@@ -406,13 +405,13 @@
406
405
  let px, py;
407
406
 
408
407
  if (xBand) {
409
- px = slotted(xBand, xRaw[dataIdx], rangeX, 'x');
408
+ px = slotted(xBand, xRaw[dataIdx], rangeX);
410
409
  } else {
411
410
  px = xScale(xRaw[dataIdx]);
412
411
  }
413
412
 
414
413
  if (yBand) {
415
- py = slotted(yBand, yRaw[dataIdx], rangeY, 'y');
414
+ py = slotted(yBand, yRaw[dataIdx], rangeY);
416
415
  } else {
417
416
  py = yScale(yRaw[dataIdx]);
418
417
  }
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import type { ScaleRequirement } from '../../../scales/ScalePriorities';
21
21
  import type { DataAccessor } from '../../../data/DataAccessor';
22
- import type { FieldRef, NormalizedSortConfig, Transform, ChartContext } from '../../../types';
22
+ import type { FieldRef, NormalizedSortConfig, Transform, ChartContext, NullsResponse } from '../../../types';
23
23
  import type { ColorScaleState } from '../../../scales/ColorScaleState.svelte';
24
24
  import type { SizeScaleState } from '../../../scales/SizeScaleState.svelte';
25
25
  import type { QueryEngine } from '../../../query-engine/QueryEngine';
@@ -27,6 +27,7 @@ import type { FilterGroup } from '../../../query-engine/FilterGroup.svelte';
27
27
  import { type SelectionResponse } from '../../rendering/activity-reads';
28
28
  import type { MarkLODSurface } from '../../../lod/rung-types';
29
29
  import type { GPUCoordinator } from '../../../gpu/GPUCoordinator.svelte';
30
+ import { OtherRows } from './other-rows.svelte';
30
31
  export interface MarkLifecycleV2Config {
31
32
  markType: string;
32
33
  supportedScaleTypes: {
@@ -93,6 +94,8 @@ export interface ReactiveConfig {
93
94
  readonly fitDomain: 'x' | 'y' | boolean | undefined;
94
95
  readonly tip: (unknown | '*')[] | false | undefined;
95
96
  readonly selectionResponse: SelectionResponse | undefined;
97
+ readonly nulls: NullsResponse | undefined;
98
+ readonly contributeDomain: boolean | undefined;
96
99
  }
97
100
  export declare class MarkLifecycleV2 {
98
101
  #private;
@@ -101,6 +104,8 @@ export declare class MarkLifecycleV2 {
101
104
  gpuCoordinator?: GPUCoordinator | null;
102
105
  onMarkLOD?: ((surface: MarkLODSurface | null) => void) | null;
103
106
  });
107
+ /** What this mark's Other rows hold, and what is done with it. */
108
+ readonly otherRows: OtherRows;
104
109
  /**
105
110
  * The axis whose rows are one per category, in category order — or null.
106
111
  *
@@ -42,6 +42,7 @@ import { buildQueryParts, assembleQuery, castReal } from './query-builder';
42
42
  import { setScalesReady, markDeferredScalesReady, setInitialContinuousDomains, registerDataDerivedDomains } from './domain-registration';
43
43
  import { fetchCategoricalDomains, fetchGlobalAggregates } from './domain-queries';
44
44
  import { transformScaleKey } from './transform-scale-key';
45
+ import { OtherRows } from './other-rows.svelte';
45
46
  // ---------------------------------------------------------------------------
46
47
  // MarkLifecycleV2
47
48
  // ---------------------------------------------------------------------------
@@ -185,8 +186,68 @@ export class MarkLifecycleV2 {
185
186
  this.#inlineIdentityColumns(resolvedSelect);
186
187
  this.#inlineTimeOffsets(resolvedSelect);
187
188
  const parts = buildQueryParts(resolvedSelect, this.#config._orderConfig ?? null, this.#transformCompiler.hasSQLTransforms(), passthroughChannels);
189
+ const nullDrops = this.#nullDrops(semanticSelect, resolvedSelect);
190
+ if (nullDrops.length > 0)
191
+ parts.nullDrops = nullDrops;
188
192
  return parts;
189
193
  });
194
+ /**
195
+ * The mark's categorical channels, each with the source expression it draws:
196
+ * a band or point axis, an ordinal colour, a separate series, and the facet
197
+ * (`__facet`, from the chart's fx). What `nulls="drop"` filters.
198
+ */
199
+ #categoricalChannels(select) {
200
+ const out = [];
201
+ const categorical = (type) => type === 'band' || type === 'point';
202
+ if (categorical(this.actualXScaleType))
203
+ out.push(['x', select.x]);
204
+ if (categorical(this.actualYScaleType))
205
+ out.push(['y', select.y]);
206
+ const colorChannel = this.#config.colorChannel || 'fill';
207
+ if (this.#colorScaleState?.isOrdinal)
208
+ out.push([colorChannel, select[colorChannel]]);
209
+ const seriesChannel = this.#config._orderConfig?.seriesChannel ?? null;
210
+ if (seriesChannel && seriesChannel !== colorChannel) {
211
+ out.push([seriesChannel, select[seriesChannel]]);
212
+ }
213
+ if (this.#chartContext.fx)
214
+ out.push(['__facet', this.#chartContext.fx]);
215
+ // Only row-level values: an aggregate has no NULL category to drop.
216
+ return out.filter(([, source]) => source != null &&
217
+ typeof source !== 'number' &&
218
+ !(isNode(source) && isAggregateExpression(source)));
219
+ }
220
+ /**
221
+ * `nulls="drop"`, by channel (see QueryParts.nullDrops). A channel the query
222
+ * selects as its dim's id column drops the NULL category's id; one still
223
+ * carrying its raw value (identity not landed yet) drops SQL NULL. Read off
224
+ * the select itself, so the two cannot disagree: inlining an id column is
225
+ * what changes the channel's expression.
226
+ */
227
+ #nullDrops(semantic, resolved) {
228
+ if (this.#reactiveConfig.nulls !== 'drop')
229
+ return [];
230
+ const drops = [];
231
+ for (const [channel, source] of this.#categoricalChannels(semantic)) {
232
+ const encoded = channel === '__facet' ? '__facet' in resolved : resolved[channel] !== source;
233
+ if (channel === '__facet' && !encoded)
234
+ continue;
235
+ const key = encoded ? identityKeyFor(source)?.key : null;
236
+ const nullId = key ? this.#queryEngine.identityFor(key).nullId : null;
237
+ drops.push({ channel, nullId });
238
+ }
239
+ return drops;
240
+ }
241
+ /**
242
+ * `nulls="drop"` for a query over the RAW select (no id columns, no
243
+ * `__facet`): `<source> IS NOT NULL` per categorical channel, the facet's
244
+ * on the chart's fx. Passed as that query's filter.
245
+ */
246
+ #rawNullFilters(select) {
247
+ if (this.#reactiveConfig.nulls !== 'drop')
248
+ return [];
249
+ return this.#categoricalChannels(select).map(([, source]) => isNotNull((typeof source === 'string' ? sqlColumn(source) : source)));
250
+ }
190
251
  // --- Domain State ---
191
252
  #channelsNeedingDataDomain = new Set();
192
253
  #dataDerivedDomainsSet = $state(false);
@@ -257,6 +318,25 @@ export class MarkLifecycleV2 {
257
318
  // than rebuilding. With no sort it passes the query through.
258
319
  this.#transformCompiler = new TransformCompiler(withRanking(withDenseCategories(config.transforms || [], config.dense !== false), () => this.#rankingSort), this.#transformConfig);
259
320
  this.#detectAutoBins();
321
+ this.otherRows = new OtherRows({
322
+ queryEngine,
323
+ filterGroup,
324
+ markId,
325
+ table: () => this.#handle?.arrowTable ?? null,
326
+ dataVersion: () => this.dataVersion,
327
+ identity: () => this.#identity,
328
+ identityKey: (axis) => this.#identityKey(axis)?.key ?? null,
329
+ filtered: () => this.#reactiveConfig.selectionResponse === 'filter',
330
+ plan: () => this.#splitPlan,
331
+ colorKey: () => {
332
+ const color = this.#colorScaleState;
333
+ return color?.isOrdinal ? color.identityKey : null;
334
+ },
335
+ bandScaleKey: (axis) => {
336
+ const band = (axis === 'x' ? this.#chartContext.xScaleState : this.#chartContext.yScaleState);
337
+ return band?.identityKey ?? null;
338
+ }
339
+ });
260
340
  // AdaptiveBinRung for binned marks (watches viewport domain, triggers rebin)
261
341
  if (this.#autoBinNodes.size > 0 && options?.gpuCoordinator) {
262
342
  const binAxes = [...this.#autoBinNodes.keys()].map((ch) => (ch.startsWith('x') ? 'x' : 'y'));
@@ -342,7 +422,8 @@ export class MarkLifecycleV2 {
342
422
  return;
343
423
  this.#identityFields.clear();
344
424
  // The Other ids name rows of the dropped dims. Every holder lets go, so
345
- // the scale acquires afresh when setup re-runs.
425
+ // the scale acquires afresh when setup re-runs; so do the registered rows.
426
+ this.otherRows.unregister();
346
427
  for (const scale of [...this.#otherScales])
347
428
  this.#releaseOther(scale);
348
429
  // Every dim it names just went away — topN must not bucket into one.
@@ -491,14 +572,35 @@ export class MarkLifecycleV2 {
491
572
  return;
492
573
  untrack(() => {
493
574
  this.#handleDataDomains(dv);
575
+ this.otherRows.register();
494
576
  });
495
577
  });
496
- // Tooltip: write tooltip table when data updates (debounced).
578
+ // Tooltip: two effects, so a data update does not take the table away.
579
+ //
580
+ // This one owns the table's lifetime: it drops it when tooltips go from
581
+ // wanted to not. It used to be one effect with the write below, and its
582
+ // cleanup then ran on every data update too: the table was dropped and
583
+ // marked not ready, the mark's tooltip unregistered, and the hovered row
584
+ // went with it. A click right after a data update found no row under the
585
+ // pointer, read as a click on empty space and cleared the selection, and
586
+ // the next click did nothing until the pointer moved.
587
+ $effect(() => {
588
+ const tooltipConfig = this.#reactiveConfig.tooltip;
589
+ const fitDomain = this.#reactiveConfig.fitDomain;
590
+ if (tooltipConfig === false && !fitDomain)
591
+ return;
592
+ return () => {
593
+ clearTimeout(this.#tooltipWriteTimeout);
594
+ this.#tooltipTableReady = false;
595
+ this.#queryEngine.exec(`DROP TABLE IF EXISTS "${this.#tooltipTableName}"`).catch(() => { });
596
+ };
597
+ });
598
+ // And this one rewrites it when data updates (debounced). The old table
599
+ // stays readable until the rewrite replaces it.
497
600
  $effect(() => {
498
601
  const tooltipConfig = this.#reactiveConfig.tooltip;
499
602
  const fitDomain = this.#reactiveConfig.fitDomain;
500
- const tooltipWanted = tooltipConfig !== false || !!fitDomain;
501
- if (!tooltipWanted)
603
+ if (tooltipConfig === false && !fitDomain)
502
604
  return;
503
605
  const dataVersion = this.dataVersion;
504
606
  if (dataVersion === 0)
@@ -511,12 +613,7 @@ export class MarkLifecycleV2 {
511
613
  this.#writeTooltipTable(data);
512
614
  }, 300);
513
615
  });
514
- // Cleanup: when tooltip goes true→false, drop the temp table.
515
- return () => {
516
- clearTimeout(this.#tooltipWriteTimeout);
517
- this.#tooltipTableReady = false;
518
- this.#queryEngine.exec(`DROP TABLE IF EXISTS "${this.#tooltipTableName}"`).catch(() => { });
519
- };
616
+ return () => clearTimeout(this.#tooltipWriteTimeout);
520
617
  });
521
618
  // fitDomain — query DuckDB for fitted axis extent on driving axis change.
522
619
  $effect(() => {
@@ -612,7 +709,7 @@ export class MarkLifecycleV2 {
612
709
  const [schemaColumns, fetchCount, aggResult, fieldStats] = await Promise.all([
613
710
  // Fetch table schema (cached on QueryEngine, needed for CAST type decisions)
614
711
  this.#queryEngine.describe(this.#config.table),
615
- fetchCategoricalDomains(this.#queryEngine, this.#config.table, this.#config.select, this.actualXScaleType, this.actualYScaleType, this.#chartContext.xScaleState, this.#chartContext.yScaleState, this.#chartContext.facetBandScale, this.#chartContext.fx, this.#config.colorChannel || 'fill', this.#colorScaleState, this.#config.sizeChannel || 'r', this.#sizeScaleState, this.#seriesScaleState, seriesKey, this.#bandDomainFromData, this.#markId, this.#reactiveConfig.domain),
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'),
616
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),
617
714
  this.#fetchFieldStats(),
618
715
  this.#ensureIdentityColumns()
@@ -872,7 +969,7 @@ export class MarkLifecycleV2 {
872
969
  const additionalChannels = this.#transformCompiler.getAdditionalChannels();
873
970
  const xState = this.#chartContext.xScaleState;
874
971
  const yState = this.#chartContext.yScaleState;
875
- const domainResult = registerDataDerivedDomains(data, this.#channelsNeedingDataDomain, additionalChannels, this.#skipDomainUpdateForAxis, this.#config.zeroBaseline, this.actualXScaleType, this.actualYScaleType, xState, yState, this.#bandDomainFromData, this.#chartContext.xScaleState, this.#chartContext.yScaleState, this.#markId, false, // isRebinning
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
876
973
  this.#colorScaleState);
877
974
  this.#dataDerivedDomainsSet = domainResult.dataDerivedDomainsSet;
878
975
  this.#skipDomainUpdateForAxis = domainResult.skipDomainUpdateForAxis;
@@ -1007,11 +1104,15 @@ export class MarkLifecycleV2 {
1007
1104
  const identity = {};
1008
1105
  const describe = (channel, key, positional) => {
1009
1106
  const present = this.#queryEngine.presentTableFor(key, this.#config.table);
1107
+ // nulls="drop": its rows are filtered out, and dense() must not pad a
1108
+ // zero row for it either, or a sorted axis keeps a "(null)" slot.
1109
+ const nullId = this.#reactiveConfig.nulls === 'drop' ? this.#queryEngine.identityFor(key).nullId : null;
1010
1110
  identity[channel] = {
1011
1111
  dim: this.#queryEngine.dimTableFor(key),
1012
1112
  size: this.#queryEngine.identityFor(key).values.length,
1013
1113
  positional,
1014
- ...(present ? { present } : {})
1114
+ ...(present ? { present } : {}),
1115
+ ...(nullId !== null ? { dropNullId: nullId } : {})
1015
1116
  };
1016
1117
  };
1017
1118
  for (const { axis, key } of axes)
@@ -1136,6 +1237,67 @@ export class MarkLifecycleV2 {
1136
1237
  this.#releaseOther(scale);
1137
1238
  }
1138
1239
  }
1240
+ #warnedFollow = false;
1241
+ /**
1242
+ * Whether this mark follows its band axes instead of adding its categories to
1243
+ * them (`contributeDomain={false}`). It cannot also choose its own categories
1244
+ * or their order: a topN or a ranking would be what a follower exists not to
1245
+ * do, and an explicit domain is a contribution. Such a mark is refused, once,
1246
+ * and contributes as usual.
1247
+ */
1248
+ get #followsDomain() {
1249
+ if (this.#reactiveConfig.contributeDomain !== false)
1250
+ return false;
1251
+ const reason = this.#transformCompiler.hasCategoryReducingTransforms()
1252
+ ? 'a category-reducing transform (topN)'
1253
+ : this.#rankingSort
1254
+ ? 'a sort'
1255
+ : this.#reactiveConfig.domain
1256
+ ? 'an explicit domain'
1257
+ : null;
1258
+ if (!reason)
1259
+ return true;
1260
+ if (!this.#warnedFollow) {
1261
+ this.#warnedFollow = true;
1262
+ console.error(`[${this.#markId}] Unsupported: contributeDomain={false} with ${reason}. A mark that ` +
1263
+ `follows its band axis cannot choose its own categories or their order. It will ` +
1264
+ `contribute to the domain as usual.`);
1265
+ }
1266
+ return false;
1267
+ }
1268
+ /**
1269
+ * The band scale of an axis, for registering this mark's categories on it:
1270
+ * null for a mark that follows, so it registers nothing and withdraws what it
1271
+ * had (the prop can change). The scale still draws the mark's rows, only those
1272
+ * of categories it shows.
1273
+ */
1274
+ #bandScaleForDomain(axis) {
1275
+ const scale = (axis === 'x' ? this.#chartContext.xScaleState : this.#chartContext.yScaleState);
1276
+ // Only a band or point axis has categories to follow; a value axis is
1277
+ // the mark's own.
1278
+ const type = axis === 'x' ? this.actualXScaleType : this.actualYScaleType;
1279
+ if ((type !== 'band' && type !== 'point') || !this.#followsDomain)
1280
+ return scale;
1281
+ scale?.removeMark(this.#markId);
1282
+ return null;
1283
+ }
1284
+ /** What this mark's Other rows hold, and what is done with it. */
1285
+ otherRows;
1286
+ /** What the mark is, for whether its Other bar splits; null before setup completes. */
1287
+ get #splitPlan() {
1288
+ if (!this.setupComplete)
1289
+ return null;
1290
+ const markType = this.#transformConfig.markType;
1291
+ const length = markType === 'barx' ? this.#transformConfig.x : this.#transformConfig.y;
1292
+ return {
1293
+ markType,
1294
+ length,
1295
+ by: this.#transformCompiler.otherBy(),
1296
+ expr: length ? this.#config.select[length] : undefined,
1297
+ hasOther: this.#transformCompiler.hasSyntheticOtherBucket(),
1298
+ hasJsTransforms: this.#transformCompiler.jsTransforms.length > 0
1299
+ };
1300
+ }
1139
1301
  /** The band scales whose Other bucket this mark holds a claim on. */
1140
1302
  #otherScales = new Set();
1141
1303
  #releaseOther(scale) {
@@ -1195,10 +1357,12 @@ export class MarkLifecycleV2 {
1195
1357
  // Build raw query parts directly from config.select (no identity columns,
1196
1358
  // no bin resolution — scale states aren't ready yet during Phase 1).
1197
1359
  const parts = buildQueryParts(this.#config.select, this.#config._orderConfig ?? null, this.#transformCompiler.hasSQLTransforms());
1360
+ // Domains from the rows the mark draws: without what nulls="drop" leaves
1361
+ // out. This select is raw, so the filter is on the raw values.
1198
1362
  const q = assembleQuery({
1199
1363
  table: this.#config.table,
1200
1364
  parts,
1201
- filter,
1365
+ filter: [...filter, ...this.#rawNullFilters(this.#config.select)],
1202
1366
  transformCompiler: this.#transformCompiler
1203
1367
  });
1204
1368
  if (!q)
@@ -1496,10 +1660,8 @@ export class MarkLifecycleV2 {
1496
1660
  // the rest are scalars (the rank, id columns), cheap to keep, and the
1497
1661
  // lists are the only ones whose size grows with the field.
1498
1662
  const withoutMembers = withoutOtherMembers(tooltipQuery);
1499
- await this.#queryEngine.exec([
1500
- `DROP TABLE IF EXISTS "${tableName}"`,
1501
- `CREATE TEMP TABLE "${tableName}" AS SELECT *${replaceClause} FROM (${withoutMembers})${orderClause}`
1502
- ]);
1663
+ // 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}`);
1503
1665
  }
1504
1666
  else {
1505
1667
  // Path B: Serialize transformed data via SQL VALUES.
@@ -1554,6 +1716,9 @@ export class MarkLifecycleV2 {
1554
1716
  }
1555
1717
  }
1556
1718
  this.#tooltipTableReady = true;
1719
+ // The row under a still pointer was read from the old table: read it
1720
+ // again, so the tooltip and a click show the new data without a move.
1721
+ this.#chartContext.tooltipManager?.refresh();
1557
1722
  this.#onTooltipTableWritten();
1558
1723
  }
1559
1724
  catch (error) {
@@ -1624,6 +1789,9 @@ export class MarkLifecycleV2 {
1624
1789
  }
1625
1790
  Object.assign(sidecarSelect, tipExtras);
1626
1791
  const sidecarParts = buildQueryParts(sidecarSelect, undefined, this.#transformCompiler.hasSQLTransforms());
1792
+ // The same rows as the mark: it leaves out what the mark leaves out. Its
1793
+ // select holds every non-aggregate channel, so each dropped one is there.
1794
+ sidecarParts.nullDrops = this.#resolvedQueryParts.nullDrops;
1627
1795
  const filter = this.#filterGroup.selection.predicate(this.#route) ?? [];
1628
1796
  const sidecarQuery = assembleQuery({
1629
1797
  table: this.#sourceTable,
@@ -1822,6 +1990,7 @@ export class MarkLifecycleV2 {
1822
1990
  this.#destroyed = true;
1823
1991
  this.#identityResetOff?.();
1824
1992
  this.#identityResetOff = null;
1993
+ this.otherRows.unregister();
1825
1994
  for (const scale of [...this.#otherScales])
1826
1995
  this.#releaseOther(scale);
1827
1996
  this.#handle?.destroy();
@@ -14,7 +14,9 @@ import type { ContinuousScaleState } from '../../../types';
14
14
  export declare function fetchCategoricalDomains(coordinator: QueryEngine, table: string, select: Record<string, unknown>, actualXScaleType: string, actualYScaleType: string, xScaleState: BandScaleState | null, yScaleState: BandScaleState | null, facetBandScale: BandScaleState | null | undefined, fx: unknown, colorChannel: string, colorScaleState: ColorScaleState | null, sizeChannel: string, sizeScaleState: SizeScaleState | null, seriesScaleState: ColorScaleState | null, seriesChannel: string | null, bandDomainFromData: boolean, markId: string, explicitDomain?: {
15
15
  x?: DimValue[];
16
16
  y?: DimValue[];
17
- } | null): Promise<number>;
17
+ } | null,
18
+ /** nulls="drop": the band and facet domains leave out the NULL category. */
19
+ dropNull?: boolean): Promise<number>;
18
20
  /**
19
21
  * Fetch global min/max extents for aggregated fields using a "sidecar" query.
20
22
  * Runs the same aggregation as the main query but without filters, wrapped
@@ -10,7 +10,13 @@ import { identityKeyFor } from '../../../query-engine/identity-key';
10
10
  *
11
11
  * Returns the number of fetches executed (for logging).
12
12
  */
13
- export async function fetchCategoricalDomains(coordinator, table, select, actualXScaleType, actualYScaleType, xScaleState, yScaleState, facetBandScale, fx, colorChannel, colorScaleState, sizeChannel, sizeScaleState, seriesScaleState, seriesChannel, bandDomainFromData, markId, explicitDomain) {
13
+ export async function fetchCategoricalDomains(coordinator, table, select, actualXScaleType, actualYScaleType, xScaleState, yScaleState, facetBandScale, fx, colorChannel, colorScaleState, sizeChannel, sizeScaleState, seriesScaleState, seriesChannel, bandDomainFromData, markId, explicitDomain,
14
+ /** nulls="drop": the band and facet domains leave out the NULL category. */
15
+ dropNull = false) {
16
+ const categoriesOf = (key) => {
17
+ const categories = coordinator.categoryValues(key, table);
18
+ return dropNull ? categories.filter((c) => c !== null) : categories;
19
+ };
14
20
  const fetches = [];
15
21
  const setupFetch = (axis, field, scaleState) => {
16
22
  if (!field || !scaleState)
@@ -65,7 +71,7 @@ export async function fetchCategoricalDomains(coordinator, table, select, actual
65
71
  // Idempotent and coalesced — the mark's own setup calls this too.
66
72
  await coordinator.ensureColumns(table, { categorical: [identityField] });
67
73
  // This table's categories: the dim spans every table on the field.
68
- const categories = coordinator.categoryValues(identityField.key, table);
74
+ const categories = categoriesOf(identityField.key);
69
75
  // A snapshot of the real categories. The scale adopts the live
70
76
  // identity itself in MarkLifecycleV2's #ensureIdentityColumns, which
71
77
  // also covers the explicit-domain axes that never reach this branch.
@@ -103,9 +109,8 @@ export async function fetchCategoricalDomains(coordinator, table, select, actual
103
109
  // Registered from the data.
104
110
  }
105
111
  else if (explicitDomain?.y && yScaleState) {
106
- // Y band scale maps index 0 → bottom, so reverse the user's
107
- // top-to-bottom array to match GPU rendering order.
108
- deferredExplicitY = [...explicitDomain.y].reverse();
112
+ // Slot 0 is at the top of a y band axis, so the list is used as given.
113
+ deferredExplicitY = explicitDomain.y;
109
114
  }
110
115
  else {
111
116
  setupFetch('y', select.y, yScaleState);
@@ -123,7 +128,7 @@ export async function fetchCategoricalDomains(coordinator, table, select, actual
123
128
  fetches.push((async () => {
124
129
  // Idempotent and coalesced — the mark's own setup calls this too.
125
130
  await coordinator.ensureColumns(table, { categorical: [facetKey] });
126
- const categories = coordinator.categoryValues(facetKey.key, table);
131
+ const categories = categoriesOf(facetKey.key);
127
132
  if (categories.length > 0)
128
133
  facetBandScale.addMark(categories, markId);
129
134
  })());
@@ -193,12 +193,8 @@ export function registerDataDerivedDomains(data, channelsNeedingDataDomain, addi
193
193
  // order the ranked query had. See RANK_COLUMN.
194
194
  const ranked = data.getColumn(RANK_COLUMN) != null;
195
195
  const ids = extractIdsRankedFromData(data, axis, RANK_COLUMN);
196
- // A y band axis puts slot 0 at the bottom and a ranking is read
197
- // from the top. Reversed here, once, for every ranked axis —
198
- // topN's and the sort's alike — rather than by whichever transform
199
- // produced the rank.
200
- if (ranked && axis === 'y')
201
- ids.reverse();
196
+ // Used as ranked on both axes: slot 0 is at the left of x and the
197
+ // top of y, where a ranking is read from.
202
198
  if (ids.length === 0)
203
199
  continue;
204
200
  // `sorted` says this list is a ranking the axis should prefer over