@fjader/svelte 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) 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 +1 -1
  7. package/dist/components/axes/axis-title.d.ts +30 -0
  8. package/dist/components/axes/axis-title.js +77 -0
  9. package/dist/components/charts/ChartCell.svelte +1 -1
  10. package/dist/components/charts/ChartContainer.svelte +10 -2
  11. package/dist/components/charts/ChartContainer.svelte.d.ts +7 -0
  12. package/dist/components/guides/legend/ContinuousColorLegend.svelte +7 -1
  13. package/dist/components/guides/legend/OrdinalColorLegend.svelte +8 -5
  14. package/dist/components/marks/AreaMarkX.svelte +2 -0
  15. package/dist/components/marks/AreaMarkY.svelte +2 -0
  16. package/dist/components/marks/BarMarkX.svelte +13 -1
  17. package/dist/components/marks/BarMarkY.svelte +13 -1
  18. package/dist/components/marks/DotsMark.svelte +2 -0
  19. package/dist/components/marks/LineMark.svelte +5 -1
  20. package/dist/components/marks/ParallelCoordinatesMark.svelte +4 -0
  21. package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +2 -0
  22. package/dist/components/marks/RectMark.svelte +2 -0
  23. package/dist/components/marks/RectXMark.svelte +2 -0
  24. package/dist/components/marks/RectYMark.svelte +2 -0
  25. package/dist/components/marks/RuleMarkX.svelte +2 -0
  26. package/dist/components/marks/RuleMarkY.svelte +2 -0
  27. package/dist/components/marks/TextMark.svelte +8 -9
  28. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +6 -1
  29. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +187 -18
  30. package/dist/components/marks/lifecycle/domain-queries.d.ts +3 -1
  31. package/dist/components/marks/lifecycle/domain-queries.js +11 -6
  32. package/dist/components/marks/lifecycle/domain-registration.js +2 -6
  33. package/dist/components/marks/lifecycle/other-rows.svelte.d.ts +104 -0
  34. package/dist/components/marks/lifecycle/other-rows.svelte.js +193 -0
  35. package/dist/components/marks/lifecycle/other-split.d.ts +80 -0
  36. package/dist/components/marks/lifecycle/other-split.js +112 -0
  37. package/dist/components/marks/lifecycle/query-builder.d.ts +26 -1
  38. package/dist/components/marks/lifecycle/query-builder.js +25 -5
  39. package/dist/components/marks/utils/reactive-config.d.ts +1 -1
  40. package/dist/components/marks/utils/reactive-config.js +6 -0
  41. package/dist/components/marks/utils/tooltip-helpers.d.ts +4 -1
  42. package/dist/components/marks/utils/tooltip-helpers.js +2 -1
  43. package/dist/components/rendering/BaseGPURenderer.svelte.d.ts +3 -1
  44. package/dist/components/rendering/BaseGPURenderer.svelte.js +4 -2
  45. package/dist/components/rendering/Rects.svelte +117 -23
  46. package/dist/components/rendering/Rects.svelte.d.ts +7 -0
  47. package/dist/components/rendering/activity-reads.d.ts +18 -0
  48. package/dist/components/rendering/activity-reads.js +19 -0
  49. package/dist/config/ChartConfig.svelte.d.ts +3 -1
  50. package/dist/gpu/MorphedChannels.d.ts +6 -32
  51. package/dist/gpu/MorphedChannels.js +45 -3
  52. package/dist/gpu/ScaleTemplateSystem.js +25 -4
  53. package/dist/index.d.ts +2 -0
  54. package/dist/index.js +3 -0
  55. package/dist/interactors/Interval1D.svelte +6 -1
  56. package/dist/interactors/Interval2D.svelte +6 -1
  57. package/dist/interactors/ParallelAxisSource.svelte +10 -1
  58. package/dist/interactors/Toggle.svelte +32 -3
  59. package/dist/interactors/Tooltip.svelte +138 -41
  60. package/dist/interactors/TooltipManager.svelte.d.ts +20 -5
  61. package/dist/interactors/TooltipManager.svelte.js +32 -7
  62. package/dist/interactors/other-tooltip.d.ts +16 -0
  63. package/dist/interactors/other-tooltip.js +22 -0
  64. package/dist/query-engine/FieldActivity.svelte.d.ts +50 -1
  65. package/dist/query-engine/FieldActivity.svelte.js +176 -5
  66. package/dist/query-engine/FilterGroup.svelte.d.ts +23 -1
  67. package/dist/query-engine/FilterGroup.svelte.js +43 -3
  68. package/dist/query-engine/FilterGroupRegistry.svelte.d.ts +31 -3
  69. package/dist/query-engine/FilterGroupRegistry.svelte.js +62 -15
  70. package/dist/query-engine/QueryEngine.js +26 -6
  71. package/dist/query-engine/categorical-identity.d.ts +15 -0
  72. package/dist/query-engine/categorical-identity.js +15 -0
  73. package/dist/query-engine/preagg/plan.d.ts +5 -0
  74. package/dist/query-engine/preagg/plan.js +31 -7
  75. package/dist/query-engine/toggle-predicate.d.ts +21 -0
  76. package/dist/query-engine/toggle-predicate.js +29 -1
  77. package/dist/scales/ActivityTable.d.ts +5 -1
  78. package/dist/scales/ActivityTable.js +12 -3
  79. package/dist/scales/BandScaleState.svelte.d.ts +13 -8
  80. package/dist/scales/BandScaleState.svelte.js +23 -16
  81. package/dist/spec/types.d.ts +2 -0
  82. package/dist/theme.css +5 -4
  83. package/dist/transforms/TransformCompiler.d.ts +37 -2
  84. package/dist/transforms/TransformCompiler.js +44 -1
  85. package/dist/transforms/index.js +32 -18
  86. package/dist/types.d.ts +41 -0
  87. package/package.json +3 -2
@@ -70,7 +70,7 @@
70
70
  function bandCategoryAt(
71
71
  axis: 'x' | 'y',
72
72
  innerPos: number
73
- ): { field: string; value: unknown } | null {
73
+ ): { field: string; value: unknown; otherId: number | null } | null {
74
74
  if (chart.scaleTypes?.[axis] !== 'band') return null;
75
75
  const bandState = chart[`${axis}ScaleState`] as BandScaleState;
76
76
  const extent = axis === 'x' ? chart.innerWidth : chart.innerHeight;
@@ -79,7 +79,28 @@
79
79
  const id = bandState.idAtSlot(slot);
80
80
  // undefined is "no category here"; null is the NULL category, a real one.
81
81
  const value = id == null ? undefined : bandState.categoryAt(id);
82
- return value === undefined ? null : { field: bandState.sourceField, value };
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;
83
104
  }
84
105
 
85
106
  chart.onChartClick = (event) => {
@@ -91,6 +112,12 @@
91
112
  // Path 1: Inner area click — use tooltip hit data (don't require visible —
92
113
  // pointer micro-movement during click hides tooltip but data stays valid)
93
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
+ }
94
121
  const tuple = fields.map((f) => tm.currentPoint![f]);
95
122
  // A missing field is undefined; null is the NULL category, selectable.
96
123
  if (tuple.some((v) => v === undefined)) return;
@@ -106,7 +133,9 @@
106
133
  ? bandCategoryAt('x', innerPoint.x)
107
134
  : null;
108
135
  if (hit) {
109
- 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);
110
139
  return;
111
140
  }
112
141
 
@@ -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
@@ -14,6 +18,7 @@
14
18
  import { format as d3format } from 'd3-format';
15
19
  import { categoryLabel } from '../query-engine/categorical-identity';
16
20
  import type { ChartContext, ContinuousScaleState } from '../types';
21
+ import { otherTooltipRows } from './other-tooltip';
17
22
 
18
23
  const commaFormat = d3format(',.4~g');
19
24
 
@@ -36,40 +41,76 @@
36
41
  let colorField = $derived(tm?.currentColorField ?? null);
37
42
  let channelFields = $derived(tm?.currentChannelFields ?? null);
38
43
  let displayFields = $derived(tm?.currentDisplayFields ?? null);
39
- let px = $derived(tm?.pointerScreenX ?? 0);
40
- let py = $derived(tm?.pointerScreenY ?? 0);
44
+ let px = $derived(tm?.pointerClientX ?? 0);
45
+ let py = $derived(tm?.pointerClientY ?? 0);
41
46
 
42
- // High-water mark for tooltip width — grows but never shrinks.
43
- 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.
44
51
  let minWidth = $state(0);
45
52
  let tooltipW = $state(0);
46
53
  let tooltipH = $state(0);
47
54
 
48
- $effect(() => {
49
- // Dependencies: re-measure when focused point or index changes
50
- void point;
51
- void focusedIndex;
52
- if (!visible || !tooltipEl) return;
53
- const prev = tooltipEl.style.minWidth;
54
- tooltipEl.style.minWidth = '0px';
55
- const natural = tooltipEl.offsetWidth;
56
- tooltipEl.style.minWidth = prev;
57
- if (natural > minWidth) minWidth = natural;
58
- tooltipW = tooltipEl.offsetWidth;
59
- tooltipH = tooltipEl.offsetHeight;
60
- });
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
+ }
61
92
 
62
- // 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.
63
96
  const GAP = 12;
64
97
  const HYSTERESIS = 40;
65
- let flippedX = $state(true);
66
- let flippedY = $state(false);
67
- $effect(() => {
98
+ const MARGIN = 8;
99
+ let leftOfCursor = true;
100
+ let aboveCursor = false;
101
+ const flippedX = $derived.by(() => {
68
102
  const spaceLeft = px - tooltipW - GAP;
69
- flippedX = spaceLeft >= 0 && (!flippedX ? spaceLeft >= HYSTERESIS : true);
70
- const spaceY = chart.height - py - tooltipH;
71
- flippedY = spaceY < 0 || (flippedY && spaceY < HYSTERESIS);
103
+ leftOfCursor = spaceLeft >= 0 && (leftOfCursor || spaceLeft >= HYSTERESIS);
104
+ return leftOfCursor;
72
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));
73
114
 
74
115
  // Transform origin: corner nearest the anchor point
75
116
  let originX = $derived(flippedX ? '100%' : '0%');
@@ -95,7 +136,8 @@
95
136
  if (!point) return [];
96
137
  if (displayFields) return displayFields.filter((k) => k in point);
97
138
  if (fields) return fields.filter((k) => k in point);
98
- 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('_'));
99
141
  });
100
142
 
101
143
  // Scale-aware formatters for numeric values (same as MarginLabel)
@@ -110,6 +152,14 @@
110
152
  : null
111
153
  );
112
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
+
113
163
  function formatValue(key: string, value: unknown) {
114
164
  if (formatters[key]) return formatters[key](value);
115
165
  if (typeof value === 'number') {
@@ -123,20 +173,23 @@
123
173
 
124
174
  const channelIcons: Record<string, string> = { x: '\u2192', y: '\u2191' };
125
175
 
126
- // Computed style values — kept here so Prettier can't break them by line-wrapping
127
- const translateX = $derived(flippedX ? 'calc(-100% - 12px)' : '12px');
128
- const translateY = $derived(flippedY ? 'calc(-100% + 4px)' : '-4px');
129
- 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`);
130
180
  const transformOrigin = $derived(`${originX} ${originY}`);
131
181
  </script>
132
182
 
133
- <!-- 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 -->
134
186
  <div
135
187
  class="tooltip"
136
188
  class:visible
137
- bind:this={tooltipEl}
138
- style:left="{px}px"
139
- style:top="{py}px"
189
+ popover="manual"
190
+ {@attach measure}
191
+ {@attach popover}
192
+ ontransitionend={handleTransitionEnd}
140
193
  style:translate
141
194
  style:transform-origin={transformOrigin}
142
195
  style:min-width="{minWidth}px"
@@ -155,6 +208,12 @@
155
208
  </span>
156
209
  </div>
157
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}
158
217
  <div class="dots" class:show={isMulti}>
159
218
  {#each matched as m, i (i)}
160
219
  <span
@@ -168,24 +227,53 @@
168
227
 
169
228
  <style>
170
229
  .tooltip {
171
- position: absolute;
172
- background: var(--fjader-tooltip-bg, rgba(0, 0, 0, 0.88));
173
- 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)));
174
245
  padding: 6px 10px;
175
246
  border-radius: 4px;
176
247
  font-family: var(--fjader-font, system-ui, sans-serif);
177
248
  font-size: 12px;
178
249
  line-height: 1.4;
179
250
  pointer-events: none;
180
- z-index: 1000;
181
251
  white-space: nowrap;
182
- 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);
183
255
  opacity: 0;
184
256
  scale: 0.78;
185
257
  transition:
186
258
  opacity 100ms ease-out,
187
259
  scale 200ms cubic-bezier(0.34, 1.3, 0.64, 1);
188
- 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));
189
277
  }
190
278
 
191
279
  .tooltip.visible {
@@ -193,6 +281,15 @@
193
281
  scale: 1;
194
282
  }
195
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
+
196
293
  .row {
197
294
  display: flex;
198
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
  */
@@ -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)
@@ -562,6 +578,12 @@ export class TooltipManager {
562
578
  let rangeInsertIndex = -1;
563
579
  for (let i = 0; i < entries.length; i++) {
564
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
+ }
565
587
  // Skip internal columns
566
588
  if (key.startsWith('_') || key.startsWith('__dplot_'))
567
589
  continue;
@@ -583,6 +605,9 @@ export class TooltipManager {
583
605
  // (Toggle selects it) rather than fall back to the raw id.
584
606
  const category = this.#bandScale(key)?.categoryAt(value);
585
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;
586
611
  continue;
587
612
  }
588
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
+ }
@@ -2,6 +2,21 @@ import type { ClockLoop } from '../animation';
2
2
  import { ActivityTable } from '../scales/ActivityTable';
3
3
  import type { FieldIdentity } from './categorical-identity';
4
4
  /** What a FieldActivity needs from the page around it. */
5
+ /** One mark's Other rows for one bucket: what a click on it selects. */
6
+ export interface OtherEntry {
7
+ /** The mark's filter group, or null when it is filtered by the selection (`'filter'`). */
8
+ group: number | null;
9
+ /** One per Other row: a facet cell (null without facets) and its folded ids. */
10
+ rows: {
11
+ facetId: number | null;
12
+ ids: ArrayLike<number>;
13
+ }[];
14
+ }
15
+ /** What a click on Other does to a selection: values to set, add or remove. */
16
+ export interface OtherClick {
17
+ action: 'replace' | 'add' | 'remove';
18
+ values: unknown[];
19
+ }
5
20
  export interface FieldActivityHost {
6
21
  /** Drives the fades; null in unit tests and before a coordinator exists, and fades land at once. */
7
22
  readonly loop: ClockLoop | null;
@@ -35,12 +50,28 @@ export declare const DETACHED_HOST: FieldActivityHost;
35
50
  * `reconcile` runs from the packer and from `set`, never from a reader, so
36
51
  * nothing is resized or retargeted while a template renders.
37
52
  */
53
+ /**
54
+ * THE FREE SLOT. Each group's table holds one slot past the field's ids: 1 while
55
+ * the group selects nothing on the field, 0 while it selects anything, faded on
56
+ * the same clock as every id. The shader reads it as the last slot of the
57
+ * group's run (identityFreeActivity): an Other bar's unselected part dims by it,
58
+ * so clearing a selection fades it back in with the other bars, rather than its
59
+ * split sliding.
60
+ */
38
61
  export declare class FieldActivity {
39
62
  #private;
40
63
  readonly key: string;
41
64
  constructor(key: string, host?: FieldActivityHost);
42
- /** How many ids the tables cover: the applied identity's, 0 without one. */
65
+ /**
66
+ * How many slots the tables cover: one per id of the applied identity, and
67
+ * one past them (FREE_SLOT); 0 without an identity.
68
+ */
43
69
  get count(): number;
70
+ /**
71
+ * Group `index`'s selected values, or null when it selects nothing on this
72
+ * field. Reactive: a reader re-runs when the selection changes.
73
+ */
74
+ selectedValues(index: number): ReadonlySet<unknown> | null;
44
75
  /** Group `index`'s fade, or null when that group never selected on this field. */
45
76
  fadeOf(index: number): ActivityTable | null;
46
77
  /**
@@ -76,4 +107,22 @@ export declare class FieldActivity {
76
107
  * live: a label is never dimmed on a guess.
77
108
  */
78
109
  opacityOfId(index: number, id: number, inactiveOpacity?: number): number;
110
+ /** Register `markId`'s Other rows under `otherId`; null removes them. */
111
+ setOther(otherId: number, markId: string, entry: OtherEntry | null): void;
112
+ /**
113
+ * What a click on Other `otherId` does to group `group`'s selection, given
114
+ * the values it holds now (`current`), and records it. Null when nothing is
115
+ * registered under the id (no result yet) or it holds no members.
116
+ *
117
+ * - The members: every registered row under the id, or only facet cell
118
+ * `facetId`'s when the click came from a cell.
119
+ * - Shift-click adds them, or takes back what this Other added before if it
120
+ * is still selected.
121
+ * - A plain click on an Other whose marks all filter by the selection (no
122
+ * group: `'filter'`) drills down, replacing the selection: such an Other is
123
+ * computed inside the selection, so a second click means "the rest of it".
124
+ * - Otherwise a plain click toggles: it takes back what this Other added if
125
+ * that is still selected, and replaces the selection with the members if not.
126
+ */
127
+ otherClick(group: number, otherId: number, facetId: number | null, current: ReadonlySet<unknown>, multi: boolean): OtherClick | null;
79
128
  }