{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "spark-chart", "type": "registry:ui", "title": "Spark Chart", "description": "An interactive sparkline with path-following indicator, dual-path color reveal, gradient fill, and draw-in animation. Pixel-perfect cursor tracking via binary search on SVG path length.", "dependencies": [], "files": [ { "path": "registry/ruixenui/spark-chart.tsx", "content": "\"use client\";\n\nimport { useCallback, useId, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface SparkChartProps {\n data: number[];\n color?: string;\n height?: number;\n formatValue?: (value: number) => string;\n onValueChange?: (value: number, index: number) => void;\n onActiveChange?: (active: boolean) => void;\n className?: string;\n}\n\n/* ─── Geometry ─────────────────────────────────────────── */\n\nconst VW = 400;\nconst LABEL_H = 26;\nconst PAD_B = 4;\n\nfunction buildPath(data: number[], h: number): string {\n const n = data.length;\n if (n < 2) return \"\";\n\n const min = Math.min(...data);\n const max = Math.max(...data);\n const range = max - min || 1;\n\n const pts = data.map((v, i) => ({\n x: (i / (n - 1)) * VW,\n y: LABEL_H + (1 - (v - min) / range) * (h - LABEL_H - PAD_B),\n }));\n\n const t = 0.4;\n let d = `M ${pts[0].x} ${pts[0].y}`;\n\n for (let i = 0; i < n - 1; i++) {\n const p0 = pts[Math.max(0, i - 1)];\n const p1 = pts[i];\n const p2 = pts[i + 1];\n const p3 = pts[Math.min(n - 1, i + 2)];\n\n d += ` C ${p1.x + ((p2.x - p0.x) * t) / 3} ${\n p1.y + ((p2.y - p0.y) * t) / 3\n }, ${p2.x - ((p3.x - p1.x) * t) / 3} ${\n p2.y - ((p3.y - p1.y) * t) / 3\n }, ${p2.x} ${p2.y}`;\n }\n\n return d;\n}\n\nfunction findPointAtX(\n path: SVGPathElement,\n targetX: number,\n): { x: number; y: number } {\n const total = path.getTotalLength();\n let lo = 0;\n let hi = total;\n\n for (let i = 0; i < 25; i++) {\n const mid = (lo + hi) / 2;\n if (path.getPointAtLength(mid).x < targetX) lo = mid;\n else hi = mid;\n }\n\n const pt = path.getPointAtLength((lo + hi) / 2);\n return { x: pt.x, y: pt.y };\n}\n\nfunction interpolate(\n data: number[],\n pct: number,\n): { value: number; index: number } {\n const idx = pct * (data.length - 1);\n const lo = Math.floor(idx);\n const hi = Math.min(Math.ceil(idx), data.length - 1);\n const t = idx - lo;\n return {\n value: data[lo] + (data[hi] - data[lo]) * t,\n index: Math.round(idx),\n };\n}\n\n/* ─── Component ────────────────────────────────────────── */\n\nexport function SparkChart({\n data,\n color = \"hsl(217, 91%, 60%)\",\n height = 160,\n formatValue,\n onValueChange,\n onActiveChange,\n className,\n}: SparkChartProps) {\n const uid = useId();\n const containerRef = useRef(null);\n const pathRef = useRef(null);\n\n const dotRef = useRef(null);\n const lineRef = useRef(null);\n const revealRef = useRef(null);\n const labelRef = useRef(null);\n\n const [active, setActive] = useState(false);\n\n const pathD = useMemo(() => buildPath(data, height), [data, height]);\n const fillD = useMemo(\n () => (pathD ? `${pathD} L ${VW} ${height} L 0 ${height} Z` : \"\"),\n [pathD, height],\n );\n\n const update = useCallback(\n (clientX: number) => {\n const el = containerRef.current;\n const path = pathRef.current;\n if (!el || !path) return;\n\n const rect = el.getBoundingClientRect();\n const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));\n const svgX = pct * VW;\n const pt = findPointAtX(path, svgX);\n const pctY = pt.y / height;\n const { value, index } = interpolate(data, pct);\n\n if (dotRef.current) {\n dotRef.current.style.left = `${pct * 100}%`;\n dotRef.current.style.top = `${pctY * 100}%`;\n }\n if (lineRef.current) {\n lineRef.current.style.left = `${pct * 100}%`;\n }\n if (revealRef.current) {\n revealRef.current.style.clipPath = `inset(0 ${(1 - pct) * 100}% 0 0)`;\n }\n if (labelRef.current && formatValue) {\n labelRef.current.style.left = `${pct * 100}%`;\n labelRef.current.textContent = formatValue(value);\n }\n\n onValueChange?.(value, index);\n },\n [data, height, formatValue, onValueChange],\n );\n\n const handleEnter = useCallback(\n (e: React.PointerEvent) => {\n setActive(true);\n onActiveChange?.(true);\n update(e.clientX);\n },\n [update, onActiveChange],\n );\n\n const handleLeave = useCallback(() => {\n setActive(false);\n onActiveChange?.(false);\n if (revealRef.current) {\n revealRef.current.style.clipPath = \"inset(0 0% 0 0)\";\n }\n }, [onActiveChange]);\n\n const handleMove = useCallback(\n (e: React.PointerEvent) => update(e.clientX),\n [update],\n );\n\n const gradMutedId = `sg-m-${uid}`;\n const gradColorId = `sg-c-${uid}`;\n\n return (\n \n \n \n \n \n \n \n \n \n \n \n \n\n {/* Muted base — full path, always visible (the \"right side\" on scrub) */}\n \n \n \n \n\n {/* Colored overlay — full at rest, clips to scrub position on hover */}\n \n \n \n \n \n\n {/* Scrub label */}\n {formatValue && (\n \n )}\n\n {/* Scrub line */}\n \n\n {/* Indicator dot */}\n \n \n );\n}\n", "type": "registry:ui", "target": "components/ruixen/spark-chart.tsx" } ] }