@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.
- package/README.md +2 -0
- package/dist/components/axes/AxisBottom.svelte +23 -6
- package/dist/components/axes/AxisBottom.svelte.d.ts +10 -0
- package/dist/components/axes/AxisLeft.svelte +77 -8
- package/dist/components/axes/AxisLeft.svelte.d.ts +17 -0
- package/dist/components/axes/BandAxisTicks.svelte +1 -1
- package/dist/components/axes/axis-title.d.ts +30 -0
- package/dist/components/axes/axis-title.js +77 -0
- package/dist/components/charts/ChartCell.svelte +1 -1
- package/dist/components/charts/ChartContainer.svelte +10 -2
- package/dist/components/charts/ChartContainer.svelte.d.ts +7 -0
- package/dist/components/guides/legend/ContinuousColorLegend.svelte +7 -1
- package/dist/components/guides/legend/OrdinalColorLegend.svelte +8 -5
- package/dist/components/marks/AreaMarkX.svelte +2 -0
- package/dist/components/marks/AreaMarkY.svelte +2 -0
- package/dist/components/marks/BarMarkX.svelte +13 -1
- package/dist/components/marks/BarMarkY.svelte +13 -1
- package/dist/components/marks/DotsMark.svelte +2 -0
- package/dist/components/marks/LineMark.svelte +5 -1
- package/dist/components/marks/ParallelCoordinatesMark.svelte +4 -0
- package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +2 -0
- package/dist/components/marks/RectMark.svelte +2 -0
- package/dist/components/marks/RectXMark.svelte +2 -0
- package/dist/components/marks/RectYMark.svelte +2 -0
- package/dist/components/marks/RuleMarkX.svelte +2 -0
- package/dist/components/marks/RuleMarkY.svelte +2 -0
- package/dist/components/marks/TextMark.svelte +8 -9
- package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +6 -1
- package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +187 -18
- package/dist/components/marks/lifecycle/domain-queries.d.ts +3 -1
- package/dist/components/marks/lifecycle/domain-queries.js +11 -6
- package/dist/components/marks/lifecycle/domain-registration.js +2 -6
- package/dist/components/marks/lifecycle/other-rows.svelte.d.ts +104 -0
- package/dist/components/marks/lifecycle/other-rows.svelte.js +193 -0
- package/dist/components/marks/lifecycle/other-split.d.ts +80 -0
- package/dist/components/marks/lifecycle/other-split.js +112 -0
- package/dist/components/marks/lifecycle/query-builder.d.ts +26 -1
- package/dist/components/marks/lifecycle/query-builder.js +25 -5
- package/dist/components/marks/utils/reactive-config.d.ts +1 -1
- package/dist/components/marks/utils/reactive-config.js +6 -0
- package/dist/components/marks/utils/tooltip-helpers.d.ts +4 -1
- package/dist/components/marks/utils/tooltip-helpers.js +2 -1
- package/dist/components/rendering/BaseGPURenderer.svelte.d.ts +3 -1
- package/dist/components/rendering/BaseGPURenderer.svelte.js +4 -2
- package/dist/components/rendering/Rects.svelte +117 -23
- package/dist/components/rendering/Rects.svelte.d.ts +7 -0
- package/dist/components/rendering/activity-reads.d.ts +18 -0
- package/dist/components/rendering/activity-reads.js +19 -0
- package/dist/config/ChartConfig.svelte.d.ts +3 -1
- package/dist/gpu/MorphedChannels.d.ts +6 -32
- package/dist/gpu/MorphedChannels.js +45 -3
- package/dist/gpu/ScaleTemplateSystem.js +25 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/interactors/Interval1D.svelte +6 -1
- package/dist/interactors/Interval2D.svelte +6 -1
- package/dist/interactors/ParallelAxisSource.svelte +10 -1
- package/dist/interactors/Toggle.svelte +32 -3
- package/dist/interactors/Tooltip.svelte +138 -41
- package/dist/interactors/TooltipManager.svelte.d.ts +20 -5
- package/dist/interactors/TooltipManager.svelte.js +32 -7
- package/dist/interactors/other-tooltip.d.ts +16 -0
- package/dist/interactors/other-tooltip.js +22 -0
- package/dist/query-engine/FieldActivity.svelte.d.ts +50 -1
- package/dist/query-engine/FieldActivity.svelte.js +176 -5
- package/dist/query-engine/FilterGroup.svelte.d.ts +23 -1
- package/dist/query-engine/FilterGroup.svelte.js +43 -3
- package/dist/query-engine/FilterGroupRegistry.svelte.d.ts +31 -3
- package/dist/query-engine/FilterGroupRegistry.svelte.js +62 -15
- package/dist/query-engine/QueryEngine.js +26 -6
- package/dist/query-engine/categorical-identity.d.ts +15 -0
- package/dist/query-engine/categorical-identity.js +15 -0
- package/dist/query-engine/preagg/plan.d.ts +5 -0
- package/dist/query-engine/preagg/plan.js +31 -7
- package/dist/query-engine/toggle-predicate.d.ts +21 -0
- package/dist/query-engine/toggle-predicate.js +29 -1
- package/dist/scales/ActivityTable.d.ts +5 -1
- package/dist/scales/ActivityTable.js +12 -3
- package/dist/scales/BandScaleState.svelte.d.ts +13 -8
- package/dist/scales/BandScaleState.svelte.js +23 -16
- package/dist/spec/types.d.ts +2 -0
- package/dist/theme.css +5 -4
- package/dist/transforms/TransformCompiler.d.ts +37 -2
- package/dist/transforms/TransformCompiler.js +44 -1
- package/dist/transforms/index.js +32 -18
- package/dist/types.d.ts +41 -0
- 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
|
-
|
|
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)
|
|
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
|
|
6
|
-
|
|
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?.
|
|
40
|
-
let py = $derived(tm?.
|
|
44
|
+
let px = $derived(tm?.pointerClientX ?? 0);
|
|
45
|
+
let py = $derived(tm?.pointerClientY ?? 0);
|
|
41
46
|
|
|
42
|
-
//
|
|
43
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
66
|
-
let
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
const translate = $derived(`${
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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:
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
*
|
|
125
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
38
|
+
pointerClientX = $state(0);
|
|
39
|
+
pointerClientY = $state(0);
|
|
40
40
|
matchedRadius = $state(null);
|
|
41
41
|
// Focused point getters — used by Tooltip and HoverIndicator
|
|
42
42
|
get focusedPoint() {
|
|
@@ -45,6 +45,12 @@ export class TooltipManager {
|
|
|
45
45
|
get focusedMatch() {
|
|
46
46
|
return this.matchedPoints[this.focusedIndex] ?? null;
|
|
47
47
|
}
|
|
48
|
+
/** The focused point's Other numbers, or null where it is not an Other bar. */
|
|
49
|
+
get focusedOtherStats() {
|
|
50
|
+
const point = this.focusedPoint;
|
|
51
|
+
const stats = this.currentMarkId ? this.#marks.get(this.currentMarkId)?.otherStats : null;
|
|
52
|
+
return point && stats ? stats(point) : null;
|
|
53
|
+
}
|
|
48
54
|
// Backwards-compat getters
|
|
49
55
|
get currentPoint() {
|
|
50
56
|
return this.currentPoints[0] ?? null;
|
|
@@ -81,6 +87,15 @@ export class TooltipManager {
|
|
|
81
87
|
this.#pendingY = yData;
|
|
82
88
|
this.#hasPending = true;
|
|
83
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Run the hover query again at the last position, if the pointer is still
|
|
92
|
+
* in the chart: a mark's tooltip table was rewritten under a still pointer,
|
|
93
|
+
* so the row it shows is the old data's. The next frame runs it.
|
|
94
|
+
*/
|
|
95
|
+
refresh() {
|
|
96
|
+
if (this.#cursorActive)
|
|
97
|
+
this.#hasPending = true;
|
|
98
|
+
}
|
|
84
99
|
/**
|
|
85
100
|
* Cycle the focused point index by delta. Wraps around.
|
|
86
101
|
* Remembers the focused category so it persists across cursor moves.
|
|
@@ -129,12 +144,13 @@ export class TooltipManager {
|
|
|
129
144
|
return this.#chartConfig.getMarkLOD(this.currentMarkId) ?? null;
|
|
130
145
|
}
|
|
131
146
|
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
147
|
+
* The pointer's viewport (client) position, where the tooltip is placed: it
|
|
148
|
+
* renders in the top layer, outside the chart's clipping ancestors. Called from
|
|
149
|
+
* the chart's pointer move handler.
|
|
134
150
|
*/
|
|
135
|
-
|
|
136
|
-
this.
|
|
137
|
-
this.
|
|
151
|
+
updatePointerClient(x, y) {
|
|
152
|
+
this.pointerClientX = x;
|
|
153
|
+
this.pointerClientY = y;
|
|
138
154
|
}
|
|
139
155
|
// =========================================================================
|
|
140
156
|
// QUERY SCHEDULING (driven by animation loop)
|
|
@@ -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
|
-
/**
|
|
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
|
}
|