mirror of
https://github.com/Asixa/codemap-skill.git
synced 2026-08-28 17:01:14 +00:00
474 lines
34 KiB
HTML
474 lines
34 KiB
HTML
<!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 <90)</option>
|
||
<option value="75">≤ C (<75)</option>
|
||
<option value="60">≤ D (<60)</option>
|
||
<option value="40">F (<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 & 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> (0–100). 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>
|