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));
window._fanData = { in: [], out: [] };
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 = window.viewTier(level) === 'crate' && 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 = window.viewTier(level) === 'crate' && 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`;
}
const seenGroupNF = 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) || seenGroupNF.has(key)) continue;
seenGroupNF.add(key);
dot += ` ${dotId(sg)} -> ${dotId(tg)} [color="${E_COLOR}" style="dashed" constraint=false class="edge-nonflow"]\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 underDepth = n => underDepthOf(level, n); const maxFocusD = drillNodes.length ? Math.max(...drillNodes.map(underDepth)) : 0;
const fz = window.focusDig || 0;
const minFz = -Math.max(0, maxFocusD - activeDig);
const D = fz - minFz;
const frontierDig = activeDig + D + 1;
const focusBase = focusStripBase(level);
const relLevel = n => underDepth(n) - activeDig;
const isFileNode = n => relLevel(n) <= D;
const renderId = id => { const n = allNodesById.get(id); return (n && !isFileNode(n)) ? groupKeyAtDig(level, n, frontierDig) : id; };
const anyBoxed = drillNodes.some(n => !isFileNode(n));
window._FOCUS = { folderMode: anyBoxed, focusD: frontierDig, 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 => {
const flow = edgeIsFlow(level, e.kind);
return `color="${E_COLOR}" style="${flow ? 'solid' : 'dashed'}" class="edge-${e.kind || 'unknown'} status-${e.status} cycle-status-${edgeCycleOf ? edgeCycleOf(e.source, e.target) : 'none'}${flow ? '' : ' edge-nonflow'}"`;
};
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 crateOf = n => crateIdOf(level, n) ?? gOf(n);
const inGrp = new Map(); const outGrp = new Map();
const touch = (m, crate, theirFile, ourId, e, flow) => {
let r = m.get(crate);
if (!r) { r = { their: new Set(), our: new Map() }; m.set(crate, r); }
if (flow) r.their.add(theirFile); let rec = r.our.get(ourId);
if (!rec) { rec = { b: false, c: false, flow: false }; r.our.set(ourId, rec); }
rec.b = rec.b || e.status !== 'added'; rec.c = rec.c || e.status !== 'removed'; rec.flow = rec.flow || flow; };
for (const e of edges) {
const flow = edgeIsFlow(level, e.kind);
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;
touch(inGrp, crateOf(src), e.source, renderId(e.target), e, flow);
} else if (sIn && !tIn) {
const tgt = allNodesById.get(e.target);
if (!tgt || isExternalNode(tgt, level)) continue;
touch(outGrp, crateOf(tgt), e.target, renderId(e.source), e, flow);
}
}
for (const c of inGrp.keys()) outGrp.delete(c);
const statusClass = (b, c) => (b && c) ? 'unchanged' : c ? 'added' : 'removed';
const grpStatus = r => { let b = false, c = false; for (const rec of r.our.values()) { b = b || rec.b; c = c || rec.c; } return statusClass(b, c); };
const fanSerialize = grp => [...grp].map(([crate, r]) => ({
crate, count: r.their.size, status: grpStatus(r),
our: [...r.our].map(([fid, rec]) => ({ fid, flow: rec.flow, status: statusClass(rec.b, rec.c) })),
}));
window._fanData = { in: fanSerialize(inGrp), out: fanSerialize(outGrp) };
const fileNodes = drillNodes.filter(isFileNode);
const boxNodes = drillNodes.filter(n => !isFileNode(n));
const boxes = new Map();
for (const n of boxNodes) { const k = groupKeyAtDig(level, n, frontierDig); (boxes.get(k) || boxes.set(k, []).get(k)).push(n); }
for (const [k, ns] of boxes) {
const gCyc = aggCycleStatus(ns.map(n => cycleOf?.get(n.id) || 'none'));
if (isMetric) {
const aggB = ns.reduce((s, n) => s + metricNodeVal(baselineById.get(n.id), sizeMode), 0);
const aggC = ns.reduce((s, n) => s + metricNodeVal(currentById.get(n.id), sizeMode), 0);
const agg = Math.max(aggB, aggC) || ns.reduce((s, n) => s + metricNodeVal(n, sizeMode), 0);
const d = metricGroupDiam(agg, sizeMode);
const lbl = agg > 0 ? fmtMetricShort(agg) : '';
const fs = metricFontSize(d);
dot += ` ${dotId(k)} [label=${dotId(lbl)} fontsize=${fs} fontcolor="#555555" fillcolor="#ececec" color="#bbbbbb" width=${d} shape=circle style=filled fixedsize=true class="cycle-status-${gCyc}"]\n`;
continue;
}
const lbl = `${stripDirPrefix(focusBase, groupLabel(level, k, frontierDig))} (${ns.length})`;
dot += ` ${dotId(k)} [label=${dotId(lbl)} fillcolor="#ececec" color="#bbbbbb" fontcolor="#555555" shape=box style=filled fontname="Helvetica" fontsize=11 class="cycle-status-${gCyc}"]\n`;
}
const subGroups = new Map();
fileNodes.forEach(n => { const d = nodeFullDir(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(stripDirPrefix(focusBase, 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';
}
const flowPairs = 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 (flowPairs.has(key)) continue;
flowPairs.add(key);
dot += ` ${dotId(s)} -> ${dotId(t)} [${eAttr(e)}]\n`;
}
const seenNonFlow = 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 (flowPairs.has(key) || seenNonFlow.has(key)) continue;
seenNonFlow.add(key);
dot += ` ${dotId(s)} -> ${dotId(t)} [${eAttr(e)} constraint=false]\n`;
}
dot += '}';
return dot;
}