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 lensInfo(level) {
if (window.drillGroup !== null) {
const minFz = focusMinFz(level);
const fz = window.focusDig || 0;
return { depth: fz - minFz, canDown: fz > minFz, canUp: fz < 0,
cur: focusRenderCount(level, fz),
down: fz > minFz ? focusRenderCount(level, fz - 1) : null,
up: fz < 0 ? focusRenderCount(level, fz + 1) : null };
}
const z = window.dig || 0;
const floor = digFloor(level);
let ceil = Math.max(z, 0), prev = window.groupCountAtDig(level, ceil);
while (ceil < DIG_MAX) {
const c = window.groupCountAtDig(level, ceil + 1);
if (c == null || c === prev) break;
ceil++; prev = c;
}
if (z > ceil) ceil = z;
return { depth: z - overviewBaseDig(level), canDown: z > floor, canUp: z < ceil,
cur: window.groupCountAtDig(level, z),
down: z > floor ? window.groupCountAtDig(level, z - 1) : null,
up: z < ceil ? window.groupCountAtDig(level, z + 1) : null };
}
function underDepthOf(level, n) {
const dirs = nodeDirSegs(n.id);
const crate = window.viewTier(level) === 'file' ? null : crateIdOf(level, n);
return crate == null
? dirs.length - maxFileDepth(level)
: Math.max(0, dirs.length - (crateRoots(level).get(crate) || []).length);
}
function focusMaxDepth(level) {
const grp = window.drillGroup;
if (grp == null) return 0;
const gOf = grouperForDig(level, window.drillDig ?? 0);
let m = null;
for (const n of unionGraph(level).nodes) {
if (gOf(n) !== grp) continue;
const ud = underDepthOf(level, n);
if (m === null || ud > m) m = ud;
}
return m == null ? 0 : m;
}
function focusMinFz(level) {
return -Math.max(0, focusMaxDepth(level) - (window.drillDig ?? 0));
}
window.focusMinFz = focusMinFz;
function focusRenderCount(level, fz) {
const grp = window.drillGroup;
const baseDig = window.drillDig ?? 0;
const D = fz - focusMinFz(level);
const gOf = grouperForDig(level, baseDig);
let files = 0; const boxes = new Set();
for (const n of unionGraph(level).nodes) {
if (gOf(n) !== grp) continue;
if (underDepthOf(level, n) - baseDig <= D) files++;
else boxes.add(groupKeyAtDig(level, n, baseDig + D + 1));
}
return files + boxes.size;
}
const FOCUS_NODE_BUDGET = 20;
function landingFocusDig(level) {
const minFz = focusMinFz(level);
let best = minFz;
for (let fz = minFz; fz <= 0; fz++) {
if (focusRenderCount(level, fz) < FOCUS_NODE_BUDGET) best = fz;
}
return best;
}
window.landingFocusDig = landingFocusDig;
function digOfKeyForTier(level, key, tier) {
if (tier === 'file') return key.split('/').length - maxFileDepth(level);
const cut = key.indexOf('/');
return cut >= 0 ? key.slice(cut + 1).split('/').length : 0; }
window.digOfKeyForTier = digOfKeyForTier;
function chipDig(level, i, tier) {
return tier === 'file' ? (i + 1) - maxFileDepth(level) : i;
}
function tierAnchorHtml(level, tier) {
const tierLabel = tier === 'file' ? 'files' : (levelUi(level).grouping?.key || 'crate') + 's';
if (!levelUi(level).grouping?.key) return `<span class="drill-crumb-cur tier-label">${escHtml(tierLabel)}</span>`;
return `<button class="drill-crumb tier-label" data-tier-toggle type="button" title="Switch dimension (crates ⇄ files)">${escHtml(tierLabel)} ▾</button>` +
`<span class="tier-menu" hidden>` +
`<button class="tier-opt${tier === 'crate' ? ' on' : ''}" data-tier="crate" type="button">crates</button>` +
`<button class="tier-opt${tier === 'file' ? ' on' : ''}" data-tier="file" type="button">files</button>` +
`</span>`;
}
window.tierAnchorHtml = tierAnchorHtml;
function handleTierToggle(e) {
const tg = e.target.closest('[data-tier-toggle]');
if (!tg) return false;
tg.parentElement.querySelector('.tier-menu')?.toggleAttribute('hidden');
e.stopPropagation();
return true;
}
window.handleTierToggle = handleTierToggle;
function renderBreadcrumb(level) {
level = level || currentLevel();
const grp = window.drillGroup;
const tier = window.viewTier(level);
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 col = (inner, count) =>
`<span class="crumb-col">${inner}<span class="crumb-count">${count == null ? '' : count}</span></span>`;
document.querySelectorAll(`.view[data-view="${level}"] .drill-breadcrumb`).forEach(bc => {
bc.style.display = '';
const rootLabel = tier === 'file' ? 'root' : 'all';
const rootCount = tier === 'crate' ? window.groupCountAtDig?.(level, 0)
: uNodes.filter(n => !isExternalNode(n, level)).length;
const parts = [col(`<span class="crumb-tier">${tierAnchorHtml(level, tier)}</span>`, null)];
parts.push('<span class="drill-sep">›</span>');
parts.push(grp == null
? col(`<span class="drill-crumb-cur">${rootLabel}</span>`, rootCount)
: col(`<button class="drill-crumb" data-crumb-root="1" type="button" title="Show the whole overview">${rootLabel}</button>`, rootCount));
if (grp != null) {
const segs = String(grp).split('/');
for (let i = 0; i < segs.length; i++) {
const key = segs.slice(0, i + 1).join('/');
const dg = chipDig(level, i, tier);
const last = i === segs.length - 1;
parts.push('<span class="drill-sep">›</span>');
if (last) parts.push(col(`<span class="drill-crumb-cur">${escHtml(segs[i])}</span>`, filesUnder(key, dg)));
else parts.push(col(`<button class="drill-crumb" data-crumb-key="${escAttr(key)}" data-crumb-dig="${dg}" type="button">${escHtml(segs[i])}</button>`, filesUnder(key, dg)));
}
}
const li = lensInfo(level);
if (li.canDown || li.canUp) {
parts.push('<span class="crumb-lens">' +
col(`<button class="lens-btn" data-lens-step="-1" type="button"${li.canDown ? '' : ' disabled'} title="Collapse one level">⊟</button>`, li.canDown ? li.down : null) +
col(`<span class="lens-depth" title="reveal depth">depth ${li.depth}</span>`, li.cur) +
col(`<button class="lens-btn" data-lens-step="1" type="button"${li.canUp ? '' : ' disabled'} title="Reveal one level deeper">⊞</button>`, li.canUp ? li.up : null) +
'</span>');
}
bc.innerHTML = parts.join(' ');
if (!bc.dataset.crumbInit) {
bc.dataset.crumbInit = '1';
bc.addEventListener('click', e => {
if (handleTierToggle(e)) return;
const opt = e.target.closest('[data-tier]');
if (opt) { switchTier(opt.dataset.tier, level); return; }
const step = e.target.closest('.lens-btn');
if (step) { if (!step.disabled) setDig(Number(step.dataset.lensStep), 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;
document.addEventListener('click', e => {
if (e.target.closest('[data-tier-toggle]') || e.target.closest('.tier-menu')) return;
document.querySelectorAll('.tier-menu:not([hidden])').forEach(m => m.setAttribute('hidden', ''));
});
function switchTier(tier, level) {
level = level || currentLevel();
document.querySelectorAll('.tier-menu:not([hidden])').forEach(m => m.setAttribute('hidden', ''));
if (tier === window.viewTier(level)) { if (window.drillGroup !== null) drillOutOfGroup(level);
return;
}
const cur = window.drillGroup;
let mapped = null;
if (cur != null) {
const map = k => tier === 'file' ? crateKeyToFileKey(level, k) : fileKeyToCrateKey(level, k);
mapped = map(cur);
if (mapped == null) { const segs = String(cur).split('/');
for (let k = segs.length - 1; k > 0 && mapped == null; k--) mapped = map(segs.slice(0, k).join('/'));
}
}
window.tier = tier;
if (mapped != null && mapped !== '_root') {
window.drillGroup = mapped;
window.drillDig = digOfKeyForTier(level, mapped, tier);
window.focusDig = landingFocusDig(level); } else {
window.drillGroup = null;
window.dig = tier === 'file' ? clampDig(digFloor(level) + 1) : 0;
}
renderBreadcrumb(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.switchTier = switchTier;
function drillIntoGroup(groupId, level, dig) {
window.drillGroup = groupId;
window.drillDig = (dig != null) ? dig : (window.dig || 0);
window.focusDig = landingFocusDig(level); 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;
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 });
}
const SVGNS = 'http://www.w3.org/2000/svg';
const FAN_GEO = { BOXH: 22, BOXMINW: 52, BOXPADX: 18, GAPX: 8, GAPY: 8, LBLH: 18, PAD: 12, BTN: 18, PILLW: 128 };
const FAN_PAL = {
in: { fill: '#edf7ed', stroke: '#88bb88', text: '#447744' },
out: { fill: '#fdf3e3', stroke: '#ccaa77', text: '#886633' },
};
const svgEl = (name, attrs) => {
const e = document.createElementNS(SVGNS, name);
for (const k in attrs) if (attrs[k] != null) e.setAttribute(k, attrs[k]);
return e;
};
function fanCollapsed(dir) {
const st = window._fanCollapsed || (window._fanCollapsed = { in: true, out: true });
return st[dir];
}
function nodeCenterUser(svg, el) {
const m = svg.getScreenCTM();
if (!m) return null;
const r = el.getBoundingClientRect();
const p = svg.createSVGPoint();
p.x = r.left + r.width / 2; p.y = r.top + r.height / 2;
const o = p.matrixTransform(m.inverse());
return { x: o.x, y: o.y, hw: (r.width / 2) / (m.a || 1), hh: (r.height / 2) / (m.d || 1) };
}
function rectEdgePoint(t, from) {
const dx = from.x - t.x, dy = from.y - t.y;
if (!dx && !dy) return { x: t.x, y: t.y };
const s = Math.min(dx ? t.hw / Math.abs(dx) : Infinity, dy ? t.hh / Math.abs(dy) : Infinity);
return { x: t.x + dx * s, y: t.y + dy * s };
}
const _fanTextW = new Map();
function fanMeasure(svg, s) {
if (_fanTextW.has(s)) return _fanTextW.get(s);
const t = svgEl('text', { 'font-size': 11, 'font-family': 'Helvetica', x: -9999, y: -9999 });
t.textContent = s;
svg.appendChild(t);
let w = 0; try { w = t.getComputedTextLength(); } catch {}
t.remove();
if (!w) w = s.length * 6.6;
_fanTextW.set(s, w);
return w;
}
function chipFlow(svg, secData, availW) {
const chips = [];
const rowW = []; let x = 0, row = 0;
for (const c of secData) {
const label = `${c.crate} (${c.count})`;
const w = Math.max(FAN_GEO.BOXMINW, Math.ceil(fanMeasure(svg, label)) + FAN_GEO.BOXPADX);
if (x > 0 && x + w > availW) { row++; x = 0; }
chips.push({ c, label, x, row, w });
rowW[row] = x + w;
x += w + FAN_GEO.GAPX;
}
const rows = secData.length ? row + 1 : 0;
return { chips, rows, rowW, h: rows * FAN_GEO.BOXH + Math.max(0, rows - 1) * FAN_GEO.GAPY };
}
function fanReservedH(svg, secData, availW) {
if (!secData.length) return 0;
return FAN_GEO.PAD * 2 + FAN_GEO.LBLH + chipFlow(svg, secData, availW).h;
}
function fanBtn(sym, x, y, pal, onClick) {
const g = svgEl('g', { class: 'fan-btn' });
g.appendChild(svgEl('rect', { x, y, width: FAN_GEO.BTN, height: FAN_GEO.BTN, rx: 4, fill: 'transparent' }));
const t = svgEl('text', { x: x + FAN_GEO.BTN / 2, y: y + FAN_GEO.BTN / 2 + 5, 'text-anchor': 'middle', 'font-size': 15, 'font-weight': 700, fill: pal.text, 'font-family': 'Helvetica' });
t.textContent = sym;
g.appendChild(t);
if (onClick) g.addEventListener('click', e => { e.stopPropagation(); onClick(); });
return g;
}
function composeFanSections(svgFrame, level) {
const svg = svgFrame?.querySelector('svg');
if (!svg) return;
svg.querySelector('#fan-overlay')?.remove();
const data = window._fanData || { in: [], out: [] };
if (!data.in.length && !data.out.length) return;
if (!svgFrame._fanBase) {
const vb = (svg.getAttribute('viewBox') || '').split(/\s+/).map(Number);
if (!(vb.length === 4 && vb.every(Number.isFinite))) return;
const anchors = new Map();
svg.querySelectorAll('g.node').forEach(g => {
const id = g.querySelector('title')?.textContent?.trim();
if (!id) return;
const c = nodeCenterUser(svg, g);
if (c) anchors.set(id, c);
});
const base = { x: vb[0], y: vb[1], w: vb[2], h: vb[3] };
base.secW = Math.max(base.w, 250);
base.secX = base.x + (base.w - base.secW) / 2;
const availW = base.secW - FAN_GEO.PAD * 2;
base.topH = fanReservedH(svg, data.in, availW);
base.botH = fanReservedH(svg, data.out, availW);
svgFrame._fanBase = base; svgFrame._fanAnchors = anchors;
svg.setAttribute('viewBox', `${base.secX} ${base.y - base.topH} ${base.secW} ${base.h + base.topH + base.botH}`);
}
const base = svgFrame._fanBase, anchors = svgFrame._fanAnchors;
const overlay = svgEl('g', { id: 'fan-overlay' });
const defs = svgEl('defs', {});
for (const dir of ['in', 'out']) {
const m = svgEl('marker', { id: `fan-ah-${dir}`, markerWidth: 7, markerHeight: 6, refX: 6, refY: 3, orient: 'auto' });
m.appendChild(svgEl('path', { d: 'M0,0 L0,6 L7,3 z', fill: FAN_PAL[dir].stroke }));
defs.appendChild(m);
}
overlay.appendChild(defs);
const trunc = (s, n) => s.length > n ? s.slice(0, n - 1) + '…' : s;
const buildSection = (dir, secData, bandTop, bandH) => {
if (!secData.length) return;
const pal = FAN_PAL[dir];
const cx = base.x + base.w / 2;
const g = svgEl('g', { class: `fan-section fan-${dir}` });
const collapsed = secData.length > 1 && fanCollapsed(dir);
const btnX = base.secX + base.secW - FAN_GEO.PAD - FAN_GEO.BTN;
const btnY = bandTop + FAN_GEO.PAD / 2 + (FAN_GEO.BOXH - FAN_GEO.BTN) / 2;
const lblX = base.secX + base.secW / 2;
const lblY = bandTop + FAN_GEO.PAD / 2 + FAN_GEO.BOXH / 2 + 4;
const drawArrows = (our, ax, ay, parent) => {
for (const o of our) {
const fa = anchors.get(o.fid);
if (!fa) continue;
const ep = rectEdgePoint(fa, { x: ax, y: ay });
const d = dir === 'in' ? `M${ax},${ay} L${ep.x},${ep.y}` : `M${ep.x},${ep.y} L${ax},${ay}`;
parent.appendChild(svgEl('path', {
d, fill: 'none', stroke: pal.stroke, 'stroke-width': 1.2,
'stroke-dasharray': o.flow ? null : '4,3',
'marker-end': `url(#fan-ah-${dir})`,
'data-fid': o.fid,
class: `fan-arrow status-${o.status || 'unchanged'}`,
}));
}
};
if (collapsed) {
const pillH = FAN_GEO.BOXH, pillW = base.secW - 8;
const px = base.secX + 4, py = bandTop + FAN_GEO.PAD / 2;
const agg = new Map();
for (const c of secData) for (const o of c.our) {
const a = agg.get(o.fid) || { fid: o.fid, flow: false, status: o.status };
a.flow = a.flow || o.flow; agg.set(o.fid, a);
}
const pill = svgEl('g', { class: 'fan-pill' });
drawArrows([...agg.values()], cx, dir === 'in' ? py + pillH : py, pill);
pill.appendChild(svgEl('rect', { x: px, y: py, width: pillW, height: pillH, fill: pal.fill, stroke: pal.stroke }));
const t = svgEl('text', { x: lblX, y: lblY, 'text-anchor': 'middle', 'font-size': 11, fill: pal.text, 'font-family': 'Helvetica' });
t.textContent = `Fan-${dir} ${secData.length}`;
pill.appendChild(t);
pill.appendChild(fanBtn('+', btnX, btnY, pal));
pill.addEventListener('click', e => { e.stopPropagation(); toggleFanSection(dir); });
g.appendChild(pill);
} else {
const lbl = svgEl('text', { x: lblX, y: lblY, 'text-anchor': 'middle', 'font-size': 11, fill: pal.text, 'font-family': 'Helvetica' });
lbl.textContent = `Fan-${dir}`;
g.appendChild(lbl);
if (secData.length > 1)
g.appendChild(fanBtn('−', btnX, btnY, pal, () => toggleFanSection(dir)));
const startX = base.secX + FAN_GEO.PAD;
const gridTop = bandTop + FAN_GEO.PAD + FAN_GEO.LBLH;
const flow = chipFlow(svg, secData, base.secW - FAN_GEO.PAD * 2);
const contentH = FAN_GEO.LBLH + flow.h;
const bg = svgEl('rect', { class: 'fan-bg', x: base.secX + 4, y: bandTop + FAN_GEO.PAD / 2, width: base.secW - 8, height: contentH + FAN_GEO.PAD, fill: pal.fill, stroke: pal.stroke });
bg.addEventListener('mouseenter', () => g.classList.add('fan-show-all'));
bg.addEventListener('mouseleave', () => g.classList.remove('fan-show-all'));
g.insertBefore(bg, g.firstChild);
const availW = base.secW - FAN_GEO.PAD * 2;
for (const chip of flow.chips) {
const c = chip.c;
const bx = startX + (availW - flow.rowW[chip.row]) / 2 + chip.x;
const by = gridTop + chip.row * (FAN_GEO.BOXH + FAN_GEO.GAPY);
const box = svgEl('g', { class: `fan-crate status-${c.status}` });
box.appendChild(svgEl('rect', { x: bx, y: by, width: chip.w, height: FAN_GEO.BOXH, fill: pal.fill, stroke: pal.stroke, 'stroke-dasharray': c.count === 0 ? '4,3' : null }));
const ct = svgEl('text', { x: bx + chip.w / 2, y: by + FAN_GEO.BOXH / 2 + 4, 'text-anchor': 'middle', 'font-size': 11, fill: pal.text, 'font-family': 'Helvetica' });
ct.textContent = chip.label;
box.appendChild(ct);
box.addEventListener('click', e => {
e.stopPropagation();
const t = crateFocusTarget(level, c.crate);
drillIntoGroup(t.key, level, t.dig);
});
drawArrows(c.our, bx + chip.w / 2, dir === 'in' ? by + FAN_GEO.BOXH : by, box);
g.appendChild(box);
}
}
overlay.appendChild(g);
};
buildSection('in', data.in, base.y - base.topH, base.topH);
buildSection('out', data.out, base.y + base.h, base.botH);
svg.appendChild(overlay);
}
window.composeFanSections = composeFanSections;
function toggleFanSection(dir) {
const st = window._fanCollapsed || (window._fanCollapsed = { in: true, out: true });
st[dir] = !st[dir];
if (window._fanFrame) composeFanSections(window._fanFrame, window._fanFrame.dataset.fanLevel);
}
window.toggleFanSection = toggleFanSection;
function fanHighlightFile(on, fid) {
const ov = window._fanFrame?.querySelector('#fan-overlay');
if (!ov) return;
ov.querySelectorAll('.fan-arrow.fan-arrow-on').forEach(a => a.classList.remove('fan-arrow-on'));
if (!on || !fid) return;
const ids = Array.isArray(fid) ? new Set(fid) : null;
ov.querySelectorAll('.fan-arrow').forEach(a => {
const f = a.getAttribute('data-fid');
if (ids ? ids.has(f) : f === fid) a.classList.add('fan-arrow-on');
});
}
window.fanHighlightFile = fanHighlightFile;
function focusFolderTarget(level, n) {
const dig = underDepthOf(level, n);
return { key: groupKeyAtDig(level, n, dig), dig };
}
function crateFocusTarget(level, crate) {
if (window.viewTier(level) === 'file') {
const key = crateKeyToFileKey(level, crate);
if (key && key !== '_root') return { key, dig: digOfKeyForTier(level, key, 'file') };
}
return { key: crate, dig: 0 };
}
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;
}
renderBreadcrumb(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) { renderBreadcrumb(level || currentLevel()); }
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 = nodeDirSegs(n.id);
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 focusBase = window.focusStripBase?.(level) ?? '';
const nodeRelDir = n => stripDirPrefix(focusBase, nodeFullDir(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, isLeaf = false) => {
svgFrame.classList.add('node-hovered');
svgFrame.classList.toggle('leaf-hovered', !!isLeaf);
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', 'leaf-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, memberIds = null;
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;
const crateLabelEl = clusterEl.querySelector('text');
if (crateLabelEl) crateLabelEl.style.cursor = 'pointer';
clusterEl.addEventListener('click', e => {
if (e.target.closest('g.node')) return; if (!e.target.closest('text')) return; e.stopPropagation();
drillIntoGroup(label, level, 0);
});
} else {
const matchIds = [...edgeMap.keys()].filter(k => {
const node = nodeById.get(k);
return node ? nodeRelDir(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;
memberIds = matchIds;
const sample = [...nodeById.values()].find(n => nodeRelDir(n) === label);
if (sample) {
const tgt = focusFolderTarget(level, sample);
const dirLabelEl = clusterEl.querySelector('text');
if (dirLabelEl) dirLabelEl.style.cursor = 'pointer';
clusterEl.addEventListener('click', e => {
if (e.target.closest('g.node')) return; if (!e.target.closest('text')) 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(), true); 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 gAggMap = new Map();
const aggRow = key => section?.querySelector(`tr[data-agg-key="${(window.CSS?.escape ? CSS.escape(key) : key)}"]`);
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 drillG = grouperForDig(level, window.drillDig ?? 0);
const neighbourStats = computeGroupStats(level, n => crateIdOf(level, n) ?? drillG(n));
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 dir = neighborPrefix === 'IN\x01' ? 'in' : 'out';
const arrow = dir === 'in' ? '← ' : '→ ';
if (neighborGroup === '\x02') {
g.addEventListener('click', e => { e.stopPropagation(); toggleFanSection(dir); });
wireNodeHover(g,
() => showStatus(`${arrow}Fan-${dir} — click to expand`),
e => { if (!e.relatedTarget?.closest?.('g.cluster')) hideStatus(); });
return;
}
g.addEventListener('click', e => {
e.stopPropagation();
const t = crateFocusTarget(level, neighborGroup);
drillIntoGroup(t.key, level, t.dig);
});
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); window.fanHighlightFile?.(true, nodeId); },
e => { window.fanHighlightFile?.(false, nodeId); 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));
window.fanHighlightFile?.(true, nodeId);
},
e => {
section?.querySelector(`tr[data-node-id="${nodeId.replace(/\\/g,'\\\\').replace(/"/g,'\\"')}"]`)
?.classList.remove('row-hl');
window.fanHighlightFile?.(false, nodeId);
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);
const aggKey = (window.dig || 0) === 0 ? 'group:' + groupId : null;
if (aggKey) {
gAggMap.set(aggKey, g);
if (section?.querySelector(`tr[data-agg-key="${(window.CSS?.escape ? CSS.escape(aggKey) : aggKey)}"].row-selected`))
g.classList.add('node-selected');
}
g.addEventListener('click', e => {
e.stopPropagation();
drillIntoGroup(groupId, level);
});
wireNodeHover(g,
() => { if (aggKey) aggRow(aggKey)?.classList.add('row-hl'); showStatus(statusLineForGroup(stats)); },
e => { if (aggKey) aggRow(aggKey)?.classList.remove('row-hl'); if (!e.relatedTarget?.closest?.('g.cluster')) hideStatus(); });
});
}
if (section) { section._gNodeMap = gNodeMap; section._gAggMap = gAggMap; }
}