@fjader/svelte 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +3 -1
  2. package/dist/animation/EntranceGate.d.ts +52 -0
  3. package/dist/animation/EntranceGate.js +93 -0
  4. package/dist/animation/TransitionClock.d.ts +61 -30
  5. package/dist/animation/TransitionClock.js +67 -29
  6. package/dist/animation/transition-log.d.ts +41 -0
  7. package/dist/animation/transition-log.js +57 -0
  8. package/dist/components/axes/AxisBottom.svelte +27 -8
  9. package/dist/components/axes/AxisBottom.svelte.d.ts +10 -0
  10. package/dist/components/axes/AxisCenter.svelte +4 -2
  11. package/dist/components/axes/AxisLeft.svelte +81 -10
  12. package/dist/components/axes/AxisLeft.svelte.d.ts +17 -0
  13. package/dist/components/axes/BandAxisTicks.svelte +4 -3
  14. package/dist/components/axes/axis-title.d.ts +30 -0
  15. package/dist/components/axes/axis-title.js +77 -0
  16. package/dist/components/charts/ChartCell.svelte +33 -6
  17. package/dist/components/charts/ChartCell.svelte.d.ts +9 -0
  18. package/dist/components/charts/ChartContainer.svelte +55 -5
  19. package/dist/components/charts/ChartContainer.svelte.d.ts +14 -1
  20. package/dist/components/charts/ChartHover.svelte.d.ts +14 -0
  21. package/dist/components/charts/ChartHover.svelte.js +22 -0
  22. package/dist/components/charts/ChartVisibility.svelte.d.ts +43 -0
  23. package/dist/components/charts/ChartVisibility.svelte.js +67 -0
  24. package/dist/components/guides/Grid.svelte +12 -1
  25. package/dist/components/guides/legend/ContinuousColorLegend.svelte +7 -1
  26. package/dist/components/guides/legend/OrdinalColorLegend.svelte +8 -5
  27. package/dist/components/marks/AreaMarkX.svelte +18 -4
  28. package/dist/components/marks/AreaMarkY.svelte +18 -4
  29. package/dist/components/marks/BarMarkX.svelte +20 -4
  30. package/dist/components/marks/BarMarkY.svelte +20 -4
  31. package/dist/components/marks/DotsMark.svelte +8 -3
  32. package/dist/components/marks/LineMark.svelte +13 -6
  33. package/dist/components/marks/ParallelCoordinatesMark.svelte +11 -2
  34. package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +4 -0
  35. package/dist/components/marks/RectMark.svelte +8 -3
  36. package/dist/components/marks/RectXMark.svelte +9 -3
  37. package/dist/components/marks/RectYMark.svelte +9 -3
  38. package/dist/components/marks/RuleMarkX.svelte +31 -25
  39. package/dist/components/marks/RuleMarkY.svelte +31 -25
  40. package/dist/components/marks/TextMark.svelte +52 -14
  41. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +22 -1
  42. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +579 -66
  43. package/dist/components/marks/lifecycle/domain-queries.d.ts +13 -2
  44. package/dist/components/marks/lifecycle/domain-queries.js +43 -11
  45. package/dist/components/marks/lifecycle/domain-registration.d.ts +6 -1
  46. package/dist/components/marks/lifecycle/domain-registration.js +17 -20
  47. package/dist/components/marks/lifecycle/other-rows.svelte.d.ts +112 -0
  48. package/dist/components/marks/lifecycle/other-rows.svelte.js +193 -0
  49. package/dist/components/marks/lifecycle/other-split.d.ts +80 -0
  50. package/dist/components/marks/lifecycle/other-split.js +112 -0
  51. package/dist/components/marks/lifecycle/query-builder.d.ts +53 -3
  52. package/dist/components/marks/lifecycle/query-builder.js +46 -23
  53. package/dist/components/marks/utils/MarkColor.svelte.d.ts +3 -1
  54. package/dist/components/marks/utils/MarkColor.svelte.js +33 -3
  55. package/dist/components/marks/utils/MarkSeries.svelte.d.ts +14 -1
  56. package/dist/components/marks/utils/MarkSeries.svelte.js +25 -1
  57. package/dist/components/marks/utils/MarkSize.svelte.js +4 -0
  58. package/dist/components/marks/utils/reactive-config.d.ts +1 -1
  59. package/dist/components/marks/utils/reactive-config.js +9 -0
  60. package/dist/components/marks/utils/tooltip-helpers.d.ts +4 -1
  61. package/dist/components/marks/utils/tooltip-helpers.js +2 -1
  62. package/dist/components/rendering/Areas.svelte +57 -19
  63. package/dist/components/rendering/Areas.svelte.d.ts +12 -0
  64. package/dist/components/rendering/BaseGPURenderer.svelte.d.ts +3 -1
  65. package/dist/components/rendering/BaseGPURenderer.svelte.js +4 -2
  66. package/dist/components/rendering/Dots.svelte +23 -3
  67. package/dist/components/rendering/Dots.svelte.d.ts +6 -0
  68. package/dist/components/rendering/GridLine.svelte +13 -1
  69. package/dist/components/rendering/GridLine.svelte.d.ts +5 -0
  70. package/dist/components/rendering/Lines.svelte +48 -13
  71. package/dist/components/rendering/Lines.svelte.d.ts +12 -0
  72. package/dist/components/rendering/ParallelLines.svelte +5 -5
  73. package/dist/components/rendering/Rects.svelte +164 -30
  74. package/dist/components/rendering/Rects.svelte.d.ts +23 -2
  75. package/dist/components/rendering/RuleX.svelte +1 -1
  76. package/dist/components/rendering/RuleY.svelte +1 -1
  77. package/dist/components/rendering/WebGPUCanvas.svelte +4 -2
  78. package/dist/components/rendering/activity-reads.d.ts +47 -12
  79. package/dist/components/rendering/activity-reads.js +61 -19
  80. package/dist/components/rendering/uniform-sizes.d.ts +1 -1
  81. package/dist/components/rendering/uniform-sizes.js +1 -1
  82. package/dist/config/ChartConfig.svelte.d.ts +10 -1
  83. package/dist/config/ChartConfig.svelte.js +17 -0
  84. package/dist/gpu/GPUCoordinator.svelte.d.ts +31 -1
  85. package/dist/gpu/GPUCoordinator.svelte.js +164 -7
  86. package/dist/gpu/MorphedChannels.d.ts +11 -28
  87. package/dist/gpu/MorphedChannels.js +72 -8
  88. package/dist/gpu/ScaleTemplateSystem.d.ts +20 -2
  89. package/dist/gpu/ScaleTemplateSystem.js +83 -6
  90. package/dist/index.d.ts +19 -4
  91. package/dist/index.js +22 -4
  92. package/dist/interactors/Interval1D.svelte +6 -1
  93. package/dist/interactors/Interval2D.svelte +6 -1
  94. package/dist/interactors/ParallelAxisSource.svelte +10 -1
  95. package/dist/interactors/Toggle.svelte +32 -3
  96. package/dist/interactors/Tooltip.svelte +138 -41
  97. package/dist/interactors/TooltipManager.svelte.d.ts +20 -5
  98. package/dist/interactors/TooltipManager.svelte.js +46 -12
  99. package/dist/interactors/other-tooltip.d.ts +16 -0
  100. package/dist/interactors/other-tooltip.js +22 -0
  101. package/dist/query-engine/CrossfilterSelection.svelte.d.ts +4 -2
  102. package/dist/query-engine/CrossfilterSelection.svelte.js +2 -0
  103. package/dist/query-engine/FieldActivity.svelte.d.ts +49 -1
  104. package/dist/query-engine/FieldActivity.svelte.js +175 -5
  105. package/dist/query-engine/FilterGroup.svelte.d.ts +23 -1
  106. package/dist/query-engine/FilterGroup.svelte.js +43 -3
  107. package/dist/query-engine/FilterGroupRegistry.svelte.d.ts +31 -3
  108. package/dist/query-engine/FilterGroupRegistry.svelte.js +62 -15
  109. package/dist/query-engine/MarkHandle.svelte.d.ts +28 -1
  110. package/dist/query-engine/MarkHandle.svelte.js +33 -1
  111. package/dist/query-engine/QueryEngine.d.ts +225 -5
  112. package/dist/query-engine/QueryEngine.js +697 -47
  113. package/dist/query-engine/categorical-identity.d.ts +41 -0
  114. package/dist/query-engine/categorical-identity.js +145 -65
  115. package/dist/query-engine/debug/query-log.d.ts +39 -0
  116. package/dist/query-engine/debug/query-log.js +90 -0
  117. package/dist/query-engine/duckdb.d.ts +55 -8
  118. package/dist/query-engine/duckdb.js +82 -30
  119. package/dist/query-engine/index.d.ts +7 -4
  120. package/dist/query-engine/index.js +3 -3
  121. package/dist/query-engine/preagg/BuildQueue.d.ts +33 -13
  122. package/dist/query-engine/preagg/BuildQueue.js +51 -15
  123. package/dist/query-engine/preagg/Preagg.d.ts +46 -3
  124. package/dist/query-engine/preagg/Preagg.js +159 -31
  125. package/dist/query-engine/preagg/plan.d.ts +33 -1
  126. package/dist/query-engine/preagg/plan.js +158 -14
  127. package/dist/query-engine/sliced-lane.d.ts +108 -0
  128. package/dist/query-engine/sliced-lane.js +299 -0
  129. package/dist/query-engine/source-routing.d.ts +106 -0
  130. package/dist/query-engine/source-routing.js +189 -0
  131. package/dist/query-engine/toggle-predicate.d.ts +21 -0
  132. package/dist/query-engine/toggle-predicate.js +29 -1
  133. package/dist/scales/ActivityTable.d.ts +5 -1
  134. package/dist/scales/ActivityTable.js +12 -3
  135. package/dist/scales/BandScaleState.svelte.d.ts +22 -9
  136. package/dist/scales/BandScaleState.svelte.js +39 -21
  137. package/dist/scales/ChartScales.svelte.d.ts +3 -0
  138. package/dist/scales/ChartScales.svelte.js +26 -8
  139. package/dist/scales/ColorScaleState.svelte.d.ts +18 -2
  140. package/dist/scales/ColorScaleState.svelte.js +138 -26
  141. package/dist/scales/LinearScaleState.svelte.d.ts +7 -10
  142. package/dist/scales/LinearScaleState.svelte.js +21 -39
  143. package/dist/scales/ScaleGeneration.svelte.d.ts +48 -0
  144. package/dist/scales/ScaleGeneration.svelte.js +42 -0
  145. package/dist/scales/ScaleRegistry.svelte.js +3 -1
  146. package/dist/scales/SlotTransition.d.ts +15 -1
  147. package/dist/scales/SlotTransition.js +33 -4
  148. package/dist/scales/TimeScaleState.svelte.d.ts +7 -3
  149. package/dist/scales/TimeScaleState.svelte.js +7 -7
  150. package/dist/scales/color-utils.d.ts +0 -3
  151. package/dist/scales/color-utils.js +0 -3
  152. package/dist/scales/entered.d.ts +8 -0
  153. package/dist/scales/entered.js +10 -0
  154. package/dist/spec/LazyQueryEngine.js +2 -19
  155. package/dist/spec/MarkRegistry.d.ts +3 -1
  156. package/dist/spec/MarkRegistry.js +13 -1
  157. package/dist/spec/PlotFromSpec.svelte +1 -0
  158. package/dist/spec/export.js +11 -2
  159. package/dist/spec/types.d.ts +43 -13
  160. package/dist/sql/bin.d.ts +25 -0
  161. package/dist/sql/bin.js +108 -5
  162. package/dist/theme.css +5 -4
  163. package/dist/transforms/TransformCompiler.d.ts +47 -2
  164. package/dist/transforms/TransformCompiler.js +70 -1
  165. package/dist/transforms/index.js +176 -42
  166. package/dist/types.d.ts +177 -2
  167. package/package.json +4 -3
@@ -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,50 @@ 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
+ * The padding rows of this mark's queries (see SqlTransform.padRows), for a
29
+ * pre-aggregated table to hold, or null when they cannot live in a table.
30
+ *
31
+ * Only when padding is the last thing that changes which rows exist: every
32
+ * other SQL transform is one that runs the same over a padded table as over
33
+ * a padded result. A topN ranks before padding, so zero rows in its table
34
+ * would enter the ranking; anything else not listed is not known to be safe.
35
+ */
36
+ padRows(aliases: string[], facts?: Partial<MarkConfig>): string | null;
37
+ /**
38
+ * Whether a SQL transform filters on the groups' aggregates (`having`). Its
39
+ * condition names aggregates over the source rows, which a pre-aggregated table
40
+ * would recompute over its own groups: `count(*)` counts the table's groups, not
41
+ * the rows, so it gives a different answer with no error.
42
+ *
43
+ * Only the user-facing `having()` does this. The built-ins (topN, rank, stack,
44
+ * normalize, dense) add no HAVING: each wraps the grouped query in a subquery
45
+ * (`Query.from({ __topn_base: query })`) and adds window functions or joins, and
46
+ * those work the same on a preagg table's aggregated result, which is why a topN
47
+ * mark is served from preagg. The only CTE is assembleQuery's window wrapper for
48
+ * window aggregates, applied after the transforms. A test in transforms.test.ts
49
+ * fails if a built-in starts to emit a HAVING; this then has to know it.
50
+ *
51
+ * Supporting `having()` on a preagg table would mean rewriting its condition the
52
+ * way a select channel is rewritten (sufficient-statistics: collectAggregates,
53
+ * sufficientStats, rewrite; an aggregate not in the select adds a partial
54
+ * column) and applying it to the inner grouped query BEFORE the transforms wrap
55
+ * it, so it ends up inside their subquery as it does on the base table.
56
+ */
57
+ hasHaving(): boolean;
58
+ /**
59
+ * The row predicates of the SQL transforms (`filter`), for a pre-aggregated
60
+ * table to be built with: its read cannot apply them (see SqlTransform.where).
61
+ * A row filter applies before grouping, so building the table with it gives the
62
+ * same rows the base query sees; only `filter()` is one (see hasHaving for the
63
+ * transforms that are not).
64
+ */
65
+ rowFilters(): ExprNode[];
23
66
  /**
24
67
  * Which channels a query with these aliases comes back aligned on, leading
25
68
  * term first, or null. Asked with the same facts as `applyToQuery`, so the
@@ -60,5 +103,7 @@ export declare class TransformCompiler {
60
103
  * before the transform can write it.
61
104
  */
62
105
  hasSyntheticOtherBucket(): boolean;
106
+ /** The `by` a topN Other bucket ranks by when stated explicitly, else null (its default). */
107
+ otherBy(): string | null;
63
108
  getCategoryTransformKey(): string | null;
64
109
  }
@@ -1,3 +1,9 @@
1
+ /**
2
+ * SQL transforms that give the same rows over a table holding its padding as
3
+ * padding gives their result: row filters (applied to the table's build), and
4
+ * what runs after padding (rank, stack, normalize).
5
+ */
6
+ const PAD_SAFE = new Set(['dense', 'filter', 'rank', 'stack', 'normalize']);
1
7
  export class TransformCompiler {
2
8
  transforms;
3
9
  sqlTransforms;
@@ -35,10 +41,14 @@ export class TransformCompiler {
35
41
  * than of the mark — its GROUP BY keys, say — merged over the mark config for
36
42
  * this call only, so the shared config object is never written per query.
37
43
  */
38
- applyToQuery(query, facts) {
44
+ applyToQuery(query, facts,
45
+ /** The row filters are already in the table the query reads (a preagg table). */
46
+ options) {
39
47
  let result = query;
40
48
  const config = this.#configFor(facts);
41
49
  for (const transform of this.sqlTransforms) {
50
+ if (options?.skipRowFilters && transform.where !== undefined)
51
+ continue;
42
52
  try {
43
53
  result = transform.apply(result, config);
44
54
  }
@@ -48,6 +58,59 @@ export class TransformCompiler {
48
58
  }
49
59
  return result;
50
60
  }
61
+ /**
62
+ * The padding rows of this mark's queries (see SqlTransform.padRows), for a
63
+ * pre-aggregated table to hold, or null when they cannot live in a table.
64
+ *
65
+ * Only when padding is the last thing that changes which rows exist: every
66
+ * other SQL transform is one that runs the same over a padded table as over
67
+ * a padded result. A topN ranks before padding, so zero rows in its table
68
+ * would enter the ranking; anything else not listed is not known to be safe.
69
+ */
70
+ padRows(aliases, facts) {
71
+ if (!this.sqlTransforms.every((t) => PAD_SAFE.has(t.type)))
72
+ return null;
73
+ const config = this.#configFor(facts);
74
+ for (const transform of this.sqlTransforms) {
75
+ const rows = transform.padRows?.(aliases, config);
76
+ if (rows)
77
+ return rows;
78
+ }
79
+ return null;
80
+ }
81
+ /**
82
+ * Whether a SQL transform filters on the groups' aggregates (`having`). Its
83
+ * condition names aggregates over the source rows, which a pre-aggregated table
84
+ * would recompute over its own groups: `count(*)` counts the table's groups, not
85
+ * the rows, so it gives a different answer with no error.
86
+ *
87
+ * Only the user-facing `having()` does this. The built-ins (topN, rank, stack,
88
+ * normalize, dense) add no HAVING: each wraps the grouped query in a subquery
89
+ * (`Query.from({ __topn_base: query })`) and adds window functions or joins, and
90
+ * those work the same on a preagg table's aggregated result, which is why a topN
91
+ * mark is served from preagg. The only CTE is assembleQuery's window wrapper for
92
+ * window aggregates, applied after the transforms. A test in transforms.test.ts
93
+ * fails if a built-in starts to emit a HAVING; this then has to know it.
94
+ *
95
+ * Supporting `having()` on a preagg table would mean rewriting its condition the
96
+ * way a select channel is rewritten (sufficient-statistics: collectAggregates,
97
+ * sufficientStats, rewrite; an aggregate not in the select adds a partial
98
+ * column) and applying it to the inner grouped query BEFORE the transforms wrap
99
+ * it, so it ends up inside their subquery as it does on the base table.
100
+ */
101
+ hasHaving() {
102
+ return this.sqlTransforms.some((t) => t.type === 'having');
103
+ }
104
+ /**
105
+ * The row predicates of the SQL transforms (`filter`), for a pre-aggregated
106
+ * table to be built with: its read cannot apply them (see SqlTransform.where).
107
+ * A row filter applies before grouping, so building the table with it gives the
108
+ * same rows the base query sees; only `filter()` is one (see hasHaving for the
109
+ * transforms that are not).
110
+ */
111
+ rowFilters() {
112
+ return this.sqlTransforms.flatMap((t) => (t.where === undefined ? [] : [t.where]));
113
+ }
51
114
  /**
52
115
  * Which channels a query with these aliases comes back aligned on, leading
53
116
  * term first, or null. Asked with the same facts as `applyToQuery`, so the
@@ -147,6 +210,12 @@ export class TransformCompiler {
147
210
  hasSyntheticOtherBucket() {
148
211
  return this.transforms.some((t) => t.type === 'topN' && t.spec?.includeOthers === true);
149
212
  }
213
+ /** The `by` a topN Other bucket ranks by when stated explicitly, else null (its default). */
214
+ otherBy() {
215
+ const t = this.transforms.find((t) => t.type === 'topN' && t.spec?.includeOthers === true);
216
+ const by = t?.spec?.by;
217
+ return typeof by === 'string' ? by : null;
218
+ }
150
219
  getCategoryTransformKey() {
151
220
  const parts = [];
152
221
  for (const t of this.transforms) {
@@ -6,6 +6,7 @@
6
6
  * - JS: Process data via the DataAccessor interface (zero-copy on Arrow data)
7
7
  */
8
8
  import { Query, column, sql as sqlTag } from '@uwdata/mosaic-sql';
9
+ import { numericBinEdgeSQL } from '../sql/bin';
9
10
  // =============================================================================
10
11
  // SQL TRANSFORMS (Query Modifiers)
11
12
  // =============================================================================
@@ -14,6 +15,7 @@ export function filter(condition) {
14
15
  stage: 'sql',
15
16
  type: 'filter',
16
17
  spec: { transform: 'filter', condition: condition.toString() },
18
+ where: condition,
17
19
  apply(query) {
18
20
  return query.where(condition);
19
21
  },
@@ -207,18 +209,29 @@ const BAND_SOURCE_COLUMN = '__dplot_band_src';
207
209
  * instead, and the query assembler orders the outermost query by it.
208
210
  */
209
211
  function densify(inner, aliases, plan) {
210
- const { band, dim, cross } = plan;
212
+ const { band, dim, cross, bins } = plan;
211
213
  const crossAlias = new Map(cross.map(([key], i) => [key, `k${i}`]));
214
+ const binOf = new Map(bins);
212
215
  const select = {};
213
216
  for (const alias of aliases) {
214
217
  const crossed = crossAlias.get(alias);
215
- if (alias === band) {
216
- select[alias] = sqlTag `d."id"`;
218
+ const bin = binOf.get(alias);
219
+ if (bin) {
220
+ // Every row's edge from its bin index, real and padded alike, so the
221
+ // edges are the same bits on every update: they are what tells the
222
+ // renderer the row set has not changed (alignmentKey).
223
+ select[alias] = rawSql(binEdge(bin, 'd.k'));
224
+ }
225
+ else if (alias === band) {
226
+ // REAL, as the inner query's id columns are: an INTEGER column would
227
+ // reach the GPU through a copy, and hand the renderer other bits than
228
+ // a preagg read, which keeps the REAL ids.
229
+ select[alias] = rawSql(`CAST(d."id" AS REAL)`);
217
230
  }
218
231
  else if (crossed) {
219
232
  // Another group key, enumerated from its own dim: the padded row for a
220
233
  // missing (category, series) pair carries that series' real id.
221
- select[alias] = rawSql(`${crossed}."id"`);
234
+ select[alias] = rawSql(`CAST(${crossed}."id" AS REAL)`);
222
235
  }
223
236
  else if (alias.startsWith(OTHER_MEMBER_PREFIX)) {
224
237
  // Other's member lists: NULL on a padded row, as on every row but
@@ -226,7 +239,7 @@ function densify(inner, aliases, plan) {
226
239
  // a list.
227
240
  select[alias] = rawSql(`a.${column(alias)}`);
228
241
  }
229
- else if (plan.identity[alias]?.dim === dim) {
242
+ else if (!bins && plan.identity[alias]?.dim === dim) {
230
243
  // Same dim as the band, so this channel IS the category — the ordinary
231
244
  // "colour the bars by their own category" chart. Its value for a row
232
245
  // with no data is not unknown, it is that category's id, so take it
@@ -235,7 +248,7 @@ function densify(inner, aliases, plan) {
235
248
  // This is what keeps a DEPARTING bar its colour. It is still on screen
236
249
  // while it shrinks, and a sentinel would turn it grey on the frame its
237
250
  // data disappeared — the one frame where the colour matters most.
238
- select[alias] = sqlTag `d."id"`;
251
+ select[alias] = rawSql(`CAST(d."id" AS REAL)`);
239
252
  }
240
253
  else if (plan.identity[alias]) {
241
254
  // A category id from some OTHER field that is not a group key — an
@@ -243,7 +256,7 @@ function densify(inner, aliases, plan) {
243
256
  // so the absent sentinel it is. Never 0, which is a real category: it
244
257
  // would enter the colour scale's domain, shift every palette rank after
245
258
  // it, and repaint bars that had not changed.
246
- select[alias] = sqlTag `COALESCE(a.${column(alias)}, -1)`;
259
+ select[alias] = sqlTag `COALESCE(a.${column(alias)}, CAST(-1 AS REAL))`;
247
260
  }
248
261
  else {
249
262
  // A measure. 0 is the point: it is what makes a departing bar shrink to
@@ -274,11 +287,52 @@ function densify(inner, aliases, plan) {
274
287
  const joins = cross
275
288
  .map(([key, keyDim], i) => ` CROSS JOIN (SELECT "id" FROM ${padIds(plan.identity[key], keyDim)} UNION ALL SELECT -1) AS k${i}`)
276
289
  .join('');
277
- const on = [
278
- `a.${column(band)} = d."id"`,
279
- ...cross.map(([key], i) => `a.${column(key)} = k${i}."id"`)
280
- ].join(' AND ');
281
- return Query.from(rawSql(`${padIds(plan.identity[band], dim)} AS d${joins} LEFT JOIN (${inner}) AS a ON ${on}`)).select(select);
290
+ const bandBin = binOf.get(band);
291
+ const bandOn = bandBin ? binMatch(bandBin, `a.${column(band)}`) : `a.${column(band)} = d."id"`;
292
+ const on = [bandOn, ...cross.map(([key], i) => `a.${column(key)} = k${i}."id"`)].join(' AND ');
293
+ const source = bandBin ? binSource(bandBin) : `${padIds(plan.identity[band], dim)} AS d`;
294
+ return Query.from(rawSql(`${source}${joins} LEFT JOIN (${inner}) AS a ON ${on}`)).select(select);
295
+ }
296
+ /**
297
+ * Every bin, as `d.k`: an index for numeric bins, a bucket start for time
298
+ * buckets. Time buckets come from `time_bucket` itself over the field's
299
+ * extent, so they are exactly the buckets the query's own expression makes,
300
+ * months and years included, which are not a fixed width.
301
+ */
302
+ function binSource(bin) {
303
+ if (bin.kind === 'number')
304
+ return `range(0, ${bin.count}) AS d(k)`;
305
+ const first = `time_bucket(${bin.interval}, epoch_ms(${Math.round(bin.minMs)}))`;
306
+ const last = `time_bucket(${bin.interval}, epoch_ms(${Math.round(bin.maxMs)}))`;
307
+ return `range(${first}, ${last} + ${bin.interval}, ${bin.interval}) AS d(k)`;
308
+ }
309
+ /**
310
+ * Bin `k`'s edge as the REAL the query draws. Numeric: `min + alpha * (k +
311
+ * offset)`, in DOUBLE arithmetic. Time: the bucket start plus `offset`
312
+ * intervals, as seconds from `t0`, the form the mark's time channels take.
313
+ */
314
+ function binEdge(bin, k) {
315
+ if (bin.kind === 'number') {
316
+ return `CAST(${numericBinEdgeSQL(bin.min, bin.alpha, bin.offset, k)} AS REAL)`;
317
+ }
318
+ return `CAST((epoch(${k} + (${bin.interval}) * ${bin.offset}) - ${bin.t0}) AS REAL)`;
319
+ }
320
+ /**
321
+ * Whether the result row's edge `edge` is bin `d.k`. Numeric bins match by
322
+ * index, recovered from the edge: the edge is a REAL in the result (and in a
323
+ * preagg table), so comparing it to an edge computed here could miss by a
324
+ * rounding. A time edge is computed by the same expression on both sides, so
325
+ * the REALs are equal.
326
+ */
327
+ function binMatch(bin, edge) {
328
+ if (bin.kind === 'number') {
329
+ return `round((CAST(${edge} AS DOUBLE) - ${sqlNumber(bin.min)}) / ${sqlNumber(bin.alpha)}) - ${bin.offset} = d.k`;
330
+ }
331
+ return `${edge} = ${binEdge(bin, 'd.k')}`;
332
+ }
333
+ /** A JS number as a DOUBLE literal: a bare 0.1 would be read as a DECIMAL. */
334
+ function sqlNumber(n) {
335
+ return `CAST(${JSON.stringify(n)} AS DOUBLE)`;
282
336
  }
283
337
  /**
284
338
  * Most padded rows `dense()` will produce before refusing.
@@ -303,9 +357,11 @@ export const DENSE_ROW_LIMIT = 10_000;
303
357
  */
304
358
  function padIds(id, dim) {
305
359
  const present = id?.present;
360
+ // A mark that drops NULL pads no row for it: its rows were filtered out.
361
+ const notNull = id?.dropNullId != null ? ` WHERE "id" <> ${id.dropNullId}` : '';
306
362
  if (!present)
307
- return `(SELECT "id" FROM ${quoteTable(dim)})`;
308
- return (`(SELECT "id" FROM ${quoteTable(present)} UNION ALL ` +
363
+ return `(SELECT "id" FROM ${quoteTable(dim)}${notNull})`;
364
+ return (`(SELECT "id" FROM ${quoteTable(present)}${notNull} UNION ALL ` +
309
365
  `SELECT "id" FROM ${quoteTable(dim)} WHERE "label" IS NOT NULL)`);
310
366
  }
311
367
  /**
@@ -344,20 +400,24 @@ function densePlan(aliases, config, enabled, reported) {
344
400
  if (reported.has(key))
345
401
  return;
346
402
  reported.add(key);
347
- console.info(`[dense] ${reason} Categories on this mark will snap between updates ` +
348
- `rather than morphing, and a category leaving will vanish rather than shrink out.`);
403
+ console.info(`[dense] ${reason} Categories or bins on this mark will snap between updates ` +
404
+ `rather than morphing, and one leaving will vanish rather than shrink out.`);
349
405
  };
350
406
  if (!enabled)
351
407
  return null;
352
- const identity = config?.identity;
353
- if (!identity)
354
- return null;
355
408
  // Stated by whoever built the select, because it cannot be recovered from
356
409
  // the query by the time this runs: topN has already wrapped it, and the
357
410
  // GROUP BY is several levels down.
358
411
  const groupKeys = config?.groupKeys;
359
412
  if (!groupKeys)
360
413
  return null;
414
+ // A histogram: its bins are enumerable like a dim's ids.
415
+ const binned = binPlan(aliases, groupKeys, config, say);
416
+ if (binned !== undefined)
417
+ return binned;
418
+ const identity = config?.identity;
419
+ if (!identity)
420
+ return null;
361
421
  const candidates = aliases.filter((ch) => identity[ch]?.positional);
362
422
  if (candidates.length !== 1) {
363
423
  if (candidates.length > 1) {
@@ -397,7 +457,59 @@ function densePlan(aliases, config, enabled, reported) {
397
457
  `(${size} categories${cross.length > 0 ? ` across ${cross.length + 1} group keys` : ''}).`);
398
458
  return null;
399
459
  }
400
- return { band, dim, cross, identity };
460
+ return { band, dim, cross, bins: null, identity };
461
+ }
462
+ /**
463
+ * The plan for a binned axis: one row per bin, from the bins `bin()`
464
+ * resolved, crossed with each other group key's ids as for a categorical band.
465
+ * Null with nothing binned. Refuses, saying why, what it cannot enumerate: bins
466
+ * on both axes (a 2D histogram) or beside a categorical axis, a group key with
467
+ * no dim, and too many rows. Silent while identity has not landed for a group
468
+ * key that will get it.
469
+ */
470
+ function binPlan(aliases, groupKeys, config, say) {
471
+ const specs = config?.bins;
472
+ if (!specs)
473
+ return undefined;
474
+ const bins = aliases
475
+ .filter((ch) => specs[ch] && groupKeys.includes(ch))
476
+ .map((ch) => [ch, specs[ch]])
477
+ .sort((a, b) => a[1].offset - b[1].offset);
478
+ if (bins.length === 0)
479
+ return undefined;
480
+ const identity = config?.identity ?? null;
481
+ if (new Set(bins.map(([ch]) => ch[0])).size > 1) {
482
+ say('Bins on both axes (a 2D histogram), and padding is defined for one.');
483
+ return null;
484
+ }
485
+ const categorical = identity ? aliases.filter((ch) => identity[ch]?.positional) : [];
486
+ if (categorical.length > 0) {
487
+ say(`A binned axis beside a categorical one (${categorical.join(', ')}).`);
488
+ return null;
489
+ }
490
+ const binned = new Set(bins.map(([ch]) => ch));
491
+ const cross = [];
492
+ let rows = bins[0][1].count;
493
+ for (const key of groupKeys) {
494
+ if (binned.has(key) || !aliases.includes(key))
495
+ continue;
496
+ const keyIdentity = identity?.[key];
497
+ // Before identity setup a series has no dim yet; it will.
498
+ if (!identity)
499
+ return null;
500
+ if (!keyIdentity) {
501
+ say(`the group key "${key}" has no dim, so its missing combinations cannot be enumerated.`);
502
+ return null;
503
+ }
504
+ cross.push([key, keyIdentity.dim]);
505
+ rows *= keyIdentity.size + 1;
506
+ }
507
+ if (rows > DENSE_ROW_LIMIT) {
508
+ say(`padding would produce about ${rows} rows, over the ${DENSE_ROW_LIMIT} limit ` +
509
+ `(${bins[0][1].count} bins${cross.length > 0 ? ` across ${cross.length + 1} group keys` : ''}).`);
510
+ return null;
511
+ }
512
+ return { band: bins[0][0], dim: '', cross, bins, identity: identity ?? {} };
401
513
  }
402
514
  /**
403
515
  * Return a row for every category, not only the ones that survived.
@@ -437,10 +549,27 @@ export function dense(options = {}) {
437
549
  return plan ? [plan.band, ...plan.cross.map(([key]) => key)] : null;
438
550
  },
439
551
  apply(query, config) {
552
+ // A preagg table built with padRows() below: its rows are already one
553
+ // per category or bin, and only the order is left, which the query's
554
+ // ORDER BY takes from alignment() as for any other read.
555
+ if (config?.padded)
556
+ return query;
440
557
  const aliases = getSelectAliases(query);
441
558
  const plan = densePlan(aliases, config, enabled, reported);
442
559
  return plan ? densify(query, aliases, plan) : query;
443
560
  },
561
+ /**
562
+ * The rows apply() would add, as the padding of an empty result: the same
563
+ * join and the same rules for every channel, so a table built with them
564
+ * holds exactly what padding its reads would have added.
565
+ */
566
+ padRows(aliases, config) {
567
+ const plan = densePlan(aliases, config, enabled, reported);
568
+ if (!plan)
569
+ return null;
570
+ const empty = Query.select(Object.fromEntries(aliases.map((alias) => [alias, rawSql('NULL')]))).where(rawSql('false'));
571
+ return String(densify(empty, aliases, plan));
572
+ },
444
573
  describe() {
445
574
  return 'Dense categories';
446
575
  }
@@ -806,7 +935,9 @@ export function stackX(options = {}) {
806
935
  return stackSQL('x', options);
807
936
  }
808
937
  /** Resolve band (group) / value column aliases for a stack direction, validated against the select list. */
809
- function resolveStackColumns(aliases, config, direction) {
938
+ function resolveStackColumns(aliases, config, direction,
939
+ /** Called when there is no series column; the caller decides whether that is worth saying. */
940
+ onNoSeries) {
810
941
  const bandAxis = direction === 'y' ? 'x' : 'y';
811
942
  const band = config?.[bandAxis];
812
943
  const value = config?.[direction];
@@ -820,20 +951,31 @@ function resolveStackColumns(aliases, config, direction) {
820
951
  }
821
952
  const fill = config?.fill;
822
953
  const series = typeof fill === 'string' && aliases.includes(fill) ? fill : null;
823
- // With no series column there is one row per band, so the cumulative sum equals
824
- // that row's own value and the stack is arithmetically a no-op — the output is
825
- // identical to an unstacked mark. That is the correct result, but it is
826
- // indistinguishable from a broken stack to anyone looking at the chart, so say
827
- // what is missing rather than silently drawing an ordinary bar.
828
- if (!series) {
829
- console.warn(`stack${direction === 'y' ? 'Y' : 'X'}: no series column, so stacking has no effect ` +
830
- `(one row per band). Stacking groups rows within a band by the ordinal ` +
831
- `fill channel — add fill="<field>" to the mark. Select list: [${aliases.join(', ')}].`);
832
- }
954
+ if (!series)
955
+ onNoSeries(aliases);
833
956
  return { band, value, series };
834
957
  }
835
- function stackSQL(direction, options) {
958
+ /**
959
+ * `implicit`: added by the mark (withImplicitStack), not written by the user.
960
+ *
961
+ * With no series column there is one row per band, so the cumulative sum equals
962
+ * that row's own value and the stack is arithmetically a no-op. For a stack the
963
+ * user wrote, that is indistinguishable from a broken one on screen, so it says
964
+ * what is missing, once per transform rather than on every query assembled. An
965
+ * implicit stack says nothing: every bar mark without a fill gets one, and it is
966
+ * the ordinary bar it draws, not a mistake.
967
+ */
968
+ function stackSQL(direction, options, implicit = false) {
836
969
  const [loName, hiName] = direction === 'y' ? ['y0', 'y1'] : ['x0', 'x1'];
970
+ let warned = implicit;
971
+ const onNoSeries = (aliases) => {
972
+ if (warned)
973
+ return;
974
+ warned = true;
975
+ console.warn(`stack${direction === 'y' ? 'Y' : 'X'}: no series column, so stacking has no effect ` +
976
+ `(one row per band). Stacking groups rows within a band by the ordinal ` +
977
+ `fill channel — add fill="<field>" to the mark. Select list: [${aliases.join(', ')}].`);
978
+ };
837
979
  return {
838
980
  stage: 'sql',
839
981
  type: 'stack',
@@ -844,7 +986,7 @@ function stackSQL(direction, options) {
844
986
  channels: [loName, hiName],
845
987
  apply(query, config) {
846
988
  const aliases = getSelectAliases(query);
847
- const { band, value, series } = resolveStackColumns(aliases, config, direction);
989
+ const { band, value, series } = resolveStackColumns(aliases, config, direction, onNoSeries);
848
990
  const valueCol = column(value);
849
991
  const bandCol = column(band);
850
992
  const notNaN = sqlTag `NOT isnan(${valueCol})`;
@@ -954,12 +1096,6 @@ function stackXJS(options) {
954
1096
  }
955
1097
  };
956
1098
  }
957
- /**
958
- * Core stacking computation on DataAccessor columns.
959
- *
960
- * Single O(n) pass: detects group boundaries in the sorted group column,
961
- * accumulates value offsets, writes lo/hi Float32Arrays, applies offset mode.
962
- */
963
1099
  /**
964
1100
  * Core stacking computation on DataAccessor columns.
965
1101
  *
@@ -1185,9 +1321,7 @@ export function withImplicitStack(transforms, hasExplicitBounds, direction) {
1185
1321
  ? direction === 'y'
1186
1322
  ? stackY({ engine: 'js' })
1187
1323
  : stackX({ engine: 'js' })
1188
- : direction === 'y'
1189
- ? stackY()
1190
- : stackX();
1324
+ : stackSQL(direction, {}, true);
1191
1325
  return [...transforms, implicitStack];
1192
1326
  }
1193
1327
  export { TransformCompiler } from './TransformCompiler';