{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sparkline",
  "type": "registry:ui",
  "title": "Sparkline",
  "description": "A word-sized chart for tables and text: line, area or bars, coloured by trend, with a computed summary and nearest-value tooltip.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/sparkline.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type SparklineVariant = \"line\" | \"area\" | \"bar\";\n\nexport interface SparklineProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, \"children\"> {\n  /** Values, oldest first. */\n  data: number[];\n  variant?: SparklineVariant;\n  /** Fixed lower bound. Auto (data minimum) when omitted. */\n  min?: number;\n  /** Fixed upper bound. Auto (data maximum) when omitted. */\n  max?: number;\n  /** Monotone curve (never overshoots the data) or straight segments. */\n  curve?: \"smooth\" | \"linear\";\n  /** CSS width; any length works — \"100%\" makes it responsive. */\n  width?: number | string;\n  /** CSS height; the default matches the surrounding text. */\n  height?: number | string;\n  /** Line width in px. */\n  strokeWidth?: number;\n  /** Dot on the latest value. */\n  showDot?: boolean;\n  /** Latest value and change printed after the chart. */\n  showValue?: boolean;\n  /** Colour by trend direction or always use the accent. */\n  colorBy?: \"trend\" | \"accent\";\n  accent?: string;\n  upColor?: string;\n  downColor?: string;\n  /** Draw in when scrolled into view. */\n  animate?: boolean;\n  /** Draw-in length in ms. */\n  duration?: number;\n  /** Hover / focus tooltip with the nearest value. */\n  interactive?: boolean;\n  /** Metric name, used in the accessible summary, e.g. \"Revenue\". */\n  label?: string;\n  /** What one data point represents, plural, for the summary: \"over 30 days\". */\n  period?: string;\n  /** Optional label per point (e.g. dates), shown in the tooltip. */\n  labels?: string[];\n  locale?: string;\n  formatValue?: (value: number) => string;\n}\n\n/* SSR-safe: renders \"no preference\" on the server and the first client pass, so markup always hydrates. */\nfunction subscribeReducedMotion(onChange: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/** viewBox is 100 × 100 with preserveAspectRatio=\"none\"; strokes are non-scaling, the dot is HTML. */\nconst PAD_Y = 10;\nconst r2 = (n: number) => Math.round(n * 100) / 100;\n\n/** Monotone cubic interpolation (Fritsch–Carlson): smooth, but never overshoots the data. */\nfunction monotonePath(points: [number, number][]) {\n  const n = points.length;\n  if (n < 2) return n ? `M${points[0][0]},${points[0][1]}` : \"\";\n  const dx: number[] = [];\n  const slope: number[] = [];\n  for (let i = 0; i < n - 1; i++) {\n    dx[i] = points[i + 1][0] - points[i][0];\n    slope[i] = (points[i + 1][1] - points[i][1]) / (dx[i] || 1);\n  }\n  const tangent: number[] = [slope[0]];\n  for (let i = 1; i < n - 1; i++) {\n    tangent[i] = slope[i - 1] * slope[i] <= 0 ? 0 : (3 * (dx[i - 1] + dx[i])) / ((2 * dx[i] + dx[i - 1]) / slope[i - 1] + (dx[i] + 2 * dx[i - 1]) / slope[i]);\n  }\n  tangent[n - 1] = slope[n - 2];\n  let d = `M${r2(points[0][0])},${r2(points[0][1])}`;\n  for (let i = 0; i < n - 1; i++) {\n    const [x0, y0] = points[i];\n    const [x1, y1] = points[i + 1];\n    const h = dx[i] / 3;\n    d += `C${r2(x0 + h)},${r2(y0 + tangent[i] * h)},${r2(x1 - h)},${r2(y1 - tangent[i + 1] * h)},${r2(x1)},${r2(y1)}`;\n  }\n  return d;\n}\n\nfunction linearPath(points: [number, number][]) {\n  return points.map(([x, y], i) => `${i ? \"L\" : \"M\"}${r2(x)},${r2(y)}`).join(\"\");\n}\n\n/**\n * A word-sized chart for tables and running text: line, area or bars, coloured by trend with\n * an arrow and a computed text summary, a dot on the latest value, a nearest-value tooltip on\n * hover, tap and keyboard focus, and a draw-in when it scrolls into view.\n */\nexport function Sparkline({\n  data,\n  variant = \"line\",\n  min,\n  max,\n  curve = \"smooth\",\n  width = \"6em\",\n  height = \"1.25em\",\n  strokeWidth = 1.5,\n  showDot = true,\n  showValue = false,\n  colorBy = \"trend\",\n  accent = \"#FF7448\",\n  upColor = \"#2FBF71\",\n  downColor = \"#E5484D\",\n  animate = true,\n  duration = 900,\n  interactive = true,\n  label,\n  period = \"days\",\n  labels,\n  locale = \"en-US\",\n  formatValue,\n  className = \"\",\n  style,\n  ...props\n}: SparklineProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const chartRef = React.useRef<HTMLSpanElement>(null);\n  const cursorRef = React.useRef<number | null>(null);\n  const touchTimer = React.useRef(0);\n  const gradientId = `sp-${React.useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  const [inView, setInView] = React.useState(false);\n  const [cursor, setCursor] = React.useState<number | null>(null);\n  const [announce, setAnnounce] = React.useState(\"\");\n\n  const values = data.filter((v) => Number.isFinite(v));\n  const n = values.length;\n  const lo = min ?? (n ? Math.min(...values) : 0);\n  const hiRaw = max ?? (n ? Math.max(...values) : 1);\n  const hi = hiRaw === lo ? lo + 1 : hiRaw;\n\n  const fmt = (v: number) =>\n    formatValue ? formatValue(v) : new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(v);\n\n  // Trend: first → last.\n  const first = values[0] ?? 0;\n  const last = values[n - 1] ?? 0;\n  const change = first !== 0 ? ((last - first) / Math.abs(first)) * 100 : last - first === 0 ? 0 : null;\n  const direction = change === null ? (last > first ? \"up\" : \"down\") : Math.abs(change) < 0.5 ? \"flat\" : change > 0 ? \"up\" : \"down\";\n  const pct = change === null ? null : new Intl.NumberFormat(locale, { maximumFractionDigits: Math.abs(change) < 10 ? 1 : 0 }).format(Math.abs(change));\n  const summary = n\n    ? `${label ? `${label}: ` : \"\"}${\n        direction === \"flat\"\n          ? \"Trend flat\"\n          : `Trend ${direction} ${pct !== null ? `${pct}%` : `by ${fmt(Math.abs(last - first))}`}`\n      } over ${n} ${period}, latest ${fmt(last)}${n > 1 ? `, range ${fmt(Math.min(...values))} to ${fmt(Math.max(...values))}` : \"\"}`\n    : `${label ? `${label}: ` : \"\"}No data`;\n\n  const color = colorBy === \"accent\" ? accent : direction === \"up\" ? upColor : direction === \"down\" ? downColor : \"#8a8a93\";\n\n  const yOf = (v: number) => PAD_Y + (1 - (Math.min(hi, Math.max(lo, v)) - lo) / (hi - lo)) * (100 - PAD_Y * 2);\n  const xOf = (i: number) => (variant === \"bar\" ? ((i + 0.5) / n) * 100 : n > 1 ? (i / (n - 1)) * 100 : 50);\n  const points: [number, number][] = values.map((v, i) => [xOf(i), yOf(v)]);\n  const linePath = curve === \"smooth\" ? monotonePath(points) : linearPath(points);\n  const bottom = 100 - PAD_Y / 2;\n  const areaPath = n > 1 ? `${linePath}L${r2(points[n - 1][0])},${bottom}L${r2(points[0][0])},${bottom}Z` : \"\";\n\n  // Draw in once, when the chart first scrolls into view.\n  React.useEffect(() => {\n    const el = chartRef.current;\n    if (!el || !animate) return;\n    const observer = new IntersectionObserver((entries) => {\n      if (entries.some((e) => e.isIntersecting)) {\n        setInView(true);\n        observer.disconnect();\n      }\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [animate]);\n\n  React.useEffect(() => () => window.clearTimeout(touchTimer.current), []);\n\n  const drawn = !animate || reduceMotion || inView;\n  const transition = animate && !reduceMotion ? `${Math.max(0, duration)}ms` : \"0ms\";\n\n  const moveCursor = (index: number | null) => {\n    if (cursorRef.current === index) return;\n    cursorRef.current = index;\n    setCursor(index);\n  };\n\n  /** Nearest point to the pointer — state only changes when the index does. */\n  const indexAt = (clientX: number) => {\n    const el = chartRef.current;\n    if (!el || !n) return null;\n    const rect = el.getBoundingClientRect();\n    const t = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n    return variant === \"bar\" ? Math.min(n - 1, Math.floor(t * n)) : Math.round(t * (n - 1));\n  };\n\n  const pointLabel = (i: number) => labels?.[i] ?? `#${i + 1}`;\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => {\n    if (!n) return;\n    const currentIndex = cursorRef.current ?? n - 1;\n    let next: number | null = null;\n    if (event.key === \"ArrowLeft\") next = Math.max(0, currentIndex - 1);\n    else if (event.key === \"ArrowRight\") next = Math.min(n - 1, currentIndex + 1);\n    else if (event.key === \"Home\") next = 0;\n    else if (event.key === \"End\") next = n - 1;\n    else if (event.key === \"Escape\") {\n      moveCursor(null);\n      return;\n    }\n    if (next === null) return;\n    event.preventDefault();\n    moveCursor(next);\n    setAnnounce(`${pointLabel(next)}: ${fmt(values[next])}`);\n  };\n\n  const active = interactive && cursor !== null && cursor < n ? cursor : null;\n  const dotIndex = active ?? (showDot && n ? n - 1 : null);\n  const arrow = direction === \"up\" ? \"▲\" : direction === \"down\" ? \"▼\" : \"–\";\n\n  return (\n    <span\n      className={`relative inline-flex items-center gap-1.5 align-middle [--sp-ink:color-mix(in_oklab,var(--sp-color)_72%,#18181b)] dark:[--sp-ink:var(--sp-color)] ${className}`}\n      style={{ [\"--sp-color\" as string]: color, ...style }}\n      {...props}\n    >\n      <span\n        ref={chartRef}\n        role=\"img\"\n        aria-label={summary}\n        tabIndex={interactive ? 0 : undefined}\n        className={`relative inline-block min-w-0 rounded-[3px] outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sp-color)] ${\n          interactive ? \"cursor-crosshair touch-pan-y\" : \"\"\n        }`}\n        style={{ width, height }}\n        onPointerMove={interactive ? (e) => moveCursor(indexAt(e.clientX)) : undefined}\n        onPointerDown={\n          interactive\n            ? (e) => {\n                moveCursor(indexAt(e.clientX));\n                if (e.pointerType !== \"mouse\") {\n                  // Touch has no hover: keep the tooltip briefly after a tap.\n                  window.clearTimeout(touchTimer.current);\n                  touchTimer.current = window.setTimeout(() => moveCursor(null), 2200);\n                }\n              }\n            : undefined\n        }\n        onPointerLeave={interactive ? (e) => e.pointerType === \"mouse\" && moveCursor(null) : undefined}\n        onKeyDown={interactive ? handleKeyDown : undefined}\n        onFocus={interactive ? (e) => e.currentTarget.matches(\":focus-visible\") && moveCursor(n - 1) : undefined}\n        onBlur={interactive ? () => moveCursor(null) : undefined}\n      >\n        <svg\n          aria-hidden=\"true\"\n          viewBox=\"0 0 100 100\"\n          preserveAspectRatio=\"none\"\n          className=\"absolute inset-0 size-full overflow-visible\"\n          style={{\n            // Line and area draw in left to right; bars grow individually below.\n            clipPath: variant === \"bar\" || drawn ? \"inset(-50% -50% -50% -50%)\" : \"inset(-50% 100% -50% -50%)\",\n            transition: variant === \"bar\" ? undefined : `clip-path ${transition} cubic-bezier(0.65, 0, 0.35, 1)`,\n          }}\n        >\n          {variant === \"area\" && n > 1 ? (\n            <>\n              <defs>\n                <linearGradient id={gradientId} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n                  <stop offset=\"0%\" style={{ stopColor: \"var(--sp-color)\", stopOpacity: 0.28 }} />\n                  <stop offset=\"100%\" style={{ stopColor: \"var(--sp-color)\", stopOpacity: 0 }} />\n                </linearGradient>\n              </defs>\n              <path d={areaPath} fill={`url(#${gradientId})`} />\n            </>\n          ) : null}\n          {variant === \"bar\"\n            ? values.map((v, i) => {\n                const slot = 100 / n;\n                const zero = yOf(Math.min(hi, Math.max(lo, 0)));\n                const base = lo >= 0 ? bottom : zero;\n                const y = Math.min(base, yOf(v));\n                const h = Math.max(1, Math.abs(base - yOf(v)));\n                return (\n                  <rect\n                    key={i}\n                    x={r2(i * slot + slot * 0.14)}\n                    y={r2(y)}\n                    width={r2(slot * 0.72)}\n                    height={r2(h)}\n                    className=\"fill-[var(--sp-color)]\"\n                    fillOpacity={active === i || (active === null && i === n - 1) ? 1 : 0.45}\n                    style={{\n                      transformBox: \"fill-box\",\n                      transformOrigin: v >= 0 ? \"bottom\" : \"top\",\n                      transform: drawn ? \"scaleY(1)\" : \"scaleY(0)\",\n                      transition: `transform ${transition} cubic-bezier(0.22, 1, 0.36, 1) ${animate && !reduceMotion ? Math.round((i / n) * Math.max(0, duration) * 0.5) : 0}ms, fill-opacity 150ms`,\n                    }}\n                  />\n                );\n              })\n            : (\n              <path\n                d={linePath}\n                fill=\"none\"\n                className=\"stroke-[var(--sp-color)]\"\n                strokeWidth={strokeWidth}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                vectorEffect=\"non-scaling-stroke\"\n              />\n            )}\n        </svg>\n\n        {/* The dot is HTML so it stays round however the chart is stretched. */}\n        {dotIndex !== null && variant !== \"bar\" ? (\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute rounded-full bg-[var(--sp-color)] ring-2 ring-white transition-opacity duration-300 dark:ring-zinc-950\"\n            style={{\n              left: `${r2(points[dotIndex][0])}%`,\n              top: `${r2(points[dotIndex][1])}%`,\n              width: strokeWidth * 2 + 3,\n              height: strokeWidth * 2 + 3,\n              transform: \"translate(-50%, -50%)\",\n              opacity: drawn ? 1 : 0,\n              transitionDelay: drawn && active === null ? transition : \"0ms\",\n            }}\n          />\n        ) : null}\n\n        {active !== null ? (\n          <>\n            <span\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-y-0 w-px bg-zinc-900/20 dark:bg-white/25\"\n              style={{ left: `${r2(points[active][0])}%` }}\n            />\n            <span\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute bottom-full z-10 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded-md border border-zinc-900/10 bg-white px-1.5 py-1 font-mono text-[10px] leading-none tracking-[0.04em] text-zinc-900 shadow-sm dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100\"\n              style={{ left: `${r2(points[active][0])}%` }}\n            >\n              <span className=\"text-zinc-500 dark:text-zinc-400\">{pointLabel(active)}</span> {fmt(values[active])}\n            </span>\n          </>\n        ) : null}\n      </span>\n\n      {showValue && n ? (\n        <span aria-hidden=\"true\" className=\"inline-flex items-baseline gap-1 whitespace-nowrap font-mono text-[0.8em] tabular-nums\">\n          <span className=\"text-zinc-900 dark:text-zinc-100\">{fmt(last)}</span>\n          <span className=\"text-[var(--sp-ink)]\">\n            {arrow}\n            {pct !== null ? `${pct}%` : \"\"}\n          </span>\n        </span>\n      ) : null}\n\n      {/* Keyboard exploration is announced politely; hover is not, to avoid chatter. */}\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {announce}\n      </span>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/sparkline"
}
