codeblast 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,361 @@
1
+ function blindLabel(b) {
2
+ if (!b || (!b.dyn && !b.unres)) return "";
3
+ const parts = [];
4
+ if (b.dyn) parts.push(b.dyn + " dyn");
5
+ if (b.unres) parts.push(b.unres + " unres");
6
+ return " · " + parts.join(" · ");
7
+ }
8
+ const svg = document.getElementById("svg"), vp = document.getElementById("viewport");
9
+ const detail = document.getElementById("detail"), crumbs = document.getElementById("crumbs");
10
+ let view = { scope: "modules", module: null };
11
+
12
+ // --- 简单布局:按度数排序的网格+分层 ---
13
+ function layout(nodes, edges) {
14
+ const W = svg.clientWidth || 900, H = svg.clientHeight || 600;
15
+ const inDeg = {}; edges.forEach(e => inDeg[e.dst] = (inDeg[e.dst]||0)+e.w);
16
+ const outDeg = {}; edges.forEach(e => outDeg[e.src] = (outDeg[e.src]||0)+e.w);
17
+ // 层级 = 依赖深度近似:被依赖多的沉底
18
+ nodes.forEach(n => { n.score = (inDeg[n.id]||0) - (outDeg[n.id]||0); });
19
+ const sorted = [...nodes].sort((a,b) => a.score - b.score);
20
+ const layers = Math.max(2, Math.min(5, Math.ceil(Math.sqrt(nodes.length))));
21
+ const perLayer = Math.ceil(nodes.length / layers);
22
+ sorted.forEach((n, i) => {
23
+ const layer = Math.floor(i / perLayer), pos = i % perLayer;
24
+ const count = Math.min(perLayer, nodes.length - layer*perLayer);
25
+ n.x = (W / (count+1)) * (pos+1);
26
+ n.y = 80 + (H-160) * (layer / Math.max(1, layers-1));
27
+ });
28
+ return nodes;
29
+ }
30
+
31
+ function esc(s) { return s.replace(/&/g,"&amp;").replace(/</g,"&lt;"); }
32
+
33
+ function impactClass(id, isModule) {
34
+ if (DATA.diff) {
35
+ if (isModule) {
36
+ const mc = DATA.diff.moduleCounts[id];
37
+ if (!mc) return "";
38
+ if (mc.added > mc.changed && mc.added >= mc.removed) return " chg-added";
39
+ if (mc.removed > mc.added && mc.removed > mc.changed) return " chg-removed";
40
+ return " chg-changed";
41
+ }
42
+ const st = DATA.diff.fileStates[id];
43
+ return st ? " chg-" + st : "";
44
+ }
45
+ if (!DATA.impact) return "";
46
+ if (isModule) {
47
+ const mc = DATA.impact.moduleCounts[id];
48
+ if (!mc) return "";
49
+ if (mc.direct > 0) return " imp-direct";
50
+ if (mc.tests > 0) return " imp-tests";
51
+ return " imp-indirect";
52
+ }
53
+ if (id === DATA.impact.targetFile) return " imp-target";
54
+ const lv = DATA.impact.fileLevels[id];
55
+ return lv ? " imp-" + lv : "";
56
+ }
57
+ function buildView() {
58
+ if (view.scope === "modules") {
59
+ return {
60
+ key: "__modules__",
61
+ nodes: DATA.modules.filter(m => !edits.merges[m.name] && (editing || !edits.hidden[m.name])).map(m => ({
62
+ id: m.name,
63
+ label: displayName(m.name),
64
+ meta: m.files + " files" + blindLabel(m.blind),
65
+ test: m.name === "tests",
66
+ cyc: DATA.modEdges.some(e => e.cyclic && (e.src === m.name || e.dst === m.name)),
67
+ })),
68
+ edges: DATA.modEdges
69
+ .map(e => ({ src: edits.merges[e.src] ?? e.src, dst: edits.merges[e.dst] ?? e.dst, w: e.w, cyclic: e.cyclic }))
70
+ .filter(e => e.src !== e.dst),
71
+ crumb: "模块层 · " + DATA.modules.length + " modules",
72
+ };
73
+ }
74
+ const fs = DATA.files.filter(f => f.module === view.module);
75
+ const inSet = new Set(fs.map(f => f.path));
76
+ const agg = {};
77
+ DATA.fileEdges.forEach(e => {
78
+ if (inSet.has(e.src) && inSet.has(e.dst) && e.src !== e.dst) { const k = e.src+"→"+e.dst; agg[k] = (agg[k]||0)+1; }
79
+ });
80
+ return {
81
+ key: view.module,
82
+ nodes: fs.map(f => ({ id: f.path, label: f.path.split("/").pop(), meta: f.symbols.length + " symbols" + blindLabel(f.blind), test: false })),
83
+ edges: Object.entries(agg).map(([k,w]) => { const [src,dst] = k.split("→"); return { src, dst, w, cyclic: false }; }),
84
+ crumb: '<span class="crumb" onclick="goHome()">模块层</span> › ' + esc(view.module) + " · " + fs.length + " files",
85
+ };
86
+ }
87
+
88
+ function gridFallback(nodes) {
89
+ const cols = Math.ceil(Math.sqrt(nodes.length));
90
+ const out = {};
91
+ nodes.forEach((n, i) => {
92
+ out[n.id] = { x: 40 + (i % cols) * 240, y: 40 + Math.floor(i / cols) * 100, w: 210, h: 58 };
93
+ });
94
+ return out;
95
+ }
96
+
97
+ function render() {
98
+ const v = buildView();
99
+ crumbs.innerHTML = v.crumb;
100
+ const laid = DATA.layouts[v.key];
101
+ const fallback = !laid;
102
+ const pos = {};
103
+ const laidEdges = {};
104
+ if (laid) {
105
+ laid.nodes.forEach(n => pos[n.id] = n);
106
+ laid.edges.forEach(e => { laidEdges[e.src + "→" + e.dst] = e.points; });
107
+ } else {
108
+ Object.assign(pos, gridFallback(v.nodes));
109
+ }
110
+ const cx = id => pos[id] ? pos[id].x + pos[id].w / 2 : 0;
111
+ const cy = id => pos[id] ? pos[id].y + pos[id].h / 2 : 0;
112
+ // 端点裁剪:让边精确落在矩形边界而非停在节点内部/中心
113
+ function clipToRect(p, target, box) {
114
+ const cx0 = box.x + box.w / 2, cy0 = box.y + box.h / 2;
115
+ const dx = target.x - cx0, dy = target.y - cy0;
116
+ if (!dx && !dy) return { x: cx0, y: cy0 };
117
+ const sx = (box.w / 2 + 4) / Math.abs(dx || 1e-9), sy = (box.h / 2 + 4) / Math.abs(dy || 1e-9);
118
+ const t = Math.min(sx, sy);
119
+ return { x: cx0 + dx * t, y: cy0 + dy * t };
120
+ }
121
+
122
+ let g = "";
123
+ v.edges.forEach(e => {
124
+ if (!pos[e.src] || !pos[e.dst]) return;
125
+ const pts = laidEdges[e.src + "→" + e.dst];
126
+ let d;
127
+ if (pts && pts.length >= 2) {
128
+ const p2 = pts.slice();
129
+ p2[0] = clipToRect(p2[0], p2[1], pos[e.src]);
130
+ p2[p2.length - 1] = clipToRect(p2[p2.length - 1], p2[p2.length - 2], pos[e.dst]);
131
+ // 平滑折线:Catmull-Rom 风格的二次样条串
132
+ d = "M" + p2[0].x + "," + p2[0].y;
133
+ for (let i = 1; i < p2.length - 1; i++) {
134
+ const mx = (p2[i].x + p2[i + 1].x) / 2, my = (p2[i].y + p2[i + 1].y) / 2;
135
+ d += " Q" + p2[i].x + "," + p2[i].y + " " + mx + "," + my;
136
+ }
137
+ d += " L" + p2[p2.length - 1].x + "," + p2[p2.length - 1].y;
138
+ } else {
139
+ const mx = (cx(e.src) + cx(e.dst)) / 2, my = (cy(e.src) + cy(e.dst)) / 2 - 20;
140
+ d = "M" + cx(e.src) + "," + cy(e.src) + " Q" + mx + "," + my + " " + cx(e.dst) + "," + cy(e.dst);
141
+ }
142
+ const wClass = e.w >= 10 ? " heavy" : e.w >= 4 ? " mid" : "";
143
+ const eid = (e.src + "→" + e.dst).replace(/["'<>&]/g, "_");
144
+ g += '<path class="edge' + (e.cyclic ? " cyclic" : "") + wClass + (fallback ? " faint" : "") + '" data-src="' + esc(e.src) + '" data-dst="' + esc(e.dst) + '" d="' + d + '"/>';
145
+ if (pts && pts.length >= 2) {
146
+ const mp = pts[Math.floor(pts.length / 2)];
147
+ g += '<text class="edge-label" x="' + (mp.x + 4) + '" y="' + (mp.y - 4) + '">' + e.w + '</text>';
148
+ }
149
+ });
150
+ v.nodes.forEach(n => {
151
+ const p = pos[n.id];
152
+ if (!p) return;
153
+ g += '<g class="node' + (n.test ? " test" : "") + (n.cyc ? " cyc" : "") + impactClass(n.id, view.scope === "modules") + (n.id === selected ? " sel" : "") + (edits.hidden[n.id] ? " hidden-mod" : "") + '" data-id="' + esc(n.id) + '" onclick="clickNode(\'' + n.id.replace(/'/g, "\\'") + '\')" onmouseenter="hl(\'' + n.id.replace(/'/g, "\\'") + '\',1)" onmouseleave="hl(\'' + n.id.replace(/'/g, "\\'") + '\',0)">'
154
+ + '<rect x="' + p.x + '" y="' + p.y + '" width="' + p.w + '" height="' + p.h + '"/>'
155
+ + '<rect class="accentbar" x="' + p.x + '" y="' + (p.y + 10) + '" width="3.5" height="' + (p.h - 20) + '"/>'
156
+ + '<text x="' + (p.x + p.w / 2) + '" y="' + (p.y + 24) + '" text-anchor="middle">' + esc(n.label) + '</text>'
157
+ + '<text class="meta" x="' + (p.x + p.w / 2) + '" y="' + (p.y + 42) + '" text-anchor="middle">' + esc(n.meta) + '</text></g>';
158
+ });
159
+ vp.innerHTML = g;
160
+ if (laid) { lastDims = { w: laid.width, h: laid.height }; fitView(laid.width, laid.height); }
161
+ else { scale = 1; tx = 0; ty = 0; apply(); lastDims = null; }
162
+ renderSide();
163
+ }
164
+
165
+ function hl(id, on) {
166
+ document.querySelectorAll(".edge").forEach(p => {
167
+ const hit = p.dataset.src === id || p.dataset.dst === id;
168
+ p.classList.toggle("hi", on && hit);
169
+ p.classList.toggle("dim", on && !hit);
170
+ });
171
+ document.querySelectorAll(".node").forEach(nd => nd.classList.toggle("dimn", on && nd.dataset.id !== id && ![...document.querySelectorAll('.edge.hi')].some(e => e.dataset.src === nd.dataset.id || e.dataset.dst === nd.dataset.id)));
172
+ }
173
+
174
+ function fitView(w, h) {
175
+ const vw = svg.clientWidth || 900, vh = svg.clientHeight || 600;
176
+ // 初始缩放下限 0.72:超大图允许滚动而不是把标签缩到不可读
177
+ scale = Math.max(0.55, Math.min(1, (vw - 60) / w, (vh - 60) / h));
178
+ tx = Math.max(12, (vw - w * scale) / 2);
179
+ ty = 24;
180
+ apply();
181
+ }
182
+ let lastDims = null;
183
+ function toggleFit() {
184
+ if (!lastDims) return;
185
+ const vw = svg.clientWidth || 900, vh = svg.clientHeight || 600;
186
+ const fitScale = Math.min(1, (vw - 60) / lastDims.w, (vh - 60) / lastDims.h);
187
+ if (Math.abs(scale - fitScale) < 0.01) { scale = 1; tx = 12; ty = 24; }
188
+ else { scale = fitScale; tx = Math.max(12, (vw - lastDims.w * scale) / 2); ty = 24; }
189
+ apply();
190
+ }
191
+
192
+ function exportPNG() {
193
+ const clone = svg.cloneNode(true);
194
+ const style = document.createElement("style");
195
+ style.textContent = document.querySelector("style").textContent;
196
+ clone.insertBefore(style, clone.firstChild);
197
+ const xml = new XMLSerializer().serializeToString(clone);
198
+ const img = new Image();
199
+ img.onload = () => {
200
+ const c = document.createElement("canvas");
201
+ c.width = svg.clientWidth * 2; c.height = svg.clientHeight * 2;
202
+ const ctx = c.getContext("2d");
203
+ ctx.fillStyle = "#0d1117"; ctx.fillRect(0, 0, c.width, c.height);
204
+ ctx.scale(2, 2); ctx.drawImage(img, 0, 0);
205
+ const a = document.createElement("a");
206
+ a.download = "codeblast-arch.png"; a.href = c.toDataURL("image/png"); a.click();
207
+ };
208
+ img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(xml);
209
+ }
210
+
211
+ function renderSide() {
212
+ if (view.scope === "modules") {
213
+ const cyc = DATA.modEdges.filter(e => e.cyclic);
214
+ let h = "";
215
+ if (cyc.length) h += '<div class="cyclic-banner">⚠ 检测到循环依赖: ' + cyc.map(e => esc(e.src)+" ⇄ "+esc(e.dst)).filter((v,i,a)=>a.indexOf(v)===i).slice(0,3).join("; ") + '</div>';
216
+ h += DATA.modules.map(m => '<div class="item" onclick="drill(\''+m.name.replace(/'/g,"\\'")+'\')"><span>'+esc(DATA.moduleMeta[m.name]?.display ?? m.name)+'</span>'+(DATA.moduleMeta[m.name]?.desc ? '<div class="kind">'+esc(DATA.moduleMeta[m.name].desc)+'</div>' : '')+'<span class="kind">'+m.files+' files'+'<span class="blind">'+blindLabel(m.blind)+'</span>'+'</span></div>').join("");
217
+ detail.innerHTML = renderEditPanel() + h;
218
+ }
219
+ }
220
+
221
+ function clickNode(id) {
222
+ if (editing && view.scope === "modules") { selectForEdit(id); return; }
223
+ if (view.scope === "modules") drill(id);
224
+ else showFile(id);
225
+ }
226
+
227
+ // ---- overlay 编辑模式:图上改名/合并/隐藏,导出 codeblast.overlay.json ----
228
+ let editing = false;
229
+ let selected = null;
230
+ let mergeSource = null;
231
+ // 会话内编辑态(不改 DATA,渲染时叠加)
232
+ const edits = { names: {}, hidden: {}, merges: {} };
233
+
234
+ function toggleEdit() {
235
+ editing = !editing;
236
+ selected = null;
237
+ document.body.classList.toggle("editing", editing);
238
+ document.getElementById("editbtn").classList.toggle("on", editing);
239
+ if (editing && view.scope !== "modules") goHome();
240
+ else render();
241
+ }
242
+
243
+ function selectForEdit(id) {
244
+ if (selected === id) { selected = null; render(); return; }
245
+ // 合并意图:仅当上一个选中项处于"待合并"状态(用户按了合并按钮)才触发,
246
+ // 避免单纯切换选中被误判为合并(实测 bug: 改完名切到别的模块 → 被合并掉)
247
+ if (mergeSource && mergeSource !== id) {
248
+ if (confirm(`把「${displayName(mergeSource)}」并入「${displayName(id)}」?`)) {
249
+ edits.merges[mergeSource] = id;
250
+ mergeSource = null;
251
+ selected = id;
252
+ render();
253
+ return;
254
+ }
255
+ mergeSource = null;
256
+ }
257
+ selected = id;
258
+ render();
259
+ }
260
+
261
+ function startMerge() {
262
+ if (!selected) return;
263
+ mergeSource = selected;
264
+ render();
265
+ }
266
+
267
+ function displayName(m) {
268
+ return edits.names[m] ?? DATA.moduleMeta[m]?.display ?? m;
269
+ }
270
+
271
+ function applyEditName() {
272
+ if (!selected) return;
273
+ const v = document.getElementById("editname").value.trim();
274
+ if (v) edits.names[selected] = v; else delete edits.names[selected];
275
+ render();
276
+ }
277
+
278
+ function toggleHidden() {
279
+ if (!selected) return;
280
+ if (edits.hidden[selected]) delete edits.hidden[selected];
281
+ else edits.hidden[selected] = true;
282
+ render();
283
+ }
284
+
285
+ function unmerge() {
286
+ if (!selected) return;
287
+ delete edits.merges[selected];
288
+ for (const [k, v] of Object.entries(edits.merges)) if (v === selected) delete edits.merges[k];
289
+ render();
290
+ }
291
+
292
+ function overlayJSON() {
293
+ const modules = {};
294
+ const keys = new Set([...Object.keys(edits.names), ...Object.keys(edits.hidden), ...Object.keys(edits.merges)]);
295
+ for (const k of keys) {
296
+ const o = {};
297
+ if (edits.names[k]) o.name = edits.names[k];
298
+ if (edits.hidden[k]) o.hidden = true;
299
+ if (edits.merges[k]) o.mergeInto = edits.merges[k];
300
+ modules[k] = o;
301
+ }
302
+ return JSON.stringify({ modules }, null, 2);
303
+ }
304
+
305
+ function copyOverlay() {
306
+ const txt = overlayJSON();
307
+ navigator.clipboard?.writeText(txt).then(
308
+ () => alert("已复制 codeblast.overlay.json 内容,粘贴到仓库根同名文件即可持久化"),
309
+ () => alert("复制失败,请手动从下方文本框复制"),
310
+ );
311
+ }
312
+
313
+ function downloadOverlay() {
314
+ const blob = new Blob([overlayJSON()], { type: "application/json" });
315
+ const a = document.createElement("a");
316
+ a.download = "codeblast.overlay.json";
317
+ a.href = URL.createObjectURL(blob);
318
+ a.click();
319
+ }
320
+
321
+ function renderEditPanel() {
322
+ if (!editing) return "";
323
+ const dirty = Object.keys(edits.names).length + Object.keys(edits.hidden).length + Object.keys(edits.merges).length;
324
+ if (!selected) {
325
+ return '<div id="editpanel"><h2>编辑模式</h2><p class="tip">点一个模块开始编辑;先点 A 再点 B = 把 A 并入 B。'
326
+ + (dirty ? '<br/>已有 ' + dirty + ' 项修改。' : '') + '</p>'
327
+ + (dirty ? '<div class="row"><button class="primary" onclick="copyOverlay()">复制 overlay JSON</button>'
328
+ + '<button onclick="downloadOverlay()">下载文件</button></div><pre>' + esc(overlayJSON()) + '</pre>' : '')
329
+ + '</div>';
330
+ }
331
+ const merged = Object.entries(edits.merges).filter(([, v]) => v === selected).map(([k]) => k);
332
+ return '<div id="editpanel"><h2>' + esc(selected) + '</h2>'
333
+ + '<label class="tip">显示名</label>'
334
+ + '<input id="editname" value="' + esc(displayName(selected)) + '" onkeydown="if(event.key===\'Enter\')applyEditName()"/>'
335
+ + '<div class="row"><button class="primary" onclick="applyEditName()">应用名称</button>'
336
+ + '<button onclick="toggleHidden()">' + (edits.hidden[selected] ? "取消隐藏" : "隐藏此模块") + '</button></div>'
337
+ + '<div class="row"><button onclick="startMerge()">' + (mergeSource === selected ? "▸ 点击目标模块完成合并" : "合并到…") + '</button></div>'
338
+ + (edits.merges[selected] ? '<p class="tip">已并入 <b>' + esc(edits.merges[selected]) + '</b></p><div class="row"><button onclick="unmerge()">取消合并</button></div>' : '')
339
+ + (merged.length ? '<p class="tip">已合入此模块: ' + merged.map(esc).join(", ") + '</p><div class="row"><button onclick="unmerge()">解除全部</button></div>' : '')
340
+ + '<div class="row"><button class="primary" onclick="copyOverlay()">复制 overlay JSON</button><button onclick="downloadOverlay()">下载</button></div>'
341
+ + '<pre>' + esc(overlayJSON()) + '</pre></div>';
342
+ }
343
+ function drill(mod) { view = { scope: "files", module: mod }; render(); }
344
+ function goHome() { view = { scope: "modules", module: null }; render(); }
345
+ function showFile(path) {
346
+ const f = DATA.files.find(x => x.path === path); if (!f) return;
347
+ detail.innerHTML = '<h2>' + esc(path) + '</h2>'
348
+ + ((f.blind.dyn || f.blind.unres) ? '<div class="cyclic-banner">盲区(静态无法解析到仓内目标的调用/导入,并非只有动态调用): '+(f.blind.dyn||0)+' 动态/子进程/外部依赖解析失败 + '+(f.blind.unres||0)+' 未解析调用 — 影响可能被低估</div>' : '')
349
+ + f.symbols.map(s => '<div class="item" '+(DATA.repoUrl?'onclick="window.open(\''+DATA.repoUrl+'/'+path+'#L'+s.line+'\')"':'')+'><span>'+esc(s.name)+'</span><span class="kind">'+s.kind+' :'+s.line+'</span></div>').join("");
350
+ }
351
+
352
+ // 缩放平移
353
+ let scale = 1, tx = 0, ty = 0, drag = null;
354
+ svg.addEventListener("wheel", e => { e.preventDefault(); scale = Math.max(.3, Math.min(3, scale * (e.deltaY<0?1.1:0.9))); apply(); });
355
+ svg.addEventListener("mousedown", e => drag = { x: e.clientX-tx, y: e.clientY-ty });
356
+ window.addEventListener("mousemove", e => { if (drag) { tx = e.clientX-drag.x; ty = e.clientY-drag.y; apply(); } });
357
+ window.addEventListener("mouseup", () => drag = null);
358
+ function apply() { vp.setAttribute("transform", "translate("+tx+","+ty+") scale("+scale+")"); }
359
+
360
+ render();
361
+ window.addEventListener("resize", render);