Skip to content

Commit 70179ae

Browse files
ljodeaclaudeCopilotgka
authored
fix: facet-aware Pointer and HTMLTooltip positioning (#509)
## Summary Stacked on #500. - **Pointer searched ALL quadtrees** instead of only the hovered facet's trees — replaced flat `trees[]` array with a keyed `SvelteMap<facetKey, Quadtree[]>` so only the hovered facet's trees are searched - **HTMLTooltip positioned via bounding rect diffs** — replaced fragile `getBoundingClientRect()` offset computation with scale-based offsets using `plot.scales.fx.fn(fxValue)` (available after #500) - **Tree array index ≠ facet index** — `groupFacetsAndZ` produces groups in data-encounter order while `FacetGrid` iterates domain order; keyed map decouples lookup from array ordering - **HTMLTooltip event listener leak** — cleanup called `removeEventListener('mouseleave')` but registration used `addEventListener('pointerleave')`; both now use `pointerleave` ### New shared helpers (`src/lib/helpers/facets.ts`) | Helper | Purpose | |--------|---------| | `facetKey(fx, fy)` | Stable `JSON.stringify` key for `(fxValue, fyValue)` pairs | | `invertBand(scale, domain, pixel)` | Invert a `d3.scaleBand` (no native `.invert()`) | | `findFacetFromDOM(target)` | Walk DOM up to `g.facet`, read `data-facet-x/y` attributes | | `detectFacet(evt, plot)` | DOM walk first, band-inversion fallback; returns `{ fxValue, fyValue, offsetX, offsetY }` | ## Test plan - [x] `invertBand` — 5 unit tests (first/second band, out of range, single domain, padding) - [x] `facetKey` — 6 unit tests (same values, different values, null, boolean, null vs "null", numeric) - [x] Faceted Pointer — 4 tests (facet A isolation, facet B isolation, non-faceted regression, empty data) - [x] HTMLTooltip — 4 tests (facet A datum, facet B datum, non-faceted regression, pointerleave) - [x] All 704 existing tests pass - [x] `pnpm check` — 0 errors - [x] `pnpm lint` — clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: gka <617518+gka@users.noreply.github.com> Co-authored-by: Gregor Aisch <gka@users.noreply.github.com>
1 parent cf244c3 commit 70179ae

File tree

10 files changed

+753
-207
lines changed

10 files changed

+753
-207
lines changed

src/lib/helpers/facets.test.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect } from 'vitest';
2-
import { getEmptyFacets } from './facets.js';
2+
import { getEmptyFacets, invertBand, facetKey } from './facets.js';
33
import type { GenericMarkOptions, Mark } from '../types/index.js';
4+
import { scaleBand } from 'd3-scale';
45

56
/**
67
* Creates a minimal mock mark for testing getEmptyFacets.
@@ -187,3 +188,89 @@ describe('getEmptyFacets', () => {
187188
expect(result.get('B')?.get('X')).toBe(true);
188189
});
189190
});
191+
192+
describe('invertBand', () => {
193+
it('returns correct domain value for pixel inside first band', () => {
194+
// Two bands 'A','B' over [0, 200] with no padding → each is 100px wide
195+
const scale = scaleBand()
196+
.domain(['A', 'B'])
197+
.range([0, 200])
198+
.paddingInner(0)
199+
.paddingOuter(0);
200+
expect(invertBand(scale, scale.domain(), 10)).toBe('A');
201+
expect(invertBand(scale, scale.domain(), 50)).toBe('A');
202+
});
203+
204+
it('returns correct domain value for pixel inside second band', () => {
205+
const scale = scaleBand()
206+
.domain(['A', 'B'])
207+
.range([0, 200])
208+
.paddingInner(0)
209+
.paddingOuter(0);
210+
expect(invertBand(scale, scale.domain(), 110)).toBe('B');
211+
expect(invertBand(scale, scale.domain(), 199)).toBe('B');
212+
});
213+
214+
it('returns undefined for pixel outside all bands', () => {
215+
const scale = scaleBand()
216+
.domain(['A', 'B'])
217+
.range([0, 200])
218+
.paddingInner(0)
219+
.paddingOuter(0);
220+
expect(invertBand(scale, scale.domain(), -10)).toBeUndefined();
221+
expect(invertBand(scale, scale.domain(), 210)).toBeUndefined();
222+
});
223+
224+
it('handles single-value domain', () => {
225+
const scale = scaleBand().domain(['only']).range([0, 100]).paddingInner(0).paddingOuter(0);
226+
expect(invertBand(scale, scale.domain(), 50)).toBe('only');
227+
});
228+
229+
it('works with padding', () => {
230+
// With paddingInner=0.5, 3 bands over [0, 300]
231+
const scale = scaleBand()
232+
.domain(['X', 'Y', 'Z'])
233+
.range([0, 300])
234+
.paddingInner(0.5)
235+
.paddingOuter(0);
236+
// Each band should be narrower, but the pixel at the band start should still map correctly
237+
const xStart = scale('X')!;
238+
expect(invertBand(scale, scale.domain(), xStart + 1)).toBe('X');
239+
const yStart = scale('Y')!;
240+
expect(invertBand(scale, scale.domain(), yStart + 1)).toBe('Y');
241+
const zStart = scale('Z')!;
242+
expect(invertBand(scale, scale.domain(), zStart + 1)).toBe('Z');
243+
});
244+
});
245+
246+
describe('facetKey', () => {
247+
it('produces same key for same values', () => {
248+
expect(facetKey('A', 'X')).toBe(facetKey('A', 'X'));
249+
});
250+
251+
it('produces different keys for different values', () => {
252+
expect(facetKey('A', 'X')).not.toBe(facetKey('A', 'Y'));
253+
expect(facetKey('A', 'X')).not.toBe(facetKey('B', 'X'));
254+
});
255+
256+
it('handles null values', () => {
257+
const key = facetKey(null, null);
258+
expect(typeof key).toBe('string');
259+
expect(facetKey(null, null)).toBe(key);
260+
});
261+
262+
it('handles boolean true (non-faceted sentinel)', () => {
263+
const key = facetKey(true, true);
264+
expect(typeof key).toBe('string');
265+
expect(facetKey(true, true)).toBe(key);
266+
});
267+
268+
it('distinguishes null from "null" string', () => {
269+
expect(facetKey(null, 'a')).not.toBe(facetKey('null', 'a'));
270+
});
271+
272+
it('handles numeric values', () => {
273+
expect(facetKey(1, 2)).toBe(facetKey(1, 2));
274+
expect(facetKey(1, 2)).not.toBe(facetKey(2, 1));
275+
});
276+
});

src/lib/helpers/facets.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { GenericMarkOptions, Mark, RawValue } from '../types/index.js';
1+
import type { GenericMarkOptions, Mark, PlotState, RawValue } from '../types/index.js';
22
import { resolveChannel } from './resolve.js';
33

44
/**
@@ -56,3 +56,101 @@ export function getEmptyFacets(
5656
}
5757
return out;
5858
}
59+
60+
/**
61+
* Stable string key for a (fxValue, fyValue) pair, used as Map keys
62+
* for the keyed tree map in Pointer/HTMLTooltip.
63+
*/
64+
export function facetKey(fxValue: RawValue | boolean, fyValue: RawValue | boolean): string {
65+
return JSON.stringify([fxValue, fyValue]);
66+
}
67+
68+
/**
69+
* Inverts a d3 band scale: given a pixel position, returns the domain value
70+
* whose band contains that position, or undefined if outside all bands.
71+
*
72+
* d3.scaleBand has no .invert(), so we iterate the domain (O(n), n = facet count,
73+
* typically <20).
74+
*/
75+
export function invertBand(
76+
scale: { (value: string): number | undefined; bandwidth(): number },
77+
domain: readonly (string | RawValue)[],
78+
pixelPos: number
79+
): RawValue | undefined {
80+
const bw = scale.bandwidth();
81+
for (const value of domain) {
82+
const start = scale(value as string);
83+
if (start != null && pixelPos >= start && pixelPos < start + bw) {
84+
return value as RawValue;
85+
}
86+
}
87+
return undefined;
88+
}
89+
90+
/**
91+
* Walk up the DOM from `target` to find the nearest `g.facet` element.
92+
* Returns the facet x/y indices from `data-facet-x` and `data-facet-y`
93+
* attributes, or null if no facet element is found.
94+
*/
95+
export function findFacetFromDOM(
96+
target: Element | null
97+
): { fxIndex: number; fyIndex: number } | null {
98+
let el = target;
99+
while (el) {
100+
if (el.classList?.contains('facet')) {
101+
const fxIndex = parseInt((el as HTMLElement).dataset?.facetX ?? '0', 10);
102+
const fyIndex = parseInt((el as HTMLElement).dataset?.facetY ?? '0', 10);
103+
return { fxIndex, fyIndex };
104+
}
105+
el = el.parentElement;
106+
}
107+
return null;
108+
}
109+
110+
/**
111+
* Detect which facet the mouse event is in and compute the pixel offset.
112+
*
113+
* Strategy: try DOM walk first (fast, reliable when the event target is inside
114+
* a facet `<g>`). Fall back to inverting the fx/fy band scales from the mouse
115+
* position (works in jsdom where getBoundingClientRect returns zeros).
116+
*
117+
* Returns { fxValue, fyValue, offsetX, offsetY } where offset is the pixel
118+
* translation of the facet from the plot body origin.
119+
*/
120+
export function detectFacet(
121+
evt: MouseEvent,
122+
plot: PlotState
123+
): { fxValue: RawValue | boolean; fyValue: RawValue | boolean; offsetX: number; offsetY: number } {
124+
const fxScale = plot.scales.fx;
125+
const fyScale = plot.scales.fy;
126+
const fxDomain = fxScale.domain;
127+
const fyDomain = fyScale.domain;
128+
const hasFx = fxDomain.length > 0;
129+
const hasFy = fyDomain.length > 0;
130+
131+
// Try DOM walk
132+
const facetInfo = findFacetFromDOM(evt.target as Element);
133+
if (facetInfo) {
134+
const fxValue = hasFx ? fxDomain[facetInfo.fxIndex] : true;
135+
const fyValue = hasFy ? fyDomain[facetInfo.fyIndex] : true;
136+
return {
137+
fxValue,
138+
fyValue,
139+
offsetX: hasFx ? (fxScale.fn(fxValue) ?? 0) : 0,
140+
offsetY: hasFy ? (fyScale.fn(fyValue) ?? 0) : 0
141+
};
142+
}
143+
144+
// Fallback: invert mouse position against band scales
145+
const bodyRect = plot.body.getBoundingClientRect();
146+
const svgX = evt.clientX - bodyRect.left;
147+
const svgY = evt.clientY - bodyRect.top;
148+
const fxValue = hasFx ? (invertBand(fxScale.fn as any, fxDomain, svgX) ?? fxDomain[0]) : true;
149+
const fyValue = hasFy ? (invertBand(fyScale.fn as any, fyDomain, svgY) ?? fyDomain[0]) : true;
150+
return {
151+
fxValue,
152+
fyValue,
153+
offsetX: hasFx ? (fxScale.fn(fxValue) ?? 0) : 0,
154+
offsetY: hasFy ? (fyScale.fn(fyValue) ?? 0) : 0
155+
};
156+
}

src/lib/marks/HTMLTooltip.svelte

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
import { quadtree } from 'd3-quadtree';
2828
import { projectX, projectY } from '../helpers/scales.js';
2929
import { groupFacetsAndZ } from 'svelteplot/helpers/group.js';
30+
import { detectFacet, facetKey } from '../helpers/facets.js';
31+
import { SvelteMap } from 'svelte/reactivity';
3032
3133
const plot = usePlot();
3234
@@ -40,23 +42,19 @@
4042
let facetOffsetY = $state(0);
4143
4244
function onPointerMove(evt: MouseEvent) {
43-
const plotRect = plot.body.getBoundingClientRect();
44-
let facetEl: Element | null = evt.target as Element;
45-
while (facetEl && !facetEl.classList.contains('facet')) {
46-
facetEl = facetEl.parentElement as Element | null;
47-
}
48-
const facetIndex = +((facetEl as HTMLElement)?.dataset?.facet ?? 0);
49-
const facetRect = ((facetEl?.firstChild as Element) ?? plot.body).getBoundingClientRect();
45+
const { fxValue, fyValue, offsetX, offsetY } = detectFacet(evt, plot);
46+
const bodyRect = plot.body.getBoundingClientRect();
47+
48+
facetOffsetX = offsetX;
49+
facetOffsetY = offsetY;
5050
51-
facetOffsetX = facetRect.left - plotRect.left - plot.options.marginLeft;
52-
facetOffsetY = facetRect.top - plotRect.top - plot.options.marginTop;
51+
const relativeX = evt.clientX - bodyRect.left - offsetX;
52+
const relativeY = evt.clientY - bodyRect.top - offsetY;
5353
54-
const relativeX = evt.clientX - facetRect.left + (plot.options.marginLeft ?? 0);
55-
const relativeY = evt.clientY - facetRect.top + (plot.options.marginTop ?? 0);
54+
const key = facetKey(fxValue, fyValue);
55+
const facetTrees = treeMap.get(key) ?? [];
56+
const pt = facetTrees.length > 0 ? facetTrees[0].find(relativeX, relativeY, 25) : null;
5657
57-
const tree = trees[facetIndex];
58-
if (!tree) return;
59-
const pt = tree.find(relativeX, relativeY, 25);
6058
if (pt) {
6159
tooltipX = resolveChannel('x', pt, { x, y, r });
6260
tooltipY = resolveChannel('y', pt, { x, y, r });
@@ -80,20 +78,23 @@
8078
};
8179
});
8280
83-
const groups = $derived.by(() => {
84-
const groups: Datum[][] = [];
85-
groupFacetsAndZ(data, { fx, fy }, (d) => groups.push(d));
86-
return groups;
87-
});
88-
89-
const trees = $derived(
90-
groups.map((items) =>
91-
quadtree<Datum>()
81+
const treeMap = $derived.by(() => {
82+
const map = new SvelteMap<string, ReturnType<typeof quadtree<Datum>>[]>();
83+
groupFacetsAndZ(data, { fx, fy }, (items) => {
84+
if (!items.length) return;
85+
const fxVal = fx ? resolveChannel('fx', items[0], { fx }) : true;
86+
const fyVal = fy ? resolveChannel('fy', items[0], { fy }) : true;
87+
const key = facetKey(fxVal, fyVal);
88+
const tree = quadtree<Datum>()
9289
.x((d) => projectX('x', plot.scales, resolveChannel('x', d, { x, y, r })))
9390
.y((d) => projectY('y', plot.scales, resolveChannel('y', d, { x, y, r })))
94-
.addAll(items)
95-
)
96-
);
91+
.addAll(items);
92+
const existing = map.get(key) ?? [];
93+
existing.push(tree);
94+
map.set(key, existing);
95+
});
96+
return map;
97+
});
9798
</script>
9899

99100
<div

src/lib/marks/Pointer.svelte

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
import { groupFacetsAndZ } from 'svelteplot/helpers/group.js';
4141
import { getPlotDefaults } from '../hooks/plotDefaults.js';
4242
import { usePlot } from 'svelteplot/hooks/usePlot.svelte.js';
43+
import { detectFacet, facetKey } from '../helpers/facets.js';
44+
import { SvelteMap } from 'svelte/reactivity';
4345
4446
const plot = usePlot();
4547
@@ -71,31 +73,29 @@
7173
let selectedData = $state<any[]>([]);
7274
7375
function onPointerMove(evt: MouseEvent) {
74-
let facetEl: Element | null = evt.target as Element;
75-
while (facetEl && !facetEl.classList.contains('facet')) {
76-
facetEl = facetEl.parentElement as Element | null;
77-
}
78-
const facetRect = ((facetEl?.firstChild as Element) ?? plot.body).getBoundingClientRect();
76+
const { fxValue, fyValue, offsetX, offsetY } = detectFacet(evt, plot);
77+
const bodyRect = plot.body.getBoundingClientRect();
7978
80-
const relativeX = evt.clientX - facetRect.left + (plot.options.marginLeft ?? 0);
81-
const relativeY = evt.clientY - facetRect.top + (plot.options.marginTop ?? 0);
79+
const relativeX = evt.clientX - bodyRect.left - offsetX;
80+
const relativeY = evt.clientY - bodyRect.top - offsetY;
8281
83-
// console.log({ relativeX, relativeY }, evt);
84-
updateSelection(relativeX, relativeY);
82+
const key = facetKey(fxValue, fyValue);
83+
updateSelection(relativeX, relativeY, key);
8584
}
8685
8786
function onPointerLeave() {
8887
selectedData = [];
8988
if (onupdate) onupdate(selectedData);
9089
}
9190
92-
function updateSelection(ex: number, ey: number) {
93-
// find data row with minimum distance to
94-
const points = trees.map((tree) =>
91+
function updateSelection(ex: number, ey: number, key: string) {
92+
const facetTrees = treeMap.get(key) ?? [];
93+
// find data row with minimum distance to cursor
94+
const points = facetTrees.map((tree) =>
9595
tree.find(x != null ? ex : 0, y != null ? ey : 0, maxDistance)
9696
);
9797
// also include other points that share the same x or y value
98-
const otherPoints = trees.flatMap((tree, i) => {
98+
const otherPoints = facetTrees.flatMap((tree, i) => {
9999
return tree
100100
.data()
101101
.filter((d) => d !== points[i])
@@ -121,21 +121,19 @@
121121
};
122122
});
123123
124-
const groups = $derived.by(() => {
125-
const groups: any[][] = [];
126-
groupFacetsAndZ(indexData(data as object[]) as any, { x, y, z, fx, fy }, (d) =>
127-
groups.push(d)
128-
);
129-
return groups;
130-
});
131-
132-
const trees = $derived(
133-
groups.map((items) =>
134-
quadtree<any>()
124+
const treeMap = $derived.by(() => {
125+
const map = new SvelteMap<string, ReturnType<typeof quadtree<any>>[]>();
126+
groupFacetsAndZ(indexData(data as object[]) as any, { x, y, z, fx, fy }, (items) => {
127+
if (!items.length) return;
128+
// Recover fx/fy values from the first datum in the group
129+
const fxVal = fx ? resolveChannel('fx', items[0], { fx }) : true;
130+
const fyVal = fy ? resolveChannel('fy', items[0], { fy }) : true;
131+
const key = facetKey(fxVal, fyVal);
132+
const tree = quadtree<any>()
135133
.x(x != null ? (d: any) => d[POINTER_X] : () => 0)
136134
.y(y != null ? (d: any) => d[POINTER_Y] : () => 0)
137135
.addAll(
138-
items?.map((d: any) => {
136+
items.map((d: any) => {
139137
const [px, py] = projectXY(
140138
plot.scales,
141139
resolveChannel('x', d, { x }),
@@ -148,10 +146,14 @@
148146
[POINTER_X]: px,
149147
[POINTER_Y]: py
150148
};
151-
}) ?? []
152-
)
153-
)
154-
);
149+
})
150+
);
151+
const existing = map.get(key) ?? [];
152+
existing.push(tree);
153+
map.set(key, existing);
154+
});
155+
return map;
156+
});
155157
</script>
156158

157159
{#if children}

0 commit comments

Comments
 (0)