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.
npx shadcn add https://design.helpmarq.com /r/treemap-ascii-partition.jsonregistry/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"
>
< 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; }
`;
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 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.