function buildGraphTab() {
var wrapper = el('div');
wrapper.append(buildTabInfo(
'Import Dependency Graph',
'Each node is a file; an arrow from A to B means A imports B. Node size grows with total coupling (Ca + Ce); colour follows instability. Click a node to focus its neighbourhood; click the background to reset. Use "Group by directory" for a module-level view.',
[
{ color: 'var(--c-good)', label: 'Instability ≤ 0.3 (stable)' },
{ color: 'var(--c-warn)', label: '≤ 0.7' },
{ color: 'var(--c-danger)', label: '> 0.7 (unstable)' },
{ color: '#ef4444', label: 'Circular dependency (dashed edge)' }
]
));
var allEdges = R.import_edges || [];
if (allEdges.length === 0) {
var none = el('div', { className: 'no-data' });
none.append(txt('No import graph data available.'));
wrapper.append(none);
return wrapper;
}
var metricsByPath = {};
(R.per_file_coupling || []).forEach(function(m) { metricsByPath[m.path] = m; });
var cycles = R.import_cycles || [];
var cyclesByPath = {};
cycles.forEach(function(members, idx) {
members.forEach(function(p) {
(cyclesByPath[p] = cyclesByPath[p] || []).push(idx);
});
});
function sharesCycle(a, b) {
var ca = cyclesByPath[a], cb = cyclesByPath[b];
if (!ca || !cb) return false;
return ca.some(function(i) { return cb.indexOf(i) !== -1; });
}
function dirKey(path) {
var idx = path.lastIndexOf('/');
if (idx === -1) return '(root)';
var segs = path.slice(0, idx).split('/');
return segs.slice(0, 2).join('/');
}
var BAND_COLORS = {
good: 'var(--c-good)', warn: 'var(--c-warn)', danger: 'var(--c-danger)', none: '#3b82f6'
};
function instBand(i) {
return i == null ? 'none' : i <= 0.3 ? 'good' : i <= 0.7 ? 'warn' : 'danger';
}
var state = { focus: null };
var controls = el('div', { style: {
margin: '8px 0', display: 'flex', gap: '14px', alignItems: 'center', flexWrap: 'wrap'
}});
var search = el('input', {
type: 'search',
placeholder: 'Filter files…',
className: 'graph-search',
style: {
background: 'var(--bg-panel, #0f172a)', color: 'inherit',
border: '1px solid #334155', borderRadius: '6px',
padding: '5px 10px', fontSize: '13px', width: '220px'
}
});
var degreeWrap = el('label', { style: { display: 'flex', gap: '6px', alignItems: 'center', fontSize: '12px', color: '#94a3b8' } });
var degreeLabel = el('span');
degreeLabel.append(txt('Min degree: 0'));
var degreeSlider = el('input', { type: 'range', min: '0', max: '10', value: '0', style: { width: '110px' } });
degreeWrap.append(degreeLabel, degreeSlider);
var groupWrap = el('label', { style: { display: 'flex', gap: '6px', alignItems: 'center', fontSize: '12px', color: '#94a3b8', cursor: 'pointer' } });
var groupToggle = el('input', { type: 'checkbox' });
groupWrap.append(groupToggle, txt('Group by directory'));
var exportBtn = el('button', { className: 'chip', style: { cursor: 'pointer' } });
exportBtn.append(txt('Export SVG'));
controls.append(search, degreeWrap, groupWrap, exportBtn);
wrapper.append(controls);
var capNotice = el('div', { style: { color: '#94a3b8', fontSize: '12px', margin: '4px 0' } });
wrapper.append(capNotice);
var W = 1100, H = 640;
var graphBox = el('div', { className: 'graph-box', style: {
border: '1px solid #1e293b', borderRadius: '8px', overflow: 'hidden', position: 'relative'
}});
wrapper.append(graphBox);
var live = {
svg: null, nodes: [], nodeMap: {}, nodeEls: [], edgeEls: [], drawEdges: [],
viewX: 0, viewY: 0, viewScale: 1,
isPanning: false, panSX: 0, panSY: 0, panVX: 0, panVY: 0, panMoved: 0,
dragging: null, dragMoved: 0
};
var generation = 0;
function nodeRadius(n) { return 4 + Math.sqrt(n.degree) * 2.5; }
function updateViewBox() {
if (!live.svg) return;
live.svg.setAttribute('viewBox',
live.viewX + ' ' + live.viewY + ' ' + (W / live.viewScale) + ' ' + (H / live.viewScale));
}
function neighbourSet(path) {
var set = {};
set[path] = true;
live.drawEdges.forEach(function(e) {
if (e.from === path) set[e.to] = true;
if (e.to === path) set[e.from] = true;
});
return set;
}
function updateVisibility() {
var q = search.value.toLowerCase();
var hood = state.focus ? neighbourSet(state.focus) : null;
live.nodeEls.forEach(function(ne) {
var on = hood ? !!hood[ne.data.path]
: q === '' || ne.data.path.toLowerCase().indexOf(q) !== -1;
ne.el.setAttribute('opacity', on ? '1' : '0.1');
});
live.edgeEls.forEach(function(ee) {
var on = hood ? (ee.data.from === state.focus || ee.data.to === state.focus)
: q === '' ||
ee.data.from.toLowerCase().indexOf(q) !== -1 ||
ee.data.to.toLowerCase().indexOf(q) !== -1;
ee.el.setAttribute('opacity', on ? '1' : '0.08');
});
}
function buildModel() {
var mode = groupToggle.checked ? 'dirs' : 'files';
var minDegree = +degreeSlider.value;
var notice = [];
var nodeMap = {}, edges = [];
if (mode === 'files') {
allEdges.forEach(function(e) {
nodeMap[e.from] = nodeMap[e.from] || { path: e.from, degree: 0 };
nodeMap[e.to] = nodeMap[e.to] || { path: e.to, degree: 0 };
nodeMap[e.from].degree++;
nodeMap[e.to].degree++;
});
edges = allEdges.map(function(e) {
return { from: e.from, to: e.to, weight: 1, cyclic: sharesCycle(e.from, e.to) };
});
var all = Object.keys(nodeMap).map(function(k) { return nodeMap[k]; });
var MAX_NODES = 150;
if (all.length > MAX_NODES) {
var sorted = all.slice().sort(function(a, b) {
return b.degree - a.degree || (a.path < b.path ? -1 : 1);
});
var kept = {};
sorted.slice(0, MAX_NODES).forEach(function(n) { kept[n.path] = true; });
notice.push('Showing the ' + MAX_NODES + ' most-connected files ('
+ (all.length - MAX_NODES) + ' hidden — use "Group by directory" for the full picture)');
Object.keys(nodeMap).forEach(function(k) { if (!kept[k]) delete nodeMap[k]; });
edges = edges.filter(function(e) { return kept[e.from] && kept[e.to]; });
}
} else {
var dirEdges = {};
var fileCounts = {};
allEdges.forEach(function(e) {
var a = dirKey(e.from), b = dirKey(e.to);
(fileCounts[a] = fileCounts[a] || {})[e.from] = true;
(fileCounts[b] = fileCounts[b] || {})[e.to] = true;
if (a === b) return;
var key = a + '