@fjader/svelte 0.2.2 → 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 (148) 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 +64 -25
  7. package/dist/components/axes/BandAxisTicks.svelte.d.ts +5 -5
  8. package/dist/components/axes/axis-title.d.ts +30 -0
  9. package/dist/components/axes/axis-title.js +77 -0
  10. package/dist/components/charts/ChartCell.svelte +1 -1
  11. package/dist/components/charts/ChartContainer.svelte +12 -3
  12. package/dist/components/charts/ChartContainer.svelte.d.ts +7 -0
  13. package/dist/components/guides/legend/ContinuousColorLegend.svelte +9 -2
  14. package/dist/components/guides/legend/OrdinalColorLegend.svelte +96 -35
  15. package/dist/components/marks/AreaMarkX.svelte +21 -6
  16. package/dist/components/marks/AreaMarkY.svelte +21 -6
  17. package/dist/components/marks/BarMarkX.svelte +16 -1
  18. package/dist/components/marks/BarMarkY.svelte +16 -1
  19. package/dist/components/marks/DotsMark.svelte +5 -0
  20. package/dist/components/marks/LineMark.svelte +8 -1
  21. package/dist/components/marks/ParallelCoordinatesMark.svelte +10 -0
  22. package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +5 -0
  23. package/dist/components/marks/RectMark.svelte +5 -0
  24. package/dist/components/marks/RectXMark.svelte +5 -0
  25. package/dist/components/marks/RectYMark.svelte +5 -0
  26. package/dist/components/marks/RuleMarkX.svelte +12 -2
  27. package/dist/components/marks/RuleMarkY.svelte +12 -2
  28. package/dist/components/marks/TextMark.svelte +76 -13
  29. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +21 -2
  30. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +300 -42
  31. package/dist/components/marks/lifecycle/domain-queries.d.ts +3 -1
  32. package/dist/components/marks/lifecycle/domain-queries.js +22 -15
  33. package/dist/components/marks/lifecycle/domain-registration.js +2 -6
  34. package/dist/components/marks/lifecycle/other-rows.svelte.d.ts +104 -0
  35. package/dist/components/marks/lifecycle/other-rows.svelte.js +193 -0
  36. package/dist/components/marks/lifecycle/other-split.d.ts +80 -0
  37. package/dist/components/marks/lifecycle/other-split.js +112 -0
  38. package/dist/components/marks/lifecycle/query-builder.d.ts +26 -1
  39. package/dist/components/marks/lifecycle/query-builder.js +25 -5
  40. package/dist/components/marks/lifecycle/transform-scale-key.d.ts +16 -0
  41. package/dist/components/marks/lifecycle/transform-scale-key.js +27 -0
  42. package/dist/components/marks/utils/area-outline.d.ts +24 -0
  43. package/dist/components/marks/utils/area-outline.js +10 -0
  44. package/dist/components/marks/utils/reactive-config.d.ts +1 -1
  45. package/dist/components/marks/utils/reactive-config.js +9 -0
  46. package/dist/components/marks/utils/tooltip-helpers.d.ts +4 -1
  47. package/dist/components/marks/utils/tooltip-helpers.js +2 -1
  48. package/dist/components/rendering/Areas.svelte +30 -6
  49. package/dist/components/rendering/Areas.svelte.d.ts +2 -0
  50. package/dist/components/rendering/BaseGPURenderer.svelte.d.ts +3 -1
  51. package/dist/components/rendering/BaseGPURenderer.svelte.js +8 -3
  52. package/dist/components/rendering/Dots.svelte +24 -3
  53. package/dist/components/rendering/Dots.svelte.d.ts +2 -0
  54. package/dist/components/rendering/GridLine.svelte +2 -1
  55. package/dist/components/rendering/Lines.svelte +49 -7
  56. package/dist/components/rendering/Lines.svelte.d.ts +9 -0
  57. package/dist/components/rendering/ParallelLines.svelte +26 -7
  58. package/dist/components/rendering/ParallelLines.svelte.d.ts +2 -0
  59. package/dist/components/rendering/Rects.svelte +129 -20
  60. package/dist/components/rendering/Rects.svelte.d.ts +9 -0
  61. package/dist/components/rendering/RuleX.svelte +20 -5
  62. package/dist/components/rendering/RuleX.svelte.d.ts +2 -0
  63. package/dist/components/rendering/RuleY.svelte +20 -5
  64. package/dist/components/rendering/RuleY.svelte.d.ts +2 -0
  65. package/dist/components/rendering/activity-reads.d.ts +111 -0
  66. package/dist/components/rendering/activity-reads.js +154 -0
  67. package/dist/components/rendering/uniform-sizes.d.ts +26 -0
  68. package/dist/components/rendering/uniform-sizes.js +28 -0
  69. package/dist/config/ChartConfig.svelte.d.ts +5 -1
  70. package/dist/gpu/ColorScaleShaderLib.d.ts +13 -1
  71. package/dist/gpu/ColorScaleShaderLib.js +56 -5
  72. package/dist/gpu/GPUCoordinator.svelte.d.ts +8 -0
  73. package/dist/gpu/GPUCoordinator.svelte.js +68 -32
  74. package/dist/gpu/MorphedChannels.d.ts +6 -32
  75. package/dist/gpu/MorphedChannels.js +45 -3
  76. package/dist/gpu/ResolveShader.d.ts +2 -2
  77. package/dist/gpu/ResolveShader.js +2 -2
  78. package/dist/gpu/ScaleTemplateSystem.d.ts +19 -0
  79. package/dist/gpu/ScaleTemplateSystem.js +111 -11
  80. package/dist/gpu/SizeScaleShaderLib.d.ts +4 -4
  81. package/dist/gpu/SizeScaleShaderLib.js +4 -4
  82. package/dist/gpu/identity-slot-packing.d.ts +157 -40
  83. package/dist/gpu/identity-slot-packing.js +262 -62
  84. package/dist/gpu/tracked-scales.d.ts +14 -0
  85. package/dist/gpu/tracked-scales.js +18 -0
  86. package/dist/index.d.ts +2 -0
  87. package/dist/index.js +4 -0
  88. package/dist/interactors/BandLabelToggle.svelte +5 -9
  89. package/dist/interactors/BandLabelToggle.svelte.d.ts +3 -3
  90. package/dist/interactors/DerivedInterval1D.svelte +26 -54
  91. package/dist/interactors/Interval1D.svelte +8 -2
  92. package/dist/interactors/Interval2D.svelte +8 -2
  93. package/dist/interactors/ParallelAxisSource.svelte +14 -5
  94. package/dist/interactors/ParallelAxisSource.svelte.d.ts +2 -1
  95. package/dist/interactors/ParallelBrushes.svelte +8 -9
  96. package/dist/interactors/ParallelBrushes.svelte.d.ts +3 -3
  97. package/dist/interactors/Toggle.svelte +48 -38
  98. package/dist/interactors/Toggle.svelte.d.ts +4 -3
  99. package/dist/interactors/Tooltip.svelte +140 -42
  100. package/dist/interactors/TooltipManager.svelte.d.ts +20 -5
  101. package/dist/interactors/TooltipManager.svelte.js +40 -10
  102. package/dist/interactors/other-tooltip.d.ts +16 -0
  103. package/dist/interactors/other-tooltip.js +22 -0
  104. package/dist/interactors/relay-clauses.d.ts +23 -0
  105. package/dist/interactors/relay-clauses.js +60 -0
  106. package/dist/query-engine/CrossfilterSelection.svelte.d.ts +88 -15
  107. package/dist/query-engine/CrossfilterSelection.svelte.js +94 -20
  108. package/dist/query-engine/FieldActivity.svelte.d.ts +128 -0
  109. package/dist/query-engine/FieldActivity.svelte.js +378 -0
  110. package/dist/query-engine/FilterGroup.svelte.d.ts +76 -30
  111. package/dist/query-engine/FilterGroup.svelte.js +223 -124
  112. package/dist/query-engine/FilterGroupRegistry.svelte.d.ts +73 -3
  113. package/dist/query-engine/FilterGroupRegistry.svelte.js +161 -12
  114. package/dist/query-engine/MarkHandle.svelte.d.ts +9 -1
  115. package/dist/query-engine/MarkHandle.svelte.js +12 -2
  116. package/dist/query-engine/QueryEngine.d.ts +52 -16
  117. package/dist/query-engine/QueryEngine.js +116 -34
  118. package/dist/query-engine/categorical-identity.d.ts +114 -27
  119. package/dist/query-engine/categorical-identity.js +225 -51
  120. package/dist/query-engine/index.d.ts +2 -1
  121. package/dist/query-engine/index.js +1 -0
  122. package/dist/query-engine/preagg/Preagg.d.ts +1 -1
  123. package/dist/query-engine/preagg/Preagg.js +11 -9
  124. package/dist/query-engine/preagg/plan.d.ts +14 -5
  125. package/dist/query-engine/preagg/plan.js +44 -18
  126. package/dist/query-engine/toggle-predicate.d.ts +21 -0
  127. package/dist/query-engine/toggle-predicate.js +41 -2
  128. package/dist/scales/ActivityTable.d.ts +103 -0
  129. package/dist/scales/ActivityTable.js +220 -0
  130. package/dist/scales/BandScaleState.svelte.d.ts +41 -15
  131. package/dist/scales/BandScaleState.svelte.js +78 -36
  132. package/dist/scales/ChartScales.svelte.d.ts +2 -2
  133. package/dist/scales/ColorScaleState.svelte.d.ts +36 -8
  134. package/dist/scales/ColorScaleState.svelte.js +118 -24
  135. package/dist/scales/DomainResolver.d.ts +2 -0
  136. package/dist/scales/DomainResolver.js +15 -8
  137. package/dist/scales/ScaleRegistry.svelte.d.ts +9 -0
  138. package/dist/scales/ScaleRegistry.svelte.js +22 -1
  139. package/dist/scales/TimeScaleState.svelte.js +7 -1
  140. package/dist/spec/index.js +1 -0
  141. package/dist/spec/types.d.ts +4 -0
  142. package/dist/theme.css +5 -4
  143. package/dist/transforms/TransformCompiler.d.ts +37 -2
  144. package/dist/transforms/TransformCompiler.js +44 -1
  145. package/dist/transforms/index.d.ts +27 -0
  146. package/dist/transforms/index.js +127 -19
  147. package/dist/types.d.ts +71 -0
  148. package/package.json +3 -2
@@ -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,10 @@ 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';
138
+ /** What the mark does with NULL in its categorical channels; see BaseMarkProps. Default 'keep'. */
139
+ nulls?: 'keep' | 'drop';
136
140
  scheme?: ColorScheme;
137
141
  limit?: number;
138
142
  transforms?: TransformSpec[];
package/dist/theme.css CHANGED
@@ -7,6 +7,11 @@
7
7
  * Or copy and customize the variables below in your own CSS.
8
8
  * All variables are optional — Fjader components have sensible
9
9
  * inline fallbacks and work without any CSS imports.
10
+ *
11
+ * The tooltip is not set here: it follows the color-scheme it inherits (the
12
+ * one below, or the app's own) by itself, in contrast or follow mode
13
+ * (data-fjader-tooltip on any ancestor, or ChartContainer's tooltipScheme).
14
+ * Set --fjader-tooltip-bg and --fjader-tooltip-text to override both modes.
10
15
  */
11
16
 
12
17
  :where(:root) {
@@ -16,8 +21,6 @@
16
21
  --fjader-bg: #ffffff;
17
22
  --fjader-primary: #3b82f6;
18
23
  --fjader-accent: #93c5fd;
19
- --fjader-tooltip-bg: rgba(0, 0, 0, 0.88);
20
- --fjader-tooltip-text: #ffffff;
21
24
  --fjader-palette-bg: rgba(0, 0, 0, 0.92);
22
25
  --fjader-palette-text: #ffffff;
23
26
  --fjader-palette-text-secondary: rgba(255, 255, 255, 0.5);
@@ -34,8 +37,6 @@
34
37
  --fjader-bg: #111827;
35
38
  --fjader-primary: #60a5fa;
36
39
  --fjader-accent: #3b82f6;
37
- --fjader-tooltip-bg: rgba(255, 255, 255, 0.92);
38
- --fjader-tooltip-text: #111827;
39
40
  --fjader-palette-bg: rgba(255, 255, 255, 0.95);
40
41
  --fjader-palette-text: #111827;
41
42
  --fjader-palette-text-secondary: rgba(17, 24, 39, 0.5);
@@ -1,4 +1,4 @@
1
- import type { SelectQuery } from '@uwdata/mosaic-sql';
1
+ import type { ExprNode, SelectQuery } from '@uwdata/mosaic-sql';
2
2
  import type { Transform, SqlTransform, JsTransform, MarkConfig, ScaleMap } from '../types';
3
3
  import type { DataAccessor } from '../data/DataAccessor';
4
4
  export declare class TransformCompiler {
@@ -19,7 +19,40 @@ export declare class TransformCompiler {
19
19
  * than of the mark — its GROUP BY keys, say — merged over the mark config for
20
20
  * this call only, so the shared config object is never written per query.
21
21
  */
22
- applyToQuery(query: SelectQuery, facts?: Partial<MarkConfig>): SelectQuery;
22
+ applyToQuery(query: SelectQuery, facts?: Partial<MarkConfig>,
23
+ /** The row filters are already in the table the query reads (a preagg table). */
24
+ options?: {
25
+ skipRowFilters?: boolean;
26
+ }): SelectQuery;
27
+ /**
28
+ * Whether a SQL transform filters on the groups' aggregates (`having`). Its
29
+ * condition names aggregates over the source rows, which a pre-aggregated table
30
+ * would recompute over its own groups: `count(*)` counts the table's groups, not
31
+ * the rows, so it gives a different answer with no error.
32
+ *
33
+ * Only the user-facing `having()` does this. The built-ins (topN, rank, stack,
34
+ * normalize, dense) add no HAVING: each wraps the grouped query in a subquery
35
+ * (`Query.from({ __topn_base: query })`) and adds window functions or joins, and
36
+ * those work the same on a preagg table's aggregated result, which is why a topN
37
+ * mark is served from preagg. The only CTE is assembleQuery's window wrapper for
38
+ * window aggregates, applied after the transforms. A test in transforms.test.ts
39
+ * fails if a built-in starts to emit a HAVING; this then has to know it.
40
+ *
41
+ * Supporting `having()` on a preagg table would mean rewriting its condition the
42
+ * way a select channel is rewritten (sufficient-statistics: collectAggregates,
43
+ * sufficientStats, rewrite; an aggregate not in the select adds a partial
44
+ * column) and applying it to the inner grouped query BEFORE the transforms wrap
45
+ * it, so it ends up inside their subquery as it does on the base table.
46
+ */
47
+ hasHaving(): boolean;
48
+ /**
49
+ * The row predicates of the SQL transforms (`filter`), for a pre-aggregated
50
+ * table to be built with: its read cannot apply them (see SqlTransform.where).
51
+ * A row filter applies before grouping, so building the table with it gives the
52
+ * same rows the base query sees; only `filter()` is one (see hasHaving for the
53
+ * transforms that are not).
54
+ */
55
+ rowFilters(): ExprNode[];
23
56
  /**
24
57
  * Which channels a query with these aliases comes back aligned on, leading
25
58
  * term first, or null. Asked with the same facts as `applyToQuery`, so the
@@ -60,5 +93,7 @@ export declare class TransformCompiler {
60
93
  * before the transform can write it.
61
94
  */
62
95
  hasSyntheticOtherBucket(): boolean;
96
+ /** The `by` a topN Other bucket ranks by when stated explicitly, else null (its default). */
97
+ otherBy(): string | null;
63
98
  getCategoryTransformKey(): string | null;
64
99
  }
@@ -35,10 +35,14 @@ export class TransformCompiler {
35
35
  * than of the mark — its GROUP BY keys, say — merged over the mark config for
36
36
  * this call only, so the shared config object is never written per query.
37
37
  */
38
- applyToQuery(query, facts) {
38
+ applyToQuery(query, facts,
39
+ /** The row filters are already in the table the query reads (a preagg table). */
40
+ options) {
39
41
  let result = query;
40
42
  const config = this.#configFor(facts);
41
43
  for (const transform of this.sqlTransforms) {
44
+ if (options?.skipRowFilters && transform.where !== undefined)
45
+ continue;
42
46
  try {
43
47
  result = transform.apply(result, config);
44
48
  }
@@ -48,6 +52,39 @@ export class TransformCompiler {
48
52
  }
49
53
  return result;
50
54
  }
55
+ /**
56
+ * Whether a SQL transform filters on the groups' aggregates (`having`). Its
57
+ * condition names aggregates over the source rows, which a pre-aggregated table
58
+ * would recompute over its own groups: `count(*)` counts the table's groups, not
59
+ * the rows, so it gives a different answer with no error.
60
+ *
61
+ * Only the user-facing `having()` does this. The built-ins (topN, rank, stack,
62
+ * normalize, dense) add no HAVING: each wraps the grouped query in a subquery
63
+ * (`Query.from({ __topn_base: query })`) and adds window functions or joins, and
64
+ * those work the same on a preagg table's aggregated result, which is why a topN
65
+ * mark is served from preagg. The only CTE is assembleQuery's window wrapper for
66
+ * window aggregates, applied after the transforms. A test in transforms.test.ts
67
+ * fails if a built-in starts to emit a HAVING; this then has to know it.
68
+ *
69
+ * Supporting `having()` on a preagg table would mean rewriting its condition the
70
+ * way a select channel is rewritten (sufficient-statistics: collectAggregates,
71
+ * sufficientStats, rewrite; an aggregate not in the select adds a partial
72
+ * column) and applying it to the inner grouped query BEFORE the transforms wrap
73
+ * it, so it ends up inside their subquery as it does on the base table.
74
+ */
75
+ hasHaving() {
76
+ return this.sqlTransforms.some((t) => t.type === 'having');
77
+ }
78
+ /**
79
+ * The row predicates of the SQL transforms (`filter`), for a pre-aggregated
80
+ * table to be built with: its read cannot apply them (see SqlTransform.where).
81
+ * A row filter applies before grouping, so building the table with it gives the
82
+ * same rows the base query sees; only `filter()` is one (see hasHaving for the
83
+ * transforms that are not).
84
+ */
85
+ rowFilters() {
86
+ return this.sqlTransforms.flatMap((t) => (t.where === undefined ? [] : [t.where]));
87
+ }
51
88
  /**
52
89
  * Which channels a query with these aliases comes back aligned on, leading
53
90
  * term first, or null. Asked with the same facts as `applyToQuery`, so the
@@ -147,6 +184,12 @@ export class TransformCompiler {
147
184
  hasSyntheticOtherBucket() {
148
185
  return this.transforms.some((t) => t.type === 'topN' && t.spec?.includeOthers === true);
149
186
  }
187
+ /** The `by` a topN Other bucket ranks by when stated explicitly, else null (its default). */
188
+ otherBy() {
189
+ const t = this.transforms.find((t) => t.type === 'topN' && t.spec?.includeOthers === true);
190
+ const by = t?.spec?.by;
191
+ return typeof by === 'string' ? by : null;
192
+ }
150
193
  getCategoryTransformKey() {
151
194
  const parts = [];
152
195
  for (const t of this.transforms) {
@@ -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
  *
@@ -14,6 +14,7 @@ export function filter(condition) {
14
14
  stage: 'sql',
15
15
  type: 'filter',
16
16
  spec: { transform: 'filter', condition: condition.toString() },
17
+ where: condition,
17
18
  apply(query) {
18
19
  return query.where(condition);
19
20
  },
@@ -135,6 +136,37 @@ function resolveTopNColumns(aliases, options, config) {
135
136
  * the identity index columns in the same result.
136
137
  */
137
138
  export const RANK_COLUMN = '__dplot_rank';
139
+ /**
140
+ * The Other row's members: the ids of the categories folded into it, and each
141
+ * one's measure, as two aligned lists ordered by id. NULL on every other row.
142
+ *
143
+ * Returned by the query rather than worked out on the client, because only the
144
+ * query knows exactly what Other summed: under every filter the chart receives,
145
+ * NULL (`-1`) included when it was folded. Clicking Other selects these, and
146
+ * its selected share is computed from them.
147
+ *
148
+ * `__dplot_`-prefixed, like the rank, so tooltips never show them; and the SQL
149
+ * tooltip table leaves out every `OTHER_MEMBER_PREFIX` column, so a hover never
150
+ * ships them back.
151
+ */
152
+ export const OTHER_MEMBER_PREFIX = '__dplot_other_';
153
+ export const OTHER_IDS_COLUMN = `${OTHER_MEMBER_PREFIX}ids`;
154
+ export const OTHER_VALUES_COLUMN = `${OTHER_MEMBER_PREFIX}values`;
155
+ /**
156
+ * `sql` without Other's member lists, for tables queried on every hover. A
157
+ * COLUMNS filter rather than EXCLUDE, which errors on a column that is absent,
158
+ * as it is on every mark without an Other bucket.
159
+ *
160
+ * The `lambda c:` form, not `c ->`: DuckDB deprecated the arrow lambda in 1.3,
161
+ * and every SQL tooltip table goes through this, so an upgrade dropping it would
162
+ * break tooltips on every mark. Both DuckDB-WASM 1.30 (DuckDB 1.3.2) and the
163
+ * DuckDB the tests run accept it.
164
+ */
165
+ export function withoutOtherMembers(sql) {
166
+ return `SELECT COLUMNS(lambda c: NOT starts_with(c, '${OTHER_MEMBER_PREFIX}')) FROM (${sql})`;
167
+ }
168
+ /** The original band id, carried through bucketing, which rewrites the band column. */
169
+ const BAND_SOURCE_COLUMN = '__dplot_band_src';
138
170
  /**
139
171
  * Give every category in the dim a row, at zero past the cutoff.
140
172
  *
@@ -189,6 +221,12 @@ function densify(inner, aliases, plan) {
189
221
  // missing (category, series) pair carries that series' real id.
190
222
  select[alias] = rawSql(`${crossed}."id"`);
191
223
  }
224
+ else if (alias.startsWith(OTHER_MEMBER_PREFIX)) {
225
+ // Other's member lists: NULL on a padded row, as on every row but
226
+ // Other's. Never COALESCEd to 0 like a measure, which is a type error on
227
+ // a list.
228
+ select[alias] = rawSql(`a.${column(alias)}`);
229
+ }
192
230
  else if (plan.identity[alias]?.dim === dim) {
193
231
  // Same dim as the band, so this channel IS the category — the ordinary
194
232
  // "colour the bars by their own category" chart. Its value for a row
@@ -235,13 +273,13 @@ function densify(inner, aliases, plan) {
235
273
  // a missing category. Each key's ids get -1 appended: a row whose series is
236
274
  // NULL carries -1, and a join over the dim's ids alone would drop it.
237
275
  const joins = cross
238
- .map(([, keyDim], i) => ` CROSS JOIN (SELECT "id" FROM ${quoteTable(keyDim)} UNION ALL SELECT -1) AS k${i}`)
276
+ .map(([key, keyDim], i) => ` CROSS JOIN (SELECT "id" FROM ${padIds(plan.identity[key], keyDim)} UNION ALL SELECT -1) AS k${i}`)
239
277
  .join('');
240
278
  const on = [
241
279
  `a.${column(band)} = d."id"`,
242
280
  ...cross.map(([key], i) => `a.${column(key)} = k${i}."id"`)
243
281
  ].join(' AND ');
244
- return Query.from(rawSql(`${quoteTable(dim)} AS d${joins} LEFT JOIN (${inner}) AS a ON ${on}`)).select(select);
282
+ return Query.from(rawSql(`${padIds(plan.identity[band], dim)} AS d${joins} LEFT JOIN (${inner}) AS a ON ${on}`)).select(select);
245
283
  }
246
284
  /**
247
285
  * Most padded rows `dense()` will produce before refusing.
@@ -254,6 +292,25 @@ function densify(inner, aliases, plan) {
254
292
  * before any of this existed.
255
293
  */
256
294
  export const DENSE_ROW_LIMIT = 10_000;
295
+ /**
296
+ * The ids a channel pads from: the ids its mark's own table holds, plus every
297
+ * Other bucket of the dim, when the table's ids are known; the whole dim before.
298
+ *
299
+ * The dim spans every table registering the field, so padding from all of it
300
+ * gave a chart a zero row, and a slot on a sorted axis, for every category only
301
+ * another table has. Other buckets stay in: an Other row must find its padding
302
+ * row, and another mark's bucket is kept off the axis by its NaN rank, as
303
+ * before.
304
+ */
305
+ function padIds(id, dim) {
306
+ const present = id?.present;
307
+ // A mark that drops NULL pads no row for it: its rows were filtered out.
308
+ const notNull = id?.dropNullId != null ? ` WHERE "id" <> ${id.dropNullId}` : '';
309
+ if (!present)
310
+ return `(SELECT "id" FROM ${quoteTable(dim)}${notNull})`;
311
+ return (`(SELECT "id" FROM ${quoteTable(present)}${notNull} UNION ALL ` +
312
+ `SELECT "id" FROM ${quoteTable(dim)} WHERE "label" IS NOT NULL)`);
313
+ }
257
314
  /**
258
315
  * Whether `dense()` applies to a query with these aliases, and how.
259
316
  *
@@ -680,7 +737,37 @@ function collapseIntoOther(ranked, aliases, o) {
680
737
  }
681
738
  }
682
739
  bucket[RANK_COLUMN] = sqlTag `CASE WHEN ${kept} THEN ${rank} ELSE ${n + 1} END`;
740
+ // The band column is rewritten to the Other id above, so the category each
741
+ // folded row came from is carried beside it for the member lists.
742
+ bucket[BAND_SOURCE_COLUMN] = column(band);
683
743
  const bucketed = Query.from({ __topn_ranked: ranked }).select(bucket);
744
+ // PER MEMBER first: one row per group key and folded category. A grouped mark
745
+ // already has one row per (category, series), so this changes nothing there;
746
+ // an ungrouped one (raw rows, no groupKeys) would otherwise list one entry per
747
+ // RAW ROW past the cutoff — ids repeated, and a list that grows with the table
748
+ // rather than the field. Every aggregate below (SUM, MAX, MIN) gives the same
749
+ // answer over these rows as over the raw ones, so Other's values are unchanged.
750
+ const src = column(BAND_SOURCE_COLUMN);
751
+ const member = {};
752
+ const memberKeys = [src];
753
+ for (const alias of aliases) {
754
+ const col = column(alias);
755
+ if (isKey(alias)) {
756
+ member[alias] = col;
757
+ memberKeys.push(col);
758
+ }
759
+ else if (alias === by) {
760
+ member[alias] = sqlTag `SUM(${col})`;
761
+ }
762
+ else {
763
+ member[alias] = sqlTag `MAX(${col})`;
764
+ }
765
+ }
766
+ member[RANK_COLUMN] = sqlTag `MIN(${rank})`;
767
+ member[BAND_SOURCE_COLUMN] = src;
768
+ const members = Query.from({ __topn_bucketed: bucketed })
769
+ .select(member)
770
+ .groupby(...memberKeys);
684
771
  const collapse = {};
685
772
  const keys = [];
686
773
  for (const alias of aliases) {
@@ -697,7 +784,17 @@ function collapseIntoOther(ranked, aliases, o) {
697
784
  }
698
785
  }
699
786
  collapse[RANK_COLUMN] = sqlTag `MIN(${rank})`;
700
- return Query.from({ __topn_bucketed: bucketed })
787
+ // Other's members, from the rows the bucket step moved past the cutoff (their
788
+ // rank is n + 1 there): one entry per folded category, from the step above. A
789
+ // kept row's group has none, so its lists are NULL. Cast so they arrive as
790
+ // Int32Array and Float64Array children: ids are REAL in the channel, and a
791
+ // COUNT measure is BIGINT, which would arrive as BigInt64Array.
792
+ const past = sqlTag `${rank} > ${n}`;
793
+ collapse[OTHER_IDS_COLUMN] =
794
+ sqlTag `list(CAST(${src} AS INTEGER) ORDER BY ${src}) FILTER (WHERE ${past})`;
795
+ collapse[OTHER_VALUES_COLUMN] =
796
+ sqlTag `list(CAST(${column(by)} AS DOUBLE) ORDER BY ${src}) FILTER (WHERE ${past})`;
797
+ return Query.from({ __topn_members: members })
701
798
  .select(collapse)
702
799
  .groupby(...keys);
703
800
  }
@@ -712,7 +809,9 @@ export function stackX(options = {}) {
712
809
  return stackSQL('x', options);
713
810
  }
714
811
  /** Resolve band (group) / value column aliases for a stack direction, validated against the select list. */
715
- function resolveStackColumns(aliases, config, direction) {
812
+ function resolveStackColumns(aliases, config, direction,
813
+ /** Called when there is no series column; the caller decides whether that is worth saying. */
814
+ onNoSeries) {
716
815
  const bandAxis = direction === 'y' ? 'x' : 'y';
717
816
  const band = config?.[bandAxis];
718
817
  const value = config?.[direction];
@@ -726,20 +825,31 @@ function resolveStackColumns(aliases, config, direction) {
726
825
  }
727
826
  const fill = config?.fill;
728
827
  const series = typeof fill === 'string' && aliases.includes(fill) ? fill : null;
729
- // With no series column there is one row per band, so the cumulative sum equals
730
- // that row's own value and the stack is arithmetically a no-op — the output is
731
- // identical to an unstacked mark. That is the correct result, but it is
732
- // indistinguishable from a broken stack to anyone looking at the chart, so say
733
- // what is missing rather than silently drawing an ordinary bar.
734
- if (!series) {
735
- console.warn(`stack${direction === 'y' ? 'Y' : 'X'}: no series column, so stacking has no effect ` +
736
- `(one row per band). Stacking groups rows within a band by the ordinal ` +
737
- `fill channel — add fill="<field>" to the mark. Select list: [${aliases.join(', ')}].`);
738
- }
828
+ if (!series)
829
+ onNoSeries(aliases);
739
830
  return { band, value, series };
740
831
  }
741
- function stackSQL(direction, options) {
832
+ /**
833
+ * `implicit`: added by the mark (withImplicitStack), not written by the user.
834
+ *
835
+ * With no series column there is one row per band, so the cumulative sum equals
836
+ * that row's own value and the stack is arithmetically a no-op. For a stack the
837
+ * user wrote, that is indistinguishable from a broken one on screen, so it says
838
+ * what is missing, once per transform rather than on every query assembled. An
839
+ * implicit stack says nothing: every bar mark without a fill gets one, and it is
840
+ * the ordinary bar it draws, not a mistake.
841
+ */
842
+ function stackSQL(direction, options, implicit = false) {
742
843
  const [loName, hiName] = direction === 'y' ? ['y0', 'y1'] : ['x0', 'x1'];
844
+ let warned = implicit;
845
+ const onNoSeries = (aliases) => {
846
+ if (warned)
847
+ return;
848
+ warned = true;
849
+ console.warn(`stack${direction === 'y' ? 'Y' : 'X'}: no series column, so stacking has no effect ` +
850
+ `(one row per band). Stacking groups rows within a band by the ordinal ` +
851
+ `fill channel — add fill="<field>" to the mark. Select list: [${aliases.join(', ')}].`);
852
+ };
743
853
  return {
744
854
  stage: 'sql',
745
855
  type: 'stack',
@@ -750,7 +860,7 @@ function stackSQL(direction, options) {
750
860
  channels: [loName, hiName],
751
861
  apply(query, config) {
752
862
  const aliases = getSelectAliases(query);
753
- const { band, value, series } = resolveStackColumns(aliases, config, direction);
863
+ const { band, value, series } = resolveStackColumns(aliases, config, direction, onNoSeries);
754
864
  const valueCol = column(value);
755
865
  const bandCol = column(band);
756
866
  const notNaN = sqlTag `NOT isnan(${valueCol})`;
@@ -1091,9 +1201,7 @@ export function withImplicitStack(transforms, hasExplicitBounds, direction) {
1091
1201
  ? direction === 'y'
1092
1202
  ? stackY({ engine: 'js' })
1093
1203
  : stackX({ engine: 'js' })
1094
- : direction === 'y'
1095
- ? stackY()
1096
- : stackX();
1204
+ : stackSQL(direction, {}, true);
1097
1205
  return [...transforms, implicitStack];
1098
1206
  }
1099
1207
  export { TransformCompiler } from './TransformCompiler';
package/dist/types.d.ts CHANGED
@@ -3,6 +3,9 @@ import type { DataAccessor } from './data/DataAccessor';
3
3
  import type { ChartConfig } from './config/ChartConfig.svelte';
4
4
  import type { DimValue } from './query-engine/categorical-identity';
5
5
  import type { AutoBinNode } from './sql/bin';
6
+ import type { SelectionResponse } from './components/rendering/activity-reads';
7
+ /** What a mark does with NULL in its categorical channels; see BaseMarkProps.nulls. */
8
+ export type NullsResponse = 'keep' | 'drop';
6
9
  import type { RollingAvgNode } from './sql/rolling-avg';
7
10
  export type ContinuousScaleType = 'linear' | 'log' | 'symlog' | 'pow' | 'identity';
8
11
  export type DiscreteScaleType = 'band' | 'point';
@@ -57,6 +60,13 @@ export interface SqlTransform {
57
60
  apply(query: SelectQuery, config?: MarkConfig): SelectQuery;
58
61
  /** Channel aliases this transform adds to the result beyond the base select (e.g. stack's y0/y1). */
59
62
  channels?: string[];
63
+ /**
64
+ * The row predicate this transform adds to the query, when that is all it does
65
+ * (`filter`). It applies to the source rows, before any grouping, so a query
66
+ * over a pre-aggregated table cannot apply it (the columns it names are gone):
67
+ * the table is built with it instead, and the read leaves it out.
68
+ */
69
+ where?: unknown;
60
70
  /**
61
71
  * The channels whose values identify each row, leading term first, when this
62
72
  * transform guarantees exactly one row per combination of them — or null when
@@ -96,6 +106,18 @@ export interface ChannelIdentity {
96
106
  positional: boolean;
97
107
  /** The reserved id past-the-cutoff rows are bucketed into, once reserved. */
98
108
  otherId?: number;
109
+ /**
110
+ * The table of ids the mark's own table holds, which `dense()` pads from: the
111
+ * dim spans every table on the field, and a chart must not pad a slot for a
112
+ * category only another table has. Absent before that table has folded in,
113
+ * when dense() pads from the whole dim as it did before.
114
+ */
115
+ present?: string;
116
+ /**
117
+ * The NULL category's id, when the mark drops NULL (`nulls="drop"`): `dense()`
118
+ * pads no row for it, as its rows are filtered out.
119
+ */
120
+ dropNullId?: number;
99
121
  }
100
122
  export interface MarkConfig {
101
123
  markType: string;
@@ -166,6 +188,55 @@ export interface DomainConfig {
166
188
  export interface BaseMarkProps {
167
189
  table: string;
168
190
  selection?: string;
191
+ /**
192
+ * How this mark responds to selections in its filter group.
193
+ *
194
+ * - `'auto'` (the default): a categorical selection (a legend, a Toggle, a
195
+ * band label) on a field this mark draws by, its band axis or its ordinal
196
+ * colour, dims it: it keeps its data and the unselected categories fade.
197
+ * Any other selection filters it.
198
+ * - `'filter'`: every selection filters it, even on a field it draws by, so
199
+ * its aggregates are recomputed for the subset: top N within the selected
200
+ * region, shares within the selected groups.
201
+ *
202
+ * A `'filter'` mark never dims. A legend, Toggle or band label filters it
203
+ * in its own chart too: every such control on the same fields shares one
204
+ * selection, so any chart with one would otherwise ignore it, and this
205
+ * chart's legend re-ranks its own top N. A Toggle on its own bars therefore
206
+ * filters them: the unselected bars leave the chart. Known limit: on a band
207
+ * axis drawn from the data (a sorted mark), clicking a label filters the
208
+ * other categories out, their labels go with them, and a second category
209
+ * cannot be added from this chart; clicking the label again clears it. A brush still skips its
210
+ * own chart, so the marks under it do not move while it is dragged.
211
+ */
212
+ selectionResponse?: SelectionResponse;
213
+ /**
214
+ * Whether this mark adds its categories to a band axis's domain (default
215
+ * `true`). `false`: the mark follows the axis the other marks on it decide
216
+ * (their categories, their order); rows of categories the axis does not show
217
+ * are not drawn. For a layer that gives context to another, such as the whole
218
+ * behind a part. It cannot rank or reduce its own categories, so `topN`, `sort`
219
+ * and an explicit `domain` are refused with it (an error is logged and the
220
+ * mark contributes).
221
+ *
222
+ * Known limit: when the axis shows an Other bucket (another mark's topN with
223
+ * `includeOthers`), a follower has no Other row of its own. Its bars of
224
+ * categories that leave the axis walk to Other's slot and are gone when the
225
+ * transition ends, and nothing draws the whole behind Other. Folding such
226
+ * categories into an Other row would need the follower's query to depend on
227
+ * the axis's current categories.
228
+ */
229
+ contributeDomain?: boolean;
230
+ /**
231
+ * What this mark does with NULL in its categorical channels: its band axis,
232
+ * ordinal colour, series and facet.
233
+ *
234
+ * - `'keep'` (the default): NULL is a category like any other. It is ranked
235
+ * by its measure, drawn, and labelled "(null)", in the null colour.
236
+ * - `'drop'`: rows whose value in any of those channels is NULL are left out
237
+ * before any transform, so a top N ranks, and Other sums, without them.
238
+ */
239
+ nulls?: NullsResponse;
169
240
  scheme?: ColorScheme;
170
241
  limit?: number;
171
242
  transforms?: Transform[];
package/package.json CHANGED
@@ -18,7 +18,7 @@
18
18
  "type": "git",
19
19
  "url": "git+https://github.com/dlyngman/fjader.git"
20
20
  },
21
- "version": "0.2.2",
21
+ "version": "0.2.4",
22
22
  "type": "module",
23
23
  "svelte": "./dist/index.js",
24
24
  "exports": {
@@ -60,6 +60,7 @@
60
60
  "@types/d3-color": "^3.1.3",
61
61
  "@types/d3-scale": "^4.0.9",
62
62
  "@types/d3-scale-chromatic": "^3.1.0",
63
+ "@uwdata/mosaic-sql": "^0.35.0",
63
64
  "@webgpu/types": "^0.1.69",
64
65
  "duckdb": "^1.4.4",
65
66
  "eslint": "^10.0.0",
@@ -80,12 +81,12 @@
80
81
  "vitest": "^4.0.18"
81
82
  },
82
83
  "peerDependencies": {
84
+ "@uwdata/mosaic-sql": "^0.34.2 || ^0.35.0",
83
85
  "svelte": "^5.0.0"
84
86
  },
85
87
  "dependencies": {
86
88
  "@duckdb/duckdb-wasm": "1.30.0",
87
89
  "@uwdata/flechette": "2.3.0",
88
- "@uwdata/mosaic-sql": "^0.34.2",
89
90
  "d3-array": "^3.2.4",
90
91
  "d3-color": "^3.1.0",
91
92
  "d3-format": "^3.1.0",