const N_FILL = '#dbe9f4';
const N_COLOR = '#4d6f9c';
const E_COLOR = '#4d6f9c';
const EXT_FILL = '#f6e2c0';
const EXT_COLOR = '#b3801f';
function dotId(id) {
return '"' + id.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
const METRIC_BASE_DIAM = 0.3, METRIC_BASE_LOC = 100, METRIC_BASE_HK = 1000;
function metricNodeVal(n, mode) {
if (!n) return 0;
if (mode === 'loc') return Number(n.sloc ?? n.loc ?? 0);
if (mode === 'hk') return Number(n.hk ?? 0);
return 0;
}
function metricNodeDiam(n, mode) {
const v = metricNodeVal(n, mode);
if (mode === 'loc') return +(METRIC_BASE_DIAM * Math.sqrt(Math.max(v, METRIC_BASE_LOC) / METRIC_BASE_LOC)).toFixed(3);
if (mode === 'hk') return v === 0 ? 0.3 : +(METRIC_BASE_DIAM * Math.sqrt(Math.max(v, METRIC_BASE_HK) / METRIC_BASE_HK)).toFixed(3);
return 0.3;
}
function metricGroupDiam(aggVal, mode) {
if (mode === 'loc') return +(METRIC_BASE_DIAM * Math.sqrt(Math.max(aggVal, METRIC_BASE_LOC) / METRIC_BASE_LOC)).toFixed(3);
if (mode === 'hk') return aggVal === 0 ? 0.3 : +(METRIC_BASE_DIAM * Math.sqrt(Math.max(aggVal, METRIC_BASE_HK) / METRIC_BASE_HK)).toFixed(3);
return 0.3;
}
function fmtMetricShort(v) {
if (v >= 1_000_000) return Math.round(v / 1_000_000) + 'M';
if (v >= 1_000) return Math.round(v / 1_000) + 'K';
return String(Math.round(v));
}
const metricFontSize = d => Math.max(6, Math.round(d * 26));
function buildDOT(nodes, edges, level, viewport) {
const sizeMode = window.nodeSizeMode || null;
const drillGroup = window.drillGroup || null;
const isMetric = sizeMode === 'loc' || sizeMode === 'hk';
const activeDig = drillGroup === null ? (window.dig || 0) : (window.drillDig ?? 0);
const gOf = grouperForDig(level, activeDig);
const cycleOf = window.CYCLES?.[level]?.nodeCycleStatus;
const cycleOnly = !!window.cycleOnly;
const isCyc = id => !!(cycleOf && cycleOf.has(id));
let dot = 'digraph {\n';
dot += ' rankdir=LR\n';
dot += ' graph [bgcolor="white" fontname="Helvetica" pad="0.1" nodesep="0.12" ranksep="0.6"]\n';
dot += ' edge [arrowsize=0.6]\n';
if (isMetric) {
dot += ' node [shape=circle style=filled fixedsize=true width=0.3]\n\n';
} else {
dot += ' node [shape=box style=filled fontname="Helvetica" fontsize=11 margin="0.044,0.022" height=0 width=0]\n\n';
}
if (drillGroup === null) {
const nodeGroup = new Map();
const groupNodes = new Map();
for (const n of nodes) {
if (cycleOnly && !isCyc(n.id)) continue; const g = gOf(n);
nodeGroup.set(n.id, g);
if (!groupNodes.has(g)) groupNodes.set(g, []);
groupNodes.get(g).push(n);
}
const baselineById = new Map((window.BASELINE?.graphs?.[level]?.nodes || []).map(n => [n.id, n]));
const currentById = new Map((window.CURRENT?.graphs?.[level]?.nodes || []).map(n => [n.id, n]));
const isCrateTier = activeDig === 0 && !!(levelUi(level).grouping?.key);
const groupFill = isCrateTier ? '#ffd4d4' : '#ffffff';
const circleFill = isCrateTier ? '#ffd4d4' : N_FILL;
const groupBoxDot = (g, gNodes) => {
const gCyc = aggCycleStatus(gNodes.map(n => cycleOf?.get(n.id) || 'none'));
const cyc = `class="cycle-status-${gCyc}"`;
const leaf = groupLabel(level, g, activeDig);
if (isMetric) {
const aggB = gNodes.reduce((s, n) => s + metricNodeVal(baselineById.get(n.id), sizeMode), 0);
const aggC = gNodes.reduce((s, n) => s + metricNodeVal(currentById.get(n.id), sizeMode), 0);
const agg = Math.max(aggB, aggC) || gNodes.reduce((s, n) => s + metricNodeVal(n, sizeMode), 0);
const d = metricGroupDiam(agg, sizeMode);
const lbl = agg > 0 ? fmtMetricShort(agg) : '';
const fs = metricFontSize(d);
return `${dotId(g)} [label=${dotId(lbl)} fontsize=${fs} fontcolor="#333" fillcolor="${circleFill}" color="${N_COLOR}" width=${d} shape=circle style=filled fixedsize=true ${cyc}]`;
}
const lbl = `${leaf} (${gNodes.length})`;
return `${dotId(g)} [label=${dotId(lbl)} fillcolor="${groupFill}" color="${N_COLOR}" shape=box style=filled fontname="Helvetica" fontsize=11 ${cyc}]`;
};
const clusterByCrate = activeDig > 0 && !!(levelUi(level).grouping?.key);
if (clusterByCrate) {
const crateOf = g => { const i = g.indexOf('/'); return i >= 0 ? g.slice(0, i) : g; };
const byCrate = new Map(); const loose = []; for (const [g, gNodes] of groupNodes) {
if (gNodes.every(n => isExternalNode(n, level))) { loose.push([g, gNodes]); continue; }
const c = crateOf(g);
(byCrate.get(c) || byCrate.set(c, []).get(c)).push([g, gNodes]);
}
let ci = 0;
for (const [crate, entries] of byCrate) {
dot += ` subgraph cluster_crate_${ci++} {\n`;
dot += ` label=${dotId(crate)} style=filled fillcolor="#fff2f2" color="#e3b3b3" fontname="Helvetica" fontsize=11 fontcolor="#a05a5a"\n`;
for (const [g, gNodes] of entries) dot += ` ${groupBoxDot(g, gNodes)}\n`;
dot += ' }\n';
}
for (const [g, gNodes] of loose) dot += ` ${groupBoxDot(g, gNodes)}\n`;
} else {
for (const [g, gNodes] of groupNodes) dot += ` ${groupBoxDot(g, gNodes)}\n`;
}
const seenGroupEdge = new Set();
for (const e of edges) {
if (!edgeIsFlow(level, e.kind)) continue;
const sg = nodeGroup.get(e.source);
const tg = nodeGroup.get(e.target);
if (!sg || !tg || sg === tg) continue;
const key = sg + '\x00' + tg;
if (seenGroupEdge.has(key)) continue;
seenGroupEdge.add(key);
dot += ` ${dotId(sg)} -> ${dotId(tg)} [color="${E_COLOR}" style="solid"]\n`;
}
dot += '}';
return dot;
}
const drillNodes = nodes.filter(n => gOf(n) === drillGroup && (!cycleOnly || isCyc(n.id)));
const drillIds = new Set(drillNodes.map(n => n.id));
dot += ' newrank=true\n';
const baselineById = new Map((window.BASELINE?.graphs?.[level]?.nodes || []).map(n => [n.id, n]));
const currentById = new Map((window.CURRENT?.graphs?.[level]?.nodes || []).map(n => [n.id, n]));
const allNodesById = new Map(nodes.map(n => [n.id, n]));
const gkey = levelUi(level).grouping?.key;
const underDepth = n => {
const dirs = relPathOf(n.id).split('/').slice(0, -1);
const crate = gkey ? n[gkey] : null;
if (crate == null || crate === '') return dirs.length;
return Math.max(0, dirs.length - (crateRoots(level).get(String(crate)) || []).length);
};
const maxFocusD = drillNodes.length ? Math.max(...drillNodes.map(underDepth)) : 0;
const fz = window.focusDig || 0;
const folderMode = fz < 0 && maxFocusD > activeDig;
const focusD = folderMode ? Math.min(maxFocusD, Math.max(activeDig + 1, maxFocusD + fz + 1)) : 0;
const renderId = id => { const n = allNodesById.get(id); return (folderMode && n) ? groupKeyAtDig(level, n, focusD) : id; };
window._FOCUS = { folderMode, focusD, maxFocusD };
const layoutDiam = n => {
const db = baselineById.has(n.id) ? metricNodeDiam(baselineById.get(n.id), sizeMode) : 0;
const da = currentById.has(n.id) ? metricNodeDiam(currentById.get(n.id), sizeMode) : 0;
return Math.max(db, da) || metricNodeDiam(n, sizeMode);
};
const edgeCycleOf = window.CYCLES?.[level]?.edgeCycleStatus;
const eAttr = e =>
`color="${E_COLOR}" style="solid" class="edge-${e.kind || 'unknown'} status-${e.status} cycle-status-${edgeCycleOf ? edgeCycleOf(e.source, e.target) : 'none'}"`;
const nAttr = n => {
const ks = nodeKindSpec(level, n.kind);
const ext = isExternalNode(n, level);
const fill = ks.fill || (ext ? EXT_FILL : N_FILL);
const col = ks.stroke || (ext ? EXT_COLOR : N_COLOR);
const cls = `class="node-${n.kind || 'unknown'} status-${n.status} cycle-status-${cycleOf?.get(n.id) || 'none'}"`;
if (isMetric) {
const d = layoutDiam(n);
const v = metricNodeVal(n, sizeMode);
const lbl = v > 0 ? fmtMetricShort(v) : '';
const fs = metricFontSize(d);
return `label=${dotId(lbl)} fontsize=${fs} fontcolor="#333" fillcolor="${fill}" color="${col}" width=${d} ${cls}`;
}
return `label=${dotId(n.name)} fillcolor="${fill}" color="${col}" ${cls}`;
};
const inGrpFiles = new Map(); const outGrpFiles = new Map(); for (const e of edges) {
if (!edgeIsFlow(level, e.kind)) continue; const sIn = drillIds.has(e.source), tIn = drillIds.has(e.target);
if (!sIn && tIn) {
const src = allNodesById.get(e.source);
if (!src || isExternalNode(src, level)) continue;
const g = gOf(src);
if (g === drillGroup) continue;
if (!inGrpFiles.has(g)) inGrpFiles.set(g, new Set());
inGrpFiles.get(g).add(renderId(e.target));
} else if (sIn && !tIn) {
const tgt = allNodesById.get(e.target);
if (!tgt || isExternalNode(tgt, level)) continue;
const g = gOf(tgt);
if (g === drillGroup) continue;
if (!outGrpFiles.has(g)) outGrpFiles.set(g, new Set());
outGrpFiles.get(g).add(renderId(e.source));
}
}
for (const g of inGrpFiles.keys()) outGrpFiles.delete(g);
const crateOfKey = k => { const i = k.indexOf('/'); return i >= 0 ? k.slice(0, i) : k; };
const drillCrate = crateOfKey(drillGroup);
const neighbourKeys = [...inGrpFiles.keys(), ...outGrpFiles.keys()];
const singleCrate = neighbourKeys.every(k => crateOfKey(k) === drillCrate);
const neighborLabel = k => {
if (!singleCrate) return k;
const i = k.indexOf('/');
return i >= 0 ? '/' + k.slice(i + 1) : k;
};
const IN_EDGE_COLOR = '#88bb88';
const OUT_EDGE_COLOR = '#ccaa77';
const IN_FILL = '#edf7ed';
const OUT_FILL = '#fdf3e3';
const extNode = (label, borderColor, fillColor) =>
`[label=${dotId(label)} fillcolor="${fillColor}" color="${borderColor}" shape=box style=filled fixedsize=false fontname="Helvetica" fontsize=11]`;
const inNodeId = g => 'IN\x01' + g;
const outNodeId = g => 'OUT\x01' + g;
if (inGrpFiles.size > 0) {
dot += ` subgraph cluster_in {\n`;
dot += ` label="callers" style=filled fillcolor="${IN_FILL}" color="#88bb88" fontcolor="#447744" fontname="Helvetica" fontsize=11\n`;
for (const g of inGrpFiles.keys())
dot += ` ${dotId(inNodeId(g))} ${extNode(neighborLabel(g), IN_EDGE_COLOR, IN_FILL)}\n`;
dot += ' }\n';
}
if (folderMode) {
const groups = new Map();
for (const n of drillNodes) { const k = groupKeyAtDig(level, n, focusD); (groups.get(k) || groups.set(k, []).get(k)).push(n); }
for (const [k, ns] of groups) {
const gCyc = aggCycleStatus(ns.map(n => cycleOf?.get(n.id) || 'none'));
const lbl = `${groupLabel(level, k, focusD)} (${ns.length})`;
dot += ` ${dotId(k)} [label=${dotId(lbl)} fillcolor="${N_FILL}" color="${N_COLOR}" shape=box style=filled fontname="Helvetica" fontsize=11 class="cycle-status-${gCyc}"]\n`;
}
} else {
const dirOf = n => nodeFullDir(n);
const subGroups = new Map();
drillNodes.forEach(n => { const d = dirOf(n); (subGroups.get(d) || subGroups.set(d, []).get(d)).push(n); });
let si = 0;
for (const [label, ns] of subGroups) {
dot += ` subgraph cluster_${si++} {\n`;
dot += ` label=${dotId(label)} style=filled fillcolor="#f7f7f7" color="#cccccc" fontcolor="#666666" fontname="Helvetica" fontsize=11\n`;
for (const n of ns) dot += ` ${dotId(n.id)} [${nAttr(n)}]\n`;
dot += ' }\n';
}
}
if (outGrpFiles.size > 0) {
dot += ` subgraph cluster_out {\n`;
dot += ` label="dependencies" style=filled fillcolor="${OUT_FILL}" color="#ccaa77" fontcolor="#886633" fontname="Helvetica" fontsize=11\n`;
for (const g of outGrpFiles.keys())
dot += ` ${dotId(outNodeId(g))} ${extNode(neighborLabel(g), OUT_EDGE_COLOR, OUT_FILL)}\n`;
dot += ' }\n';
}
if (inGrpFiles.size > 0) {
dot += ' { rank=min';
for (const g of inGrpFiles.keys()) dot += `; ${dotId(inNodeId(g))}`;
dot += ' }\n';
}
if (outGrpFiles.size > 0) {
dot += ' { rank=max';
for (const g of outGrpFiles.keys()) dot += `; ${dotId(outNodeId(g))}`;
dot += ' }\n';
}
const seenEdge = new Set();
for (const e of edges) {
if (!edgeIsFlow(level, e.kind)) continue; if (!drillIds.has(e.source) || !drillIds.has(e.target)) continue;
const s = renderId(e.source), t = renderId(e.target);
if (s === t) continue; const key = s + '\x00' + t;
if (seenEdge.has(key)) continue;
seenEdge.add(key);
dot += ` ${dotId(s)} -> ${dotId(t)} [${eAttr(e)}]\n`;
}
for (const [g, files] of inGrpFiles) {
const src = dotId(inNodeId(g));
for (const fid of files)
dot += ` ${src} -> ${dotId(fid)} [color="${IN_EDGE_COLOR}" style="solid" constraint=false class="edge-in"]\n`;
if (outGrpFiles.has(g)) {
for (const fid of outGrpFiles.get(g))
dot += ` ${dotId(fid)} -> ${src} [color="${IN_EDGE_COLOR}" style="solid" constraint=false class="edge-in"]\n`;
}
}
for (const [g, files] of outGrpFiles) {
const tgt = dotId(outNodeId(g));
for (const fid of files)
dot += ` ${dotId(fid)} -> ${tgt} [color="${OUT_EDGE_COLOR}" style="solid" constraint=false class="edge-out"]\n`;
}
dot += '}';
return dot;
}