@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.
- package/README.md +2 -0
- package/dist/components/axes/AxisBottom.svelte +23 -6
- package/dist/components/axes/AxisBottom.svelte.d.ts +10 -0
- package/dist/components/axes/AxisLeft.svelte +77 -8
- package/dist/components/axes/AxisLeft.svelte.d.ts +17 -0
- package/dist/components/axes/BandAxisTicks.svelte +1 -1
- package/dist/components/axes/axis-title.d.ts +30 -0
- package/dist/components/axes/axis-title.js +77 -0
- package/dist/components/charts/ChartCell.svelte +1 -1
- package/dist/components/charts/ChartContainer.svelte +10 -2
- package/dist/components/charts/ChartContainer.svelte.d.ts +7 -0
- package/dist/components/guides/legend/ContinuousColorLegend.svelte +7 -1
- package/dist/components/guides/legend/OrdinalColorLegend.svelte +8 -5
- package/dist/components/marks/AreaMarkX.svelte +2 -0
- package/dist/components/marks/AreaMarkY.svelte +2 -0
- package/dist/components/marks/BarMarkX.svelte +13 -1
- package/dist/components/marks/BarMarkY.svelte +13 -1
- package/dist/components/marks/DotsMark.svelte +2 -0
- package/dist/components/marks/LineMark.svelte +5 -1
- package/dist/components/marks/ParallelCoordinatesMark.svelte +4 -0
- package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +2 -0
- package/dist/components/marks/RectMark.svelte +2 -0
- package/dist/components/marks/RectXMark.svelte +2 -0
- package/dist/components/marks/RectYMark.svelte +2 -0
- package/dist/components/marks/RuleMarkX.svelte +2 -0
- package/dist/components/marks/RuleMarkY.svelte +2 -0
- package/dist/components/marks/TextMark.svelte +8 -9
- package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +6 -1
- package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +187 -18
- package/dist/components/marks/lifecycle/domain-queries.d.ts +3 -1
- package/dist/components/marks/lifecycle/domain-queries.js +11 -6
- package/dist/components/marks/lifecycle/domain-registration.js +2 -6
- package/dist/components/marks/lifecycle/other-rows.svelte.d.ts +104 -0
- package/dist/components/marks/lifecycle/other-rows.svelte.js +193 -0
- package/dist/components/marks/lifecycle/other-split.d.ts +80 -0
- package/dist/components/marks/lifecycle/other-split.js +112 -0
- package/dist/components/marks/lifecycle/query-builder.d.ts +26 -1
- package/dist/components/marks/lifecycle/query-builder.js +25 -5
- package/dist/components/marks/utils/reactive-config.d.ts +1 -1
- package/dist/components/marks/utils/reactive-config.js +6 -0
- package/dist/components/marks/utils/tooltip-helpers.d.ts +4 -1
- package/dist/components/marks/utils/tooltip-helpers.js +2 -1
- package/dist/components/rendering/BaseGPURenderer.svelte.d.ts +3 -1
- package/dist/components/rendering/BaseGPURenderer.svelte.js +4 -2
- package/dist/components/rendering/Rects.svelte +117 -23
- package/dist/components/rendering/Rects.svelte.d.ts +7 -0
- package/dist/components/rendering/activity-reads.d.ts +18 -0
- package/dist/components/rendering/activity-reads.js +19 -0
- package/dist/config/ChartConfig.svelte.d.ts +3 -1
- package/dist/gpu/MorphedChannels.d.ts +6 -32
- package/dist/gpu/MorphedChannels.js +45 -3
- package/dist/gpu/ScaleTemplateSystem.js +25 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/interactors/Interval1D.svelte +6 -1
- package/dist/interactors/Interval2D.svelte +6 -1
- package/dist/interactors/ParallelAxisSource.svelte +10 -1
- package/dist/interactors/Toggle.svelte +32 -3
- package/dist/interactors/Tooltip.svelte +138 -41
- package/dist/interactors/TooltipManager.svelte.d.ts +20 -5
- package/dist/interactors/TooltipManager.svelte.js +32 -7
- package/dist/interactors/other-tooltip.d.ts +16 -0
- package/dist/interactors/other-tooltip.js +22 -0
- package/dist/query-engine/FieldActivity.svelte.d.ts +50 -1
- package/dist/query-engine/FieldActivity.svelte.js +176 -5
- package/dist/query-engine/FilterGroup.svelte.d.ts +23 -1
- package/dist/query-engine/FilterGroup.svelte.js +43 -3
- package/dist/query-engine/FilterGroupRegistry.svelte.d.ts +31 -3
- package/dist/query-engine/FilterGroupRegistry.svelte.js +62 -15
- package/dist/query-engine/QueryEngine.js +26 -6
- package/dist/query-engine/categorical-identity.d.ts +15 -0
- package/dist/query-engine/categorical-identity.js +15 -0
- package/dist/query-engine/preagg/plan.d.ts +5 -0
- package/dist/query-engine/preagg/plan.js +31 -7
- package/dist/query-engine/toggle-predicate.d.ts +21 -0
- package/dist/query-engine/toggle-predicate.js +29 -1
- package/dist/scales/ActivityTable.d.ts +5 -1
- package/dist/scales/ActivityTable.js +12 -3
- package/dist/scales/BandScaleState.svelte.d.ts +13 -8
- package/dist/scales/BandScaleState.svelte.js +23 -16
- package/dist/spec/types.d.ts +2 -0
- package/dist/theme.css +5 -4
- package/dist/transforms/TransformCompiler.d.ts +37 -2
- package/dist/transforms/TransformCompiler.js +44 -1
- package/dist/transforms/index.js +32 -18
- package/dist/types.d.ts +41 -0
- 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 (
|
|
284
|
-
// be expressed, so this query goes to the base
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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
|
|
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 <
|
|
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(
|
|
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
|
|
407
|
-
*
|
|
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
|
|
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.
|
|
416
|
-
*
|
|
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
|
|
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,
|
|
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(
|
|
625
|
-
|
|
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
|
|
763
|
-
*
|
|
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
|
|
766
|
-
return this.slotPosition(this.tickSlot(key), rangeSize
|
|
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.
|
|
774
|
-
*
|
|
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
|
|
777
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
package/dist/spec/types.d.ts
CHANGED
|
@@ -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
|
|
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) {
|
package/dist/transforms/index.js
CHANGED
|
@@ -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
|
-
|
|
824
|
-
|
|
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
|
-
|
|
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
|
|
1189
|
-
? stackY()
|
|
1190
|
-
: stackX();
|
|
1204
|
+
: stackSQL(direction, {}, true);
|
|
1191
1205
|
return [...transforms, implicitStack];
|
|
1192
1206
|
}
|
|
1193
1207
|
export { TransformCompiler } from './TransformCompiler';
|