{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-tooltip",
  "type": "registry:ui",
  "title": "Interactive Chart Tooltip",
  "description": "A responsive SVG line chart with a tracking crosshair, spring-led tooltip and keyboard inspection.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/chart-tooltip.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nexport interface ChartDatum {\n  label: string;\n  value: number;\n}\n\nexport interface InteractiveChartProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  data: ChartDatum[];\n  /** Line, dot and area colour. */\n  accent?: string;\n  curve?: \"linear\" | \"smooth\";\n  showArea?: boolean;\n  showGrid?: boolean;\n  /** Draw a marker on every data point. */\n  showDots?: boolean;\n  /** Chart height in px (width follows the container). */\n  height?: number;\n  /** Draw the line in on mount. */\n  animate?: boolean;\n  /** Show a button that reveals the data table visually (it is always available to screen readers). */\n  showTableToggle?: boolean;\n  /** Accessible name and table caption. */\n  label?: string;\n  /** Formats values in the tooltip, table and announcements. */\n  valueFormatter?: (value: number) => string;\n  /** Formats y-axis ticks (defaults to compact notation). */\n  tickFormatter?: (value: number) => string;\n}\n\nconst MARGIN = { top: 16, right: 16, bottom: 28, left: 44 };\nconst TOOLTIP_W = 152;\nconst TOOLTIP_H = 66;\n\nconst defaultValueFormat = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 2 });\nconst defaultTickFormat = new Intl.NumberFormat(\"en-US\", { notation: \"compact\", maximumFractionDigits: 1 });\nconst percentFormat = new Intl.NumberFormat(\"en-US\", { style: \"percent\", maximumFractionDigits: 1, signDisplay: \"exceptZero\" });\nconst percentAbsFormat = new Intl.NumberFormat(\"en-US\", { style: \"percent\", maximumFractionDigits: 1 });\n\nfunction niceStep(raw: number) {\n  if (!Number.isFinite(raw) || raw <= 0) return 1;\n  const pow = Math.pow(10, Math.floor(Math.log10(raw)));\n  const err = raw / pow;\n  return (err >= 7.5 ? 10 : err >= 3.5 ? 5 : err >= 1.5 ? 2 : 1) * pow;\n}\n\ntype Point = [number, number];\n\nfunction linearPath(points: Point[]) {\n  return points.map(([x, y], i) => `${i ? \"L\" : \"M\"}${x.toFixed(2)},${y.toFixed(2)}`).join(\"\");\n}\n\n/** Monotone cubic interpolation (Fritsch–Carlson): smooth, but never overshoots the data. */\nfunction monotonePath(points: Point[]) {\n  const n = points.length;\n  if (n < 3) return linearPath(points);\n  const secants: number[] = [];\n  for (let i = 0; i < n - 1; i++) {\n    const dx = points[i + 1][0] - points[i][0];\n    secants.push(dx === 0 ? 0 : (points[i + 1][1] - points[i][1]) / dx);\n  }\n  const tangents: number[] = [secants[0]];\n  for (let i = 1; i < n - 1; i++) {\n    tangents.push(secants[i - 1] * secants[i] <= 0 ? 0 : (secants[i - 1] + secants[i]) / 2);\n  }\n  tangents.push(secants[n - 2]);\n  for (let i = 0; i < n - 1; i++) {\n    if (secants[i] === 0) {\n      tangents[i] = 0;\n      tangents[i + 1] = 0;\n      continue;\n    }\n    const a = tangents[i] / secants[i];\n    const b = tangents[i + 1] / secants[i];\n    const s = a * a + b * b;\n    if (s > 9) {\n      const t = 3 / Math.sqrt(s);\n      tangents[i] = t * a * secants[i];\n      tangents[i + 1] = t * b * secants[i];\n    }\n  }\n  let d = `M${points[0][0].toFixed(2)},${points[0][1].toFixed(2)}`;\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 = (x1 - x0) / 3;\n    d += `C${(x0 + h).toFixed(2)},${(y0 + tangents[i] * h).toFixed(2)},${(x1 - h).toFixed(2)},${(y1 - tangents[i + 1] * h).toFixed(2)},${x1.toFixed(2)},${y1.toFixed(2)}`;\n  }\n  return d;\n}\n\n/**\n * A responsive SVG line/area chart with a tracking crosshair, spring-animated selection dot and a\n * custom tooltip. Fully operable from the keyboard and backed by a data table for assistive technology.\n */\nexport function InteractiveChart({\n  data,\n  accent = \"#FF7448\",\n  curve = \"smooth\",\n  showArea = true,\n  showGrid = true,\n  showDots = false,\n  height = 260,\n  animate = true,\n  showTableToggle = true,\n  label = \"Chart\",\n  valueFormatter,\n  tickFormatter,\n  className = \"\",\n  style,\n  ...props\n}: InteractiveChartProps) {\n  const reduceMotion = useReducedMotion();\n  const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const plotRef = React.useRef<HTMLDivElement>(null);\n  const [width, setWidth] = React.useState(0);\n  const [selected, setSelected] = React.useState<number | null>(null);\n  const [tableOpen, setTableOpen] = React.useState(false);\n\n  React.useEffect(() => {\n    const node = plotRef.current;\n    if (!node) return;\n    const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width)));\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const formatValue = valueFormatter ?? ((v: number) => defaultValueFormat.format(v));\n  const formatTick = tickFormatter ?? ((v: number) => defaultTickFormat.format(v));\n\n  const n = data.length;\n  const values = data.map((d) => d.value);\n  const rawMin = n ? Math.min(0, ...values) : 0;\n  const rawMax = n ? Math.max(...values) : 1;\n  const step = niceStep((rawMax - rawMin || 1) / 4);\n  const yMin = Math.floor(rawMin / step) * step;\n  const yMax = Math.max(yMin + step, Math.ceil(rawMax / step) * step);\n  const ticks: number[] = [];\n  for (let t = yMin; t <= yMax + step / 2; t += step) ticks.push(Number(t.toFixed(10)));\n\n  const innerW = Math.max(0, width - MARGIN.left - MARGIN.right);\n  const innerH = Math.max(0, height - MARGIN.top - MARGIN.bottom);\n  const xAt = (i: number) => MARGIN.left + (n <= 1 ? innerW / 2 : (i / (n - 1)) * innerW);\n  const yAt = (v: number) => MARGIN.top + innerH - ((v - yMin) / (yMax - yMin)) * innerH;\n  const points: Point[] = data.map((d, i) => [xAt(i), yAt(d.value)]);\n  const linePath = n ? (curve === \"smooth\" ? monotonePath(points) : linearPath(points)) : \"\";\n  const baseY = yAt(Math.max(yMin, Math.min(0, yMax)));\n  const areaPath = n\n    ? `${linePath}L${points[n - 1][0].toFixed(2)},${baseY.toFixed(2)}L${points[0][0].toFixed(2)},${baseY.toFixed(2)}Z`\n    : \"\";\n\n  // Thin x labels so they stay at least ~64px apart.\n  const labelEvery = Math.max(1, Math.ceil(n / Math.max(1, Math.floor(innerW / 64))));\n\n  const active = selected !== null && selected < n ? selected : null;\n  const deltaOf = (i: number) => {\n    if (i <= 0) return null;\n    const prev = data[i - 1].value;\n    return prev === 0 ? null : (data[i].value - prev) / Math.abs(prev);\n  };\n  const announce = (i: number) => {\n    const delta = deltaOf(i);\n    const change =\n      delta === null ? \"\" : `, ${delta > 0 ? \"up\" : delta < 0 ? \"down\" : \"unchanged\"} ${percentAbsFormat.format(Math.abs(delta))} from ${data[i - 1].label}`;\n    return `${data[i].label}: ${formatValue(data[i].value)}${change}`;\n  };\n\n  const indexFromPointer = (event: React.PointerEvent<SVGSVGElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect();\n    const x = event.clientX - rect.left - MARGIN.left;\n    if (n <= 1) return 0;\n    return Math.min(n - 1, Math.max(0, Math.round((x / innerW) * (n - 1))));\n  };\n\n  const handlePointerMove = (event: React.PointerEvent<SVGSVGElement>) => {\n    if (!n || innerW <= 0) return;\n    const i = indexFromPointer(event);\n    // Only re-render when the nearest point actually changes.\n    if (i !== selected) setSelected(i);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (!n) return;\n    const current = active ?? n - 1;\n    const next: Record<string, number> = {\n      ArrowLeft: Math.max(0, current - 1),\n      ArrowRight: Math.min(n - 1, current + 1),\n      Home: 0,\n      End: n - 1,\n    };\n    if (event.key in next) {\n      event.preventDefault();\n      setSelected(next[event.key]);\n    } else if (event.key === \"Escape\") {\n      setSelected(null);\n    }\n  };\n\n  const transition = reduceMotion ? { duration: 0 } : ({ type: \"spring\", stiffness: 420, damping: 34, mass: 0.6 } as const);\n  const px = active !== null ? points[active][0] : 0;\n  const py = active !== null ? points[active][1] : 0;\n  const tipX = Math.min(Math.max(px - TOOLTIP_W / 2, 0), Math.max(0, width - TOOLTIP_W));\n  const tipY = py - TOOLTIP_H - 14 < 0 ? py + 14 : py - TOOLTIP_H - 14;\n  const activeDelta = active !== null ? deltaOf(active) : null;\n  const descriptionId = `${uid}-desc`;\n  const tableId = `${uid}-table`;\n  const minIndex = values.indexOf(Math.min(...values));\n  const maxIndex = values.indexOf(Math.max(...values));\n\n  return (\n    <div\n      className={`w-full text-zinc-900 dark:text-zinc-100 ${className}`}\n      style={{ [\"--ch-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <div\n        ref={plotRef}\n        tabIndex={n ? 0 : -1}\n        role=\"group\"\n        aria-roledescription=\"interactive chart\"\n        aria-label={label}\n        aria-describedby={descriptionId}\n        onKeyDown={handleKeyDown}\n        onFocus={(e) => {\n          if (e.target === e.currentTarget && n) setSelected((s) => s ?? n - 1);\n        }}\n        onBlur={() => setSelected(null)}\n        className=\"relative w-full rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-[var(--ch-accent)] focus-visible:ring-offset-4 focus-visible:ring-offset-transparent\"\n        style={{ height }}\n      >\n        <p id={descriptionId} className=\"sr-only\">\n          {n\n            ? `${n} points from ${data[0].label} to ${data[n - 1].label}. Lowest ${data[minIndex].label} at ${formatValue(data[minIndex].value)}, highest ${data[maxIndex].label} at ${formatValue(data[maxIndex].value)}. Use the left and right arrow keys to inspect values.`\n            : \"No data.\"}\n        </p>\n        <p className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n          {active !== null ? announce(active) : \"\"}\n        </p>\n\n        {width > 0 ? (\n          <svg\n            width={width}\n            height={height}\n            viewBox={`0 0 ${width} ${height}`}\n            className=\"block touch-pan-y select-none overflow-visible\"\n            aria-hidden=\"true\"\n            onPointerMove={handlePointerMove}\n            onPointerDown={handlePointerMove}\n            onPointerLeave={(e) => {\n              if (e.pointerType === \"mouse\") setSelected(null);\n            }}\n          >\n            <defs>\n              <linearGradient id={`${uid}-fill`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n                <stop offset=\"0%\" stopColor={accent} stopOpacity={0.28} />\n                <stop offset=\"100%\" stopColor={accent} stopOpacity={0} />\n              </linearGradient>\n            </defs>\n\n            {/* Grid and y-axis */}\n            {ticks.map((t) => (\n              <g key={t}>\n                {showGrid ? (\n                  <line\n                    x1={MARGIN.left}\n                    x2={width - MARGIN.right}\n                    y1={yAt(t)}\n                    y2={yAt(t)}\n                    className=\"stroke-zinc-900/10 dark:stroke-white/10\"\n                    strokeDasharray={t === yMin ? undefined : \"2 4\"}\n                  />\n                ) : null}\n                <text\n                  x={MARGIN.left - 10}\n                  y={yAt(t)}\n                  dy=\"0.32em\"\n                  textAnchor=\"end\"\n                  className=\"fill-zinc-500 font-mono text-[10px] tabular-nums dark:fill-zinc-400\"\n                >\n                  {formatTick(t)}\n                </text>\n              </g>\n            ))}\n\n            {/* X labels */}\n            {data.map((d, i) =>\n              // Always label the last point; drop a regular label that would collide with it.\n              (i % labelEvery === 0 && (i === 0 || n - 1 - i >= labelEvery * 0.6)) || i === n - 1 ? (\n                <text\n                  key={`${d.label}-${i}`}\n                  x={xAt(i)}\n                  y={height - 8}\n                  textAnchor={i === 0 ? \"start\" : i === n - 1 ? \"end\" : \"middle\"}\n                  className={`font-mono text-[10px] uppercase tracking-[0.08em] transition-colors ${\n                    i === active ? \"fill-zinc-900 dark:fill-zinc-100\" : \"fill-zinc-500 dark:fill-zinc-400\"\n                  }`}\n                >\n                  {d.label}\n                </text>\n              ) : null,\n            )}\n\n            {showArea && n > 1 ? (\n              <motion.path\n                d={areaPath}\n                fill={`url(#${uid}-fill)`}\n                initial={animate && !reduceMotion ? { opacity: 0 } : false}\n                animate={{ opacity: 1 }}\n                transition={{ duration: 0.8, delay: 0.3 }}\n              />\n            ) : null}\n\n            {n > 1 ? (\n              <motion.path\n                d={linePath}\n                fill=\"none\"\n                stroke={accent}\n                strokeWidth={2}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                initial={animate && !reduceMotion ? { pathLength: 0 } : false}\n                animate={{ pathLength: 1 }}\n                transition={{ duration: 1.1, ease: [0.22, 1, 0.36, 1] }}\n              />\n            ) : null}\n\n            {showDots\n              ? points.map(([x, y], i) => (\n                  <circle\n                    key={i}\n                    cx={x}\n                    cy={y}\n                    r={2.5}\n                    className=\"fill-white dark:fill-zinc-900\"\n                    stroke={accent}\n                    strokeWidth={1.5}\n                  />\n                ))\n              : null}\n\n            <AnimatePresence>\n              {active !== null ? (\n                <motion.g\n                  key=\"selection\"\n                  initial={{ opacity: 0, x: px }}\n                  animate={{ opacity: 1, x: px }}\n                  exit={{ opacity: 0 }}\n                  transition={transition}\n                >\n                  <line\n                    x1={0}\n                    x2={0}\n                    y1={MARGIN.top}\n                    y2={MARGIN.top + innerH}\n                    className=\"stroke-zinc-900/30 dark:stroke-white/30\"\n                    strokeDasharray=\"3 3\"\n                  />\n                  <motion.g initial={{ y: py }} animate={{ y: py }} transition={transition}>\n                    <circle r={9} fill={accent} opacity={0.18} />\n                    <circle r={4.5} fill={accent} className=\"stroke-white dark:stroke-zinc-950\" strokeWidth={2} />\n                  </motion.g>\n                </motion.g>\n              ) : null}\n            </AnimatePresence>\n          </svg>\n        ) : null}\n\n        <AnimatePresence>\n          {active !== null && width > 0 ? (\n            <motion.div\n              key=\"tooltip\"\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute top-0 left-0 rounded-lg border border-zinc-900/10 bg-white/95 px-3 py-2 shadow-[0_12px_32px_-16px_rgba(0,0,0,0.4)] backdrop-blur-sm dark:border-white/10 dark:bg-zinc-900/95\"\n              style={{ width: TOOLTIP_W, height: TOOLTIP_H }}\n              initial={{ opacity: 0, x: tipX, y: tipY + 4 }}\n              animate={{ opacity: 1, x: tipX, y: tipY }}\n              exit={{ opacity: 0, transition: { duration: 0.12 } }}\n              transition={transition}\n            >\n              <div className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n                {data[active].label}\n              </div>\n              <div className=\"mt-0.5 text-lg font-semibold leading-tight tracking-tight tabular-nums\">\n                {formatValue(data[active].value)}\n              </div>\n              {activeDelta !== null ? (\n                <div\n                  className={`mt-0.5 flex items-center gap-1 font-mono text-[10px] tabular-nums ${\n                    activeDelta > 0\n                      ? \"text-emerald-600 dark:text-emerald-400\"\n                      : activeDelta < 0\n                        ? \"text-rose-600 dark:text-rose-400\"\n                        : \"text-zinc-500\"\n                  }`}\n                >\n                  <svg viewBox=\"0 0 8 8\" className={`size-2 ${activeDelta < 0 ? \"rotate-180\" : \"\"}`} aria-hidden=\"true\">\n                    {activeDelta === 0 ? <rect x=\"1\" y=\"3.5\" width=\"6\" height=\"1\" fill=\"currentColor\" /> : <path d=\"M4 1 7 6H1z\" fill=\"currentColor\" />}\n                  </svg>\n                  {percentFormat.format(activeDelta)} vs {data[active - 1].label}\n                </div>\n              ) : (\n                <div className=\"mt-0.5 font-mono text-[10px] text-zinc-500 dark:text-zinc-400\">First point</div>\n              )}\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n      </div>\n\n      {showTableToggle ? (\n        <div className=\"mt-3 flex justify-end\">\n          <button\n            type=\"button\"\n            aria-expanded={tableOpen}\n            aria-controls={tableId}\n            onClick={() => setTableOpen((o) => !o)}\n            className=\"inline-flex h-7 items-center gap-1.5 rounded-full border border-zinc-900/15 px-3 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-600 transition-colors hover:border-zinc-900/40 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ch-accent)] dark:border-white/15 dark:text-zinc-300 dark:hover:border-white/40 dark:hover:text-white\"\n          >\n            <svg viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.2\" aria-hidden=\"true\">\n              <rect x=\"1.5\" y=\"1.5\" width=\"9\" height=\"9\" rx=\"1\" />\n              <path d=\"M1.5 4.5h9M1.5 7.5h9M5 4.5v6\" />\n            </svg>\n            {tableOpen ? \"Hide table\" : \"Show table\"}\n          </button>\n        </div>\n      ) : null}\n\n      <div id={tableId} className={tableOpen && showTableToggle ? \"mt-3 max-h-56 overflow-auto rounded-lg border border-zinc-900/10 dark:border-white/10\" : \"sr-only\"}>\n        <table className=\"w-full border-collapse text-left text-sm\">\n          <caption className=\"sr-only\">{label}</caption>\n          <thead>\n            <tr className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n              <th scope=\"col\" className=\"px-3 py-2 font-normal\">\n                Label\n              </th>\n              <th scope=\"col\" className=\"px-3 py-2 text-right font-normal\">\n                Value\n              </th>\n            </tr>\n          </thead>\n          <tbody>\n            {data.map((d, i) => (\n              <tr key={`${d.label}-${i}`} className=\"border-t border-zinc-900/5 dark:border-white/5\">\n                <th scope=\"row\" className=\"px-3 py-1.5 font-normal\">\n                  {d.label}\n                </th>\n                <td className=\"px-3 py-1.5 text-right tabular-nums\">{formatValue(d.value)}</td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/chart-tooltip"
}
