@fjader/svelte 0.2.1 → 0.2.3

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 (123) hide show
  1. package/dist/components/axes/BandAxisTicks.svelte +63 -24
  2. package/dist/components/axes/BandAxisTicks.svelte.d.ts +5 -5
  3. package/dist/components/charts/ChartContainer.svelte +2 -1
  4. package/dist/components/guides/legend/ContinuousColorLegend.svelte +2 -1
  5. package/dist/components/guides/legend/OrdinalColorLegend.svelte +92 -34
  6. package/dist/components/marks/AreaMarkX.svelte +19 -6
  7. package/dist/components/marks/AreaMarkY.svelte +19 -6
  8. package/dist/components/marks/BarMarkX.svelte +3 -0
  9. package/dist/components/marks/BarMarkY.svelte +3 -0
  10. package/dist/components/marks/DotsMark.svelte +3 -0
  11. package/dist/components/marks/LineMark.svelte +3 -0
  12. package/dist/components/marks/ParallelCoordinatesMark.svelte +6 -0
  13. package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +3 -0
  14. package/dist/components/marks/RectMark.svelte +3 -0
  15. package/dist/components/marks/RectXMark.svelte +3 -0
  16. package/dist/components/marks/RectYMark.svelte +3 -0
  17. package/dist/components/marks/RuleMarkX.svelte +10 -2
  18. package/dist/components/marks/RuleMarkY.svelte +10 -2
  19. package/dist/components/marks/TextMark.svelte +68 -4
  20. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +15 -1
  21. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +116 -27
  22. package/dist/components/marks/lifecycle/domain-queries.js +13 -11
  23. package/dist/components/marks/lifecycle/transform-scale-key.d.ts +16 -0
  24. package/dist/components/marks/lifecycle/transform-scale-key.js +27 -0
  25. package/dist/components/marks/utils/area-outline.d.ts +24 -0
  26. package/dist/components/marks/utils/area-outline.js +10 -0
  27. package/dist/components/marks/utils/reactive-config.d.ts +1 -1
  28. package/dist/components/marks/utils/reactive-config.js +3 -0
  29. package/dist/components/rendering/Areas.svelte +32 -9
  30. package/dist/components/rendering/Areas.svelte.d.ts +2 -0
  31. package/dist/components/rendering/BaseGPURenderer.svelte.js +4 -1
  32. package/dist/components/rendering/Dots.svelte +24 -3
  33. package/dist/components/rendering/Dots.svelte.d.ts +2 -0
  34. package/dist/components/rendering/GridLine.svelte +2 -1
  35. package/dist/components/rendering/Lines.svelte +51 -10
  36. package/dist/components/rendering/Lines.svelte.d.ts +9 -0
  37. package/dist/components/rendering/ParallelLines.svelte +28 -8
  38. package/dist/components/rendering/ParallelLines.svelte.d.ts +2 -0
  39. package/dist/components/rendering/Rects.svelte +20 -5
  40. package/dist/components/rendering/Rects.svelte.d.ts +2 -0
  41. package/dist/components/rendering/RuleX.svelte +20 -5
  42. package/dist/components/rendering/RuleX.svelte.d.ts +2 -0
  43. package/dist/components/rendering/RuleY.svelte +20 -5
  44. package/dist/components/rendering/RuleY.svelte.d.ts +2 -0
  45. package/dist/components/rendering/activity-reads.d.ts +93 -0
  46. package/dist/components/rendering/activity-reads.js +135 -0
  47. package/dist/components/rendering/uniform-sizes.d.ts +26 -0
  48. package/dist/components/rendering/uniform-sizes.js +28 -0
  49. package/dist/config/ChartConfig.svelte.d.ts +2 -0
  50. package/dist/data/DataAccessor.js +1 -1
  51. package/dist/gpu/ColorScaleShaderLib.d.ts +14 -2
  52. package/dist/gpu/ColorScaleShaderLib.js +59 -7
  53. package/dist/gpu/GPUCoordinator.svelte.d.ts +8 -0
  54. package/dist/gpu/GPUCoordinator.svelte.js +68 -32
  55. package/dist/gpu/NanShaderLib.d.ts +30 -0
  56. package/dist/gpu/NanShaderLib.js +42 -0
  57. package/dist/gpu/ResolveShader.d.ts +3 -3
  58. package/dist/gpu/ResolveShader.js +6 -5
  59. package/dist/gpu/ScaleTemplateSystem.d.ts +19 -0
  60. package/dist/gpu/ScaleTemplateSystem.js +86 -7
  61. package/dist/gpu/SizeScaleShaderLib.d.ts +5 -5
  62. package/dist/gpu/SizeScaleShaderLib.js +6 -5
  63. package/dist/gpu/decimation/M4Shader.d.ts +13 -7
  64. package/dist/gpu/decimation/M4Shader.js +79 -64
  65. package/dist/gpu/identity-slot-packing.d.ts +157 -40
  66. package/dist/gpu/identity-slot-packing.js +262 -62
  67. package/dist/gpu/tracked-scales.d.ts +14 -0
  68. package/dist/gpu/tracked-scales.js +18 -0
  69. package/dist/index.js +1 -0
  70. package/dist/interactors/BandLabelToggle.svelte +5 -9
  71. package/dist/interactors/BandLabelToggle.svelte.d.ts +3 -3
  72. package/dist/interactors/DerivedInterval1D.svelte +26 -54
  73. package/dist/interactors/Interval1D.svelte +2 -1
  74. package/dist/interactors/Interval2D.svelte +2 -1
  75. package/dist/interactors/ParallelAxisSource.svelte +4 -4
  76. package/dist/interactors/ParallelAxisSource.svelte.d.ts +2 -1
  77. package/dist/interactors/ParallelBrushes.svelte +8 -9
  78. package/dist/interactors/ParallelBrushes.svelte.d.ts +3 -3
  79. package/dist/interactors/Toggle.svelte +17 -36
  80. package/dist/interactors/Toggle.svelte.d.ts +4 -3
  81. package/dist/interactors/Tooltip.svelte +2 -1
  82. package/dist/interactors/TooltipManager.svelte.js +8 -3
  83. package/dist/interactors/relay-clauses.d.ts +23 -0
  84. package/dist/interactors/relay-clauses.js +60 -0
  85. package/dist/query-engine/CrossfilterSelection.svelte.d.ts +88 -15
  86. package/dist/query-engine/CrossfilterSelection.svelte.js +94 -20
  87. package/dist/query-engine/FieldActivity.svelte.d.ts +79 -0
  88. package/dist/query-engine/FieldActivity.svelte.js +207 -0
  89. package/dist/query-engine/FilterGroup.svelte.d.ts +53 -29
  90. package/dist/query-engine/FilterGroup.svelte.js +181 -122
  91. package/dist/query-engine/FilterGroupRegistry.svelte.d.ts +42 -0
  92. package/dist/query-engine/FilterGroupRegistry.svelte.js +103 -1
  93. package/dist/query-engine/MarkHandle.svelte.d.ts +9 -1
  94. package/dist/query-engine/MarkHandle.svelte.js +12 -2
  95. package/dist/query-engine/QueryEngine.d.ts +52 -16
  96. package/dist/query-engine/QueryEngine.js +90 -28
  97. package/dist/query-engine/categorical-identity.d.ts +99 -27
  98. package/dist/query-engine/categorical-identity.js +210 -51
  99. package/dist/query-engine/index.d.ts +2 -1
  100. package/dist/query-engine/index.js +1 -0
  101. package/dist/query-engine/preagg/Preagg.d.ts +1 -1
  102. package/dist/query-engine/preagg/Preagg.js +11 -9
  103. package/dist/query-engine/preagg/plan.d.ts +9 -5
  104. package/dist/query-engine/preagg/plan.js +13 -11
  105. package/dist/query-engine/toggle-predicate.js +12 -1
  106. package/dist/scales/ActivityTable.d.ts +99 -0
  107. package/dist/scales/ActivityTable.js +211 -0
  108. package/dist/scales/BandScaleState.svelte.d.ts +28 -7
  109. package/dist/scales/BandScaleState.svelte.js +55 -20
  110. package/dist/scales/ChartScales.svelte.d.ts +2 -2
  111. package/dist/scales/ColorScaleState.svelte.d.ts +36 -8
  112. package/dist/scales/ColorScaleState.svelte.js +118 -24
  113. package/dist/scales/DomainResolver.d.ts +2 -0
  114. package/dist/scales/DomainResolver.js +15 -8
  115. package/dist/scales/ScaleRegistry.svelte.d.ts +9 -0
  116. package/dist/scales/ScaleRegistry.svelte.js +22 -1
  117. package/dist/scales/TimeScaleState.svelte.js +7 -1
  118. package/dist/spec/index.js +1 -0
  119. package/dist/spec/types.d.ts +2 -0
  120. package/dist/transforms/index.d.ts +27 -0
  121. package/dist/transforms/index.js +97 -3
  122. package/dist/types.d.ts +30 -0
  123. package/package.json +1 -1
@@ -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;
@@ -0,0 +1,79 @@
1
+ import type { ClockLoop } from '../animation';
2
+ import { ActivityTable } from '../scales/ActivityTable';
3
+ import type { FieldIdentity } from './categorical-identity';
4
+ /** What a FieldActivity needs from the page around it. */
5
+ export interface FieldActivityHost {
6
+ /** Drives the fades; null in unit tests and before a coordinator exists, and fades land at once. */
7
+ readonly loop: ClockLoop | null;
8
+ /** Fill `out` with the charts drawing any scale on `key`, for the fade's redraws. */
9
+ chartsFor(key: string, out: string[]): string[];
10
+ /** The field's live identity, or null; must never create one. */
11
+ identityOf(key: string): FieldIdentity | null;
12
+ }
13
+ /** A host with no page: no loop, no charts, no identity. */
14
+ export declare const DETACHED_HOST: FieldActivityHost;
15
+ /**
16
+ * Which categories of one field are selected, per filter group, and how far
17
+ * each one has faded.
18
+ *
19
+ * Activity is indexed by the field's identity id, and FieldIdentity is one per
20
+ * field, so it belongs to the field, not to any scale on it: the carrier colour
21
+ * scale and every carrier band scale read the same activity. One fade per
22
+ * filter group, indexed by the group's page-wide index, because a selection
23
+ * belongs to the group it was made in: a click in group A dims group A's marks
24
+ * and leaves group B's alone.
25
+ *
26
+ * The packer writes these tables into the field's activity block, and legends
27
+ * and band labels read them through `opacityOfValue`.
28
+ *
29
+ * IDS AND IDENTITY. Ids are only meaningful within one identity: across a
30
+ * reload the same id can name another category. So the tables follow the
31
+ * field's LIVE identity, which `reconcile` compares with the one last applied:
32
+ * a different object is a reload, and every fade is dropped and snapped to its
33
+ * selection; the same object at a higher version is a dim that grew, and the
34
+ * existing ids keep their fades while new ones start in their state.
35
+ * `reconcile` runs from the packer and from `set`, never from a reader, so
36
+ * nothing is resized or retargeted while a template renders.
37
+ */
38
+ export declare class FieldActivity {
39
+ #private;
40
+ readonly key: string;
41
+ constructor(key: string, host?: FieldActivityHost);
42
+ /** How many ids the tables cover: the applied identity's, 0 without one. */
43
+ get count(): number;
44
+ /** Group `index`'s fade, or null when that group never selected on this field. */
45
+ fadeOf(index: number): ActivityTable | null;
46
+ /**
47
+ * Select `values` in group `index`, or nothing for null.
48
+ *
49
+ * The table is first brought up to the live identity under the selection it
50
+ * had, so the change from that selection to this one fades. Sizing it with
51
+ * the new values instead would put every id straight into its new state: a
52
+ * group's first click, or the first one after the dim grew, would snap.
53
+ */
54
+ set(index: number, values: ReadonlySet<unknown> | null): void;
55
+ /**
56
+ * Follow the field's live identity: snap every fade on a reload, extend them
57
+ * when the dim grew. Two comparisons when nothing moved, no allocation.
58
+ * Returns whether it re-applied the groups.
59
+ */
60
+ reconcile(): boolean;
61
+ /**
62
+ * How visible a category is now in group `index`: its fade when the value
63
+ * has an id in the current identity, and otherwise simply selected or not.
64
+ * Pure, so it is safe in a template: it never reconciles, and a table that
65
+ * belongs to an identity no longer live is not trusted.
66
+ */
67
+ opacityOfValue(index: number, value: unknown, inactiveOpacity?: number): number;
68
+ /**
69
+ * How visible the category with dim id `id` is now in group `index`: its fade.
70
+ * The id form of `opacityOfValue`, and the only one that reaches a synthetic
71
+ * id (an Other bucket): a bucket has a label, not a value, and several may
72
+ * share one label. Pure, so it is safe in a template.
73
+ *
74
+ * Before the group's table covers the id, falls back to the selected values
75
+ * for a real id, and to fully visible for a bucket or an identity that is not
76
+ * live: a label is never dimmed on a guess.
77
+ */
78
+ opacityOfId(index: number, id: number, inactiveOpacity?: number): number;
79
+ }
@@ -0,0 +1,207 @@
1
+ import { ActivityTable, DEFAULT_INACTIVE_OPACITY, selectionOpacity } from '../scales/ActivityTable';
2
+ /** A host with no page: no loop, no charts, no identity. */
3
+ export const DETACHED_HOST = {
4
+ loop: null,
5
+ chartsFor: (_key, out) => out,
6
+ identityOf: () => null
7
+ };
8
+ /** One filter group's selection on one field, and its fade. */
9
+ class GroupFade {
10
+ table;
11
+ /** The selected values, or null for none. State, for the by-value fallback. */
12
+ values = $state.raw(null);
13
+ /** Bumped on every frame the fade is driven, and once at rest. */
14
+ frame = $state(0);
15
+ constructor(host, key, charts) {
16
+ this.table = new ActivityTable(host.loop, {
17
+ charts: () => {
18
+ charts.length = 0;
19
+ return host.chartsFor(key, charts);
20
+ },
21
+ onFrame: () => this.frame++
22
+ });
23
+ }
24
+ }
25
+ /**
26
+ * Which categories of one field are selected, per filter group, and how far
27
+ * each one has faded.
28
+ *
29
+ * Activity is indexed by the field's identity id, and FieldIdentity is one per
30
+ * field, so it belongs to the field, not to any scale on it: the carrier colour
31
+ * scale and every carrier band scale read the same activity. One fade per
32
+ * filter group, indexed by the group's page-wide index, because a selection
33
+ * belongs to the group it was made in: a click in group A dims group A's marks
34
+ * and leaves group B's alone.
35
+ *
36
+ * The packer writes these tables into the field's activity block, and legends
37
+ * and band labels read them through `opacityOfValue`.
38
+ *
39
+ * IDS AND IDENTITY. Ids are only meaningful within one identity: across a
40
+ * reload the same id can name another category. So the tables follow the
41
+ * field's LIVE identity, which `reconcile` compares with the one last applied:
42
+ * a different object is a reload, and every fade is dropped and snapped to its
43
+ * selection; the same object at a higher version is a dim that grew, and the
44
+ * existing ids keep their fades while new ones start in their state.
45
+ * `reconcile` runs from the packer and from `set`, never from a reader, so
46
+ * nothing is resized or retargeted while a template renders.
47
+ */
48
+ export class FieldActivity {
49
+ key;
50
+ #host;
51
+ #groups = [];
52
+ /**
53
+ * Bumped when a group's fade is created, so a reader that found none runs
54
+ * again once it exists. Fades are created by `set`, never while rendering.
55
+ */
56
+ #groupsVersion = $state(0);
57
+ /** The identity the tables' ids belong to, or null when they have none. */
58
+ #identity = null;
59
+ #version = -1;
60
+ /** Reused id list: selections are applied on every categorical click. */
61
+ #ids = [];
62
+ constructor(key, host = DETACHED_HOST) {
63
+ this.key = key;
64
+ this.#host = host;
65
+ }
66
+ /** How many ids the tables cover: the applied identity's, 0 without one. */
67
+ get count() {
68
+ return this.#identity ? this.#identity.values.length : 0;
69
+ }
70
+ /** Group `index`'s fade, or null when that group never selected on this field. */
71
+ fadeOf(index) {
72
+ return this.#groups[index]?.table ?? null;
73
+ }
74
+ /**
75
+ * Select `values` in group `index`, or nothing for null.
76
+ *
77
+ * The table is first brought up to the live identity under the selection it
78
+ * had, so the change from that selection to this one fades. Sizing it with
79
+ * the new values instead would put every id straight into its new state: a
80
+ * group's first click, or the first one after the dim grew, would snap.
81
+ */
82
+ set(index, values) {
83
+ const group = this.#group(index);
84
+ this.reconcile();
85
+ if (group.table.count !== this.count)
86
+ this.#apply(group);
87
+ group.values = values;
88
+ this.#apply(group);
89
+ }
90
+ /**
91
+ * Follow the field's live identity: snap every fade on a reload, extend them
92
+ * when the dim grew. Two comparisons when nothing moved, no allocation.
93
+ * Returns whether it re-applied the groups.
94
+ */
95
+ reconcile() {
96
+ const live = this.#host.identityOf(this.key);
97
+ const usable = live && live.values.length > 0 ? live : null;
98
+ if (usable === this.#identity) {
99
+ if (!usable || usable.version === this.#version)
100
+ return false;
101
+ // The same identity, grown.
102
+ this.#version = usable.version;
103
+ for (const group of this.#groups)
104
+ this.#apply(group);
105
+ return true;
106
+ }
107
+ // Another identity, or none: the old ids mean nothing now.
108
+ this.#identity = usable;
109
+ this.#version = usable ? usable.version : -1;
110
+ for (const group of this.#groups) {
111
+ group.table.resize(0);
112
+ this.#apply(group);
113
+ }
114
+ return true;
115
+ }
116
+ /**
117
+ * How visible a category is now in group `index`: its fade when the value
118
+ * has an id in the current identity, and otherwise simply selected or not.
119
+ * Pure, so it is safe in a template: it never reconciles, and a table that
120
+ * belongs to an identity no longer live is not trusted.
121
+ */
122
+ opacityOfValue(index, value, inactiveOpacity = DEFAULT_INACTIVE_OPACITY) {
123
+ void this.#groupsVersion;
124
+ const group = this.#groups[index];
125
+ if (!group)
126
+ return 1;
127
+ // Read first, so a reader that ran before identity landed already depends
128
+ // on the frame counter for the fade that follows.
129
+ void group.frame;
130
+ const identity = this.#identity;
131
+ if (identity && identity === this.#host.identityOf(this.key)) {
132
+ const id = identity.ids.get(value);
133
+ if (id != null)
134
+ return this.opacityOfId(index, id, inactiveOpacity);
135
+ }
136
+ const values = group.values;
137
+ return values == null || values.has(value) ? 1 : inactiveOpacity;
138
+ }
139
+ /**
140
+ * How visible the category with dim id `id` is now in group `index`: its fade.
141
+ * The id form of `opacityOfValue`, and the only one that reaches a synthetic
142
+ * id (an Other bucket): a bucket has a label, not a value, and several may
143
+ * share one label. Pure, so it is safe in a template.
144
+ *
145
+ * Before the group's table covers the id, falls back to the selected values
146
+ * for a real id, and to fully visible for a bucket or an identity that is not
147
+ * live: a label is never dimmed on a guess.
148
+ */
149
+ opacityOfId(index, id, inactiveOpacity = DEFAULT_INACTIVE_OPACITY) {
150
+ void this.#groupsVersion;
151
+ const group = this.#groups[index];
152
+ if (!group)
153
+ return 1;
154
+ void group.frame;
155
+ const values = group.values;
156
+ const identity = this.#identity;
157
+ if (!identity || identity !== this.#host.identityOf(this.key))
158
+ return 1;
159
+ if (id >= 0 && id < group.table.count) {
160
+ return selectionOpacity(group.table.activityOf(id), inactiveOpacity);
161
+ }
162
+ if (values == null || identity.synthetic.has(id) || id < 0 || id >= identity.values.length) {
163
+ return 1;
164
+ }
165
+ return values.has(identity.values[id]) ? 1 : inactiveOpacity;
166
+ }
167
+ #group(index) {
168
+ let group = this.#groups[index];
169
+ if (!group) {
170
+ // Fill any gap below it too: the packer walks the groups by index.
171
+ for (let i = this.#groups.length; i <= index; i++) {
172
+ this.#groups[i] = new GroupFade(this.#host, this.key, []);
173
+ }
174
+ group = this.#groups[index];
175
+ this.#groupsVersion++;
176
+ }
177
+ return group;
178
+ }
179
+ /**
180
+ * Point a group's table at its selection under the applied identity. A new
181
+ * id starts in its state rather than fading in, which is what keeps a
182
+ * selection made before its ids existed from fading on arrival.
183
+ */
184
+ #apply(group) {
185
+ const identity = this.#identity;
186
+ if (!identity) {
187
+ group.table.resize(0);
188
+ return;
189
+ }
190
+ const count = identity.values.length;
191
+ const values = group.values;
192
+ if (values == null) {
193
+ group.table.resize(count, null);
194
+ group.table.clear();
195
+ return;
196
+ }
197
+ const ids = this.#ids;
198
+ ids.length = 0;
199
+ for (const value of values) {
200
+ const id = identity.ids.get(value);
201
+ if (id != null)
202
+ ids.push(id);
203
+ }
204
+ group.table.resize(count, ids);
205
+ group.table.setActive(ids);
206
+ }
207
+ }
@@ -2,8 +2,8 @@
2
2
  * FilterGroup — visual concerns layer on top of CrossfilterSelection.
3
3
  *
4
4
  * Manages the interactor API (register, registerToggle), schema augmentation
5
- * for UI filter pills, and scale projection (dimming unselected categories in
6
- * legends/axes).
5
+ * for UI filter pills, and this group's selection on each categorical field
6
+ * (FieldActivity), which marks, legends and band axes dim by.
7
7
  *
8
8
  * Owns clauses: a clause lives while at least one registration for its id
9
9
  * exists. Components own their registrations through their own lifecycle.
@@ -12,16 +12,9 @@
12
12
  * objects, no event listeners.
13
13
  */
14
14
  import { CrossfilterSelection } from './CrossfilterSelection.svelte';
15
- import type { FilterExpr, FilterSchema } from './CrossfilterSelection.svelte';
15
+ import type { Clause, FilterExpr, FilterSchema, SourceRegistration } from './CrossfilterSelection.svelte';
16
16
  import type { ActiveDim, QueryEngine } from './QueryEngine';
17
- /**
18
- * Narrow protocol for scale states that support active-value projection.
19
- * Implemented by ColorScaleState and BandScaleState.
20
- */
21
- export interface ActiveValueScale {
22
- setActiveValues(values: Set<unknown>): void;
23
- clearActiveValues(): void;
24
- }
17
+ import { FieldActivity } from './FieldActivity.svelte';
25
18
  export interface FilterItem {
26
19
  sourceId: string;
27
20
  predicate: FilterExpr;
@@ -29,10 +22,23 @@ export interface FilterItem {
29
22
  schema?: FilterSchema;
30
23
  }
31
24
  /** One registration of a selection source: a brush, a legend, a Toggle. */
32
- export interface SourceOpts {
33
- /** Getter for mark IDs this registration's writes exclude (chart-level cross-filtering).
34
- * A getter rather than a static Set so it stays current if marks are added/removed. */
35
- excludeMarkIds: () => Set<string>;
25
+ export interface SourceOpts extends SourceRegistration {
26
+ /**
27
+ * The chart this registration skips: its ChartConfig, or nothing. A chart
28
+ * holding a control for a selection is not filtered by it (crossfilter), so
29
+ * its marks that do not read the field keep their data; those that do, dim.
30
+ * Opt-in: a control that wants its own chart filtered passes none.
31
+ */
32
+ chart?: object | null;
33
+ /**
34
+ * For a source that selects categories of one field: that field's identity
35
+ * key, and how to read the selected values from a write. Every clause the
36
+ * source writes carries both (`Clause.categorical`).
37
+ */
38
+ categorical?: {
39
+ key: string;
40
+ values: (value: unknown) => ReadonlySet<unknown>;
41
+ };
36
42
  /** How the source selects, read at hover time for preagg activation. */
37
43
  activeDims?: () => ActiveDim[];
38
44
  /**
@@ -41,16 +47,14 @@ export interface SourceOpts {
41
47
  * while its bins are as fine as the pixels.
42
48
  */
43
49
  view?: (out: Float64Array) => void;
44
- /** Schema metadata for UI filter pills, derived from the clause value. */
50
+ /**
51
+ * Schema metadata for UI filter pills, derived from the clause value. Run
52
+ * once per write and stored on the clause.
53
+ */
45
54
  augment?: (value: unknown) => FilterSchema | null;
46
- /** The scales to dim, and the values to dim them by, taken from the clause value. */
47
- projection?: {
48
- scales: () => ActiveValueScale[];
49
- values: (value: unknown) => Set<unknown> | null;
50
- };
51
55
  }
52
56
  export interface SourceHandle {
53
- /** Write the clause, excluding this registration's marks. */
57
+ /** Write the source's clause. */
54
58
  update(predicate: FilterExpr, value: unknown): void;
55
59
  clear(): void;
56
60
  /** Prepare preagg tables for this source, ahead of its first use. */
@@ -81,8 +85,23 @@ export interface ToggleHandle extends Omit<SourceHandle, 'update'> {
81
85
  export declare class FilterGroup {
82
86
  #private;
83
87
  readonly selection: CrossfilterSelection;
88
+ /**
89
+ * This group's page-wide index, assigned by FilterGroupRegistry and never
90
+ * reused: a mark stores it to read this group's half of an activity block.
91
+ */
92
+ readonly index: number;
93
+ /**
94
+ * A group made outside a registry (unit tests) keeps its field activity to
95
+ * itself, with no loop: its fades land at once.
96
+ */
97
+ constructor(index?: number, activityOf?: (key: string) => FieldActivity);
98
+ /** The selection state of a categorical field, by identity key: this group's fade is `index`. */
99
+ activity(key: string): FieldActivity;
84
100
  setEngine(engine: QueryEngine): void;
85
- /** Active filter items with schema metadata, for UI consumption. */
101
+ /**
102
+ * Active filter items with schema metadata, for UI consumption. The schema
103
+ * is the clause's own, computed when it was written (see `update`).
104
+ */
86
105
  get items(): FilterItem[];
87
106
  /** Number of active filters. */
88
107
  get activeCount(): number;
@@ -90,8 +109,7 @@ export declare class FilterGroup {
90
109
  fieldSql(field: string): string | null;
91
110
  /**
92
111
  * Register a selection source. Several registrations under one id are one
93
- * source: each writes the clause with its own exclusions, and the scales the
94
- * source dims are the union of theirs.
112
+ * source and write one clause.
95
113
  */
96
114
  register(sourceId: string, opts: SourceOpts): SourceHandle;
97
115
  /**
@@ -103,11 +121,17 @@ export declare class FilterGroup {
103
121
  * as the selection. Nothing else writes those ids (`upsertManual` prefixes
104
122
  * its own with `manual:`).
105
123
  */
106
- registerToggle(fields: string | string[], opts: {
107
- excludeMarkIds: () => Set<string>;
108
- scales?: () => ActiveValueScale[];
124
+ registerToggle(fields: string | string[], opts?: {
125
+ chart?: object | null;
109
126
  }): ToggleHandle;
110
- upsertManual(key: string, predicate: FilterExpr, value: unknown, excludeMarkIds: Set<string> | (() => Set<string>), schema?: FilterSchema): void;
127
+ upsertManual(key: string, predicate: FilterExpr, value: unknown, schema?: FilterSchema): void;
128
+ /**
129
+ * Hold `clause` under a manual key, as given: the relay passes another
130
+ * group's clause on. The clause it already holds there changes nothing, so
131
+ * it neither re-projects nor re-queries: the relay runs on every change in
132
+ * its source group, on every brush frame there.
133
+ */
134
+ relayClause(key: string, clause: Clause): void;
111
135
  removeManual(key: string): void;
112
136
  /**
113
137
  * Remove a source's clause. Re-queries only when there was a clause to