{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sparkline",
  "title": "Sparkline",
  "description": "A trend line small enough for a table cell, with the change since the first value and a readout you can move along it.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/sparkline/sparkline.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type SparklineProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  /** One value per step, oldest first. null is a gap in the line. */\n  data: readonly (number | null)[]\n  /** What the line measures. Names the chart for assistive technology. */\n  label: string\n  /** A name for each step, such as a date, used by the readout and the table. */\n  labels?: readonly string[]\n  /** Fills the space under the line with a light wash. */\n  area?: boolean\n  /** Shows the change from the first value to the latest. Defaults to true. */\n  showChange?: boolean\n  /** Which way is good news. Decides which change reads as a warning. */\n  goodDirection?: \"up\" | \"down\"\n  /** Lets a pointer or the arrow keys read any point. Defaults to true. */\n  scrub?: boolean\n  /** Adds a visually hidden table of every value. */\n  table?: boolean\n  /** Passed to Intl.NumberFormat for the values. */\n  format?: Intl.NumberFormatOptions\n  locale?: string\n  /** Any CSS colour. Defaults to the theme's first chart colour, then primary. */\n  color?: string\n  /** Height of the plot in pixels. Its width follows the element. */\n  height?: number\n}\n\ntype Point = { index: number; value: number }\n\nconst VIEW_WIDTH = 1000\nconst INSET = 4\n\nfunction segmentsOf(data: readonly (number | null)[]) {\n  const segments: Point[][] = []\n  let current: Point[] = []\n\n  data.forEach((value, index) => {\n    if (value === null || !Number.isFinite(value)) {\n      if (current.length) segments.push(current)\n      current = []\n      return\n    }\n    current.push({ index, value })\n  })\n  if (current.length) segments.push(current)\n\n  return segments\n}\n\nfunction nearest(points: readonly Point[], index: number) {\n  return points.reduce<Point | undefined>(\n    (best, point) =>\n      !best || Math.abs(point.index - index) < Math.abs(best.index - index)\n        ? point\n        : best,\n    undefined\n  )\n}\n\nexport function Sparkline({\n  data,\n  label,\n  labels,\n  area = false,\n  showChange = true,\n  goodDirection = \"up\",\n  scrub = true,\n  table = false,\n  format,\n  locale,\n  color = \"var(--chart-1, var(--primary))\",\n  height = 32,\n  className,\n  ...rootProps\n}: SparklineProps) {\n  const [active, setActive] = React.useState<number | null>(null)\n\n  const numbers = React.useMemo(\n    () => new Intl.NumberFormat(locale, format),\n    [format, locale]\n  )\n  const percent = React.useMemo(\n    () =>\n      new Intl.NumberFormat(locale, {\n        style: \"percent\",\n        maximumFractionDigits: 1,\n      }),\n    [locale]\n  )\n  const signed = React.useMemo(\n    () =>\n      new Intl.NumberFormat(locale, {\n        style: \"percent\",\n        maximumFractionDigits: 1,\n        signDisplay: \"exceptZero\",\n      }),\n    [locale]\n  )\n\n  const segments = segmentsOf(data)\n  const points = segments.flat()\n  const first = points[0]\n  const latest = points[points.length - 1]\n\n  const values = points.map((point) => point.value)\n  const min = values.length ? Math.min(...values) : 0\n  const max = values.length ? Math.max(...values) : 0\n  const span = max - min\n  const plotHeight = Math.max(height - INSET * 2, 1)\n\n  const x = (index: number) =>\n    data.length > 1 ? (index / (data.length - 1)) * VIEW_WIDTH : VIEW_WIDTH / 2\n  const y = (value: number) =>\n    span === 0 ? height / 2 : INSET + ((max - value) / span) * plotHeight\n  const xPercent = (index: number) => (x(index) / VIEW_WIDTH) * 100\n\n  const baseline = y(Math.min(Math.max(0, min), max))\n\n  const change =\n    first && latest && points.length > 1 && first.value !== 0\n      ? (latest.value - first.value) / Math.abs(first.value)\n      : null\n  const direction =\n    change === null || change === 0 ? \"flat\" : change > 0 ? \"up\" : \"down\"\n  const good = direction === \"flat\" || direction === goodDirection\n\n  const nameOf = (index: number) => labels?.[index] ?? `Point ${index + 1}`\n  const missing = data.length - points.length\n\n  const summary = latest\n    ? [\n        `${label}.`,\n        points.length > 1\n          ? `Low ${numbers.format(min)}, high ${numbers.format(max)}, latest ${numbers.format(latest.value)}.`\n          : `One value, ${numbers.format(latest.value)}.`,\n        change === null\n          ? \"\"\n          : direction === \"flat\"\n            ? \"Unchanged from the first value.\"\n            : `${direction === \"up\" ? \"Up\" : \"Down\"} ${percent.format(Math.abs(change))} from the first value.`,\n        missing ? `${missing} missing.` : \"\",\n      ]\n        .filter(Boolean)\n        .join(\" \")\n    : `${label}. No data.`\n\n  const shown =\n    active === null ? null : (points.find((p) => p.index === active) ?? null)\n  const dot = shown ?? latest\n\n  function scrubTo(clientX: number, element: HTMLElement) {\n    if (!points.length) return\n    const rect = element.getBoundingClientRect()\n    const ratio = rect.width ? (clientX - rect.left) / rect.width : 1\n    const index = Math.round(\n      Math.min(Math.max(ratio, 0), 1) * (data.length - 1)\n    )\n    const point = nearest(points, index)\n    if (point) setActive(point.index)\n  }\n\n  function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {\n    if (!latest) return\n    const position = Math.max(\n      points.findIndex((point) => point.index === (active ?? latest.index)),\n      0\n    )\n    const next =\n      event.key === \"ArrowLeft\" || event.key === \"ArrowDown\"\n        ? points[Math.max(position - 1, 0)]\n        : event.key === \"ArrowRight\" || event.key === \"ArrowUp\"\n          ? points[Math.min(position + 1, points.length - 1)]\n          : event.key === \"Home\"\n            ? points[0]\n            : event.key === \"End\"\n              ? latest\n              : undefined\n    if (!next) return\n    event.preventDefault()\n    setActive(next.index)\n  }\n\n  const line = segments\n    .map((segment) =>\n      segment\n        .map(\n          (point, i) =>\n            `${i === 0 ? \"M\" : \"L\"}${x(point.index).toFixed(2)} ${y(point.value).toFixed(2)}`\n        )\n        .join(\" \")\n    )\n    .join(\" \")\n\n  const fill = segments\n    .filter((segment) => segment.length > 1)\n    .map((segment) => {\n      const start = x(segment[0]?.index ?? 0).toFixed(2)\n      const end = x(segment[segment.length - 1]?.index ?? 0).toFixed(2)\n      const edge = segment\n        .map(\n          (point) =>\n            `L${x(point.index).toFixed(2)} ${y(point.value).toFixed(2)}`\n        )\n        .join(\" \")\n      return `M${start} ${baseline} ${edge} L${end} ${baseline} Z`\n    })\n    .join(\" \")\n\n  const readoutAlign = shown\n    ? xPercent(shown.index) < 20\n      ? \"translate-x-0\"\n      : xPercent(shown.index) > 80\n        ? \"-translate-x-full\"\n        : \"-translate-x-1/2\"\n    : \"\"\n\n  return (\n    <div\n      data-slot=\"sparkline\"\n      data-state={shown ? \"scrubbing\" : \"idle\"}\n      data-trend={direction}\n      className={cn(\n        \"inline-flex w-36 max-w-full items-center gap-2\",\n        className\n      )}\n      {...rootProps}\n    >\n      <div className=\"min-w-0 flex-1 px-1\" style={{ height }}>\n        <div className=\"relative size-full\">\n          <div role=\"img\" aria-label={summary} className=\"absolute inset-0\">\n            <svg\n              aria-hidden=\"true\"\n              className=\"block size-full overflow-visible\"\n              preserveAspectRatio=\"none\"\n              viewBox={`0 0 ${VIEW_WIDTH} ${height}`}\n            >\n              {min < 0 && max > 0 ? (\n                <line\n                  stroke=\"var(--border)\"\n                  strokeWidth=\"1\"\n                  vectorEffect=\"non-scaling-stroke\"\n                  x1=\"0\"\n                  x2={VIEW_WIDTH}\n                  y1={y(0)}\n                  y2={y(0)}\n                />\n              ) : null}\n              {area && fill ? (\n                <path d={fill} style={{ fill: color, fillOpacity: 0.12 }} />\n              ) : null}\n              {line ? (\n                <path\n                  d={line}\n                  fill=\"none\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth=\"2\"\n                  style={{ stroke: color }}\n                  vectorEffect=\"non-scaling-stroke\"\n                />\n              ) : null}\n            </svg>\n            {dot ? (\n              <span\n                aria-hidden=\"true\"\n                data-slot=\"sparkline-dot\"\n                className=\"ring-background pointer-events-none absolute size-2 -translate-1/2 rounded-full ring-2\"\n                style={{\n                  background: color,\n                  left: `${xPercent(dot.index)}%`,\n                  top: y(dot.value),\n                }}\n              />\n            ) : null}\n          </div>\n\n          {shown ? (\n            <span\n              aria-hidden=\"true\"\n              data-slot=\"sparkline-readout\"\n              className={cn(\n                \"bg-popover text-popover-foreground border-border pointer-events-none absolute bottom-full z-10 mb-1.5 rounded-md border px-1.5 py-0.5 text-xs whitespace-nowrap shadow-sm\",\n                readoutAlign\n              )}\n              style={{ left: `${xPercent(shown.index)}%` }}\n            >\n              {labels?.[shown.index] ? (\n                <span className=\"text-muted-foreground mr-1\">\n                  {labels[shown.index]}\n                </span>\n              ) : null}\n              <span className=\"font-medium tabular-nums\">\n                {numbers.format(shown.value)}\n              </span>\n            </span>\n          ) : null}\n\n          {scrub && dot ? (\n            <div\n              aria-label={`${label}, read a value`}\n              aria-valuemax={data.length - 1}\n              aria-valuemin={0}\n              aria-valuenow={dot.index}\n              aria-valuetext={`${nameOf(dot.index)}: ${numbers.format(dot.value)}`}\n              className=\"focus-visible:ring-ring absolute inset-0 cursor-crosshair touch-pan-y rounded-sm focus-visible:ring-2 focus-visible:outline-none\"\n              onBlur={() => setActive(null)}\n              onFocus={() => setActive(dot.index)}\n              onKeyDown={handleKeyDown}\n              onPointerDown={(event) =>\n                scrubTo(event.clientX, event.currentTarget)\n              }\n              onPointerLeave={() => setActive(null)}\n              onPointerMove={(event) =>\n                scrubTo(event.clientX, event.currentTarget)\n              }\n              onPointerUp={(event) => {\n                if (event.pointerType !== \"mouse\") setActive(null)\n              }}\n              role=\"slider\"\n              tabIndex={0}\n            />\n          ) : null}\n        </div>\n      </div>\n\n      {showChange && change !== null ? (\n        <span\n          data-slot=\"sparkline-change\"\n          data-good={good || undefined}\n          className={cn(\n            \"inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums\",\n            good\n              ? \"bg-muted text-foreground\"\n              : \"bg-destructive/10 text-destructive\"\n          )}\n        >\n          <svg\n            aria-hidden=\"true\"\n            className={cn(\"size-2.5\", direction === \"down\" && \"rotate-180\")}\n            fill=\"currentColor\"\n            viewBox=\"0 0 10 10\"\n          >\n            {direction === \"flat\" ? (\n              <rect height=\"2\" rx=\"1\" width=\"8\" x=\"1\" y=\"4\" />\n            ) : (\n              <path d=\"M5 1.5 9 8H1z\" />\n            )}\n          </svg>\n          {signed.format(change)}\n        </span>\n      ) : null}\n\n      {table ? (\n        <div className=\"sr-only\">\n          <table>\n            <caption>{label}</caption>\n            <thead>\n              <tr>\n                <th scope=\"col\">Point</th>\n                <th scope=\"col\">Value</th>\n              </tr>\n            </thead>\n            <tbody>\n              {data.map((value, index) => (\n                <tr key={index}>\n                  <th scope=\"row\">{nameOf(index)}</th>\n                  <td>\n                    {value === null || !Number.isFinite(value)\n                      ? \"No data\"\n                      : numbers.format(value)}\n                  </td>\n                </tr>\n              ))}\n            </tbody>\n          </table>\n        </div>\n      ) : null}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "chart",
    "data",
    "statistic",
    "table"
  ],
  "type": "registry:ui"
}