'use client' import * as React from 'react' import { cn } from '../../internal/utils' import { useDirection } from '../../hooks/use-direction' type SparklineVariant = 'line' | 'area' | 'column' interface SparklinePoint { index: number value: number label?: string } interface SparklineContextValue { data: number[] labels?: string[] activeIndex: number | null setActiveIndex: (index: number | null) => void format?: Intl.NumberFormatOptions locale?: Intl.LocalesArgument } const SparklineContext = React.createContext(null) function useSparkline(): SparklineContextValue { const ctx = React.useContext(SparklineContext) if (ctx === null) { throw new Error('Sparkline parts must be rendered inside .') } return ctx } interface SparklineProps extends Omit, 'onChange'> { /** **Required.** The series to plot. */ data: number[] /** Per-point labels (e.g. dates), surfaced in the tooltip and to `SparklineLabel`. */ labels?: string[] /** * Accent for the line, fill, indicator, and tooltip swatch. Any CSS color. * @default var(--primary) */ color?: string /** Formatting for displayed values (`SparklineValue`, tooltip). */ format?: Intl.NumberFormatOptions /** Locale used by `Intl.NumberFormat`. */ locale?: Intl.LocalesArgument /** Fires when the hovered point changes; `null` on pointer leave. */ onActiveChange?: (point: SparklinePoint | null) => void } function Sparkline({ data, labels, color, format, locale, onActiveChange, className, style, children, ...props }: SparklineProps) { const [activeIndex, setActiveIndex] = React.useState(null) const onActiveChangeRef = React.useRef(onActiveChange) const dataRef = React.useRef(data) const labelsRef = React.useRef(labels) React.useEffect(() => { onActiveChangeRef.current = onActiveChange dataRef.current = data labelsRef.current = labels }) const notifiedMountRef = React.useRef(false) React.useEffect(() => { if (!notifiedMountRef.current) { notifiedMountRef.current = true return } const cb = onActiveChangeRef.current if (!cb) return if (activeIndex === null) { cb(null) return } cb({ index: activeIndex, value: dataRef.current[activeIndex]!, label: labelsRef.current?.[activeIndex] }) }, [activeIndex]) const ctx = React.useMemo( () => ({ data, labels, activeIndex, setActiveIndex, format, locale }), [data, labels, activeIndex, format, locale], ) const rootStyle = { ...style, '--sparkline-color': color ?? 'var(--primary)' } as React.CSSProperties return (
{children}
) } interface SparklineChartProps extends Omit, 'children'> { /** * The layout. * @default 'line' */ variant?: SparklineVariant /** * Line smoothing from `0` (straight) to `1` (fully rounded). Line/area only. * @default 0.5 */ curve?: number /** * **`line` only.** Add a gradient fill from the line to the bottom edge. Area is always filled. * @default false */ fill?: boolean /** * The pivot the fill/bars grow from; values below it render below. Area and column. * @default 0 */ baseline?: number /** * Chart height, in pixels. * @default 48 */ height?: number /** * Line thickness, in pixels. Line/area only. * @default 2 */ strokeWidth?: number /** * Show the hover indicator (dot + guide, or the active-column highlight). * @default true */ indicator?: boolean /** * Float a tooltip at the hovered point. * @default false */ tooltip?: boolean /** Render custom tooltip content instead of the default swatch + value. Implies `tooltip`. */ renderTooltip?: (point: SparklinePoint) => React.ReactNode } function SparklineChart({ variant = 'line', curve = 0.5, fill = false, baseline = 0, height = 48, strokeWidth = 2, indicator = true, tooltip = false, renderTooltip, className, style, role = 'img', 'aria-label': ariaLabel, ...props }: SparklineChartProps) { const { data, labels, activeIndex, setActiveIndex, format, locale } = useSparkline() const dir = useDirection() const isRtl = dir === 'rtl' const gradientId = React.useId() const n = data.length const showTooltip = tooltip || renderTooltip !== undefined const interactive = indicator || showTooltip const rectRef = React.useRef(null) React.useEffect(() => { if (!interactive) return const invalidate = () => { rectRef.current = null } window.addEventListener('scroll', invalidate, true) window.addEventListener('resize', invalidate) return () => { window.removeEventListener('scroll', invalidate, true) window.removeEventListener('resize', invalidate) } }, [interactive]) const geom = React.useMemo(() => { if (variant === 'column' || n === 0) return null const isArea = variant === 'area' const [dataMin, dataMax] = getExtent(data) const lo = isArea ? Math.min(baseline, dataMin) : dataMin const hi = isArea ? Math.max(baseline, dataMax) : dataMax const span = hi - lo || 1 const inset = strokeWidth + 1 const plot = height - inset * 2 const yOf = (value: number) => inset + (1 - (value - lo) / span) * plot const points = data.map((value, i) => { const frac = n === 1 ? 0.5 : i / (n - 1) const xFrac = isRtl ? 1 - frac : frac const y = yOf(value) return { x: round(xFrac * 100), y: round(y), leftFrac: xFrac, topFrac: y / height } }) const d = buildLinePath(points, clamp01(curve)) const foot = isArea ? round(yOf(baseline)) : height const fillPath = `${d} L ${points[n - 1]!.x} ${foot} L ${points[0]!.x} ${foot} Z` return { d, fillPath, points, baselineY: isArea ? round(yOf(baseline)) : null } }, [data, variant, curve, height, strokeWidth, baseline, isRtl, n]) const columns = React.useMemo(() => { if (variant !== 'column' || n === 0) return null const [dataMin, dataMax] = getExtent(data) const lo = Math.min(baseline, dataMin) const hi = Math.max(baseline, dataMax) const span = hi - lo || 1 const baseFrac = (hi - baseline) / span const bars = data.map((value, i) => { const valueFrac = (hi - value) / span const center = (i + 0.5) / n return { top: round(Math.min(valueFrac, baseFrac) * 100), size: round(Math.abs(valueFrac - baseFrac) * 100), positive: value >= baseline, leftFrac: isRtl ? 1 - center : center, topFrac: valueFrac, } }) return { bars, baseFrac } }, [data, variant, baseline, isRtl, n]) if (n === 0) return null const showFill = geom !== null && (variant === 'area' || fill) const gradientFill = variant === 'line' const active = interactive && activeIndex !== null && activeIndex >= 0 && activeIndex < n ? activeIndex : null const marker = active === null ? null : geom ? geom.points[active] : columns!.bars[active] const activePoint: SparklinePoint | null = active === null ? null : { index: active, value: data[active]!, label: labels?.[active] } function updateActiveFromEvent(event: React.PointerEvent, fresh: boolean) { const rect = fresh || rectRef.current === null ? (rectRef.current = event.currentTarget.getBoundingClientRect()) : rectRef.current if (rect.width === 0) return let t = clamp01((event.clientX - rect.left) / rect.width) if (isRtl) t = 1 - t const index = variant === 'column' ? Math.min(n - 1, Math.floor(t * n)) : Math.round(t * (n - 1)) setActiveIndex(index) } function handlePointerDown(event: React.PointerEvent) { updateActiveFromEvent(event, true) } function handlePointerMove(event: React.PointerEvent) { updateActiveFromEvent(event, false) } function handlePointerUp(event: React.PointerEvent) { rectRef.current = null if (event.pointerType !== 'mouse') setActiveIndex(null) } return (
setActiveIndex(null) : undefined} onPointerUp={interactive ? handlePointerUp : undefined} onPointerCancel={interactive ? handlePointerUp : undefined} {...props} > {geom ? ( ) : ( <>
{columns!.bars.map((bar, i) => (
))}
) } type SparklineValueProps = Omit, 'children'> & { /** Render function to fully customize the displayed text. Omit for the number. */ children?: (formatted: string, value: number) => React.ReactNode } function SparklineValue({ className, children, ...props }: SparklineValueProps) { const { data, activeIndex, format, locale } = useSparkline() const index = activeIndex ?? data.length - 1 const value = data[index] ?? NaN const formatted = formatNumber(value, format, locale) return ( {children ? children(formatted, value) : formatted} ) } type SparklineLabelProps = Omit, 'children'> & { /** Render function to customize the displayed text. Omit for the raw label. */ children?: (label: string, point: SparklinePoint) => React.ReactNode } function SparklineLabel({ className, children, ...props }: SparklineLabelProps) { const { data, labels, activeIndex } = useSparkline() if (!labels) return null const index = activeIndex ?? data.length - 1 const label = labels[index] ?? '' return ( {children ? children(label, { index, value: data[index] ?? NaN, label }) : label} ) } interface PathPoint { x: number y: number leftFrac: number topFrac: number } function getExtent(data: number[]): [number, number] { let min = Infinity let max = -Infinity for (let i = 0; i < data.length; i++) { const v = data[i]! if (v < min) min = v if (v > max) max = v } return [min, max] } function buildLinePath(points: PathPoint[], smoothing: number): string { if (points.length === 0) return '' let d = `M ${points[0]!.x} ${points[0]!.y}` for (let i = 0; i < points.length - 1; i++) { const p1 = points[i]! const p2 = points[i + 1]! const p0 = points[i - 1] ?? p1 const p3 = points[i + 2] ?? p2 const cp1x = p1.x + ((p2.x - p0.x) / 6) * smoothing const cp1y = p1.y + ((p2.y - p0.y) / 6) * smoothing const cp2x = p2.x - ((p3.x - p1.x) / 6) * smoothing const cp2y = p2.y - ((p3.y - p1.y) / 6) * smoothing d += ` C ${round(cp1x)} ${round(cp1y)}, ${round(cp2x)} ${round(cp2y)}, ${p2.x} ${p2.y}` } return d } const numberFormatCache = new Map() function getNumberFormat(format?: Intl.NumberFormatOptions, locale?: Intl.LocalesArgument): Intl.NumberFormat { const cacheKey = JSON.stringify([locale ?? '', format ?? {}]) let formatter = numberFormatCache.get(cacheKey) if (!formatter) { formatter = new Intl.NumberFormat(locale, format) numberFormatCache.set(cacheKey, formatter) } return formatter } function formatNumber(value: number, format?: Intl.NumberFormatOptions, locale?: Intl.LocalesArgument): string { if (!Number.isFinite(value)) return '' return getNumberFormat(format, locale).format(value) } const round = (n: number) => Math.round(n * 100) / 100 const clamp01 = (n: number) => Math.min(1, Math.max(0, n)) export { Sparkline, SparklineChart, SparklineValue, SparklineLabel } export type { SparklineProps, SparklineChartProps, SparklineValueProps, SparklineLabelProps, SparklinePoint }