@fjader/svelte 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +2 -0
  2. package/dist/components/axes/AxisBottom.svelte +23 -6
  3. package/dist/components/axes/AxisBottom.svelte.d.ts +10 -0
  4. package/dist/components/axes/AxisLeft.svelte +77 -8
  5. package/dist/components/axes/AxisLeft.svelte.d.ts +17 -0
  6. package/dist/components/axes/BandAxisTicks.svelte +1 -1
  7. package/dist/components/axes/axis-title.d.ts +30 -0
  8. package/dist/components/axes/axis-title.js +77 -0
  9. package/dist/components/charts/ChartCell.svelte +1 -1
  10. package/dist/components/charts/ChartContainer.svelte +10 -2
  11. package/dist/components/charts/ChartContainer.svelte.d.ts +7 -0
  12. package/dist/components/guides/legend/ContinuousColorLegend.svelte +7 -1
  13. package/dist/components/guides/legend/OrdinalColorLegend.svelte +8 -5
  14. package/dist/components/marks/AreaMarkX.svelte +2 -0
  15. package/dist/components/marks/AreaMarkY.svelte +2 -0
  16. package/dist/components/marks/BarMarkX.svelte +13 -1
  17. package/dist/components/marks/BarMarkY.svelte +13 -1
  18. package/dist/components/marks/DotsMark.svelte +2 -0
  19. package/dist/components/marks/LineMark.svelte +5 -1
  20. package/dist/components/marks/ParallelCoordinatesMark.svelte +4 -0
  21. package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +2 -0
  22. package/dist/components/marks/RectMark.svelte +2 -0
  23. package/dist/components/marks/RectXMark.svelte +2 -0
  24. package/dist/components/marks/RectYMark.svelte +2 -0
  25. package/dist/components/marks/RuleMarkX.svelte +2 -0
  26. package/dist/components/marks/RuleMarkY.svelte +2 -0
  27. package/dist/components/marks/TextMark.svelte +8 -9
  28. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +6 -1
  29. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +187 -18
  30. package/dist/components/marks/lifecycle/domain-queries.d.ts +3 -1
  31. package/dist/components/marks/lifecycle/domain-queries.js +11 -6
  32. package/dist/components/marks/lifecycle/domain-registration.js +2 -6
  33. package/dist/components/marks/lifecycle/other-rows.svelte.d.ts +104 -0
  34. package/dist/components/marks/lifecycle/other-rows.svelte.js +193 -0
  35. package/dist/components/marks/lifecycle/other-split.d.ts +80 -0
  36. package/dist/components/marks/lifecycle/other-split.js +112 -0
  37. package/dist/components/marks/lifecycle/query-builder.d.ts +26 -1
  38. package/dist/components/marks/lifecycle/query-builder.js +25 -5
  39. package/dist/components/marks/utils/reactive-config.d.ts +1 -1
  40. package/dist/components/marks/utils/reactive-config.js +6 -0
  41. package/dist/components/marks/utils/tooltip-helpers.d.ts +4 -1
  42. package/dist/components/marks/utils/tooltip-helpers.js +2 -1
  43. package/dist/components/rendering/BaseGPURenderer.svelte.d.ts +3 -1
  44. package/dist/components/rendering/BaseGPURenderer.svelte.js +4 -2
  45. package/dist/components/rendering/Rects.svelte +117 -23
  46. package/dist/components/rendering/Rects.svelte.d.ts +7 -0
  47. package/dist/components/rendering/activity-reads.d.ts +18 -0
  48. package/dist/components/rendering/activity-reads.js +19 -0
  49. package/dist/config/ChartConfig.svelte.d.ts +3 -1
  50. package/dist/gpu/MorphedChannels.d.ts +6 -32
  51. package/dist/gpu/MorphedChannels.js +45 -3
  52. package/dist/gpu/ScaleTemplateSystem.js +25 -4
  53. package/dist/index.d.ts +2 -0
  54. package/dist/index.js +3 -0
  55. package/dist/interactors/Interval1D.svelte +6 -1
  56. package/dist/interactors/Interval2D.svelte +6 -1
  57. package/dist/interactors/ParallelAxisSource.svelte +10 -1
  58. package/dist/interactors/Toggle.svelte +32 -3
  59. package/dist/interactors/Tooltip.svelte +138 -41
  60. package/dist/interactors/TooltipManager.svelte.d.ts +20 -5
  61. package/dist/interactors/TooltipManager.svelte.js +32 -7
  62. package/dist/interactors/other-tooltip.d.ts +16 -0
  63. package/dist/interactors/other-tooltip.js +22 -0
  64. package/dist/query-engine/FieldActivity.svelte.d.ts +50 -1
  65. package/dist/query-engine/FieldActivity.svelte.js +176 -5
  66. package/dist/query-engine/FilterGroup.svelte.d.ts +23 -1
  67. package/dist/query-engine/FilterGroup.svelte.js +43 -3
  68. package/dist/query-engine/FilterGroupRegistry.svelte.d.ts +31 -3
  69. package/dist/query-engine/FilterGroupRegistry.svelte.js +62 -15
  70. package/dist/query-engine/QueryEngine.js +26 -6
  71. package/dist/query-engine/categorical-identity.d.ts +15 -0
  72. package/dist/query-engine/categorical-identity.js +15 -0
  73. package/dist/query-engine/preagg/plan.d.ts +5 -0
  74. package/dist/query-engine/preagg/plan.js +31 -7
  75. package/dist/query-engine/toggle-predicate.d.ts +21 -0
  76. package/dist/query-engine/toggle-predicate.js +29 -1
  77. package/dist/scales/ActivityTable.d.ts +5 -1
  78. package/dist/scales/ActivityTable.js +12 -3
  79. package/dist/scales/BandScaleState.svelte.d.ts +13 -8
  80. package/dist/scales/BandScaleState.svelte.js +23 -16
  81. package/dist/spec/types.d.ts +2 -0
  82. package/dist/theme.css +5 -4
  83. package/dist/transforms/TransformCompiler.d.ts +37 -2
  84. package/dist/transforms/TransformCompiler.js +44 -1
  85. package/dist/transforms/index.js +32 -18
  86. package/dist/types.d.ts +41 -0
  87. package/package.json +3 -2
@@ -12,7 +12,7 @@
12
12
  * License; see NOTICE.
13
13
  */
14
14
  import { isAggregateExpression, isNode, collectColumns, column, and, isBetween, literal, Query, sql as sqlTag } from '@uwdata/mosaic-sql';
15
- import { togglePredicate } from '../toggle-predicate';
15
+ import { BITMAP_MIN_IDS, idBitmapPredicate, togglePredicate } from '../toggle-predicate';
16
16
  import { rewriteForPreagg, buildBinExpr, binsFineEnough, BIN_WINDOW_MARGIN } from '../sufficient-statistics';
17
17
  import { assembleQuery, castNumeric, castReal } from '../../components/marks/lifecycle/query-builder';
18
18
  /**
@@ -37,6 +37,11 @@ export function planPreagg(descriptor, dims, identity) {
37
37
  const { parts, table, baseTable, schema } = descriptor;
38
38
  if (!parts.hasAggregates && !parts.hasWindowAggregates)
39
39
  return null;
40
+ // A having() on a preagg table would count its groups, not the source rows, and
41
+ // return the wrong rows without an error. The built-in transforms add none; see
42
+ // TransformCompiler.hasHaving for why, and for what supporting it would take.
43
+ if (descriptor.transformCompiler.hasHaving())
44
+ return null;
40
45
  // Analyze selectDef for preagg compatibility
41
46
  const analysis = rewriteForPreagg(parts.selectDef, parts.passthroughChannels);
42
47
  if (!analysis)
@@ -115,6 +120,7 @@ export function planPreagg(descriptor, dims, identity) {
115
120
  groupby: [...preaggGroupBy, ...activeGroupBy],
116
121
  // Sorted by bin columns for fast range scans
117
122
  orderby: binOrder,
123
+ where: descriptor.transformCompiler.rowFilters(),
118
124
  joins
119
125
  },
120
126
  outputSelect,
@@ -280,8 +286,9 @@ export function preaggQuerySQL(route, descriptor, selection, preagg, params, ide
280
286
  }
281
287
  if (valueExprs && valueMeta) {
282
288
  // A toggle's clause holds tuples in its field order. Id columns
283
- // filter by id; a value with no id (NULL, or not in the dim) cannot
284
- // be expressed, so this query goes to the base table instead.
289
+ // filter by id (the NULL category has one); a value with no id (not in
290
+ // the dim yet) cannot be expressed, so this query goes to the base
291
+ // table instead.
285
292
  const tuples = [];
286
293
  for (const tuple of clause.value) {
287
294
  const mapped = [];
@@ -294,7 +301,17 @@ export function preaggQuerySQL(route, descriptor, selection, preagg, params, ide
294
301
  }
295
302
  tuples.push(mapped);
296
303
  }
297
- filters.push(togglePredicate(valueExprs, tuples));
304
+ // One field by id, and many of them (a click on a large Other): a
305
+ // constant-size bitmap rather than a long IN list, which DuckDB spends
306
+ // most of its time parsing. Measured on a preagg table: 1 ms against
307
+ // 20 ms for IN of 9 000 ids.
308
+ const only = valueMeta.length === 1 ? valueMeta[0] : null;
309
+ if (only?.byId && tuples.length > BITMAP_MIN_IDS) {
310
+ filters.push(idBitmapPredicate(valueExprs[0], tuples.map((t) => t[0]), identity.identity(only.field).values.length));
311
+ }
312
+ else {
313
+ filters.push(togglePredicate(valueExprs, tuples));
314
+ }
298
315
  }
299
316
  }
300
317
  // --- Build selectDef: CAST recombined aggregates; dimensions are already
@@ -324,13 +341,19 @@ export function preaggQuerySQL(route, descriptor, selection, preagg, params, ide
324
341
  hasSQLTransforms: parts.hasSQLTransforms,
325
342
  orderChannels: parts.orderChannels,
326
343
  aliasMap: parts.aliasMap,
327
- passthroughChannels: parts.passthroughChannels
344
+ passthroughChannels: parts.passthroughChannels,
345
+ // nulls="drop", read through this select: each dropped channel is a group
346
+ // key, so the table keeps NULL as its own group, and dropping that group
347
+ // here removes exactly what the base scan would have left out.
348
+ nullDrops: parts.nullDrops
328
349
  };
329
350
  return assembleQuery({
330
351
  table: preagg.table,
331
352
  parts: preaggParts,
332
353
  filter: filters,
333
354
  transformCompiler,
355
+ // The table was built with them.
356
+ skipRowFilters: true,
334
357
  limit: params.limit,
335
358
  window: windowChannels ? { channels: windowChannels, cteName: '__preagg_cte' } : null
336
359
  }).toString();
@@ -338,8 +361,9 @@ export function preaggQuerySQL(route, descriptor, selection, preagg, params, ide
338
361
  /** A preagg table's SELECT. Build and validity check share it, so equal SQL means an equal table. */
339
362
  export function preaggSelectSQL(creation, filters) {
340
363
  const query = Query.from(creation.table).select(creation.select).groupby(creation.groupby);
341
- if (filters.length > 0)
342
- query.where(and(filters));
364
+ const where = [...creation.where, ...filters];
365
+ if (where.length > 0)
366
+ query.where(and(where));
343
367
  if (creation.joins.length === 0)
344
368
  return query.orderby(creation.orderby).toString();
345
369
  // Map fields grouped by value to their ids on the grouped result: a join
@@ -8,3 +8,24 @@ import { type ExprNode } from '@uwdata/mosaic-sql';
8
8
  * so a predicate is never rewritten after the fact.
9
9
  */
10
10
  export declare function togglePredicate(exprs: ExprNode[], tuples: unknown[][]): ExprNode;
11
+ /**
12
+ * Above this many ids, a selection over an id column is a bitmap, not an `IN`
13
+ * list (see idBitmapPredicate). The crossover measured in DuckDB-WASM 1.30 lies
14
+ * between 500 values (`IN`, 29 ms on 2 M rows) and the bitmap (26 ms).
15
+ */
16
+ export declare const BITMAP_MIN_IDS = 256;
17
+ /**
18
+ * `expr`'s id is one of `ids`, as one constant-size literal:
19
+ * `get_bit('<bits>'::BIT, <index>) = 1`.
20
+ *
21
+ * Never `NOT IN`, whatever the selection's size: DuckDB evaluates it as a chain
22
+ * of `<>`, 2.8 s on 2 M rows for 1 000 ids. And never a long `IN`, which it
23
+ * spends most of its time parsing.
24
+ *
25
+ * Bit `id + 1` is set for each selected id; bit 0 is id -1 ("not in the dim
26
+ * yet") and is never set; the last bit, `size + 1`, is a sentinel kept 0. The
27
+ * index is clamped onto [0, sentinel], so an id appended to the dim after the
28
+ * selection was made reads the sentinel (it was not selected: a snapshot), and
29
+ * get_bit never sees an index out of range, which is an error rather than a 0.
30
+ */
31
+ export declare function idBitmapPredicate(expr: ExprNode, ids: readonly number[], size: number): ExprNode;
@@ -1,4 +1,4 @@
1
- import { and as sqlAnd, isIn, isNotDistinct, literal, or } from '@uwdata/mosaic-sql';
1
+ import { and as sqlAnd, isIn, isNotDistinct, literal, or, sql as sqlTag } from '@uwdata/mosaic-sql';
2
2
  /**
3
3
  * A toggle's clause over its selected tuples: `IN` for one field, an `OR` of
4
4
  * `IS NOT DISTINCT FROM` tuples for several, so a NULL member matches NULL.
@@ -25,3 +25,31 @@ export function togglePredicate(exprs, tuples) {
25
25
  const clauses = tuples.map((t) => sqlAnd(t.map((v, i) => isNotDistinct(exprs[i], literal(v)))));
26
26
  return clauses.length === 1 ? clauses[0] : or(clauses);
27
27
  }
28
+ /**
29
+ * Above this many ids, a selection over an id column is a bitmap, not an `IN`
30
+ * list (see idBitmapPredicate). The crossover measured in DuckDB-WASM 1.30 lies
31
+ * between 500 values (`IN`, 29 ms on 2 M rows) and the bitmap (26 ms).
32
+ */
33
+ export const BITMAP_MIN_IDS = 256;
34
+ /**
35
+ * `expr`'s id is one of `ids`, as one constant-size literal:
36
+ * `get_bit('<bits>'::BIT, <index>) = 1`.
37
+ *
38
+ * Never `NOT IN`, whatever the selection's size: DuckDB evaluates it as a chain
39
+ * of `<>`, 2.8 s on 2 M rows for 1 000 ids. And never a long `IN`, which it
40
+ * spends most of its time parsing.
41
+ *
42
+ * Bit `id + 1` is set for each selected id; bit 0 is id -1 ("not in the dim
43
+ * yet") and is never set; the last bit, `size + 1`, is a sentinel kept 0. The
44
+ * index is clamped onto [0, sentinel], so an id appended to the dim after the
45
+ * selection was made reads the sentinel (it was not selected: a snapshot), and
46
+ * get_bit never sees an index out of range, which is an error rather than a 0.
47
+ */
48
+ export function idBitmapPredicate(expr, ids, size) {
49
+ const bits = new Array(size + 2).fill('0');
50
+ for (const id of ids)
51
+ if (id >= 0 && id < size)
52
+ bits[id + 1] = '1';
53
+ const sentinel = size + 1;
54
+ return sqlTag `get_bit(${literal(bits.join(''))}::BIT, CAST(greatest(least(${expr} + 1, ${sentinel}), 0) AS INTEGER)) = 1`;
55
+ }
@@ -85,8 +85,12 @@ export declare class ActivityTable {
85
85
  * fade down from 1.
86
86
  *
87
87
  * `resize(0)` drops every id, for ids that stopped meaning their category.
88
+ *
89
+ * `trailing`: the last slot is not an id but a slot of its own that stays
90
+ * last (FieldActivity's free slot). Its fade moves to the new end, and the
91
+ * slot it leaves is a new id like any other, rather than inheriting it.
88
92
  */
89
- resize(count: number, active?: readonly number[] | null): void;
93
+ resize(count: number, active?: readonly number[] | null, trailing?: boolean): void;
90
94
  /**
91
95
  * Select exactly these ids; every other id fades out. Ids outside the table
92
96
  * are ignored. Does nothing when the selection is the one already headed to.
@@ -117,11 +117,18 @@ export class ActivityTable {
117
117
  * fade down from 1.
118
118
  *
119
119
  * `resize(0)` drops every id, for ids that stopped meaning their category.
120
+ *
121
+ * `trailing`: the last slot is not an id but a slot of its own that stays
122
+ * last (FieldActivity's free slot). Its fade moves to the new end, and the
123
+ * slot it leaves is a new id like any other, rather than inheriting it.
120
124
  */
121
- resize(count, active) {
125
+ resize(count, active, trailing = false) {
122
126
  if (count === this.#to.length)
123
127
  return;
124
- const keep = Math.min(this.#to.length, count);
128
+ const oldLength = this.#to.length;
129
+ const tail = trailing && oldLength > 0 && count > 0;
130
+ const keep = tail ? Math.min(oldLength - 1, count - 1) : Math.min(oldLength, count);
131
+ const ids = tail ? count - 1 : count;
125
132
  const selecting = active === undefined ? this.#selecting : active !== null;
126
133
  const fill = selecting ? 0 : 1;
127
134
  const grown = (old) => {
@@ -130,9 +137,11 @@ export class ActivityTable {
130
137
  next.set(old.subarray(0, keep), 0);
131
138
  if (active) {
132
139
  for (const id of active)
133
- if (id >= keep && id < count)
140
+ if (id >= keep && id < ids)
134
141
  next[id] = 1;
135
142
  }
143
+ if (tail)
144
+ next[count - 1] = old[oldLength - 1];
136
145
  return next;
137
146
  };
138
147
  this.#from = grown(this.#from);
@@ -211,6 +211,8 @@ export declare class BandScaleState {
211
211
  * undisplayed Other is ignored.
212
212
  */
213
213
  setOtherId(id: number | null): void;
214
+ /** This axis's Other bucket id, while a mark on it has one; null otherwise. */
215
+ get otherId(): number | null;
214
216
  /**
215
217
  * This axis's Other bucket, shared by every mark on it that has one.
216
218
  *
@@ -331,7 +333,7 @@ export declare class BandScaleState {
331
333
  /**
332
334
  * Get D3 band scale configured for rendering
333
335
  */
334
- getBaseScale(axis: string, innerWidthOrHeight: number): ScaleBand<DimValue>;
336
+ getBaseScale(_axis: string, innerWidthOrHeight: number): ScaleBand<DimValue>;
335
337
  /**
336
338
  * Update scale domain (for D3 scale)
337
339
  */
@@ -403,19 +405,22 @@ export declare class BandScaleState {
403
405
  /**
404
406
  * Where a tick's label sits now, in pixels along the axis: the centre of its
405
407
  * category's current slot, so it travels with the bar. Slot 0 is at the start
406
- * of the range on x and at the bottom on y, as in the shader and d3's
407
- * reversed range. Per frame, O(1).
408
+ * of the range on both axes: the left on x, the top on y, as in the shader
409
+ * and d3's range. Per frame, O(1).
408
410
  */
409
- tickPosition(key: number, rangeSize: number, axis: 'x' | 'y'): number;
411
+ tickPosition(key: number, rangeSize: number): number;
410
412
  /**
411
413
  * The same, for a caller that already has the slot.
412
414
  *
413
415
  * A consumer that has to test the slot first — an SVG label, which must not
414
416
  * draw a category the axis is not displaying — would otherwise ask for it
415
- * twice per item per frame. The axis flip lives here rather than being
416
- * re-derived by such a caller.
417
+ * twice per item per frame.
418
+ *
419
+ * The same on both axes. A y band axis reads top-down, like a list: slot 0
420
+ * at the top, so a ranking and an explicit domain need no reversing, and
421
+ * the exit one past the last slot is the end of the list on either axis.
417
422
  */
418
- slotPosition(slot: number, rangeSize: number, axis: 'x' | 'y'): number;
423
+ slotPosition(slot: number, rangeSize: number): number;
419
424
  /**
420
425
  * Centre of a (possibly fractional) slot, measured from the start of the
421
426
  * range — the same layout the shader's computeBandLayout uses, so a label
@@ -452,7 +457,7 @@ export declare class BandScaleState {
452
457
  * Returns the 0-based index of the band under the cursor, or null if
453
458
  * no bands exist.
454
459
  */
455
- invertScreenToData(screenVal: number, rangeSize: number, isYAxis?: boolean): number | null;
460
+ invertScreenToData(screenVal: number, rangeSize: number, _isYAxis?: boolean): number | null;
456
461
  /**
457
462
  * Get all mark extents (for debugging). Ids when the scale has identity.
458
463
  */
@@ -327,6 +327,10 @@ export class BandScaleState {
327
327
  setOtherId(id) {
328
328
  this.#slots.otherId = id;
329
329
  }
330
+ /** This axis's Other bucket id, while a mark on it has one; null otherwise. */
331
+ get otherId() {
332
+ return this.#slots.otherId;
333
+ }
330
334
  /**
331
335
  * This axis's Other bucket, shared by every mark on it that has one.
332
336
  *
@@ -621,8 +625,10 @@ export class BandScaleState {
621
625
  /**
622
626
  * Get D3 band scale configured for rendering
623
627
  */
624
- getBaseScale(axis, innerWidthOrHeight) {
625
- this.#sharedScale.range(axis === 'x' ? [0, innerWidthOrHeight] : [innerWidthOrHeight, 0]);
628
+ getBaseScale(_axis, innerWidthOrHeight) {
629
+ // The same on both axes, not reversed on y: slot 0 is at the top, as in
630
+ // the shader (slotPosition).
631
+ this.#sharedScale.range([0, innerWidthOrHeight]);
626
632
  if (this.#paddingInner !== null && this.#paddingOuter !== null) {
627
633
  this.#sharedScale.paddingInner(this.#paddingInner).paddingOuter(this.#paddingOuter);
628
634
  }
@@ -759,23 +765,25 @@ export class BandScaleState {
759
765
  /**
760
766
  * Where a tick's label sits now, in pixels along the axis: the centre of its
761
767
  * category's current slot, so it travels with the bar. Slot 0 is at the start
762
- * of the range on x and at the bottom on y, as in the shader and d3's
763
- * reversed range. Per frame, O(1).
768
+ * of the range on both axes: the left on x, the top on y, as in the shader
769
+ * and d3's range. Per frame, O(1).
764
770
  */
765
- tickPosition(key, rangeSize, axis) {
766
- return this.slotPosition(this.tickSlot(key), rangeSize, axis);
771
+ tickPosition(key, rangeSize) {
772
+ return this.slotPosition(this.tickSlot(key), rangeSize);
767
773
  }
768
774
  /**
769
775
  * The same, for a caller that already has the slot.
770
776
  *
771
777
  * A consumer that has to test the slot first — an SVG label, which must not
772
778
  * draw a category the axis is not displaying — would otherwise ask for it
773
- * twice per item per frame. The axis flip lives here rather than being
774
- * re-derived by such a caller.
779
+ * twice per item per frame.
780
+ *
781
+ * The same on both axes. A y band axis reads top-down, like a list: slot 0
782
+ * at the top, so a ranking and an explicit domain need no reversing, and
783
+ * the exit one past the last slot is the end of the list on either axis.
775
784
  */
776
- slotPosition(slot, rangeSize, axis) {
777
- const centre = this.slotCenter(slot, rangeSize);
778
- return axis === 'y' ? rangeSize - centre : centre;
785
+ slotPosition(slot, rangeSize) {
786
+ return this.slotCenter(slot, rangeSize);
779
787
  }
780
788
  /**
781
789
  * Centre of a (possibly fractional) slot, measured from the start of the
@@ -854,7 +862,9 @@ export class BandScaleState {
854
862
  * Returns the 0-based index of the band under the cursor, or null if
855
863
  * no bands exist.
856
864
  */
857
- invertScreenToData(screenVal, rangeSize, isYAxis = false) {
865
+ invertScreenToData(screenVal, rangeSize,
866
+ // Band slots run from the start of the range on both axes (slotPosition).
867
+ _isYAxis = false) {
858
868
  const config = this.getConfig();
859
869
  const n = config.categoryCount;
860
870
  if (n === 0)
@@ -863,10 +873,7 @@ export class BandScaleState {
863
873
  if (stepSize === 0)
864
874
  return null;
865
875
  const offset = config.paddingOuter * stepSize;
866
- // GPU band scale renders Y categories bottom-to-top (matching D3),
867
- // so flip screen coordinate for Y axis inversion.
868
- const pos = isYAxis ? rangeSize - screenVal : screenVal;
869
- const bandIndex = Math.floor((pos - offset) / stepSize);
876
+ const bandIndex = Math.floor((screenVal - offset) / stepSize);
870
877
  // Clamped to a WHOLE slot. Mid-transition the count is fractional, so a
871
878
  // bare `n - 1` hands back a fraction for a pointer past the last band —
872
879
  // and a fractional slot indexes nothing, so idAtSlot and the domain both
@@ -135,6 +135,8 @@ interface BaseMarkSpec {
135
135
  selection?: string;
136
136
  /** How the mark responds to selections in its group; see BaseMarkProps. Default 'auto'. */
137
137
  selectionResponse?: 'auto' | 'filter';
138
+ /** What the mark does with NULL in its categorical channels; see BaseMarkProps. Default 'keep'. */
139
+ nulls?: 'keep' | 'drop';
138
140
  scheme?: ColorScheme;
139
141
  limit?: number;
140
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) {
@@ -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
  },
@@ -303,9 +304,11 @@ export const DENSE_ROW_LIMIT = 10_000;
303
304
  */
304
305
  function padIds(id, dim) {
305
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}` : '';
306
309
  if (!present)
307
- return `(SELECT "id" FROM ${quoteTable(dim)})`;
308
- return (`(SELECT "id" FROM ${quoteTable(present)} UNION ALL ` +
310
+ return `(SELECT "id" FROM ${quoteTable(dim)}${notNull})`;
311
+ return (`(SELECT "id" FROM ${quoteTable(present)}${notNull} UNION ALL ` +
309
312
  `SELECT "id" FROM ${quoteTable(dim)} WHERE "label" IS NOT NULL)`);
310
313
  }
311
314
  /**
@@ -806,7 +809,9 @@ export function stackX(options = {}) {
806
809
  return stackSQL('x', options);
807
810
  }
808
811
  /** Resolve band (group) / value column aliases for a stack direction, validated against the select list. */
809
- 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) {
810
815
  const bandAxis = direction === 'y' ? 'x' : 'y';
811
816
  const band = config?.[bandAxis];
812
817
  const value = config?.[direction];
@@ -820,20 +825,31 @@ function resolveStackColumns(aliases, config, direction) {
820
825
  }
821
826
  const fill = config?.fill;
822
827
  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
- }
828
+ if (!series)
829
+ onNoSeries(aliases);
833
830
  return { band, value, series };
834
831
  }
835
- 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) {
836
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
+ };
837
853
  return {
838
854
  stage: 'sql',
839
855
  type: 'stack',
@@ -844,7 +860,7 @@ function stackSQL(direction, options) {
844
860
  channels: [loName, hiName],
845
861
  apply(query, config) {
846
862
  const aliases = getSelectAliases(query);
847
- const { band, value, series } = resolveStackColumns(aliases, config, direction);
863
+ const { band, value, series } = resolveStackColumns(aliases, config, direction, onNoSeries);
848
864
  const valueCol = column(value);
849
865
  const bandCol = column(band);
850
866
  const notNaN = sqlTag `NOT isnan(${valueCol})`;
@@ -1185,9 +1201,7 @@ export function withImplicitStack(transforms, hasExplicitBounds, direction) {
1185
1201
  ? direction === 'y'
1186
1202
  ? stackY({ engine: 'js' })
1187
1203
  : stackX({ engine: 'js' })
1188
- : direction === 'y'
1189
- ? stackY()
1190
- : stackX();
1204
+ : stackSQL(direction, {}, true);
1191
1205
  return [...transforms, implicitStack];
1192
1206
  }
1193
1207
  export { TransformCompiler } from './TransformCompiler';