@fjader/svelte 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +64 -25
- package/dist/components/axes/BandAxisTicks.svelte.d.ts +5 -5
- 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 +12 -3
- package/dist/components/charts/ChartContainer.svelte.d.ts +7 -0
- package/dist/components/guides/legend/ContinuousColorLegend.svelte +9 -2
- package/dist/components/guides/legend/OrdinalColorLegend.svelte +96 -35
- package/dist/components/marks/AreaMarkX.svelte +21 -6
- package/dist/components/marks/AreaMarkY.svelte +21 -6
- package/dist/components/marks/BarMarkX.svelte +16 -1
- package/dist/components/marks/BarMarkY.svelte +16 -1
- package/dist/components/marks/DotsMark.svelte +5 -0
- package/dist/components/marks/LineMark.svelte +8 -1
- package/dist/components/marks/ParallelCoordinatesMark.svelte +10 -0
- package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +5 -0
- package/dist/components/marks/RectMark.svelte +5 -0
- package/dist/components/marks/RectXMark.svelte +5 -0
- package/dist/components/marks/RectYMark.svelte +5 -0
- package/dist/components/marks/RuleMarkX.svelte +12 -2
- package/dist/components/marks/RuleMarkY.svelte +12 -2
- package/dist/components/marks/TextMark.svelte +76 -13
- package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +21 -2
- package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +300 -42
- package/dist/components/marks/lifecycle/domain-queries.d.ts +3 -1
- package/dist/components/marks/lifecycle/domain-queries.js +22 -15
- 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/lifecycle/transform-scale-key.d.ts +16 -0
- package/dist/components/marks/lifecycle/transform-scale-key.js +27 -0
- package/dist/components/marks/utils/area-outline.d.ts +24 -0
- package/dist/components/marks/utils/area-outline.js +10 -0
- package/dist/components/marks/utils/reactive-config.d.ts +1 -1
- package/dist/components/marks/utils/reactive-config.js +9 -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/Areas.svelte +30 -6
- package/dist/components/rendering/Areas.svelte.d.ts +2 -0
- package/dist/components/rendering/BaseGPURenderer.svelte.d.ts +3 -1
- package/dist/components/rendering/BaseGPURenderer.svelte.js +8 -3
- package/dist/components/rendering/Dots.svelte +24 -3
- package/dist/components/rendering/Dots.svelte.d.ts +2 -0
- package/dist/components/rendering/GridLine.svelte +2 -1
- package/dist/components/rendering/Lines.svelte +49 -7
- package/dist/components/rendering/Lines.svelte.d.ts +9 -0
- package/dist/components/rendering/ParallelLines.svelte +26 -7
- package/dist/components/rendering/ParallelLines.svelte.d.ts +2 -0
- package/dist/components/rendering/Rects.svelte +129 -20
- package/dist/components/rendering/Rects.svelte.d.ts +9 -0
- package/dist/components/rendering/RuleX.svelte +20 -5
- package/dist/components/rendering/RuleX.svelte.d.ts +2 -0
- package/dist/components/rendering/RuleY.svelte +20 -5
- package/dist/components/rendering/RuleY.svelte.d.ts +2 -0
- package/dist/components/rendering/activity-reads.d.ts +111 -0
- package/dist/components/rendering/activity-reads.js +154 -0
- package/dist/components/rendering/uniform-sizes.d.ts +26 -0
- package/dist/components/rendering/uniform-sizes.js +28 -0
- package/dist/config/ChartConfig.svelte.d.ts +5 -1
- package/dist/gpu/ColorScaleShaderLib.d.ts +13 -1
- package/dist/gpu/ColorScaleShaderLib.js +56 -5
- package/dist/gpu/GPUCoordinator.svelte.d.ts +8 -0
- package/dist/gpu/GPUCoordinator.svelte.js +68 -32
- package/dist/gpu/MorphedChannels.d.ts +6 -32
- package/dist/gpu/MorphedChannels.js +45 -3
- package/dist/gpu/ResolveShader.d.ts +2 -2
- package/dist/gpu/ResolveShader.js +2 -2
- package/dist/gpu/ScaleTemplateSystem.d.ts +19 -0
- package/dist/gpu/ScaleTemplateSystem.js +111 -11
- package/dist/gpu/SizeScaleShaderLib.d.ts +4 -4
- package/dist/gpu/SizeScaleShaderLib.js +4 -4
- package/dist/gpu/identity-slot-packing.d.ts +157 -40
- package/dist/gpu/identity-slot-packing.js +262 -62
- package/dist/gpu/tracked-scales.d.ts +14 -0
- package/dist/gpu/tracked-scales.js +18 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/interactors/BandLabelToggle.svelte +5 -9
- package/dist/interactors/BandLabelToggle.svelte.d.ts +3 -3
- package/dist/interactors/DerivedInterval1D.svelte +26 -54
- package/dist/interactors/Interval1D.svelte +8 -2
- package/dist/interactors/Interval2D.svelte +8 -2
- package/dist/interactors/ParallelAxisSource.svelte +14 -5
- package/dist/interactors/ParallelAxisSource.svelte.d.ts +2 -1
- package/dist/interactors/ParallelBrushes.svelte +8 -9
- package/dist/interactors/ParallelBrushes.svelte.d.ts +3 -3
- package/dist/interactors/Toggle.svelte +48 -38
- package/dist/interactors/Toggle.svelte.d.ts +4 -3
- package/dist/interactors/Tooltip.svelte +140 -42
- package/dist/interactors/TooltipManager.svelte.d.ts +20 -5
- package/dist/interactors/TooltipManager.svelte.js +40 -10
- package/dist/interactors/other-tooltip.d.ts +16 -0
- package/dist/interactors/other-tooltip.js +22 -0
- package/dist/interactors/relay-clauses.d.ts +23 -0
- package/dist/interactors/relay-clauses.js +60 -0
- package/dist/query-engine/CrossfilterSelection.svelte.d.ts +88 -15
- package/dist/query-engine/CrossfilterSelection.svelte.js +94 -20
- package/dist/query-engine/FieldActivity.svelte.d.ts +128 -0
- package/dist/query-engine/FieldActivity.svelte.js +378 -0
- package/dist/query-engine/FilterGroup.svelte.d.ts +76 -30
- package/dist/query-engine/FilterGroup.svelte.js +223 -124
- package/dist/query-engine/FilterGroupRegistry.svelte.d.ts +73 -3
- package/dist/query-engine/FilterGroupRegistry.svelte.js +161 -12
- package/dist/query-engine/MarkHandle.svelte.d.ts +9 -1
- package/dist/query-engine/MarkHandle.svelte.js +12 -2
- package/dist/query-engine/QueryEngine.d.ts +52 -16
- package/dist/query-engine/QueryEngine.js +116 -34
- package/dist/query-engine/categorical-identity.d.ts +114 -27
- package/dist/query-engine/categorical-identity.js +225 -51
- package/dist/query-engine/index.d.ts +2 -1
- package/dist/query-engine/index.js +1 -0
- package/dist/query-engine/preagg/Preagg.d.ts +1 -1
- package/dist/query-engine/preagg/Preagg.js +11 -9
- package/dist/query-engine/preagg/plan.d.ts +14 -5
- package/dist/query-engine/preagg/plan.js +44 -18
- package/dist/query-engine/toggle-predicate.d.ts +21 -0
- package/dist/query-engine/toggle-predicate.js +41 -2
- package/dist/scales/ActivityTable.d.ts +103 -0
- package/dist/scales/ActivityTable.js +220 -0
- package/dist/scales/BandScaleState.svelte.d.ts +41 -15
- package/dist/scales/BandScaleState.svelte.js +78 -36
- package/dist/scales/ChartScales.svelte.d.ts +2 -2
- package/dist/scales/ColorScaleState.svelte.d.ts +36 -8
- package/dist/scales/ColorScaleState.svelte.js +118 -24
- package/dist/scales/DomainResolver.d.ts +2 -0
- package/dist/scales/DomainResolver.js +15 -8
- package/dist/scales/ScaleRegistry.svelte.d.ts +9 -0
- package/dist/scales/ScaleRegistry.svelte.js +22 -1
- package/dist/scales/TimeScaleState.svelte.js +7 -1
- package/dist/spec/index.js +1 -0
- package/dist/spec/types.d.ts +4 -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.d.ts +27 -0
- package/dist/transforms/index.js +127 -19
- package/dist/types.d.ts +71 -0
- package/package.json +3 -2
|
@@ -35,8 +35,8 @@ export class TooltipManager {
|
|
|
35
35
|
currentChannelFields = $state(null);
|
|
36
36
|
currentDisplayFields = $state(null);
|
|
37
37
|
visible = $state(false);
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
pointerClientX = $state(0);
|
|
39
|
+
pointerClientY = $state(0);
|
|
40
40
|
matchedRadius = $state(null);
|
|
41
41
|
// Focused point getters — used by Tooltip and HoverIndicator
|
|
42
42
|
get focusedPoint() {
|
|
@@ -45,6 +45,12 @@ export class TooltipManager {
|
|
|
45
45
|
get focusedMatch() {
|
|
46
46
|
return this.matchedPoints[this.focusedIndex] ?? null;
|
|
47
47
|
}
|
|
48
|
+
/** The focused point's Other numbers, or null where it is not an Other bar. */
|
|
49
|
+
get focusedOtherStats() {
|
|
50
|
+
const point = this.focusedPoint;
|
|
51
|
+
const stats = this.currentMarkId ? this.#marks.get(this.currentMarkId)?.otherStats : null;
|
|
52
|
+
return point && stats ? stats(point) : null;
|
|
53
|
+
}
|
|
48
54
|
// Backwards-compat getters
|
|
49
55
|
get currentPoint() {
|
|
50
56
|
return this.currentPoints[0] ?? null;
|
|
@@ -81,6 +87,15 @@ export class TooltipManager {
|
|
|
81
87
|
this.#pendingY = yData;
|
|
82
88
|
this.#hasPending = true;
|
|
83
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Run the hover query again at the last position, if the pointer is still
|
|
92
|
+
* in the chart: a mark's tooltip table was rewritten under a still pointer,
|
|
93
|
+
* so the row it shows is the old data's. The next frame runs it.
|
|
94
|
+
*/
|
|
95
|
+
refresh() {
|
|
96
|
+
if (this.#cursorActive)
|
|
97
|
+
this.#hasPending = true;
|
|
98
|
+
}
|
|
84
99
|
/**
|
|
85
100
|
* Cycle the focused point index by delta. Wraps around.
|
|
86
101
|
* Remembers the focused category so it persists across cursor moves.
|
|
@@ -129,12 +144,13 @@ export class TooltipManager {
|
|
|
129
144
|
return this.#chartConfig.getMarkLOD(this.currentMarkId) ?? null;
|
|
130
145
|
}
|
|
131
146
|
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
147
|
+
* The pointer's viewport (client) position, where the tooltip is placed: it
|
|
148
|
+
* renders in the top layer, outside the chart's clipping ancestors. Called from
|
|
149
|
+
* the chart's pointer move handler.
|
|
134
150
|
*/
|
|
135
|
-
|
|
136
|
-
this.
|
|
137
|
-
this.
|
|
151
|
+
updatePointerClient(x, y) {
|
|
152
|
+
this.pointerClientX = x;
|
|
153
|
+
this.pointerClientY = y;
|
|
138
154
|
}
|
|
139
155
|
// =========================================================================
|
|
140
156
|
// QUERY SCHEDULING (driven by animation loop)
|
|
@@ -440,7 +456,8 @@ export class TooltipManager {
|
|
|
440
456
|
if (index == null)
|
|
441
457
|
return null;
|
|
442
458
|
const category = this.#bandScale(axis)?.categoryAt(index);
|
|
443
|
-
|
|
459
|
+
// undefined is no category; null is the NULL category, which has a band.
|
|
460
|
+
if (category === undefined)
|
|
444
461
|
return null;
|
|
445
462
|
const pos = scale(category);
|
|
446
463
|
const bandwidth = 'bandwidth' in scale ? scale.bandwidth() : 0;
|
|
@@ -492,7 +509,8 @@ export class TooltipManager {
|
|
|
492
509
|
const { scaleTypes, bandDomains } = config;
|
|
493
510
|
// Band channel → category label
|
|
494
511
|
if ((key === 'x' || key === 'y') && scaleTypes[key] === 'band' && bandDomains?.[key]) {
|
|
495
|
-
|
|
512
|
+
const category = this.#bandScale(key)?.categoryAt(value);
|
|
513
|
+
return category === undefined ? value : category;
|
|
496
514
|
}
|
|
497
515
|
// Time offset → formatted datetime
|
|
498
516
|
if (key.startsWith(axis) && isTemporalScale(scaleTypes[axis]) && typeof value === 'number') {
|
|
@@ -560,6 +578,12 @@ export class TooltipManager {
|
|
|
560
578
|
let rangeInsertIndex = -1;
|
|
561
579
|
for (let i = 0; i < entries.length; i++) {
|
|
562
580
|
const [key, value] = entries[i];
|
|
581
|
+
// The facet cell's dim id, kept for a click (Toggle reads it to pick the
|
|
582
|
+
// clicked cell's Other); not displayed: `_` keys never are.
|
|
583
|
+
if (key === '__facet') {
|
|
584
|
+
point.__facetId = value;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
563
587
|
// Skip internal columns
|
|
564
588
|
if (key.startsWith('_') || key.startsWith('__dplot_'))
|
|
565
589
|
continue;
|
|
@@ -577,7 +601,13 @@ export class TooltipManager {
|
|
|
577
601
|
// field name to build its filter tuples, so this is also what makes a
|
|
578
602
|
// click on a reordered band axis filter the right category.
|
|
579
603
|
if ((key === 'x' || key === 'y') && scaleTypes[key] === 'band' && bandDomains?.[key]) {
|
|
580
|
-
|
|
604
|
+
// Not `??`: the NULL category decodes to null, which must stay null
|
|
605
|
+
// (Toggle selects it) rather than fall back to the raw id.
|
|
606
|
+
const category = this.#bandScale(key)?.categoryAt(value);
|
|
607
|
+
point[fieldName] = category === undefined ? value : category;
|
|
608
|
+
// The raw id beside it: a label can't name an Other bucket (several
|
|
609
|
+
// share one, and it is no value), so a click on Other reads this.
|
|
610
|
+
point[key === 'x' ? '__bandIdX' : '__bandIdY'] = value;
|
|
581
611
|
continue;
|
|
582
612
|
}
|
|
583
613
|
// Resolve time offset to formatted datetime string
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rows an Other bar's tooltip adds: how many categories it holds, and,
|
|
3
|
+
* while a selection reaches into it, how much of it is selected.
|
|
4
|
+
*/
|
|
5
|
+
import type { OtherRowStats } from '../components/marks/lifecycle/other-rows.svelte';
|
|
6
|
+
export interface OtherTooltipRow {
|
|
7
|
+
key: string;
|
|
8
|
+
value: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* `format` writes a sum the way the chart writes its value axis. Rows:
|
|
12
|
+
* - categories: "41", or "3 of 41" while a selection is active;
|
|
13
|
+
* - selected: "1,234 of 5,678 (22%)", only while a selection is active.
|
|
14
|
+
* A share that rounds to 0 but is not 0 reads "<1%", so a sliver is not "0%".
|
|
15
|
+
*/
|
|
16
|
+
export declare function otherTooltipRows(stats: OtherRowStats, format: (value: number) => string): OtherTooltipRow[];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rows an Other bar's tooltip adds: how many categories it holds, and,
|
|
3
|
+
* while a selection reaches into it, how much of it is selected.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* `format` writes a sum the way the chart writes its value axis. Rows:
|
|
7
|
+
* - categories: "41", or "3 of 41" while a selection is active;
|
|
8
|
+
* - selected: "1,234 of 5,678 (22%)", only while a selection is active.
|
|
9
|
+
* A share that rounds to 0 but is not 0 reads "<1%", so a sliver is not "0%".
|
|
10
|
+
*/
|
|
11
|
+
export function otherTooltipRows(stats, format) {
|
|
12
|
+
const count = String(stats.memberCount);
|
|
13
|
+
if (!stats.active)
|
|
14
|
+
return [{ key: 'categories', value: count }];
|
|
15
|
+
const percent = stats.total === 0 ? 0 : (100 * stats.picked) / stats.total;
|
|
16
|
+
const rounded = Math.round(percent);
|
|
17
|
+
const share = rounded === 0 && stats.picked > 0 ? '<1%' : `${rounded}%`;
|
|
18
|
+
return [
|
|
19
|
+
{ key: 'categories', value: `${stats.pickedCount} of ${count}` },
|
|
20
|
+
{ key: 'selected', value: `${format(stats.picked)} of ${format(stats.total)} (${share})` }
|
|
21
|
+
];
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { FilterGroup } from '../query-engine/FilterGroup.svelte';
|
|
2
|
+
/**
|
|
3
|
+
* Mirrors one group's clauses into another, for DerivedInterval1D: every
|
|
4
|
+
* clause but the range brush on the derived axis, which the interactor
|
|
5
|
+
* replaces with its shifted copy.
|
|
6
|
+
*
|
|
7
|
+
* `sync` runs on every change in the source group, which is every brush frame
|
|
8
|
+
* there. It relays the source's clause objects themselves, so an unchanged
|
|
9
|
+
* one is the object the target already holds, which it ignores: a brush in the
|
|
10
|
+
* source re-queries nothing in the target for the clauses it did not touch.
|
|
11
|
+
*/
|
|
12
|
+
export declare class ClauseRelay {
|
|
13
|
+
#private;
|
|
14
|
+
constructor(source: FilterGroup, target: FilterGroup);
|
|
15
|
+
/**
|
|
16
|
+
* Relay the source's clauses (all of them but the range on `axisColumn`),
|
|
17
|
+
* and remove relayed clauses the source no longer has. With `relay` false,
|
|
18
|
+
* only the removal. Call untracked: it reads the target group it writes.
|
|
19
|
+
*/
|
|
20
|
+
sync(relay: boolean, axisColumn: string): void;
|
|
21
|
+
/** Remove every relayed clause, for an interactor going away. */
|
|
22
|
+
clear(): void;
|
|
23
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirrors one group's clauses into another, for DerivedInterval1D: every
|
|
3
|
+
* clause but the range brush on the derived axis, which the interactor
|
|
4
|
+
* replaces with its shifted copy.
|
|
5
|
+
*
|
|
6
|
+
* `sync` runs on every change in the source group, which is every brush frame
|
|
7
|
+
* there. It relays the source's clause objects themselves, so an unchanged
|
|
8
|
+
* one is the object the target already holds, which it ignores: a brush in the
|
|
9
|
+
* source re-queries nothing in the target for the clauses it did not touch.
|
|
10
|
+
*/
|
|
11
|
+
export class ClauseRelay {
|
|
12
|
+
#source;
|
|
13
|
+
#target;
|
|
14
|
+
#relayed = new Set();
|
|
15
|
+
/** Reused as the next pass's key set, swapped with #relayed after each. */
|
|
16
|
+
#current = new Set();
|
|
17
|
+
#axisColumn = '';
|
|
18
|
+
constructor(source, target) {
|
|
19
|
+
this.#source = source;
|
|
20
|
+
this.#target = target;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Relay the source's clauses (all of them but the range on `axisColumn`),
|
|
24
|
+
* and remove relayed clauses the source no longer has. With `relay` false,
|
|
25
|
+
* only the removal. Call untracked: it reads the target group it writes.
|
|
26
|
+
*/
|
|
27
|
+
sync(relay, axisColumn) {
|
|
28
|
+
this.#current.clear();
|
|
29
|
+
if (relay) {
|
|
30
|
+
this.#axisColumn = axisColumn;
|
|
31
|
+
this.#source.selection.forEach(this.#visit);
|
|
32
|
+
}
|
|
33
|
+
for (const key of this.#relayed) {
|
|
34
|
+
if (!this.#current.has(key))
|
|
35
|
+
this.#target.removeManual(key);
|
|
36
|
+
}
|
|
37
|
+
const done = this.#relayed;
|
|
38
|
+
this.#relayed = this.#current;
|
|
39
|
+
this.#current = done;
|
|
40
|
+
}
|
|
41
|
+
/** Remove every relayed clause, for an interactor going away. */
|
|
42
|
+
clear() {
|
|
43
|
+
for (const key of this.#relayed)
|
|
44
|
+
this.#target.removeManual(key);
|
|
45
|
+
this.#relayed.clear();
|
|
46
|
+
}
|
|
47
|
+
#visit = (clause, sourceId) => {
|
|
48
|
+
const { schema } = clause;
|
|
49
|
+
if (schema?.kind === 'range' && schema.column === this.#axisColumn)
|
|
50
|
+
return;
|
|
51
|
+
if (!clause.predicate)
|
|
52
|
+
return;
|
|
53
|
+
const key = `relay:${sourceId}`;
|
|
54
|
+
this.#current.add(key);
|
|
55
|
+
// The clause itself: nothing in it depends on its group, so a relayed
|
|
56
|
+
// toggle routes and dims in the target as a native one would, and an
|
|
57
|
+
// unchanged clause is the same object, which the target ignores.
|
|
58
|
+
this.#target.relayClause(key, clause);
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -2,12 +2,19 @@
|
|
|
2
2
|
* CrossfilterSelection — Svelte 5-native crossfilter predicate engine.
|
|
3
3
|
*
|
|
4
4
|
* Replaces Mosaic's Selection.intersect({ cross: true }) with a reactive
|
|
5
|
-
* $state Map
|
|
6
|
-
*
|
|
5
|
+
* $state Map of clauses, plus the registrations of the sources that write
|
|
6
|
+
* them. Marks derive their filter via predicate(route): every clause that
|
|
7
|
+
* filters the mark.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* WHICH CLAUSES FILTER A MARK. The mark decides, not the control that wrote
|
|
10
|
+
* the clause. A clause filters a mark unless:
|
|
11
|
+
* - it is a categorical selection on a field the mark reads selection
|
|
12
|
+
* activity for (the mark dims by it instead), or
|
|
13
|
+
* - the mark's chart holds a registration of the clause's source that opted
|
|
14
|
+
* into skipping its chart (crossfilter: a brush does not filter its own
|
|
15
|
+
* chart).
|
|
16
|
+
* Both are computed when asked, from the clause, the mark's route and the
|
|
17
|
+
* live registrations: nothing is cached, so nothing goes stale.
|
|
11
18
|
*/
|
|
12
19
|
import type { ExprNode } from '@uwdata/mosaic-sql';
|
|
13
20
|
export type FilterExpr = ExprNode | string | boolean;
|
|
@@ -18,52 +25,118 @@ export interface FilterSchema {
|
|
|
18
25
|
values?: unknown[];
|
|
19
26
|
[key: string]: unknown;
|
|
20
27
|
}
|
|
21
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* A single-field categorical selection, as a clause states it: the field's
|
|
30
|
+
* identity key and the selected values. Brushes, tuples and manual clauses have
|
|
31
|
+
* none.
|
|
32
|
+
*/
|
|
33
|
+
export interface CategoricalSelection {
|
|
34
|
+
key: string;
|
|
35
|
+
values: ReadonlySet<unknown>;
|
|
36
|
+
}
|
|
37
|
+
/** A clause stored in the selection. Nothing in it depends on its group. */
|
|
22
38
|
export interface Clause {
|
|
23
39
|
predicate: FilterExpr;
|
|
24
|
-
excludeMarkIds: Set<string>;
|
|
25
40
|
value: unknown;
|
|
26
41
|
schema?: FilterSchema;
|
|
42
|
+
/** Set when the clause selects categories of one field; written with the clause. */
|
|
43
|
+
categorical?: CategoricalSelection | null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* What routing needs to know about a mark: the identity keys of the
|
|
47
|
+
* categorical fields its renderer reads selection activity for, its chart, and
|
|
48
|
+
* its selectionResponse. One object per mark, owned by its lifecycle and
|
|
49
|
+
* handed around by reference.
|
|
50
|
+
*
|
|
51
|
+
* A categorical selection on a field in `reads` dims the mark instead of
|
|
52
|
+
* filtering it. `chart` is the chart's ChartConfig, compared by reference: a
|
|
53
|
+
* faceted chart's cells have ids of their own, but share one config.
|
|
54
|
+
* `response` 'filter' makes every selection filter the mark, and a click
|
|
55
|
+
* control's (a discrete registration's) in its own chart too; brushes still
|
|
56
|
+
* skip their own chart.
|
|
57
|
+
*/
|
|
58
|
+
export interface MarkRoute {
|
|
59
|
+
readonly reads: readonly string[];
|
|
60
|
+
readonly chart: object | null;
|
|
61
|
+
readonly response?: 'auto' | 'filter';
|
|
27
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* What routing reads from one registration of a source: the chart it skips,
|
|
65
|
+
* if it opted into skipping one. Compared by reference with a mark's route.
|
|
66
|
+
*/
|
|
67
|
+
export interface SourceRegistration {
|
|
68
|
+
readonly chart?: object | null;
|
|
69
|
+
/**
|
|
70
|
+
* The source writes its clauses on a click (a Toggle, a legend, a band
|
|
71
|
+
* label), not under a dragged pointer. A 'filter' mark in the chart it
|
|
72
|
+
* skips is filtered by it all the same.
|
|
73
|
+
*/
|
|
74
|
+
readonly discrete?: boolean;
|
|
75
|
+
}
|
|
76
|
+
/** A mark with no route: it reads no activity and is in no chart. */
|
|
77
|
+
export declare const NO_ROUTE: MarkRoute;
|
|
28
78
|
/** A clause with its source identifier, for external consumers. */
|
|
29
79
|
export interface ClauseEntry extends Clause {
|
|
30
80
|
sourceId: string;
|
|
31
81
|
}
|
|
32
82
|
export declare class CrossfilterSelection {
|
|
33
83
|
#private;
|
|
84
|
+
/** Add a registration of `sourceId`. */
|
|
85
|
+
addRegistration(sourceId: string, registration: SourceRegistration): void;
|
|
86
|
+
/** Remove a registration of `sourceId`; the last one removes the source. */
|
|
87
|
+
removeRegistration(sourceId: string, registration: SourceRegistration): void;
|
|
88
|
+
/** The live registrations of `sourceId`, or undefined when it has none. */
|
|
89
|
+
registrationsOf(sourceId: string): ReadonlySet<SourceRegistration> | undefined;
|
|
34
90
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
91
|
+
* Whether a clause of `sourceId` filters the mark with `route`. `key` is
|
|
92
|
+
* the clause's categorical field (its identity key), or null for a brush,
|
|
93
|
+
* a tuple or a manual clause; before the source has written a clause, its
|
|
94
|
+
* registrations' key, which preagg activation needs on hover.
|
|
95
|
+
*
|
|
96
|
+
* A plain loop over the registrations, which are one to three: no closure,
|
|
97
|
+
* no allocation, as it runs per mark per clause on every brush frame.
|
|
38
98
|
*/
|
|
39
|
-
|
|
99
|
+
filters(route: MarkRoute, sourceId: string, key: string | null): boolean;
|
|
100
|
+
/** Whether `clause`, written by `sourceId`, filters the mark with `route`. */
|
|
101
|
+
applies(sourceId: string, clause: Clause, route: MarkRoute): boolean;
|
|
102
|
+
/** Every predicate that filters the mark with `route`. */
|
|
103
|
+
predicate(route: MarkRoute): FilterExpr[];
|
|
40
104
|
/**
|
|
41
105
|
* Update or remove a clause.
|
|
42
106
|
* Pass null to remove (clear) the clause for the given source.
|
|
107
|
+
*
|
|
108
|
+
* Returns the clause it replaced or removed, so the caller can re-query the
|
|
109
|
+
* marks either one applies to.
|
|
43
110
|
*/
|
|
44
|
-
update(sourceId: string, clause: Clause | null):
|
|
111
|
+
update(sourceId: string, clause: Clause | null): Clause | undefined;
|
|
45
112
|
/** Whether a given source has an active (non-null) clause. */
|
|
46
113
|
isActive(sourceId: string): boolean;
|
|
47
114
|
/** O(1) lookup of a clause by source ID. */
|
|
48
115
|
get(sourceId: string): Clause | undefined;
|
|
116
|
+
/**
|
|
117
|
+
* Visit every active clause: the stored objects themselves, not copies, so a
|
|
118
|
+
* caller can tell an unchanged clause by reference. Reactive like `items`,
|
|
119
|
+
* without building an entry object per clause.
|
|
120
|
+
*/
|
|
121
|
+
forEach(visit: (clause: Clause, sourceId: string) => void): void;
|
|
49
122
|
/** Reactive list of all active clauses with their source IDs. */
|
|
50
123
|
get items(): ClauseEntry[];
|
|
51
124
|
/**
|
|
52
125
|
* The clause objects a preagg table bakes in, in map insertion order.
|
|
53
126
|
* Stored at build time so bakedClausesMatch() can compare by reference.
|
|
54
127
|
*/
|
|
55
|
-
bakedClauses(
|
|
128
|
+
bakedClauses(route: MarkRoute, activeSourceId: string): Clause[];
|
|
56
129
|
/**
|
|
57
130
|
* The baked predicates sorted by source ID, so the same filters always
|
|
58
131
|
* produce the same preagg SQL whatever order their clauses were added in.
|
|
59
132
|
*/
|
|
60
|
-
bakedPredicates(
|
|
133
|
+
bakedPredicates(route: MarkRoute, activeSourceId: string): FilterExpr[];
|
|
61
134
|
/**
|
|
62
135
|
* Whether the clauses baked into a preagg table are still the ones in force.
|
|
63
136
|
* Every filter change replaces its Clause object, so an unchanged filter
|
|
64
137
|
* keeps its reference. Runs every brush frame, so it allocates nothing.
|
|
65
138
|
*/
|
|
66
|
-
bakedClausesMatch(
|
|
139
|
+
bakedClausesMatch(route: MarkRoute, activeSourceId: string, then: Clause[]): boolean;
|
|
67
140
|
/** Number of active clauses. */
|
|
68
141
|
get activeCount(): number;
|
|
69
142
|
/** Remove all clauses. */
|
|
@@ -2,44 +2,110 @@
|
|
|
2
2
|
* CrossfilterSelection — Svelte 5-native crossfilter predicate engine.
|
|
3
3
|
*
|
|
4
4
|
* Replaces Mosaic's Selection.intersect({ cross: true }) with a reactive
|
|
5
|
-
* $state Map
|
|
6
|
-
*
|
|
5
|
+
* $state Map of clauses, plus the registrations of the sources that write
|
|
6
|
+
* them. Marks derive their filter via predicate(route): every clause that
|
|
7
|
+
* filters the mark.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* WHICH CLAUSES FILTER A MARK. The mark decides, not the control that wrote
|
|
10
|
+
* the clause. A clause filters a mark unless:
|
|
11
|
+
* - it is a categorical selection on a field the mark reads selection
|
|
12
|
+
* activity for (the mark dims by it instead), or
|
|
13
|
+
* - the mark's chart holds a registration of the clause's source that opted
|
|
14
|
+
* into skipping its chart (crossfilter: a brush does not filter its own
|
|
15
|
+
* chart).
|
|
16
|
+
* Both are computed when asked, from the clause, the mark's route and the
|
|
17
|
+
* live registrations: nothing is cached, so nothing goes stale.
|
|
11
18
|
*/
|
|
12
19
|
import { SvelteMap } from 'svelte/reactivity';
|
|
20
|
+
/** A mark with no route: it reads no activity and is in no chart. */
|
|
21
|
+
export const NO_ROUTE = { reads: [], chart: null };
|
|
13
22
|
// ---------------------------------------------------------------------------
|
|
14
23
|
// CrossfilterSelection
|
|
15
24
|
// ---------------------------------------------------------------------------
|
|
16
25
|
export class CrossfilterSelection {
|
|
17
26
|
#clauses = new SvelteMap();
|
|
27
|
+
/** Registrations per source id. Several under one id are one source. Not reactive. */
|
|
28
|
+
#sources = new Map();
|
|
29
|
+
/** Add a registration of `sourceId`. */
|
|
30
|
+
addRegistration(sourceId, registration) {
|
|
31
|
+
let registrations = this.#sources.get(sourceId);
|
|
32
|
+
if (!registrations) {
|
|
33
|
+
registrations = new Set();
|
|
34
|
+
this.#sources.set(sourceId, registrations);
|
|
35
|
+
}
|
|
36
|
+
registrations.add(registration);
|
|
37
|
+
}
|
|
38
|
+
/** Remove a registration of `sourceId`; the last one removes the source. */
|
|
39
|
+
removeRegistration(sourceId, registration) {
|
|
40
|
+
const registrations = this.#sources.get(sourceId);
|
|
41
|
+
if (!registrations?.delete(registration))
|
|
42
|
+
return;
|
|
43
|
+
if (registrations.size === 0)
|
|
44
|
+
this.#sources.delete(sourceId);
|
|
45
|
+
}
|
|
46
|
+
/** The live registrations of `sourceId`, or undefined when it has none. */
|
|
47
|
+
registrationsOf(sourceId) {
|
|
48
|
+
return this.#sources.get(sourceId);
|
|
49
|
+
}
|
|
18
50
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
51
|
+
* Whether a clause of `sourceId` filters the mark with `route`. `key` is
|
|
52
|
+
* the clause's categorical field (its identity key), or null for a brush,
|
|
53
|
+
* a tuple or a manual clause; before the source has written a clause, its
|
|
54
|
+
* registrations' key, which preagg activation needs on hover.
|
|
55
|
+
*
|
|
56
|
+
* A plain loop over the registrations, which are one to three: no closure,
|
|
57
|
+
* no allocation, as it runs per mark per clause on every brush frame.
|
|
22
58
|
*/
|
|
23
|
-
|
|
59
|
+
filters(route, sourceId, key) {
|
|
60
|
+
if (key !== null && route.reads.includes(key))
|
|
61
|
+
return false;
|
|
62
|
+
const chart = route.chart;
|
|
63
|
+
if (chart) {
|
|
64
|
+
// Every chart with a Toggle or legend on the same fields registers
|
|
65
|
+
// their one source, so a chart skip would let a 'filter' mark ignore
|
|
66
|
+
// those fields wherever it has such a control. A click does not move
|
|
67
|
+
// under the pointer, so filtering the own chart is safe for it.
|
|
68
|
+
const filterMark = route.response === 'filter';
|
|
69
|
+
const registrations = this.#sources.get(sourceId);
|
|
70
|
+
if (registrations) {
|
|
71
|
+
for (const registration of registrations) {
|
|
72
|
+
if (registration.chart === chart && !(filterMark && registration.discrete)) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
/** Whether `clause`, written by `sourceId`, filters the mark with `route`. */
|
|
81
|
+
applies(sourceId, clause, route) {
|
|
82
|
+
return !!clause.predicate && this.filters(route, sourceId, clause.categorical?.key ?? null);
|
|
83
|
+
}
|
|
84
|
+
/** Every predicate that filters the mark with `route`. */
|
|
85
|
+
predicate(route) {
|
|
24
86
|
const result = [];
|
|
25
|
-
for (const clause of this.#clauses
|
|
26
|
-
if (
|
|
87
|
+
for (const [sourceId, clause] of this.#clauses) {
|
|
88
|
+
if (this.applies(sourceId, clause, route))
|
|
27
89
|
result.push(clause.predicate);
|
|
28
|
-
}
|
|
29
90
|
}
|
|
30
91
|
return result;
|
|
31
92
|
}
|
|
32
93
|
/**
|
|
33
94
|
* Update or remove a clause.
|
|
34
95
|
* Pass null to remove (clear) the clause for the given source.
|
|
96
|
+
*
|
|
97
|
+
* Returns the clause it replaced or removed, so the caller can re-query the
|
|
98
|
+
* marks either one applies to.
|
|
35
99
|
*/
|
|
36
100
|
update(sourceId, clause) {
|
|
101
|
+
const prev = this.#clauses.get(sourceId);
|
|
37
102
|
if (clause) {
|
|
38
103
|
this.#clauses.set(sourceId, clause);
|
|
39
104
|
}
|
|
40
105
|
else {
|
|
41
106
|
this.#clauses.delete(sourceId);
|
|
42
107
|
}
|
|
108
|
+
return prev;
|
|
43
109
|
}
|
|
44
110
|
/** Whether a given source has an active (non-null) clause. */
|
|
45
111
|
isActive(sourceId) {
|
|
@@ -49,6 +115,14 @@ export class CrossfilterSelection {
|
|
|
49
115
|
get(sourceId) {
|
|
50
116
|
return this.#clauses.get(sourceId);
|
|
51
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Visit every active clause: the stored objects themselves, not copies, so a
|
|
120
|
+
* caller can tell an unchanged clause by reference. Reactive like `items`,
|
|
121
|
+
* without building an entry object per clause.
|
|
122
|
+
*/
|
|
123
|
+
forEach(visit) {
|
|
124
|
+
this.#clauses.forEach(visit);
|
|
125
|
+
}
|
|
52
126
|
/** Reactive list of all active clauses with their source IDs. */
|
|
53
127
|
get items() {
|
|
54
128
|
const result = [];
|
|
@@ -61,17 +135,17 @@ export class CrossfilterSelection {
|
|
|
61
135
|
* Whether a clause is baked into a mark's preagg table: every clause that
|
|
62
136
|
* filters the mark, except the active brush, which the table bins instead.
|
|
63
137
|
*/
|
|
64
|
-
#isBaked(sourceId, clause,
|
|
65
|
-
return sourceId !== activeSourceId &&
|
|
138
|
+
#isBaked(sourceId, clause, route, activeSourceId) {
|
|
139
|
+
return sourceId !== activeSourceId && this.applies(sourceId, clause, route);
|
|
66
140
|
}
|
|
67
141
|
/**
|
|
68
142
|
* The clause objects a preagg table bakes in, in map insertion order.
|
|
69
143
|
* Stored at build time so bakedClausesMatch() can compare by reference.
|
|
70
144
|
*/
|
|
71
|
-
bakedClauses(
|
|
145
|
+
bakedClauses(route, activeSourceId) {
|
|
72
146
|
const result = [];
|
|
73
147
|
for (const [sourceId, clause] of this.#clauses) {
|
|
74
|
-
if (this.#isBaked(sourceId, clause,
|
|
148
|
+
if (this.#isBaked(sourceId, clause, route, activeSourceId))
|
|
75
149
|
result.push(clause);
|
|
76
150
|
}
|
|
77
151
|
return result;
|
|
@@ -80,10 +154,10 @@ export class CrossfilterSelection {
|
|
|
80
154
|
* The baked predicates sorted by source ID, so the same filters always
|
|
81
155
|
* produce the same preagg SQL whatever order their clauses were added in.
|
|
82
156
|
*/
|
|
83
|
-
bakedPredicates(
|
|
157
|
+
bakedPredicates(route, activeSourceId) {
|
|
84
158
|
const entries = [];
|
|
85
159
|
for (const [sourceId, clause] of this.#clauses) {
|
|
86
|
-
if (this.#isBaked(sourceId, clause,
|
|
160
|
+
if (this.#isBaked(sourceId, clause, route, activeSourceId)) {
|
|
87
161
|
entries.push([sourceId, clause.predicate]);
|
|
88
162
|
}
|
|
89
163
|
}
|
|
@@ -95,10 +169,10 @@ export class CrossfilterSelection {
|
|
|
95
169
|
* Every filter change replaces its Clause object, so an unchanged filter
|
|
96
170
|
* keeps its reference. Runs every brush frame, so it allocates nothing.
|
|
97
171
|
*/
|
|
98
|
-
bakedClausesMatch(
|
|
172
|
+
bakedClausesMatch(route, activeSourceId, then) {
|
|
99
173
|
let i = 0;
|
|
100
174
|
for (const [sourceId, clause] of this.#clauses) {
|
|
101
|
-
if (!this.#isBaked(sourceId, clause,
|
|
175
|
+
if (!this.#isBaked(sourceId, clause, route, activeSourceId))
|
|
102
176
|
continue;
|
|
103
177
|
if (i >= then.length || clause !== then[i++])
|
|
104
178
|
return false;
|