@fjader/svelte 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/README.md +1 -1
  2. package/dist/animation/EntranceGate.d.ts +52 -0
  3. package/dist/animation/EntranceGate.js +93 -0
  4. package/dist/animation/TransitionClock.d.ts +61 -30
  5. package/dist/animation/TransitionClock.js +67 -29
  6. package/dist/animation/transition-log.d.ts +41 -0
  7. package/dist/animation/transition-log.js +57 -0
  8. package/dist/components/axes/AxisBottom.svelte +4 -2
  9. package/dist/components/axes/AxisCenter.svelte +4 -2
  10. package/dist/components/axes/AxisLeft.svelte +4 -2
  11. package/dist/components/axes/BandAxisTicks.svelte +3 -2
  12. package/dist/components/charts/ChartCell.svelte +32 -5
  13. package/dist/components/charts/ChartCell.svelte.d.ts +9 -0
  14. package/dist/components/charts/ChartContainer.svelte +45 -3
  15. package/dist/components/charts/ChartContainer.svelte.d.ts +7 -1
  16. package/dist/components/charts/ChartHover.svelte.d.ts +14 -0
  17. package/dist/components/charts/ChartHover.svelte.js +22 -0
  18. package/dist/components/charts/ChartVisibility.svelte.d.ts +43 -0
  19. package/dist/components/charts/ChartVisibility.svelte.js +67 -0
  20. package/dist/components/guides/Grid.svelte +12 -1
  21. package/dist/components/marks/AreaMarkX.svelte +16 -4
  22. package/dist/components/marks/AreaMarkY.svelte +16 -4
  23. package/dist/components/marks/BarMarkX.svelte +7 -3
  24. package/dist/components/marks/BarMarkY.svelte +7 -3
  25. package/dist/components/marks/DotsMark.svelte +6 -3
  26. package/dist/components/marks/LineMark.svelte +11 -8
  27. package/dist/components/marks/ParallelCoordinatesMark.svelte +7 -2
  28. package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +2 -0
  29. package/dist/components/marks/RectMark.svelte +6 -3
  30. package/dist/components/marks/RectXMark.svelte +7 -3
  31. package/dist/components/marks/RectYMark.svelte +7 -3
  32. package/dist/components/marks/RuleMarkX.svelte +30 -26
  33. package/dist/components/marks/RuleMarkY.svelte +30 -26
  34. package/dist/components/marks/TextMark.svelte +44 -5
  35. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +16 -0
  36. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +398 -54
  37. package/dist/components/marks/lifecycle/domain-queries.d.ts +11 -2
  38. package/dist/components/marks/lifecycle/domain-queries.js +33 -6
  39. package/dist/components/marks/lifecycle/domain-registration.d.ts +6 -1
  40. package/dist/components/marks/lifecycle/domain-registration.js +15 -14
  41. package/dist/components/marks/lifecycle/other-rows.svelte.d.ts +9 -1
  42. package/dist/components/marks/lifecycle/other-rows.svelte.js +2 -2
  43. package/dist/components/marks/lifecycle/query-builder.d.ts +27 -2
  44. package/dist/components/marks/lifecycle/query-builder.js +23 -20
  45. package/dist/components/marks/utils/MarkColor.svelte.d.ts +3 -1
  46. package/dist/components/marks/utils/MarkColor.svelte.js +33 -3
  47. package/dist/components/marks/utils/MarkSeries.svelte.d.ts +14 -1
  48. package/dist/components/marks/utils/MarkSeries.svelte.js +25 -1
  49. package/dist/components/marks/utils/MarkSize.svelte.js +4 -0
  50. package/dist/components/marks/utils/reactive-config.d.ts +1 -1
  51. package/dist/components/marks/utils/reactive-config.js +3 -0
  52. package/dist/components/rendering/Areas.svelte +57 -19
  53. package/dist/components/rendering/Areas.svelte.d.ts +12 -0
  54. package/dist/components/rendering/BaseGPURenderer.svelte.d.ts +1 -1
  55. package/dist/components/rendering/Dots.svelte +23 -3
  56. package/dist/components/rendering/Dots.svelte.d.ts +6 -0
  57. package/dist/components/rendering/GridLine.svelte +13 -1
  58. package/dist/components/rendering/GridLine.svelte.d.ts +5 -0
  59. package/dist/components/rendering/Lines.svelte +48 -13
  60. package/dist/components/rendering/Lines.svelte.d.ts +12 -0
  61. package/dist/components/rendering/ParallelLines.svelte +5 -5
  62. package/dist/components/rendering/Rects.svelte +51 -11
  63. package/dist/components/rendering/Rects.svelte.d.ts +16 -2
  64. package/dist/components/rendering/RuleX.svelte +1 -1
  65. package/dist/components/rendering/RuleY.svelte +1 -1
  66. package/dist/components/rendering/WebGPUCanvas.svelte +4 -2
  67. package/dist/components/rendering/activity-reads.d.ts +41 -24
  68. package/dist/components/rendering/activity-reads.js +54 -31
  69. package/dist/components/rendering/uniform-sizes.d.ts +1 -1
  70. package/dist/components/rendering/uniform-sizes.js +1 -1
  71. package/dist/config/ChartConfig.svelte.d.ts +7 -0
  72. package/dist/config/ChartConfig.svelte.js +17 -0
  73. package/dist/gpu/GPUCoordinator.svelte.d.ts +31 -1
  74. package/dist/gpu/GPUCoordinator.svelte.js +164 -7
  75. package/dist/gpu/MorphedChannels.d.ts +11 -2
  76. package/dist/gpu/MorphedChannels.js +33 -11
  77. package/dist/gpu/ScaleTemplateSystem.d.ts +20 -2
  78. package/dist/gpu/ScaleTemplateSystem.js +58 -2
  79. package/dist/index.d.ts +17 -4
  80. package/dist/index.js +19 -4
  81. package/dist/interactors/TooltipManager.svelte.js +14 -5
  82. package/dist/query-engine/CrossfilterSelection.svelte.d.ts +4 -2
  83. package/dist/query-engine/CrossfilterSelection.svelte.js +2 -0
  84. package/dist/query-engine/FieldActivity.svelte.d.ts +2 -3
  85. package/dist/query-engine/FieldActivity.svelte.js +1 -2
  86. package/dist/query-engine/MarkHandle.svelte.d.ts +28 -1
  87. package/dist/query-engine/MarkHandle.svelte.js +33 -1
  88. package/dist/query-engine/QueryEngine.d.ts +225 -5
  89. package/dist/query-engine/QueryEngine.js +671 -41
  90. package/dist/query-engine/categorical-identity.d.ts +34 -8
  91. package/dist/query-engine/categorical-identity.js +138 -73
  92. package/dist/query-engine/debug/query-log.d.ts +39 -0
  93. package/dist/query-engine/debug/query-log.js +90 -0
  94. package/dist/query-engine/duckdb.d.ts +55 -8
  95. package/dist/query-engine/duckdb.js +82 -30
  96. package/dist/query-engine/index.d.ts +7 -4
  97. package/dist/query-engine/index.js +3 -3
  98. package/dist/query-engine/preagg/BuildQueue.d.ts +33 -13
  99. package/dist/query-engine/preagg/BuildQueue.js +51 -15
  100. package/dist/query-engine/preagg/Preagg.d.ts +46 -3
  101. package/dist/query-engine/preagg/Preagg.js +159 -31
  102. package/dist/query-engine/preagg/plan.d.ts +28 -1
  103. package/dist/query-engine/preagg/plan.js +128 -8
  104. package/dist/query-engine/sliced-lane.d.ts +108 -0
  105. package/dist/query-engine/sliced-lane.js +299 -0
  106. package/dist/query-engine/source-routing.d.ts +106 -0
  107. package/dist/query-engine/source-routing.js +189 -0
  108. package/dist/scales/BandScaleState.svelte.d.ts +9 -1
  109. package/dist/scales/BandScaleState.svelte.js +16 -5
  110. package/dist/scales/ChartScales.svelte.d.ts +3 -0
  111. package/dist/scales/ChartScales.svelte.js +26 -8
  112. package/dist/scales/ColorScaleState.svelte.d.ts +18 -2
  113. package/dist/scales/ColorScaleState.svelte.js +138 -26
  114. package/dist/scales/LinearScaleState.svelte.d.ts +7 -10
  115. package/dist/scales/LinearScaleState.svelte.js +21 -39
  116. package/dist/scales/ScaleGeneration.svelte.d.ts +48 -0
  117. package/dist/scales/ScaleGeneration.svelte.js +42 -0
  118. package/dist/scales/ScaleRegistry.svelte.js +3 -1
  119. package/dist/scales/SlotTransition.d.ts +15 -1
  120. package/dist/scales/SlotTransition.js +33 -4
  121. package/dist/scales/TimeScaleState.svelte.d.ts +7 -3
  122. package/dist/scales/TimeScaleState.svelte.js +7 -7
  123. package/dist/scales/color-utils.d.ts +0 -3
  124. package/dist/scales/color-utils.js +0 -3
  125. package/dist/scales/entered.d.ts +8 -0
  126. package/dist/scales/entered.js +10 -0
  127. package/dist/spec/LazyQueryEngine.js +2 -19
  128. package/dist/spec/MarkRegistry.d.ts +3 -1
  129. package/dist/spec/MarkRegistry.js +13 -1
  130. package/dist/spec/PlotFromSpec.svelte +1 -0
  131. package/dist/spec/export.js +11 -2
  132. package/dist/spec/types.d.ts +41 -13
  133. package/dist/sql/bin.d.ts +25 -0
  134. package/dist/sql/bin.js +108 -5
  135. package/dist/transforms/TransformCompiler.d.ts +10 -0
  136. package/dist/transforms/TransformCompiler.js +26 -0
  137. package/dist/transforms/index.js +144 -24
  138. package/dist/types.d.ts +136 -2
  139. package/package.json +2 -2
package/dist/sql/bin.js CHANGED
@@ -1,5 +1,43 @@
1
- import { binDate, binHistogram, column } from '@uwdata/mosaic-sql';
1
+ import { binDate, binHistogram, binSpec, column, interval, sql, timeInterval } from '@uwdata/mosaic-sql';
2
2
  import { isTemporalScale } from '../scales/scale-categories';
3
+ /**
4
+ * Numeric bins over [min, max]: binHistogram's own derivation (binSpec, with
5
+ * the linear transform it is given), as the first edge and the width.
6
+ */
7
+ function numericBins(min, max, options) {
8
+ const b = binSpec(min, max, options);
9
+ return { min: b.min, alpha: (b.max - b.min) / b.steps || 1 };
10
+ }
11
+ /**
12
+ * The edge of numeric bin `index` (SQL), `min + alpha * (index + offset)`, in
13
+ * DOUBLE arithmetic. The one formula both `bin()` and `dense()`'s padding
14
+ * write, so a bin's edge is the same bits whichever produced it: a padded row
15
+ * then merges with a real one, and a mark's rows are the same bits whether
16
+ * they came from the base table or a preagg table.
17
+ */
18
+ export function numericBinEdgeSQL(min, alpha, offset, index) {
19
+ return `(${sqlDouble(min)} + ${sqlDouble(alpha)} * (${index} + ${offset}))`;
20
+ }
21
+ /** A JS number as a DOUBLE literal: a bare 0.1 would be read as a DECIMAL. */
22
+ function sqlDouble(n) {
23
+ return `CAST(${JSON.stringify(n)} AS DOUBLE)`;
24
+ }
25
+ /** Engine-built SQL text as an expression node. Never user input. */
26
+ function sqlFragment(text) {
27
+ return sql([text]);
28
+ }
29
+ /** A time unit's typical length, for estimating a bucket count. */
30
+ const UNIT_MS = {
31
+ year: 365 * 86_400_000,
32
+ quarter: 91 * 86_400_000,
33
+ month: 30 * 86_400_000,
34
+ day: 86_400_000,
35
+ hour: 3_600_000,
36
+ minute: 60_000,
37
+ second: 1000,
38
+ millisecond: 1,
39
+ microsecond: 0.001
40
+ };
3
41
  export function bin(field, options = {}) {
4
42
  return new AutoBinNode(field, options);
5
43
  }
@@ -19,6 +57,70 @@ export class AutoBinNode {
19
57
  };
20
58
  }
21
59
  resolve(fieldInfo, scaleType, timeOffset = null, currentDomain = null, binStepMultiplier = 1.0) {
60
+ const { col, min, max, isTemporal, binOptions } = this.#plan(fieldInfo, scaleType, currentDomain, binStepMultiplier);
61
+ if (isTemporal)
62
+ return binDate(col, [min, max], binOptions);
63
+ // A single value: binHistogram floors it rather than binning.
64
+ if (max === min)
65
+ return binHistogram(col, [min, max], binOptions);
66
+ // binHistogram's bins, written with the formula dense() pads with, so a
67
+ // real row's edge and a padded row's are the same bits (see
68
+ // numericBinEdgeSQL).
69
+ const { min: lo, alpha } = numericBins(min, max, binOptions);
70
+ const index = `floor((CAST(${col} AS DOUBLE) - ${sqlDouble(lo)}) / ${sqlDouble(alpha)})`;
71
+ return sqlFragment(numericBinEdgeSQL(lo, alpha, binOptions.offset ?? 0, index));
72
+ }
73
+ /**
74
+ * The bins `resolve` produces with the same arguments: every bin a value of
75
+ * the field can fall in. What `dense()` pads a histogram from, so a bin
76
+ * emptied by a filter stays as a zero row and the bars can morph.
77
+ *
78
+ * Numeric bins as numbers, edge k at `min + alpha * (k + offset)`. Time
79
+ * buckets as the interval `time_bucket` steps by, and the field's extent;
80
+ * those need `t0`, the time scale's origin in epoch seconds, because a time
81
+ * channel is drawn as seconds from it, and are null without it (before the
82
+ * scale has one, the channel is not rewritten either).
83
+ *
84
+ * Null for a field with a single value, which binHistogram floors instead
85
+ * of binning.
86
+ */
87
+ padding(fieldInfo, scaleType, currentDomain = null, binStepMultiplier = 1.0, t0 = null) {
88
+ const { col, min, max, isTemporal, binOptions } = this.#plan(fieldInfo, scaleType, currentDomain, binStepMultiplier);
89
+ const offset = binOptions.offset ?? 0;
90
+ const source = String(col);
91
+ if (isTemporal) {
92
+ if (t0 === null)
93
+ return null;
94
+ // binDate's own choice of unit and step.
95
+ const { unit, step = 1 } = binOptions.interval
96
+ ? { unit: binOptions.interval, step: binOptions.step }
97
+ : timeInterval(min, max, binOptions.steps || 40);
98
+ const width = (UNIT_MS[unit] ?? UNIT_MS.day) * step;
99
+ return {
100
+ kind: 'time',
101
+ source,
102
+ interval: String(interval(unit, step)),
103
+ minMs: min,
104
+ maxMs: max,
105
+ t0,
106
+ count: Math.ceil((max - min) / width) + 2,
107
+ offset
108
+ };
109
+ }
110
+ if (max === min)
111
+ return null;
112
+ const { min: lo, alpha } = numericBins(min, max, binOptions);
113
+ return {
114
+ kind: 'number',
115
+ source,
116
+ min: lo,
117
+ alpha,
118
+ count: Math.floor((max - lo) / alpha) + 1,
119
+ offset
120
+ };
121
+ }
122
+ /** The column, extent and options both `resolve` and `padding` bin with. */
123
+ #plan(fieldInfo, scaleType, currentDomain, binStepMultiplier) {
22
124
  const fieldName = typeof this.field === 'string' ? this.field : this.field.toString();
23
125
  const info = fieldInfo[fieldName] || {};
24
126
  const { min, max, type } = info;
@@ -50,12 +152,13 @@ export class AutoBinNode {
50
152
  binOptions.steps = Math.max(1, Math.round(binOptions.steps * binStepMultiplier));
51
153
  }
52
154
  if (binOptions.step) {
53
- binOptions.step = binOptions.step / binStepMultiplier;
155
+ // A time interval counts whole units: `INTERVAL 6.78 day` is not SQL.
156
+ binOptions.step = binOptions.interval
157
+ ? Math.max(1, Math.round(binOptions.step / binStepMultiplier))
158
+ : binOptions.step / binStepMultiplier;
54
159
  }
55
160
  }
56
- return isTemporal
57
- ? binDate(col, [min, max], binOptions)
58
- : binHistogram(col, [min, max], binOptions);
161
+ return { col, min, max, isTemporal, binOptions };
59
162
  }
60
163
  withOffset(offset) {
61
164
  return new AutoBinNode(this.field, {
@@ -24,6 +24,16 @@ export declare class TransformCompiler {
24
24
  options?: {
25
25
  skipRowFilters?: boolean;
26
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;
27
37
  /**
28
38
  * Whether a SQL transform filters on the groups' aggregates (`having`). Its
29
39
  * condition names aggregates over the source rows, which a pre-aggregated table
@@ -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;
@@ -52,6 +58,26 @@ export class TransformCompiler {
52
58
  }
53
59
  return result;
54
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
+ }
55
81
  /**
56
82
  * Whether a SQL transform filters on the groups' aggregates (`having`). Its
57
83
  * condition names aggregates over the source rows, which a pre-aggregated table
@@ -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
  // =============================================================================
@@ -208,18 +209,29 @@ const BAND_SOURCE_COLUMN = '__dplot_band_src';
208
209
  * instead, and the query assembler orders the outermost query by it.
209
210
  */
210
211
  function densify(inner, aliases, plan) {
211
- const { band, dim, cross } = plan;
212
+ const { band, dim, cross, bins } = plan;
212
213
  const crossAlias = new Map(cross.map(([key], i) => [key, `k${i}`]));
214
+ const binOf = new Map(bins);
213
215
  const select = {};
214
216
  for (const alias of aliases) {
215
217
  const crossed = crossAlias.get(alias);
216
- if (alias === band) {
217
- 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)`);
218
230
  }
219
231
  else if (crossed) {
220
232
  // Another group key, enumerated from its own dim: the padded row for a
221
233
  // missing (category, series) pair carries that series' real id.
222
- select[alias] = rawSql(`${crossed}."id"`);
234
+ select[alias] = rawSql(`CAST(${crossed}."id" AS REAL)`);
223
235
  }
224
236
  else if (alias.startsWith(OTHER_MEMBER_PREFIX)) {
225
237
  // Other's member lists: NULL on a padded row, as on every row but
@@ -227,7 +239,7 @@ function densify(inner, aliases, plan) {
227
239
  // a list.
228
240
  select[alias] = rawSql(`a.${column(alias)}`);
229
241
  }
230
- else if (plan.identity[alias]?.dim === dim) {
242
+ else if (!bins && plan.identity[alias]?.dim === dim) {
231
243
  // Same dim as the band, so this channel IS the category — the ordinary
232
244
  // "colour the bars by their own category" chart. Its value for a row
233
245
  // with no data is not unknown, it is that category's id, so take it
@@ -236,7 +248,7 @@ function densify(inner, aliases, plan) {
236
248
  // This is what keeps a DEPARTING bar its colour. It is still on screen
237
249
  // while it shrinks, and a sentinel would turn it grey on the frame its
238
250
  // data disappeared — the one frame where the colour matters most.
239
- select[alias] = sqlTag `d."id"`;
251
+ select[alias] = rawSql(`CAST(d."id" AS REAL)`);
240
252
  }
241
253
  else if (plan.identity[alias]) {
242
254
  // A category id from some OTHER field that is not a group key — an
@@ -244,7 +256,7 @@ function densify(inner, aliases, plan) {
244
256
  // so the absent sentinel it is. Never 0, which is a real category: it
245
257
  // would enter the colour scale's domain, shift every palette rank after
246
258
  // it, and repaint bars that had not changed.
247
- select[alias] = sqlTag `COALESCE(a.${column(alias)}, -1)`;
259
+ select[alias] = sqlTag `COALESCE(a.${column(alias)}, CAST(-1 AS REAL))`;
248
260
  }
249
261
  else {
250
262
  // A measure. 0 is the point: it is what makes a departing bar shrink to
@@ -275,11 +287,52 @@ function densify(inner, aliases, plan) {
275
287
  const joins = cross
276
288
  .map(([key, keyDim], i) => ` CROSS JOIN (SELECT "id" FROM ${padIds(plan.identity[key], keyDim)} UNION ALL SELECT -1) AS k${i}`)
277
289
  .join('');
278
- const on = [
279
- `a.${column(band)} = d."id"`,
280
- ...cross.map(([key], i) => `a.${column(key)} = k${i}."id"`)
281
- ].join(' AND ');
282
- 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)`;
283
336
  }
284
337
  /**
285
338
  * Most padded rows `dense()` will produce before refusing.
@@ -347,20 +400,24 @@ function densePlan(aliases, config, enabled, reported) {
347
400
  if (reported.has(key))
348
401
  return;
349
402
  reported.add(key);
350
- console.info(`[dense] ${reason} Categories on this mark will snap between updates ` +
351
- `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.`);
352
405
  };
353
406
  if (!enabled)
354
407
  return null;
355
- const identity = config?.identity;
356
- if (!identity)
357
- return null;
358
408
  // Stated by whoever built the select, because it cannot be recovered from
359
409
  // the query by the time this runs: topN has already wrapped it, and the
360
410
  // GROUP BY is several levels down.
361
411
  const groupKeys = config?.groupKeys;
362
412
  if (!groupKeys)
363
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;
364
421
  const candidates = aliases.filter((ch) => identity[ch]?.positional);
365
422
  if (candidates.length !== 1) {
366
423
  if (candidates.length > 1) {
@@ -400,7 +457,59 @@ function densePlan(aliases, config, enabled, reported) {
400
457
  `(${size} categories${cross.length > 0 ? ` across ${cross.length + 1} group keys` : ''}).`);
401
458
  return null;
402
459
  }
403
- 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 ?? {} };
404
513
  }
405
514
  /**
406
515
  * Return a row for every category, not only the ones that survived.
@@ -440,10 +549,27 @@ export function dense(options = {}) {
440
549
  return plan ? [plan.band, ...plan.cross.map(([key]) => key)] : null;
441
550
  },
442
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;
443
557
  const aliases = getSelectAliases(query);
444
558
  const plan = densePlan(aliases, config, enabled, reported);
445
559
  return plan ? densify(query, aliases, plan) : query;
446
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
+ },
447
573
  describe() {
448
574
  return 'Dense categories';
449
575
  }
@@ -970,12 +1096,6 @@ function stackXJS(options) {
970
1096
  }
971
1097
  };
972
1098
  }
973
- /**
974
- * Core stacking computation on DataAccessor columns.
975
- *
976
- * Single O(n) pass: detects group boundaries in the sorted group column,
977
- * accumulates value offsets, writes lo/hi Float32Arrays, applies offset mode.
978
- */
979
1099
  /**
980
1100
  * Core stacking computation on DataAccessor columns.
981
1101
  *
package/dist/types.d.ts CHANGED
@@ -39,13 +39,27 @@ export type FieldRef = string | ExprNode;
39
39
  * resolves against the chart's state, such as bin() or a rolling average.
40
40
  */
41
41
  export type ChannelRef = FieldRef | AutoBinNode | RollingAvgNode;
42
- /** Color prop: CSS constant, column name, object wrapper {field, scheme?, type?}, SQL node, or null. */
42
+ /** A category value a colour can be pinned to. */
43
+ export type ColorDomainValue = string | number | boolean | null;
44
+ /**
45
+ * Color prop: CSS constant, column name, object wrapper, SQL node, or null.
46
+ *
47
+ * The object wrapper takes a scheme and type, or explicit ordinal colours:
48
+ *
49
+ * { field: 'won', domain: [0, 1], range: ['#d62728', '#2ca02c'] }
50
+ *
51
+ * `range` replaces the scheme's colours, which go to the values in sorted
52
+ * order. `domain` pins values to them instead: domain[i] draws range[i], and
53
+ * values outside it draw the null colour. Either one makes the scale ordinal.
54
+ */
43
55
  export type ColorProp = FieldRef | {
44
56
  field: string;
45
57
  scheme?: ColorScheme;
46
58
  type?: ColorScaleType;
47
59
  constant?: number;
48
60
  exponent?: number;
61
+ domain?: readonly ColorDomainValue[];
62
+ range?: readonly string[];
49
63
  } | null;
50
64
  /**
51
65
  * A SQL transform modifies the Mosaic Query AST before execution.
@@ -77,6 +91,12 @@ export interface SqlTransform {
77
91
  * band a mark may morph along.
78
92
  */
79
93
  alignment?(aliases: string[], config?: MarkConfig): string[] | null;
94
+ /**
95
+ * The rows this transform would add to a result to pad it, as a SELECT of
96
+ * every alias, or null when it pads nothing. A pre-aggregated table is
97
+ * built with them, so its reads need no padding of their own (`padded`).
98
+ */
99
+ padRows?(aliases: string[], config?: MarkConfig): string | null;
80
100
  describe?(): string;
81
101
  }
82
102
  /** A JS transform processes client-side data after the query returns. */
@@ -119,6 +139,40 @@ export interface ChannelIdentity {
119
139
  */
120
140
  dropNullId?: number;
121
141
  }
142
+ /**
143
+ * One binned channel's bins, as `bin()` resolved them for a query, which
144
+ * `dense()` pads a histogram from. A rect's upper edge is the same bins with
145
+ * offset 1.
146
+ */
147
+ export type BinPadding = NumericBinPadding | TimeBinPadding;
148
+ /** Numeric bins: edge k is `min + alpha * (k + offset)` for k in [0, count). */
149
+ export interface NumericBinPadding {
150
+ kind: 'number';
151
+ /** The binned column or expression, as SQL: a bin is NULL exactly where it is. */
152
+ source: string;
153
+ min: number;
154
+ alpha: number;
155
+ count: number;
156
+ offset: number;
157
+ }
158
+ /**
159
+ * Time buckets of `interval` (SQL, as `time_bucket` takes it) over the field's
160
+ * extent, minMs to maxMs. An edge is the bucket start plus `offset` intervals,
161
+ * drawn as seconds from the time scale's origin `t0` (epoch seconds), the form
162
+ * a time channel takes in the query. `count` estimates the buckets, for the
163
+ * row limit: months and years are not a fixed width.
164
+ */
165
+ export interface TimeBinPadding {
166
+ kind: 'time';
167
+ /** The binned column or expression, as SQL: a bucket is NULL exactly where it is. */
168
+ source: string;
169
+ interval: string;
170
+ minMs: number;
171
+ maxMs: number;
172
+ t0: number;
173
+ count: number;
174
+ offset: number;
175
+ }
122
176
  export interface MarkConfig {
123
177
  markType: string;
124
178
  table: string;
@@ -129,6 +183,10 @@ export interface MarkConfig {
129
183
  identity?: Record<string, ChannelIdentity>;
130
184
  /** The query's GROUP BY channels, or null when it does not aggregate. Per query. */
131
185
  groupKeys?: string[] | null;
186
+ /** The numeric `bin()` channels and their bins, which `dense()` pads from. Per query. */
187
+ bins?: Record<string, BinPadding> | null;
188
+ /** The query reads a table that already holds its padding rows. Per query. */
189
+ padded?: boolean;
132
190
  [key: string]: unknown;
133
191
  }
134
192
  export interface ScaleMap {
@@ -198,6 +256,32 @@ export interface BaseMarkProps {
198
256
  * - `'filter'`: every selection filters it, even on a field it draws by, so
199
257
  * its aggregates are recomputed for the subset: top N within the selected
200
258
  * region, shares within the selected groups.
259
+ * - `'ignore'`: no selection filters or dims it, its own chart's controls
260
+ * included, and a selection change never re-queries it. For context: the
261
+ * whole drawn faintly behind a filtered part, on the same scales, whose
262
+ * extent then keeps the axis still while the part changes. A click on its
263
+ * Other bar toggles, as under `'auto'`.
264
+ *
265
+ * Background totals are two layers of the same mark: an `'ignore'` one
266
+ * drawn faintly behind, the whole, and an `'auto'` one in front, the part a
267
+ * brush or click elsewhere leaves. Each bar shrinks over its total instead
268
+ * of losing it, and the y axis keeps the total's extent:
269
+ *
270
+ * ```svelte
271
+ * <BarMarkY table="flights" x="carrier" y={count()} selectionResponse="ignore" fillOpacity={0.2} />
272
+ * <BarMarkY table="flights" x="carrier" y={count()} />
273
+ * ```
274
+ *
275
+ * With a top N, choose which layer ranks; the other gets
276
+ * `contributeDomain={false}` and follows the axis:
277
+ *
278
+ * - the back layer, to rank once over the whole: the same N categories
279
+ * stay while the front bars shrink;
280
+ * - the front layer, to re-rank within the selection: the N categories
281
+ * change with it, and the whole is drawn for whichever are shown.
282
+ *
283
+ * Not with `includeOthers`: the follower has no Other row (see
284
+ * `contributeDomain`).
201
285
  *
202
286
  * A `'filter'` mark never dims. A legend, Toggle or band label filters it
203
287
  * in its own chart too: every such control on the same fields shares one
@@ -237,12 +321,32 @@ export interface BaseMarkProps {
237
321
  * before any transform, so a top N ranks, and Other sums, without them.
238
322
  */
239
323
  nulls?: NullsResponse;
324
+ /**
325
+ * Whether the chart's entrance waits for this mark's first data. Default
326
+ * true: the chart enters once every mark has landed, so later marks cannot
327
+ * widen the axes mid-entrance. False for a mark known to be slower than
328
+ * the rest: the chart enters without it, and it joins the entrance where it
329
+ * is, or appears after it. A chart waits at most a second past its first
330
+ * mark either way.
331
+ */
332
+ holdsEntrance?: boolean;
240
333
  scheme?: ColorScheme;
241
334
  limit?: number;
242
335
  transforms?: Transform[];
243
336
  sort?: SortConfig | null;
244
337
  domain?: DomainConfig | null;
245
- tooltip?: boolean;
338
+ /**
339
+ * Hover tooltips. `true` (the default) or `false`, or options:
340
+ *
341
+ * - `sortTable`: write the mark's tooltip table sorted by x, so a hover
342
+ * lookup can skip DuckDB row groups outside the pointer's box. Off by
343
+ * default: the sort delays the first hover (the table is written when the
344
+ * pointer enters the chart), and measured up to 2M rows it did not make
345
+ * lookups faster.
346
+ */
347
+ tooltip?: boolean | {
348
+ sortTable?: boolean;
349
+ };
246
350
  tooltipProximity?: 'x' | 'y' | 'xy';
247
351
  tip?: (FieldRef | '*')[] | false;
248
352
  fitDomain?: 'x' | 'y' | boolean;
@@ -313,6 +417,18 @@ export interface LODConfig {
313
417
  /** Callback fired on LOD state transitions (decimation toggle, dots appear/disappear). */
314
418
  onLODChange?: (state: LODChangeState) => void;
315
419
  }
420
+ /**
421
+ * How a chart's marks enter when its data first draws.
422
+ *
423
+ * - `'auto'` (the default): bars, histograms and areas grow from 0 on their
424
+ * measure axis, showing magnitude; everything else fades in.
425
+ * - `'fade'`: every mark fades in. Calmer, for dense small multiples.
426
+ * - `'none'`: marks appear at once.
427
+ *
428
+ * Under `prefers-reduced-motion: reduce`, `'auto'` becomes `'fade'`. The axes
429
+ * never move on load: the entrance is the data's, not the viewport's.
430
+ */
431
+ export type EntranceMode = 'auto' | 'fade' | 'none';
316
432
  import type { Margin, Point, AxesResult } from './layout/ChartLayout.svelte';
317
433
  import type { LinearScaleState } from './scales/LinearScaleState.svelte';
318
434
  import type { BandScaleState } from './scales/BandScaleState.svelte';
@@ -372,6 +488,24 @@ export interface ChartContext {
372
488
  readonly currentXScreenPoint: number | null | undefined;
373
489
  readonly currentYScreenPoint: number | null | undefined;
374
490
  readonly xScaleState: ScaleStateInstance | null | undefined;
491
+ /**
492
+ * How many times the chart's x or y scale has been replaced by another (not
493
+ * its first resolution). Marks rebuild their lifecycle when it changes.
494
+ */
495
+ readonly scaleGeneration: number;
496
+ /**
497
+ * Whether any of the chart's cells is on screen (with a small margin).
498
+ * Marks stop re-querying while it is false; see MarkHandle.active.
499
+ */
500
+ readonly visible: boolean;
501
+ /**
502
+ * Whether a visible chart other than this one draws `scale`: a catch-up
503
+ * springs such a scale rather than snapping it. See
504
+ * ChartVisibility.shownElsewhere.
505
+ */
506
+ shownElsewhere(scale: object): boolean;
507
+ /** Whether the pointer is over any of the chart's cells (hover intent). */
508
+ readonly hovered: boolean;
375
509
  readonly yScaleState: ScaleStateInstance | null | undefined;
376
510
  readonly scaleTypes: {
377
511
  x: string;
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.4",
21
+ "version": "0.2.5",
22
22
  "type": "module",
23
23
  "svelte": "./dist/index.js",
24
24
  "exports": {
@@ -41,7 +41,7 @@
41
41
  "prepack": "svelte-package",
42
42
  "prepare": "svelte-kit sync || echo ''",
43
43
  "format": "prettier --write .",
44
- "lint": "prettier --check . && eslint .",
44
+ "lint": "prettier --check . && eslint . && node scripts/check-doc-blocks.mjs",
45
45
  "test": "vitest",
46
46
  "typecheck": "svelte-check --tsconfig ./jsconfig.json",
47
47
  "build:widget": "vite build --config vite.widget.config.js",