// Dominator-tree SVG visualization — shared between the unreachable garbage-root
// trees and the leak-suspect dominator subtrees.
//
// Uses d3-dag `graphHierarchy` + `sugiyama` for layout. Nodes are boxes sized
// proportionally to retained heap, labeled with class + retained. Edges are
// bezier curves with arrowheads. Each tree is rendered in its own <svg>.
import React from "react";
import { graphHierarchy, sugiyama, coordGreedy, decrossDfs, layeringLongestPath } from "d3-dag";
import type { DomTreeNode, UnreachableGarbageRoot } from "./types";
import { formatBytes, fmtCount, fmtExactBytes } from "./format";
// ── Layout constants ──────────────────────────────────────────────────────────
const NODE_W = 180;
const NODE_H = 52;
const H_GAP = 40;
const V_GAP = 60;
const FONT_SIZE = 11;
const PALETTE = [
"#2563eb", "#16a34a", "#d97706", "#dc2626",
"#7c3aed", "#0891b2", "#db2777", "#65a30d",
];
// ── Helpers ───────────────────────────────────────────────────────────────────
function classColor(name: string): string {
let h = 0;
for (let i = 0; i < name.length; i++) h = (Math.imul(31, h) + name.charCodeAt(i)) | 0;
return PALETTE[Math.abs(h) % PALETTE.length];
}
function truncateClass(name: string, maxLen = 22): string {
if (name.length <= maxLen) return name;
const short = name.split(".").pop() ?? name;
return short.length <= maxLen ? short : short.slice(0, maxLen - 1) + "…";
}
// ── Generic tree node used by the layout engine ───────────────────────────────
interface GenNode {
label: string; // class name
retained: number;
sublabel: string; // second line (e.g. "N objects" or "shallow X")
children: GenNode[];
objIndex?: number; // dense 0-based index; undefined for garbage roots
}
// ── Tagged wrapper carrying a stable id for d3-dag ───────────────────────────
interface TaggedNode {
id: string;
node: GenNode;
children: TaggedNode[];
}
function tagTree(node: GenNode, prefix: string): TaggedNode {
return {
id: prefix,
node,
children: node.children.map((c, i) => tagTree(c, `${prefix}.${i}`)),
};
}
function countNodes(t: TaggedNode): number {
return 1 + t.children.reduce((s, c) => s + countNodes(c), 0);
}
// If the tree has too many nodes the sugiyama layout becomes very wide.
// Prune to depth 1 (root + direct children, no grandchildren).
const MAX_LAYOUT_NODES = 25;
function pruneToDepth1(t: TaggedNode): TaggedNode {
return { ...t, children: t.children.map((c) => ({ ...c, children: [] })) };
}
// ── Layout ────────────────────────────────────────────────────────────────────
interface LayoutNode { id: string; x: number; y: number; node: GenNode; }
interface LayoutEdge { source: string; target: string; }
function layoutTree(root: GenNode): { nodes: LayoutNode[]; edges: LayoutEdge[]; width: number; height: number } {
let tagged = tagTree(root, "root");
if (countNodes(tagged) > MAX_LAYOUT_NODES) tagged = pruneToDepth1(tagged);
if (tagged.children.length === 0) {
return {
nodes: [{ id: "root", x: NODE_W / 2, y: NODE_H / 2, node: root }],
edges: [],
width: NODE_W + H_GAP,
height: NODE_H + V_GAP,
};
}
try {
const builder = graphHierarchy<TaggedNode>().children((d) => d.children);
const dag = builder(tagged);
const layout = sugiyama()
.layering(layeringLongestPath())
.decross(decrossDfs())
.coord(coordGreedy())
.nodeSize(() => [NODE_W + H_GAP, NODE_H + V_GAP]);
const { width, height } = layout(dag as Parameters<typeof layout>[0]);
const nodes: LayoutNode[] = [];
for (const n of (dag as any).nodes()) {
nodes.push({ id: n.data.id, x: n.x, y: n.y, node: n.data.node });
}
const edges: LayoutEdge[] = [];
for (const link of (dag as any).links()) {
edges.push({ source: link.source.data.id, target: link.target.data.id });
}
return { nodes, edges, width: Math.max(width, NODE_W + H_GAP), height: Math.max(height, NODE_H + V_GAP) };
} catch {
const allNodes: TaggedNode[] = [];
const queue: TaggedNode[] = [tagged];
while (queue.length) {
const cur = queue.shift()!;
allNodes.push(cur);
queue.push(...cur.children);
}
const nodes: LayoutNode[] = allNodes.map((n, i) => ({
id: n.id,
x: NODE_W / 2 + H_GAP / 2,
y: i * (NODE_H + V_GAP) + NODE_H / 2,
node: n.node,
}));
const edges: LayoutEdge[] = allNodes.flatMap((n) =>
n.children.map((c) => ({ source: n.id, target: c.id }))
);
return { nodes, edges, width: NODE_W + H_GAP * 2, height: allNodes.length * (NODE_H + V_GAP) };
}
}
// ── Core SVG renderer (works on GenNode) ─────────────────────────────────────
function TreeSvg({ root, maxRetained, ariaLabel, onNavigate }: { root: GenNode; maxRetained: number; ariaLabel: string; onNavigate?: (idx: number) => void }) {
if (root.children.length === 0) return null;
const { nodes, edges, width, height } = layoutTree(root);
const PAD = 12;
const svgW = width + PAD * 2;
const svgH = height + PAD * 2;
const byId = Object.fromEntries(nodes.map((n) => [n.id, n]));
return (
<svg
width={svgW}
height={svgH}
viewBox={`0 0 ${svgW} ${svgH}`}
style={{ display: "block", overflow: "visible" }}
role="img"
aria-label={ariaLabel}
>
<defs>
<marker id="arrow" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill="var(--muted, #94a3b8)" />
</marker>
</defs>
<g transform={`translate(${PAD},${PAD})`}>
{edges.map((e, i) => {
const src = byId[e.source];
const tgt = byId[e.target];
if (!src || !tgt) return null;
const x1 = src.x, y1 = src.y + NODE_H / 2;
const x2 = tgt.x, y2 = tgt.y - NODE_H / 2;
const my = (y1 + y2) / 2;
return (
<path
key={i}
d={`M${x1},${y1} C${x1},${my} ${x2},${my} ${x2},${y2}`}
fill="none"
stroke="var(--muted, #cbd5e1)"
strokeWidth={1.5}
markerEnd="url(#arrow)"
/>
);
})}
{nodes.map((node) => {
const x = node.x - NODE_W / 2;
const y = node.y - NODE_H / 2;
const col = classColor(node.node.label);
const barW = maxRetained > 0 ? Math.round((node.node.retained / maxRetained) * (NODE_W - 8)) : 0;
const navigable = onNavigate != null && node.node.objIndex != null;
return (
<g key={node.id} transform={`translate(${x},${y})`} style={{ cursor: navigable ? "pointer" : "default" }} onClick={navigable ? () => onNavigate!(node.node.objIndex!) : undefined}>
<title>{node.node.label}{"\n"}{fmtExactBytes(node.node.retained)} retained</title>
<rect width={NODE_W} height={NODE_H} rx={5} fill="var(--card, #f7f7f8)" stroke={col} strokeWidth={2} />
<rect x={4} y={NODE_H - 8} width={barW} height={4} rx={2} fill={col} opacity={0.6} />
<text x={NODE_W / 2} y={16} textAnchor="middle" fontSize={FONT_SIZE} fontWeight="bold" fill={col} fontFamily="monospace">
{truncateClass(node.node.label)}
</text>
<text x={NODE_W / 2} y={30} textAnchor="middle" fontSize={FONT_SIZE - 1} fill="var(--muted, #666)" fontFamily="system-ui, sans-serif">
{formatBytes(node.node.retained)} retained
</text>
<text x={NODE_W / 2} y={43} textAnchor="middle" fontSize={FONT_SIZE - 2} fill="var(--muted, #9aa0aa)" fontFamily="system-ui, sans-serif">
{node.node.sublabel}
</text>
</g>
);
})}
</g>
</svg>
);
}
// ── Adapters: convert model types → GenNode ───────────────────────────────────
function garbageRootToGen(r: UnreachableGarbageRoot): GenNode {
return {
label: r.pretty_class,
retained: r.retained,
sublabel: `${fmtCount(r.objects)} ${r.objects === 1 ? "object" : "objects"}`,
children: r.children.map(garbageRootToGen),
};
}
function domTreeNodeToGen(n: DomTreeNode): GenNode {
// Merge consecutive same-class leaf children into one summary node.
const rawChildren = n.children.map(domTreeNodeToGen);
const merged: GenNode[] = [];
let i = 0;
while (i < rawChildren.length) {
const c = rawChildren[i];
if (c.children.length === 0) {
let j = i + 1;
while (j < rawChildren.length && rawChildren[j].children.length === 0 && rawChildren[j].label === c.label) j++;
const count = j - i;
if (count > 1) {
const totalRetained = rawChildren.slice(i, j).reduce((s, x) => s + x.retained, 0);
const totalShallow = n.children.slice(i, j).reduce((s, x) => s + x.shallow, 0);
merged.push({ label: c.label, retained: totalRetained, sublabel: `×${fmtCount(count)} · shallow ${formatBytes(totalShallow)}`, children: [], objIndex: rawChildren[i].objIndex });
i = j;
continue;
}
}
merged.push(c);
i++;
}
return {
label: n.display_class,
retained: n.retained,
sublabel: `shallow ${formatBytes(n.shallow)}`,
children: merged,
objIndex: n.obj_index_1based - 1,
};
}
// ── Public: Unreachable garbage-root trees ────────────────────────────────────
export function UnreachableDomTreeSection({ roots }: { roots: UnreachableGarbageRoot[] }) {
if (roots.length === 0) return null;
const maxRetained = roots.reduce((m, r) => Math.max(m, r.retained), 0);
return (
<>
<h3>Garbage-Root Dominator Trees</h3>
<p className="subtitle">
Top garbage-root subtrees by retained heap — unreachable objects with no
reachable predecessor. Each node shows retained heap within its subtree.
</p>
{roots.map((root, i) => (
<details key={i} open={i < 3}>
<summary>
<strong>{root.pretty_class}</strong>
{" — "}<span title={fmtExactBytes(root.retained)}>{formatBytes(root.retained)}</span> retained, {fmtCount(root.objects)} {root.objects === 1 ? "object" : "objects"}
</summary>
<div style={{ overflowX: "auto", margin: "8px 0" }}>
<TreeSvg
root={garbageRootToGen(root)}
maxRetained={maxRetained}
ariaLabel={`Garbage-root dominator subtree: ${root.pretty_class}`}
/>
</div>
</details>
))}
</>
);
}
// ── Public: Leak-suspect dominator subtree ────────────────────────────────────
export function DomSubtreeSvg({
node,
onNavigate,
}: {
node: DomTreeNode;
onNavigate?: (denseIdx: number) => void;
}) {
const gen = domTreeNodeToGen(node);
return (
<div style={{ overflowX: "auto", margin: "8px 0" }}>
<TreeSvg
root={gen}
maxRetained={node.retained}
ariaLabel={`Dominator subtree: ${node.display_class}`}
onNavigate={onNavigate}
/>
</div>
);
}