{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-kit",
  "title": "Chart Kit",
  "description": "Shared theme, scales, formatters, textures, card, legend and tooltip used by tweenly charts.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/lib/chart-kit.tsx",
      "content": "\"use client\"\n\nimport { useRef } from \"react\"\nimport { AnimatePresence, motion, useInView, useReducedMotion } from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\n\n// ------------------------------------------------------------------ types\n\nexport type ChartDatum = Record<string, string | number>\nexport type ChartSurface = \"dark\" | \"light\"\nexport type ChartTexture = \"solid\" | \"hatch\" | \"dots\" | \"muted\"\nexport type ChartCurve = \"smooth\" | \"linear\" | \"step\"\nexport type ValueFormat = \"number\" | \"compact\" | \"percent\" | \"currency\" | ((value: number) => string)\n\nexport interface ChartSeries {\n  /** Key in each data row that holds this series' values. */\n  key: string\n  /** Name shown in the legend and tooltip. Defaults to the key. */\n  label?: string\n  /** Series color. Defaults to the accent / palette. */\n  color?: string\n  /** Fill style. Defaults to solid, hatch, muted, dots in order. */\n  texture?: ChartTexture\n}\n\n/** Props shared by every chart card. */\nexport interface ChartBaseProps {\n  /** Card heading. */\n  title?: string\n  /** Small line under the heading. */\n  description?: string\n  /** \"hero\" is a huge bold heading; \"default\" is a compact dashboard header. Default: \"default\" */\n  titleSize?: \"hero\" | \"default\"\n  /** Card surface. Default: \"dark\" */\n  surface?: ChartSurface\n  /** Main accent color. Default: \"#FF4D12\" */\n  accent?: string\n  /** Series colors in order; overrides the accent-derived palette. */\n  palette?: string[]\n  /** Content of the round badge in the top-right corner. Pass null to hide it. */\n  icon?: React.ReactNode\n  /** Card corner radius in px. Default: 28 */\n  radius?: number\n  /** Render only the chart, without the card. Default: false */\n  bare?: boolean\n  /** Play the entrance animation. Default: true */\n  animate?: boolean\n  /** Entrance duration in seconds. Default: 1.2 */\n  duration?: number\n  /** Delay before the entrance starts, in seconds. Default: 0 */\n  delay?: number\n  /** Animate only the first time the chart enters view. Default: true */\n  once?: boolean\n  /** How values are formatted in labels and tooltips. Default: \"number\" */\n  valueFormat?: ValueFormat\n  /** Currency code used when valueFormat is \"currency\". Default: \"USD\" */\n  currency?: string\n  /** Fraction digits for formatted values. Default: auto */\n  decimals?: number\n  className?: string\n}\n\nexport const CHART_ACCENT = \"#FF4D12\"\n\n// ------------------------------------------------------------------ theme\n\nexport interface ChartTheme {\n  surface: ChartSurface\n  bg: string\n  fg: string\n  muted: string\n  subtle: string\n  grid: string\n  track: string\n  border: string\n  accent: string\n  tooltipBg: string\n}\n\nexport function chartTheme(surface: ChartSurface = \"dark\", accent = CHART_ACCENT): ChartTheme {\n  return surface === \"dark\"\n    ? {\n        surface,\n        bg: \"#1a1a1a\",\n        fg: \"#fafafa\",\n        muted: \"#8a8a8a\",\n        subtle: \"#3a3a3a\",\n        grid: \"rgb(255 255 255 / 0.07)\",\n        track: \"#262626\",\n        border: \"rgb(255 255 255 / 0.09)\",\n        accent,\n        tooltipBg: \"#0f0f0f\",\n      }\n    : {\n        surface,\n        bg: \"#ffffff\",\n        fg: \"#0f0f0f\",\n        muted: \"#737373\",\n        subtle: \"#d4d4d4\",\n        grid: \"rgb(0 0 0 / 0.06)\",\n        track: \"#f0f0f0\",\n        border: \"rgb(0 0 0 / 0.08)\",\n        accent,\n        tooltipBg: \"#ffffff\",\n      }\n}\n\nconst DEFAULT_TEXTURES: ChartTexture[] = [\"solid\", \"hatch\", \"muted\", \"dots\"]\n\nexport type ResolvedSeries = Required<Pick<ChartSeries, \"key\" | \"label\" | \"color\" | \"texture\">>\n\n/** Fill in labels, colors and textures for each series. */\nexport function resolveSeries(series: ChartSeries[], theme: ChartTheme, palette?: string[]): ResolvedSeries[] {\n  const derived = [\n    theme.accent,\n    `color-mix(in oklab, ${theme.accent} 55%, ${theme.fg})`,\n    theme.muted,\n    theme.fg,\n  ]\n  return series.map((s, i) => ({\n    key: s.key,\n    label: s.label ?? s.key,\n    texture: s.texture ?? DEFAULT_TEXTURES[i % DEFAULT_TEXTURES.length],\n    color: s.color ?? palette?.[i % palette.length] ?? derived[i % derived.length],\n  }))\n}\n\n// ------------------------------------------------------------------ numbers\n\nexport function createFormatter(format: ValueFormat = \"number\", options: { currency?: string; decimals?: number } = {}) {\n  if (typeof format === \"function\") return format\n  const { currency = \"USD\", decimals } = options\n  const digits = decimals === undefined ? {} : { minimumFractionDigits: decimals, maximumFractionDigits: decimals }\n  const intl =\n    format === \"compact\"\n      ? new Intl.NumberFormat(\"en-US\", { notation: \"compact\", maximumFractionDigits: decimals ?? 1 })\n      : format === \"currency\"\n        ? new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency, maximumFractionDigits: decimals ?? 0, ...digits })\n        : format === \"percent\"\n          ? new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: decimals ?? 1, ...digits })\n          : new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: decimals ?? 2, ...digits })\n  return (value: number) => (format === \"percent\" ? `${intl.format(value)}%` : intl.format(value))\n}\n\n/** Human-friendly axis ticks covering [min, max]. */\nexport function niceTicks(min: number, max: number, count = 4) {\n  if (min === max) max = min + 1\n  const raw = (max - min) / Math.max(count, 1)\n  const mag = 10 ** Math.floor(Math.log10(raw))\n  const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw\n  const lo = Math.floor(min / step) * step\n  const hi = Math.ceil(max / step) * step\n  const ticks: number[] = []\n  for (let v = lo; v <= hi + step / 2; v += step) ticks.push(Number(v.toFixed(10)))\n  return ticks\n}\n\nexport const toNumber = (v: unknown) => (typeof v === \"number\" ? v : Number(v) || 0)\n\n// ------------------------------------------------------------------ geometry\n\nexport type Point = [number, number]\n\n/** SVG path through points. \"smooth\" uses monotone cubic interpolation, so it never overshoots the data. */\nexport function linePath(points: Point[], curve: ChartCurve = \"smooth\") {\n  if (!points.length) return \"\"\n  if (points.length === 1) return `M${points[0][0]},${points[0][1]}`\n  if (curve === \"linear\") return points.map((p, i) => `${i ? \"L\" : \"M\"}${p[0]},${p[1]}`).join(\"\")\n  if (curve === \"step\") {\n    return points\n      .map((p, i) => (i ? `H${(points[i - 1][0] + p[0]) / 2}V${p[1]}H${p[0]}` : `M${p[0]},${p[1]}`))\n      .join(\"\")\n  }\n\n  const n = points.length\n  const dx = points.slice(1).map((p, i) => p[0] - points[i][0])\n  const slope = points.slice(1).map((p, i) => (p[1] - points[i][1]) / (dx[i] || 1))\n  const tangent = points.map((_, i) => {\n    if (i === 0) return slope[0]\n    if (i === n - 1) return slope[n - 2]\n    return slope[i - 1] * slope[i] <= 0 ? 0 : (slope[i - 1] + slope[i]) / 2\n  })\n  // Fritsch–Carlson: clamp tangents to keep the curve monotone between points\n  for (let i = 0; i < n - 1; i++) {\n    if (slope[i] === 0) {\n      tangent[i] = tangent[i + 1] = 0\n      continue\n    }\n    const a = tangent[i] / slope[i]\n    const b = tangent[i + 1] / slope[i]\n    const h = Math.hypot(a, b)\n    if (h > 3) {\n      tangent[i] = (3 / h) * a * slope[i]\n      tangent[i + 1] = (3 / h) * b * slope[i]\n    }\n  }\n  let d = `M${points[0][0]},${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${x0 + h},${y0 + tangent[i] * h} ${x1 - h},${y1 - tangent[i + 1] * h} ${x1},${y1}`\n  }\n  return d\n}\n\n/** Point on a circle; angle in degrees, 0 = 12 o'clock, clockwise. */\nexport function polar(cx: number, cy: number, r: number, angle: number): Point {\n  const a = ((angle - 90) * Math.PI) / 180\n  return [cx + r * Math.cos(a), cy + r * Math.sin(a)]\n}\n\n/** Ring segment from angle a0 to a1 (degrees) between radii r0 and r1. */\nexport function arcPath(cx: number, cy: number, r0: number, r1: number, a0: number, a1: number) {\n  const sweep = Math.min(a1 - a0, 359.999)\n  const large = sweep > 180 ? 1 : 0\n  const [x0, y0] = polar(cx, cy, r1, a0)\n  const [x1, y1] = polar(cx, cy, r1, a0 + sweep)\n  const [x2, y2] = polar(cx, cy, r0, a0 + sweep)\n  const [x3, y3] = polar(cx, cy, r0, a0)\n  return `M${x0},${y0}A${r1},${r1} 0 ${large} 1 ${x1},${y1}L${x2},${y2}A${r0},${r0} 0 ${large} 0 ${x3},${y3}Z`\n}\n\n// ------------------------------------------------------------------ textures\n\n/** SVG <pattern> for a texture. Render inside <defs>. */\nexport function ChartPattern({ id, color, texture, theme }: {\n  id: string\n  color: string\n  texture: ChartTexture\n  theme: ChartTheme\n}) {\n  if (texture === \"solid\") return null\n  if (texture === \"dots\") {\n    return (\n      <pattern id={id} width=\"4\" height=\"4\" patternUnits=\"userSpaceOnUse\">\n        <circle cx=\"2\" cy=\"2\" r=\"0.9\" fill={color} />\n      </pattern>\n    )\n  }\n  const stroke = texture === \"muted\" ? theme.muted : color\n  return (\n    <pattern id={id} width=\"6\" height=\"6\" patternUnits=\"userSpaceOnUse\" patternTransform=\"rotate(45)\">\n      <rect width=\"6\" height=\"6\" fill={stroke} opacity={texture === \"muted\" ? 0.08 : 0.2} />\n      <line x1=\"0\" y1=\"0\" x2=\"0\" y2=\"6\" stroke={stroke} strokeWidth=\"1.6\" opacity={texture === \"muted\" ? 0.55 : 0.9} />\n    </pattern>\n  )\n}\n\n/** Fill value for a series: its color, or its pattern. */\nexport const textureFill = (id: string, texture: ChartTexture, color: string) =>\n  texture === \"solid\" ? color : `url(#${id})`\n\n// ------------------------------------------------------------------ hooks\n\n/** True once the chart should play its entrance (in view, or immediately if animation is off). */\nexport function useChartReveal(once = true, animate = true) {\n  const ref = useRef<HTMLDivElement>(null)\n  const inView = useInView(ref, { once, amount: 0.35 })\n  const reduced = useReducedMotion()\n  const still = !animate || !!reduced\n  return { ref, shown: still || inView, still }\n}\n\n// ------------------------------------------------------------------ card\n\nexport function ChartCard({\n  theme,\n  title,\n  description,\n  titleSize = \"default\",\n  value,\n  delta,\n  icon,\n  radius = 28,\n  bare,\n  className,\n  style,\n  children,\n  footer,\n}: {\n  theme: ChartTheme\n  title?: string\n  description?: string\n  titleSize?: \"hero\" | \"default\"\n  /** Headline figure, e.g. a total. */\n  value?: React.ReactNode\n  /** Change vs. the previous period, in percent. */\n  delta?: number\n  icon?: React.ReactNode\n  radius?: number\n  bare?: boolean\n  className?: string\n  style?: React.CSSProperties\n  children: React.ReactNode\n  footer?: React.ReactNode\n}) {\n  if (bare) {\n    return (\n      <div className={cn(\"relative\", className)} style={{ color: theme.fg, ...style }}>\n        {children}\n      </div>\n    )\n  }\n\n  const hasHeader = title || description || value !== undefined || icon\n  return (\n    <div\n      className={cn(\"relative flex w-full flex-col overflow-hidden border p-6\", className)}\n      style={{\n        background: theme.bg,\n        color: theme.fg,\n        borderColor: theme.border,\n        borderRadius: radius,\n        boxShadow:\n          theme.surface === \"dark\"\n            ? \"inset 0 1px 0 rgb(255 255 255 / 0.06), 0 24px 48px -28px rgb(0 0 0 / 0.6)\"\n            : \"inset 0 1px 0 #fff, 0 24px 48px -28px rgb(0 0 0 / 0.25)\",\n        ...style,\n      }}\n    >\n      {hasHeader && (\n        <div className=\"mb-5 flex items-start justify-between gap-4\">\n          <div className=\"min-w-0\">\n            {title && (\n              <p\n                className={cn(\n                  titleSize === \"hero\"\n                    ? \"max-w-[9ch] text-[2.5rem] font-bold leading-[0.9] tracking-tight\"\n                    : \"text-[13px] font-medium\"\n                )}\n                style={titleSize === \"hero\" ? undefined : { color: theme.muted }}\n              >\n                {title}\n              </p>\n            )}\n            {value !== undefined && (\n              <div className=\"mt-1.5 flex items-baseline gap-2.5\">\n                <span className=\"text-[1.9rem] font-semibold leading-none tracking-tight tabular-nums\">{value}</span>\n                {delta !== undefined && <DeltaPill delta={delta} theme={theme} />}\n              </div>\n            )}\n            {description && (\n              <p className=\"mt-1.5 text-[12.5px]\" style={{ color: theme.muted }}>\n                {description}\n              </p>\n            )}\n          </div>\n          {icon !== null && icon !== undefined && (\n            <span\n              className=\"grid size-9 shrink-0 place-items-center rounded-full border\"\n              style={{ borderColor: theme.border, background: theme.surface === \"dark\" ? \"#111\" : \"#f6f6f6\" }}\n            >\n              {icon}\n            </span>\n          )}\n        </div>\n      )}\n      {children}\n      {footer && <div className=\"mt-5\">{footer}</div>}\n    </div>\n  )\n}\n\nexport function DeltaPill({ delta, theme }: { delta: number; theme: ChartTheme }) {\n  const up = delta >= 0\n  return (\n    <span\n      className=\"inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[11px] font-semibold tabular-nums\"\n      style={{\n        color: up ? theme.accent : theme.muted,\n        background: up ? `color-mix(in oklab, ${theme.accent} 14%, transparent)` : theme.track,\n      }}\n    >\n      {up ? \"▲\" : \"▼\"} {Math.abs(delta).toFixed(1)}%\n    </span>\n  )\n}\n\n// ------------------------------------------------------------------ legend\n\nexport function ChartLegend({ series, theme, hidden, onToggle, patternPrefix, align = \"start\" }: {\n  series: ResolvedSeries[]\n  theme: ChartTheme\n  hidden?: Set<string>\n  /** When provided, clicking an item toggles that series. */\n  onToggle?: (key: string) => void\n  /** Pattern id prefix so swatches can show textures. */\n  patternPrefix?: string\n  align?: \"start\" | \"center\" | \"end\"\n}) {\n  return (\n    <div\n      className={cn(\n        \"flex flex-wrap gap-x-4 gap-y-1.5 text-[12px]\",\n        align === \"center\" && \"justify-center\",\n        align === \"end\" && \"justify-end\"\n      )}\n    >\n      {series.map((s) => {\n        const off = hidden?.has(s.key)\n        const Tag = onToggle ? \"button\" : \"span\"\n        return (\n          <Tag\n            key={s.key}\n            type={onToggle ? \"button\" : undefined}\n            onClick={onToggle ? () => onToggle(s.key) : undefined}\n            aria-pressed={onToggle ? !off : undefined}\n            className={cn(\"flex items-center gap-1.5 transition-opacity\", off && \"opacity-35\", onToggle && \"hover:opacity-80\")}\n            style={{ color: theme.muted }}\n          >\n            <svg width=\"10\" height=\"10\" className=\"shrink-0 overflow-visible rounded-[3px]\">\n              <rect\n                width=\"10\"\n                height=\"10\"\n                rx=\"3\"\n                fill={patternPrefix ? textureFill(`${patternPrefix}-${s.key}`, s.texture, s.color) : s.color}\n              />\n              {s.texture !== \"solid\" && <rect width=\"10\" height=\"10\" rx=\"3\" fill=\"none\" stroke={s.texture === \"muted\" ? theme.muted : s.color} strokeOpacity=\"0.6\" />}\n            </svg>\n            {s.label}\n          </Tag>\n        )\n      })}\n    </div>\n  )\n}\n\n// ------------------------------------------------------------------ tooltip\n\nexport interface TooltipRow {\n  label: string\n  value: string\n  color: string\n  texture?: ChartTexture\n}\n\n/** Floating tooltip positioned inside a relative container. Flips to the left near the right edge. */\nexport function ChartTooltip({ open, x, y, width, title, rows, theme }: {\n  open: boolean\n  x: number\n  y: number\n  /** Container width, used to flip the tooltip. */\n  width: number\n  title?: string\n  rows: TooltipRow[]\n  theme: ChartTheme\n}) {\n  const flip = x > width * 0.6\n  return (\n    <AnimatePresence>\n      {open && (\n        <motion.div\n          initial={{ opacity: 0, scale: 0.94 }}\n          animate={{ opacity: 1, scale: 1, x: flip ? x - 12 : x + 12, y }}\n          exit={{ opacity: 0, scale: 0.94 }}\n          transition={{ type: \"spring\", stiffness: 500, damping: 38, opacity: { duration: 0.12 } }}\n          className={cn(\n            \"pointer-events-none absolute left-0 top-0 z-10 min-w-32 -translate-y-1/2 rounded-xl border px-3 py-2 text-[12px] shadow-xl\",\n            flip && \"-translate-x-full\"\n          )}\n          style={{ background: theme.tooltipBg, borderColor: theme.border, color: theme.fg }}\n        >\n          {title && (\n            <p className=\"mb-1.5 text-[11px]\" style={{ color: theme.muted }}>\n              {title}\n            </p>\n          )}\n          <div className=\"space-y-1\">\n            {rows.map((r) => (\n              <div key={r.label} className=\"flex items-center gap-2\">\n                <span className=\"size-2 rounded-[2px]\" style={{ background: r.texture === \"muted\" ? theme.muted : r.color }} />\n                <span style={{ color: theme.muted }}>{r.label}</span>\n                <span className=\"ml-auto pl-3 font-medium tabular-nums\">{r.value}</span>\n              </div>\n            ))}\n          </div>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  )\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:lib"
}