Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
65 lines
2.7 KiB
TypeScript
65 lines
2.7 KiB
TypeScript
import { useRef, useState } from 'react'
|
|
|
|
export interface SparkSeries { name: string; values: number[]; color: string }
|
|
|
|
interface Props {
|
|
series: SparkSeries[]
|
|
max?: number // fixed ceiling (e.g. 100 for percent); otherwise auto from data
|
|
format: (v: number) => string
|
|
height?: number
|
|
}
|
|
|
|
const W = 300
|
|
|
|
// Thin-line sparkline with a crosshair tooltip. Stroke stays 2px at any width via
|
|
// non-scaling-stroke; the box is stretched with preserveAspectRatio="none".
|
|
export default function Sparkline({ series, max, format, height = 44 }: Props) {
|
|
const box = useRef<HTMLDivElement>(null)
|
|
const [hover, setHover] = useState<number | null>(null)
|
|
const n = Math.max(...series.map((s) => s.values.length), 0)
|
|
if (n < 2) return <div className="spark" style={{ height }} />
|
|
|
|
let top = max ?? 0
|
|
if (max === undefined) for (const s of series) for (const v of s.values) if (v > top) top = v
|
|
if (top <= 0) top = 1
|
|
const H = height
|
|
const x = (i: number) => (i / (n - 1)) * W
|
|
const y = (v: number) => H - 2 - (Math.min(v, top) / top) * (H - 4)
|
|
const path = (vals: number[]) => {
|
|
const off = n - vals.length
|
|
return vals.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i + off).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
|
|
}
|
|
|
|
const move = (clientX: number) => {
|
|
const r = box.current?.getBoundingClientRect()
|
|
if (!r || r.width === 0) return
|
|
const f = Math.min(1, Math.max(0, (clientX - r.left) / r.width))
|
|
setHover(Math.round(f * (n - 1)))
|
|
}
|
|
|
|
return (
|
|
<div ref={box} className="spark" style={{ height }}
|
|
onMouseMove={(e) => move(e.clientX)} onMouseLeave={() => setHover(null)}
|
|
onTouchStart={(e) => move(e.touches[0].clientX)} onTouchMove={(e) => move(e.touches[0].clientX)} onTouchEnd={() => setHover(null)}>
|
|
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
|
|
<line x1={0} x2={W} y1={H - 2} y2={H - 2} stroke="var(--border)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
|
{series.map((s) => (
|
|
<path key={s.name} d={path(s.values)} fill="none" stroke={s.color} strokeWidth={2}
|
|
strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
|
|
))}
|
|
{hover !== null && (
|
|
<line x1={x(hover)} x2={x(hover)} y1={0} y2={H} stroke="var(--muted)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
|
)}
|
|
</svg>
|
|
{hover !== null && (
|
|
<div className="tip" style={{ left: `${(hover / (n - 1)) * 100}%` }}>
|
|
{series.map((s) => {
|
|
const v = s.values[hover - (n - s.values.length)]
|
|
return v === undefined ? null : <span key={s.name}><span className="sw" style={{ background: s.color }} />{s.name} {format(v)} </span>
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|