@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.
Files changed (148) 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 +64 -25
  7. package/dist/components/axes/BandAxisTicks.svelte.d.ts +5 -5
  8. package/dist/components/axes/axis-title.d.ts +30 -0
  9. package/dist/components/axes/axis-title.js +77 -0
  10. package/dist/components/charts/ChartCell.svelte +1 -1
  11. package/dist/components/charts/ChartContainer.svelte +12 -3
  12. package/dist/components/charts/ChartContainer.svelte.d.ts +7 -0
  13. package/dist/components/guides/legend/ContinuousColorLegend.svelte +9 -2
  14. package/dist/components/guides/legend/OrdinalColorLegend.svelte +96 -35
  15. package/dist/components/marks/AreaMarkX.svelte +21 -6
  16. package/dist/components/marks/AreaMarkY.svelte +21 -6
  17. package/dist/components/marks/BarMarkX.svelte +16 -1
  18. package/dist/components/marks/BarMarkY.svelte +16 -1
  19. package/dist/components/marks/DotsMark.svelte +5 -0
  20. package/dist/components/marks/LineMark.svelte +8 -1
  21. package/dist/components/marks/ParallelCoordinatesMark.svelte +10 -0
  22. package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +5 -0
  23. package/dist/components/marks/RectMark.svelte +5 -0
  24. package/dist/components/marks/RectXMark.svelte +5 -0
  25. package/dist/components/marks/RectYMark.svelte +5 -0
  26. package/dist/components/marks/RuleMarkX.svelte +12 -2
  27. package/dist/components/marks/RuleMarkY.svelte +12 -2
  28. package/dist/components/marks/TextMark.svelte +76 -13
  29. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +21 -2
  30. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +300 -42
  31. package/dist/components/marks/lifecycle/domain-queries.d.ts +3 -1
  32. package/dist/components/marks/lifecycle/domain-queries.js +22 -15
  33. package/dist/components/marks/lifecycle/domain-registration.js +2 -6
  34. package/dist/components/marks/lifecycle/other-rows.svelte.d.ts +104 -0
  35. package/dist/components/marks/lifecycle/other-rows.svelte.js +193 -0
  36. package/dist/components/marks/lifecycle/other-split.d.ts +80 -0
  37. package/dist/components/marks/lifecycle/other-split.js +112 -0
  38. package/dist/components/marks/lifecycle/query-builder.d.ts +26 -1
  39. package/dist/components/marks/lifecycle/query-builder.js +25 -5
  40. package/dist/components/marks/lifecycle/transform-scale-key.d.ts +16 -0
  41. package/dist/components/marks/lifecycle/transform-scale-key.js +27 -0
  42. package/dist/components/marks/utils/area-outline.d.ts +24 -0
  43. package/dist/components/marks/utils/area-outline.js +10 -0
  44. package/dist/components/marks/utils/reactive-config.d.ts +1 -1
  45. package/dist/components/marks/utils/reactive-config.js +9 -0
  46. package/dist/components/marks/utils/tooltip-helpers.d.ts +4 -1
  47. package/dist/components/marks/utils/tooltip-helpers.js +2 -1
  48. package/dist/components/rendering/Areas.svelte +30 -6
  49. package/dist/components/rendering/Areas.svelte.d.ts +2 -0
  50. package/dist/components/rendering/BaseGPURenderer.svelte.d.ts +3 -1
  51. package/dist/components/rendering/BaseGPURenderer.svelte.js +8 -3
  52. package/dist/components/rendering/Dots.svelte +24 -3
  53. package/dist/components/rendering/Dots.svelte.d.ts +2 -0
  54. package/dist/components/rendering/GridLine.svelte +2 -1
  55. package/dist/components/rendering/Lines.svelte +49 -7
  56. package/dist/components/rendering/Lines.svelte.d.ts +9 -0
  57. package/dist/components/rendering/ParallelLines.svelte +26 -7
  58. package/dist/components/rendering/ParallelLines.svelte.d.ts +2 -0
  59. package/dist/components/rendering/Rects.svelte +129 -20
  60. package/dist/components/rendering/Rects.svelte.d.ts +9 -0
  61. package/dist/components/rendering/RuleX.svelte +20 -5
  62. package/dist/components/rendering/RuleX.svelte.d.ts +2 -0
  63. package/dist/components/rendering/RuleY.svelte +20 -5
  64. package/dist/components/rendering/RuleY.svelte.d.ts +2 -0
  65. package/dist/components/rendering/activity-reads.d.ts +111 -0
  66. package/dist/components/rendering/activity-reads.js +154 -0
  67. package/dist/components/rendering/uniform-sizes.d.ts +26 -0
  68. package/dist/components/rendering/uniform-sizes.js +28 -0
  69. package/dist/config/ChartConfig.svelte.d.ts +5 -1
  70. package/dist/gpu/ColorScaleShaderLib.d.ts +13 -1
  71. package/dist/gpu/ColorScaleShaderLib.js +56 -5
  72. package/dist/gpu/GPUCoordinator.svelte.d.ts +8 -0
  73. package/dist/gpu/GPUCoordinator.svelte.js +68 -32
  74. package/dist/gpu/MorphedChannels.d.ts +6 -32
  75. package/dist/gpu/MorphedChannels.js +45 -3
  76. package/dist/gpu/ResolveShader.d.ts +2 -2
  77. package/dist/gpu/ResolveShader.js +2 -2
  78. package/dist/gpu/ScaleTemplateSystem.d.ts +19 -0
  79. package/dist/gpu/ScaleTemplateSystem.js +111 -11
  80. package/dist/gpu/SizeScaleShaderLib.d.ts +4 -4
  81. package/dist/gpu/SizeScaleShaderLib.js +4 -4
  82. package/dist/gpu/identity-slot-packing.d.ts +157 -40
  83. package/dist/gpu/identity-slot-packing.js +262 -62
  84. package/dist/gpu/tracked-scales.d.ts +14 -0
  85. package/dist/gpu/tracked-scales.js +18 -0
  86. package/dist/index.d.ts +2 -0
  87. package/dist/index.js +4 -0
  88. package/dist/interactors/BandLabelToggle.svelte +5 -9
  89. package/dist/interactors/BandLabelToggle.svelte.d.ts +3 -3
  90. package/dist/interactors/DerivedInterval1D.svelte +26 -54
  91. package/dist/interactors/Interval1D.svelte +8 -2
  92. package/dist/interactors/Interval2D.svelte +8 -2
  93. package/dist/interactors/ParallelAxisSource.svelte +14 -5
  94. package/dist/interactors/ParallelAxisSource.svelte.d.ts +2 -1
  95. package/dist/interactors/ParallelBrushes.svelte +8 -9
  96. package/dist/interactors/ParallelBrushes.svelte.d.ts +3 -3
  97. package/dist/interactors/Toggle.svelte +48 -38
  98. package/dist/interactors/Toggle.svelte.d.ts +4 -3
  99. package/dist/interactors/Tooltip.svelte +140 -42
  100. package/dist/interactors/TooltipManager.svelte.d.ts +20 -5
  101. package/dist/interactors/TooltipManager.svelte.js +40 -10
  102. package/dist/interactors/other-tooltip.d.ts +16 -0
  103. package/dist/interactors/other-tooltip.js +22 -0
  104. package/dist/interactors/relay-clauses.d.ts +23 -0
  105. package/dist/interactors/relay-clauses.js +60 -0
  106. package/dist/query-engine/CrossfilterSelection.svelte.d.ts +88 -15
  107. package/dist/query-engine/CrossfilterSelection.svelte.js +94 -20
  108. package/dist/query-engine/FieldActivity.svelte.d.ts +128 -0
  109. package/dist/query-engine/FieldActivity.svelte.js +378 -0
  110. package/dist/query-engine/FilterGroup.svelte.d.ts +76 -30
  111. package/dist/query-engine/FilterGroup.svelte.js +223 -124
  112. package/dist/query-engine/FilterGroupRegistry.svelte.d.ts +73 -3
  113. package/dist/query-engine/FilterGroupRegistry.svelte.js +161 -12
  114. package/dist/query-engine/MarkHandle.svelte.d.ts +9 -1
  115. package/dist/query-engine/MarkHandle.svelte.js +12 -2
  116. package/dist/query-engine/QueryEngine.d.ts +52 -16
  117. package/dist/query-engine/QueryEngine.js +116 -34
  118. package/dist/query-engine/categorical-identity.d.ts +114 -27
  119. package/dist/query-engine/categorical-identity.js +225 -51
  120. package/dist/query-engine/index.d.ts +2 -1
  121. package/dist/query-engine/index.js +1 -0
  122. package/dist/query-engine/preagg/Preagg.d.ts +1 -1
  123. package/dist/query-engine/preagg/Preagg.js +11 -9
  124. package/dist/query-engine/preagg/plan.d.ts +14 -5
  125. package/dist/query-engine/preagg/plan.js +44 -18
  126. package/dist/query-engine/toggle-predicate.d.ts +21 -0
  127. package/dist/query-engine/toggle-predicate.js +41 -2
  128. package/dist/scales/ActivityTable.d.ts +103 -0
  129. package/dist/scales/ActivityTable.js +220 -0
  130. package/dist/scales/BandScaleState.svelte.d.ts +41 -15
  131. package/dist/scales/BandScaleState.svelte.js +78 -36
  132. package/dist/scales/ChartScales.svelte.d.ts +2 -2
  133. package/dist/scales/ColorScaleState.svelte.d.ts +36 -8
  134. package/dist/scales/ColorScaleState.svelte.js +118 -24
  135. package/dist/scales/DomainResolver.d.ts +2 -0
  136. package/dist/scales/DomainResolver.js +15 -8
  137. package/dist/scales/ScaleRegistry.svelte.d.ts +9 -0
  138. package/dist/scales/ScaleRegistry.svelte.js +22 -1
  139. package/dist/scales/TimeScaleState.svelte.js +7 -1
  140. package/dist/spec/index.js +1 -0
  141. package/dist/spec/types.d.ts +4 -0
  142. package/dist/theme.css +5 -4
  143. package/dist/transforms/TransformCompiler.d.ts +37 -2
  144. package/dist/transforms/TransformCompiler.js +44 -1
  145. package/dist/transforms/index.d.ts +27 -0
  146. package/dist/transforms/index.js +127 -19
  147. package/dist/types.d.ts +71 -0
  148. 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
- pointerScreenX = $state(0);
39
- pointerScreenY = $state(0);
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
- * Update raw screen position for tooltip positioning.
133
- * Called from chart container's pointer move handler.
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
- updatePointerScreen(x, y) {
136
- this.pointerScreenX = x;
137
- this.pointerScreenY = y;
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
- if (category == null)
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
- return this.#bandScale(key)?.categoryAt(value) ?? value;
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
- point[fieldName] = this.#bandScale(key)?.categoryAt(value) ?? value;
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. Marks derive their filter via predicate(markId), which
6
- * returns all active predicates except those that exclude that mark.
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
- * Cross-filtering is chart-level: interactors pass excludeMarkIds (the set
9
- * of all marks in the source chart). When a brush in chart A fires, all
10
- * marks in chart A skip that clause. All other marks receive it.
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
- /** A clause stored in the selection. */
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
- * Compute the filter predicate for a given mark.
36
- * Returns all active predicates except those whose excludeMarkIds
37
- * contains the given markId.
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
- predicate(markId: string): FilterExpr[];
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): void;
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(markId: string, activeSourceId: string): Clause[];
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(markId: string, activeSourceId: string): FilterExpr[];
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(markId: string, activeSourceId: string, then: Clause[]): boolean;
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. Marks derive their filter via predicate(markId), which
6
- * returns all active predicates except those that exclude that mark.
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
- * Cross-filtering is chart-level: interactors pass excludeMarkIds (the set
9
- * of all marks in the source chart). When a brush in chart A fires, all
10
- * marks in chart A skip that clause. All other marks receive it.
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
- * Compute the filter predicate for a given mark.
20
- * Returns all active predicates except those whose excludeMarkIds
21
- * contains the given markId.
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
- predicate(markId) {
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.values()) {
26
- if (!clause.excludeMarkIds.has(markId) && clause.predicate) {
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, markId, activeSourceId) {
65
- return sourceId !== activeSourceId && !clause.excludeMarkIds.has(markId) && !!clause.predicate;
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(markId, activeSourceId) {
145
+ bakedClauses(route, activeSourceId) {
72
146
  const result = [];
73
147
  for (const [sourceId, clause] of this.#clauses) {
74
- if (this.#isBaked(sourceId, clause, markId, activeSourceId))
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(markId, activeSourceId) {
157
+ bakedPredicates(route, activeSourceId) {
84
158
  const entries = [];
85
159
  for (const [sourceId, clause] of this.#clauses) {
86
- if (this.#isBaked(sourceId, clause, markId, activeSourceId)) {
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(markId, activeSourceId, then) {
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, markId, activeSourceId))
175
+ if (!this.#isBaked(sourceId, clause, route, activeSourceId))
102
176
  continue;
103
177
  if (i >= then.length || clause !== then[i++])
104
178
  return false;