Merge pull request #45 from ArnasDon/fix/dashboard-chart-hover-precision

fix(dashboard): precise hover on the conversations-over-time chart
This commit is contained in:
Arnas Donauskas 2026-04-17 23:24:43 +03:00 committed by GitHub
commit 221afcefc4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -106,8 +106,14 @@ function LineSvg({
maxY: number maxY: number
ticks: number[] ticks: number[]
}) { }) {
const [hoverIdx, setHoverIdx] = useState<number | null>(null) // Hover state: both the snapped index AND the tooltip's pixel
// offset inside the wrapper div. They're stored together so the
// tooltip positions against the chart's actual rendered pixels,
// not against a raw viewBox percentage. See the precision note on
// the onMove handler below.
const [hover, setHover] = useState<{ idx: number; tooltipLeftPx: number } | null>(null)
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const chartW = VB_W - PADDING.left - PADDING.right const chartW = VB_W - PADDING.left - PADDING.right
const chartH = VB_H - PADDING.top - PADDING.bottom const chartH = VB_H - PADDING.top - PADDING.bottom
@ -123,40 +129,67 @@ function LineSvg({
const incomingPath = data.map((p, i) => `${i === 0 ? 'M' : 'L'}${xFor(i)},${yFor(p.incoming)}`).join(' ') const incomingPath = data.map((p, i) => `${i === 0 ? 'M' : 'L'}${xFor(i)},${yFor(p.incoming)}`).join(' ')
const outgoingPath = data.map((p, i) => `${i === 0 ? 'M' : 'L'}${xFor(i)},${yFor(p.outgoing)}`).join(' ') const outgoingPath = data.map((p, i) => `${i === 0 ? 'M' : 'L'}${xFor(i)},${yFor(p.outgoing)}`).join(' ')
// Mouse-move: snap to nearest data-point index. // Mouse-move: use the SVG's current screen-CTM to map clientX
// back to viewBox coordinates. The previous rect-based math
// assumed the viewBox filled the SVG DOM box linearly, but
// `preserveAspectRatio="xMidYMid meet"` (the SVG default)
// letterboxes the content horizontally when the container is
// wider than the viewBox aspect — so hover snapped hundreds of
// pixels off on wide layouts. CTM-inverse correctly accounts for
// letterboxing, scaling, and any future transform changes.
useEffect(() => { useEffect(() => {
const svg = svgRef.current const svg = svgRef.current
if (!svg) return const wrap = wrapRef.current
if (!svg || !wrap) return
const onMove = (e: MouseEvent) => { const onMove = (e: MouseEvent) => {
const rect = svg.getBoundingClientRect() const ctm = svg.getScreenCTM()
const xPct = (e.clientX - rect.left) / rect.width if (!ctm) return
const xPx = xPct * VB_W const pt = svg.createSVGPoint()
if (xPx < PADDING.left - 8 || xPx > VB_W - PADDING.right + 8) { pt.x = e.clientX
setHoverIdx(null) pt.y = e.clientY
const local = pt.matrixTransform(ctm.inverse())
const xVb = local.x
if (xVb < PADDING.left - 8 || xVb > VB_W - PADDING.right + 8) {
setHover(null)
return return
} }
const local = xPx - PADDING.left const relative = xVb - PADDING.left
const idx = Math.round(stepX === 0 ? 0 : local / stepX) const idx = Math.max(
setHoverIdx(Math.max(0, Math.min(data.length - 1, idx))) 0,
Math.min(data.length - 1, Math.round(stepX === 0 ? 0 : relative / stepX)),
)
// Map the snapped data-point's viewBox x back to screen, then
// subtract the wrapper's left edge — that pixel offset is what
// the absolutely-positioned tooltip div consumes. `xFor` is
// inlined here so the effect deps stay stable (it's a closure
// that'd otherwise be a new reference every render).
const dataPointVbX = PADDING.left + idx * stepX
const dataPointPt = svg.createSVGPoint()
dataPointPt.x = dataPointVbX
dataPointPt.y = 0
const screen = dataPointPt.matrixTransform(ctm)
const wrapRect = wrap.getBoundingClientRect()
setHover({ idx, tooltipLeftPx: screen.x - wrapRect.left })
} }
const onLeave = () => setHoverIdx(null) const onLeave = () => setHover(null)
svg.addEventListener('mousemove', onMove) svg.addEventListener('mousemove', onMove)
svg.addEventListener('mouseleave', onLeave) svg.addEventListener('mouseleave', onLeave)
return () => { return () => {
svg.removeEventListener('mousemove', onMove) svg.removeEventListener('mousemove', onMove)
svg.removeEventListener('mouseleave', onLeave) svg.removeEventListener('mouseleave', onLeave)
} }
// xFor + yFor close over stepX, so stepX covers them.
}, [data, stepX]) }, [data, stepX])
const hovered = hoverIdx !== null ? data[hoverIdx] : null const hovered = hover !== null ? data[hover.idx] : null
const hoverX = hoverIdx !== null ? xFor(hoverIdx) : 0 const hoverX = hover !== null ? xFor(hover.idx) : 0
// X-axis label strategy: show ~6 evenly-spaced labels regardless // X-axis label strategy: show ~6 evenly-spaced labels regardless
// of range so the axis never looks crowded. // of range so the axis never looks crowded.
const labelStride = Math.max(1, Math.ceil(data.length / 6)) const labelStride = Math.max(1, Math.ceil(data.length / 6))
return ( return (
<div className="relative w-full"> <div ref={wrapRef} className="relative w-full">
<svg <svg
ref={svgRef} ref={svgRef}
viewBox={`0 0 ${VB_W} ${VB_H}`} viewBox={`0 0 ${VB_W} ${VB_H}`}
@ -225,7 +258,7 @@ function LineSvg({
/> />
{/* Hover crosshair */} {/* Hover crosshair */}
{hoverIdx !== null && ( {hover !== null && (
<g pointerEvents="none"> <g pointerEvents="none">
<line <line
x1={hoverX} x1={hoverX}
@ -235,17 +268,20 @@ function LineSvg({
stroke="rgb(71 85 105)" stroke="rgb(71 85 105)"
strokeDasharray="3 3" strokeDasharray="3 3"
/> />
<circle cx={hoverX} cy={yFor(data[hoverIdx].incoming)} r={3.5} fill="#3b82f6" /> <circle cx={hoverX} cy={yFor(data[hover.idx].incoming)} r={3.5} fill="#3b82f6" />
<circle cx={hoverX} cy={yFor(data[hoverIdx].outgoing)} r={3.5} fill="#10b981" /> <circle cx={hoverX} cy={yFor(data[hover.idx].outgoing)} r={3.5} fill="#10b981" />
</g> </g>
)} )}
</svg> </svg>
{/* Tooltip — absolute-positioned div so we get crisp text, not SVG */} {/* Tooltip — absolute-positioned div so we get crisp text, not
{hovered && hoverIdx !== null && ( SVG-rendered text. The left offset comes from the CTM-based
mapping so it lines up with the actual crosshair pixel, not a
letterboxed viewBox percentage. */}
{hovered && hover !== null && (
<div <div
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 rounded-md border border-slate-700 bg-slate-950 px-2.5 py-1.5 text-[11px] shadow-lg" className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 rounded-md border border-slate-700 bg-slate-950 px-2.5 py-1.5 text-[11px] shadow-lg"
style={{ left: `${(hoverX / VB_W) * 100}%` }} style={{ left: `${hover.tooltipLeftPx}px` }}
> >
<div className="font-medium text-white">{longDayLabel(hovered.day)}</div> <div className="font-medium text-white">{longDayLabel(hovered.day)}</div>
<div className="mt-1 flex flex-col gap-0.5"> <div className="mt-1 flex flex-col gap-0.5">