{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"stat-card","type":"registry:block","title":"Stat Card","description":"KPI tiles whose headline numbers roll digit by digit to each new value, with Intl number, currency, percent and compact formatting, a delta badge with an up or down arrow toned by whether the move is good news, an inline sparkline that redraws when its data changes, a period caption, a skeleton loading state and a container-query StatGrid layout.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/stat-card.tsx","type":"registry:component","target":"@components/motion/stat-card.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/stat-card\n\nimport { ArrowDownRight, ArrowUpRight } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useMemo } from \"react\";\nimport { Sparkline } from \"@/components/motion/chart/sparkline\";\nimport { NumberTicker } from \"@/components/motion/number-ticker\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type StatFormat = \"number\" | \"currency\" | \"percent\" | \"compact\";\nexport type StatTone = \"positive\" | \"negative\" | \"neutral\";\n\nexport interface StatCardProps {\n  label: ReactNode;\n  /** Headline value. For `format=\"percent\"` pass a fraction (0.034 = 3.4%). */\n  value: number;\n  /** Preset formatting. Default \"number\". */\n  format?: StatFormat;\n  /** ISO 4217 code for `format=\"currency\"`. Default \"USD\". */\n  currency?: string;\n  /** Formatting locale. Default \"en-US\" so server and client agree. */\n  locale?: string;\n  /** Extra Intl options merged over the preset (e.g. fraction digits). */\n  formatOptions?: Intl.NumberFormatOptions;\n  /** Relative change as a fraction (0.12 = +12%). Omit to hide the badge. */\n  delta?: number;\n  /** Which direction is good news. Default \"up\"; use \"down\" for churn, latency… */\n  goodDirection?: \"up\" | \"down\";\n  /** Force the badge tone instead of deriving it from delta and goodDirection. */\n  deltaTone?: StatTone;\n  /** Trend values for the inline sparkline. */\n  sparkline?: number[];\n  /** Sparkline color (any CSS color). Default follows the delta tone. */\n  sparklineColor?: string;\n  /** Small caption under the value, e.g. \"vs previous 30 days\". */\n  caption?: ReactNode;\n  /** Optional icon beside the label. */\n  icon?: ReactNode;\n  /** Render a skeleton instead of the value. */\n  loading?: boolean;\n  className?: string;\n}\n\nconst TONE_CLASS: Record<StatTone, string> = {\n  positive: \"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400\",\n  negative: \"bg-destructive/10 text-destructive\",\n  neutral: \"bg-muted text-muted-foreground\",\n};\nconst TONE_COLOR: Record<StatTone, string> = {\n  positive: \"var(--chart-3, #1baf7a)\",\n  negative: \"var(--destructive, #e5484d)\",\n  neutral: \"var(--chart-1, #2a78d6)\",\n};\n\nfunction presetOptions(\n  format: StatFormat,\n  currency: string,\n): Intl.NumberFormatOptions {\n  switch (format) {\n    case \"currency\":\n      return { style: \"currency\", currency, maximumFractionDigits: 0 };\n    case \"percent\":\n      return { style: \"percent\", maximumFractionDigits: 1 };\n    case \"compact\":\n      return { notation: \"compact\", maximumFractionDigits: 1 };\n    default:\n      return { maximumFractionDigits: 0 };\n  }\n}\n\n/**\n * KPI tile: label, a headline number whose digits roll to each new value, a\n * delta badge toned by whether the move is good news, an optional sparkline\n * that redraws when its data changes, and a caption. `loading` swaps in a\n * skeleton of the same footprint so the grid never jumps.\n */\nexport function StatCard({\n  label,\n  value,\n  format = \"number\",\n  currency = \"USD\",\n  locale = \"en-US\",\n  formatOptions,\n  delta,\n  goodDirection = \"up\",\n  deltaTone,\n  sparkline,\n  sparklineColor,\n  caption,\n  icon,\n  loading = false,\n  className,\n}: StatCardProps) {\n  const reduce = useReducedMotion();\n  const formatter = useMemo(\n    () =>\n      new Intl.NumberFormat(locale, {\n        ...presetOptions(format, currency),\n        ...formatOptions,\n      }),\n    [locale, format, currency, formatOptions],\n  );\n  const deltaFormatter = useMemo(\n    () =>\n      new Intl.NumberFormat(locale, {\n        style: \"percent\",\n        maximumFractionDigits: 1,\n        signDisplay: \"exceptZero\",\n      }),\n    [locale],\n  );\n\n  // NumberTicker rolls integers, so feed it the value scaled by the shown\n  // precision and divide back in the formatter — decimals survive the roll.\n  const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 0;\n  const scale = 10 ** (fractionDigits + (format === \"percent\" ? 2 : 0));\n  const tickerValue = Math.round(value * scale);\n  const formatTicker = (n: number) => formatter.format(n / scale);\n\n  const tone: StatTone =\n    deltaTone ??\n    (delta === undefined || delta === 0\n      ? \"neutral\"\n      : (delta > 0) === (goodDirection === \"up\")\n        ? \"positive\"\n        : \"negative\");\n  const DeltaIcon = (delta ?? 0) < 0 ? ArrowDownRight : ArrowUpRight;\n  const deltaText = delta === undefined ? \"\" : deltaFormatter.format(delta);\n  const sparkLabel = typeof label === \"string\" ? label : \"Trend\";\n\n  return (\n    <section\n      aria-busy={loading || undefined}\n      className={cn(\n        \"flex min-w-0 flex-col rounded-2xl border border-border bg-card p-4 text-card-foreground\",\n        className,\n      )}\n    >\n      <div className=\"flex items-center gap-2 text-xs font-medium text-muted-foreground\">\n        {icon ? <span className=\"[&_svg]:size-3.5\">{icon}</span> : null}\n        <span className=\"truncate\">{label}</span>\n      </div>\n\n      {loading ? (\n        <div className=\"mt-2 flex flex-col gap-2\" aria-hidden>\n          <span className=\"h-7 w-28 animate-pulse rounded-md bg-muted\" />\n          <span className=\"h-4 w-16 animate-pulse rounded-full bg-muted\" />\n          {sparkline ? <span className=\"mt-1 h-9 w-full animate-pulse rounded-md bg-muted\" /> : null}\n        </div>\n      ) : (\n        <>\n          <div className=\"mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1\">\n            <NumberTicker\n              value={tickerValue}\n              format={formatTicker}\n              duration={0.7}\n              stagger={0.03}\n              className=\"text-2xl font-semibold tracking-tight\"\n            />\n            {delta !== undefined ? (\n              <span\n                className={cn(\n                  \"inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[11px] font-semibold tabular-nums transition-colors\",\n                  TONE_CLASS[tone],\n                )}\n              >\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  <motion.span\n                    key={DeltaIcon === ArrowUpRight ? \"up\" : \"down\"}\n                    initial={{ opacity: 0, y: reduce ? 0 : 4 }}\n                    animate={{ opacity: 1, y: 0 }}\n                    exit={{ opacity: 0, y: reduce ? 0 : -4 }}\n                    transition={{ duration: 0.18, ease: EASE_OUT }}\n                    className=\"inline-flex\"\n                  >\n                    <DeltaIcon aria-hidden className=\"size-3\" />\n                  </motion.span>\n                </AnimatePresence>\n                <span className=\"sr-only\">\n                  {tone === \"positive\" ? \"Improved \" : tone === \"negative\" ? \"Worsened \" : \"Changed \"}\n                </span>\n                {deltaText}\n              </span>\n            ) : null}\n          </div>\n          {caption ? (\n            <p className=\"mt-0.5 text-[11px] text-muted-foreground\">{caption}</p>\n          ) : null}\n          {sparkline && sparkline.length > 1 ? (\n            <Sparkline\n              // Keyed by the data so a new range remounts and draws in again.\n              key={sparkline.join(\",\")}\n              data={sparkline}\n              height={36}\n              label={sparkLabel}\n              color={sparklineColor ?? TONE_COLOR[tone]}\n              formatValue={(v) => formatter.format(v)}\n              className=\"mt-3\"\n            />\n          ) : null}\n        </>\n      )}\n    </section>\n  );\n}\n\nexport interface StatGridProps {\n  children: ReactNode;\n  className?: string;\n}\n\n/**\n * Container-query grid for StatCards: one column when narrow, two from\n * 28rem, four from 48rem of its own width.\n */\nexport function StatGrid({ children, className }: StatGridProps) {\n  return (\n    <div className={cn(\"@container w-full\", className)}>\n      <div className=\"grid grid-cols-1 gap-3 @md:grid-cols-2 @3xl:grid-cols-4\">\n        {children}\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/motion/chart/sparkline.tsx","type":"registry:component","target":"@components/motion/chart/sparkline.tsx","content":"\"use client\";\n\nimport { motion, useInView, useReducedMotion } from \"motion/react\";\nimport { useId, useRef } from \"react\";\nimport {\n  formatFull,\n  GROW,\n  useChartWidth,\n} from \"@/components/motion/chart/shared\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface SparklineProps {\n  /** Values in time order. */\n  data: number[];\n  /** Height in px; width fills the container. Default 40. */\n  height?: number;\n  /** Any CSS color. Default: the first `--chart-*` slot. */\n  color?: string;\n  /** Soft fill under the line. Default true. */\n  area?: boolean;\n  formatValue?: (value: number) => string;\n  /** What the values are, e.g. \"Weekly revenue\". Used in the spoken summary. */\n  label: string;\n  className?: string;\n}\n\nconst PAD = 5; // room for the end dot and its ring\n\n/**\n * Word-sized trend line for stat tiles. Draws in when scrolled into view and\n * ends on a ringed dot at the latest value. It has no hover layer: the tile's\n * headline number carries the value, and the summary below is spoken.\n */\nexport function Sparkline({\n  data,\n  height = 40,\n  color = \"var(--chart-1, #2a78d6)\",\n  area = true,\n  formatValue = formatFull,\n  label,\n  className,\n}: SparklineProps) {\n  const uid = useId();\n  const reduce = useReducedMotion();\n  const [measureRef, width] = useChartWidth();\n  const viewRef = useRef<HTMLDivElement>(null);\n  const inView = useInView(viewRef, { once: true, amount: 0.5 });\n  const shown = reduce || inView;\n\n  const min = Math.min(...data);\n  const max = Math.max(...data);\n  const span = max - min || 1;\n  const x = (i: number) =>\n    PAD + (data.length > 1 ? (i / (data.length - 1)) * (width - PAD * 2) : 0);\n  const y = (v: number) => PAD + (1 - (v - min) / span) * (height - PAD * 2);\n  const line = data.map((v, i) => `${i ? \"L\" : \"M\"}${x(i)},${y(v)}`).join(\"\");\n  const last = data.length - 1;\n\n  const first = data[0] ?? 0;\n  const end = data[last] ?? 0;\n  const change = first ? ((end - first) / Math.abs(first)) * 100 : 0;\n  const summary = `${label}: ${formatValue(first)} to ${formatValue(end)}, ${\n    change >= 0 ? \"up\" : \"down\"\n  } ${Math.abs(change).toFixed(1)}% over ${data.length} points`;\n\n  return (\n    <div ref={viewRef} className={cn(\"w-full\", className)}>\n      <div ref={measureRef} style={{ height }} className=\"w-full\">\n        {width > 0 && data.length > 1 && (\n          <svg width={width} height={height} role=\"img\" aria-label={summary}>\n            {area && (\n              <>\n                <defs>\n                  <linearGradient id={`${uid}-fill`} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n                    <stop offset=\"0%\" stopColor={color} stopOpacity={0.2} />\n                    <stop offset=\"100%\" stopColor={color} stopOpacity={0} />\n                  </linearGradient>\n                </defs>\n                <motion.path\n                  d={`${line}L${x(last)},${height}L${x(0)},${height}Z`}\n                  fill={`url(#${uid}-fill)`}\n                  initial={false}\n                  animate={{ opacity: shown ? 1 : 0 }}\n                  transition={reduce ? { duration: 0 } : { ...GROW, delay: 0.3 }}\n                />\n              </>\n            )}\n            <motion.path\n              d={line}\n              fill=\"none\"\n              stroke={color}\n              strokeWidth={2}\n              strokeLinejoin=\"round\"\n              strokeLinecap=\"round\"\n              initial={false}\n              animate={{ pathLength: shown ? 1 : 0 }}\n              transition={reduce ? { duration: 0 } : { duration: 0.8, ease: GROW.ease }}\n            />\n            <motion.circle\n              cx={x(last)}\n              cy={y(end)}\n              r={3.5}\n              fill={color}\n              stroke=\"var(--card)\"\n              strokeWidth={2}\n              initial={false}\n              animate={{ opacity: shown ? 1 : 0, scale: shown ? 1 : 0.4 }}\n              transition={reduce ? { duration: 0 } : { duration: 0.2, delay: 0.7 }}\n              style={{ transformBox: \"fill-box\", transformOrigin: \"center\" }}\n            />\n          </svg>\n        )}\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/motion/number-ticker.tsx","type":"registry:component","target":"@components/motion/number-ticker.tsx","content":"\"use client\";\n\nimport { animate, motion, useInView, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface NumberTickerProps {\n  value: number;\n  /** Digits to pad to (left). */\n  pad?: number;\n  /** Per-digit roll duration in seconds. */\n  duration?: number;\n  /** Stagger between digits. */\n  stagger?: number;\n  /** Render only after the element enters the viewport. */\n  startOnView?: boolean;\n  prefix?: string;\n  suffix?: string;\n  /** Add a small blur during digit rolls. */\n  blur?: boolean;\n  className?: string;\n  digitClassName?: string;\n  /** Insert en-US group separators (commas); fixed so server and client match. Server-component safe. */\n  locale?: boolean;\n  /** Custom formatter. Client-only — server components must use `locale` instead. */\n  format?: (value: number) => string;\n}\n\nconst DIGIT_HEIGHT_EM = 1.1;\nconst DIGITS = Array.from({ length: 10 }, (_, n) => n);\n\nexport function NumberTicker({\n  value,\n  pad,\n  duration = 0.9,\n  stagger = 0.04,\n  startOnView = true,\n  prefix,\n  suffix,\n  blur = false,\n  className,\n  digitClassName,\n  locale,\n  format,\n}: NumberTickerProps) {\n  const containerRef = useRef<HTMLSpanElement>(null);\n  const inView = useInView(containerRef, { once: true, amount: 0.6 });\n  const [armed, setArmed] = useState(!startOnView);\n\n  useEffect(() => {\n    if (startOnView && inView) setArmed(true);\n  }, [startOnView, inView]);\n\n  const text = useMemo(() => {\n    const rounded = Math.round(value);\n    const formatted = format\n      ? format(rounded)\n      : locale\n        ? rounded.toLocaleString(\"en-US\")\n        : rounded.toString();\n    return pad ? formatted.padStart(pad, \"0\") : formatted;\n  }, [value, pad, format, locale]);\n  const glyphs = useMemo(() => {\n    const chars = text.split(\"\");\n    // Key by place value (position from the right): a changing digit keeps its\n    // identity and rolls to the new value instead of remounting and replaying\n    // from 0. Growing numbers add glyphs on the left without re-keying the\n    // ones, tens, hundreds already on screen.\n    return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));\n  }, [text]);\n  const readableText = `${prefix ?? \"\"}${text}${suffix ?? \"\"}`;\n\n  // Stagger is an entrance flourish. Once the reveal has played, value\n  // changes roll every digit immediately — a per-digit delay on live updates\n  // reads as lag.\n  const [entered, setEntered] = useState(false);\n  useEffect(() => {\n    if (!armed || entered) return;\n    const total = (duration + glyphs.length * stagger) * 1000;\n    const t = window.setTimeout(() => setEntered(true), total);\n    return () => window.clearTimeout(t);\n  }, [armed, entered, duration, stagger, glyphs.length]);\n\n  return (\n    <span\n      ref={containerRef}\n      className={cn(\"inline-flex items-center tabular-nums\", className)}\n    >\n      <span className=\"sr-only\">{readableText}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex items-center\">\n        {prefix ? <span>{prefix}</span> : null}\n        {glyphs.map(({ char, id }, i) => {\n          const isDigit = /\\d/.test(char);\n          if (!isDigit) {\n            return (\n              <span key={id} className=\"inline-block\">\n                {char}\n              </span>\n            );\n          }\n          const digit = Number(char);\n          return (\n            <Digit\n              key={id}\n              digit={armed ? digit : 0}\n              delay={entered ? 0 : i * stagger}\n              duration={duration}\n              blur={blur}\n              className={digitClassName}\n            />\n          );\n        })}\n        {suffix ? <span>{suffix}</span> : null}\n      </span>\n    </span>\n  );\n}\n\nfunction Digit({\n  digit,\n  delay,\n  duration,\n  blur,\n  className,\n}: {\n  digit: number;\n  delay: number;\n  duration: number;\n  blur: boolean;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  const columnRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) {\n      return;\n    }\n\n    const node = columnRef.current;\n    const controls = animate(\n      node,\n      { filter: [\"blur(10px)\", \"blur(0px)\"] },\n      {\n        duration: Math.min(duration * 0.75, 0.32),\n        delay,\n        ease: EASE_OUT,\n      },\n    );\n\n    return () => {\n      controls.stop();\n      node.style.filter = \"blur(0px)\";\n    };\n  }, [blur, delay, digit, duration, reduce]);\n\n  return (\n    <span\n      className={cn(\"relative inline-block overflow-hidden\", className)}\n      style={{ height: `${DIGIT_HEIGHT_EM}em`, width: \"1ch\" }}\n    >\n      <motion.span\n        ref={columnRef}\n        initial={{ y: 0 }}\n        animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}\n        transition={\n          reduce\n            ? { duration: 0 }\n            : { duration, delay, ease: EASE_OUT }\n        }\n        className=\"absolute inset-x-0 top-0 flex flex-col items-center will-change-[transform,filter]\"\n      >\n        {DIGITS.map((n) => (\n          <span\n            key={n}\n            className=\"flex h-[1.1em] items-center justify-center leading-none\"\n          >\n            {n}\n          </span>\n        ))}\n      </motion.span>\n    </span>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/motion/chart/shared.tsx","type":"registry:component","target":"@components/motion/chart/shared.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ChartSeries = {\n  /** Key of the value in each datum. */\n  key: string;\n  label: string;\n  /** Any CSS color. Default: the next `--chart-N` slot, in fixed order. */\n  color?: string;\n};\n\nexport type ChartDatum = Record<string, string | number>;\n\n/** \"natural\" is a monotone curve: smooth, but never overshoots a data point. */\nexport type ChartCurve = \"natural\" | \"linear\" | \"step\";\n\nexport type TooltipIndicator = \"dot\" | \"line\" | \"dashed\";\n\n// Fixed categorical order, validated for colorblind and normal-vision\n// separation (light and dark steps live in the theme's --chart-* tokens).\n// Fallbacks are the light steps, for projects without the theme layer.\nconst SLOT_FALLBACKS = [\"#2a78d6\", \"#eb6834\", \"#1baf7a\", \"#eda100\", \"#e87ba4\"];\n\n/** Color for slot `index`; color follows the series, never its rank. */\nexport function seriesColor(series: { color?: string }, index: number) {\n  if (series.color) return series.color;\n  const slot = index % SLOT_FALLBACKS.length;\n  return `var(--chart-${slot + 1}, ${SLOT_FALLBACKS[slot]})`;\n}\n\n/**\n * Layout width of the element given the returned ref. ResizeObserver reports\n * the untransformed box, unlike getBoundingClientRect (react-use-measure), so a\n * chart inside a scaled container (thumbnails, zoomed stages) still fills it.\n */\nexport function useChartWidth<T extends Element = HTMLDivElement>() {\n  const [el, setEl] = useState<T | null>(null);\n  const [width, setWidth] = useState(0);\n  useEffect(() => {\n    if (!el) return;\n    const observer = new ResizeObserver(([entry]) =>\n      setWidth(entry.contentRect.width),\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [el]);\n  return [setEl, width] as const;\n}\n\n// A fixed locale keeps server and client output identical for hydration.\nconst FULL = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 2 });\nconst COMPACT = new Intl.NumberFormat(\"en-US\", {\n  notation: \"compact\",\n  maximumFractionDigits: 1,\n});\nexport const formatFull = (n: number) => FULL.format(n);\nexport const formatCompact = (n: number) => COMPACT.format(n);\n\n/** Round tick values covering [min, max], about `count` of them. */\nexport function niceTicks(min: number, max: number, count = 4) {\n  if (min === max) max = min + 1;\n  const raw = (max - min) / count;\n  const mag = 10 ** Math.floor(Math.log10(raw));\n  const step =\n    [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? 10 * mag;\n  const start = Math.floor(min / step) * step;\n  const end = Math.ceil(max / step) * step;\n  const ticks: number[] = [];\n  for (let v = start; v <= end + step / 2; v += step) {\n    ticks.push(Number(v.toFixed(10)));\n  }\n  return ticks;\n}\n\n/** Keep every k-th label so neighbours stay at least `minGap` px apart. */\nexport function labelStride(count: number, width: number, minGap = 56) {\n  return Math.max(1, Math.ceil(count / Math.max(1, Math.floor(width / minGap))));\n}\n\n/** Approximate gutter for axis labels at 12px. */\nexport function gutterFor(labels: string[]) {\n  return Math.max(...labels.map((l) => l.length), 1) * 7 + 12;\n}\n\ntype Point = [number, number];\n\n/** SVG path through `points` (sorted by x) with the given curve. */\nexport function curvePath(points: Point[], curve: ChartCurve) {\n  if (points.length === 0) return \"\";\n  const [x0, y0] = points[0];\n  if (points.length === 1) return `M${x0},${y0}`;\n  if (curve === \"step\") {\n    // Changes value halfway between points, like a stepped series.\n    let d = `M${x0},${y0}`;\n    for (let i = 1; i < points.length; i++) {\n      const [px] = points[i - 1];\n      const [x, y] = points[i];\n      const mid = (px + x) / 2;\n      d += `H${mid}V${y}H${x}`;\n    }\n    return d;\n  }\n  if (curve === \"linear\" || points.length < 3) {\n    return points.map(([x, y], i) => `${i ? \"L\" : \"M\"}${x},${y}`).join(\"\");\n  }\n  // Monotone cubic (Fritsch–Carlson): tangents are clamped so the curve stays\n  // between its neighbours and never invents a peak the data doesn't have.\n  const n = points.length;\n  const slopes: number[] = [];\n  for (let i = 0; i < n - 1; i++) {\n    const dx = points[i + 1][0] - points[i][0];\n    slopes.push(dx ? (points[i + 1][1] - points[i][1]) / dx : 0);\n  }\n  const tangents = points.map((_, i) => {\n    if (i === 0) return slopes[0];\n    if (i === n - 1) return slopes[n - 2];\n    const a = slopes[i - 1];\n    const b = slopes[i];\n    return a * b <= 0 ? 0 : (a + b) / 2;\n  });\n  for (let i = 0; i < n - 1; i++) {\n    const m = slopes[i];\n    if (m === 0) {\n      tangents[i] = 0;\n      tangents[i + 1] = 0;\n      continue;\n    }\n    const a = tangents[i] / m;\n    const b = tangents[i + 1] / m;\n    const s = a * a + b * b;\n    if (s > 9) {\n      const t = 3 / Math.sqrt(s);\n      tangents[i] = t * a * m;\n      tangents[i + 1] = t * b * m;\n    }\n  }\n  let d = `M${x0},${y0}`;\n  for (let i = 0; i < n - 1; i++) {\n    const [xa, ya] = points[i];\n    const [xb, yb] = points[i + 1];\n    const h = (xb - xa) / 3;\n    d += `C${xa + h},${ya + tangents[i] * h} ${xb - h},${yb - tangents[i + 1] * h} ${xb},${yb}`;\n  }\n  return d;\n}\n\n// ── tooltip ────────────────────────────────────────────────────────────────\n\nexport type TooltipRow = { label: string; value: string; color: string };\n\nfunction Indicator({ kind, color }: { kind: TooltipIndicator; color: string }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"shrink-0 rounded-[2px]\",\n        kind === \"dot\" && \"size-2.5\",\n        kind === \"line\" && \"w-1 self-stretch\",\n        kind === \"dashed\" && \"w-0 self-stretch border-[1.5px] border-dashed\",\n      )}\n      style={\n        kind === \"dashed\" ? { borderColor: color } : { background: color }\n      }\n    />\n  );\n}\n\n/**\n * Compact readout that springs between data positions: an optional label,\n * then one row per series with a colored indicator, the name and the value.\n */\nexport function ChartTooltip({\n  open,\n  x,\n  y,\n  width,\n  label,\n  rows,\n  indicator = \"dot\",\n}: {\n  open: boolean;\n  /** Anchor in px, relative to the chart's positioned wrapper. */\n  x: number;\n  y: number;\n  /** Wrapper width, to flip the tooltip before it overflows the edge. */\n  width: number;\n  label?: string;\n  rows: TooltipRow[];\n  indicator?: TooltipIndicator;\n}) {\n  const reduce = useReducedMotion();\n  const flip = x > width / 2;\n  return (\n    <AnimatePresence>\n      {open && (\n        <motion.div\n          aria-hidden\n          initial={{ opacity: 0, x, y, scale: reduce ? 1 : 0.96 }}\n          animate={{ opacity: 1, x, y, scale: 1 }}\n          exit={{ opacity: 0, scale: reduce ? 1 : 0.96, transition: { duration: 0.1 } }}\n          transition={\n            reduce\n              ? { duration: 0 }\n              : {\n                  opacity: { duration: 0.12 },\n                  scale: { duration: 0.15, ease: EASE_OUT },\n                  // Tracks the pointer between data positions; firm enough to\n                  // never trail behind a quick sweep.\n                  default: { type: \"spring\", stiffness: 520, damping: 42 },\n                }\n          }\n          className=\"pointer-events-none absolute top-0 left-0 z-10\"\n        >\n          <div\n            className={cn(\n              \"grid min-w-32 -translate-y-1/2 gap-1.5 rounded-lg border border-border bg-background px-2.5 py-1.5 text-xs shadow-xl\",\n              flip ? \"-translate-x-[calc(100%+12px)]\" : \"translate-x-3\",\n            )}\n          >\n            {label ? (\n              <p className=\"font-medium text-foreground\">{label}</p>\n            ) : null}\n            {rows.map((row) => (\n              <div key={row.label} className=\"flex items-stretch gap-2\">\n                <Indicator kind={indicator} color={row.color} />\n                <div className=\"flex flex-1 items-center justify-between gap-4 leading-none\">\n                  <span className=\"text-muted-foreground\">{row.label}</span>\n                  <span className=\"font-mono font-medium tabular-nums text-foreground\">\n                    {row.value}\n                  </span>\n                </div>\n              </div>\n            ))}\n          </div>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n\n// ── legend & table ─────────────────────────────────────────────────────────\n\nexport function ChartLegend({\n  items,\n  className,\n}: {\n  items: { label: string; color: string }[];\n  className?: string;\n}) {\n  return (\n    <ul\n      className={cn(\n        \"flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3 text-xs text-muted-foreground\",\n        className,\n      )}\n    >\n      {items.map((item) => (\n        <li key={item.label} className=\"flex items-center gap-1.5\">\n          <span\n            aria-hidden\n            className=\"size-2 shrink-0 rounded-[2px]\"\n            style={{ background: item.color }}\n          />\n          {item.label}\n        </li>\n      ))}\n    </ul>\n  );\n}\n\n/** The table view: every value reachable without hovering or seeing color. */\nexport function ChartTable({\n  caption,\n  head,\n  rows,\n}: {\n  caption: string;\n  head: string[];\n  rows: ReactNode[][];\n}) {\n  // The wrapper is what hides it: a <caption> escapes a visually hidden\n  // table's clip and would otherwise paint over the chart.\n  return (\n    <div className=\"sr-only\">\n      <table>\n        <caption>{caption}</caption>\n        <thead>\n          <tr>\n            {head.map((h) => (\n              <th key={h} scope=\"col\">\n                {h}\n              </th>\n            ))}\n          </tr>\n        </thead>\n        <tbody>\n          {rows.map((row, r) => (\n            // biome-ignore lint/suspicious/noArrayIndexKey: rows mirror data order and never reorder.\n            <tr key={r}>\n              {row.map((cell, c) =>\n                c === 0 ? (\n                  // biome-ignore lint/suspicious/noArrayIndexKey: fixed column order.\n                  <th key={c} scope=\"row\">\n                    {cell}\n                  </th>\n                ) : (\n                  // biome-ignore lint/suspicious/noArrayIndexKey: fixed column order.\n                  <td key={c}>{cell}</td>\n                ),\n              )}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n\n/** Shared entrance timing: marks grow once the chart scrolls into view. */\nexport const GROW = { duration: 0.7, ease: EASE_OUT } as const;\n\n/** Arrow-key stepping shared by every chart that reads positions. */\nexport function stepIndex(\n  key: string,\n  current: number | null,\n  count: number,\n  wrap = false,\n) {\n  const forward = key === \"ArrowRight\" || key === \"ArrowDown\";\n  const back = key === \"ArrowLeft\" || key === \"ArrowUp\";\n  if (!forward && !back) return undefined;\n  if (current === null) return forward ? 0 : count - 1;\n  const next = current + (forward ? 1 : -1);\n  if (wrap) return (next + count) % count;\n  return Math.min(count - 1, Math.max(0, next));\n}\n"}]}