@monoes/graph 1.0.0 → 1.0.1

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,820 @@
1
+ import { writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import type { SerializedGraph } from './types.js';
4
+
5
+ const PALETTE = [
6
+ '#6366f1','#f59e0b','#10b981','#ef4444','#3b82f6',
7
+ '#8b5cf6','#ec4899','#14b8a6','#f97316','#06b6d4',
8
+ '#a3e635','#fb923c','#e879f9','#34d399','#60a5fa',
9
+ '#fbbf24','#f472b6','#4ade80','#a78bfa','#38bdf8',
10
+ '#818cf8','#fcd34d','#6ee7b7','#fca5a5','#93c5fd',
11
+ ];
12
+
13
+ /** Export a rich, self-contained HTML knowledge graph explorer. */
14
+ export function exportHTML(serialized: SerializedGraph, outputDir: string): string {
15
+ const htmlPath = join(outputDir, 'graph.html');
16
+
17
+ const nodeCount = serialized.nodes.length;
18
+ const edgeCount = serialized.links.length;
19
+ const projectName = serialized.projectPath.split('/').filter(Boolean).pop() ?? serialized.projectPath;
20
+ const builtAt = new Date(serialized.builtAt).toLocaleString();
21
+
22
+ // Precompute degree map for legend stats
23
+ const degMap: Record<string, number> = {};
24
+ for (const l of serialized.links) {
25
+ degMap[l.source] = (degMap[l.source] ?? 0) + 1;
26
+ degMap[l.target] = (degMap[l.target] ?? 0) + 1;
27
+ }
28
+ const topNodes = [...serialized.nodes]
29
+ .sort((a, b) => (degMap[b.id] ?? 0) - (degMap[a.id] ?? 0))
30
+ .slice(0, 10);
31
+
32
+ const relTypes = [...new Set(serialized.links.map(l => (l as Record<string,unknown>)['relation'] as string || 'ref'))];
33
+
34
+ const html = `<!DOCTYPE html>
35
+ <html lang="en">
36
+ <head>
37
+ <meta charset="UTF-8">
38
+ <meta name="viewport" content="width=device-width,initial-scale=1">
39
+ <title>Knowledge Graph — ${projectName}</title>
40
+ <style>
41
+ *{box-sizing:border-box;margin:0;padding:0}
42
+ :root{
43
+ --bg0:#0a0e1a;--bg1:#111827;--bg2:#1e293b;--bg3:#273448;
44
+ --border:#2d3f55;--text:#e2e8f0;--muted:#64748b;--accent:#6366f1;
45
+ --success:#10b981;--warn:#f59e0b;--danger:#ef4444;
46
+ }
47
+ body{background:var(--bg0);color:var(--text);font-family:system-ui,sans-serif;height:100vh;display:flex;flex-direction:column;overflow:hidden}
48
+
49
+ /* ── Top bar ── */
50
+ #topbar{display:flex;align-items:center;gap:12px;padding:0 16px;height:48px;background:var(--bg1);border-bottom:1px solid var(--border);flex-shrink:0;z-index:20}
51
+ #logo{font-size:13px;font-weight:700;color:#f1f5f9;display:flex;align-items:center;gap:6px}
52
+ #logo span{color:var(--accent)}
53
+ .pill{background:var(--bg0);border:1px solid var(--border);border-radius:999px;padding:2px 10px;font-size:11px;color:var(--muted)}
54
+ .pill b{color:#c7d2fe}
55
+ #search-wrap{margin-left:auto;position:relative;display:flex;align-items:center}
56
+ #search-wrap svg{position:absolute;left:8px;color:var(--muted);pointer-events:none}
57
+ #search{padding:5px 10px 5px 30px;border-radius:7px;border:1px solid var(--border);background:var(--bg0);color:var(--text);font-size:12px;width:220px;outline:none;transition:.15s border-color}
58
+ #search:focus{border-color:var(--accent)}
59
+ .tbtn{background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:5px 12px;font-size:12px;cursor:pointer;white-space:nowrap;transition:.1s background}
60
+ .tbtn:hover{background:var(--bg3)}
61
+ .tbtn.active{background:var(--accent);border-color:var(--accent);color:#fff}
62
+
63
+ /* ── Main layout ── */
64
+ #main{display:flex;flex:1;overflow:hidden}
65
+ #sidebar{width:280px;flex-shrink:0;background:var(--bg1);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
66
+ #canvas-area{flex:1;position:relative;overflow:hidden}
67
+ canvas{display:block}
68
+
69
+ /* ── Sidebar tabs ── */
70
+ #tabs{display:flex;border-bottom:1px solid var(--border);flex-shrink:0}
71
+ .tab{flex:1;padding:10px;font-size:12px;text-align:center;cursor:pointer;color:var(--muted);border-bottom:2px solid transparent;transition:.1s}
72
+ .tab:hover{color:var(--text)}
73
+ .tab.active{color:var(--accent);border-bottom-color:var(--accent)}
74
+ #tab-content{flex:1;overflow-y:auto;padding:14px}
75
+
76
+ /* ── Sidebar sections ── */
77
+ .section{margin-bottom:18px}
78
+ .sec-title{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px}
79
+ .stat-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
80
+ .stat-box{background:var(--bg0);border:1px solid var(--border);border-radius:8px;padding:10px;text-align:center}
81
+ .stat-box .sv{font-size:22px;font-weight:700;color:#f1f5f9}
82
+ .stat-box .sk{font-size:10px;color:var(--muted);margin-top:2px}
83
+
84
+ /* Node card */
85
+ #node-card{display:none}
86
+ #node-card .nc-label{font-size:15px;font-weight:600;color:#f1f5f9;margin-bottom:10px;word-break:break-all}
87
+ .nc-row{display:flex;justify-content:space-between;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px}
88
+ .nc-row:last-child{border:none}
89
+ .nc-row .nk{color:var(--muted)}
90
+ .nc-row .nv{color:#c7d2fe;text-align:right;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
91
+ .nbr-list{margin-top:10px;max-height:200px;overflow-y:auto}
92
+ .nbr-item{display:flex;align-items:center;gap:6px;padding:5px 6px;border-radius:6px;cursor:pointer;font-size:12px}
93
+ .nbr-item:hover{background:var(--bg3)}
94
+ .nbr-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
95
+ .nbr-lbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
96
+ .nbr-rel{color:var(--muted);font-size:10px;flex-shrink:0}
97
+
98
+ /* Community list */
99
+ .com-row{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;cursor:pointer;font-size:12px;transition:.1s}
100
+ .com-row:hover{background:var(--bg3)}
101
+ .com-row.dim{opacity:.3}
102
+ .com-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
103
+ .com-name{flex:1}
104
+ .com-count{color:var(--muted);font-size:11px}
105
+
106
+ /* God nodes */
107
+ .god-item{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;cursor:pointer;font-size:12px;transition:.1s}
108
+ .god-item:hover{background:var(--bg3)}
109
+ .god-rank{width:20px;text-align:right;color:var(--muted);font-size:11px;flex-shrink:0}
110
+ .god-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
111
+ .god-lbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
112
+ .god-deg{font-size:11px;color:var(--muted);flex-shrink:0}
113
+
114
+ /* Edge legend */
115
+ .rel-row{display:flex;align-items:center;gap:8px;padding:3px 0;font-size:12px}
116
+ .rel-line{width:24px;height:2px;border-radius:2px;flex-shrink:0}
117
+
118
+ /* ── Tooltip ── */
119
+ #tooltip{position:absolute;pointer-events:none;background:var(--bg2);border:1px solid var(--border);border-radius:9px;padding:12px 16px;font-size:12px;line-height:1.7;max-width:260px;display:none;box-shadow:0 8px 32px rgba(0,0,0,.6);z-index:10}
120
+ #tooltip .tt-lbl{font-weight:600;font-size:14px;color:#f1f5f9;margin-bottom:4px;word-break:break-all}
121
+ #tooltip .tt-r{display:flex;gap:8px;color:var(--muted)}
122
+ #tooltip .tt-r b{color:#c7d2fe;min-width:70px}
123
+
124
+ /* ── Minimap ── */
125
+ #minimap{position:absolute;bottom:14px;right:14px;border:1px solid var(--border);border-radius:7px;overflow:hidden;background:var(--bg1)88;backdrop-filter:blur(6px);cursor:crosshair}
126
+
127
+ /* ── Floating controls ── */
128
+ #floatctrl{position:absolute;bottom:14px;left:14px;display:flex;gap:6px}
129
+ .fcbtn{background:var(--bg2)cc;backdrop-filter:blur(6px);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:6px 11px;font-size:12px;cursor:pointer;transition:.1s}
130
+ .fcbtn:hover{background:var(--bg3)}
131
+
132
+ /* ── Scrollbar ── */
133
+ ::-webkit-scrollbar{width:5px}
134
+ ::-webkit-scrollbar-track{background:transparent}
135
+ ::-webkit-scrollbar-thumb{background:var(--border);border-radius:99px}
136
+ ::-webkit-scrollbar-thumb:hover{background:#475569}
137
+
138
+ /* ── No-node placeholder ── */
139
+ #no-select{color:var(--muted);font-size:12px;text-align:center;padding:30px 0;display:flex;flex-direction:column;align-items:center;gap:8px}
140
+ #no-select svg{opacity:.3}
141
+ </style>
142
+ </head>
143
+ <body>
144
+
145
+ <!-- Top bar -->
146
+ <div id="topbar">
147
+ <div id="logo">🧠 <span>monobrain</span> graph</div>
148
+ <div class="pill"><b>${nodeCount}</b> nodes</div>
149
+ <div class="pill"><b>${edgeCount}</b> edges</div>
150
+ <div class="pill"><b id="com-count-pill">—</b> communities</div>
151
+ <div class="pill" style="color:#475569">${builtAt}</div>
152
+ <div id="search-wrap">
153
+ <svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
154
+ <input id="search" type="text" placeholder="Search nodes…" autocomplete="off">
155
+ </div>
156
+ <button class="tbtn" id="btn-labels">Labels</button>
157
+ <button class="tbtn" id="btn-pause">⏸</button>
158
+ </div>
159
+
160
+ <!-- Main -->
161
+ <div id="main">
162
+
163
+ <!-- Sidebar -->
164
+ <div id="sidebar">
165
+ <div id="tabs">
166
+ <div class="tab active" data-tab="overview">Overview</div>
167
+ <div class="tab" data-tab="node">Node</div>
168
+ <div class="tab" data-tab="communities">Groups</div>
169
+ <div class="tab" data-tab="god">God Nodes</div>
170
+ </div>
171
+ <div id="tab-content">
172
+
173
+ <!-- Overview tab -->
174
+ <div data-panel="overview">
175
+ <div class="section">
176
+ <div class="sec-title">Project</div>
177
+ <div style="font-size:14px;font-weight:600;color:#f1f5f9;margin-bottom:4px">${projectName}</div>
178
+ <div style="font-size:11px;color:var(--muted)">${serialized.projectPath}</div>
179
+ </div>
180
+ <div class="section">
181
+ <div class="sec-title">Graph Stats</div>
182
+ <div class="stat-grid">
183
+ <div class="stat-box"><div class="sv" id="stat-nodes">${nodeCount}</div><div class="sk">Nodes</div></div>
184
+ <div class="stat-box"><div class="sv" id="stat-edges">${edgeCount}</div><div class="sk">Edges</div></div>
185
+ <div class="stat-box"><div class="sv" id="stat-coms">—</div><div class="sk">Communities</div></div>
186
+ <div class="stat-box"><div class="sv" id="stat-density">—</div><div class="sk">Density</div></div>
187
+ </div>
188
+ </div>
189
+ <div class="section">
190
+ <div class="sec-title">Relation Types</div>
191
+ <div id="rel-legend"></div>
192
+ </div>
193
+ <div class="section">
194
+ <div class="sec-title">Node Size</div>
195
+ <div style="font-size:11px;color:var(--muted);line-height:1.6">
196
+ Node radius scales with <b style="color:#c7d2fe">degree</b> (number of connections).<br>
197
+ Larger = more central to the codebase.
198
+ </div>
199
+ <div style="display:flex;align-items:center;gap:10px;margin-top:10px">
200
+ <svg width="60" height="30" viewBox="0 0 60 30">
201
+ <circle cx="8" cy="22" r="4" fill="#6366f1" opacity="0.8"/>
202
+ <circle cx="25" cy="20" r="6" fill="#6366f1" opacity="0.8"/>
203
+ <circle cx="46" cy="17" r="9" fill="#6366f1" opacity="0.8"/>
204
+ </svg>
205
+ <span style="font-size:11px;color:var(--muted)">low → high degree</span>
206
+ </div>
207
+ </div>
208
+ </div>
209
+
210
+ <!-- Node tab -->
211
+ <div data-panel="node" style="display:none">
212
+ <div id="no-select">
213
+ <svg width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
214
+ <circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/>
215
+ </svg>
216
+ <span>Click a node on the graph<br>to see its details here</span>
217
+ </div>
218
+ <div id="node-card">
219
+ <div class="nc-label" id="nc-label"></div>
220
+ <div class="nc-row"><span class="nk">File</span><span class="nv" id="nc-file"></span></div>
221
+ <div class="nc-row"><span class="nk">Community</span><span class="nv" id="nc-com"></span></div>
222
+ <div class="nc-row"><span class="nk">Degree</span><span class="nv" id="nc-deg"></span></div>
223
+ <div class="nc-row"><span class="nk">Type</span><span class="nv" id="nc-type"></span></div>
224
+ <div class="sec-title" style="margin-top:14px">Connections</div>
225
+ <div class="nbr-list" id="nc-nbrs"></div>
226
+ </div>
227
+ </div>
228
+
229
+ <!-- Communities tab -->
230
+ <div data-panel="communities" style="display:none">
231
+ <div class="section">
232
+ <div class="sec-title">Filter by Community</div>
233
+ <div id="com-list"></div>
234
+ </div>
235
+ <button class="tbtn" style="width:100%;margin-top:4px" id="btn-show-all">Show All</button>
236
+ </div>
237
+
238
+ <!-- God nodes tab -->
239
+ <div data-panel="god" style="display:none">
240
+ <div class="section">
241
+ <div class="sec-title">Most Connected Nodes</div>
242
+ <div id="god-list"></div>
243
+ </div>
244
+ </div>
245
+
246
+ </div><!-- /tab-content -->
247
+ </div><!-- /sidebar -->
248
+
249
+ <!-- Canvas area -->
250
+ <div id="canvas-area">
251
+ <canvas id="canvas"></canvas>
252
+ <div id="tooltip"></div>
253
+ <canvas id="minimap" width="160" height="100"></canvas>
254
+ <div id="floatctrl">
255
+ <button class="fcbtn" id="btn-fit">Fit</button>
256
+ <button class="fcbtn" id="btn-zoomin">+</button>
257
+ <button class="fcbtn" id="btn-zoomout">−</button>
258
+ <button class="fcbtn" id="btn-reset">Reset</button>
259
+ </div>
260
+ </div>
261
+
262
+ </div><!-- /main -->
263
+
264
+ <script>
265
+ // ═══════════════════════════════════════════════════════════════
266
+ // DATA
267
+ // ═══════════════════════════════════════════════════════════════
268
+ const RAW = ${JSON.stringify(serialized)};
269
+ const PALETTE = ${JSON.stringify(PALETTE)};
270
+
271
+ const REL_COLORS = {
272
+ imports:'#6366f1', calls:'#f59e0b', uses:'#10b981', contains:'#3b82f6',
273
+ implements:'#8b5cf6', extends:'#ec4899', ref:'#475569', default:'#64748b',
274
+ };
275
+ function relColor(r){ return REL_COLORS[r]||REL_COLORS.default; }
276
+ function comColor(c){ return (c==null||c<0)?'#64748b':PALETTE[c%PALETTE.length]; }
277
+
278
+ // Build node and edge objects
279
+ const nodeById={};
280
+ const nodes=RAW.nodes.map(n=>{
281
+ const obj={
282
+ id:n.id, label:n.label||n.id,
283
+ file:n.source_file||n.sourceFile||'',
284
+ community:n.community??-1,
285
+ fileType:n.file_type||n.fileType||'code',
286
+ degree:0,
287
+ x:(Math.random()-0.5)*800, y:(Math.random()-0.5)*800,
288
+ vx:0, vy:0, pinned:false,
289
+ visible:true, highlighted:false,
290
+ rawDeg:n.degree??0,
291
+ };
292
+ nodeById[n.id]=obj;
293
+ return obj;
294
+ });
295
+ const edges=RAW.links.map(l=>({
296
+ source:nodeById[l.source], target:nodeById[l.target],
297
+ relation:l.relation||'ref',
298
+ confidence:l.confidence||'EXTRACTED',
299
+ weight:l.weight||1,
300
+ })).filter(e=>e.source&&e.target);
301
+
302
+ // Compute degrees
303
+ for(const e of edges){
304
+ e.source.degree++;
305
+ e.target.degree++;
306
+ }
307
+
308
+ // Community metadata
309
+ const comSet=new Set(nodes.map(n=>n.community));
310
+ const communities=[...comSet].sort((a,b)=>a-b);
311
+ document.getElementById('com-count-pill').textContent=communities.length;
312
+ document.getElementById('stat-coms').textContent=communities.length;
313
+ const density=nodes.length>1?(2*edges.length/(nodes.length*(nodes.length-1))).toFixed(4):'0';
314
+ document.getElementById('stat-density').textContent=density;
315
+
316
+ // ═══════════════════════════════════════════════════════════════
317
+ // CANVAS + CAMERA
318
+ // ═══════════════════════════════════════════════════════════════
319
+ const canvas=document.getElementById('canvas');
320
+ const ctx=canvas.getContext('2d');
321
+ const minimap=document.getElementById('minimap');
322
+ const mctx=minimap.getContext('2d');
323
+ let W,H,dpr;
324
+
325
+ function resize(){
326
+ const wrap=document.getElementById('canvas-area');
327
+ dpr=window.devicePixelRatio||1;
328
+ W=wrap.clientWidth; H=wrap.clientHeight;
329
+ canvas.width=W*dpr; canvas.height=H*dpr;
330
+ canvas.style.width=W+'px'; canvas.style.height=H+'px';
331
+ ctx.setTransform(dpr,0,0,dpr,0,0);
332
+ }
333
+ window.addEventListener('resize',resize); resize();
334
+
335
+ let cam={x:0,y:0,z:0.6};
336
+ function toScreen(wx,wy){return{x:(wx+cam.x)*cam.z+W/2, y:(wy+cam.y)*cam.z+H/2};}
337
+ function toWorld(sx,sy){return{x:(sx-W/2)/cam.z-cam.x, y:(sy-H/2)/cam.z-cam.y};}
338
+
339
+ // ═══════════════════════════════════════════════════════════════
340
+ // PHYSICS — Force-directed simulation
341
+ // ═══════════════════════════════════════════════════════════════
342
+ let running=true, simHeat=1;
343
+ const REPEL=1200, DAMPEN=0.82, CENTER=0.002, LINK_DIST=120, LINK_K=0.04;
344
+
345
+ function physicsStep(){
346
+ const vis=nodes.filter(n=>n.visible);
347
+ const N=vis.length;
348
+
349
+ // Repulsion (Barnes-Hut approximation: just O(n^2) for now, skip quad-tree)
350
+ for(let i=0;i<N;i++){
351
+ const a=vis[i];
352
+ for(let j=i+1;j<N;j++){
353
+ const b=vis[j];
354
+ const dx=b.x-a.x, dy=b.y-a.y;
355
+ const d2=dx*dx+dy*dy+1;
356
+ const f=REPEL/d2*simHeat;
357
+ a.vx-=dx*f; a.vy-=dy*f;
358
+ b.vx+=dx*f; b.vy+=dy*f;
359
+ }
360
+ }
361
+
362
+ // Link springs
363
+ for(const e of edges){
364
+ if(!e.source.visible||!e.target.visible) continue;
365
+ const dx=e.target.x-e.source.x, dy=e.target.y-e.source.y;
366
+ const d=Math.sqrt(dx*dx+dy*dy)+0.01;
367
+ const f=(d-LINK_DIST)*LINK_K*simHeat;
368
+ e.source.vx+=dx/d*f; e.source.vy+=dy/d*f;
369
+ e.target.vx-=dx/d*f; e.target.vy-=dy/d*f;
370
+ }
371
+
372
+ // Integrate + center gravity
373
+ for(const n of vis){
374
+ if(n.pinned){n.vx=0;n.vy=0;continue;}
375
+ n.vx=(n.vx-n.x*CENTER)*DAMPEN;
376
+ n.vy=(n.vy-n.y*CENTER)*DAMPEN;
377
+ n.x+=n.vx; n.y+=n.vy;
378
+ }
379
+
380
+ // Cool down slowly
381
+ simHeat=Math.max(0.15, simHeat*0.9995);
382
+ }
383
+
384
+ // ═══════════════════════════════════════════════════════════════
385
+ // RENDERING
386
+ // ═══════════════════════════════════════════════════════════════
387
+ let showLabels=true, searchFilter='';
388
+ let selectedNode=null;
389
+ const hiddenComs=new Set();
390
+
391
+ function nodeRadius(n){ return Math.max(4, Math.min(22, 3+Math.sqrt(n.degree)*2.2)); }
392
+
393
+ function draw(){
394
+ ctx.clearRect(0,0,W,H);
395
+
396
+ const hasHL=nodes.some(n=>n.highlighted);
397
+
398
+ // Edges
399
+ for(const e of edges){
400
+ if(!e.source.visible||!e.target.visible) continue;
401
+ const s=toScreen(e.source.x,e.source.y);
402
+ const t=toScreen(e.target.x,e.target.y);
403
+
404
+ let alpha=0.18;
405
+ if(hasHL){
406
+ alpha=(e.source.highlighted||e.target.highlighted)?0.7:0.05;
407
+ }
408
+
409
+ ctx.beginPath();
410
+ ctx.strokeStyle=relColor(e.relation)+(Math.round(alpha*255).toString(16).padStart(2,'0'));
411
+ ctx.lineWidth=(e.confidence==='EXTRACTED'?1:0.5)*Math.min(1.5,cam.z);
412
+ ctx.moveTo(s.x,s.y); ctx.lineTo(t.x,t.y);
413
+ ctx.stroke();
414
+
415
+ // Arrowhead at small zoom levels skip
416
+ if(cam.z>0.5&&RAW.directed){
417
+ const dx=t.x-s.x, dy=t.y-s.y;
418
+ const len=Math.sqrt(dx*dx+dy*dy);
419
+ if(len<2) continue;
420
+ const r=nodeRadius(e.target)*cam.z+3;
421
+ const tx=t.x-dx/len*r, ty=t.y-dy/len*r;
422
+ const ax=-dy/len*4, ay=dx/len*4;
423
+ ctx.beginPath();
424
+ ctx.fillStyle=relColor(e.relation)+(Math.round(Math.min(alpha+0.1,1)*255).toString(16).padStart(2,'0'));
425
+ ctx.moveTo(tx,ty);
426
+ ctx.lineTo(tx-dx/len*7+ax,ty-dy/len*7+ay);
427
+ ctx.lineTo(tx-dx/len*7-ax,ty-dy/len*7-ay);
428
+ ctx.closePath(); ctx.fill();
429
+ }
430
+ }
431
+
432
+ // Nodes
433
+ for(const n of nodes){
434
+ if(!n.visible) continue;
435
+ const p=toScreen(n.x,n.y);
436
+ const r=nodeRadius(n)*cam.z;
437
+ if(p.x+r<-20||p.x-r>W+20||p.y+r<-20||p.y-r>H+20) continue;
438
+
439
+ const alpha=hasHL?(n.highlighted?1:0.15):1;
440
+ const col=comColor(n.community);
441
+
442
+ // Glow for selected or highlighted
443
+ if(n===selectedNode||n.highlighted){
444
+ ctx.beginPath();
445
+ ctx.arc(p.x,p.y,r+5,0,Math.PI*2);
446
+ ctx.fillStyle=col+'33';
447
+ ctx.fill();
448
+ }
449
+
450
+ ctx.beginPath();
451
+ ctx.arc(p.x,p.y,r,0,Math.PI*2);
452
+ ctx.globalAlpha=alpha;
453
+ ctx.fillStyle=col;
454
+ ctx.fill();
455
+ ctx.globalAlpha=1;
456
+
457
+ if(n===selectedNode){
458
+ ctx.strokeStyle='#f1f5f9';
459
+ ctx.lineWidth=2;
460
+ ctx.stroke();
461
+ } else if(n.pinned){
462
+ ctx.strokeStyle=col+'99';
463
+ ctx.lineWidth=1;
464
+ ctx.stroke();
465
+ }
466
+
467
+ // Label
468
+ if(showLabels&&cam.z>0.5&&r>4*cam.z){
469
+ const label=n.label.length>22?n.label.slice(0,20)+'…':n.label;
470
+ const fs=Math.max(9,Math.min(12,r*0.75));
471
+ ctx.font=fs+'px system-ui';
472
+ ctx.textAlign='center';
473
+ ctx.globalAlpha=alpha;
474
+ ctx.fillStyle='#f1f5f9';
475
+ ctx.fillText(label,p.x,p.y+r+fs+2);
476
+ ctx.globalAlpha=1;
477
+ }
478
+ }
479
+
480
+ drawMinimap();
481
+ }
482
+
483
+ // ── Minimap ────────────────────────────────────────────────────
484
+ function drawMinimap(){
485
+ const MW=minimap.width, MH=minimap.height;
486
+ mctx.clearRect(0,0,MW,MH);
487
+ mctx.fillStyle='#111827cc';
488
+ mctx.fillRect(0,0,MW,MH);
489
+
490
+ const vis=nodes.filter(n=>n.visible);
491
+ if(!vis.length) return;
492
+ const xs=vis.map(n=>n.x), ys=vis.map(n=>n.y);
493
+ const minX=Math.min(...xs),maxX=Math.max(...xs);
494
+ const minY=Math.min(...ys),maxY=Math.max(...ys);
495
+ const rngX=maxX-minX||1, rngY=maxY-minY||1;
496
+ const scl=Math.min((MW-4)/rngX,(MH-4)/rngY)*0.9;
497
+ const ox=MW/2-(minX+maxX)/2*scl;
498
+ const oy=MH/2-(minY+maxY)/2*scl;
499
+
500
+ for(const n of vis){
501
+ mctx.beginPath();
502
+ mctx.arc(n.x*scl+ox, n.y*scl+oy, Math.max(1.5,nodeRadius(n)*scl*0.5),0,Math.PI*2);
503
+ mctx.fillStyle=comColor(n.community)+'cc';
504
+ mctx.fill();
505
+ }
506
+
507
+ // Viewport rect
508
+ const tl=toWorld(0,0), br=toWorld(W,H);
509
+ const vx=(tl.x*scl+ox), vy=(tl.y*scl+oy);
510
+ const vw=(br.x-tl.x)*scl, vh=(br.y-tl.y)*scl;
511
+ mctx.strokeStyle='#f1f5f980';
512
+ mctx.lineWidth=1;
513
+ mctx.strokeRect(vx,vy,vw,vh);
514
+ }
515
+
516
+ // ═══════════════════════════════════════════════════════════════
517
+ // LOOP
518
+ // ═══════════════════════════════════════════════════════════════
519
+ function loop(){
520
+ if(running) physicsStep();
521
+ draw();
522
+ requestAnimationFrame(loop);
523
+ }
524
+ loop();
525
+
526
+ // ═══════════════════════════════════════════════════════════════
527
+ // SIDEBAR SETUP
528
+ // ═══════════════════════════════════════════════════════════════
529
+
530
+ // Tabs
531
+ document.querySelectorAll('.tab').forEach(t=>{
532
+ t.addEventListener('click',()=>{
533
+ document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
534
+ t.classList.add('active');
535
+ document.querySelectorAll('[data-panel]').forEach(p=>p.style.display='none');
536
+ document.querySelector('[data-panel="'+t.dataset.tab+'"]').style.display='';
537
+ });
538
+ });
539
+
540
+ // Relation legend
541
+ const relEl=document.getElementById('rel-legend');
542
+ const relTypes=${JSON.stringify(relTypes)};
543
+ for(const r of relTypes.slice(0,12)){
544
+ const row=document.createElement('div');
545
+ row.className='rel-row';
546
+ row.innerHTML='<div class="rel-line" style="background:'+relColor(r)+'"></div><span>'+r+'</span>';
547
+ relEl.appendChild(row);
548
+ }
549
+
550
+ // Community list
551
+ const comList=document.getElementById('com-list');
552
+ for(const c of communities){
553
+ const count=nodes.filter(n=>n.community===c).length;
554
+ const row=document.createElement('div');
555
+ row.className='com-row';
556
+ row.innerHTML='<div class="com-dot" style="background:'+comColor(c)+'"></div>'
557
+ +'<span class="com-name">'+(c<0?'Unassigned':'Group '+c)+'</span>'
558
+ +'<span class="com-count">'+count+'</span>';
559
+ row.addEventListener('click',()=>{
560
+ if(hiddenComs.has(c)){hiddenComs.delete(c);row.classList.remove('dim');}
561
+ else{hiddenComs.add(c);row.classList.add('dim');}
562
+ applyFilters();
563
+ });
564
+ comList.appendChild(row);
565
+ }
566
+ document.getElementById('btn-show-all').addEventListener('click',()=>{
567
+ hiddenComs.clear();
568
+ document.querySelectorAll('.com-row').forEach(r=>r.classList.remove('dim'));
569
+ applyFilters();
570
+ });
571
+
572
+ // God nodes list
573
+ const godList=document.getElementById('god-list');
574
+ const topNodes=${JSON.stringify(topNodes.map(n=>({id:n.id,label:(n as Record<string,unknown>)['label']||n.id, community:(n as Record<string,unknown>)['community']??-1, degree:degMap[n.id]??0})))};
575
+ topNodes.forEach((n,i)=>{
576
+ const row=document.createElement('div');
577
+ row.className='god-item';
578
+ row.innerHTML='<span class="god-rank">#'+(i+1)+'</span>'
579
+ +'<div class="god-dot" style="background:'+comColor(n.community)+'"></div>'
580
+ +'<span class="god-lbl">'+n.label+'</span>'
581
+ +'<span class="god-deg">'+n.degree+'</span>';
582
+ row.addEventListener('click',()=>{
583
+ const target=nodeById[n.id];
584
+ if(target) selectNode(target,true);
585
+ });
586
+ godList.appendChild(row);
587
+ });
588
+
589
+ // ═══════════════════════════════════════════════════════════════
590
+ // NODE SELECTION
591
+ // ═══════════════════════════════════════════════════════════════
592
+ function selectNode(n, flyTo=false){
593
+ selectedNode=n;
594
+
595
+ // Highlight node + immediate neighbors
596
+ const nbrIds=new Set([n.id]);
597
+ edges.forEach(e=>{
598
+ if(e.source===n) nbrIds.add(e.target.id);
599
+ if(e.target===n) nbrIds.add(e.source.id);
600
+ });
601
+ nodes.forEach(nd=>{ nd.highlighted=nbrIds.has(nd.id); });
602
+
603
+ // Switch to node tab
604
+ document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
605
+ document.querySelector('.tab[data-tab="node"]').classList.add('active');
606
+ document.querySelectorAll('[data-panel]').forEach(p=>p.style.display='none');
607
+ document.querySelector('[data-panel="node"]').style.display='';
608
+
609
+ // Fill card
610
+ document.getElementById('no-select').style.display='none';
611
+ document.getElementById('node-card').style.display='';
612
+ document.getElementById('nc-label').textContent=n.label;
613
+ document.getElementById('nc-file').textContent=n.file||'—';
614
+ document.getElementById('nc-com').textContent=n.community>=0?'Group '+n.community:'—';
615
+ document.getElementById('nc-deg').textContent=n.degree+' connections';
616
+ document.getElementById('nc-type').textContent=n.fileType||'code';
617
+
618
+ // Neighbors
619
+ const nbrEl=document.getElementById('nc-nbrs');
620
+ nbrEl.innerHTML='';
621
+ const nbrs=[];
622
+ edges.forEach(e=>{
623
+ if(e.source===n) nbrs.push({node:e.target,rel:e.relation,dir:'out'});
624
+ if(e.target===n) nbrs.push({node:e.source,rel:e.relation,dir:'in'});
625
+ });
626
+ nbrs.sort((a,b)=>b.node.degree-a.node.degree).slice(0,30).forEach(({node:nb,rel})=>{
627
+ const item=document.createElement('div');
628
+ item.className='nbr-item';
629
+ item.innerHTML='<div class="nbr-dot" style="background:'+comColor(nb.community)+'"></div>'
630
+ +'<span class="nbr-lbl">'+nb.label+'</span>'
631
+ +'<span class="nbr-rel">'+rel+'</span>';
632
+ item.addEventListener('click',()=>selectNode(nb,true));
633
+ nbrEl.appendChild(item);
634
+ });
635
+
636
+ if(flyTo){
637
+ cam.x=-n.x; cam.y=-n.y;
638
+ cam.z=Math.min(2.5,Math.max(0.8,cam.z));
639
+ }
640
+ }
641
+
642
+ function clearSelection(){
643
+ selectedNode=null;
644
+ nodes.forEach(n=>{n.highlighted=false;});
645
+ document.getElementById('no-select').style.display='';
646
+ document.getElementById('node-card').style.display='none';
647
+ }
648
+
649
+ // ═══════════════════════════════════════════════════════════════
650
+ // SEARCH / FILTER
651
+ // ═══════════════════════════════════════════════════════════════
652
+ function applyFilters(){
653
+ const q=searchFilter.toLowerCase();
654
+ for(const n of nodes){
655
+ const comOk=!hiddenComs.has(n.community);
656
+ const searchOk=!q||(n.label.toLowerCase().includes(q)||n.file.toLowerCase().includes(q));
657
+ n.visible=comOk&&searchOk;
658
+ }
659
+ }
660
+ document.getElementById('search').addEventListener('input',e=>{
661
+ searchFilter=e.target.value;
662
+ applyFilters();
663
+ });
664
+
665
+ // ═══════════════════════════════════════════════════════════════
666
+ // INTERACTION
667
+ // ═══════════════════════════════════════════════════════════════
668
+ const tip=document.getElementById('tooltip');
669
+
670
+ function findNodeAt(mx,my){
671
+ const w=toWorld(mx,my);
672
+ let best=null,bestD=Infinity;
673
+ for(const n of nodes){
674
+ if(!n.visible) continue;
675
+ const dx=n.x-w.x, dy=n.y-w.y;
676
+ const d2=dx*dx+dy*dy;
677
+ const nr=nodeRadius(n)/cam.z;
678
+ if(d2<nr*nr*1.5&&d2<bestD){bestD=d2;best=n;}
679
+ }
680
+ return best;
681
+ }
682
+
683
+ canvas.addEventListener('mousemove',e=>{
684
+ const r=canvas.getBoundingClientRect();
685
+ const mx=e.clientX-r.left, my=e.clientY-r.top;
686
+ const n=findNodeAt(mx,my);
687
+ if(n){
688
+ canvas.style.cursor='pointer';
689
+ tip.style.display='block';
690
+ tip.style.left=(mx+16)+'px';
691
+ tip.style.top=Math.min(my-10,H-120)+'px';
692
+ tip.innerHTML='<div class="tt-lbl">'+n.label+'</div>'
693
+ +'<div class="tt-r"><b>File</b><span style="color:#e2e8f0">'+(n.file||'—')+'</span></div>'
694
+ +'<div class="tt-r"><b>Community</b><span style="color:'+comColor(n.community)+'">'+(n.community>=0?'Group '+n.community:'—')+'</span></div>'
695
+ +'<div class="tt-r"><b>Degree</b><span style="color:#e2e8f0">'+n.degree+'</span></div>'
696
+ +'<div class="tt-r"><b>Type</b><span style="color:#e2e8f0">'+(n.fileType||'code')+'</span></div>';
697
+ if(n.pinned) tip.innerHTML+='<div style="margin-top:4px;font-size:11px;color:#f59e0b">📌 Pinned</div>';
698
+ } else {
699
+ canvas.style.cursor='default';
700
+ tip.style.display='none';
701
+ }
702
+ });
703
+ canvas.addEventListener('mouseleave',()=>{tip.style.display='none';});
704
+
705
+ canvas.addEventListener('click',e=>{
706
+ const r=canvas.getBoundingClientRect();
707
+ const mx=e.clientX-r.left, my=e.clientY-r.top;
708
+ const n=findNodeAt(mx,my);
709
+ if(n){
710
+ if(e.altKey||e.metaKey){ n.pinned=!n.pinned; return; }
711
+ selectNode(n);
712
+ } else {
713
+ clearSelection();
714
+ }
715
+ });
716
+ canvas.addEventListener('dblclick',e=>{
717
+ const r=canvas.getBoundingClientRect();
718
+ const mx=e.clientX-r.left, my=e.clientY-r.top;
719
+ const n=findNodeAt(mx,my);
720
+ if(n) n.pinned=!n.pinned;
721
+ });
722
+
723
+ // Pan
724
+ let dragging=false,lastMX=0,lastMY=0,dragMoved=false;
725
+ canvas.addEventListener('mousedown',e=>{
726
+ if(e.button===0){dragging=true;lastMX=e.clientX;lastMY=e.clientY;dragMoved=false;}
727
+ });
728
+ window.addEventListener('mouseup',()=>{dragging=false;});
729
+ window.addEventListener('mousemove',e=>{
730
+ if(!dragging) return;
731
+ const dx=e.clientX-lastMX, dy=e.clientY-lastMY;
732
+ if(Math.abs(dx)+Math.abs(dy)>3) dragMoved=true;
733
+ cam.x+=dx/cam.z; cam.y+=dy/cam.z;
734
+ lastMX=e.clientX; lastMY=e.clientY;
735
+ });
736
+
737
+ // Zoom
738
+ canvas.addEventListener('wheel',e=>{
739
+ e.preventDefault();
740
+ const factor=e.deltaY<0?1.1:0.91;
741
+ const r=canvas.getBoundingClientRect();
742
+ const mx=e.clientX-r.left, my=e.clientY-r.top;
743
+ const w=toWorld(mx,my);
744
+ cam.z=Math.max(0.05,Math.min(8,cam.z*factor));
745
+ cam.x=w.x-(mx-W/2)/cam.z; cam.y=w.y-(my-H/2)/cam.z;
746
+ },{passive:false});
747
+
748
+ // Touch
749
+ let lastTouches=[];
750
+ canvas.addEventListener('touchstart',e=>{lastTouches=[...e.touches];},{passive:true});
751
+ canvas.addEventListener('touchmove',e=>{
752
+ if(e.touches.length===1&&lastTouches.length===1){
753
+ cam.x+=(e.touches[0].clientX-lastTouches[0].clientX)/cam.z;
754
+ cam.y+=(e.touches[0].clientY-lastTouches[0].clientY)/cam.z;
755
+ } else if(e.touches.length===2&&lastTouches.length===2){
756
+ const d0=Math.hypot(lastTouches[0].clientX-lastTouches[1].clientX,lastTouches[0].clientY-lastTouches[1].clientY);
757
+ const d1=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY);
758
+ cam.z=Math.max(0.05,Math.min(8,cam.z*d1/d0));
759
+ }
760
+ lastTouches=[...e.touches];
761
+ },{passive:true});
762
+
763
+ // ═══════════════════════════════════════════════════════════════
764
+ // TOOLBAR
765
+ // ═══════════════════════════════════════════════════════════════
766
+ const pauseBtn=document.getElementById('btn-pause');
767
+ pauseBtn.addEventListener('click',()=>{
768
+ running=!running;
769
+ pauseBtn.textContent=running?'⏸':'▶';
770
+ pauseBtn.classList.toggle('active',!running);
771
+ if(running) simHeat=Math.max(simHeat,0.3);
772
+ });
773
+
774
+ const labelsBtn=document.getElementById('btn-labels');
775
+ labelsBtn.classList.toggle('active',showLabels);
776
+ labelsBtn.addEventListener('click',()=>{
777
+ showLabels=!showLabels;
778
+ labelsBtn.classList.toggle('active',showLabels);
779
+ });
780
+
781
+ function fitAll(){
782
+ const vis=nodes.filter(n=>n.visible);
783
+ if(!vis.length) return;
784
+ const xs=vis.map(n=>n.x), ys=vis.map(n=>n.y);
785
+ const minX=Math.min(...xs),maxX=Math.max(...xs);
786
+ const minY=Math.min(...ys),maxY=Math.max(...ys);
787
+ const mx=(minX+maxX)/2, my=(minY+maxY)/2;
788
+ const sc=Math.min(0.85*W/(maxX-minX+1),0.85*H/(maxY-minY+1),3);
789
+ cam={x:-mx, y:-my, z:sc};
790
+ }
791
+
792
+ document.getElementById('btn-fit').addEventListener('click',fitAll);
793
+ document.getElementById('btn-reset').addEventListener('click',()=>{cam={x:0,y:0,z:0.6};});
794
+ document.getElementById('btn-zoomin').addEventListener('click',()=>{cam.z=Math.min(8,cam.z*1.3);});
795
+ document.getElementById('btn-zoomout').addEventListener('click',()=>{cam.z=Math.max(0.05,cam.z*0.77);});
796
+
797
+ // Minimap click to pan
798
+ minimap.addEventListener('click',e=>{
799
+ const vis=nodes.filter(n=>n.visible);
800
+ if(!vis.length) return;
801
+ const MW=minimap.width, MH=minimap.height;
802
+ const xs=vis.map(n=>n.x), ys=vis.map(n=>n.y);
803
+ const minX=Math.min(...xs),maxX=Math.max(...xs);
804
+ const minY=Math.min(...ys),maxY=Math.max(...ys);
805
+ const scl=Math.min((MW-4)/(maxX-minX||1),(MH-4)/(maxY-minY||1))*0.9;
806
+ const ox=MW/2-(minX+maxX)/2*scl, oy=MH/2-(minY+maxY)/2*scl;
807
+ const r=minimap.getBoundingClientRect();
808
+ const wx=(e.clientX-r.left-ox)/scl, wy=(e.clientY-r.top-oy)/scl;
809
+ cam.x=-wx; cam.y=-wy;
810
+ });
811
+
812
+ // Initial fit after settling
813
+ setTimeout(fitAll,1500);
814
+ </script>
815
+ </body>
816
+ </html>`;
817
+
818
+ writeFileSync(htmlPath, html, 'utf-8');
819
+ return htmlPath;
820
+ }