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