{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "treemap",
  "type": "registry:ui",
  "title": "Treemap",
  "description": "A squarified treemap of nested data: colour by group, tooltips, and click-to-zoom drill-down with a breadcrumb back up.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/treemap.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nexport interface TreemapNode {\n  id: string;\n  label: string;\n  /** Leaf value; groups sum their children (a group's own value is ignored). */\n  value?: number;\n  children?: TreemapNode[];\n}\n\nexport interface TreemapProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  data: TreemapNode;\n  /** Height of the map in px; the width follows the container. */\n  height?: number;\n  /** Show each group's children inside it (two levels at a time). */\n  nested?: boolean;\n  /** Value under the label on large tiles. */\n  showValues?: boolean;\n  /** Gap between tiles in px. */\n  gap?: number;\n  /** Tile corner radius in px. */\n  radius?: number;\n  /** Spring for the drill-down zoom. */\n  spring?: { stiffness: number; damping: number; mass: number };\n  /** Colour of the first top-level group; others use a categorical palette. */\n  accent?: string;\n  format?: (value: number) => string;\n  /** Called with the id of the node now in view. */\n  onDrill?: (id: string) => void;\n}\n\n/* --------------------------- reduced-motion hook --------------------------- */\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* ------------------------------- hierarchy -------------------------------- */\n\ninterface Node {\n  id: string;\n  label: string;\n  value: number;\n  children: Node[];\n  parent: Node | null;\n  /** Index of the top-level group this node belongs to (for colour). */\n  group: number;\n  depth: number;\n}\n\nfunction build(data: TreemapNode): { root: Node; byId: Map<string, Node> } {\n  const byId = new Map<string, Node>();\n  const visit = (d: TreemapNode, parent: Node | null, group: number, depth: number): Node => {\n    const node: Node = { id: d.id, label: d.label, value: 0, children: [], parent, group, depth };\n    byId.set(d.id, node);\n    if (d.children?.length) {\n      node.children = d.children.map((c, i) => visit(c, node, depth === 0 ? i : group, depth + 1));\n      node.value = node.children.reduce((s, c) => s + c.value, 0);\n    } else {\n      node.value = Math.max(0, d.value ?? 0);\n    }\n    return node;\n  };\n  const root = visit(data, null, 0, 0);\n  return { root, byId };\n}\n\nconst pathTo = (node: Node) => {\n  const out: Node[] = [];\n  for (let n: Node | null = node; n; n = n.parent) out.unshift(n);\n  return out;\n};\n\n/* ------------------------------- squarify --------------------------------- */\n\ninterface Rect {\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n}\n\n/** Worst aspect ratio in a row laid along a side of length `side`. */\nfunction worst(areas: number[], side: number) {\n  const sum = areas.reduce((a, b) => a + b, 0);\n  const max = Math.max(...areas);\n  const min = Math.min(...areas);\n  const s2 = side * side;\n  return Math.max((s2 * max) / (sum * sum), (sum * sum) / (s2 * min));\n}\n\n/**\n * Squarified treemap (Bruls, Huizing & van Wijk): greedily add items to a row while the row's\n * worst aspect ratio improves, then lay the row along the shorter side and recurse on the rest.\n */\nfunction squarify<T extends { value: number }>(items: T[], rect: Rect): (Rect & { item: T })[] {\n  const list = items.filter((i) => i.value > 0).sort((a, b) => b.value - a.value);\n  const total = list.reduce((s, i) => s + i.value, 0);\n  if (!total || rect.w <= 0 || rect.h <= 0) return [];\n  const scale = (rect.w * rect.h) / total;\n  const out: (Rect & { item: T })[] = [];\n  let r = { ...rect };\n  let i = 0;\n  while (i < list.length) {\n    const side = Math.min(r.w, r.h);\n    const row = [list[i]];\n    let areas = [list[i].value * scale];\n    i++;\n    while (i < list.length) {\n      const next = [...areas, list[i].value * scale];\n      if (worst(next, side) > worst(areas, side)) break;\n      row.push(list[i]);\n      areas = next;\n      i++;\n    }\n    const rowArea = areas.reduce((a, b) => a + b, 0);\n    if (r.w >= r.h) {\n      // Column on the left.\n      const cw = rowArea / r.h;\n      let y = r.y;\n      row.forEach((item, k) => {\n        const h = areas[k] / cw;\n        out.push({ x: r.x, y, w: cw, h, item });\n        y += h;\n      });\n      r = { x: r.x + cw, y: r.y, w: r.w - cw, h: r.h };\n    } else {\n      // Row along the top.\n      const rh = rowArea / r.w;\n      let x = r.x;\n      row.forEach((item, k) => {\n        const w = areas[k] / rh;\n        out.push({ x, y: r.y, w, h: rh, item });\n        x += w;\n      });\n      r = { x: r.x, y: r.y + rh, w: r.w, h: r.h - rh };\n    }\n  }\n  return out;\n}\n\n/* --------------------------------- palette --------------------------------- */\n\nconst PALETTE_VARS =\n  \"[--tm-2:#3d8bea] [--tm-3:#22b07d] [--tm-4:#f0b429] [--tm-5:#e87ba4] [--tm-6:#8b7cf0] [--tm-7:#4fb9c9] [--tm-8:#a3a3a3]\";\n\nconst groupColor = (group: number) => (group === 0 ? \"var(--tm-accent)\" : `var(--tm-${Math.min(group, 7) + 1})`);\n/** Lighter mixes for smaller siblings so a group reads as a family. */\nconst tileFill = (group: number, rank: number) => `color-mix(in oklab, ${groupColor(group)} ${Math.max(52, 96 - rank * 7)}%, white)`;\n\nconst compact = new Intl.NumberFormat(\"en-US\", { notation: \"compact\", maximumFractionDigits: 1 });\nconst pctText = (part: number, whole: number) => `${whole > 0 ? ((part / whole) * 100).toFixed(1) : \"0\"}%`;\n\n/* -------------------------------- component -------------------------------- */\n\ninterface Tile extends Rect {\n  node: Node;\n  /** The child of the focused node this tile belongs to. */\n  owner: Node;\n  rank: number;\n}\n\ninterface Frame extends Rect {\n  node: Node;\n  header: boolean;\n}\n\nconst HEADER = 20;\nconst DEFAULT_SPRING = { stiffness: 210, damping: 28, mass: 1 };\n\n/**\n * A squarified treemap of hierarchical data. Tiles are coloured by top-level group and labelled\n * when they have room; click or press Enter on a tile to zoom into its group (tiles that stay in\n * view animate to their new boxes), and use the breadcrumb, Escape or Backspace to zoom out.\n * Arrow keys move focus to the nearest tile in that direction.\n */\nexport function Treemap({\n  data,\n  height = 380,\n  nested = true,\n  showValues = true,\n  gap = 2,\n  radius = 4,\n  spring = DEFAULT_SPRING,\n  accent = \"#FF7448\",\n  format,\n  onDrill,\n  className = \"\",\n  style,\n  ...props\n}: TreemapProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n  const hintId = `${uid}-hint`;\n  const boxRef = React.useRef<HTMLDivElement>(null);\n  const pendingFocus = React.useRef<string | null>(null);\n  const [width, setWidth] = React.useState(720);\n  const { root, byId } = React.useMemo(() => build(data), [data]);\n  const [focusId, setFocusId] = React.useState(root.id);\n  const [origin, setOrigin] = React.useState<Map<string, Rect> | null>(null);\n  const [active, setActive] = React.useState<string | null>(null);\n  const [tabId, setTabId] = React.useState<string | null>(null);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  const fmt = format ?? ((v: number) => compact.format(v));\n\n  const focusNode = byId.get(focusId) ?? root;\n  const crumbs = pathTo(focusNode);\n\n  /* -------------------------------- layout -------------------------------- */\n\n  const W = Math.max(1, width);\n  const H = Math.max(1, height);\n  const frames: Frame[] = [];\n  const tiles: Tile[] = [];\n  for (const cell of squarify(focusNode.children.length ? focusNode.children : [focusNode], { x: 0, y: 0, w: W, h: H })) {\n    const owner = cell.item;\n    const inner = nested && owner.children.length > 0;\n    const header = inner && cell.w > 64 && cell.h > 56;\n    frames.push({ ...cell, node: owner, header });\n    if (inner) {\n      const pad = gap;\n      const area = { x: cell.x + pad, y: cell.y + (header ? HEADER : pad), w: cell.w - pad * 2, h: cell.h - (header ? HEADER : pad) - pad };\n      squarify(owner.children, area).forEach((c, rank) => tiles.push({ ...c, node: c.item, owner, rank }));\n    } else {\n      tiles.push({ ...cell, node: owner, owner, rank: 0 });\n    }\n  }\n\n  const toPct = (r: Rect, inset = 0) => ({\n    left: `${(((r.x + inset) / W) * 100).toFixed(3)}%`,\n    top: `${(((r.y + inset) / H) * 100).toFixed(3)}%`,\n    width: `${((Math.max(0, r.w - inset * 2) / W) * 100).toFixed(3)}%`,\n    height: `${((Math.max(0, r.h - inset * 2) / H) * 100).toFixed(3)}%`,\n  });\n\n  const currentRects = () => {\n    const map = new Map<string, Rect>();\n    for (const f of frames) map.set(f.node.id, f);\n    for (const t of tiles) map.set(t.node.id, t);\n    return map;\n  };\n\n  /** Where a newly visible element starts: its nearest ancestor's previous box. */\n  const startRect = (node: Node): Rect | null => {\n    if (!origin) return null;\n    for (let n: Node | null = node; n; n = n.parent) {\n      const r = origin.get(n.id);\n      if (r) return r;\n    }\n    return null;\n  };\n\n  /* ------------------------------- navigation ------------------------------ */\n\n  const drillTo = (node: Node, focusAfter: string | null) => {\n    if (node.id === focusNode.id) return;\n    setOrigin(currentRects());\n    setFocusId(node.id);\n    setActive(null);\n    pendingFocus.current = focusAfter;\n    setAnnouncement(`Showing ${node.label}: ${fmt(node.value)}, ${node.children.length} ${node.children.length === 1 ? \"item\" : \"items\"}.`);\n    onDrill?.(node.id);\n  };\n\n  const drillDown = (tile: Tile) => {\n    // Zoom into the tile's group; a tile that is itself a leaf group at the top can't go deeper.\n    const target = tile.owner.children.length ? tile.owner : null;\n    if (!target) return;\n    drillTo(target, null);\n  };\n\n  const drillUp = () => {\n    const parent = focusNode.parent;\n    if (parent) drillTo(parent, focusNode.id);\n  };\n\n  // After a drill, keep keyboard users in the map: focus a tile of the group they came from\n  // (zooming out) or the largest tile (zooming in). Exiting tiles are still in the DOM, so the\n  // target is picked from the new layout rather than by DOM order.\n  const refocus = React.useEffectEvent(() => {\n    const box = boxRef.current;\n    const wanted = pendingFocus.current;\n    pendingFocus.current = null;\n    if (!box || !box.contains(document.activeElement)) return;\n    const tile = (wanted ? tiles.find((t) => t.owner.id === wanted) : undefined) ?? tiles[0];\n    if (!tile) return;\n    setTabId(tile.node.id);\n    box.querySelector<HTMLElement>(`[data-tm-tile=\"${CSS.escape(tile.node.id)}\"]`)?.focus({ preventScroll: true });\n  });\n  React.useEffect(() => {\n    refocus();\n  }, [focusId]);\n\n  React.useEffect(() => {\n    const box = boxRef.current;\n    if (!box) return;\n    const ro = new ResizeObserver((entries) => setWidth(Math.max(1, Math.round(entries[0].contentRect.width))));\n    ro.observe(box);\n    return () => ro.disconnect();\n  }, []);\n\n  const rovingId = tiles.some((t) => t.node.id === tabId) ? tabId : (tiles[0]?.node.id ?? null);\n\n  /** Nearest tile in a direction, by centre distance with a penalty for sideways drift. */\n  const neighbour = (from: Tile, dx: number, dy: number) => {\n    const cx = from.x + from.w / 2;\n    const cy = from.y + from.h / 2;\n    let best: Tile | null = null;\n    let bestScore = Infinity;\n    for (const t of tiles) {\n      if (t === from) continue;\n      const tx = t.x + t.w / 2 - cx;\n      const ty = t.y + t.h / 2 - cy;\n      const along = tx * dx + ty * dy;\n      if (along <= 1) continue;\n      const across = Math.abs(tx * dy - ty * dx);\n      const score = along + across * 2;\n      if (score < bestScore) {\n        bestScore = score;\n        best = t;\n      }\n    }\n    return best;\n  };\n\n  const onTileKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, tile: Tile) => {\n    const dirs: Record<string, [number, number]> = { ArrowRight: [1, 0], ArrowLeft: [-1, 0], ArrowDown: [0, 1], ArrowUp: [0, -1] };\n    const dir = dirs[event.key];\n    let next: Tile | null = null;\n    if (dir) next = neighbour(tile, dir[0], dir[1]);\n    else if (event.key === \"Home\") next = tiles[0] ?? null;\n    else if (event.key === \"End\") next = tiles[tiles.length - 1] ?? null;\n    else if (event.key === \"Escape\" || event.key === \"Backspace\") {\n      if (focusNode.parent) {\n        event.preventDefault();\n        drillUp();\n      }\n      return;\n    } else return;\n    event.preventDefault();\n    if (next) boxRef.current?.querySelector<HTMLElement>(`[data-tm-tile=\"${CSS.escape(next.node.id)}\"]`)?.focus();\n  };\n\n  /* --------------------------------- render --------------------------------- */\n\n  const transition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, ...spring };\n  const activeTile = tiles.find((t) => t.node.id === active) ?? null;\n\n  /** Below the tile if there is room, else above it, else tucked inside its top edge. */\n  const tipPlacement = (t: Tile): React.CSSProperties => {\n    const room = 78;\n    if (H - (t.y + t.h) >= room) return { top: `${(((t.y + t.h) / H) * 100).toFixed(2)}%`, marginTop: 6 };\n    if (t.y >= room) return { bottom: `${(((H - t.y) / H) * 100).toFixed(2)}%`, marginBottom: 6 };\n    return { top: `${((t.y / H) * 100).toFixed(2)}%`, marginTop: 28 };\n  };\n\n  const tileName = (t: Tile) => {\n    const parts = [`${t.node.label}: ${fmt(t.node.value)}`];\n    if (t.owner !== t.node) parts.push(`${pctText(t.node.value, t.owner.value)} of ${t.owner.label}`);\n    parts.push(`${pctText(t.node.value, root.value)} of total`);\n    return parts.join(\", \");\n  };\n\n  const renderTree = (n: Node): React.ReactNode => (\n    <li key={n.id}>\n      {n.label}: {fmt(n.value)} ({pctText(n.value, n.parent?.value ?? n.value)} of {n.parent?.label ?? \"total\"})\n      {n.children.length > 0 && <ul>{n.children.map(renderTree)}</ul>}\n    </li>\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={{ [\"--tm-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <nav aria-label={`${root.label} path`}>\n        <ol className=\"flex min-w-0 flex-wrap items-center gap-1 font-mono text-[11px] tracking-[0.04em]\">\n          {crumbs.map((n, i) => {\n            const current = i === crumbs.length - 1;\n            return (\n              <li key={n.id} className=\"flex min-w-0 items-center gap-1\">\n                {i > 0 && (\n                  <span aria-hidden=\"true\" className=\"text-zinc-400 dark:text-zinc-600\">\n                    /\n                  </span>\n                )}\n                <button\n                  type=\"button\"\n                  aria-current={current ? \"location\" : undefined}\n                  onClick={() => !current && drillTo(n, crumbs[i + 1]?.id ?? null)}\n                  className={`max-w-[16ch] truncate rounded px-1.5 py-0.5 uppercase transition-colors outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[color:var(--tm-accent)] ${\n                    current\n                      ? \"cursor-default bg-zinc-900/[0.06] text-zinc-950 dark:bg-white/10 dark:text-white\"\n                      : \"text-zinc-500 hover:bg-zinc-900/[0.05] hover:text-zinc-950 dark:text-zinc-400 dark:hover:bg-white/[0.07] dark:hover:text-white\"\n                  }`}\n                >\n                  {n.label}\n                </button>\n              </li>\n            );\n          })}\n          <li aria-hidden=\"true\" className=\"ml-auto pl-3 text-zinc-500 tabular-nums dark:text-zinc-400\">\n            {fmt(focusNode.value)}\n          </li>\n        </ol>\n      </nav>\n\n      <div\n        ref={boxRef}\n        role=\"group\"\n        aria-label={`${focusNode.label} treemap`}\n        aria-describedby={hintId}\n        onPointerLeave={(e) => e.pointerType === \"mouse\" && setActive(null)}\n        className=\"relative w-full overflow-hidden rounded-lg bg-zinc-900/[0.04] dark:bg-white/[0.04]\"\n        style={{ height }}\n      >\n        <AnimatePresence initial={false}>\n          {frames\n            .filter((f) => f.node.children.length > 0 && nested)\n            .map((f) => {\n              const from = startRect(f.node);\n              return (\n                <motion.div\n                  key={`frame-${f.node.id}`}\n                  aria-hidden=\"true\"\n                  initial={from ? { ...toPct(from, gap / 2), opacity: 0 } : { ...toPct(f, gap / 2), opacity: 0 }}\n                  animate={{ ...toPct(f, gap / 2), opacity: 1 }}\n                  exit={{ opacity: 0, transition: { duration: reduceMotion ? 0 : 0.15 } }}\n                  transition={transition}\n                  className=\"pointer-events-none absolute border border-zinc-900/10 dark:border-white/10\"\n                  style={{ borderRadius: radius + 2, backgroundColor: `color-mix(in oklab, ${groupColor(f.node.group)} 10%, transparent)` }}\n                >\n                  {f.header && (\n                    <span className=\"absolute inset-x-2 top-[3px] flex items-baseline justify-between gap-2 font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-600 dark:text-zinc-300\">\n                      <span className=\"truncate\">{f.node.label}</span>\n                      <span className=\"shrink-0 tabular-nums opacity-70\">{fmt(f.node.value)}</span>\n                    </span>\n                  )}\n                </motion.div>\n              );\n            })}\n\n          {tiles.map((t) => {\n            const from = startRect(t.node);\n            const big = t.w > 72 && t.h > 38;\n            const tiny = t.w < 34 || t.h < 18;\n            const drillable = t.owner.children.length > 0 && t.owner !== focusNode;\n            return (\n              <motion.button\n                key={`tile-${t.node.id}`}\n                type=\"button\"\n                data-tm-tile={t.node.id}\n                data-tm-owner={t.owner.id}\n                tabIndex={rovingId === t.node.id ? 0 : -1}\n                aria-label={tileName(t)}\n                aria-describedby={hintId}\n                initial={from ? { ...toPct(from, gap / 2), opacity: 0.4 } : { ...toPct(t, gap / 2), opacity: 0, scale: 0.96 }}\n                animate={{ ...toPct(t, gap / 2), opacity: active && active !== t.node.id ? 0.55 : 1, scale: 1 }}\n                exit={{ opacity: 0, transition: { duration: reduceMotion ? 0 : 0.15 } }}\n                transition={transition}\n                onPointerEnter={(e) => e.pointerType === \"mouse\" && setActive(t.node.id)}\n                onFocus={() => {\n                  setTabId(t.node.id);\n                  setActive(t.node.id);\n                }}\n                onBlur={() => setActive(null)}\n                onClick={() => (drillable ? drillDown(t) : setActive((a) => (a === t.node.id ? null : t.node.id)))}\n                onKeyDown={(e) => onTileKeyDown(e, t)}\n                className={`absolute overflow-hidden p-0 text-left text-zinc-950 outline-hidden focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-zinc-950 dark:focus-visible:outline-white ${\n                  drillable ? \"cursor-zoom-in\" : \"cursor-default\"\n                }`}\n                style={{ borderRadius: radius, backgroundColor: tileFill(t.node.group, t.rank) }}\n              >\n                {!tiny && (\n                  <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 flex flex-col justify-between p-[7px] leading-tight\">\n                    <span className={`truncate font-medium tracking-[-0.01em] ${big ? \"text-[13px]\" : \"text-[11px]\"}`}>{t.node.label}</span>\n                    {showValues && big && <span className=\"font-mono text-[10px] tabular-nums text-zinc-950/70\">{fmt(t.node.value)}</span>}\n                  </span>\n                )}\n              </motion.button>\n            );\n          })}\n        </AnimatePresence>\n\n        {activeTile && (\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute z-20 w-max max-w-[220px] -translate-x-1/2 rounded-md border border-zinc-900/10 bg-white/95 px-2.5 py-2 text-left shadow-[0_10px_30px_-12px_rgb(0_0_0/0.4)] dark:border-white/10 dark:bg-zinc-900/95\"\n            style={{\n              left: `${Math.min(84, Math.max(16, ((activeTile.x + activeTile.w / 2) / W) * 100)).toFixed(2)}%`,\n              ...tipPlacement(activeTile),\n            }}\n          >\n            <span className=\"block font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400\">\n              {pathTo(activeTile.node)\n                .slice(1, -1)\n                .map((n) => n.label)\n                .join(\" / \") || root.label}\n            </span>\n            <span className=\"block text-sm font-semibold tracking-[-0.01em]\">{activeTile.node.label}</span>\n            <span className=\"mt-0.5 block font-mono text-[11px] tabular-nums text-zinc-600 dark:text-zinc-300\">\n              {fmt(activeTile.node.value)} · {pctText(activeTile.node.value, root.value)} of total\n            </span>\n          </div>\n        )}\n      </div>\n\n      <p id={hintId} className=\"sr-only\">\n        Arrow keys move between tiles. Enter opens a tile&apos;s group; Escape or Backspace goes up a level.\n      </p>\n      <p className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </p>\n      <div className=\"sr-only\">\n        <p>{root.label}: full hierarchy</p>\n        <ul>{renderTree(root)}</ul>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/treemap"
}
