import { extent, max, min } from 'd3-array'; import { scaleLinear } from 'd3-scale'; import { area as d3area, curveMonotoneX, line as d3line } from 'd3-shape'; import { cn } from '@/lib/cn'; /** * Inline sparkline (SVG, server-renderable). Values are plotted left→right; nulls break the line. `baseline` draws a * dashed reference (e.g. index 100). `tone` colours by trend when 'auto'. Never fabricates: empty → a flat hairline. */ export function Sparkline({ values, width = 96, height = 24, className, tone = 'auto', fill = true, baseline, strokeWidth = 1.4, ariaLabel, }: { values: Array | undefined | null; width?: number; height?: number; className?: string; tone?: 'auto' | 'accent' | 'positive' | 'negative' | 'muted' | 'ink'; fill?: boolean; baseline?: number; strokeWidth?: number; ariaLabel?: string; }) { const pts = (values ?? []).map((v, i) => [i, typeof v === 'number' && Number.isFinite(v) ? v : null] as [number, number | null]); const valid = pts.filter((p): p is [number, number] => p[1] !== null); if (valid.length < 2) { return ( ); } const first = valid[0]?.[1] ?? 0; const last = valid[valid.length - 1]?.[1] ?? 0; const color = tone === 'auto' ? (last > first ? 'var(--positive)' : last < first ? 'var(--danger)' : 'var(--ink-3)') : tone === 'accent' ? 'var(--accent)' : tone === 'positive' ? 'var(--positive)' : tone === 'negative' ? 'var(--danger)' : tone === 'ink' ? 'var(--ink)' : 'var(--ink-3)'; const x = scaleLinear() .domain([0, pts.length - 1]) .range([1, width - 1]); const ys = valid.map((p) => p[1]); let [lo, hi] = extent([...ys, ...(baseline !== undefined ? [baseline] : [])]) as [number, number]; if (lo === hi) { lo -= 1; hi += 1; } const y = scaleLinear() .domain([lo, hi]) .range([height - 2, 2]); const l = d3line<[number, number | null]>() .defined((d) => d[1] !== null) .x((d) => x(d[0])) .y((d) => y(d[1] as number)) .curve(curveMonotoneX); const a = d3area<[number, number | null]>() .defined((d) => d[1] !== null) .x((d) => x(d[0])) .y0(height) .y1((d) => y(d[1] as number)) .curve(curveMonotoneX); const lastPt = valid[valid.length - 1] as [number, number]; return ( {fill && } {baseline !== undefined && } ); } /** Delta helper: first vs last of a series (percentage), null when not computable. */ export function sparkDelta(values: Array | undefined | null): number | null { const v = (values ?? []).filter((x): x is number => typeof x === 'number' && Number.isFinite(x)); if (v.length < 2) return null; const a = v[0] as number; const b = v[v.length - 1] as number; if (a === 0) return null; return ((b - a) / Math.abs(a)) * 100; } export function seriesMinMax(values: number[]): [number, number] | null { if (!values.length) return null; return [min(values) ?? 0, max(values) ?? 0]; }