From 2b99854c2265319deaf06a6297813e670ec92b71 Mon Sep 17 00:00:00 2001 From: zvspany Date: Thu, 12 Mar 2026 16:13:36 +0100 Subject: [PATCH] feat(converter): enhance PriceSparkline with hover functionality and tooltip display --- components/converter/price-sparkline.tsx | 170 ++++++++++++++++++++++- 1 file changed, 164 insertions(+), 6 deletions(-) diff --git a/components/converter/price-sparkline.tsx b/components/converter/price-sparkline.tsx index 6fbaa6c..544f85e 100644 --- a/components/converter/price-sparkline.tsx +++ b/components/converter/price-sparkline.tsx @@ -1,7 +1,14 @@ "use client"; -import { useId, useMemo } from "react"; +import { + useId, + useMemo, + useState, + type CSSProperties, + type MouseEvent, +} from "react"; +import { formatUsdPrice } from "@/lib/format"; import { cn } from "@/lib/utils"; interface PriceSparklinePoint { @@ -22,7 +29,8 @@ export function PriceSparkline({ isLoading = false, className, }: PriceSparklineProps) { - const gradientId = useId(); + const [hoveredIndex, setHoveredIndex] = useState(null); + const gradientBaseId = useId().replace(/:/g, "-"); const chart = useMemo(() => { const sorted = [...points] @@ -66,15 +74,100 @@ export function PriceSparkline({ const areaPath = `${linePath} L ${lastPoint.x.toFixed(2)} ${(height - padding).toFixed(2)} L ${firstPoint.x.toFixed(2)} ${(height - padding).toFixed(2)} Z`; const isUptrend = prices[prices.length - 1] >= prices[0]; + const gradientId = `sparkline-gradient-${gradientBaseId}`; return { width, height, + padding, linePath, areaPath, isUptrend, + gradientId, + points: sorted.map((point, index) => ({ + timestamp: point.timestamp, + priceUsd: point.priceUsd, + x: coordinates[index]?.x ?? 0, + y: coordinates[index]?.y ?? 0, + })), }; - }, [points]); + }, [points, gradientBaseId]); + + const hoveredPoint = useMemo(() => { + if (!chart || hoveredIndex === null) { + return null; + } + + const clampedIndex = Math.max( + 0, + Math.min(hoveredIndex, chart.points.length - 1), + ); + + return chart.points[clampedIndex] ?? null; + }, [chart, hoveredIndex]); + + const hoveredPointPosition = useMemo(() => { + if (!chart || !hoveredPoint) { + return null; + } + + return { + leftPercent: (hoveredPoint.x / chart.width) * 100, + topPercent: (hoveredPoint.y / chart.height) * 100, + }; + }, [chart, hoveredPoint]); + + const tooltipPlacement = useMemo(() => { + if (!hoveredPointPosition) { + return null; + } + + const { leftPercent, topPercent } = hoveredPointPosition; + + if (leftPercent >= 76) { + return { + transform: "translate(calc(-100% - 10px), -50%)", + }; + } + + if (leftPercent <= 24) { + return { + transform: "translate(10px, -50%)", + }; + } + + if (topPercent <= 30) { + return { + transform: "translate(-50%, 10px)", + }; + } + + return { + transform: "translate(-50%, calc(-100% - 10px))", + }; + }, [hoveredPointPosition]); + + const handleMouseMove = (event: MouseEvent) => { + if (!chart) { + return; + } + + const rect = event.currentTarget.getBoundingClientRect(); + + if (rect.width <= 0) { + return; + } + + const relativeX = (event.clientX - rect.left) / rect.width; + const clamped = Math.max(0, Math.min(relativeX, 1)); + const nextIndex = Math.round(clamped * (chart.points.length - 1)); + + setHoveredIndex(nextIndex); + }; + + const handleMouseLeave = () => { + setHoveredIndex(null); + }; return (
{chart ? ( -
+
- + + {hoveredPoint ? ( + <> + + + ) : null} + + {hoveredPoint && hoveredPointPosition ? ( + <> + + + + ) : null} + + {hoveredPoint && hoveredPointPosition && tooltipPlacement ? ( +
+

{formatUsdPrice(hoveredPoint.priceUsd)}

+

+ {new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(hoveredPoint.timestamp))} +

+
+ ) : null}
) : (