Treemap ASCII Partition

Treemap

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.

Install
npx shadcn add https://design.helpmarq.com/r/treemap-ascii-partition.json
Source
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 {
  data?: TreemapNode[];
  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-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-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-muted transition-colors duration-150 motion-reduce:transition-none hover:text-foreground hover:border-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-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-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-muted">{r.node.value.toLocaleString()}</span>
              </div>
            </button>
          );
        })}
      </div>
    </div>
  );
}

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

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.

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.

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 `--accent` and the fill ink from `--muted` to `--foreground`; focus additionally gets a `--accent` focus-visible outline. Tokens only — `--background --foreground --muted --border --accent`, applied as Tailwind utility classes (`bg-background`, `border-border`/`border-accent`, `text-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.

Tags
treemappartitionchartdata-vizasciihierarchydrill-down