Skip to main content

ns-ui

Treemap ASCII Partition

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.

Usage

registry/core/treemap-ascii-partition/demo.tsx
"use client";

import { AsciiPartition } from "./component";

export default function AsciiPartitionDemo() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / treemap-ascii-partition
      </p>

      <div className="inline-block rounded-[12px] border border-border bg-background p-6">
        <AsciiPartition />
      </div>

      <p className="max-w-md text-center text-xs text-ns-muted">
        Click a rectangle to descend a level; density is an ASCII ramp keyed
        to value. Use the breadcrumb or Escape to climb back out.
      </p>
    </div>
  );
}

Component

registry/core/treemap-ascii-partition/component.tsx
"use client";

import { useEffect, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// AsciiPartition — a recursive slice-and-dice treemap where every cell's
// interior is an ASCII density ramp (" .:-=+*#%@") rather than a colour
// scale: darker/denser glyph runs read as "bigger share", exactly the way
// heatmap-year-stipple reads density instead of hue, but as filled rectangles
// rather than dot scatter. The mechanic (not just the picture): clicking a
// rectangle that has children DESCENDS into it — the partition recomputes
// from scratch over just that rectangle's children, filling the whole plot
// again (never a shrinking inset), and a breadcrumb trail above it can climb
// back out to any ancestor level, or straight to Root. Split axis alternates
// with depth (horizontal at the root, vertical one level down, and so on),
// the classic slice-and-dice rule, computed with cumulative-rounded
// boundaries so adjacent cells always share an exact edge — never a stray
// 1px gap or overlap from independently rounding each cell's own width.
// ---------------------------------------------------------------------------

export interface TreemapNode {
  id: string;
  label: string;
  value: number;
  children?: TreemapNode[];
}

export interface AsciiPartitionProps {
  /** the treemap's nested nodes */
  data?: TreemapNode[];
  /** extra classes merged onto the rendered root element */
  className?: string;
}

const ASCII_RAMP = " .:-=+*#%@";

const COLS = 34;
const ROWS = 15;
const CELL_W = 11;
const CELL_H = 15;

const DEFAULT_DATA: TreemapNode[] = [
  {
    id: "alpha",
    label: "Cluster Alpha",
    value: 420,
    children: [
      { id: "alpha-1", label: "Shard 1", value: 180 },
      { id: "alpha-2", label: "Shard 2", value: 140 },
      { id: "alpha-3", label: "Shard 3", value: 100 },
    ],
  },
  {
    id: "beta",
    label: "Cluster Beta",
    value: 260,
    children: [
      { id: "beta-1", label: "Shard 1", value: 150 },
      { id: "beta-2", label: "Shard 2", value: 110 },
    ],
  },
  {
    id: "gamma",
    label: "Cluster Gamma",
    value: 180,
    children: [
      { id: "gamma-1", label: "Shard 1", value: 95 },
      { id: "gamma-2", label: "Shard 2", value: 55 },
      { id: "gamma-3", label: "Shard 3", value: 30 },
    ],
  },
  { id: "delta", label: "Cluster Delta", value: 140 },
];

interface Rect {
  node: TreemapNode;
  x: number;
  y: number;
  w: number;
  h: number;
}

// Cumulative-rounded slice-and-dice: each boundary is derived from the
// running total so far, never from rounding one segment's own share in
// isolation — that is what keeps neighboring cells' edges flush.
function layoutSlice(nodes: TreemapNode[], x: number, y: number, w: number, h: number, dir: "h" | "v"): Rect[] {
  const total = nodes.reduce((s, n) => s + Math.max(0, n.value), 0) || 1;
  const axisSize = dir === "h" ? w : h;
  const start = dir === "h" ? x : y;
  let acc = 0;
  let cursor = start;
  return nodes.map((node) => {
    acc += Math.max(0, node.value);
    const next = start + Math.round((acc / total) * axisSize);
    const size = Math.max(1, next - cursor);
    const rect: Rect =
      dir === "h" ? { node, x: cursor, y, w: size, h } : { node, x, y: cursor, w, h: size };
    cursor = next;
    return rect;
  });
}

// This used to read --foreground/--background/etc via getComputedStyle at
// mount, hold them in state, and apply the result as raw hex through inline
// `style` — a real bug, not a style choice, see diagram-ascii-flow's
// component.tsx for the full story. Short version: SSR always renders with
// no `document`, so the FIRST markup bakes in the light-theme fallback hex,
// and because the client-computed value never CHANGES across renders (this
// component recomputes the same "correct" dark hex every time), React never
// patches it into the DOM — it only writes an attribute when the new
// render's value differs from the PREVIOUS render's, not from what's
// actually painted. Tailwind classes bound to the same custom properties
// sidestep the whole bug class: the cascade resolves --background per theme
// at PAINT time, no JS or hydration step involved.

function buildMap(nodes: TreemapNode[], map: Map<string, TreemapNode>) {
  for (const n of nodes) {
    map.set(n.id, n);
    if (n.children) buildMap(n.children, map);
  }
}

function rampLine(char: string, count: number): string {
  return char.repeat(Math.max(0, count));
}

export function AsciiPartition({ data = DEFAULT_DATA, className = "" }: AsciiPartitionProps) {
  const [path, setPath] = useState<string[]>([]);
  const [hoverId, setHoverId] = useState<string | null>(null);
  const [focusIndex, setFocusIndex] = useState(0);
  const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({});

  const byId = useMemo(() => {
    const map = new Map<string, TreemapNode>();
    buildMap(data, map);
    return map;
  }, [data]);

  const siblings = useMemo(() => {
    if (path.length === 0) return data;
    const parent = byId.get(path[path.length - 1]);
    return parent?.children ?? [];
  }, [path, byId, data]);

  const dir: "h" | "v" = path.length % 2 === 0 ? "h" : "v";
  const rects = useMemo(() => layoutSlice(siblings, 0, 0, COLS, ROWS, dir), [siblings, dir]);
  const maxValue = useMemo(() => Math.max(1, ...siblings.map((n) => n.value)), [siblings]);

  useEffect(() => {
    setFocusIndex(0);
  }, [path]);

  const crumbLabels = path.map((id) => byId.get(id)?.label ?? id);

  const ascendTo = (depth: number) => {
    setPath((p) => p.slice(0, depth));
  };

  const descend = (node: TreemapNode) => {
    if (!node.children?.length) return;
    setPath((p) => [...p, node.id]);
  };

  const moveFocus = (delta: number) => {
    if (rects.length === 0) return;
    const next = Math.max(0, Math.min(rects.length - 1, focusIndex + delta));
    setFocusIndex(next);
    btnRefs.current[rects[next].node.id]?.focus();
  };

  return (
    <div className={`ns-tap font-mono ${className}`}>
      <style>{CSS}</style>

      <div className="mb-2 flex flex-wrap items-center gap-1 text-xs">
        <button
          type="button"
          onClick={() => ascendTo(0)}
          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"
          aria-current={path.length === 0 ? "true" : undefined}
        >
          Root
        </button>
        {crumbLabels.map((label, i) => (
          <span key={i} className="flex items-center gap-1">
            <span aria-hidden className="text-border">
              /
            </span>
            <button
              type="button"
              onClick={() => ascendTo(i + 1)}
              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"
              aria-current={i === crumbLabels.length - 1 ? "true" : undefined}
            >
              {label}
            </button>
          </span>
        ))}
        {path.length > 0 && (
          <button
            type="button"
            data-treemap-up
            onClick={() => ascendTo(path.length - 1)}
            aria-label={`Up one level to ${crumbLabels.length > 1 ? crumbLabels[crumbLabels.length - 2] : "Root"}`}
            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"
          >
            &lt; Up
          </button>
        )}
      </div>

      <div
        className="relative"
        style={{ width: COLS * CELL_W, height: ROWS * CELL_H, maxWidth: "100%" }}
        onKeyDown={(e) => {
          if (e.key === "ArrowRight" || e.key === "ArrowDown") {
            e.preventDefault();
            moveFocus(1);
          } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
            e.preventDefault();
            moveFocus(-1);
          } else if (e.key === "Escape" && path.length > 0) {
            e.preventDefault();
            ascendTo(path.length - 1);
          }
        }}
      >
        {rects.map((r, i) => {
          const level = Math.round((r.node.value / maxValue) * (ASCII_RAMP.length - 1));
          const ch = ASCII_RAMP[Math.max(1, level)];
          const hasChildren = !!r.node.children?.length;
          const hovered = hoverId === r.node.id;
          // r.w / r.h are already whole grid-cell counts; approximate one
          // character per cell width at this font size/line-height pairing.
          const lines = r.h;
          const lineText = rampLine(ch, r.w);

          return (
            <button
              key={r.node.id}
              type="button"
              data-treemap-rect
              ref={(el) => {
                btnRefs.current[r.node.id] = el;
              }}
              tabIndex={i === focusIndex ? 0 : -1}
              onFocus={() => setFocusIndex(i)}
              onPointerEnter={() => setHoverId(r.node.id)}
              onPointerLeave={() => setHoverId((c) => (c === r.node.id ? null : c))}
              onClick={() => descend(r.node)}
              aria-label={`${r.node.label}: ${r.node.value.toLocaleString()}${
                hasChildren ? ", press Enter to open" : ""
              }`}
              className={`ns-tap-rect absolute overflow-hidden border bg-background text-left transition-colors duration-150 motion-reduce:transition-none ${
                hovered ? "border-ns-accent/40" : "border-border"
              } ${hasChildren ? "cursor-pointer" : "cursor-default"}`}
              style={{
                left: r.x * CELL_W,
                top: r.y * CELL_H,
                width: r.w * CELL_W,
                height: r.h * CELL_H,
              }}
            >
              <div
                aria-hidden
                className={`absolute inset-0 select-none overflow-hidden whitespace-pre leading-[15px] ${
                  hovered ? "text-foreground" : "text-ns-muted"
                }`}
                style={{ fontSize: 10 }}
              >
                {Array.from({ length: lines }).map((_, row) => (
                  <div key={row}>{lineText}</div>
                ))}
              </div>
              <div
                aria-hidden
                className="relative z-10 truncate bg-background/78 px-1 py-0.5 text-[10px] text-foreground"
              >
                {r.node.label}
                <span className="ml-1 text-ns-muted">{r.node.value.toLocaleString()}</span>
              </div>
            </button>
          );
        })}
      </div>
    </div>
  );
}

const CSS = `
.ns-tap-rect:focus-visible { outline: 2px solid var(--ns-accent); outline-offset: -2px; }
.ns-tap-crumb:focus-visible { outline: 2px solid var(--ns-accent); outline-offset: 2px; }
`;

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/treemap-ascii-partition.tsx, so point the import there.

Use when showing a hierarchy whose PROPORTIONS matter at a glance (disk/budget/traffic share nested one or two levels deep) and letting the viewer drill into one branch without losing the whole picture — reach for drill-down-spines instead for a linear stack of full pages rather than proportional rectangles, or grid-bento-dense for a fixed editorial layout with one always-featured cell rather than a value-proportional partition.

Install

npx shadcn add https://design.helpmarq.com/r/treemap-ascii-partition.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

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.

Props

PropTypeDefaultDescription
data?TreemapNode[]DEFAULT_DATAthe treemap's nested nodes
className?string—extra classes merged onto the rendered root element