Skip to content

Commit c4a1eb0

Browse files
authored
fix: flip tooltip to left/top when near chart edges (#68)
* fix: flip tooltip to left/top when near chart edges * fix: bump tooltip z-index to render above legend * fix: flip tooltip to left/top when near chart container edges
1 parent a4c8e65 commit c4a1eb0

4 files changed

Lines changed: 55 additions & 17 deletions

File tree

packages/app/src/components/inference/ui/ScatterGraph.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
ZoomContext,
2020
} from '@/lib/d3-chart/D3Chart/types';
2121
import type { ContinuousScale } from '@/lib/d3-chart/types';
22+
import { computeTooltipPosition } from '@/lib/d3-chart/layers/scatter-points';
2223
import {
2324
POINT_SIZE,
2425
HIT_AREA_RADIUS,
@@ -1062,7 +1063,8 @@ const ScatterGraph = React.memo(
10621063
.on('mousemove', function (event) {
10631064
if (chartRef.current?.isPinned()) return;
10641065
const [mx, my] = d3.pointer(event, container);
1065-
tooltip.style('left', `${mx + 10}px`).style('top', `${my + 10}px`);
1066+
const pos = computeTooltipPosition(mx, my, tooltip, container);
1067+
tooltip.style('left', `${pos.left}px`).style('top', `${pos.top}px`);
10661068
})
10671069
.on('mouseleave', function () {
10681070
if (chartRef.current?.isPinned()) return;
@@ -1074,13 +1076,14 @@ const ScatterGraph = React.memo(
10741076
.on('click', function (event, d) {
10751077
event.stopPropagation();
10761078
const [mx, my] = d3.pointer(event, container);
1079+
tooltip.html(generateOverlayTooltipContent(createOverlayConfig(d, true)));
1080+
const pos = computeTooltipPosition(mx, my, tooltip, container);
10771081
tooltip
1078-
.style('left', `${mx + 10}px`)
1079-
.style('top', `${my + 10}px`)
1082+
.style('left', `${pos.left}px`)
1083+
.style('top', `${pos.top}px`)
10801084
.style('opacity', 1)
10811085
.style('display', 'block')
10821086
.style('pointer-events', 'auto');
1083-
tooltip.html(generateOverlayTooltipContent(createOverlayConfig(d, true)));
10841087

10851088
// Position rulers at clicked point
10861089
const ct = d3.zoomTransform(svgNode);

packages/app/src/components/ui/d3-chart-wrapper.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export function D3ChartWrapper({
8080
opacity: pinnedPoint ? 1 : 0,
8181
pointerEvents: pinnedPoint ? 'auto' : 'none',
8282
display: pinnedPoint ? 'block' : 'none',
83-
zIndex: 10,
83+
zIndex: 50,
8484
}}
8585
/>
8686
{noDataOverlay}

packages/app/src/hooks/useChartTooltipHandlers.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as d3 from 'd3';
22
import { useCallback } from 'react';
3+
import { computeTooltipPosition } from '@/lib/d3-chart/layers/scatter-points';
34
import { useStickyTooltip } from './useStickyTooltip';
45

56
export type RulerType = 'vertical' | 'horizontal' | 'crosshair' | 'none';
@@ -271,9 +272,10 @@ export function useChartTooltipHandlers<TData>(): ChartTooltipHandlers<TData> {
271272
if (isPinned()) return;
272273

273274
const rect = containerElement.getBoundingClientRect();
274-
tooltipElement
275-
.style('left', `${event.clientX - rect.left + 10}px`)
276-
.style('top', `${event.clientY - rect.top + 10}px`);
275+
const mx = event.clientX - rect.left;
276+
const my = event.clientY - rect.top;
277+
const pos = computeTooltipPosition(mx, my, tooltipElement, containerElement);
278+
tooltipElement.style('left', `${pos.left}px`).style('top', `${pos.top}px`);
277279
})
278280
.on('mouseleave', function (_event, d) {
279281
if (isPinned()) return;
@@ -292,15 +294,18 @@ export function useChartTooltipHandlers<TData>(): ChartTooltipHandlers<TData> {
292294
.on('click', function (event, d) {
293295
event.stopPropagation();
294296

295-
// Position tooltip near the clicked point
297+
// Set content first so dimensions are available for position calc
296298
const rect = containerElement.getBoundingClientRect();
299+
const mx = event.clientX - rect.left;
300+
const my = event.clientY - rect.top;
301+
tooltipElement.html(config.generateTooltipContent(d, true));
302+
const pos = computeTooltipPosition(mx, my, tooltipElement, containerElement);
297303
tooltipElement
298-
.style('left', `${event.clientX - rect.left + 10}px`)
299-
.style('top', `${event.clientY - rect.top + 10}px`)
304+
.style('left', `${pos.left}px`)
305+
.style('top', `${pos.top}px`)
300306
.style('opacity', 1)
301307
.style('display', 'block')
302-
.style('pointer-events', 'auto')
303-
.html(config.generateTooltipContent(d, true));
308+
.style('pointer-events', 'auto');
304309

305310
// Position rulers at the clicked point
306311
const { curX: currentXScale, curY: currentYScale } = getZoomedScales();

packages/app/src/lib/d3-chart/layers/scatter-points.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,8 @@ export function attachScatterTooltipHandlers<
192192
.on('mousemove', function (event) {
193193
if (isPinned()) return;
194194
const [mx, my] = d3.pointer(event, container);
195-
tooltip.style('left', `${mx + 10}px`).style('top', `${my + 10}px`);
195+
const pos = computeTooltipPosition(mx, my, tooltip, container);
196+
tooltip.style('left', `${pos.left}px`).style('top', `${pos.top}px`);
196197
})
197198
.on('mouseleave', function (_event, d) {
198199
if (isPinned()) return;
@@ -203,13 +204,14 @@ export function attachScatterTooltipHandlers<
203204
.on('click', function (event, d) {
204205
event.stopPropagation();
205206
const [mx, my] = d3.pointer(event, container);
207+
tooltip.html(generateTooltipContent(d, true));
208+
const pos = computeTooltipPosition(mx, my, tooltip, container);
206209
tooltip
207-
.style('left', `${mx + 10}px`)
208-
.style('top', `${my + 10}px`)
210+
.style('left', `${pos.left}px`)
211+
.style('top', `${pos.top}px`)
209212
.style('opacity', 1)
210213
.style('display', 'block')
211214
.style('pointer-events', 'auto');
212-
tooltip.html(generateTooltipContent(d, true));
213215
pinTooltip(d, false);
214216
onPointClick?.(d, tooltip);
215217
trackEvent?.(String(d.hwKey), d.x, d.y);
@@ -222,6 +224,34 @@ export function attachScatterTooltipHandlers<
222224
});
223225
}
224226

227+
/** Compute tooltip left/top, flipping when it would overflow the chart container. */
228+
export function computeTooltipPosition(
229+
mx: number,
230+
my: number,
231+
tooltip:
232+
| d3.Selection<HTMLDivElement | null, unknown, null, undefined>
233+
| d3.Selection<HTMLDivElement, unknown, null, undefined>,
234+
container: HTMLElement,
235+
offset = 10,
236+
): { left: number; top: number } {
237+
const node = tooltip.node();
238+
if (!node) return { left: mx + offset, top: my + offset };
239+
240+
// Ensure tooltip is measurable
241+
node.style.display = 'block';
242+
243+
// Force reflow so we get real dimensions
244+
const tw = node.getBoundingClientRect().width || node.offsetWidth;
245+
const th = node.getBoundingClientRect().height || node.offsetHeight;
246+
const cw = container.clientWidth;
247+
const ch = container.clientHeight;
248+
249+
const left = mx + offset + tw > cw ? mx - offset - tw : mx + offset;
250+
const top = my + offset + th > ch ? my - offset - th : my + offset;
251+
252+
return { left, top };
253+
}
254+
225255
/** Update scatter point positions on zoom. */
226256
export function updateScatterPointsOnZoom(
227257
zoomGroup: d3.Selection<SVGGElement, unknown, null, undefined>,

0 commit comments

Comments
 (0)