function nodePercentiles(snap, level, getVal) {
const nodes = (snap?.graphs?.[level]?.nodes || []).filter(n => !isExternalNode(n, level));
const vals = nodes.map(n => getVal(n)).filter(v => typeof v === 'number' && isFinite(v) && v > 0);
if (!vals.length) return null;
vals.sort((a, b) => a - b);
const pct = p => {
const idx = p / 100 * (vals.length - 1);
const lo = Math.floor(idx), hi = Math.ceil(idx);
return vals[lo] + (vals[hi] - vals[lo]) * (idx - lo);
};
const avg = vals.reduce((s, v) => s + v, 0) / vals.length;
return { count: vals.length, avg, min: vals[0], max: vals[vals.length - 1],
p1: pct(1), p10: pct(10), p50: pct(50), p90: pct(90), p99: pct(99) };
}
function buildSummary() {
const tbody = document.getElementById('summary-tbody');
const thead = document.getElementById('summary-thead');
if (!tbody) return;
const isReview = !window.BASELINE || !window.CURRENT;
const baseline = window.BASELINE ?? window.CURRENT;
const current = window.CURRENT ?? window.BASELINE;
const levels = ['files'];
const LLABELS = { files: 'Files' };
const titleEl = document.getElementById('summary-title');
if (titleEl) titleEl.textContent = isReview ? 'Summary' : 'Diff summary';
if (thead) {
if (isReview) {
thead.innerHTML =
`<tr><th>Metric</th>` +
levels.map((l, i) =>
`<th class="num level-header${i > 0 ? ' grp-start' : ''}">${LLABELS[l]}</th>`
).join('') + `</tr>`;
} else {
thead.innerHTML =
`<tr><th rowspan="2" class="metric-header">Metric</th>` +
levels.map((l, i) =>
`<th colspan="3" class="level-header${i > 0 ? ' grp-start' : ''}">${LLABELS[l]}</th>`
).join('') + `</tr><tr>` +
levels.map((_, i) =>
`<th class="num${i > 0 ? ' grp-start' : ''}">Baseline</th><th class="num">Current</th><th class="num">Δ delta</th>`
).join('') + `</tr>`;
}
}
const countNodes = (snap, level) =>
((snap?.graphs || {})[level]?.nodes || []).filter(n => !isExternalNode(n, level)).length;
const countEdges = (snap, level) => {
const g = (snap?.graphs || {})[level];
if (!g) return 0;
const ids = new Set((g.nodes || []).filter(n => !isExternalNode(n, level)).map(n => n.id));
return (g.edges || []).filter(e => ids.has(e.source) && ids.has(e.target)).length;
};
const sumAttr = (snap, level, key) =>
((snap?.graphs || {})[level]?.nodes || [])
.filter(n => !isExternalNode(n, level))
.reduce((s, n) => {
const v = nodeAttr(n, key);
return s + (typeof v === 'number' && isFinite(v) ? v : 0);
}, 0);
const hasAttrKey = (level, key) => !!levelSpec(level).node_attributes?.[key];
const fmtV = v => typeof v === 'number' && isFinite(v) ? fmtNum(v) : '';
const fmtDeltaNum = d => {
const ad = Math.abs(d);
if (ad < 1e-9) return '0';
const rounded = fmtNum(ad);
return rounded !== '0' ? rounded : String(Number(ad.toPrecision(2)));
};
const fmtDelta = (d, dir) => {
const mag = fmtDeltaNum(d);
if (mag === '0') return `<td class="num">0</td>`; const ds = d > 0 ? `+${mag}` : `−${mag}`;
let cls = '';
if (typeof dir === 'boolean') {
const lb = dir;
cls = (lb ? d < 0 : d > 0) ? ' delta-good' : (lb ? d > 0 : d < 0) ? ' delta-bad' : '';
}
return `<td class="num${cls}">${ds}</td>`;
};
const ttAttr = pct => pct ? ` data-tt="${escAttr(JSON.stringify(pct))}"` : '';
const valueCellsHTML = (b, a, { dir = null, ttB = null, ttA = null, badge = false } = {}) => {
const cell = (v, tt) => {
const inner = badge && typeof v === 'number' && v > 0
? `<span class="cycle-badge">${fmtV(v)}</span>` : fmtV(v);
return `<td class="num"${tt ? ttAttr(tt) : ''}>${inner}</td>`;
};
if (isReview) return cell(b, ttB);
const d = typeof b === 'number' && typeof a === 'number' ? a - b : null;
return cell(b, ttB) + cell(a, ttA) + (d !== null ? fmtDelta(d, dir) : '<td></td>');
};
const rowRecord = (label, b, a, opts = {}) => {
const tipAttr = opts.tip ? ` data-tip="${escAttr(opts.tip)}"` : '';
const fAttr = opts.formula ? ` data-tip-formula="${escAttr(opts.formula)}"` : '';
const html = `<tr><td class="metric-cell"${tipAttr}${fAttr}>${label}</td>${valueCellsHTML(b, a, opts)}</tr>`;
const bn = typeof b === 'number' ? b : null;
const an = isReview ? null : (typeof a === 'number' ? a : null);
const delta = bn != null && an != null ? an - bn : null;
return { html, data: { label, baseline: bn, current: an, delta } };
};
const level0 = levels[0];
const summaryKeys = levelUi(level0).summary_metrics || [];
const metricRow = key => {
if (!hasAttrKey(level0, key)) return null; const dirRaw = attrDirection(level0, key); const stat = window._summaryStat || 'avg';
const dir = stat === 'sum' ? null
: dirRaw === 'lower_better' ? true
: dirRaw === 'higher_better' ? false : null;
const opts = { dir, tip: attrDesc(level0, key) || undefined, formula: attrFormula(level0, key) || undefined };
const human = attrName(level0, key); const label = human && human.toLowerCase() !== key.toLowerCase() ? `${key} - ${human}` : key;
if (stat === 'sum')
return rowRecord(label, sumAttr(baseline, level0, key), sumAttr(current, level0, key), opts);
const distB = nodePercentiles(baseline, level0, n => nodeAttr(n, key));
const distA = nodePercentiles(current, level0, n => nodeAttr(n, key));
return rowRecord(label, distB ? distB[stat] : null, distA ? distA[stat] : null,
{ ...opts, ttB: distB, ttA: distA });
};
const groupingKey = levelUi(level0).grouping?.key || null;
const countGroups = (snap, level) => {
if (!groupingKey) return 0;
const s = new Set();
for (const n of ((snap?.graphs || {})[level]?.nodes || []))
if (!isExternalNode(n, level)) {
const v = nodeAttr(n, groupingKey);
if (v != null && v !== '') s.add(v);
}
return s.size;
};
const groupsLabel = groupingKey
? groupingKey.charAt(0).toUpperCase() + groupingKey.slice(1) + 's' : 'Groups';
const countFolders = (snap, level) => {
const s = new Set();
for (const n of ((snap?.graphs || {})[level]?.nodes || []))
if (!isExternalNode(n, level)) s.add(nodeFullDir(n));
return s.size;
};
const cyclesRow = () => {
const cy = window.CYCLES?.[level0];
if (!cy || (cy.cycleBaseline + cy.cycleBoth + cy.cycleCurrent) === 0) return null;
const kc = {};
for (const g of (current?.graphs?.[level0]?.cycles || [])) kc[g.kind] = (kc[g.kind] || 0) + 1;
const kparts = Object.entries(kc).filter(([, n]) => n > 0)
.map(([k, n]) => `${cycleKindLabel(level0, k)}: ${n}`);
const tip = kparts.length
? `Nodes in at least one dependency cycle. Cycle groups by type — ${kparts.join(', ')}.`
: 'Number of nodes that participate in at least one dependency cycle.';
return rowRecord('Nodes in cycles', cy.cycleBaseline + cy.cycleBoth, cy.cycleCurrent + cy.cycleBoth,
{ dir: true, badge: true, tip });
};
const builders = {
'nodes': () => rowRecord(LLABELS[level0] || 'Nodes', countNodes(baseline, level0), countNodes(current, level0)),
'folders': () => rowRecord('Folders',
countFolders(baseline, level0), countFolders(current, level0),
{ tip: 'Distinct directories that contain the files.' }),
'groups': () => groupingKey
? rowRecord(groupsLabel,
countGroups(baseline, level0), countGroups(current, level0),
{ tip: `Distinct ${groupingKey} values — the groups shown on the map.` })
: null,
'edges': () => rowRecord('Edges',
countEdges(baseline, level0), countEdges(current, level0),
{ tip: 'Total dependency edges between internal nodes (external-library edges excluded).' }),
'cycles': cyclesRow,
};
const LAYOUT = [
{ title: 'sum always', rows: ['nodes', 'folders', 'groups', 'edges', 'cycles'] },
{ radio: true },
{ title: 'Coupling', rows: ['metric:fan_in', 'metric:fan_out', 'metric:hk'] },
{ title: 'Lines', rows: ['metric:loc', 'metric:sloc', 'metric:lloc', 'metric:cloc', 'metric:blank', 'metric:tloc'] },
{ title: 'Complexity', rows: ['metric:cyclomatic', 'metric:cognitive', 'metric:mi', 'metric:mi_sei'] },
{ title: 'Halstead', rows: ['metric:volume', 'metric:bugs', 'metric:effort', 'metric:time', 'metric:length', 'metric:vocabulary'] },
];
const laidOutRows = LAYOUT.flatMap(s => s.rows || []);
const metricKeys = new Set([
...summaryKeys,
...laidOutRows.filter(id => id.startsWith('metric:')).map(id => id.slice('metric:'.length)),
]);
for (const key of metricKeys) builders[`metric:${key}`] = () => metricRow(key);
const headSpan = 1 + levels.length * (isReview ? 1 : 3);
const headerRow = title =>
`<tr class="summary-subhead"><td colspan="${headSpan}">${escHtml(title)}</td></tr>`;
const statRow = () => {
const cur = window._summaryStat || 'avg';
const opts = SUMMARY_STATS.map(s =>
`<label class="summary-stat-opt"><input type="radio" name="summary-stat" value="${s}"` +
`${s === cur ? ' checked' : ''}>${s}</label>`).join('');
return `<tr class="summary-stat-row"><td colspan="${headSpan}"><span class="summary-stat">${opts}</span></td></tr>`;
};
const placed = new Set(laidOutRows);
const leftovers = Object.keys(builders).filter(id => !placed.has(id));
const sections = leftovers.length ? [...LAYOUT, { title: 'Other', rows: leftovers }] : LAYOUT;
const out = [], model = [];
for (const sec of sections) {
if (sec.radio) { out.push(statRow()); continue; } const recs = sec.rows.map(id => (builders[id] ? builders[id]() : null)).filter(Boolean);
if (!recs.length) continue;
if (sec.title) out.push(headerRow(sec.title));
out.push(...recs.map(r => r.html));
model.push({ section: sec.title, rows: recs.map(r => r.data) });
}
tbody.innerHTML = out.join('');
window._summaryModel = {
target: window.META?.target || 'snapshot',
mode: isReview ? 'review' : 'diff',
stat: window._summaryStat || 'avg',
baseline: window.META?.baseline || null,
current: isReview ? null : (window.META?.current || null),
sections: model,
};
}
const SUMMARY_STATS = ['avg', 'min', 'p50', 'p90', 'max', 'sum'];
function setupSummaryStatControl() {
const tbody = document.getElementById('summary-tbody');
if (!tbody || tbody._statWired) return;
tbody._statWired = true;
if (!window._summaryStat) window._summaryStat = 'avg';
tbody.addEventListener('change', e => {
if (e.target.name !== 'summary-stat') return;
window._summaryStat = e.target.value;
buildSummary();
window.navReplaceView?.();
});
}
window.setupSummaryStatControl = setupSummaryStatControl;
window.isSummaryStat = s => SUMMARY_STATS.includes(s);
window.setSummaryStat = s => {
if (!SUMMARY_STATS.includes(s) || s === (window._summaryStat || 'avg')) return;
window._summaryStat = s;
const radio = document.querySelector(`.summary-stat input[value="${s}"]`);
if (radio) radio.checked = true;
buildSummary();
};
function downloadFile(name, text, mime) {
const blob = new Blob([text], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function summaryFileBase() {
const file = decodeURIComponent((location.pathname || '').split('/').pop() || '');
const stem = file.replace(/\.html?$/i, '').trim();
if (stem && stem.toLowerCase() !== 'index') return `${stem}-report`;
const m = window._summaryModel || {};
const slug = String(m.target || 'summary').replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'summary';
return `${slug}-report`;
}
function summaryJSONText() {
const m = window._summaryModel;
return m ? JSON.stringify(m, null, 2) : '';
}
function summaryMarkdownText() {
const m = window._summaryModel;
if (!m) return '';
const review = m.mode === 'review';
const fmt = v => (v == null ? '' : fmtNum(v));
const dlt = v => {
if (v == null) return '';
const ad = Math.abs(v);
if (ad < 1e-9) return '0';
const r = fmtNum(ad);
const mag = r !== '0' ? r : String(Number(ad.toPrecision(2)));
return v > 0 ? `+${mag}` : `−${mag}`;
};
const side = s => s ? `${s.name}${s.commit ? ` (${s.commit})` : ''}` : '—';
const lines = [`# ${m.target} — ${review ? 'summary' : 'diff summary'}`, ''];
if (!review) lines.push(`Baseline: ${side(m.baseline)} · Current: ${side(m.current)}`, '');
lines.push(`Stat: \`${m.stat}\``, '');
for (const sec of m.sections) {
if (sec.section) lines.push(`## ${sec.section}`, '');
lines.push(review ? '| Metric | Value |' : '| Metric | Baseline | Current | Δ |',
review ? '| --- | ---: |' : '| --- | ---: | ---: | ---: |');
for (const r of sec.rows)
lines.push(review
? `| ${r.label} | ${fmt(r.baseline)} |`
: `| ${r.label} | ${fmt(r.baseline)} | ${fmt(r.current)} | ${dlt(r.delta)} |`);
lines.push('');
}
return lines.join('\n');
}
function exportSummaryJSON() { const t = summaryJSONText(); if (t) downloadFile(`${summaryFileBase()}.json`, t, 'application/json'); }
function exportSummaryMarkdown() { const t = summaryMarkdownText(); if (t) downloadFile(`${summaryFileBase()}.md`, t, 'text/markdown'); }
const SUMMARY_CHECK_SVG =
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" ' +
'stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="m5 13 4 4L19 7"/></svg>';
function copySummaryText(text, btn) {
if (!text) return;
navigator.clipboard?.writeText(text).then(() => {
if (!btn || btn._copyReset) return;
const prev = btn.innerHTML;
btn.innerHTML = SUMMARY_CHECK_SVG;
btn.classList.add('copied');
btn._copyReset = setTimeout(() => {
btn.innerHTML = prev;
btn.classList.remove('copied');
btn._copyReset = null;
}, 1200);
});
}
window.exportSummaryJSON = exportSummaryJSON;
window.exportSummaryMarkdown = exportSummaryMarkdown;
function openSummaryPopup(syncUrl = true) {
const ov = document.getElementById('summary-overlay');
if (!ov) return;
window._statsOpen = true;
buildSummary(); const hdr = document.querySelector('header');
ov.style.top = (hdr ? hdr.offsetHeight : 0) + 'px';
ov.style.display = 'flex';
document.body.style.overflow = 'hidden';
if (syncUrl) window.navReplaceView?.();
}
function closeSummaryPopup(syncUrl = true) {
const ov = document.getElementById('summary-overlay');
window._statsOpen = false;
if (ov) ov.style.display = 'none';
document.body.style.overflow = '';
if (syncUrl) window.navReplaceView?.();
}
window.openSummaryPopup = openSummaryPopup;
window.closeSummaryPopup = closeSummaryPopup;
function setupSummaryPopup() {
const ov = document.getElementById('summary-overlay');
if (!ov || ov._wired) return;
ov._wired = true;
document.getElementById('stats-btn')?.addEventListener('click', openSummaryPopup);
document.getElementById('summary-close')?.addEventListener('click', closeSummaryPopup);
document.getElementById('summary-dl-json')?.addEventListener('click', e => { e.preventDefault(); exportSummaryJSON(); });
document.getElementById('summary-dl-md')?.addEventListener('click', e => { e.preventDefault(); exportSummaryMarkdown(); });
document.getElementById('summary-copy-json')?.addEventListener('click', e => copySummaryText(summaryJSONText(), e.currentTarget));
document.getElementById('summary-copy-md')?.addEventListener('click', e => copySummaryText(summaryMarkdownText(), e.currentTarget));
ov.addEventListener('mousedown', e => { if (e.target === ov) closeSummaryPopup(); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && ov.style.display !== 'none') closeSummaryPopup();
});
}
window.setupSummaryPopup = setupSummaryPopup;