fix: use SVG-level onMouseMove with data attrs for reliable hover

This commit is contained in:
Usman Baig
2026-03-12 23:04:08 +01:00
parent 9c8943d1e3
commit 4e7c495160

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useMemo, useState } from 'react' import { useCallback, useMemo, useRef, useState } from 'react'
import { useTheme } from '@ciphera-net/ui' import { useTheme } from '@ciphera-net/ui'
import { TreeStructure } from '@phosphor-icons/react' import { TreeStructure } from '@phosphor-icons/react'
import { sankey, sankeyJustify } from 'd3-sankey' import { sankey, sankeyJustify } from 'd3-sankey'
@@ -153,6 +153,7 @@ export default function SankeyDiagram({
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark' const isDark = resolvedTheme === 'dark'
const [hovered, setHovered] = useState<{ type: 'link' | 'node'; id: string } | null>(null) const [hovered, setHovered] = useState<{ type: 'link' | 'node'; id: string } | null>(null)
const svgRef = useRef<SVGSVGElement>(null)
const data = useMemo( const data = useMemo(
() => buildSankeyData(transitions, depth), () => buildSankeyData(transitions, depth),
@@ -178,6 +179,24 @@ export default function SankeyDiagram({
}) })
}, [data]) }, [data])
// Single event handler on SVG — reads data-* attrs from e.target
const handleMouseOver = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
const target = e.target as SVGElement
const el = target.closest('[data-node-id], [data-link-id]') as SVGElement | null
if (!el) return
const nodeId = el.getAttribute('data-node-id')
const linkId = el.getAttribute('data-link-id')
if (nodeId) {
setHovered((prev) => (prev?.type === 'node' && prev.id === nodeId) ? prev : { type: 'node', id: nodeId })
} else if (linkId) {
setHovered((prev) => (prev?.type === 'link' && prev.id === linkId) ? prev : { type: 'link', id: linkId })
}
}, [])
const handleMouseLeave = useCallback(() => {
setHovered(null)
}, [])
// ─── Empty state ──────────────────────────────────────────────── // ─── Empty state ────────────────────────────────────────────────
if (!transitions.length || !layout) { if (!transitions.length || !layout) {
return ( return (
@@ -202,25 +221,29 @@ export default function SankeyDiagram({
return ( return (
<svg <svg
ref={svgRef}
viewBox={`0 0 ${SVG_W} ${SVG_H}`} viewBox={`0 0 ${SVG_W} ${SVG_H}`}
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
className="w-full" className="w-full"
role="img" role="img"
aria-label="User journey Sankey diagram" aria-label="User journey Sankey diagram"
onMouseMove={handleMouseOver}
onMouseLeave={handleMouseLeave}
> >
{/* Links */} {/* Links */}
<g> <g>
{layout.links.map((link, i) => { {layout.links.map((link, i) => {
const src = link.source as LayoutNode const src = link.source as LayoutNode
const tgt = link.target as LayoutNode const tgt = link.target as LayoutNode
const linkId = `${String(src.id)}->${String(tgt.id)}` const srcId = String(src.id)
const tgtId = String(tgt.id)
const linkId = `${srcId}->${tgtId}`
let isHighlighted = false let isHighlighted = false
if (hovered?.type === 'link') { if (hovered?.type === 'link') {
isHighlighted = hovered.id === linkId isHighlighted = hovered.id === linkId
} else if (hovered?.type === 'node') { } else if (hovered?.type === 'node') {
isHighlighted = isHighlighted = srcId === hovered.id || tgtId === hovered.id
String(src.id) === hovered.id || String(tgt.id) === hovered.id
} }
let opacity = isDark ? 0.45 : 0.5 let opacity = isDark ? 0.45 : 0.5
@@ -235,8 +258,7 @@ export default function SankeyDiagram({
fill={src.color} fill={src.color}
opacity={opacity} opacity={opacity}
style={{ transition: 'opacity 0.15s ease' }} style={{ transition: 'opacity 0.15s ease' }}
onMouseEnter={() => setHovered({ type: 'link', id: linkId })} data-link-id={linkId}
onMouseLeave={() => setHovered(null)}
> >
<title> <title>
{src.label} {tgt.label}:{' '} {src.label} {tgt.label}:{' '}
@@ -269,8 +291,7 @@ export default function SankeyDiagram({
className={ className={
onNodeClick && !isExit ? 'cursor-pointer' : 'cursor-default' onNodeClick && !isExit ? 'cursor-pointer' : 'cursor-default'
} }
onMouseEnter={() => setHovered({ type: 'node', id: nodeId })} data-node-id={nodeId}
onMouseLeave={() => setHovered(null)}
onClick={() => { onClick={() => {
if (onNodeClick && !isExit) onNodeClick(node.label) if (onNodeClick && !isExit) onNodeClick(node.label)
}} }}
@@ -296,7 +317,6 @@ export default function SankeyDiagram({
const label = truncateLabel(node.label, 28) const label = truncateLabel(node.label, 28)
const textW = estimateTextWidth(label) const textW = estimateTextWidth(label)
const padX = 6 const padX = 6
const padY = 3
const rectW = textW + padX * 2 const rectW = textW + padX * 2
const rectH = 20 const rectH = 20
@@ -312,11 +332,7 @@ export default function SankeyDiagram({
const isExit = nodeId.startsWith('exit') const isExit = nodeId.startsWith('exit')
return ( return (
<g <g key={`label-${nodeId}`} data-node-id={nodeId}>
key={`label-${nodeId}`}
onMouseEnter={() => setHovered({ type: 'node', id: nodeId })}
onMouseLeave={() => setHovered(null)}
>
<rect <rect
x={bgX} x={bgX}
y={bgY} y={bgY}