Files
codemap-skill/assets/template.html
T

474 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<!--
Functional Architecture Map (interactive). GENERATED from modules.json by the codemap skill.
Do not hand-edit — edit modules.json and re-run render.py.
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Functional Architecture Map</title>
<style>
:root{
--bg:#131517; --bg2:#0e1012; --panel:#1b1e21; --panel2:#202428;
--ink:#e8e6e3; --muted:#9aa1a8; --faint:#6b7280;
--border:#2a2e33; --border2:#363b41;
--accent:#f59e0b; --accent-dim:#b4730e; --accent-soft:rgba(245,158,11,.14);
--in:#7c8794; --out:#f59e0b;
--c-low:#525a62; --c-med:#7e8893; --c-high:#c9cdd2; --c-core:#f59e0b;
--mono:"SF Mono",ui-monospace,"Cascadia Code","JetBrains Mono",Consolas,monospace;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{background:var(--bg);
color:var(--ink); font-family:var(--sans); -webkit-font-smoothing:antialiased; overflow:hidden;}
.app{display:grid; grid-template-columns:1fr 348px; grid-template-rows:auto 1fr; height:100vh}
header{grid-column:1/3; display:flex; align-items:center; gap:16px; padding:12px 20px;
border-bottom:1px solid var(--border); background:var(--bg);}
header .mark{width:22px;height:22px;border-radius:6px;flex:0 0 auto;background:var(--accent);}
header h1{font-size:14px;font-weight:600;margin:0;letter-spacing:.1px;white-space:nowrap}
header h1 span{color:var(--muted);font-weight:400}
header .sub{font-size:11.5px;color:var(--faint);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
header .spacer{flex:1}
.search{display:flex;align-items:center;gap:8px;background:var(--panel);
border:1px solid var(--border);border-radius:8px;padding:7px 11px;width:230px;}
.search:focus-within{border-color:var(--border2)}
.search input{background:none;border:none;outline:none;color:var(--ink);font-family:var(--sans);font-size:12.5px;width:100%;}
.search svg{flex:0 0 auto;opacity:.6}
.btn{font:inherit;font-size:12px;color:var(--muted);background:var(--panel);
border:1px solid var(--border);border-radius:8px;padding:7px 12px;cursor:pointer;transition:.12s;}
.btn:hover{color:var(--ink);border-color:var(--border2);background:var(--panel2)}
.btn.active{color:#0e0f11;background:var(--accent);border-color:var(--accent)}
.filt{font:inherit;font-size:12px;color:var(--muted);background:var(--panel);
border:1px solid var(--border);border-radius:8px;padding:7px 8px;cursor:pointer;max-width:150px}
.filt:hover{color:var(--ink);border-color:var(--border2);background:var(--panel2)}
.filt.on{color:#1a1a1a;background:var(--accent);border-color:var(--accent)}
.fcount{font-family:var(--mono);font-size:10.5px;color:var(--accent);white-space:nowrap;min-width:52px}
.board-wrap{position:relative;overflow:auto;background:var(--bg2)}
.board{position:relative;min-width:min-content;padding:26px 30px 60px}
svg.edges{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:1;overflow:visible}
.band{position:relative;z-index:2;margin-bottom:13px}
.band-head{display:flex;align-items:baseline;gap:10px;margin:0 2px 9px;position:sticky;left:0;}
.band-head .t{font-size:11px;font-weight:700;letter-spacing:.9px;text-transform:uppercase;color:var(--ink)}
.band-head .d{font-size:11px;color:var(--faint)}
.band-head .num{font-size:9.5px;color:var(--faint);border:1px solid var(--border);border-radius:20px;padding:1px 7px;font-family:var(--mono)}
.band-head .gloc{color:var(--muted);border-color:transparent;padding-left:0}
.band-body{display:flex;flex-wrap:wrap;gap:7px}
.wire{position:relative;z-index:2;display:flex;align-items:center;gap:14px;margin:6px 2px 16px;color:var(--accent);}
.wire .line{flex:1;height:1px;background:linear-gradient(90deg,transparent,var(--accent-dim),transparent)}
.wire .lbl{font-family:var(--mono);font-size:10.5px;letter-spacing:1px;color:var(--accent);white-space:nowrap}
.node{position:relative;background:var(--panel);border:1px solid var(--border);border-radius:9px;
padding:8px 30px 8px 12px;cursor:pointer;min-width:104px;
transition:transform .12s, border-color .12s, background .12s, box-shadow .12s, opacity .15s;user-select:none;}
.node::before{content:"";position:absolute;left:0;top:8px;bottom:8px;width:3px;border-radius:3px;background:var(--c-low);}
.node.c-med::before{background:var(--c-med)} .node.c-high::before{background:var(--c-high)}
.node.c-core::before{background:var(--c-core);box-shadow:0 0 8px var(--accent-soft)}
.node .lab{font-size:12px;font-weight:560;line-height:1.25;letter-spacing:.1px}
.node .meta{font-size:9.5px;color:var(--faint);font-family:var(--mono);margin-top:2px}
.node:hover{border-color:var(--border2);background:var(--panel2);transform:translateY(-1px)}
.node .chip{position:absolute;top:6px;right:7px;font-family:var(--mono);font-size:9px;font-weight:700;
line-height:1;padding:2px 4px;border-radius:4px;color:#0c0d0e;background:var(--h,#6b7280);opacity:0;transition:opacity .15s;}
.board.show-health .node::before{background:var(--h)!important;box-shadow:none}
.board.show-health .node .chip{opacity:1}
.board.show-health.has-sel .node:not(.lit) .chip{opacity:.25}
.board.has-sel .node{opacity:.26;filter:saturate(.7)}
.board.has-sel .node.lit{opacity:1;filter:none}
.node.sel{border-color:var(--accent);background:#241d12;box-shadow:0 0 0 1px var(--accent), 0 6px 22px rgba(245,158,11,.18);transform:translateY(-1px);}
.node.dep{border-color:var(--accent-dim)} .node.dependent{border-color:var(--in)}
aside{background:var(--panel);border-left:1px solid var(--border);overflow-y:auto;padding:20px 20px 40px;}
.empty-hint{color:var(--faint);font-size:12.5px;line-height:1.7} .empty-hint b{color:var(--muted);font-weight:600}
.legend{margin-top:22px} .legend h4,aside h4{font-size:10.5px;text-transform:uppercase;letter-spacing:1px;color:var(--faint);margin:0 0 10px}
.legend .row{display:flex;align-items:center;gap:9px;margin-bottom:7px;font-size:12px;color:var(--muted)}
.swatch{width:16px;height:9px;border-radius:3px;flex:0 0 auto}
.ln{width:26px;height:0;border-top-width:2px;border-top-style:solid;flex:0 0 auto}
.detail .kicker{font-family:var(--mono);font-size:10px;color:var(--accent);letter-spacing:.5px;text-transform:uppercase}
.detail h2{font-size:18px;margin:5px 0 3px;font-weight:650;line-height:1.2}
.detail .path{font-family:var(--mono);font-size:11px;color:var(--faint);word-break:break-all;margin-bottom:13px}
.pill-row{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:15px}
.pill{font-size:10.5px;border:1px solid var(--border2);border-radius:20px;padding:3px 9px;color:var(--muted);font-family:var(--mono)}
.pill.cpl{border-color:var(--accent-dim);color:var(--accent)}
.detail p.desc{font-size:13px;line-height:1.6;color:var(--ink);margin:0 0 16px}
.reltitle{font-size:10.5px;text-transform:uppercase;letter-spacing:1px;color:var(--faint);margin:16px 0 8px;display:flex;align-items:center;gap:7px}
.reltitle .dotc{width:7px;height:7px;border-radius:50%}
.rel{display:flex;flex-direction:column;gap:4px}
.rel button{text-align:left;font:inherit;font-size:12px;color:var(--muted);background:var(--panel);
border:1px solid var(--border);border-radius:7px;padding:6px 9px;cursor:pointer;transition:.13s;
display:flex;justify-content:space-between;align-items:center;gap:8px;}
.rel button:hover{color:var(--ink);border-color:var(--accent-dim);background:var(--panel2)}
.rel button .bnd{font-size:9px;font-family:var(--mono);color:var(--faint)}
.rel .none{font-size:11.5px;color:var(--faint);font-style:italic;padding:2px}
.clearbtn{margin-top:18px;width:100%}
.scorebox{display:flex;align-items:center;gap:12px;margin:2px 0 13px}
.scorebox .big{font-size:34px;font-weight:740;line-height:1;font-family:var(--mono)}
.scorebox .gr{font-size:13px;font-weight:700;border:1.5px solid;border-radius:7px;padding:3px 9px}
.scorebox .bar{flex:1;height:7px;border-radius:5px;background:#23272c;overflow:hidden}
.scorebox .bar i{display:block;height:100%;border-radius:5px}
.findings{display:flex;flex-direction:column;gap:7px;margin-top:4px}
.finding{border:1px solid var(--border);border-left-width:3px;border-radius:7px;padding:7px 9px;background:#16191c}
.finding .top{display:flex;align-items:center;gap:7px;margin-bottom:3px}
.finding .sev{font-family:var(--mono);font-size:8.5px;font-weight:700;padding:1px 5px;border-radius:4px;letter-spacing:.4px}
.finding .loc{font-family:var(--mono);font-size:10px;color:var(--muted);word-break:break-all}
.finding .txt{font-size:11.5px;line-height:1.5;color:#cfd3d8}
.sev-HIGH{background:#3a1714;color:#f0857a} .finding.sev-HIGH{border-left-color:#e0524b}
.sev-MED{background:#3a2c12;color:#e7b35e} .finding.sev-MED{border-left-color:#d9a441}
.sev-LOW{background:#23282d;color:#9aa1a8} .finding.sev-LOW{border-left-color:#525a62}
.tagchips{display:flex;flex-wrap:wrap;gap:5px;margin:0 0 13px}
.tg{font-size:9.5px;font-family:var(--mono);border-radius:5px;padding:2px 7px;background:#23282d;color:#aeb4bb;border:1px solid var(--border)}
.tg.bad{background:#2c1a17;color:#e88;border-color:#4a2420} .tg.ok{background:#1d2420;color:#8a9;border-color:#2a352e}
.report .stat-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:6px;margin:14px 0 6px}
.report .stat{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:9px 6px;text-align:center}
.report .stat .n{font-family:var(--mono);font-size:18px;font-weight:740;line-height:1}
.report .stat .l{font-size:8.5px;color:var(--faint);text-transform:uppercase;letter-spacing:.5px;margin-top:3px}
.report h3{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--faint);margin:20px 0 8px}
.report .theme{font-size:12px;line-height:1.6;color:#cfd3d8;margin:0 0 9px;padding-left:11px;border-left:2px solid var(--accent-dim)}
.report .theme b{color:#fff;font-weight:600}
.grade-A{color:#76b39a;border-color:#3a5249} .grade-B{color:#b9c0c7;border-color:var(--border2)}
.grade-C{color:#d9a441;border-color:#5a4720} .grade-D{color:#e08a4a;border-color:#5a3a20} .grade-F{color:#e0524b;border-color:#5a2420}
.scrollnote{position:absolute;right:14px;bottom:12px;z-index:5;font-size:10.5px;color:var(--faint);font-family:var(--mono);pointer-events:none;background:#0e1012aa;padding:3px 8px;border-radius:6px;border:1px solid var(--border)}
::-webkit-scrollbar{width:11px;height:11px}
::-webkit-scrollbar-thumb{background:#2c3137;border-radius:6px;border:3px solid var(--bg2)}
::-webkit-scrollbar-track{background:transparent}
</style>
</head>
<body>
<div class="app">
<header>
<div class="mark"></div>
<div>
<h1 id="projTitle">Functional Architecture Map</h1>
<div class="sub" id="subLine"></div>
</div>
<div class="spacer"></div>
<div class="search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input id="search" placeholder="Find a module…" autocomplete="off"/>
</div>
<select class="filt" id="gradeFilter" title="Show only modules at/below this grade">
<option value="">All grades</option>
<option value="90">≤ B (score &lt;90)</option>
<option value="75">≤ C (&lt;75)</option>
<option value="60">≤ D (&lt;60)</option>
<option value="40">F&nbsp;&nbsp;(&lt;40)</option>
</select>
<select class="filt" id="tagFilter" title="Show only modules with this issue"><option value="">Any issue</option></select>
<span class="fcount" id="fcount"></span>
<button class="btn" id="reportBtn">Audit report</button>
<button class="btn" id="healthBtn">Color: coupling</button>
<button class="btn" id="spineBtn">Data-flow spine</button>
</header>
<div class="board-wrap" id="boardWrap">
<div class="board" id="board">
<svg class="edges" id="edges">
<defs>
<marker id="ah-out" markerWidth="9" markerHeight="9" refX="7" refY="4.2" orient="auto"><path d="M0,0 L8,4.2 L0,8.4 Z" fill="var(--out)"/></marker>
<marker id="ah-in" markerWidth="9" markerHeight="9" refX="7" refY="4.2" orient="auto"><path d="M0,0 L8,4.2 L0,8.4 Z" fill="var(--in)"/></marker>
</defs>
</svg>
</div>
<div class="scrollnote">click a module · scroll to pan</div>
</div>
<aside id="aside"><div id="detail"></div></aside>
</div>
<script>
const DATA = __ARCH_DATA__;
const M = DATA.modules || [];
const BANDS = DATA.bands || [];
const SPINE = DATA.spine || [];
const REPORT_THEMES = DATA.reportThemes || [];
const META = DATA.meta || {};
const BAD_TAGS = new Set(["monkeypatch","fallback","legacy","dual-format","stub","fake-output","bloat","duplication","glue","silent-except","silent-catch","any-escape","over-fit","god-component","placeholder"]);
/* ---------- i18n: display language via meta.lang (module names never translated) ---------- */
const LANG = META.lang || "en";
const I18N = {
en:{ mapSuffix:"· Functional Architecture Map", sub:"functional modules · call hierarchy · coupling · quality score · LoC",
report:"report", searchPh:"Find a module…", allGrades:"All grades", anyIssue:"Any issue", scrollNote:"click a module · scroll to pan",
about:"What it does", audit:"Quality audit", clean:"no material issues — clean / well-scoped",
dependsOn:"Depends on / calls →", usedBy:"← Used by (dependents)", none:"— none —", clearSel:"Clear selection",
couplingLbl:"coupling", deps:"dependencies", dependents:"dependents", locUnit:"LoC", coreModules:"Core modules (the spine)",
lgCoupling:"Coupling", lgCore:"core — system spine", lgHigh:"high — many connections", lgMed:"medium", lgLow:"low — leaf / self-contained",
lgEdges:"Edges", lgOut:"depends on / calls →", lgIn:"← used by (dependent)",
introHint:"<b>Click any module</b> to see what it does, its quality score &amp; findings, what it calls (solid amber, downstream) and what depends on it (dashed gray, upstream).<br><br>Each card shows a <b>health score</b> (0100). Toggle the color mode, filter by grade/issue, or open the <b>Audit report</b>.",
criticalPath:"Critical path", spineTitle:"Data-flow spine", path:"Path", healthReport:"Health report", qa:"Quality audit",
worst:"Worst offenders — click to inspect", commonTags:"Most-common smell tags", themes:"Cross-cutting themes", backToMap:"← Back to map", avg:"avg",
filter:"Filter", modulesWord:"modules", allWord:"all", noMatches:"no matches", clearFilters:"Clear filters",
btnReport:"Audit report", btnSpine:"Data-flow spine", colorCoupling:"Color: coupling", colorHealth:"Color: health",
trackedLoc:(l,f)=>`${l} tracked LoC · ${f} files` },
zh:{ mapSuffix:"· 功能架构图", sub:"功能模块 · 调用层级 · 耦合 · 质量评分 · 代码行数",
report:"报告", searchPh:"查找模块…", allGrades:"全部等级", anyIssue:"全部问题", scrollNote:"点击模块 · 滚动平移",
about:"模块简介", audit:"质量审计", clean:"无实质问题 —— 干净、职责单一",
dependsOn:"依赖 / 调用 →", usedBy:"← 被谁依赖", none:"— 无 —", clearSel:"清除选择",
couplingLbl:"耦合", deps:"个依赖", dependents:"个被依赖", locUnit:"行", coreModules:"核心模块(主线)",
lgCoupling:"耦合", lgCore:"core — 系统主线枢纽", lgHigh:"high — 连接很多", lgMed:"medium — 中等", lgLow:"low — 叶子 / 自洽",
lgEdges:"连线", lgOut:"依赖 / 调用 →", lgIn:"← 被依赖",
introHint:"<b>点击任意模块</b>查看它在做什么、质量评分与问题、它调用了谁(实线琥珀=下游)以及谁依赖它(虚线灰=上游)。<br><br>每张卡片都有<b>健康分</b>0–100)。可切换配色模式、按等级或问题筛选,或打开<b>审计报告</b>。",
criticalPath:"关键路径", spineTitle:"数据流主线", path:"路径", healthReport:"健康报告", qa:"质量审计",
worst:"最差模块 —— 点击查看", commonTags:"最常见问题标签", themes:"跨模块共性问题", backToMap:"← 返回地图", avg:"平均",
filter:"筛选", modulesWord:"个模块", allWord:"全部", noMatches:"无匹配", clearFilters:"清除筛选",
btnReport:"审计报告", btnSpine:"数据流主线", colorCoupling:"配色:耦合", colorHealth:"配色:健康度",
trackedLoc:(l,f)=>`${l} 行(已跟踪)· ${f} 个文件` },
};
function tl(k){ const d=I18N[LANG]||I18N.en; return d[k]!=null?d[k]:(I18N.en[k]!=null?I18N.en[k]:k); }
document.documentElement.lang = LANG;
/* smell-tag display labels (the tag id stays English for logic/filtering) */
const TAGS_ZH={ monkeypatch:"猴补丁", fallback:"回退兜底", legacy:"遗留", "dual-format":"双格式",
stub:"占位桩", "fake-output":"伪造输出", bloat:"臃肿", duplication:"重复", glue:"胶水",
"silent-except":"静默吞错", "silent-catch":"静默吞错", "any-escape":"类型逃逸",
"over-fit":"过度特化", "god-component":"上帝组件", placeholder:"占位", clean:"干净" };
function tagLabel(t){ return LANG==="zh" ? (TAGS_ZH[t]||t) : t; }
/* problems POP (saturated red→amber), good RECEDES (pale low-sat green).
The cue is saturation/lightness, not hue — colorblind-friendlier. */
function healthColor(s){
if(s==null) return "#6b7280";
const stops=[[0,[224,78,74]],[42,[226,128,66]],[60,[222,172,52]],
[74,[214,180,72]],[82,[168,180,140]],[100,[150,178,150]]];
s=Math.max(0,Math.min(100,s));
let a=stops[0], b=stops[stops.length-1];
for(let i=0;i<stops.length-1;i++){ if(s>=stops[i][0] && s<=stops[i+1][0]){a=stops[i];b=stops[i+1];break;} }
const t=(s-a[0])/((b[0]-a[0])||1);
const c=a[1].map((v,k)=>Math.round(v+(b[1][k]-v)*t));
return `rgb(${c[0]},${c[1]},${c[2]})`;
}
const byId = Object.fromEntries(M.map(m=>[m.id,m]));
const dependentsOf = id => M.filter(m=>(m.deps||[]).includes(id)).map(m=>m.id);
const board=document.getElementById("board");
const svg=document.getElementById("edges");
const detail=document.getElementById("detail");
const cardEl={};
document.getElementById("projTitle").innerHTML = (META.project||"Project")+` <span>${tl('mapSuffix')}</span>`;
document.getElementById("subLine").innerHTML = (META.subtitle||tl('sub'))+
(META.mdPath?` · <a href="${META.mdPath.split('/').pop()}" style="color:var(--accent);text-decoration:none">${tl('report')} ↗</a>`:"");
/* localize static header chrome (module names/labels are never translated) */
document.querySelector(".scrollnote").textContent = tl("scrollNote");
document.getElementById("search").placeholder = tl("searchPh");
document.getElementById("reportBtn").textContent = tl("btnReport");
document.getElementById("spineBtn").textContent = tl("btnSpine");
document.getElementById("gradeFilter").options[0].textContent = tl("allGrades");
document.getElementById("tagFilter").options[0].textContent = tl("anyIssue");
BANDS.forEach(b=>{
if(b.wire){
const w=document.createElement("div"); w.className="wire";
w.innerHTML=`<div class="line"></div><div class="lbl">${b.t||"boundary"}</div><div class="line"></div>`;
board.appendChild(w); return;
}
const band=document.createElement("div"); band.className=`band tier-${b.tier||""}`;
const items=M.filter(m=>m.band===b.id);
const gloc=items.reduce((a,m)=>a+(m.loc||0),0);
const head=document.createElement("div"); head.className="band-head";
head.innerHTML=`<span class="t">${b.t||b.id}</span><span class="d">${b.d||""}</span>`+
`<span class="num">${items.length}</span><span class="num gloc">${gloc.toLocaleString()} ${tl('locUnit')}</span>`;
band.appendChild(head);
const body=document.createElement("div"); body.className="band-body";
items.forEach(m=>{
const n=document.createElement("div");
n.className=`node c-${m.coupling||"low"}`; n.dataset.id=m.id;
if(m.score!=null) n.style.setProperty("--h",healthColor(m.score));
const chip=m.score!=null?`<span class="chip">${m.score}</span>`:"";
const locStr=m.loc!=null?` · ${Number(m.loc).toLocaleString()} ${tl('locUnit')}`:"";
n.innerHTML=`${chip}<div class="lab">${m.label}</div><div class="meta">${m.grade||""} · ${(m.deps||[]).length}${dependentsOf(m.id).length}${locStr}</div>`;
n.addEventListener("click",e=>{e.stopPropagation();select(m.id)});
body.appendChild(n); cardEl[m.id]=n;
});
band.appendChild(body); board.appendChild(band);
});
function center(el){const r=el.getBoundingClientRect(), br=board.getBoundingClientRect();
return {x:r.left-br.left+r.width/2, y:r.top-br.top+r.height/2, w:r.width, h:r.height};}
function clip(c,from){const dx=from.x-c.x, dy=from.y-c.y, hw=c.w/2+2, hh=c.h/2+2;
if(dx===0&&dy===0) return c;
const s=Math.min(hw/Math.abs(dx||1e-6), hh/Math.abs(dy||1e-6));
return {x:c.x+dx*s, y:c.y+dy*s};}
function edge(aEl,bEl,kind){
const ca=center(aEl), cb=center(bEl), a=clip(ca,cb), b=clip(cb,ca);
const dy=b.y-a.y, k=Math.min(Math.abs(dy)*0.4+30,150);
const p=document.createElementNS("http://www.w3.org/2000/svg","path");
p.setAttribute("d",`M${a.x},${a.y} C${a.x},${a.y+(dy>=0?k:-k)} ${b.x},${b.y-(dy>=0?k:-k)} ${b.x},${b.y}`);
p.setAttribute("fill","none");
p.setAttribute("stroke",kind==="out"?"var(--out)":"var(--in)");
p.setAttribute("stroke-width",kind==="out"?"1.9":"1.5");
p.setAttribute("stroke-opacity",kind==="out"?"0.95":"0.6");
if(kind==="in")p.setAttribute("stroke-dasharray","4 3");
p.setAttribute("marker-end",kind==="out"?"url(#ah-out)":"url(#ah-in)");
svg.appendChild(p);
}
function clearEdges(){[...svg.querySelectorAll("path")].forEach(p=>p.remove());}
let current=null, spineOn=false;
function resetClasses(){board.classList.remove("has-sel");
Object.values(cardEl).forEach(n=>n.classList.remove("sel","dep","dependent","lit"));}
function select(id){
if(current===id){clearSel();return;}
current=id; spineOn=false; document.getElementById("spineBtn").classList.remove("active");
clearEdges(); resetClasses();
const m=byId[id]; const outs=(m.deps||[]); const ins=dependentsOf(id);
board.classList.add("has-sel"); cardEl[id].classList.add("sel","lit");
outs.forEach(d=>{cardEl[d]&&(cardEl[d].classList.add("dep","lit"),edge(cardEl[id],cardEl[d],"out"));});
ins.forEach(d=>{cardEl[d]&&(cardEl[d].classList.add("dependent","lit"),edge(cardEl[d],cardEl[id],"in"));});
renderDetail(m,outs,ins);
cardEl[id].scrollIntoView({block:"nearest",inline:"nearest",behavior:"smooth"});
}
function clearSel(){current=null; clearEdges(); resetClasses(); renderIntro();}
function showSpine(){
current=null; clearEdges(); resetClasses(); board.classList.add("has-sel");
spineOn=true; document.getElementById("spineBtn").classList.add("active");
SPINE.forEach(id=>cardEl[id]&&cardEl[id].classList.add("lit"));
for(let i=0;i<SPINE.length-1;i++){const a=cardEl[SPINE[i]],b=cardEl[SPINE[i+1]];
if(a&&b){a.classList.add("sel");b.classList.add("sel");edge(a,b,"out");}}
detail.innerHTML=`<div class="detail"><div class="kicker">${tl('criticalPath')}</div><h2>${tl('spineTitle')}</h2>
<p class="desc">${META.spineDesc||"The system's critical request path, end to end."}</p>
<div class="reltitle">${tl('path')}</div>
<div class="rel">${SPINE.map(id=>byId[id]?`<button data-go="${id}"><span>${byId[id].label}</span><span class="bnd">${byId[id].coupling}</span></button>`:"").join("")}</div>
${legendHTML()}</div>`;
bindGo();
}
function auditHTML(m){
if(m.score==null) return "";
const col=healthColor(m.score);
const tags=(m.tags||[]).map(t=>`<span class="tg ${t==='clean'?'ok':(BAD_TAGS.has(t)?'bad':'')}">${tagLabel(t)}</span>`).join("");
const fnd=(m.findings||[]).length
? m.findings.map(f=>`<div class="finding sev-${f.sev}"><div class="top"><span class="sev sev-${f.sev}">${f.sev}</span><span class="loc">${f.loc||""}</span></div><div class="txt">${f.text||""}</div></div>`).join("")
: `<div class="none">${tl('clean')}</div>`;
return `<div class="reltitle" style="margin-top:6px">${tl('audit')}</div>`+`
<div class="scorebox"><span class="big" style="color:${col}">${m.score}</span>
<span class="gr grade-${m.grade}">${m.grade}</span>
<span class="bar"><i style="width:${m.score}%;background:${col}"></i></span></div>
<div class="tagchips">${tags}</div><div class="findings">${fnd}</div>`;
}
function renderDetail(m,outs,ins){
const li=arr=>arr.length?arr.map(id=>byId[id]?`<button data-go="${id}"><span>${byId[id].label}</span><span class="bnd">${byId[id].band}</span></button>`:"").join(""):`<div class="none">${tl('none')}</div>`;
detail.innerHTML=`<div class="detail"><div class="kicker">${bandTitle(m.band)}</div>
<h2>${m.label}</h2><div class="path">${m.path||""}</div>
<div class="pill-row"><span class="pill cpl">${tl('couplingLbl')}: ${m.coupling}</span>
${m.loc!=null?`<span class="pill">${Number(m.loc).toLocaleString()} ${tl('locUnit')}</span>`:""}
<span class="pill">${outs.length} ${tl('deps')}</span><span class="pill">${ins.length} ${tl('dependents')}</span></div>
${m.desc?`<div class="reltitle">${tl('about')}</div><p class="desc">${m.desc}</p>`:""}
${auditHTML(m)}
<div class="reltitle"><span class="dotc" style="background:var(--out)"></span>${tl('dependsOn')}</div>
<div class="rel">${li(outs)}</div>
<div class="reltitle"><span class="dotc" style="background:var(--in)"></span>${tl('usedBy')}</div>
<div class="rel">${li(ins)}</div>
<button class="btn clearbtn" id="clearBtn">${tl('clearSel')}</button></div>`;
bindGo(); document.getElementById("clearBtn").addEventListener("click",clearSel);
}
function bindGo(){detail.querySelectorAll("[data-go]").forEach(b=>b.addEventListener("click",()=>select(b.dataset.go)));}
function bandTitle(b){return (BANDS.find(x=>x.id===b)||{}).t||b;}
function legendHTML(){
return `<div class="legend"><h4>${tl('lgCoupling')}</h4>
<div class="row"><span class="swatch" style="background:var(--c-core)"></span>${tl('lgCore')}</div>
<div class="row"><span class="swatch" style="background:var(--c-high)"></span>${tl('lgHigh')}</div>
<div class="row"><span class="swatch" style="background:var(--c-med)"></span>${tl('lgMed')}</div>
<div class="row"><span class="swatch" style="background:var(--c-low)"></span>${tl('lgLow')}</div>
<h4 style="margin-top:18px">${tl('lgEdges')}</h4>
<div class="row"><span class="ln" style="border-color:var(--out)"></span>${tl('lgOut')}</div>
<div class="row"><span class="ln" style="border-color:var(--in);border-top-style:dashed"></span>${tl('lgIn')}</div></div>`;
}
function renderIntro(){
const cores=M.filter(m=>m.coupling==="core");
detail.innerHTML=`<div class="detail"><div class="empty-hint">${tl('introHint')}</div>
${cores.length?`<div class="reltitle" style="margin-top:22px">${tl('coreModules')}</div>
<div class="rel">${cores.map(m=>`<button data-go="${m.id}"><span>${m.label}</span><span class="bnd">${m.band}</span></button>`).join("")}</div>`:""}
${legendHTML()}</div>`;
bindGo();
}
function renderReport(){
current=null; spineOn=false; clearEdges(); resetClasses();
document.getElementById("spineBtn").classList.remove("active");
const scored=M.filter(m=>m.score!=null);
if(!scored.length){renderIntro();return;}
const avg=Math.round(scored.reduce((a,m)=>a+m.score,0)/scored.length);
const gc={A:0,B:0,C:0,D:0,F:0}; scored.forEach(m=>gc[m.grade]!=null&&gc[m.grade]++);
const worst=[...scored].sort((a,b)=>a.score-b.score).slice(0,10);
const tagCount={}; scored.forEach(m=>(m.tags||[]).forEach(t=>{if(BAD_TAGS.has(t))tagCount[t]=(tagCount[t]||0)+1;}));
const topTags=Object.entries(tagCount).sort((a,b)=>b[1]-a[1]).slice(0,8);
const locLine=META.locLine||(META.tracked_loc?tl('trackedLoc')(Number(META.tracked_loc).toLocaleString(),META.tracked_files||"?"):"");
detail.innerHTML=`<div class="report">
<div class="kicker" style="font-family:var(--mono);font-size:10px;color:var(--accent);letter-spacing:.5px">${tl('qa')} · ${scored.length} ${tl('modulesWord')}</div>
<h2 style="font-size:18px;margin:5px 0 2px;font-weight:650">${tl('healthReport')}</h2>
<div class="path" style="color:var(--faint);font-size:11px;margin-bottom:4px">${["monkeypatch","fallback","legacy","stub","bloat","duplication","dual-format"].map(tagLabel).join(" · ")}</div>
${locLine?`<div class="path" style="color:var(--muted);font-size:11px;margin-bottom:2px">${locLine}</div>`:""}
<div class="stat-grid">
<div class="stat"><div class="n" style="color:${healthColor(avg)}">${avg}</div><div class="l">${tl('avg')}</div></div>
<div class="stat"><div class="n grade-A">${gc.A}</div><div class="l">A</div></div>
<div class="stat"><div class="n grade-B">${gc.B}</div><div class="l">B</div></div>
<div class="stat"><div class="n grade-C">${gc.C}</div><div class="l">C</div></div>
<div class="stat"><div class="n grade-D" style="color:#e0524b">${gc.D+gc.F}</div><div class="l">D/F</div></div>
</div>
<h3>${tl('worst')}</h3>
<div class="rel">${worst.map(m=>`<button data-go="${m.id}"><span>${m.label}</span><span class="bnd" style="color:${healthColor(m.score)}">${m.score} · ${m.grade}</span></button>`).join("")}</div>
${topTags.length?`<h3>${tl('commonTags')}</h3><div class="tagchips">${topTags.map(([t,n])=>`<span class="tg bad">${tagLabel(t)} ·${n}</span>`).join("")}</div>`:""}
${REPORT_THEMES.length?`<h3>${tl('themes')}</h3>${REPORT_THEMES.map(([h,b])=>`<p class="theme"><b>${h}.</b> ${b}</p>`).join("")}`:""}
<button class="btn clearbtn" id="toMapBtn">${tl('backToMap')}</button></div>`;
bindGo(); document.getElementById("toMapBtn").addEventListener("click",clearSel);
}
function toggleHealth(){
const on=!board.classList.contains("show-health");
board.classList.toggle("show-health",on);
const b=document.getElementById("healthBtn");
b.classList.toggle("active",on); b.textContent=on?tl('colorHealth'):tl('colorCoupling');
}
/* ---------- filtering: search + grade threshold + issue tag (combined, AND) ---------- */
let filterGrade=null, filterTag=null, searchQuery="";
function filtersActive(){return filterGrade!=null||filterTag!=null||searchQuery!=="";}
function matchesFilter(m){
if(filterGrade!=null && !(m.score!=null && m.score<filterGrade)) return false;
if(filterTag && !(m.tags||[]).includes(filterTag)) return false;
if(searchQuery){const q=searchQuery;
if(!(m.label.toLowerCase().includes(q)||m.id.toLowerCase().includes(q)||(m.desc||"").toLowerCase().includes(q))) return false;}
return true;
}
function runFilter(){
if(!filtersActive()){document.getElementById("fcount").textContent="";clearSel();return;}
current=null; spineOn=false; document.getElementById("spineBtn").classList.remove("active");
clearEdges(); resetClasses(); board.classList.add("has-sel");
document.getElementById("gradeFilter").classList.toggle("on",filterGrade!=null);
document.getElementById("tagFilter").classList.toggle("on",!!filterTag);
let n=0;
M.forEach(m=>{ if(matchesFilter(m)){cardEl[m.id].classList.add("lit");n++;} });
document.getElementById("fcount").textContent=n+" / "+M.length;
const matches=M.filter(matchesFilter).sort((a,b)=>(a.score??999)-(b.score??999));
const gl={90:"≤ B",75:"≤ C",60:"≤ D",40:"F"}[filterGrade];
const crit=[gl||"",filterTag?("#"+tagLabel(filterTag)):"",searchQuery?('"'+searchQuery+'"'):""].filter(Boolean).join(" · ");
detail.innerHTML=`<div class="detail"><div class="kicker">${tl('filter')}</div>
<h2>${n} ${tl('modulesWord')}</h2><div class="path">${crit||tl('allWord')}</div>
<div class="rel">${matches.map(m=>`<button data-go="${m.id}"><span>${m.label}</span><span class="bnd" style="color:${healthColor(m.score)}">${m.score!=null?m.score+" · "+m.grade:""}</span></button>`).join("")||`<div class="none">${tl('noMatches')}</div>`}</div>
<button class="btn clearbtn" id="clearFilterBtn">${tl('clearFilters')}</button></div>`;
bindGo(); document.getElementById("clearFilterBtn").addEventListener("click",resetFilters);
}
function resetFilters(){
filterGrade=null; filterTag=null; searchQuery="";
document.getElementById("gradeFilter").value=""; document.getElementById("gradeFilter").classList.remove("on");
document.getElementById("tagFilter").value=""; document.getElementById("tagFilter").classList.remove("on");
document.getElementById("search").value=""; document.getElementById("fcount").textContent="";
clearSel();
}
/* populate the issue-tag dropdown from the negative tags present, by frequency */
(function(){
const cnt={}; M.forEach(m=>(m.tags||[]).forEach(t=>{if(BAD_TAGS.has(t))cnt[t]=(cnt[t]||0)+1;}));
const sel=document.getElementById("tagFilter");
Object.entries(cnt).sort((a,b)=>b[1]-a[1]).forEach(([t,c])=>{
const o=document.createElement("option"); o.value=t; o.textContent=`${tagLabel(t)} (${c})`; sel.appendChild(o);});
})();
document.getElementById("search").addEventListener("input",e=>{searchQuery=e.target.value.trim().toLowerCase();runFilter();});
document.getElementById("search").addEventListener("keydown",e=>{
if(e.key==="Enter"){const lit=board.querySelector(".node.lit");if(lit)select(lit.dataset.id);}});
document.getElementById("gradeFilter").addEventListener("change",e=>{filterGrade=e.target.value?Number(e.target.value):null;runFilter();});
document.getElementById("tagFilter").addEventListener("change",e=>{filterTag=e.target.value||null;runFilter();});
document.getElementById("healthBtn").addEventListener("click",toggleHealth);
document.getElementById("reportBtn").addEventListener("click",renderReport);
document.getElementById("spineBtn").addEventListener("click",()=>{spineOn?clearSel():showSpine();});
board.addEventListener("click",()=>{filtersActive()?runFilter():clearSel();});
let rt; window.addEventListener("resize",()=>{clearTimeout(rt);rt=setTimeout(()=>{const c=current;if(c){current=null;select(c);}else if(spineOn)showSpine();},120);});
board.classList.add("show-health");
document.getElementById("healthBtn").classList.add("active");
document.getElementById("healthBtn").textContent=tl("colorHealth");
renderIntro();
</script>
</body>
</html>