function toggleNodeSelected(node, level, section) {
if (!window._ntSelected) window._ntSelected = {};
if (!window._ntSelected[level]) window._ntSelected[level] = new Set();
const selectedIds = window._ntSelected[level];
const sel = !selectedIds.has(node.id);
if (sel) selectedIds.add(node.id); else selectedIds.delete(node.id);
section?._gNodeMap?.get(node.id)?.classList.toggle('node-selected', sel);
const row = section?.querySelector(
`.node-table-body .node-table tbody tr[data-node-id="${CSS.escape(node.id)}"]`);
if (row) {
row.classList.toggle('row-selected', sel);
const cb = row.querySelector('.nt-cb');
if (cb) cb.checked = sel;
}
markPopupSelected(node.id, sel);
section?._updateAllCb?.();
}
const IS_MAC = /Mac|iP(hone|ad|od)/.test(
(typeof navigator !== 'undefined' && (navigator.platform || navigator.userAgent)) || ''
);
const OPEN_SRC_KEY = IS_MAC ? 'Meta' : 'Control';
const isOpenSrcClick = e => (IS_MAC ? e.metaKey : e.ctrlKey);
window.isOpenSrcClick = isOpenSrcClick;
function kbdHintsHtml() {
const srcKey = IS_MAC ? '⌘' : 'Ctrl';
return `<span class="kbd-hint"><kbd>⇧ Shift</kbd> + click — select node</span>` +
`<span class="kbd-hint"><kbd>${srcKey}</kbd> + click — view source</span>` +
`<span class="kbd-hint kbd-hint-toggle"><kbd>t</kbd> — toggle baseline/current</span>`;
}
window.kbdHintsHtml = kbdHintsHtml;
(function initMapModifiers() {
const setShift = on => document.body.classList.toggle('shift-select', on);
const setSrc = on => document.body.classList.toggle('ctrl-link', on);
const hints = document.getElementById('kbd-hints');
if (hints) hints.innerHTML = kbdHintsHtml();
window.addEventListener('keydown', e => {
if (window.isPromptPopupOpen?.()) return; if (e.key === 'Shift') setShift(true);
if (e.key === OPEN_SRC_KEY) setSrc(true);
});
window.addEventListener('keyup', e => {
if (e.key === 'Shift') setShift(false);
if (e.key === OPEN_SRC_KEY) setSrc(false);
});
window.addEventListener('blur', () => { setShift(false); setSrc(false); });
})();
function renderBreadcrumb(level) {
level = level || currentLevel();
const grp = window.drillGroup;
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
const escA = s => esc(s).replace(/"/g,'"');
document.querySelectorAll(`.view[data-view="${level}"] .drill-breadcrumb`).forEach(bc => {
if (grp == null) { bc.style.display = 'none'; return; }
bc.style.display = '';
const grpKey = levelUi(level).grouping?.key || 'group';
const maxFocusD = window._FOCUS?.maxFocusD ?? 0;
const baseDig = window.drillDig ?? 0;
const fz = window.focusDig || 0;
const minFz = -Math.max(0, maxFocusD - baseDig);
const canDown = fz > minFz; const canUp = fz < 0;
const uNodes = (typeof unionGraph === 'function' ? unionGraph(level).nodes : []);
const filesUnder = (key, dg) => uNodes.reduce((c, n) => c + (groupKeyAtDig(level, n, dg) === key ? 1 : 0), 0);
const drillG = grouperForDig(level, baseDig);
const focusNodes = uNodes.filter(n => drillG(n) === grp);
const renderCount = f => {
if (f >= 0) return focusNodes.length; const D = Math.min(maxFocusD, Math.max(baseDig + 1, maxFocusD + Math.max(minFz, f) + 1));
return new Set(focusNodes.map(n => groupKeyAtDig(level, n, D))).size; };
const col = (inner, count) =>
`<span class="crumb-col">${inner}<span class="crumb-count">${count == null ? '' : count}</span></span>`;
const segs = String(grp).split('/');
const parts = [col(`<button class="drill-crumb" data-crumb-root="1" type="button">all ${esc(grpKey)}s</button>`,
window.groupCountAtDig?.(level, 0))];
for (let i = 0; i < segs.length; i++) {
const key = segs.slice(0, i + 1).join('/');
const last = i === segs.length - 1;
parts.push('<span class="drill-sep">›</span>');
if (last) {
parts.push('<span class="crumb-dig">' +
col(`<button class="crumb-dig-btn" data-crumb-dig-step="-1" type="button"${canDown ? '' : ' disabled'} title="Collapse files into folders">−</button>`, canDown ? renderCount(fz - 1) : null) +
col(`<span class="drill-crumb-cur">${esc(segs[i])}</span>`, renderCount(fz)) +
col(`<button class="crumb-dig-btn" data-crumb-dig-step="1" type="button"${canUp ? '' : ' disabled'} title="Expand folders into files">+</button>`, canUp ? renderCount(fz + 1) : null) +
'</span>');
} else {
parts.push(col(`<button class="drill-crumb" data-crumb-key="${escA(key)}" data-crumb-dig="${i}" type="button">${esc(segs[i])}</button>`, filesUnder(key, i)));
}
}
bc.innerHTML = parts.join(' ');
if (!bc.dataset.crumbInit) {
bc.dataset.crumbInit = '1';
bc.addEventListener('click', e => {
const step = e.target.closest('.crumb-dig-btn');
if (step) { if (!step.disabled) setDig(Number(step.dataset.crumbDigStep), level); return; }
const btn = e.target.closest('.drill-crumb');
if (!btn) return;
if (btn.dataset.crumbRoot) { drillOutOfGroup(level); return; }
drillIntoGroup(btn.dataset.crumbKey, level, Number(btn.dataset.crumbDig) || 0);
});
}
});
}
window.renderBreadcrumb = renderBreadcrumb;
function drillIntoGroup(groupId, level, dig) {
window.drillGroup = groupId;
window.drillDig = (dig != null) ? dig : (window.dig || 0);
window.focusDig = 0; document.querySelector(`.view[data-view="${level}"] .frame-wrap .dig-lod`)?.style.setProperty('display', 'none');
renderBreadcrumb(level);
window.navPushView?.();
document.querySelectorAll('.view').forEach(sec => { sec.dataset.rendered = 'false'; });
const active = document.querySelector('.view.active');
if (active && window.gv) renderView(active, { preserve: false });
}
function drillOutOfGroup(level) {
window.drillGroup = null;
window.focusDig = 0;
const frameWrap = document.querySelector(`.view[data-view="${level}"] .frame-wrap`);
frameWrap?.querySelector('.drill-breadcrumb')?.style.setProperty('display', 'none');
frameWrap?.querySelector('.dig-lod')?.style.removeProperty('display'); updateDigLabel(level);
window.navPushView?.();
document.querySelectorAll('.view').forEach(sec => { sec.dataset.rendered = 'false'; });
const active = document.querySelector('.view.active');
if (active && window.gv) renderView(active, { preserve: false });
}
function focusFolderTarget(level, n) {
const dirs = relPathOf(n.id).split('/').slice(0, -1);
const gk = levelUi(level).grouping?.key;
const crate = gk ? n[gk] : null;
const dig = (crate == null || crate === '')
? dirs.length
: Math.max(0, dirs.length - (crateRoots(level).get(String(crate)) || []).length);
return { key: groupKeyAtDig(level, n, dig), dig };
}
function clampFocusDig(z) {
const maxFocusD = window._FOCUS?.maxFocusD ?? 0;
const baseDig = window.drillDig ?? 0;
return Math.max(-Math.max(0, maxFocusD - baseDig), Math.min(0, z | 0));
}
function setDig(delta, level) {
level = level || currentLevel();
if (window.drillGroup !== null) {
const fz = clampFocusDig((window.focusDig || 0) + delta);
if (fz === (window.focusDig || 0)) return;
window.focusDig = fz;
} else {
const z = clampDig((window.dig || 0) + delta);
if (z === (window.dig || 0)) return;
window.dig = z;
}
updateDigLabel(level);
window.navReplaceView?.();
document.querySelectorAll('.view').forEach(sec => { sec.dataset.rendered = 'false'; });
const active = document.querySelector('.view.active');
if (active && window.gv) renderView(active, { preserve: false });
}
window.setDig = setDig;
function updateDigLabel(level) {
level = level || currentLevel();
const root = document.querySelector(`.view[data-view="${level}"] .dig-lod`);
if (!root) return;
if (window.drillGroup !== null) { renderBreadcrumb(level); return; }
const z = window.dig || 0;
const gk = levelUi(level).grouping?.key || 'group';
const val = root.querySelector('.dig-lod-val');
if (val) val.textContent = z === 0 ? gk : `/${gk}/folder${z > 0 ? '+' : ''}${z}`;
const curN = window.groupCountAtDig?.(level, z);
const outN = window.groupCountAtDig?.(level, z - 1);
const inN = window.groupCountAtDig?.(level, z + 1);
const maxD = window.maxCrateDepth?.(level) ?? 0;
root.querySelector('[data-lod="out"]')?.toggleAttribute('disabled', z <= -maxD || z <= DIG_MIN);
root.querySelector('[data-lod="in"]') ?.toggleAttribute('disabled',
z >= DIG_MAX || (z >= 0 && inN != null && curN != null && inN === curN));
const curC = root.querySelector('[data-count="cur"]');
const outC = root.querySelector('[data-count="out"]');
const inC = root.querySelector('[data-count="in"]');
if (curC) curC.textContent = curN != null ? String(curN) : '';
if (outC) outC.textContent = outN != null ? String(outN) : '';
if (inC) inC.textContent = inN != null ? String(inN) : '';
}
window.updateDigLabel = updateDigLabel;
function statusLineFor(node, level) {
const parts = [];
const name = node.name || node.id.split('/').pop() || node.id;
parts.push(name);
const path = (node.path || node.id || '').replace(/^\{[^}]+\}\//, '');
if (path && path !== name) parts.push(path);
const gk = levelUi(level)?.grouping?.key;
if (gk) {
const gv = nodeAttr(node, gk);
if (gv != null && gv !== '') parts.push(`${gk}: ${gv}`);
}
const hkV = nodeAttr(node, 'hk') ?? node.hk;
if (hkV != null) parts.push(`hk: ${fmtMetricShort(Number(hkV))}`);
const slocV = nodeAttr(node, 'sloc') ?? nodeAttr(node, 'loc') ?? node.sloc ?? node.loc;
if (slocV != null) parts.push(`sloc: ${fmtMetricShort(Number(slocV))}`);
if (node.fan_in != null) parts.push(`fan-in: ${node.fan_in}`);
if (node.fan_out != null) parts.push(`fan-out: ${node.fan_out}`);
return parts.join(' · ');
}
function computeGroupStats(level, grouper) {
const cyc = window.CYCLES?.[level]?.nodeCycleStatus;
const stats = new Map();
for (const n of unionGraph(level).nodes) {
const grp = grouper(n);
let s = stats.get(grp);
if (!s) { s = { name: grp, files: 0, folders: 0, sloc: 0, hk: 0, cycle: 0, _common: null, _dirs: new Set() }; stats.set(grp, s); }
s.files++;
s.sloc += Number(n.sloc ?? n.loc ?? 0);
s.hk += Number(n.hk ?? 0);
const cs = cyc?.get(n.id);
if (cs && cs !== 'none') s.cycle++;
const dir = relPathOf(n.id).split('/').slice(0, -1);
s._dirs.add(dir.join('/'));
if (s._common === null) s._common = dir.slice();
else { let i = 0; while (i < s._common.length && i < dir.length && s._common[i] === dir[i]) i++; s._common.length = i; }
}
for (const s of stats.values()) {
s.path = s._common && s._common.length ? '/' + s._common.join('/') : '/';
s.folders = s._dirs.size;
delete s._common; delete s._dirs;
}
return stats;
}
function statusLineForGroup(stats) {
const parts = [stats.name === '_root' ? '/' : stats.name];
const norm = s => String(s).replace(/^[←→]\s*/, '').replace(/^\//, '');
if (stats.path && stats.path !== '/' && norm(stats.path) !== norm(stats.name)) parts.push(stats.path);
if (stats.files) parts.push(`files: ${stats.files}`);
if (stats.folders) parts.push(`folders: ${stats.folders}`);
if (stats.sloc > 0) parts.push(`sloc: ${fmtMetricShort(stats.sloc)}`);
if (stats.hk > 0) parts.push(`hk: ${fmtMetricShort(stats.hk)}`);
if (stats.cycle > 0) parts.push(`in cycle: ${stats.cycle}`);
return parts.join(' · ');
}
function raisePaint(el) {
if (el && !el._raised) { el.parentNode?.appendChild(el); el._raised = true; }
}
const HOVER_DELAY = 70;
function wireNodeHover(el, onEnter, onLeave) {
let timer = null, active = false;
el.addEventListener('mouseenter', () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null; active = true;
(el.ownerSVGElement || el.closest('svg'))
?.querySelectorAll('.node-hl').forEach(n => { if (n !== el) n.classList.remove('node-hl'); });
raisePaint(el);
el.classList.add('node-hl');
onEnter?.();
}, HOVER_DELAY);
});
el.addEventListener('mouseleave', e => {
if (timer) { clearTimeout(timer); timer = null; }
if (active) { active = false; el.classList.remove('node-hl'); }
onLeave?.(e);
});
}
function setupEdgeHighlight(svgFrame, level) {
const allEdgeEls = [...svgFrame.querySelectorAll('g.edge')];
const allNodeEls = [...svgFrame.querySelectorAll('g.node')];
if (allEdgeEls.length === 0) return;
const nodeById = new Map((typeof unionGraph === 'function' ? unionGraph(level).nodes : []).map(n => [n.id, n]));
const sb = svgFrame._statusBar;
const showSB = text => { if (sb) { sb.textContent = text; sb.hidden = false; } };
const hideSB = () => { if (sb) { sb.hidden = true; sb.textContent = ''; } };
const inEdges = allEdgeEls.filter(e => e.classList.contains('edge-in'));
const outEdges = allEdgeEls.filter(e => e.classList.contains('edge-out'));
const edgeMap = new Map();
for (const edgeEl of allEdgeEls) {
const title = edgeEl.querySelector('title')?.textContent?.trim() ?? '';
const sep = title.indexOf('->');
if (sep < 0) continue;
const src = title.slice(0, sep);
const tgt = title.slice(sep + 2);
for (const id of [src, tgt]) {
if (!edgeMap.has(id)) edgeMap.set(id, new Set());
edgeMap.get(id).add(edgeEl);
}
}
const applyHighlight = connected => {
svgFrame.classList.add('node-hovered');
for (const e of allEdgeEls) {
e.classList.remove('edge-connected', 'edge-dim');
if (connected.has(e)) e.classList.add('edge-connected');
else e.classList.add('edge-dim');
}
};
const clearHighlight = () => {
svgFrame.classList.remove('node-hovered');
for (const e of allEdgeEls) e.classList.remove('edge-connected', 'edge-dim');
};
const setShowInOut = (showIn, showOut) => {
svgFrame.classList.toggle('show-in-edges', !!showIn);
svgFrame.classList.toggle('show-out-edges', !!showOut);
};
let ehTimer = null;
const ehSchedule = fn => {
if (ehTimer) clearTimeout(ehTimer);
ehTimer = setTimeout(() => { ehTimer = null; fn(); }, HOVER_DELAY);
};
const clusterData = new Map();
let clusterInEl = null, clusterOutEl = null;
for (const clusterEl of svgFrame.querySelectorAll('g.cluster')) {
const cTitle = clusterEl.querySelector('title')?.textContent?.trim() || '';
const label = clusterEl.querySelector('text')?.textContent?.trim() || '';
let edges, nc;
if (cTitle === 'cluster_in') {
clusterInEl = clusterEl;
edges = new Set(inEdges);
nc = inEdges.length;
} else if (cTitle === 'cluster_out') {
clusterOutEl = clusterEl;
edges = new Set(outEdges);
nc = outEdges.length;
} else if (cTitle.startsWith('cluster_crate_')) {
const matchIds = [...edgeMap.keys()].filter(k => k === label || k.startsWith(label + '/'));
edges = new Set();
for (const id of matchIds) {
for (const e of (edgeMap.get(id) ?? new Set())) edges.add(e);
}
nc = matchIds.length;
clusterEl.style.cursor = 'pointer';
clusterEl.addEventListener('click', e => {
if (e.target.closest('g.node')) return; e.stopPropagation();
drillIntoGroup(label, level, 0);
});
} else {
const matchIds = [...edgeMap.keys()].filter(k => {
const node = nodeById.get(k);
return node ? nodeFullDir(node) === label : false;
});
edges = new Set();
for (const id of matchIds) {
for (const e of (edgeMap.get(id) ?? new Set())) edges.add(e);
}
nc = matchIds.length;
const sampleId = clusterEl.querySelector('g.node title')?.textContent?.trim();
const sample = sampleId ? nodeById.get(sampleId) : null;
if (sample) {
const tgt = focusFolderTarget(level, sample);
clusterEl.style.cursor = 'pointer';
clusterEl.addEventListener('click', e => {
if (e.target.closest('g.node')) return; e.stopPropagation();
drillIntoGroup(tgt.key, level, tgt.dig);
});
}
}
const ec = edges.size;
const statusText = [label,
nc ? `${nc} node${nc !== 1 ? 's' : ''}` : '',
ec ? `${ec} edge${ec !== 1 ? 's' : ''}` : '',
].filter(Boolean).join(' · ');
const isIn = cTitle === 'cluster_in', isOut = cTitle === 'cluster_out';
clusterData.set(clusterEl, { edges, statusText, isIn, isOut });
clusterEl.addEventListener('mouseenter', () =>
ehSchedule(() => { applyHighlight(edges); showSB(statusText); setShowInOut(isIn, isOut); }));
clusterEl.addEventListener('mouseleave', () =>
ehSchedule(() => { clearHighlight(); hideSB(); setShowInOut(false, false); }));
}
inEdges.forEach(e => e.classList.add('cluster-edge-hidden'));
outEdges.forEach(e => e.classList.add('cluster-edge-hidden'));
for (const nodeEl of allNodeEls) {
const nodeId = nodeEl.querySelector('title')?.textContent?.trim();
if (!nodeId) continue;
nodeEl.addEventListener('mouseenter', () => {
ehSchedule(() => { applyHighlight(edgeMap.get(nodeId) ?? new Set()); setShowInOut(false, false); });
});
nodeEl.addEventListener('mouseleave', e => {
const destCluster = e.relatedTarget?.closest?.('g.cluster');
const cd = destCluster ? clusterData.get(destCluster) : null;
if (cd) ehSchedule(() => { applyHighlight(cd.edges); showSB(cd.statusText); setShowInOut(cd.isIn, cd.isOut); });
else ehSchedule(() => { clearHighlight(); setShowInOut(false, false); });
});
}
}
function setupTooltips(svgFrame, level) {
svgFrame.querySelectorAll('g.edge title, g.cluster title').forEach(t => t.remove());
const drillGroup = window.drillGroup || null;
const section = svgFrame.closest('.view');
const gNodeMap = new Map();
const sb = svgFrame._statusBar;
const showStatus = text => { if (sb) { sb.textContent = text; sb.hidden = false; } };
const hideStatus = () => { if (sb) { sb.hidden = true; sb.textContent = ''; } };
if (drillGroup !== null) {
const nodeMap = new Map(unionGraph(level).nodes.map(n => [n.id, n]));
const neighbourStats = computeGroupStats(level, grouperForDig(level, window.drillDig ?? 0));
const focusFolder = window._FOCUS?.folderMode ? window._FOCUS : null;
const focusStats = focusFolder ? computeGroupStats(level, grouperForDig(level, focusFolder.focusD)) : null;
svgFrame.querySelectorAll('g.node').forEach(g => {
const titleEl = g.querySelector('title');
const nodeId = titleEl?.textContent?.trim();
titleEl?.remove();
const neighborPrefix = nodeId?.startsWith('IN\x01') ? 'IN\x01'
: nodeId?.startsWith('OUT\x01') ? 'OUT\x01' : null;
if (neighborPrefix) {
const neighborGroup = nodeId.slice(neighborPrefix.length);
const arrow = neighborPrefix === 'IN\x01' ? '← ' : '→ ';
g.addEventListener('click', e => {
e.stopPropagation();
drillIntoGroup(neighborGroup, level);
});
wireNodeHover(g,
() => {
const st = neighbourStats.get(neighborGroup);
showStatus(st ? statusLineForGroup({ ...st, name: arrow + st.name })
: arrow + neighborGroup);
},
e => { if (!e.relatedTarget?.closest?.('g.cluster')) hideStatus(); });
return;
}
if (focusFolder && !nodeMap.has(nodeId)) {
g.addEventListener('click', e => {
e.stopPropagation();
drillIntoGroup(nodeId, level, focusFolder.focusD);
});
wireNodeHover(g,
() => { const st = focusStats?.get(nodeId); showStatus(st ? statusLineForGroup(st) : nodeId); },
e => { if (!e.relatedTarget?.closest?.('g.cluster')) hideStatus(); });
return;
}
const node = nodeMap.get(nodeId);
if (!node) return;
g.dataset.nodeId = nodeId;
gNodeMap.set(nodeId, g);
g.addEventListener('click', e => {
e.stopPropagation();
if (isOpenSrcClick(e)) {
const url = nodeSourceUrl(node, level);
if (url) window.open(url, '_blank', 'noopener');
return;
}
if (e.shiftKey) { toggleNodeSelected(node, level, section); return; }
if (window.openModalForNode?.(node.id, level)) window.navPush?.(level, node.id);
});
wireNodeHover(g,
() => {
section?.querySelector(`tr[data-node-id="${nodeId.replace(/\\/g,'\\\\').replace(/"/g,'\\"')}"]`)
?.classList.add('row-hl');
showStatus(statusLineFor(node, level));
},
e => {
section?.querySelector(`tr[data-node-id="${nodeId.replace(/\\/g,'\\\\').replace(/"/g,'\\"')}"]`)
?.classList.remove('row-hl');
if (!e.relatedTarget?.closest?.('g.cluster')) hideStatus();
});
});
} else {
const gOf = grouperForDig(level, window.dig || 0);
const groupStats = computeGroupStats(level, gOf);
svgFrame.querySelectorAll('g.node').forEach(g => {
const titleEl = g.querySelector('title');
const groupId = titleEl?.textContent?.trim();
titleEl?.remove();
if (!groupId) return;
const stats = groupStats.get(groupId);
if (!stats) return;
g.dataset.groupId = groupId;
g.dataset.groupStats = JSON.stringify(stats);
g.addEventListener('click', e => {
e.stopPropagation();
drillIntoGroup(groupId, level);
});
wireNodeHover(g,
() => showStatus(statusLineForGroup(stats)),
e => { if (!e.relatedTarget?.closest?.('g.cluster')) hideStatus(); });
});
}
if (section) section._gNodeMap = gNodeMap;
}