@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
@@ -62,7 +62,8 @@
62
62
  );
63
63
 
64
64
  const brushHandle: SourceHandle = filterGroup.register(source, {
65
- excludeMarkIds: () => new Set(chart.config.entries.keys()),
65
+ // Its own chart is not filtered by it (crossfilter).
66
+ chart: chart.config,
66
67
  activeDims: () => {
67
68
  const scale = getScale();
68
69
  if (!scale || !columnRef) return [];
@@ -96,7 +97,12 @@
96
97
  column: typeof resolvedField === 'string' ? resolvedField : (resolvedField?.toString() ?? ''),
97
98
  label: typeof resolvedField === 'string' ? resolvedField : resolvedField?.toString(),
98
99
  values: value ? [(value as number[])[0], (value as number[])[1]] : []
99
- })
100
+ }),
101
+ // The clause was removed from outside (clearAll, a filter pill): take the
102
+ // rectangle down too, which only this control holds.
103
+ onCleared: () => {
104
+ if (brush.active) clearBrush();
105
+ }
100
106
  });
101
107
 
102
108
  // Pointer state (reused to avoid GC)
@@ -54,7 +54,8 @@
54
54
  state instanceof LinearScaleState ? state.domain.target : (scale.domain() as [number, number]);
55
55
 
56
56
  const brushHandle: SourceHandle = filterGroup.register(source, {
57
- excludeMarkIds: () => new Set(chart.config.entries.keys()),
57
+ // Its own chart is not filtered by it (crossfilter).
58
+ chart: chart.config,
58
59
  activeDims: () => {
59
60
  if (!xColumnRef || !yColumnRef) return [];
60
61
  return [
@@ -96,7 +97,12 @@
96
97
  yColumn: resolvedYField,
97
98
  xValues: (value as unknown[][])?.[0],
98
99
  yValues: (value as unknown[][])?.[1]
99
- })
100
+ }),
101
+ // The clause was removed from outside (clearAll, a filter pill): take the
102
+ // rectangle down too, which only this control holds.
103
+ onCleared: () => {
104
+ if (brush.active) clearBrush();
105
+ }
100
106
  });
101
107
 
102
108
  // Pointer state (reused to avoid GC)
@@ -37,7 +37,8 @@
37
37
  filterGroup: FilterGroup;
38
38
  column: string;
39
39
  chartId: string;
40
- excludeMarkIds: () => Set<string>;
40
+ /** The chart the brush skips, or null for a chart that filters its own lines. */
41
+ chart: object | null;
41
42
  /** The axis's scale state now: removing another column renumbers the axes. */
42
43
  scaleState: () => LinearScaleState | null;
43
44
  /** The axes' pixel extent (the chart's inner height). */
@@ -47,13 +48,12 @@
47
48
  }
48
49
 
49
50
  // Keyed by column in the parent, so none of these change for an instance.
50
- const { filterGroup, column, chartId, excludeMarkIds, scaleState, pixelExtent, axes }: Props =
51
- $props();
51
+ const { filterGroup, column, chartId, chart, scaleState, pixelExtent, axes }: Props = $props();
52
52
 
53
53
  const columnRef = sqlColumn(column);
54
54
 
55
55
  const handle = filterGroup.register(`parallel-brush-${column}-${chartId}`, {
56
- excludeMarkIds,
56
+ chart,
57
57
  activeDims: () => {
58
58
  const state = scaleState();
59
59
  if (!state) return [];
@@ -83,7 +83,16 @@
83
83
  column,
84
84
  label: column,
85
85
  values: value ? [(value as number[])[0], (value as number[])[1]] : []
86
- })
86
+ }),
87
+ // The clause was removed from outside (clearAll, a filter pill): take this
88
+ // axis's rectangle down too, which only ParallelBrushes' state holds.
89
+ onCleared: () => {
90
+ const axis = axes.get(column);
91
+ if (!axis) return;
92
+ axis.brush.clear();
93
+ axis.lastEmitted.min = null;
94
+ axis.lastEmitted.max = null;
95
+ }
87
96
  });
88
97
 
89
98
  axes.set(column, { handle, brush: new BrushState1D(), lastEmitted: { min: null, max: null } });
@@ -31,7 +31,8 @@ interface Props {
31
31
  filterGroup: FilterGroup;
32
32
  column: string;
33
33
  chartId: string;
34
- excludeMarkIds: () => Set<string>;
34
+ /** The chart the brush skips, or null for a chart that filters its own lines. */
35
+ chart: object | null;
35
36
  /** The axis's scale state now: removing another column renumbers the axes. */
36
37
  scaleState: () => LinearScaleState | null;
37
38
  /** The axes' pixel extent (the chart's inner height). */
@@ -19,9 +19,9 @@
19
19
  interface Props {
20
20
  /** Filter group name — selects which FilterGroup these brushes write to. */
21
21
  selection?: string;
22
- /** When true, the parallel chart excludes itself from its own brush filters
23
- * (standard crossfilter behavior). When false (default), brushes self-filter
24
- * the chart — typical for parallel coordinates. */
22
+ /** When true, the brushes skip their own chart (standard crossfilter
23
+ * behavior). When false (default), they filter it — typical for parallel
24
+ * coordinates. */
25
25
  cross?: boolean;
26
26
  }
27
27
 
@@ -105,11 +105,10 @@
105
105
  return Math.min(30, (chart.innerWidth / (ps.columns.length - 1)) * 0.35);
106
106
  });
107
107
 
108
- // Cross-filter: when cross=true, exclude chart's own marks (standard crossfilter).
109
- // When cross=false (default for parallel coords), empty set — all marks self-filter.
110
- const excludeMarkIds = cross
111
- ? () => new Set(chart.config.entries.keys())
112
- : () => new Set<string>();
108
+ // Cross-filter: with cross=true the brushes skip their own chart (standard
109
+ // crossfilter). With cross=false, the default for parallel coordinates, the
110
+ // chart filters its own lines.
111
+ const skips = cross ? chart.config : null;
113
112
 
114
113
  /** An axis's scale state, looked up by column: removing a column renumbers the axes. */
115
114
  function scaleStateOf(col: string): LinearScaleState | null {
@@ -344,7 +343,7 @@
344
343
  {filterGroup}
345
344
  column={col}
346
345
  chartId={chart.chartId}
347
- {excludeMarkIds}
346
+ chart={skips}
348
347
  scaleState={() => scaleStateOf(col)}
349
348
  pixelExtent={() => chart.innerHeight}
350
349
  {axes}
@@ -1,9 +1,9 @@
1
1
  interface Props {
2
2
  /** Filter group name — selects which FilterGroup these brushes write to. */
3
3
  selection?: string;
4
- /** When true, the parallel chart excludes itself from its own brush filters
5
- * (standard crossfilter behavior). When false (default), brushes self-filter
6
- * the chart — typical for parallel coordinates. */
4
+ /** When true, the brushes skip their own chart (standard crossfilter
5
+ * behavior). When false (default), they filter it — typical for parallel
6
+ * coordinates. */
7
7
  cross?: boolean;
8
8
  }
9
9
  declare const ParallelBrushes: import("svelte").Component<Props, {}, "">;
@@ -1,8 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { getContext, onDestroy } from 'svelte';
3
3
  import type { ChartContext } from '../types';
4
- import type { ScaleRegistry } from '../scales/ScaleRegistry.svelte';
5
- import type { ActiveValueScale, ToggleHandle } from '../query-engine/FilterGroup.svelte';
4
+ import type { ToggleHandle } from '../query-engine/FilterGroup.svelte';
6
5
  import type { FilterGroupRegistry } from '../query-engine/FilterGroupRegistry.svelte';
7
6
  import type { BandScaleState } from '../scales/BandScaleState.svelte';
8
7
  import BandLabelToggle from './BandLabelToggle.svelte';
@@ -16,9 +15,10 @@
16
15
  * - Band axis label: toggles the axis's own source field, whatever the
17
16
  * declared field is. Labels are a ready-made category picker.
18
17
  *
19
- * Toggles on different fields AND together. The source chart is excluded
20
- * from its own toggles (crossfilter semantics) and dims instead via scale
21
- * active-value projection; other charts in the filter group filter.
18
+ * Toggles on different fields AND together. The chart skips its own toggles
19
+ * (crossfilter): its marks that read the toggled field dim, the rest keep
20
+ * their data. Elsewhere in the group, marks that read the field dim and the
21
+ * rest filter.
22
22
  */
23
23
 
24
24
  interface Props {
@@ -32,33 +32,18 @@
32
32
  const filterGroups = getContext<FilterGroupRegistry>('filter-groups');
33
33
  const filterGroup = filterGroups.get(selection);
34
34
  const chart = getContext<ChartContext>('chart');
35
- const scaleRegistry = getContext<ScaleRegistry>('scale-registry');
36
35
 
37
36
  // Normalize field to array for multi-column support
38
37
  const fields = Array.isArray(field) ? field : [field];
39
38
  const declaredField = fields.length === 1 ? fields[0] : null;
40
39
 
41
- /** Marks in this chart — excluded so the source dims rather than filters. */
42
- const excludeMarkIds = () => new Set(chart.config.entries.keys());
43
-
44
- /** Scales keyed by the toggled field: its color scale and any band scale
45
- * whose source column is that field. Never a band scale for another field. */
46
- function scalesFor(toggleField: string): ActiveValueScale[] {
47
- const out: ActiveValueScale[] = [];
48
- const color = scaleRegistry?.getScaleByField(toggleField, 'color');
49
- if (color) out.push(color as unknown as ActiveValueScale);
50
- for (const axis of ['x', 'y'] as const) {
51
- if (chart.scaleTypes?.[axis] !== 'band') continue;
52
- const band = chart[`${axis}ScaleState`] as BandScaleState | null | undefined;
53
- if (band && band.sourceField === toggleField) out.push(band as unknown as ActiveValueScale);
54
- }
55
- return out;
56
- }
40
+ /**
41
+ * This chart skips its own toggles (crossfilter): its marks keyed by the
42
+ * toggled field dim, the rest keep their data.
43
+ */
44
+ const skips = chart.config;
57
45
 
58
- const declared = filterGroup.registerToggle(fields, {
59
- excludeMarkIds,
60
- scales: () => (declaredField ? scalesFor(declaredField) : [])
61
- });
46
+ const declared = filterGroup.registerToggle(fields, { chart: skips });
62
47
  chart.activators.add(declared.activate);
63
48
 
64
49
  /** One BandLabelToggle per band axis field (see bandFieldsOf). */
@@ -85,15 +70,37 @@
85
70
  function bandCategoryAt(
86
71
  axis: 'x' | 'y',
87
72
  innerPos: number
88
- ): { field: string; value: unknown } | null {
73
+ ): { field: string; value: unknown; otherId: number | null } | null {
89
74
  if (chart.scaleTypes?.[axis] !== 'band') return null;
90
75
  const bandState = chart[`${axis}ScaleState`] as BandScaleState;
91
76
  const extent = axis === 'x' ? chart.innerWidth : chart.innerHeight;
92
77
  const slot = bandState.invertScreenToData(innerPos, extent, axis === 'y');
93
78
  if (slot == null) return null;
94
79
  const id = bandState.idAtSlot(slot);
95
- const value = id == null ? null : (bandState.categoryAt(id) ?? null);
96
- return value == null ? null : { field: bandState.sourceField, value };
80
+ // undefined is "no category here"; null is the NULL category, a real one.
81
+ const value = id == null ? undefined : bandState.categoryAt(id);
82
+ if (value === undefined) return null;
83
+ // The axis's Other bucket: its label is no value, so the click selects
84
+ // what it holds instead.
85
+ const otherId = id != null && id === bandState.otherId ? id : null;
86
+ return { field: bandState.sourceField, value, otherId };
87
+ }
88
+
89
+ /**
90
+ * The Other bucket an inner click hit, or null: the hit row's band id on an
91
+ * axis of the declared field, when it is that axis's Other. The label in
92
+ * the row can't say, so the tooltip keeps the raw id beside it.
93
+ */
94
+ function otherHit(point: Record<string, unknown>): number | null {
95
+ if (!declaredField) return null;
96
+ for (const axis of ['x', 'y'] as const) {
97
+ if (chart.scaleTypes?.[axis] !== 'band') continue;
98
+ const bandState = chart[`${axis}ScaleState`] as BandScaleState;
99
+ if (bandState.sourceField !== declaredField) continue;
100
+ const id = point[axis === 'x' ? '__bandIdX' : '__bandIdY'];
101
+ if (typeof id === 'number' && id === bandState.otherId) return id;
102
+ }
103
+ return null;
97
104
  }
98
105
 
99
106
  chart.onChartClick = (event) => {
@@ -105,8 +112,15 @@
105
112
  // Path 1: Inner area click — use tooltip hit data (don't require visible —
106
113
  // pointer micro-movement during click hides tooltip but data stays valid)
107
114
  if (xInBounds && yInBounds && tm?.currentPoint) {
115
+ const otherId = otherHit(tm.currentPoint);
116
+ if (otherId !== null) {
117
+ const facetId = tm.currentPoint.__facetId;
118
+ declared.toggleOther(otherId, typeof facetId === 'number' ? facetId : null, isMulti);
119
+ return;
120
+ }
108
121
  const tuple = fields.map((f) => tm.currentPoint![f]);
109
- if (tuple.some((v) => v == null)) return;
122
+ // A missing field is undefined; null is the NULL category, selectable.
123
+ if (tuple.some((v) => v === undefined)) return;
110
124
  declared.toggle(tuple, isMulti);
111
125
  return;
112
126
  }
@@ -119,7 +133,9 @@
119
133
  ? bandCategoryAt('x', innerPoint.x)
120
134
  : null;
121
135
  if (hit) {
122
- toggleFor(hit.field)?.toggle([hit.value], isMulti);
136
+ const toggle = toggleFor(hit.field);
137
+ if (hit.otherId !== null) toggle?.toggleOther(hit.otherId, null, isMulti);
138
+ else toggle?.toggle([hit.value], isMulti);
123
139
  return;
124
140
  }
125
141
 
@@ -139,11 +155,5 @@
139
155
  </script>
140
156
 
141
157
  {#each bandFields as bandField (bandField)}
142
- <BandLabelToggle
143
- {filterGroup}
144
- field={bandField}
145
- {excludeMarkIds}
146
- scales={() => scalesFor(bandField)}
147
- handles={bandToggles}
148
- />
158
+ <BandLabelToggle {filterGroup} field={bandField} chart={skips} handles={bandToggles} />
149
159
  {/each}
@@ -6,9 +6,10 @@
6
6
  * - Band axis label: toggles the axis's own source field, whatever the
7
7
  * declared field is. Labels are a ready-made category picker.
8
8
  *
9
- * Toggles on different fields AND together. The source chart is excluded
10
- * from its own toggles (crossfilter semantics) and dims instead via scale
11
- * active-value projection; other charts in the filter group filter.
9
+ * Toggles on different fields AND together. The chart skips its own toggles
10
+ * (crossfilter): its marks that read the toggled field dim, the rest keep
11
+ * their data. Elsewhere in the group, marks that read the field dim and the
12
+ * rest filter.
12
13
  */
13
14
  interface Props {
14
15
  /** Filter group name — selects which FilterGroup this toggle writes to. */
@@ -2,8 +2,12 @@
2
2
  Tooltip.svelte - Display component for data tooltips.
3
3
 
4
4
  Reads from TooltipManager (via context) and renders a floating tooltip
5
- at the cursor position. Uses CSS anchor positioning for automatic
6
- edge-aware placement.
5
+ at the cursor. It is a manual popover, so it paints in the top layer and no
6
+ overflow, scroll area, stacking context or transformed ancestor clips it. It
7
+ stays in the chart's DOM, so inherited theme variables and scoped styles still
8
+ apply. Placed in viewport pixels with the `translate` property (no layout per
9
+ move), flipped and clamped against the viewport, and measured by a
10
+ ResizeObserver rather than by reading its size.
7
11
 
8
12
  Always renders one point at a time (stable layout). When multiple
9
13
  points match, colored dots appear at the bottom and the user can
@@ -12,7 +16,9 @@
12
16
  <script lang="ts">
13
17
  import { getContext } from 'svelte';
14
18
  import { format as d3format } from 'd3-format';
19
+ import { categoryLabel } from '../query-engine/categorical-identity';
15
20
  import type { ChartContext, ContinuousScaleState } from '../types';
21
+ import { otherTooltipRows } from './other-tooltip';
16
22
 
17
23
  const commaFormat = d3format(',.4~g');
18
24
 
@@ -35,40 +41,76 @@
35
41
  let colorField = $derived(tm?.currentColorField ?? null);
36
42
  let channelFields = $derived(tm?.currentChannelFields ?? null);
37
43
  let displayFields = $derived(tm?.currentDisplayFields ?? null);
38
- let px = $derived(tm?.pointerScreenX ?? 0);
39
- let py = $derived(tm?.pointerScreenY ?? 0);
44
+ let px = $derived(tm?.pointerClientX ?? 0);
45
+ let py = $derived(tm?.pointerClientY ?? 0);
40
46
 
41
- // High-water mark for tooltip width — grows but never shrinks.
42
- let tooltipEl: HTMLDivElement;
47
+ // The tooltip's size, from a ResizeObserver: read after the browser's own
48
+ // layout, so measuring forces none. Its width is a high-water mark (grows,
49
+ // never shrinks) so it does not jitter between points: content wider than
50
+ // min-width makes the observed width exceed it, and min-width follows.
43
51
  let minWidth = $state(0);
44
52
  let tooltipW = $state(0);
45
53
  let tooltipH = $state(0);
46
54
 
47
- $effect(() => {
48
- // Dependencies: re-measure when focused point or index changes
49
- void point;
50
- void focusedIndex;
51
- if (!visible || !tooltipEl) return;
52
- const prev = tooltipEl.style.minWidth;
53
- tooltipEl.style.minWidth = '0px';
54
- const natural = tooltipEl.offsetWidth;
55
- tooltipEl.style.minWidth = prev;
56
- if (natural > minWidth) minWidth = natural;
57
- tooltipW = tooltipEl.offsetWidth;
58
- tooltipH = tooltipEl.offsetHeight;
59
- });
55
+ function measure(el: HTMLElement) {
56
+ const observer = new ResizeObserver((entries) => {
57
+ const box = entries[entries.length - 1].borderBoxSize[0];
58
+ // A closed popover is display: none and reports 0: keep the last size.
59
+ if (box.inlineSize === 0) return;
60
+ tooltipW = box.inlineSize;
61
+ tooltipH = box.blockSize;
62
+ if (box.inlineSize > minWidth) minWidth = box.inlineSize;
63
+ });
64
+ observer.observe(el);
65
+ return () => observer.disconnect();
66
+ }
67
+
68
+ // The viewport, without the scrollbars. Read when the tooltip opens and on
69
+ // resize, never per move.
70
+ let viewW = $state(0);
71
+ let viewH = $state(0);
72
+ function readViewport() {
73
+ const root = document.documentElement;
74
+ viewW = root.clientWidth;
75
+ viewH = root.clientHeight;
76
+ }
77
+
78
+ // Open in the top layer while shown; closed again once it has faded out, so a
79
+ // page of charts keeps at most the hovered one's tooltip there.
80
+ function popover(el: HTMLElement) {
81
+ if (visible && !el.matches(':popover-open')) {
82
+ readViewport();
83
+ el.showPopover();
84
+ }
85
+ }
86
+ function handleTransitionEnd(event: TransitionEvent) {
87
+ const el = event.currentTarget as HTMLElement;
88
+ if (event.propertyName === 'opacity' && !visible && el.matches(':popover-open')) {
89
+ el.hidePopover();
90
+ }
91
+ }
60
92
 
61
- // Flip when tooltip would overflow chart bounds, with hysteresis to prevent jitter
93
+ // Left of the cursor when it fits, else right; below it, else above. With
94
+ // hysteresis so it does not jitter at the edge. Then clamped into the viewport,
95
+ // for a tooltip that fits on neither side.
62
96
  const GAP = 12;
63
97
  const HYSTERESIS = 40;
64
- let flippedX = $state(true);
65
- let flippedY = $state(false);
66
- $effect(() => {
98
+ const MARGIN = 8;
99
+ let leftOfCursor = true;
100
+ let aboveCursor = false;
101
+ const flippedX = $derived.by(() => {
67
102
  const spaceLeft = px - tooltipW - GAP;
68
- flippedX = spaceLeft >= 0 && (!flippedX ? spaceLeft >= HYSTERESIS : true);
69
- const spaceY = chart.height - py - tooltipH;
70
- flippedY = spaceY < 0 || (flippedY && spaceY < HYSTERESIS);
103
+ leftOfCursor = spaceLeft >= 0 && (leftOfCursor || spaceLeft >= HYSTERESIS);
104
+ return leftOfCursor;
71
105
  });
106
+ const flippedY = $derived.by(() => {
107
+ const spaceBelow = viewH - py - tooltipH;
108
+ aboveCursor = spaceBelow < 0 || (aboveCursor && spaceBelow < HYSTERESIS);
109
+ return aboveCursor;
110
+ });
111
+ const clamp = (v: number, max: number) => Math.max(MARGIN, Math.min(v, max - MARGIN));
112
+ const left = $derived(clamp(flippedX ? px - tooltipW - GAP : px + GAP, viewW - tooltipW));
113
+ const top = $derived(clamp(flippedY ? py - tooltipH + 4 : py - 4, viewH - tooltipH));
72
114
 
73
115
  // Transform origin: corner nearest the anchor point
74
116
  let originX = $derived(flippedX ? '100%' : '0%');
@@ -94,7 +136,8 @@
94
136
  if (!point) return [];
95
137
  if (displayFields) return displayFields.filter((k) => k in point);
96
138
  if (fields) return fields.filter((k) => k in point);
97
- return Object.keys(point);
139
+ // Keys starting with `_` are kept for clicks (a band's raw id), never shown.
140
+ return Object.keys(point).filter((k) => !k.startsWith('_'));
98
141
  });
99
142
 
100
143
  // Scale-aware formatters for numeric values (same as MarginLabel)
@@ -109,6 +152,14 @@
109
152
  : null
110
153
  );
111
154
 
155
+ // An Other bar's rows, after the point's own: written with the value axis's format.
156
+ let otherRows = $derived.by(() => {
157
+ const stats = tm?.focusedOtherStats;
158
+ if (!stats || !point) return [];
159
+ const format = ('__bandIdX' in point ? yFormat : xFormat) ?? commaFormat;
160
+ return otherTooltipRows(stats, format);
161
+ });
162
+
112
163
  function formatValue(key: string, value: unknown) {
113
164
  if (formatters[key]) return formatters[key](value);
114
165
  if (typeof value === 'number') {
@@ -117,25 +168,28 @@
117
168
  if (fmt) return fmt(value);
118
169
  return commaFormat(value);
119
170
  }
120
- return String(value);
171
+ return String(categoryLabel(value));
121
172
  }
122
173
 
123
174
  const channelIcons: Record<string, string> = { x: '\u2192', y: '\u2191' };
124
175
 
125
- // Computed style values — kept here so Prettier can't break them by line-wrapping
126
- const translateX = $derived(flippedX ? 'calc(-100% - 12px)' : '12px');
127
- const translateY = $derived(flippedY ? 'calc(-100% + 4px)' : '-4px');
128
- const translate = $derived(`${translateX} ${translateY}`);
176
+ // Computed style values — kept here so Prettier can't break them by line-wrapping.
177
+ // `translate`, not `transform` or left/top: it moves the box without layout, and
178
+ // it applies after `scale`, so the pop-in scales about the box's own corner.
179
+ const translate = $derived(`${left}px ${top}px`);
129
180
  const transformOrigin = $derived(`${originX} ${originY}`);
130
181
  </script>
131
182
 
132
- <!-- Tooltip positioned via JS with flip detection -->
183
+ <svelte:window onresize={readViewport} />
184
+
185
+ <!-- In the top layer, placed in viewport pixels with flip detection -->
133
186
  <div
134
187
  class="tooltip"
135
188
  class:visible
136
- bind:this={tooltipEl}
137
- style:left="{px}px"
138
- style:top="{py}px"
189
+ popover="manual"
190
+ {@attach measure}
191
+ {@attach popover}
192
+ ontransitionend={handleTransitionEnd}
139
193
  style:translate
140
194
  style:transform-origin={transformOrigin}
141
195
  style:min-width="{minWidth}px"
@@ -154,6 +208,12 @@
154
208
  </span>
155
209
  </div>
156
210
  {/each}
211
+ {#each otherRows as row (row.key)}
212
+ <div class="row">
213
+ <span class="key">{row.key}</span>
214
+ <span class="value">{row.value}</span>
215
+ </div>
216
+ {/each}
157
217
  <div class="dots" class:show={isMulti}>
158
218
  {#each matched as m, i (i)}
159
219
  <span
@@ -167,24 +227,53 @@
167
227
 
168
228
  <style>
169
229
  .tooltip {
170
- position: absolute;
171
- background: var(--fjader-tooltip-bg, rgba(0, 0, 0, 0.88));
172
- color: var(--fjader-tooltip-text, #ffffff);
230
+ /* A popover in the top layer: fixed to the viewport at its origin, placed by
231
+ translate. Resets the UA popover box (inset, margin, border, overflow). */
232
+ position: fixed;
233
+ inset: 0 auto auto 0;
234
+ margin: 0;
235
+ border: 0;
236
+ overflow: visible;
237
+ /* Colours, highest first: the app's --fjader-tooltip-bg/-text; the mode a
238
+ data-fjader-tooltip ancestor picks; contrast. light-dark() resolves against
239
+ the color-scheme the tooltip inherits from the chart's DOM ancestors. */
240
+ background: var(
241
+ --fjader-tooltip-bg,
242
+ var(--_fjader-tooltip-bg, light-dark(rgba(0, 0, 0, 0.88), rgba(255, 255, 255, 0.92)))
243
+ );
244
+ color: var(--fjader-tooltip-text, var(--_fjader-tooltip-text, light-dark(#ffffff, #111827)));
173
245
  padding: 6px 10px;
174
246
  border-radius: 4px;
175
247
  font-family: var(--fjader-font, system-ui, sans-serif);
176
248
  font-size: 12px;
177
249
  line-height: 1.4;
178
250
  pointer-events: none;
179
- z-index: 1000;
180
251
  white-space: nowrap;
181
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
252
+ box-shadow:
253
+ 0 0 0 1px var(--_fjader-tooltip-ring, transparent),
254
+ 0 2px 8px rgba(0, 0, 0, 0.25);
182
255
  opacity: 0;
183
256
  scale: 0.78;
184
257
  transition:
185
258
  opacity 100ms ease-out,
186
259
  scale 200ms cubic-bezier(0.34, 1.3, 0.64, 1);
187
- will-change: opacity, scale;
260
+ will-change: opacity, scale, translate;
261
+ }
262
+
263
+ /* The mode, set on any ancestor (the page, a panel, a chart through ChartContainer's
264
+ tooltipScheme): custom properties inherit, so the nearest one wins. `contrast` is
265
+ the opposite of the scheme, `follow` the same, with a ring to separate it from a
266
+ background of its own colour. */
267
+ :global([data-fjader-tooltip='contrast']) {
268
+ --_fjader-tooltip-bg: light-dark(rgba(0, 0, 0, 0.88), rgba(255, 255, 255, 0.92));
269
+ --_fjader-tooltip-text: light-dark(#ffffff, #111827);
270
+ --_fjader-tooltip-ring: transparent;
271
+ }
272
+
273
+ :global([data-fjader-tooltip='follow']) {
274
+ --_fjader-tooltip-bg: light-dark(rgba(255, 255, 255, 0.96), rgba(31, 41, 55, 0.96));
275
+ --_fjader-tooltip-text: light-dark(#111827, #f9fafb);
276
+ --_fjader-tooltip-ring: light-dark(rgba(17, 24, 39, 0.1), rgba(255, 255, 255, 0.12));
188
277
  }
189
278
 
190
279
  .tooltip.visible {
@@ -192,6 +281,15 @@
192
281
  scale: 1;
193
282
  }
194
283
 
284
+ /* Opening the popover takes it out of display: none, which runs no transition:
285
+ start the pop-in from the hidden look. */
286
+ @starting-style {
287
+ .tooltip.visible {
288
+ opacity: 0;
289
+ scale: 0.78;
290
+ }
291
+ }
292
+
195
293
  .row {
196
294
  display: flex;
197
295
  gap: 8px;
@@ -1,3 +1,4 @@
1
+ import type { OtherRowStats } from '../components/marks/lifecycle/other-rows.svelte';
1
2
  import type { QueryEngine } from '../query-engine/QueryEngine';
2
3
  import type { D3Scale, ScaleStateInstance } from '../types';
3
4
  import type { DimValue } from '../query-engine/categorical-identity';
@@ -50,6 +51,11 @@ interface TooltipMarkRegistration {
50
51
  };
51
52
  constantRadius?: number;
52
53
  displayFields?: string[] | null;
54
+ /**
55
+ * What an Other bar's point holds, for the tooltip to add its rows; read at
56
+ * display time, so a selection that changes under the pointer shows at once.
57
+ */
58
+ otherStats?: (point: DataRow) => OtherRowStats | null;
53
59
  proximityAxis: 'x' | 'y' | 'xy';
54
60
  enabled: boolean;
55
61
  }
@@ -83,11 +89,13 @@ export declare class TooltipManager {
83
89
  currentChannelFields: Record<string, string> | null;
84
90
  currentDisplayFields: string[] | null;
85
91
  visible: boolean;
86
- pointerScreenX: number;
87
- pointerScreenY: number;
92
+ pointerClientX: number;
93
+ pointerClientY: number;
88
94
  matchedRadius: number | null;
89
95
  get focusedPoint(): DataRow | null;
90
96
  get focusedMatch(): MatchedPoint | null;
97
+ /** The focused point's Other numbers, or null where it is not an Other bar. */
98
+ get focusedOtherStats(): OtherRowStats | null;
91
99
  get currentPoint(): DataRow | null;
92
100
  get currentColor(): string | null;
93
101
  get matchedScreenX(): number | null;
@@ -97,6 +105,12 @@ export declare class TooltipManager {
97
105
  * Update the cursor's data-space position. Called directly from pointer move handler.
98
106
  */
99
107
  updatePosition(xData: number | null, yData: number | null): void;
108
+ /**
109
+ * Run the hover query again at the last position, if the pointer is still
110
+ * in the chart: a mark's tooltip table was rewritten under a still pointer,
111
+ * so the row it shows is the old data's. The next frame runs it.
112
+ */
113
+ refresh(): void;
100
114
  /**
101
115
  * Cycle the focused point index by delta. Wraps around.
102
116
  * Remembers the focused category so it persists across cursor moves.
@@ -121,10 +135,11 @@ export declare class TooltipManager {
121
135
  */
122
136
  get currentMarkLOD(): MarkLODSurface | null;
123
137
  /**
124
- * Update raw screen position for tooltip positioning.
125
- * Called from chart container's pointer move handler.
138
+ * The pointer's viewport (client) position, where the tooltip is placed: it
139
+ * renders in the top layer, outside the chart's clipping ancestors. Called from
140
+ * the chart's pointer move handler.
126
141
  */
127
- updatePointerScreen(x: number, y: number): void;
142
+ updatePointerClient(x: number, y: number): void;
128
143
  /**
129
144
  * Called once per animation frame by GPUCoordinator's render loop.
130
145
  */