{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "treemap-ascii-partition",
  "title": "Treemap ASCII Partition",
  "description": "A recursive slice-and-dice treemap where every rectangle's interior is filled with an ASCII density ramp keyed to its value instead of a colour scale. Clicking a rectangle with children descends into it, recomputing the partition over just its children; a breadcrumb (or Escape) climbs back out to any ancestor.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/treemap-ascii-partition/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// AsciiPartition — a recursive slice-and-dice treemap where every cell's\n// interior is an ASCII density ramp (\" .:-=+*#%@\") rather than a colour\n// scale: darker/denser glyph runs read as \"bigger share\", exactly the way\n// heatmap-year-stipple reads density instead of hue, but as filled rectangles\n// rather than dot scatter. The mechanic (not just the picture): clicking a\n// rectangle that has children DESCENDS into it — the partition recomputes\n// from scratch over just that rectangle's children, filling the whole plot\n// again (never a shrinking inset), and a breadcrumb trail above it can climb\n// back out to any ancestor level, or straight to Root. Split axis alternates\n// with depth (horizontal at the root, vertical one level down, and so on),\n// the classic slice-and-dice rule, computed with cumulative-rounded\n// boundaries so adjacent cells always share an exact edge — never a stray\n// 1px gap or overlap from independently rounding each cell's own width.\n// ---------------------------------------------------------------------------\n\nexport interface TreemapNode {\n  id: string;\n  label: string;\n  value: number;\n  children?: TreemapNode[];\n}\n\nexport interface AsciiPartitionProps {\n  /** the treemap's nested nodes */\n  data?: TreemapNode[];\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst ASCII_RAMP = \" .:-=+*#%@\";\n\nconst COLS = 34;\nconst ROWS = 15;\nconst CELL_W = 11;\nconst CELL_H = 15;\n\nconst DEFAULT_DATA: TreemapNode[] = [\n  {\n    id: \"alpha\",\n    label: \"Cluster Alpha\",\n    value: 420,\n    children: [\n      { id: \"alpha-1\", label: \"Shard 1\", value: 180 },\n      { id: \"alpha-2\", label: \"Shard 2\", value: 140 },\n      { id: \"alpha-3\", label: \"Shard 3\", value: 100 },\n    ],\n  },\n  {\n    id: \"beta\",\n    label: \"Cluster Beta\",\n    value: 260,\n    children: [\n      { id: \"beta-1\", label: \"Shard 1\", value: 150 },\n      { id: \"beta-2\", label: \"Shard 2\", value: 110 },\n    ],\n  },\n  {\n    id: \"gamma\",\n    label: \"Cluster Gamma\",\n    value: 180,\n    children: [\n      { id: \"gamma-1\", label: \"Shard 1\", value: 95 },\n      { id: \"gamma-2\", label: \"Shard 2\", value: 55 },\n      { id: \"gamma-3\", label: \"Shard 3\", value: 30 },\n    ],\n  },\n  { id: \"delta\", label: \"Cluster Delta\", value: 140 },\n];\n\ninterface Rect {\n  node: TreemapNode;\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n}\n\n// Cumulative-rounded slice-and-dice: each boundary is derived from the\n// running total so far, never from rounding one segment's own share in\n// isolation — that is what keeps neighboring cells' edges flush.\nfunction layoutSlice(nodes: TreemapNode[], x: number, y: number, w: number, h: number, dir: \"h\" | \"v\"): Rect[] {\n  const total = nodes.reduce((s, n) => s + Math.max(0, n.value), 0) || 1;\n  const axisSize = dir === \"h\" ? w : h;\n  const start = dir === \"h\" ? x : y;\n  let acc = 0;\n  let cursor = start;\n  return nodes.map((node) => {\n    acc += Math.max(0, node.value);\n    const next = start + Math.round((acc / total) * axisSize);\n    const size = Math.max(1, next - cursor);\n    const rect: Rect =\n      dir === \"h\" ? { node, x: cursor, y, w: size, h } : { node, x, y: cursor, w, h: size };\n    cursor = next;\n    return rect;\n  });\n}\n\n// This used to read --foreground/--background/etc via getComputedStyle at\n// mount, hold them in state, and apply the result as raw hex through inline\n// `style` — a real bug, not a style choice, see diagram-ascii-flow's\n// component.tsx for the full story. Short version: SSR always renders with\n// no `document`, so the FIRST markup bakes in the light-theme fallback hex,\n// and because the client-computed value never CHANGES across renders (this\n// component recomputes the same \"correct\" dark hex every time), React never\n// patches it into the DOM — it only writes an attribute when the new\n// render's value differs from the PREVIOUS render's, not from what's\n// actually painted. Tailwind classes bound to the same custom properties\n// sidestep the whole bug class: the cascade resolves --background per theme\n// at PAINT time, no JS or hydration step involved.\n\nfunction buildMap(nodes: TreemapNode[], map: Map<string, TreemapNode>) {\n  for (const n of nodes) {\n    map.set(n.id, n);\n    if (n.children) buildMap(n.children, map);\n  }\n}\n\nfunction rampLine(char: string, count: number): string {\n  return char.repeat(Math.max(0, count));\n}\n\nexport function AsciiPartition({ data = DEFAULT_DATA, className = \"\" }: AsciiPartitionProps) {\n  const [path, setPath] = useState<string[]>([]);\n  const [hoverId, setHoverId] = useState<string | null>(null);\n  const [focusIndex, setFocusIndex] = useState(0);\n  const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({});\n\n  const byId = useMemo(() => {\n    const map = new Map<string, TreemapNode>();\n    buildMap(data, map);\n    return map;\n  }, [data]);\n\n  const siblings = useMemo(() => {\n    if (path.length === 0) return data;\n    const parent = byId.get(path[path.length - 1]);\n    return parent?.children ?? [];\n  }, [path, byId, data]);\n\n  const dir: \"h\" | \"v\" = path.length % 2 === 0 ? \"h\" : \"v\";\n  const rects = useMemo(() => layoutSlice(siblings, 0, 0, COLS, ROWS, dir), [siblings, dir]);\n  const maxValue = useMemo(() => Math.max(1, ...siblings.map((n) => n.value)), [siblings]);\n\n  useEffect(() => {\n    setFocusIndex(0);\n  }, [path]);\n\n  const crumbLabels = path.map((id) => byId.get(id)?.label ?? id);\n\n  const ascendTo = (depth: number) => {\n    setPath((p) => p.slice(0, depth));\n  };\n\n  const descend = (node: TreemapNode) => {\n    if (!node.children?.length) return;\n    setPath((p) => [...p, node.id]);\n  };\n\n  const moveFocus = (delta: number) => {\n    if (rects.length === 0) return;\n    const next = Math.max(0, Math.min(rects.length - 1, focusIndex + delta));\n    setFocusIndex(next);\n    btnRefs.current[rects[next].node.id]?.focus();\n  };\n\n  return (\n    <div className={`ns-tap font-mono ${className}`}>\n      <style>{CSS}</style>\n\n      <div className=\"mb-2 flex flex-wrap items-center gap-1 text-xs\">\n        <button\n          type=\"button\"\n          onClick={() => ascendTo(0)}\n          className=\"ns-tap-crumb rounded-sm px-1.5 py-0.5 text-ns-muted transition-colors duration-150 motion-reduce:transition-none hover:text-foreground\"\n          aria-current={path.length === 0 ? \"true\" : undefined}\n        >\n          Root\n        </button>\n        {crumbLabels.map((label, i) => (\n          <span key={i} className=\"flex items-center gap-1\">\n            <span aria-hidden className=\"text-border\">\n              /\n            </span>\n            <button\n              type=\"button\"\n              onClick={() => ascendTo(i + 1)}\n              className=\"ns-tap-crumb rounded-sm px-1.5 py-0.5 text-ns-muted transition-colors duration-150 motion-reduce:transition-none hover:text-foreground\"\n              aria-current={i === crumbLabels.length - 1 ? \"true\" : undefined}\n            >\n              {label}\n            </button>\n          </span>\n        ))}\n        {path.length > 0 && (\n          <button\n            type=\"button\"\n            data-treemap-up\n            onClick={() => ascendTo(path.length - 1)}\n            aria-label={`Up one level to ${crumbLabels.length > 1 ? crumbLabels[crumbLabels.length - 2] : \"Root\"}`}\n            className=\"ns-tap-crumb ml-2 rounded-sm border border-border px-1.5 py-0.5 text-ns-muted transition-colors duration-150 motion-reduce:transition-none hover:text-foreground hover:border-ns-accent/40\"\n          >\n            &lt; Up\n          </button>\n        )}\n      </div>\n\n      <div\n        className=\"relative\"\n        style={{ width: COLS * CELL_W, height: ROWS * CELL_H, maxWidth: \"100%\" }}\n        onKeyDown={(e) => {\n          if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") {\n            e.preventDefault();\n            moveFocus(1);\n          } else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") {\n            e.preventDefault();\n            moveFocus(-1);\n          } else if (e.key === \"Escape\" && path.length > 0) {\n            e.preventDefault();\n            ascendTo(path.length - 1);\n          }\n        }}\n      >\n        {rects.map((r, i) => {\n          const level = Math.round((r.node.value / maxValue) * (ASCII_RAMP.length - 1));\n          const ch = ASCII_RAMP[Math.max(1, level)];\n          const hasChildren = !!r.node.children?.length;\n          const hovered = hoverId === r.node.id;\n          // r.w / r.h are already whole grid-cell counts; approximate one\n          // character per cell width at this font size/line-height pairing.\n          const lines = r.h;\n          const lineText = rampLine(ch, r.w);\n\n          return (\n            <button\n              key={r.node.id}\n              type=\"button\"\n              data-treemap-rect\n              ref={(el) => {\n                btnRefs.current[r.node.id] = el;\n              }}\n              tabIndex={i === focusIndex ? 0 : -1}\n              onFocus={() => setFocusIndex(i)}\n              onPointerEnter={() => setHoverId(r.node.id)}\n              onPointerLeave={() => setHoverId((c) => (c === r.node.id ? null : c))}\n              onClick={() => descend(r.node)}\n              aria-label={`${r.node.label}: ${r.node.value.toLocaleString()}${\n                hasChildren ? \", press Enter to open\" : \"\"\n              }`}\n              className={`ns-tap-rect absolute overflow-hidden border bg-background text-left transition-colors duration-150 motion-reduce:transition-none ${\n                hovered ? \"border-ns-accent/40\" : \"border-border\"\n              } ${hasChildren ? \"cursor-pointer\" : \"cursor-default\"}`}\n              style={{\n                left: r.x * CELL_W,\n                top: r.y * CELL_H,\n                width: r.w * CELL_W,\n                height: r.h * CELL_H,\n              }}\n            >\n              <div\n                aria-hidden\n                className={`absolute inset-0 select-none overflow-hidden whitespace-pre leading-[15px] ${\n                  hovered ? \"text-foreground\" : \"text-ns-muted\"\n                }`}\n                style={{ fontSize: 10 }}\n              >\n                {Array.from({ length: lines }).map((_, row) => (\n                  <div key={row}>{lineText}</div>\n                ))}\n              </div>\n              <div\n                aria-hidden\n                className=\"relative z-10 truncate bg-background/78 px-1 py-0.5 text-[10px] text-foreground\"\n              >\n                {r.node.label}\n                <span className=\"ml-1 text-ns-muted\">{r.node.value.toLocaleString()}</span>\n              </div>\n            </button>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\nconst CSS = `\n.ns-tap-rect:focus-visible { outline: 2px solid var(--ns-accent); outline-offset: -2px; }\n.ns-tap-crumb:focus-visible { outline: 2px solid var(--ns-accent); outline-offset: 2px; }\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/treemap-ascii-partition.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "treemap",
      "partition",
      "chart",
      "data-viz",
      "ascii",
      "hierarchy",
      "drill-down"
    ],
    "instruction": "Build a recursive slice-and-dice treemap from a `data` prop (TreemapNode[], each `{id, label, value, children?}`). At any moment the component renders exactly one level: the root's children, or — once the user has descended — the children of whichever node is currently open. Layout is computed on a fixed 34x15 character-cell grid: `layoutSlice(nodes, x, y, w, h, dir)` walks the sibling list once, accumulating each node's share of the total value and deriving every boundary from the RUNNING cumulative fraction (`start + round((acc/total)*axisSize)`), never from independently rounding one node's own share — that cumulative-rounding is what keeps adjacent cells' edges flush with no 1px gap or overlap. The split axis alternates with depth: horizontal at the root, vertical one level down, horizontal again below that (classic slice-and-dice). Each rectangle is a real, focusable `<button data-treemap-rect>` (never a div with a click handler) sized and positioned from its cell-grid rect, with a plain `border-border` CSS border (crisp rectangle edges) and an interior fill of `ASCII_RAMP = ' .:-=+*#%@'` — the shared dithered-chart-family ramp — repeated across `value/maxValue-at-this-level` many ramp positions as literal monospace text rows (never canvas), so bigger value reads as denser/darker ink fill, not a colour hue. A small label+value badge sits over the fill with a translucent background so it stays legible regardless of density. Clicking (or Enter/Space on) a rectangle whose node HAS children descends: the current path array gains that node's id and the whole grid re-lays-out over just its children, filling the same 34x15 area again — descending is never a shrinking inset of the old rectangle. A leaf rectangle (no children) is still focusable/hoverable but the click is a no-op. A breadcrumb row above the grid shows Root plus every ancestor label, each a real button that truncates the path back to that depth; a dedicated `data-treemap-up` button (aria-label naming the level it returns to) renders ONLY once the path is non-empty, giving one-click access back exactly one level, and Escape does the same. Roving tabindex across the current level's rectangles: ArrowRight/Down and ArrowLeft/Up move focus between siblings by index, both wired through the same underlying focus-index state so keyboard and pointer never fight over which rectangle is 'active'. Hover and keyboard focus are visibly distinct from rest and from each other: hover brightens the border toward `--ns-accent` and the fill ink from `--ns-muted` to `--foreground`; focus additionally gets a `--ns-accent` focus-visible outline. Tokens only — `--background --foreground --ns-muted --border --ns-accent`, applied as Tailwind utility classes (`bg-background`, `border-border`/`border-ns-accent`, `text-ns-muted`/`text-foreground`) bound to the same CSS custom properties, so both themes repaint correctly via the cascade with no JS token reads and no remount. No dependencies, no canvas — pure DOM text + CSS."
  },
  "type": "registry:ui"
}