@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.
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/pipeline.d.ts.map +1 -1
- package/dist/src/pipeline.js +8 -0
- package/dist/src/pipeline.js.map +1 -1
- package/dist/src/visualize.d.ts +4 -0
- package/dist/src/visualize.d.ts.map +1 -0
- package/dist/src/visualize.js +813 -0
- package/dist/src/visualize.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +13 -4
- package/src/index.ts +1 -0
- package/src/pipeline.ts +8 -0
- package/src/visualize.ts +820 -0
package/src/visualize.ts
ADDED
|
@@ -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
|
+
}
|