Skip to content

Commit f840326

Browse files
feat(charts): drive all chart hovers through the design-system tooltip
Adds a shared ChartTooltip component + useChartTooltip hook (src/components/charts/ChartTooltip) and wires it into every Plotly chart, replacing both Plotly's native hover labels and the interim hand-rolled tooltip div. - ChartTooltip renders the ui Tooltip primitives (portal, arrow, bg-foreground styling) anchored at the hover position via a controlled Radix tooltip with a zero-size trigger - useChartTooltip binds plotly_hover/plotly_unhover, positions from the mouse event (works for cartesian, pie, and heatmap traces), raises hoverdistance to 40px and keeps the tooltip through a 150ms grace period so it is easier to acquire and keep open - chartTooltipLines (pure, unit-tested) builds default content for cartesian points, unified multi-trace hovers, pie slices, and text-carrying traces (PlateMap wells); charts pass axis titles as labels; pie detection avoids the label/value alias on bar points - All charts set hoverinfo "none" and render the shared element; InteractiveScatter keeps tooltip.native to restore Plotly labels - jsdom suite: mock relayout, stub ResizeObserver, assert against the portaled Radix content Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4c59d76 commit f840326

19 files changed

Lines changed: 492 additions & 101 deletions

File tree

src/components/charts/AreaGraph/AreaGraph.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import Plotly from "plotly.js-dist";
22
import React, { useEffect, useRef, useMemo } from "react";
33

4+
import { useChartTooltip } from "../ChartTooltip";
5+
46
import { usePlotlyTheme } from "@/hooks/use-plotly-theme";
57
import { CHART_COLORS } from "@/utils/colors";
68

@@ -40,6 +42,7 @@ const AreaGraph: React.FC<AreaGraphProps> = ({
4042
}) => {
4143
const plotRef = useRef<HTMLDivElement>(null);
4244
const theme = usePlotlyTheme();
45+
const { bindTooltip, tooltipElement } = useChartTooltip({ xLabel: xTitle, yLabel: yTitle });
4346

4447
const { xMin, xMax, yMin, yMax } = useMemo(() => {
4548
let minX = Number.MAX_VALUE;
@@ -173,6 +176,7 @@ const AreaGraph: React.FC<AreaGraphProps> = ({
173176
type: "scatter" as const,
174177
mode: "lines" as const,
175178
name: series.name,
179+
hoverinfo: "none" as const,
176180
fill: index === 0 ? ("tozeroy" as const) : ("tonexty" as const),
177181
fillcolor: color,
178182
line: {
@@ -191,6 +195,7 @@ const AreaGraph: React.FC<AreaGraphProps> = ({
191195
type: "scatter" as const,
192196
mode: "lines" as const,
193197
name: series.name,
198+
hoverinfo: "none" as const,
194199
fill: series.fill || ("tozeroy" as const),
195200
fillcolor: color,
196201
line: {
@@ -274,6 +279,7 @@ const AreaGraph: React.FC<AreaGraphProps> = ({
274279
};
275280

276281
Plotly.newPlot(plotRef.current, data, layout, config);
282+
bindTooltip(plotRef.current);
277283

278284
// Capture ref value for cleanup
279285
const plotElement = plotRef.current;
@@ -284,11 +290,12 @@ const AreaGraph: React.FC<AreaGraphProps> = ({
284290
Plotly.purge(plotElement);
285291
}
286292
};
287-
}, [dataSeries, width, height, xRange, yRange, effectiveXRange, effectiveYRange, variant, xTitle, yTitle, titleOptions, tickOptions, xTicks, yTicks, theme]);
293+
}, [dataSeries, width, height, xRange, yRange, effectiveXRange, effectiveYRange, variant, xTitle, yTitle, titleOptions, tickOptions, xTicks, yTicks, theme, bindTooltip]);
288294

289295
return (
290-
<div className="area-graph-container">
296+
<div className="area-graph-container relative">
291297
<div ref={plotRef} style={{ width: "100%", height: "100%" }} />
298+
{tooltipElement}
292299
</div>
293300
);
294301
};

src/components/charts/BarGraph/BarGraph.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import Plotly from "plotly.js-dist";
22
import React, { useEffect, useRef, useMemo } from "react";
33

4+
import { useChartTooltip } from "../ChartTooltip";
5+
46
import { usePlotlyTheme } from "@/hooks/use-plotly-theme";
57
import { CHART_COLORS } from "@/utils/colors";
68

@@ -46,6 +48,7 @@ const BarGraph: React.FC<BarGraphProps> = ({
4648
}) => {
4749
const plotRef = useRef<HTMLDivElement>(null);
4850
const theme = usePlotlyTheme();
51+
const { bindTooltip, tooltipElement } = useChartTooltip({ xLabel: xTitle, yLabel: yTitle });
4952

5053
const { yMin, yMax } = useMemo(() => {
5154
let minX = Number.MAX_VALUE;
@@ -141,6 +144,7 @@ const BarGraph: React.FC<BarGraphProps> = ({
141144
y: series.y,
142145
type: "bar" as const,
143146
name: series.name,
147+
hoverinfo: "none" as const,
144148
marker: {
145149
color: series.color ?? CHART_COLORS[index % CHART_COLORS.length],
146150
},
@@ -229,6 +233,7 @@ const BarGraph: React.FC<BarGraphProps> = ({
229233
};
230234

231235
Plotly.newPlot(plotRef.current, data, layout, config);
236+
bindTooltip(plotRef.current);
232237

233238
// Capture ref value for cleanup
234239
const plotElement = plotRef.current;
@@ -239,11 +244,12 @@ const BarGraph: React.FC<BarGraphProps> = ({
239244
Plotly.purge(plotElement);
240245
}
241246
};
242-
}, [dataSeries, width, height, xRange, yRange, xTitle, yTitle, title, barWidth, barMode, tickOptions, xTicks, yTicks, theme]);
247+
}, [dataSeries, width, height, xRange, yRange, xTitle, yTitle, title, barWidth, barMode, tickOptions, xTicks, yTicks, theme, bindTooltip]);
243248

244249
return (
245-
<div className="bar-graph-container">
250+
<div className="bar-graph-container relative">
246251
<div ref={plotRef} style={{ width: "100%", height: "100%" }} />
252+
{tooltipElement}
247253
</div>
248254
);
249255
};

src/components/charts/Boxplot/Boxplot.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import Plotly from "plotly.js-dist";
22
import React, { useEffect, useRef, useMemo } from "react";
33

4+
import { useChartTooltip } from "../ChartTooltip";
5+
46
import { usePlotlyTheme } from "@/hooks/use-plotly-theme";
57
import { CHART_COLORS } from "@/utils/colors";
68

@@ -43,6 +45,7 @@ const Boxplot: React.FC<BoxplotProps> = ({
4345
}) => {
4446
const plotRef = useRef<HTMLDivElement>(null);
4547
const theme = usePlotlyTheme();
48+
const { bindTooltip, tooltipElement } = useChartTooltip({ xLabel: xTitle, yLabel: yTitle });
4649

4750
const { yMin, yMax } = useMemo(() => {
4851
let minY = Number.MAX_VALUE;
@@ -133,6 +136,7 @@ const Boxplot: React.FC<BoxplotProps> = ({
133136
x: series.x,
134137
type: "box" as const,
135138
name: series.name,
139+
hoverinfo: "none" as const,
136140
marker: {
137141
color,
138142
},
@@ -222,6 +226,7 @@ const Boxplot: React.FC<BoxplotProps> = ({
222226
};
223227

224228
Plotly.newPlot(plotRef.current, data, layout, config);
229+
bindTooltip(plotRef.current);
225230

226231
// Capture ref value for cleanup
227232
const plotElement = plotRef.current;
@@ -232,11 +237,12 @@ const Boxplot: React.FC<BoxplotProps> = ({
232237
Plotly.purge(plotElement);
233238
}
234239
};
235-
}, [dataSeries, width, height, xRange, yRange, effectiveYRange, xTitle, yTitle, showPoints, titleOptions, tickOptions, yTicks, theme]);
240+
}, [dataSeries, width, height, xRange, yRange, effectiveYRange, xTitle, yTitle, showPoints, titleOptions, tickOptions, yTicks, theme, bindTooltip]);
236241

237242
return (
238-
<div className="boxplot-container">
243+
<div className="boxplot-container relative">
239244
<div ref={plotRef} style={{ width: "100%", height: "100%" }} />
245+
{tooltipElement}
240246
</div>
241247
);
242248
};
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import Plotly from "plotly.js-dist";
2+
import { useCallback, useRef, useState } from "react";
3+
4+
import { chartTooltipLines } from "./lines";
5+
6+
import type { ChartTooltipHoverPoint } from "./lines";
7+
8+
import {
9+
Tooltip,
10+
TooltipContent,
11+
TooltipProvider,
12+
TooltipTrigger,
13+
} from "@/components/ui/tooltip";
14+
15+
export { chartTooltipLines } from "./lines";
16+
export type { ChartTooltipHoverPoint } from "./lines";
17+
18+
/** How far (px) from a point Plotly still reports a hover — larger = stickier */
19+
const HOVER_DISTANCE_PX = 40;
20+
/** Grace period before hiding, so the tooltip survives gaps between points */
21+
const HIDE_GRACE_MS = 150;
22+
/** Distance (px) between the anchor position and the tooltip arrow */
23+
const TOOLTIP_OFFSET_PX = 12;
24+
25+
export interface ChartTooltipAnchor {
26+
left: number;
27+
top: number;
28+
lines: string[];
29+
}
30+
31+
interface ChartTooltipEmitter {
32+
on: (event: string, handler: (data: ChartTooltipHoverEvent) => void) => void;
33+
}
34+
35+
interface ChartTooltipHoverEvent {
36+
points?: ChartTooltipHoverPoint[];
37+
event?: MouseEvent;
38+
}
39+
40+
export interface ChartTooltipProps {
41+
/** Anchor position (relative to the chart container) and content lines */
42+
anchor: ChartTooltipAnchor | null;
43+
}
44+
45+
/**
46+
* The design-system tooltip anchored at a chart position. Render inside a
47+
* `relative` chart container; drive it with `useChartTooltip`.
48+
*/
49+
export function ChartTooltip({ anchor }: ChartTooltipProps) {
50+
if (!anchor) return null;
51+
return (
52+
<TooltipProvider>
53+
{/* Re-key per anchor so Radix repositions to the moved trigger */}
54+
<Tooltip open key={`${anchor.left},${anchor.top}`}>
55+
<TooltipTrigger asChild>
56+
<span
57+
aria-hidden
58+
data-slot="chart-tooltip-anchor"
59+
className="pointer-events-none absolute size-0"
60+
style={{ left: anchor.left, top: anchor.top }}
61+
/>
62+
</TooltipTrigger>
63+
<TooltipContent
64+
side="top"
65+
sideOffset={TOOLTIP_OFFSET_PX}
66+
className="pointer-events-none"
67+
>
68+
{anchor.lines.map((line, index) => (
69+
<div key={`${index}-${line}`}>{line}</div>
70+
))}
71+
</TooltipContent>
72+
</Tooltip>
73+
</TooltipProvider>
74+
);
75+
}
76+
77+
export interface UseChartTooltipOptions {
78+
/** Axis label used for the default x line (falls back to "X") */
79+
xLabel?: string;
80+
/** Axis label used for the default y line (falls back to "Y") */
81+
yLabel?: string;
82+
/** Override the default line builder entirely */
83+
getLines?: (points: ChartTooltipHoverPoint[]) => string[];
84+
}
85+
86+
/**
87+
* Drives the design-system tooltip from Plotly hover events.
88+
*
89+
* ```tsx
90+
* const { bindTooltip, tooltipElement } = useChartTooltip({ xLabel, yLabel });
91+
* // after Plotly.newPlot(...): set trace hoverinfo to "none", then
92+
* bindTooltip(plotRef.current);
93+
* // in the (relative-positioned) container:
94+
* {tooltipElement}
95+
* ```
96+
*/
97+
export function useChartTooltip(options: UseChartTooltipOptions = {}) {
98+
const [anchor, setAnchor] = useState<ChartTooltipAnchor | null>(null);
99+
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
100+
const optionsRef = useRef(options);
101+
optionsRef.current = options;
102+
103+
const bindTooltip = useCallback((plotDiv: HTMLElement | null) => {
104+
if (!plotDiv) return;
105+
setAnchor(null);
106+
107+
// A larger hover radius makes the tooltip easier to acquire and keep
108+
void Plotly.relayout(plotDiv, { hoverdistance: HOVER_DISTANCE_PX });
109+
110+
const emitter = plotDiv as unknown as ChartTooltipEmitter;
111+
112+
emitter.on("plotly_hover", (eventData) => {
113+
const points = eventData.points ?? [];
114+
const { getLines, xLabel, yLabel } = optionsRef.current;
115+
const lines = getLines
116+
? getLines(points)
117+
: chartTooltipLines(points, { xLabel, yLabel });
118+
if (lines.length === 0) return;
119+
120+
clearTimeout(hideTimerRef.current);
121+
const rect = plotDiv.getBoundingClientRect();
122+
const mouse = eventData.event;
123+
setAnchor({
124+
left: mouse ? mouse.clientX - rect.left : 0,
125+
top: mouse ? mouse.clientY - rect.top : 0,
126+
lines,
127+
});
128+
});
129+
130+
emitter.on("plotly_unhover", () => {
131+
clearTimeout(hideTimerRef.current);
132+
hideTimerRef.current = setTimeout(() => setAnchor(null), HIDE_GRACE_MS);
133+
});
134+
}, []);
135+
136+
return {
137+
bindTooltip,
138+
tooltipElement: <ChartTooltip anchor={anchor} />,
139+
};
140+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, it, expect } from "vitest";
2+
3+
import { chartTooltipLines } from "../lines";
4+
5+
describe("chartTooltipLines", () => {
6+
it("returns no lines for an empty hover", () => {
7+
expect(chartTooltipLines([])).toEqual([]);
8+
});
9+
10+
it("formats pie slices with label, value, and percent", () => {
11+
expect(
12+
chartTooltipLines([{ label: "pH", value: 12, percent: 23.5 }]),
13+
).toEqual(["pH", "Value: 12", "23.5%"]);
14+
});
15+
16+
it("omits the percent line when the slice has none", () => {
17+
expect(chartTooltipLines([{ label: "pH", value: 1.5 }])).toEqual([
18+
"pH",
19+
"Value: 1.50",
20+
]);
21+
});
22+
23+
it("splits trace-provided hover text on <br>", () => {
24+
expect(
25+
chartTooltipLines([{ text: "Well A1<br>Value: 42", x: 1, y: 1 }]),
26+
).toEqual(["Well A1", "Value: 42"]);
27+
});
28+
29+
it("formats a single cartesian point with axis labels and trace name", () => {
30+
expect(
31+
chartTooltipLines([{ x: 1, y: 2.345, data: { name: "Series A" } }], {
32+
xLabel: "Time",
33+
yLabel: "Signal",
34+
}),
35+
).toEqual(["Series A", "Time: 1", "Signal: 2.35"]);
36+
});
37+
38+
it("falls back to X/Y labels and includes z values", () => {
39+
expect(chartTooltipLines([{ x: "col-1", y: "row-A", z: 7 }])).toEqual([
40+
"X: col-1",
41+
"Y: row-A",
42+
"Value: 7",
43+
]);
44+
});
45+
46+
it("renders unified multi-trace hovers as one line per trace", () => {
47+
expect(
48+
chartTooltipLines(
49+
[
50+
{ x: 5, y: 10, data: { name: "A" } },
51+
{ x: 5, y: 20, data: { name: "B" } },
52+
{ x: 5, data: { name: "no-y" } },
53+
],
54+
{ xLabel: "Position" },
55+
),
56+
).toEqual(["Position: 5", "A: 10", "B: 20"]);
57+
});
58+
59+
it("labels unnamed traces in unified hovers", () => {
60+
expect(chartTooltipLines([{ x: 1, y: 2 }, { x: 1, y: 3 }])).toEqual([
61+
"X: 1",
62+
"Series: 2",
63+
"Series: 3",
64+
]);
65+
});
66+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export {
2+
ChartTooltip,
3+
chartTooltipLines,
4+
useChartTooltip,
5+
} from "./ChartTooltip";
6+
export type {
7+
ChartTooltipAnchor,
8+
ChartTooltipHoverPoint,
9+
ChartTooltipProps,
10+
UseChartTooltipOptions,
11+
} from "./ChartTooltip";

0 commit comments

Comments
 (0)