feat(converter): enhance PriceSparkline with hover functionality and tooltip display

This commit is contained in:
2026-03-12 16:13:36 +01:00
parent 349f979b2b
commit 2b99854c22

View File

@@ -1,7 +1,14 @@
"use client"; "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"; import { cn } from "@/lib/utils";
interface PriceSparklinePoint { interface PriceSparklinePoint {
@@ -22,7 +29,8 @@ export function PriceSparkline({
isLoading = false, isLoading = false,
className, className,
}: PriceSparklineProps) { }: PriceSparklineProps) {
const gradientId = useId(); const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const gradientBaseId = useId().replace(/:/g, "-");
const chart = useMemo(() => { const chart = useMemo(() => {
const sorted = [...points] 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 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 isUptrend = prices[prices.length - 1] >= prices[0];
const gradientId = `sparkline-gradient-${gradientBaseId}`;
return { return {
width, width,
height, height,
padding,
linePath, linePath,
areaPath, areaPath,
isUptrend, isUptrend,
gradientId,
points: sorted.map((point, index) => ({
timestamp: point.timestamp,
priceUsd: point.priceUsd,
x: coordinates[index]?.x ?? 0,
y: coordinates[index]?.y ?? 0,
})),
};
}, [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<HTMLDivElement>) => {
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);
}; };
}, [points]);
return ( return (
<div <div
@@ -88,7 +181,11 @@ export function PriceSparkline({
</p> </p>
{chart ? ( {chart ? (
<div className="mt-2"> <div
className="relative mt-2"
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
<svg <svg
viewBox={`0 0 ${chart.width} ${chart.height}`} viewBox={`0 0 ${chart.width} ${chart.height}`}
className="h-20 w-full" className="h-20 w-full"
@@ -99,7 +196,7 @@ export function PriceSparkline({
> >
<defs> <defs>
<linearGradient <linearGradient
id={gradientId} id={chart.gradientId}
x1="0" x1="0"
y1="0" y1="0"
x2="0" x2="0"
@@ -118,7 +215,7 @@ export function PriceSparkline({
</linearGradient> </linearGradient>
</defs> </defs>
<path d={chart.areaPath} fill={`url(#${gradientId})`} /> <path d={chart.areaPath} fill={`url(#${chart.gradientId})`} />
<path <path
d={chart.linePath} d={chart.linePath}
fill="none" fill="none"
@@ -128,7 +225,68 @@ export function PriceSparkline({
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="bevel" strokeLinejoin="bevel"
/> />
{hoveredPoint ? (
<>
<line
x1={hoveredPoint.x}
y1={chart.padding}
x2={hoveredPoint.x}
y2={chart.height - chart.padding}
stroke={chart.isUptrend ? "#34d399" : "#fb7185"}
strokeOpacity="0.35"
strokeWidth="1"
strokeDasharray="2 3"
vectorEffect="non-scaling-stroke"
/>
</>
) : null}
</svg> </svg>
{hoveredPoint && hoveredPointPosition ? (
<>
<span
className="pointer-events-none absolute h-2.5 w-2.5 rounded-full border border-background shadow-[0_0_0_3px_rgba(0,0,0,0.16)]"
style={{
left: `${hoveredPointPosition.leftPercent}%`,
top: `${hoveredPointPosition.topPercent}%`,
transform: "translate(-50%, -50%)",
backgroundColor: chart.isUptrend ? "#34d399" : "#fb7185",
}}
/>
<span
className="pointer-events-none absolute h-5 w-5 rounded-full border"
style={{
left: `${hoveredPointPosition.leftPercent}%`,
top: `${hoveredPointPosition.topPercent}%`,
transform: "translate(-50%, -50%)",
borderColor: chart.isUptrend
? "rgba(52, 211, 153, 0.35)"
: "rgba(251, 113, 133, 0.35)",
}}
/>
</>
) : null}
{hoveredPoint && hoveredPointPosition && tooltipPlacement ? (
<div
className="pointer-events-none absolute top-1 z-10 rounded-md border border-border/70 bg-background/95 px-2 py-1 text-[11px] text-foreground shadow-sm backdrop-blur"
style={
{
left: `${hoveredPointPosition.leftPercent}%`,
top: `${hoveredPointPosition.topPercent}%`,
transform: tooltipPlacement.transform,
} as CSSProperties
}
>
<p className="font-medium">{formatUsdPrice(hoveredPoint.priceUsd)}</p>
<p className="text-[10px] text-muted-foreground">
{new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(hoveredPoint.timestamp))}
</p>
</div>
) : null}
</div> </div>
) : ( ) : (
<div className="mt-2 flex h-20 items-center justify-center rounded-md border border-dashed border-border/60 text-xs text-muted-foreground"> <div className="mt-2 flex h-20 items-center justify-center rounded-md border border-dashed border-border/60 text-xs text-muted-foreground">