{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart",
  "title": "Chart",
  "description": "Interactive line chart with a cursor-tracked tooltip card, snap-to-point hover, and built-in X-axis labels.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/spell-ui/chart.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst VIEWBOX_W = 640;\nconst VIEWBOX_H = 220;\nconst PAD_X = 0;\nconst PAD_Y_TOP = 24;\nconst PAD_Y_BOTTOM = 12;\nconst LINE_WIDTH = 2;\nconst CORNER_RADIUS = 2.5;\nconst TRANSITION = \"200ms cubic-bezier(0.16, 1, 0.3, 1)\";\nconst TOOLTIP_SHADOW = [\n  \"0 0 0 1px rgba(0, 0, 0, 0.04)\",\n  \"0 1px 2px rgba(0, 0, 0, 0.04)\",\n  \"0 4px 16px -4px rgba(0, 0, 0, 0.12)\",\n  \"0 12px 32px -8px rgba(0, 0, 0, 0.08)\",\n].join(\", \");\nconst DOT_SHADOW = \"0 0 0 2px #FFF, 0 0 8px 2px rgba(0, 0, 0, 0.12)\";\n\nfunction buildRoundedPath(\n  pts: Array<{ x: number; y: number }>,\n  radius: number,\n): string {\n  if (pts.length === 0) return \"\";\n  const f = (n: number) => n.toFixed(3);\n  if (pts.length === 1) return `M${f(pts[0].x)} ${f(pts[0].y)}`;\n\n  let d = `M${f(pts[0].x)} ${f(pts[0].y)}`;\n  for (let i = 1; i < pts.length - 1; i++) {\n    const prev = pts[i - 1];\n    const curr = pts[i];\n    const next = pts[i + 1];\n    const inDx = curr.x - prev.x;\n    const inDy = curr.y - prev.y;\n    const inLen = Math.hypot(inDx, inDy) || 1;\n    const outDx = next.x - curr.x;\n    const outDy = next.y - curr.y;\n    const outLen = Math.hypot(outDx, outDy) || 1;\n    const r = Math.min(radius, inLen / 2, outLen / 2);\n    const bx = curr.x - (inDx / inLen) * r;\n    const by = curr.y - (inDy / inLen) * r;\n    const ax = curr.x + (outDx / outLen) * r;\n    const ay = curr.y + (outDy / outLen) * r;\n    d += ` L${f(bx)} ${f(by)} Q${f(curr.x)} ${f(curr.y)} ${f(ax)} ${f(ay)}`;\n  }\n  const last = pts[pts.length - 1];\n  d += ` L${f(last.x)} ${f(last.y)}`;\n  return d;\n}\n\nexport interface ChartProps {\n  /** Numeric values to plot — one segment per pair of consecutive points. */\n  data: number[];\n  /** Optional label for each data point — shown at the top of the tooltip card. */\n  labels?: string[];\n  /** Series name shown next to the indicator dot in the tooltip. */\n  name?: string;\n  /** Line + dot color as a hex string (e.g. `\"#0090FD\"`). Default `\"#0090FD\"`. */\n  color?: string;\n  /** Maximum chart width in px. The chart preserves a 640:220 aspect ratio. Default `640`. */\n  width?: number;\n  /** Format the tooltip value. Receives the value and its index. */\n  formatValue?: (value: number, index: number) => React.ReactNode;\n  /** Index of the active point on initial render. Defaults to the last point. */\n  defaultIndex?: number;\n  /** Show X-axis tick labels at the bottom. Requires `labels`. Default `true`. */\n  showXAxis?: boolean;\n  /** Reveal the line + fill in color from left to the cursor (gray before, color after). Default `false`. */\n  reveal?: boolean;\n  /** Show the gray gradient fill under the line. Default `true`. */\n  showFill?: boolean;\n  /** Show the colored dot at the active data point. Default `true`. */\n  showDot?: boolean;\n  /** Smoothly animate the cursor, dot, and tooltip when the active point changes. Default `true`. */\n  animated?: boolean;\n  /** Target number of X-axis ticks. Default `6`. */\n  tickCount?: number;\n  /** Additional class names on the root container. */\n  className?: string;\n}\n\nexport function Chart({\n  data,\n  labels,\n  name,\n  color = \"#0090FD\",\n  width = 640,\n  formatValue = (v) => v.toLocaleString(),\n  defaultIndex,\n  showXAxis = true,\n  tickCount = 6,\n  reveal = false,\n  showFill = true,\n  showDot = true,\n  animated = true,\n  className,\n}: ChartProps) {\n  const transition = animated ? TRANSITION : \"0ms\";\n  const reactId = React.useId();\n  const grayFillId = `${reactId}-gf`;\n  const clipId = `${reactId}-clip`;\n\n  const root = React.useRef<HTMLDivElement>(null);\n  const [activeIndex, setActiveIndex] = React.useState<number>(\n    defaultIndex ?? Math.max(0, data.length - 1),\n  );\n  const [containerWidth, setContainerWidth] = React.useState<number>(width);\n\n  React.useEffect(() => {\n    if (!root.current) return;\n    const ro = new ResizeObserver((entries) => {\n      const w = entries[0]?.contentRect.width;\n      if (w) setContainerWidth(w);\n    });\n    ro.observe(root.current);\n    return () => ro.disconnect();\n  }, []);\n\n  const points = React.useMemo(() => {\n    const n = data.length;\n    if (n === 0) return [];\n    const minV = Math.min(...data);\n    const maxV = Math.max(...data);\n    const range = maxV - minV || 1;\n    const innerW = VIEWBOX_W - 2 * PAD_X;\n    const innerH = VIEWBOX_H - PAD_Y_TOP - PAD_Y_BOTTOM;\n    return data.map((value, i) => ({\n      value,\n      index: i,\n      x: PAD_X + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW),\n      y: PAD_Y_TOP + (1 - (value - minV) / range) * innerH,\n    }));\n  }, [data]);\n\n  const { strokePath, fillPath } = React.useMemo(() => {\n    if (points.length === 0) return { strokePath: \"\", fillPath: \"\" };\n    const stroke = buildRoundedPath(points, CORNER_RADIUS);\n    const last = points[points.length - 1];\n    const first = points[0];\n    const baseY = VIEWBOX_H - PAD_Y_BOTTOM;\n    const fill = `${stroke} L${last.x.toFixed(3)} ${baseY} L${first.x.toFixed(3)} ${baseY} Z`;\n    return { strokePath: stroke, fillPath: fill };\n  }, [points]);\n\n  const active = points[Math.min(activeIndex, points.length - 1)] ?? points[0];\n  const activeXPct = active ? active.x / VIEWBOX_W : 0;\n  const activeYPct = active ? active.y / VIEWBOX_H : 0;\n\n  function onMove(e: React.MouseEvent<HTMLDivElement>) {\n    if (!root.current || points.length === 0) return;\n    const rect = root.current.getBoundingClientRect();\n    const rel = (e.clientX - rect.left) / rect.width;\n    const innerLeft = PAD_X / VIEWBOX_W;\n    const innerRight = (VIEWBOX_W - PAD_X) / VIEWBOX_W;\n    const t = (rel - innerLeft) / (innerRight - innerLeft);\n    const idx = Math.round(t * (points.length - 1));\n    setActiveIndex(Math.max(0, Math.min(points.length - 1, idx)));\n  }\n\n  const axisVisible = showXAxis && !!labels && labels.length > 0;\n  const tickIndices = React.useMemo(() => {\n    if (!axisVisible) return [];\n    const n = points.length;\n    // Estimate label width ~80px and require min gap so labels don't overlap.\n    const maxFit = Math.max(2, Math.floor(containerWidth / 80));\n    const count = Math.min(tickCount, maxFit, n);\n    if (count <= 1) return [0];\n    return Array.from({ length: count }, (_, i) =>\n      Math.round((i * (n - 1)) / (count - 1)),\n    );\n  }, [axisVisible, points.length, tickCount, containerWidth]);\n\n  if (points.length === 0 || !active) return null;\n\n  return (\n    <div\n      style={\n        {\n          maxWidth: width,\n          \"--spell-color\": color,\n        } as React.CSSProperties\n      }\n      className={cn(\n        \"w-full select-none\",\n        \"[--spell-line:#c7c7c7] [--spell-badge:#e8e8e8]\",\n        \"dark:[--spell-line:#4f4f4f] dark:[--spell-badge:#2d2d2d]\",\n        className,\n      )}\n    >\n      <div\n        ref={root}\n        onMouseMove={onMove}\n        className=\"relative w-full aspect-[640/220] touch-none\"\n      >\n      <svg\n        width=\"100%\"\n        height=\"100%\"\n        viewBox={`0 0 ${VIEWBOX_W} ${VIEWBOX_H}`}\n        fill=\"none\"\n        preserveAspectRatio=\"xMidYMid meet\"\n        style={{ overflow: \"visible\" }}\n      >\n        <defs>\n          <linearGradient\n            id={grayFillId}\n            x1={VIEWBOX_W / 2}\n            y1={PAD_Y_TOP}\n            x2={VIEWBOX_W / 2}\n            y2={VIEWBOX_H}\n            gradientUnits=\"userSpaceOnUse\"\n          >\n            <stop stopColor=\"var(--spell-badge)\" />\n            <stop offset=\"1\" stopColor=\"var(--spell-badge)\" stopOpacity=\"0\" />\n          </linearGradient>\n          <clipPath id={clipId} clipPathUnits=\"userSpaceOnUse\">\n            <rect\n              x={0}\n              y={0}\n              width={VIEWBOX_W}\n              height={VIEWBOX_H}\n              style={{\n                transform: `scaleX(${activeXPct})`,\n                transformOrigin: \"left center\",\n                transition: `transform ${transition}`,\n              }}\n            />\n          </clipPath>\n        </defs>\n\n        {showFill && <path d={fillPath} fill={`url(#${grayFillId})`} />}\n\n        {reveal ? (\n          <>\n            <path\n              d={strokePath}\n              stroke=\"var(--spell-line)\"\n              strokeWidth=\"2.2\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n            <g clipPath={`url(#${clipId})`}>\n              <path\n                d={strokePath}\n                stroke=\"var(--spell-color)\"\n                strokeWidth=\"2.2\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n              />\n            </g>\n          </>\n        ) : (\n          <path\n            d={strokePath}\n            stroke=\"var(--spell-color)\"\n            strokeWidth=\"2.2\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        )}\n      </svg>\n\n      {(() => {\n        const cursorPx = activeXPct * containerWidth;\n        const containerHeight = (containerWidth * VIEWBOX_H) / VIEWBOX_W;\n        const cursorYpx = activeYPct * containerHeight;\n        const tooltipOnLeft = activeXPct > 0.5;\n        return (\n          <>\n            <div\n              className=\"absolute pointer-events-none rounded-full bg-[var(--spell-line)] left-0\"\n              style={{\n                width: LINE_WIDTH,\n                top: `${(PAD_Y_TOP / VIEWBOX_H) * 100}%`,\n                height: `${((VIEWBOX_H - PAD_Y_TOP - PAD_Y_BOTTOM / 2) / VIEWBOX_H) * 100}%`,\n                transform: `translate3d(${cursorPx - LINE_WIDTH / 2}px, 0, 0)`,\n                transition: `transform ${transition}`,\n                willChange: \"transform\",\n              }}\n            />\n\n            {showDot && (\n              <div\n                className=\"absolute pointer-events-none w-3 h-3 rounded-full bg-[var(--spell-color)] z-10 left-0 top-0\"\n                style={{\n                  transform: `translate3d(${cursorPx - 6}px, ${cursorYpx - 6}px, 0)`,\n                  boxShadow: DOT_SHADOW,\n                  transition: `transform ${transition}`,\n                  willChange: \"transform\",\n                }}\n              />\n            )}\n\n            <div\n              className=\"absolute pointer-events-none z-20 grid min-w-32 items-start gap-1.5 rounded-lg bg-background px-2.5 py-1.5 text-xs left-0 top-0\"\n              style={{\n                transform: tooltipOnLeft\n                  ? `translate3d(calc(${cursorPx}px - 100% - 12px), calc(${cursorYpx}px - 50%), 0)`\n                  : `translate3d(${cursorPx + 12}px, calc(${cursorYpx}px - 50%), 0)`,\n                transition: `transform ${transition}`,\n                willChange: \"transform\",\n                boxShadow: TOOLTIP_SHADOW,\n              }}\n            >\n        {labels?.[active.index] && (\n          <div className=\"font-medium text-foreground\">\n            {labels[active.index]}\n          </div>\n        )}\n        <div className=\"flex w-full items-center gap-2\">\n          <div\n            className=\"h-2.5 w-2.5 shrink-0 rounded-[2px]\"\n            style={{ background: color }}\n          />\n          <div className=\"flex flex-1 items-center justify-between gap-3 leading-none\">\n            {name && (\n              <span className=\"text-muted-foreground whitespace-nowrap\">\n                {name}\n              </span>\n            )}\n            <span className=\"font-mono font-medium text-foreground tabular-nums ml-auto\">\n              {formatValue(active.value, active.index)}\n            </span>\n          </div>\n        </div>\n      </div>\n          </>\n        );\n      })()}\n      </div>\n\n      {axisVisible && (\n        <div className=\"relative h-5 mt-2\">\n          {tickIndices.map((i) => {\n            const pt = points[i];\n            const xPct = pt.x / VIEWBOX_W;\n            return (\n              <div\n                key={i}\n                className=\"absolute top-0 -translate-x-1/2 text-[11px] leading-none text-muted-foreground tabular-nums whitespace-nowrap\"\n                style={{ left: `${xPct * 100}%` }}\n              >\n                {labels![i]}\n              </div>\n            );\n          })}\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}