{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "waffle-chart",
  "type": "registry:ui",
  "title": "Waffle Chart",
  "description": "A grid of cells filled in category order to show parts of a whole, with a staggered fill, highlightable categories and smooth reassignment.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/waffle-chart.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface WaffleDatum {\n  label: string;\n  value: number;\n  /** Optional colour (any CSS colour); otherwise the accent and a categorical palette are used. */\n  color?: string;\n}\n\nexport type WaffleShape = \"square\" | \"rounded\" | \"circle\";\nexport type WaffleDirection = \"row\" | \"column\";\n\n/** Passed to `renderCell` for every cell. */\nexport interface WaffleCell {\n  /** Position in fill order (0 = first cell filled). */\n  order: number;\n  /** Index into `data`, or null for an empty cell. */\n  category: number | null;\n  datum: WaffleDatum | null;\n  /** Resolved colour for the cell (a CSS colour or var()). */\n  color: string;\n  /** True when this cell's category is highlighted. */\n  active: boolean;\n}\n\nexport interface WaffleChartProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  data: WaffleDatum[];\n  /** Accessible name; it opens the computed aria-label and the screen-reader list. */\n  label: string;\n  rows?: number;\n  columns?: number;\n  /** The whole that the grid represents. Defaults to the sum of values; anything above it stays as empty cells. */\n  total?: number;\n  /** Cell shape (ignored when `renderCell` is given). */\n  shape?: WaffleShape;\n  /** Gap between cells, in px. */\n  gap?: number;\n  /** Largest cell size, in px. Cells shrink to fit narrow containers. */\n  cellSize?: number;\n  /** Delay between consecutive cells in the entrance and when values change, in ms. */\n  stagger?: number;\n  /** Fill order: across rows (from the top) or down columns (from the left). */\n  direction?: WaffleDirection;\n  showLegend?: boolean;\n  /** Colour of the first category; the others use a categorical palette. */\n  accent?: string;\n  format?: (value: number) => string;\n  /** Custom cell content (e.g. an icon); receives the cell's colour and state. */\n  renderCell?: (cell: WaffleCell) => React.ReactNode;\n}\n\n/* ------------------------------- Allocation -------------------------------- */\n\n/**\n * Largest-remainder allocation: every category gets the floor of its exact share of the cells,\n * then the leftover cells go to the largest fractional parts — so the counts always add up.\n */\nfunction allocate(values: number[], whole: number, cells: number) {\n  const sum = values.reduce((s, v) => s + v, 0);\n  const filled = whole > 0 ? Math.round((Math.min(sum, whole) / whole) * cells) : 0;\n  if (sum <= 0 || filled === 0) return values.map(() => 0);\n  const exact = values.map((v) => (v / sum) * filled);\n  const counts = exact.map(Math.floor);\n  let left = filled - counts.reduce((s, c) => s + c, 0);\n  const order = exact.map((e, i) => ({ i, r: e - Math.floor(e) })).sort((a, b) => b.r - a.r || values[b.i] - values[a.i]);\n  for (let k = 0; left > 0; k = (k + 1) % order.length, left--) counts[order[k].i]++;\n  return counts;\n}\n\n/* --------------------------------- Palette --------------------------------- */\n\nconst PALETTE_VARS =\n  \"[--wf-2:#3d8bea] [--wf-3:#22b07d] [--wf-4:#e0a21a] [--wf-5:#e87ba4] [--wf-6:#8b7cf0] [--wf-7:#4fb9c9] [--wf-8:#a3a3a3]\";\nconst paletteColor = (i: number) => (i === 0 ? \"var(--wf-accent)\" : `var(--wf-${Math.min(i, 7) + 1})`);\n\nconst SHAPES: Record<WaffleShape, string> = {\n  square: \"rounded-[1px]\",\n  rounded: \"rounded-[28%]\",\n  circle: \"rounded-full\",\n};\n\nconst percent = new Intl.NumberFormat(\"en-US\", { style: \"percent\", maximumFractionDigits: 1 });\nconst pct = (part: number, whole: number) => percent.format(whole > 0 ? part / whole : 0);\nconst defaultFormat = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 1 });\n\n/* -------------------------------- Component -------------------------------- */\n\n/**\n * A waffle chart: a grid of cells filled in category order to show parts of a whole. Cells grow\n * in with a stagger along the fill order; when values change, cells re-colour in the same wave,\n * so reassignment reads as a smooth sweep. Hover a cell or hover/focus a legend item to\n * highlight that category with its value and share; click a legend item (or tap a cell) to pin\n * it. The grid is role=\"img\" with a computed label, and a visually hidden list repeats the data.\n */\nexport function WaffleChart({\n  data,\n  label,\n  rows = 10,\n  columns = 10,\n  total,\n  shape = \"rounded\",\n  gap = 3,\n  cellSize = 26,\n  stagger = 10,\n  direction = \"row\",\n  showLegend = true,\n  accent = \"#FF7448\",\n  format,\n  renderCell,\n  className = \"\",\n  style,\n  ...props\n}: WaffleChartProps) {\n  const [hovered, setHovered] = React.useState<number | null>(null);\n  const [pinned, setPinned] = React.useState<number | null>(null);\n  const fmt = format ?? ((v: number) => defaultFormat.format(v));\n\n  const R = Math.max(1, Math.round(rows));\n  const C = Math.max(1, Math.round(columns));\n  const cells = R * C;\n  const values = data.map((d) => Math.max(0, d.value));\n  const sum = values.reduce((s, v) => s + v, 0);\n  const whole = Math.max(sum, total ?? 0);\n  const counts = allocate(values, whole, cells);\n  const colors = data.map((d, i) => d.color ?? paletteColor(i));\n\n  // Category for each fill position.\n  const byOrder: (number | null)[] = [];\n  counts.forEach((n, i) => {\n    for (let k = 0; k < n; k++) byOrder.push(i);\n  });\n  while (byOrder.length < cells) byOrder.push(null);\n\n  const active = hovered ?? pinned;\n  const activeDatum = active != null ? data[active] : null;\n  const perCell = whole / cells;\n  const cellValue = Number(perCell.toPrecision(3));\n  const step = Math.max(0, stagger);\n\n  const summary = data.map((d, i) => `${d.label} ${fmt(values[i])} (${pct(values[i], whole)})`).join(\", \");\n  const ariaLabel = `${label}: ${summary}${whole > sum ? `; ${fmt(whole - sum)} (${pct(whole - sum, whole)}) not assigned` : \"\"}.`;\n\n  /** Category under a pointer event, from the cell's data attribute (event delegation). */\n  const categoryAt = (target: EventTarget | null) => {\n    const cell = target instanceof Element ? target.closest<HTMLElement>(\"[data-wf-cat]\") : null;\n    const raw = cell?.dataset.wfCat;\n    return raw == null || raw === \"\" ? null : Number(raw);\n  };\n\n  return (\n    <div\n      className={`flex w-full min-w-0 flex-col gap-3 text-zinc-950 dark:text-zinc-50 ${PALETTE_VARS} ${className}`}\n      style={{ [\"--wf-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <style>{`@keyframes wf-in{from{opacity:0;transform:scale(.35)}}`}</style>\n      {/* Read-out: the highlighted category, or the scale of one cell. */}\n      <div aria-hidden=\"true\" className=\"flex h-10 items-end justify-between gap-4\">\n        {activeDatum && active != null ? (\n          <div className=\"min-w-0\">\n            <span className=\"flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n              <span className=\"size-2 rounded-[2px]\" style={{ backgroundColor: colors[active] }} />\n              <span className=\"truncate\">{activeDatum.label}</span>\n            </span>\n            <span className=\"text-xl font-semibold tracking-[-0.02em] tabular-nums\">\n              {pct(values[active], whole)}\n              <span className=\"ml-2 font-mono text-[11px] font-normal tracking-normal text-zinc-500 dark:text-zinc-400\">\n                {fmt(values[active])} · {counts[active]} {counts[active] === 1 ? \"cell\" : \"cells\"}\n              </span>\n            </span>\n          </div>\n        ) : (\n          <div>\n            <span className=\"block font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">{label}</span>\n            <span className=\"font-mono text-[11px] text-zinc-500 dark:text-zinc-400\">\n              {R}×{C} · 1 cell {Math.abs(cellValue - perCell) < 1e-9 ? \"=\" : \"≈\"} {fmt(cellValue)}\n            </span>\n          </div>\n        )}\n      </div>\n\n      {/* Grid and legend sit side by side when there is room and stack when there isn't. */}\n      <div className=\"flex min-w-0 flex-wrap items-start gap-x-8 gap-y-5\">\n        <div\n          role=\"img\"\n          aria-label={ariaLabel}\n          className=\"grid w-max max-w-full touch-manipulation\"\n          style={{ gridTemplateColumns: `repeat(${C}, minmax(0, ${Math.max(4, cellSize)}px))`, gap }}\n          onPointerOver={(e) => {\n            // Gaps between cells keep the current highlight, so it doesn't flicker while moving.\n            if (e.pointerType !== \"mouse\" || e.target === e.currentTarget) return;\n            setHovered(categoryAt(e.target));\n          }}\n          onPointerLeave={(e) => {\n            if (e.pointerType === \"mouse\") setHovered(null);\n          }}\n          onClick={(e) => {\n            // Tap (or click) a cell to pin its category; tap it again or an empty cell to release.\n            const cat = categoryAt(e.target);\n            setPinned((p) => (cat == null || p === cat ? null : cat));\n          }}\n        >\n          {Array.from({ length: cells }, (_, pos) => {\n            const r = Math.floor(pos / C);\n            const c = pos % C;\n            const order = direction === \"row\" ? pos : c * R + r;\n            const cat = byOrder[order];\n            const isActive = cat != null && cat === active;\n            const dimmed = active != null && !isActive;\n            const color = cat == null ? \"\" : colors[cat];\n            const delay = `${Math.round(order * step)}ms`;\n            return (\n              <span\n                key={pos}\n                data-wf-cat={cat ?? \"\"}\n                className={`relative block aspect-square transition-[background-color,opacity,color] duration-500 ease-out [animation:wf-in_420ms_cubic-bezier(0.2,0.8,0.2,1)_both] motion-reduce:transition-none motion-reduce:[animation:none] ${\n                  renderCell ? \"\" : SHAPES[shape]\n                } ${cat == null && !renderCell ? \"bg-zinc-900/[0.07] dark:bg-white/[0.08]\" : \"\"} ${cat == null && renderCell ? \"text-zinc-900/[0.12] dark:text-white/[0.14]\" : \"\"}`}\n                style={{\n                  backgroundColor: renderCell || cat == null ? undefined : color,\n                  color: renderCell && cat != null ? color : undefined,\n                  opacity: dimmed ? 0.2 : 1,\n                  animationDelay: delay,\n                  // Value changes re-colour cells in the same wave as the entrance, at half speed.\n                  transitionDelay: `${Math.round(order * step * 0.5)}ms`,\n                }}\n              >\n                {renderCell?.({ order, category: cat, datum: cat == null ? null : data[cat], color: color || \"currentColor\", active: isActive })}\n              </span>\n            );\n          })}\n        </div>\n\n        {showLegend && (\n          <ul className=\"flex min-w-[12rem] max-w-sm flex-1 flex-col gap-1\">\n            {data.map((d, i) => {\n              const on = active === i;\n              return (\n                <li key={`${d.label}-${i}`}>\n                  <button\n                    type=\"button\"\n                    aria-pressed={pinned === i}\n                    aria-label={`${d.label}: ${fmt(values[i])}, ${pct(values[i], whole)}`}\n                    onPointerEnter={(e) => e.pointerType === \"mouse\" && setHovered(i)}\n                    onPointerLeave={(e) => e.pointerType === \"mouse\" && setHovered(null)}\n                    onFocus={() => setHovered(i)}\n                    onBlur={() => setHovered(null)}\n                    onClick={() => setPinned((p) => (p === i ? null : i))}\n                    className={`grid w-full min-w-[12rem] grid-cols-[auto_1fr_auto_auto] items-center gap-x-2.5 rounded-md px-2 py-1.5 text-left text-[13px] transition-[background-color,color,border-color] outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[color:var(--wf-accent)] ${\n                      on ? \"bg-zinc-900/[0.06] dark:bg-white/[0.08]\" : \"hover:bg-zinc-900/[0.04] dark:hover:bg-white/[0.05]\"\n                    } ${active != null && !on ? \"text-zinc-400 dark:text-zinc-500\" : \"\"}`}\n                  >\n                    <span aria-hidden=\"true\" className={`size-2.5 ${SHAPES[shape]}`} style={{ backgroundColor: colors[i] }} />\n                    <span className=\"truncate\">{d.label}</span>\n                    <span className=\"font-mono text-[11px] tabular-nums text-zinc-500 dark:text-zinc-400\">{fmt(values[i])}</span>\n                    <span className=\"w-10 text-right font-mono text-[11px] tabular-nums\">{pct(values[i], whole)}</span>\n                  </button>\n                </li>\n              );\n            })}\n          </ul>\n        )}\n      </div>\n\n      <ul className=\"sr-only\">\n        {data.map((d, i) => (\n          <li key={`${d.label}-${i}`}>\n            {d.label}: {fmt(values[i])}, {pct(values[i], whole)} ({counts[i]} of {cells} cells)\n          </li>\n        ))}\n        {whole > sum && (\n          <li>\n            Not assigned: {fmt(whole - sum)}, {pct(whole - sum, whole)}\n          </li>\n        )}\n      </ul>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/waffle-chart"
}
