"use client"; import { useId, useMemo, useState, type CSSProperties, type MouseEvent, } from "react"; import { formatUsdPrice } from "@/lib/format"; import { cn } from "@/lib/utils"; interface PriceSparklinePoint { timestamp: string; priceUsd: number; } interface PriceSparklineProps { points: PriceSparklinePoint[]; rangeLabel: string; isLoading?: boolean; className?: string; } export function PriceSparkline({ points, rangeLabel, isLoading = false, className, }: PriceSparklineProps) { const [hoveredIndex, setHoveredIndex] = useState(null); const gradientBaseId = useId().replace(/:/g, "-"); const chart = useMemo(() => { const sorted = [...points] .filter((point) => Number.isFinite(point.priceUsd) && point.priceUsd > 0) .sort( (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), ); if (sorted.length < 2) { return null; } const width = 320; const height = 84; const padding = 6; const prices = sorted.map((point) => point.priceUsd); const minPrice = Math.min(...prices); const maxPrice = Math.max(...prices); const range = Math.max(maxPrice - minPrice, maxPrice * 0.001, 0.00000001); const coordinates = sorted.map((point, index) => { const x = padding + (index / (sorted.length - 1)) * (width - padding * 2); const y = padding + (1 - (point.priceUsd - minPrice) / range) * (height - padding * 2); return { x, y }; }); const linePath = coordinates .map((point, index) => `${index === 0 ? "M" : "L"} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`, ) .join(" "); const firstPoint = coordinates[0]; const lastPoint = coordinates[coordinates.length - 1]; 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, 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 (

Price ({rangeLabel})

{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}
) : (
{isLoading ? `Loading ${rangeLabel} price history...` : `${rangeLabel} price history unavailable`}
)}
); }