SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
3.7 KB · 92 lines tsx
Raw Blame History
1import { extent, max, min } from 'd3-array';2import { scaleLinear } from 'd3-scale';3import { area as d3area, curveMonotoneX, line as d3line } from 'd3-shape';4import { cn } from '@/lib/cn';56/**7 * Inline sparkline (SVG, server-renderable). Values are plotted left→right; nulls break the line. `baseline` draws a8 * dashed reference (e.g. index 100). `tone` colours by trend when 'auto'. Never fabricates: empty → a flat hairline.9 */10export function Sparkline({11  values,12  width = 96,13  height = 24,14  className,15  tone = 'auto',16  fill = true,17  baseline,18  strokeWidth = 1.4,19  ariaLabel,20}: {21  values: Array<number | null | undefined> | undefined | null;22  width?: number;23  height?: number;24  className?: string;25  tone?: 'auto' | 'accent' | 'positive' | 'negative' | 'muted' | 'ink';26  fill?: boolean;27  baseline?: number;28  strokeWidth?: number;29  ariaLabel?: string;30}) {31  const pts = (values ?? []).map((v, i) => [i, typeof v === 'number' && Number.isFinite(v) ? v : null] as [number, number | null]);32  const valid = pts.filter((p): p is [number, number] => p[1] !== null);33  if (valid.length < 2) {34    return (35      <svg width={width} height={height} className={cn('block shrink-0', className)} aria-hidden>36        <line x1={0} x2={width} y1={height / 2} y2={height / 2} stroke="var(--rule-strong)" strokeDasharray="2 3" />37      </svg>38    );39  }40  const first = valid[0]?.[1] ?? 0;41  const last = valid[valid.length - 1]?.[1] ?? 0;42  const color =43    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)';44  const x = scaleLinear()45    .domain([0, pts.length - 1])46    .range([1, width - 1]);47  const ys = valid.map((p) => p[1]);48  let [lo, hi] = extent([...ys, ...(baseline !== undefined ? [baseline] : [])]) as [number, number];49  if (lo === hi) {50    lo -= 1;51    hi += 1;52  }53  const y = scaleLinear()54    .domain([lo, hi])55    .range([height - 2, 2]);56  const l = d3line<[number, number | null]>()57    .defined((d) => d[1] !== null)58    .x((d) => x(d[0]))59    .y((d) => y(d[1] as number))60    .curve(curveMonotoneX);61  const a = d3area<[number, number | null]>()62    .defined((d) => d[1] !== null)63    .x((d) => x(d[0]))64    .y0(height)65    .y1((d) => y(d[1] as number))66    .curve(curveMonotoneX);67  const lastPt = valid[valid.length - 1] as [number, number];68  return (69    <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={cn('block shrink-0 overflow-visible', className)} role={ariaLabel ? 'img' : undefined} aria-label={ariaLabel} aria-hidden={ariaLabel ? undefined : true}>70      {fill && <path d={a(pts) ?? ''} fill={color} opacity={0.1} />}71      {baseline !== undefined && <line x1={0} x2={width} y1={y(baseline)} y2={y(baseline)} stroke="var(--rule-strong)" strokeDasharray="2 3" />}72      <path d={l(pts) ?? ''} fill="none" stroke={color} strokeWidth={strokeWidth} strokeLinejoin="round" strokeLinecap="round" />73      <circle cx={x(lastPt[0])} cy={y(lastPt[1])} r={1.8} fill={color} />74    </svg>75  );76}7778/** Delta helper: first vs last of a series (percentage), null when not computable. */79export function sparkDelta(values: Array<number | null | undefined> | undefined | null): number | null {80  const v = (values ?? []).filter((x): x is number => typeof x === 'number' && Number.isFinite(x));81  if (v.length < 2) return null;82  const a = v[0] as number;83  const b = v[v.length - 1] as number;84  if (a === 0) return null;85  return ((b - a) / Math.abs(a)) * 100;86}8788export function seriesMinMax(values: number[]): [number, number] | null {89  if (!values.length) return null;90  return [min(values) ?? 0, max(values) ?? 0];91}92