{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "treemap",
  "title": "Treemap",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A dependency-free treemap: each item is a rectangle sized by its share of the whole in a squarified layout, with real text on the cells, group colours and a legend with totals, and arrow keys that move to the nearest cell.",
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/direction.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/treemap.tsx",
      "content": "// Ballmac UI: Treemap. https://ui.ballmac.com/components/treemap\n\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useDirection } from \"@/lib/ballmac/direction\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype TreemapItem = {\n  id: string\n  label: string\n  /** The size, in any unit. Items with a value of zero or less are left out. */\n  value: number\n  /** Items in the same group share a colour and a line in the legend. */\n  group?: string\n  /** A line shown under the chart when the item is selected. */\n  detail?: string\n}\n\ntype Rect = { x: number; y: number; w: number; h: number }\n\n/** The squarified layout (Bruls, Huizing and van Wijk): rows of items kept as close to square as the shape allows. Sizes are in the units of w and h. */\nfunction squarify(items: { id: string; value: number }[], w: number, h: number): Record<string, Rect> {\n  const total = items.reduce((n, i) => n + i.value, 0)\n  const out: Record<string, Rect> = {}\n  if (total <= 0 || w <= 0 || h <= 0) return out\n  const scale = (w * h) / total\n  const rest = [...items].sort((a, b) => b.value - a.value).map((i) => ({ id: i.id, area: i.value * scale }))\n  let x = 0\n  let y = 0\n  let rw = w\n  let rh = h\n  let row: typeof rest = []\n  const worst = (r: typeof rest, side: number) => {\n    const s = r.reduce((n, i) => n + i.area, 0)\n    const max = Math.max(...r.map((i) => i.area))\n    const min = Math.min(...r.map((i) => i.area))\n    return Math.max((side * side * max) / (s * s), (s * s) / (side * side * min))\n  }\n  const place = (r: typeof rest) => {\n    const s = r.reduce((n, i) => n + i.area, 0)\n    if (rw >= rh) {\n      const colW = s / rh\n      let cy = y\n      for (const i of r) {\n        const ih = i.area / colW\n        out[i.id] = { x, y: cy, w: colW, h: ih }\n        cy += ih\n      }\n      x += colW\n      rw -= colW\n    } else {\n      const rowH = s / rw\n      let cx = x\n      for (const i of r) {\n        const iw = i.area / rowH\n        out[i.id] = { x: cx, y, w: iw, h: rowH }\n        cx += iw\n      }\n      y += rowH\n      rh -= rowH\n    }\n  }\n  while (rest.length) {\n    const side = Math.min(rw, rh)\n    const next = rest[0]\n    if (row.length === 0 || worst([...row, next], side) <= worst(row, side)) {\n      row.push(next)\n      rest.shift()\n    } else {\n      place(row)\n      row = []\n    }\n  }\n  if (row.length) place(row)\n  return out\n}\n\nconst TINTS = [1, 2, 3, 4, 5]\n\ntype TreemapProps = Omit<React.ComponentProps<\"div\">, \"onSelect\"> & {\n  items: TreemapItem[]\n  /** Turns a value into text. */\n  format?: (value: number) => string\n  /** Width divided by height of the chart. */\n  aspect?: number\n  /** The selected item id. Controlled. */\n  selected?: string | null\n  /** The selected item id for uncontrolled use. */\n  defaultSelected?: string | null\n  /** Called when an item is chosen, or null when it is cleared. */\n  onSelectedChange?: (id: string | null) => void\n  /** What the sizes mean, for assistive technology, for example \"tokens\". */\n  unit?: string\n}\n\n/**\n * A treemap: every item is a rectangle whose area is its share of the whole, laid out so the rectangles stay close to square. Cells are buttons with real\n * text (name, value and share), the arrow keys move to the nearest cell in that direction, and groups get a colour and a legend line with their total.\n */\nfunction Treemap({ items, format = (v) => v.toLocaleString(\"en-US\"), aspect = 16 / 9, selected: selectedProp, defaultSelected = null, onSelectedChange, unit, dir, className, ...props }: TreemapProps) {\n  const msg = useMessages()\n  const direction = useDirection(dir === \"rtl\" || dir === \"ltr\" ? dir : undefined)\n  const [inner, setInner] = React.useState<string | null>(defaultSelected)\n  const selected = selectedProp !== undefined ? selectedProp : inner\n  const data = React.useMemo(() => items.filter((i) => i.value > 0), [items])\n  const total = data.reduce((n, i) => n + i.value, 0)\n  const W = 100 * aspect\n  const H = 100\n  const rects = React.useMemo(() => squarify(data, W, H), [data, W])\n  const groups = React.useMemo(() => {\n    const list: string[] = []\n    for (const i of data) if (i.group && !list.includes(i.group)) list.push(i.group)\n    return list\n  }, [data])\n  const tint = (i: TreemapItem) => (i.group ? TINTS[groups.indexOf(i.group) % TINTS.length] : 1)\n  const ordered = [...data].sort((a, b) => b.value - a.value)\n  const [focus, setFocus] = React.useState<string | null>(null)\n  const tabbable = (focus && data.some((i) => i.id === focus) ? focus : selected && data.some((i) => i.id === selected) ? selected : ordered[0]?.id) ?? null\n  const refs = React.useRef(new Map<string, HTMLButtonElement>())\n  const chosen = data.find((i) => i.id === selected)\n\n  function pick(id: string) {\n    const next = id === selected ? null : id\n    if (selectedProp === undefined) setInner(next)\n    onSelectedChange?.(next)\n  }\n  function onKeyDown(e: React.KeyboardEvent, id: string) {\n    const me = rects[id]\n    if (!me) return\n    const cx = me.x + me.w / 2\n    const cy = me.y + me.h / 2\n    const rtl = direction === \"rtl\"\n    const dirs: Record<string, [number, number]> = { ArrowRight: [rtl ? -1 : 1, 0], ArrowLeft: [rtl ? 1 : -1, 0], ArrowDown: [0, 1], ArrowUp: [0, -1] }\n    if (e.key === \"Home\" || e.key === \"End\") {\n      e.preventDefault()\n      const to = e.key === \"Home\" ? ordered[0] : ordered[ordered.length - 1]\n      setFocus(to.id)\n      refs.current.get(to.id)?.focus()\n      return\n    }\n    const d = dirs[e.key]\n    if (!d) return\n    e.preventDefault()\n    // The nearest cell whose centre lies in that direction, preferring ones that line up with this cell.\n    let best: { id: string; score: number } | null = null\n    for (const o of data) {\n      if (o.id === id) continue\n      const r = rects[o.id]\n      const dx = r.x + r.w / 2 - cx\n      const dy = r.y + r.h / 2 - cy\n      const along = dx * d[0] + dy * d[1]\n      if (along <= 0.001) continue\n      const across = Math.abs(dx * d[1]) + Math.abs(dy * d[0])\n      const score = along + across * 2\n      if (!best || score < best.score) best = { id: o.id, score }\n    }\n    if (best) {\n      setFocus(best.id)\n      refs.current.get(best.id)?.focus()\n    }\n  }\n\n  return (\n    <div data-slot=\"treemap\" dir={dir} className={cn(\"grid w-full min-w-0 gap-3\", className)} {...props}>\n      <div className=\"relative w-full overflow-hidden rounded-xl border bg-card\" style={{ aspectRatio: String(aspect) }} role=\"group\" aria-label={msg(\"treemap.label\", \"Share of the total by item\")}>\n        {data.map((i) => {\n          const r = rects[i.id]\n          if (!r) return null\n          const share = (i.value / total) * 100\n          const on = selected === i.id\n          const big = (r.w * r.h) / (W * H) > 0.045\n          const mid = (r.w * r.h) / (W * H) > 0.015\n          return (\n            <button\n              key={i.id}\n              ref={(el) => {\n                if (el) refs.current.set(i.id, el)\n                else refs.current.delete(i.id)\n              }}\n              type=\"button\"\n              aria-pressed={on}\n              tabIndex={tabbable === i.id ? 0 : -1}\n              aria-label={msg(\"treemap.cell\", \"{label}: {value}{unit}, {share}% of the total{group}\", { label: i.label, value: format(i.value), unit: unit ? ` ${unit}` : \"\", share: Math.round(share * 10) / 10, group: i.group ? `, ${i.group}` : \"\" })}\n              onClick={() => pick(i.id)}\n              onFocus={() => setFocus(i.id)}\n              onKeyDown={(e) => onKeyDown(e, i.id)}\n              className={cn(\"absolute flex flex-col items-start justify-between overflow-hidden rounded-md p-2 text-start text-foreground outline-none transition-shadow focus-visible:z-10 focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none\", on ? \"z-10 shadow-[inset_0_0_0_2px_var(--foreground)]\" : \"hover:shadow-[inset_0_0_0_1.5px_var(--foreground)]\")}\n              style={{\n                insetInlineStart: `calc(${(r.x / W) * 100}% + 1.5px)`,\n                top: `calc(${(r.y / H) * 100}% + 1.5px)`,\n                width: `calc(${(r.w / W) * 100}% - 3px)`,\n                height: `calc(${(r.h / H) * 100}% - 3px)`,\n                backgroundColor: `color-mix(in oklab, var(--chart-${tint(i)}) 30%, var(--card))`,\n              }}\n            >\n              {mid && <span className={cn(\"w-full truncate font-medium\", big ? \"text-sm\" : \"text-xs\")}>{i.label}</span>}\n              {big && (\n                <span className=\"text-xs tabular-nums text-foreground/80\">\n                  {format(i.value)} <span>· {Math.round(share)}%</span>\n                </span>\n              )}\n            </button>\n          )\n        })}\n      </div>\n\n      <div aria-live=\"polite\" className=\"min-h-[2.5rem] text-sm\">\n        {chosen ? (\n          <p>\n            <span className=\"font-medium\">{chosen.label}</span> <span className=\"text-muted-foreground tabular-nums\">· {format(chosen.value)}{unit ? ` ${unit}` : \"\"} · {msg(\"treemap.share\", \"{percent}% of the total\", { percent: Math.round((chosen.value / total) * 1000) / 10 })}</span>\n            {chosen.detail && <span className=\"mt-0.5 block text-muted-foreground\">{chosen.detail}</span>}\n          </p>\n        ) : (\n          <p className=\"text-muted-foreground\">{msg(\"treemap.hint\", \"Select a block to see its share.\")}</p>\n        )}\n      </div>\n\n      {groups.length > 0 && (\n        <ul className=\"flex flex-wrap gap-x-5 gap-y-1.5 text-xs\" aria-label={msg(\"treemap.groups\", \"Groups\")}>\n          {groups.map((g, gi) => {\n            const sum = data.filter((i) => i.group === g).reduce((n, i) => n + i.value, 0)\n            return (\n              <li key={g} className=\"flex items-center gap-2\">\n                <span aria-hidden=\"true\" className=\"size-3 rounded-sm border\" style={{ backgroundColor: `color-mix(in oklab, var(--chart-${TINTS[gi % TINTS.length]}) 30%, var(--card))` }} />\n                <span className=\"font-medium\">{g}</span>\n                <span className=\"text-muted-foreground tabular-nums\">{format(sum)} · {Math.round((sum / total) * 100)}%</span>\n              </li>\n            )\n          })}\n        </ul>\n      )}\n    </div>\n  )\n}\n\nexport { Treemap, squarify, type Rect, type TreemapItem, type TreemapProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/treemap.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "treemap",
      "chart",
      "proportion",
      "share",
      "composition",
      "context window",
      "disk usage"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "items is [{ id, label, value, group?, detail? }]; zero or negative values are dropped. format(value), unit, aspect (width over height, default 16/9), selected / defaultSelected / onSelectedChange. Exports squarify(items, w, h) for your own layouts.",
      "whenToUse": [
        "Showing how a whole divides into many parts of very different size",
        "Disk, cost, token or traffic breakdowns"
      ],
      "whenNotToUse": [
        "A handful of parts; use chart-pie or a bar list",
        "A change over time; use chart-area",
        "Flows between steps; use flow-sankey"
      ],
      "composesWith": [
        "token-meter",
        "run-ledger",
        "chart-frame",
        "heat-grid"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Enters the chart once, on the largest cell (or the selected one)"
        },
        {
          "keys": "Arrow keys",
          "action": "Move to the nearest cell in that direction (left and right swap in right-to-left); Home and End go to the largest and smallest"
        },
        {
          "keys": "Enter / Space",
          "action": "Selects the cell; again to clear it"
        },
        {
          "keys": "Screen readers",
          "action": "Each cell reads 'Compute: 4,210 USD, 38.2% of the total, Production'; the selected item is a polite live region"
        }
      ],
      "customization": [
        "aspect",
        "format and unit",
        "group colours follow the order groups first appear (chart 1 to 5)",
        "squarify is exported"
      ]
    },
    "statesNone": "A presentational chart: you pass the items in, so it has nothing of its own to load or fail.",
    "examples": [
      "treemap-demo",
      "treemap-context"
    ],
    "url": "https://ui.ballmac.com/components/treemap"
  },
  "categories": [
    "data-display"
  ],
  "type": "registry:ui"
}