mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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:
commit
221afcefc4
@ -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">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user