{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-kpi",
  "title": "Chart KPI",
  "description": "Stat card with a count-up headline, delta pill, halftone sparkline with scrubbing, and an optional goal bar.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://trytweenly.vercel.app/r/chart-kit.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/chart-kpi/chart-kpi.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\"\nimport { animate as animateValue, motion } from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\nimport {\n  CHART_ACCENT,\n  ChartCard,\n  ChartTooltip,\n  chartTheme,\n  createFormatter,\n  linePath,\n  toNumber,\n  useChartReveal,\n  type ChartBaseProps,\n  type ChartCurve,\n  type ChartDatum,\n  type ChartTheme,\n  type Point,\n  type TooltipRow,\n} from \"@/registry/new-york/lib/chart-kit\"\n\nexport interface ChartKpiProps extends ChartBaseProps {\n  /** Rows of data, oldest first. */\n  data: ChartDatum[]\n  /** Key holding each row's label, e.g. \"date\". */\n  index: string\n  /** Key holding the metric. */\n  valueKey: string\n  /** Key holding the previous period, drawn as a dashed line and used for the delta. */\n  compareKey?: string\n  /** How rows combine into the headline figure. Default: \"sum\" */\n  aggregate?: \"sum\" | \"last\" | \"average\"\n  /** Change in percent. \"auto\" compares with compareKey, or first to last row. Default: \"auto\" */\n  delta?: number | \"auto\"\n  /** Target for the headline; shows a thin progress bar. */\n  goal?: number\n  /** Sparkline style. Default: \"area\" */\n  sparkline?: \"area\" | \"line\" | \"bars\"\n  /** Line interpolation. Default: \"smooth\" */\n  curve?: ChartCurve\n  /** Sparkline height in px. Default: 64 */\n  sparkHeight?: number\n  /** Scrub the sparkline to see each row. Default: true */\n  showTooltip?: boolean\n  /** Sparkline under the number, or beside it. Default: \"stacked\" */\n  layout?: \"stacked\" | \"inline\"\n  /** \"semantic\" paints rises green and falls red. Default: \"accent\" */\n  trendColor?: \"accent\" | \"semantic\"\n}\n\nconst UP = \"#22c55e\"\nconst DOWN = \"#ef4444\"\nconst EASE = [0.22, 1, 0.36, 1] as const\n\nfunction combine(values: number[], mode: \"sum\" | \"last\" | \"average\") {\n  if (!values.length) return 0\n  if (mode === \"last\") return values[values.length - 1]\n  const sum = values.reduce((a, b) => a + b, 0)\n  return mode === \"average\" ? sum / values.length : sum\n}\n\nfunction useWidth() {\n  const [node, setNode] = useState<HTMLDivElement | null>(null)\n  const [width, setWidth] = useState(0)\n  useEffect(() => {\n    if (!node) return\n    const ro = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width))\n    ro.observe(node)\n    return () => ro.disconnect()\n  }, [node])\n  return [setNode, width] as const\n}\n\nfunction useCountUp(\n  target: number,\n  format: (n: number) => string,\n  { shown, still, duration, delay }: { shown: boolean; still: boolean; duration: number; delay: number }\n) {\n  const ref = useRef<HTMLSpanElement>(null)\n  const current = useRef(0)\n  useEffect(() => {\n    const el = ref.current\n    if (!el) return\n    if (still) {\n      current.current = target\n      el.textContent = format(target)\n      return\n    }\n    el.textContent = format(current.current)\n    if (!shown) return\n    const controls = animateValue(current.current, target, {\n      duration,\n      delay,\n      ease: EASE,\n      onUpdate: (v) => {\n        current.current = v\n        el.textContent = format(v)\n      },\n    })\n    return () => controls.stop()\n  }, [target, format, shown, still, duration, delay])\n  return ref\n}\n\nfunction TrendGlyph() {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n      <path d=\"M2 11.5 6 7.5l2.5 2.5L14 4.5M10 4.5h4v4\" />\n    </svg>\n  )\n}\n\nexport function ChartKpi({\n  data,\n  index,\n  valueKey,\n  compareKey,\n  aggregate = \"sum\",\n  delta = \"auto\",\n  goal,\n  sparkline = \"area\",\n  curve = \"smooth\",\n  sparkHeight = 64,\n  showTooltip = true,\n  layout = \"stacked\",\n  trendColor = \"accent\",\n  title,\n  description,\n  titleSize,\n  surface = \"dark\",\n  accent = CHART_ACCENT,\n  palette,\n  icon,\n  radius,\n  bare,\n  animate = true,\n  duration = 1.2,\n  delay = 0,\n  once = true,\n  valueFormat = \"number\",\n  currency,\n  decimals,\n  className,\n}: ChartKpiProps) {\n  const theme = chartTheme(surface, accent)\n  const { ref, shown, still } = useChartReveal(once, animate)\n  const format = useMemo(() => createFormatter(valueFormat, { currency, decimals }), [valueFormat, currency, decimals])\n\n  const values = data.map((d) => toNumber(d[valueKey]))\n  const compare = compareKey ? data.map((d) => toNumber(d[compareKey])) : undefined\n  const labels = data.map((d) => String(d[index] ?? \"\"))\n  const headline = combine(values, aggregate)\n  const previous = compare ? combine(compare, aggregate) : undefined\n\n  let change: number\n  if (typeof delta === \"number\") change = delta\n  else if (previous !== undefined) change = previous ? ((headline - previous) / Math.abs(previous)) * 100 : 0\n  else change = values.length > 1 && values[0] ? ((values[values.length - 1] - values[0]) / Math.abs(values[0])) * 100 : 0\n\n  const up = change >= 0\n  const base = palette?.[0] ?? accent\n  const color = trendColor === \"semantic\" ? (up ? UP : DOWN) : base\n  const pillColor = trendColor === \"semantic\" ? color : up ? base : theme.muted\n\n  const countRef = useCountUp(headline, format, { shown, still, duration, delay })\n  const [sparkRef, width] = useWidth()\n  const reveal = (d: number) => (still ? { duration: 0 } : { duration, delay: delay + d, ease: EASE })\n  const goalShare = goal ? Math.max(0, headline / goal) : 0\n\n  const figure = (\n    <div className=\"min-w-0\">\n      <span ref={countRef} className=\"block text-[2.6rem] font-semibold leading-none tracking-tight tabular-nums\">\n        {format(still ? headline : 0)}\n      </span>\n      <div className=\"mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px]\">\n        <motion.span\n          className=\"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] font-semibold tabular-nums\"\n          style={{ color: pillColor, background: `color-mix(in oklab, ${pillColor} 15%, transparent)` }}\n          initial={still ? false : { opacity: 0, scale: 0.6, y: 4 }}\n          animate={shown ? { opacity: 1, scale: 1, y: 0 } : { opacity: 0, scale: 0.6, y: 4 }}\n          transition={still ? { duration: 0 } : { type: \"spring\", stiffness: 520, damping: 22, delay: delay + duration * 0.55 }}\n        >\n          <svg viewBox=\"0 0 10 10\" className={cn(\"size-2.5\", !up && \"rotate-180\")} fill=\"currentColor\">\n            <path d=\"M5 1.5 9 7.5H1z\" />\n          </svg>\n          {Math.abs(change).toFixed(1)}%\n        </motion.span>\n        {previous !== undefined && (\n          <span style={{ color: theme.muted }}>\n            vs <span className=\"tabular-nums\">{format(previous)}</span> prior\n          </span>\n        )}\n      </div>\n    </div>\n  )\n\n  const spark = (\n    <div className=\"min-w-0\">\n      <div ref={sparkRef} className=\"relative w-full\" style={{ height: sparkHeight }}>\n        {width > 0 && values.length > 0 && (\n          <Sparkline\n            values={values}\n            compare={compare}\n            labels={labels}\n            valueLabel={valueKey}\n            compareLabel={compareKey}\n            width={width}\n            height={sparkHeight}\n            mode={sparkline}\n            curve={curve}\n            color={color}\n            theme={theme}\n            shown={shown}\n            still={still}\n            duration={duration}\n            delay={delay}\n            showTooltip={showTooltip}\n            format={format}\n          />\n        )}\n      </div>\n      {layout === \"stacked\" && labels.length > 1 && (\n        <div className=\"mt-2 flex justify-between text-[11px]\" style={{ color: theme.muted }}>\n          <span>{labels[0]}</span>\n          <span>{labels[labels.length - 1]}</span>\n        </div>\n      )}\n    </div>\n  )\n\n  return (\n    <ChartCard\n      theme={theme}\n      title={title}\n      description={description}\n      titleSize={titleSize}\n      icon={icon === undefined ? <TrendGlyph /> : icon}\n      radius={radius}\n      bare={bare}\n      className={className}\n    >\n      <div ref={ref}>\n        {layout === \"inline\" ? (\n          <div className=\"grid grid-cols-[auto_minmax(0,1fr)] items-end gap-6\">\n            {figure}\n            {spark}\n          </div>\n        ) : (\n          <div className=\"flex flex-col gap-5\">\n            {figure}\n            {spark}\n          </div>\n        )}\n\n        {goal ? (\n          <div className=\"mt-5\">\n            <div className=\"mb-2 flex items-baseline justify-between text-[12px]\" style={{ color: theme.muted }}>\n              <span>\n                <span className=\"font-medium tabular-nums\" style={{ color: theme.fg }}>\n                  {Math.round(goalShare * 100)}%\n                </span>{\" \"}\n                of goal\n              </span>\n              <span className=\"tabular-nums\">{format(goal)}</span>\n            </div>\n            <div className=\"h-1.5 overflow-hidden rounded-full\" style={{ background: theme.track }}>\n              <motion.div\n                className=\"h-full rounded-full\"\n                style={{ background: color }}\n                initial={still ? false : { width: \"0%\" }}\n                animate={{ width: shown ? `${Math.min(1, goalShare) * 100}%` : \"0%\" }}\n                transition={reveal(0.1)}\n              />\n            </div>\n          </div>\n        ) : null}\n      </div>\n    </ChartCard>\n  )\n}\n\nfunction Sparkline({\n  values,\n  compare,\n  labels,\n  valueLabel,\n  compareLabel,\n  width: w,\n  height: h,\n  mode,\n  curve,\n  color,\n  theme,\n  shown,\n  still,\n  duration,\n  delay,\n  showTooltip,\n  format,\n}: {\n  values: number[]\n  compare?: number[]\n  labels: string[]\n  valueLabel: string\n  compareLabel?: string\n  width: number\n  height: number\n  mode: \"area\" | \"line\" | \"bars\"\n  curve: ChartCurve\n  color: string\n  theme: ChartTheme\n  shown: boolean\n  still: boolean\n  duration: number\n  delay: number\n  showTooltip: boolean\n  format: (n: number) => string\n}) {\n  const id = useId().replace(/[^a-zA-Z0-9_-]/g, \"\")\n  const [hover, setHover] = useState<number | null>(null)\n  const n = values.length\n  const all = compare ? values.concat(compare) : values\n  const bars = mode === \"bars\"\n\n  let lo = Math.min(...all)\n  let hi = Math.max(...all)\n  if (bars) lo = Math.min(0, lo)\n  else {\n    const span = hi - lo || Math.abs(hi) || 1\n    lo -= span * 0.12\n    hi += span * 0.08\n  }\n  if (hi === lo) hi = lo + 1\n\n  const padX = bars ? 0 : 4\n  const top = 5\n  const slot = w / n\n  const x = (i: number) => (bars ? slot * (i + 0.5) : n > 1 ? padX + (i / (n - 1)) * (w - padX * 2) : w / 2)\n  const y = (v: number) => top + (1 - (v - lo) / (hi - lo)) * (h - top - (bars ? 0 : 2))\n  const pts: Point[] = values.map((v, i) => [x(i), y(v)])\n  const line = linePath(pts, curve)\n  const area = `${line}L${pts[n - 1][0]},${h}L${pts[0][0]},${h}Z`\n  const ghost = compare ? linePath(compare.map((v, i) => [x(i), y(v)] as Point), curve) : \"\"\n  const t = (d: number) => (still ? { duration: 0 } : { duration: duration * 0.9, delay: delay + d, ease: EASE })\n\n  const onMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    const r = e.currentTarget.getBoundingClientRect()\n    const px = e.clientX - r.left\n    const i = bars ? Math.floor(px / slot) : Math.round(((px - padX) / Math.max(1, w - padX * 2)) * (n - 1))\n    setHover(Math.max(0, Math.min(n - 1, i)))\n  }\n\n  const rows: TooltipRow[] = []\n  if (hover !== null) {\n    rows.push({ label: valueLabel, value: format(values[hover]), color })\n    if (compare && compareLabel) rows.push({ label: compareLabel, value: format(compare[hover]), color: theme.muted, texture: \"muted\" })\n  }\n\n  return (\n    <div\n      className=\"absolute inset-0\"\n      onPointerMove={showTooltip ? onMove : undefined}\n      onPointerLeave={showTooltip ? () => setHover(null) : undefined}\n    >\n      <svg width={w} height={h} className=\"block overflow-visible\">\n        <defs>\n          <pattern id={`${id}-dots`} width=\"4\" height=\"4\" patternUnits=\"userSpaceOnUse\">\n            <circle cx=\"2\" cy=\"2\" r=\"1\" fill={color} />\n          </pattern>\n          <clipPath id={`${id}-reveal`}>\n            <motion.rect\n              x={-8}\n              y={-8}\n              height={h + 16}\n              initial={still ? false : { width: 0 }}\n              animate={{ width: shown ? w + 16 : 0 }}\n              transition={t(0)}\n            />\n          </clipPath>\n        </defs>\n\n        {bars ? (\n          values.map((v, i) => {\n            const bw = Math.max(2, slot * 0.62)\n            const bx = x(i) - bw / 2\n            const by = y(Math.max(v, lo))\n            const bh = Math.max(1.5, y(lo) - by)\n            const active = hover === null ? i === n - 1 : hover === i\n            const stagger = (i / n) * duration * 0.5\n            return (\n              <g key={i}>\n                {compare && (\n                  <rect x={bx} y={y(Math.max(compare[i], lo))} width={bw} height={Math.max(1.5, y(lo) - y(Math.max(compare[i], lo)))} rx={Math.min(3, bw / 2)} fill={theme.subtle} opacity={0.45} />\n                )}\n                <motion.rect\n                  x={bx}\n                  width={bw}\n                  rx={Math.min(3, bw / 2)}\n                  fill={active ? color : `color-mix(in oklab, ${color} 42%, ${theme.track})`}\n                  initial={still ? false : { y: h, height: 0 }}\n                  animate={shown ? { y: by, height: bh } : { y: h, height: 0 }}\n                  transition={still ? { duration: 0 } : { type: \"spring\", stiffness: 260, damping: 26, delay: delay + stagger }}\n                />\n              </g>\n            )\n          })\n        ) : (\n          <g clipPath={`url(#${id}-reveal)`}>\n            {mode === \"area\" && (\n              <g opacity={0.55}>\n                <path d={area} fill={color} opacity={0.1} />\n                <path d={area} fill={`url(#${id}-dots)`} opacity={0.85} />\n              </g>\n            )}\n            {ghost && <path d={ghost} fill=\"none\" stroke={theme.muted} strokeWidth={1.5} strokeDasharray=\"3 4\" strokeLinecap=\"round\" opacity={0.7} />}\n            <path d={line} fill=\"none\" stroke={color} strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n          </g>\n        )}\n\n        {!bars && hover !== null && (\n          <line x1={pts[hover][0]} x2={pts[hover][0]} y1={0} y2={h} stroke={theme.muted} strokeOpacity={0.4} strokeDasharray=\"2 3\" />\n        )}\n        {!bars && (\n          <motion.circle\n            cx={pts[hover ?? n - 1][0]}\n            cy={pts[hover ?? n - 1][1]}\n            r={3.5}\n            fill={color}\n            stroke={theme.bg}\n            strokeWidth={2}\n            style={{ transformBox: \"fill-box\", transformOrigin: \"center\" }}\n            initial={still ? false : { scale: 0 }}\n            animate={{ scale: shown ? 1 : 0 }}\n            transition={still ? { duration: 0 } : { type: \"spring\", stiffness: 500, damping: 20, delay: hover === null ? delay + duration * 0.85 : 0 }}\n          />\n        )}\n      </svg>\n      {showTooltip && (\n        <ChartTooltip\n          open={hover !== null}\n          x={hover === null ? 0 : pts[hover][0]}\n          y={hover === null ? 0 : pts[hover][1]}\n          width={w}\n          title={hover === null ? undefined : labels[hover]}\n          rows={rows}\n          theme={theme}\n        />\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/chart-kpi",
  "type": "registry:component"
}