{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-dots",
  "title": "Chart Dots",
  "description": "Dot matrix of filled versus hatched units with a partial last dot, spring wave in fill order, and a huge count-up figure.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://trytweenly.vercel.app/r/chart-kit.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/chart-dots/chart-dots.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useMemo, useRef } from \"react\"\nimport { animate as animateValue, motion } from \"motion/react\"\nimport {\n  CHART_ACCENT,\n  ChartCard,\n  chartTheme,\n  createFormatter,\n  useChartReveal,\n  type ChartBaseProps,\n} from \"@/registry/new-york/lib/chart-kit\"\n\nexport interface ChartDotsProps extends ChartBaseProps {\n  /** Filled amount, e.g. 41 seats. */\n  value: number\n  /** Amount that fills every dot, e.g. 50 seats. Default: 100 */\n  total?: number\n  /** Dots per row. Default: 6 */\n  columns?: number\n  /** Number of rows. Default: 4 */\n  rows?: number\n  /** Show a fractional share as a partly filled dot. Default: true */\n  partial?: boolean\n  /** Text under the headline figure. Default: \"Pattern Hero\" */\n  caption?: string\n  /** Headline as a percentage, the raw value, or \"value/total\". Default: \"percent\" */\n  display?: \"percent\" | \"value\" | \"fraction\"\n  /** Dot shape. Default: \"circle\" */\n  shape?: \"circle\" | \"square\" | \"rounded\"\n  /** Fill of empty dots. Default: \"hatch\" */\n  emptyTexture?: \"hatch\" | \"track\"\n  /** Order in which dots fill. Default: \"row\" */\n  order?: \"row\" | \"column\" | \"spiral\"\n  /** Space between dots in px. Default: 10 */\n  gap?: number\n}\n\nconst RADIUS = { circle: \"50%\", square: \"3px\", rounded: \"28%\" }\n\nfunction fillOrder(cols: number, rows: number, order: \"row\" | \"column\" | \"spiral\") {\n  const cells: [number, number][] = []\n  if (order === \"row\") {\n    for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) cells.push([c, r])\n  } else if (order === \"column\") {\n    for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) cells.push([c, r])\n  } else {\n    let top = 0\n    let bottom = rows - 1\n    let left = 0\n    let right = cols - 1\n    while (top <= bottom && left <= right) {\n      for (let c = left; c <= right; c++) cells.push([c, top])\n      for (let r = top + 1; r <= bottom; r++) cells.push([right, r])\n      if (top < bottom) for (let c = right - 1; c >= left; c--) cells.push([c, bottom])\n      if (left < right) for (let r = bottom - 1; r > top; r--) cells.push([left, r])\n      top++\n      bottom--\n      left++\n      right--\n    }\n  }\n  const rank = new Array<number>(cols * rows)\n  cells.forEach(([c, r], i) => (rank[r * cols + c] = i))\n  return rank\n}\n\nfunction DotsGlyph() {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"currentColor\">\n      <circle cx=\"5\" cy=\"5\" r=\"1.8\" />\n      <circle cx=\"11\" cy=\"5\" r=\"1.8\" />\n      <circle cx=\"5\" cy=\"11\" r=\"1.8\" />\n      <circle cx=\"11\" cy=\"11\" r=\"1.8\" fillOpacity=\"0.35\" />\n    </svg>\n  )\n}\n\nexport function ChartDots({\n  value,\n  total = 100,\n  columns = 6,\n  rows = 4,\n  partial = true,\n  caption = \"Pattern Hero\",\n  display = \"percent\",\n  shape = \"circle\",\n  emptyTexture = \"hatch\",\n  order = \"row\",\n  gap = 10,\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}: ChartDotsProps) {\n  const theme = chartTheme(surface, accent)\n  const color = palette?.[0] ?? accent\n  const { ref, shown, still } = useChartReveal(once, animate)\n  const format = useMemo(() => createFormatter(valueFormat, { currency, decimals }), [valueFormat, currency, decimals])\n\n  const cols = Math.max(1, Math.round(columns))\n  const rowCount = Math.max(1, Math.round(rows))\n  const count = cols * rowCount\n  const whole = total > 0 ? total : 1\n  const share = Math.max(0, Math.min(1, value / whole))\n  const exact = share * count\n  const full = partial ? Math.floor(exact + 1e-9) : Math.round(exact)\n  const frac = partial ? exact - full : 0\n  const rank = useMemo(() => fillOrder(cols, rowCount, order), [cols, rowCount, order])\n\n  const text = useMemo(() => {\n    const pct = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: decimals ?? 0 })\n    return (v: number) =>\n      display === \"value\"\n        ? format(v)\n        : display === \"fraction\"\n          ? `${format(v)}/${format(whole)}`\n          : `${pct.format((v / whole) * 100)}%`\n  }, [display, format, whole, decimals])\n\n  const countRef = useRef<HTMLSpanElement>(null)\n  const current = useRef(0)\n  useEffect(() => {\n    const el = countRef.current\n    if (!el) return\n    if (still) {\n      current.current = value\n      el.textContent = text(value)\n      return\n    }\n    el.textContent = text(current.current)\n    if (!shown) return\n    const controls = animateValue(current.current, value, {\n      duration,\n      delay,\n      ease: [0.16, 1, 0.3, 1],\n      onUpdate: (v) => {\n        current.current = v\n        el.textContent = text(v)\n      },\n    })\n    return () => controls.stop()\n  }, [value, text, shown, still, duration, delay])\n\n  const hasHeader = !!(title || description)\n  const badge = icon === undefined ? <DotsGlyph /> : icon\n  const step = (duration * 0.7) / Math.max(1, count)\n  const empty =\n    emptyTexture === \"hatch\"\n      ? // Hard-edged hatch stripes (a pattern, no color blending)\n        `repeating-linear-gradient(-45deg, ${theme.subtle} 0 1.5px, ${theme.track} 1.5px 5px)`\n      : theme.track\n\n  return (\n    <ChartCard\n      theme={theme}\n      title={title}\n      description={description}\n      titleSize={titleSize}\n      icon={hasHeader ? badge : null}\n      radius={radius}\n      bare={bare}\n      className={className}\n    >\n      <div ref={ref} className=\"flex flex-col gap-5\">\n        <div className=\"flex items-start justify-between gap-3\">\n          <div className=\"min-w-0\">\n            <span ref={countRef} className=\"block text-[3.5rem] font-bold leading-[0.85] tracking-tighter tabular-nums\">\n              {text(still ? value : 0)}\n            </span>\n            {caption && (\n              <span className=\"mt-2 block text-sm font-medium tracking-tight\" style={{ color: theme.muted }}>\n                {caption}\n              </span>\n            )}\n          </div>\n          {!hasHeader && !bare && badge !== null && (\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              {badge}\n            </span>\n          )}\n        </div>\n\n        <div\n          className=\"grid\"\n          style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`, gap }}\n          role=\"img\"\n          aria-label={`${text(value)}${caption ? ` ${caption}` : \"\"}`}\n        >\n          {Array.from({ length: count }, (_, i) => {\n            const r = rank[i]\n            const on = r < full\n            const part = !on && r === full ? frac : 0\n            return (\n              <motion.div\n                key={i}\n                className=\"relative aspect-square overflow-hidden\"\n                style={{\n                  borderRadius: RADIUS[shape],\n                  background: on ? color : empty,\n                  boxShadow: on ? undefined : `inset 0 0 0 1px ${theme.subtle}`,\n                }}\n                initial={still ? false : { scale: 0, opacity: 0 }}\n                animate={shown ? { scale: 1, opacity: 1 } : { scale: 0, opacity: 0 }}\n                transition={still ? { duration: 0 } : { type: \"spring\", stiffness: 420, damping: 18, delay: delay + r * step }}\n              >\n                {part > 0 && (\n                  <motion.div\n                    className=\"absolute inset-y-0 left-0\"\n                    style={{ background: color }}\n                    initial={still ? false : { width: \"0%\" }}\n                    animate={{ width: shown ? `${part * 100}%` : \"0%\" }}\n                    transition={still ? { duration: 0 } : { duration: 0.5, delay: delay + (r + 2) * step, ease: [0.22, 1, 0.36, 1] }}\n                  />\n                )}\n              </motion.div>\n            )\n          })}\n        </div>\n      </div>\n    </ChartCard>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/chart-dots",
  "type": "registry:component"
}