@leanlabsinnov/codegraph 0.1.3 → 0.1.5

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,1466 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>CodeGraph</title>
7
+ <style>
8
+ /* ───────────────────────── Design tokens ───────────────────────── */
9
+ :root {
10
+ --bg-0: #0a0e14;
11
+ --bg-1: #0f141b;
12
+ --bg-2: #161b22;
13
+ --bg-3: #1c232c;
14
+ --border: #262d36;
15
+ --border-mid: #30363d;
16
+ --text-0: #e6edf3;
17
+ --text-1: #b1bac4;
18
+ --text-2: #7d8590;
19
+ --text-3: #484f58;
20
+ --accent: #58a6ff;
21
+ --accent-mid: rgba(88,166,255,0.18);
22
+ --warn: #fbbf24;
23
+ --success: #56d364;
24
+ --shadow-lg: 0 10px 40px -10px rgba(0,0,0,0.6);
25
+ --shadow-md: 0 4px 16px -4px rgba(0,0,0,0.5);
26
+
27
+ /* Node colors — softer, Obsidian-inspired palette */
28
+ --kind-File: #6b7280;
29
+ --kind-Function: #60a5fa;
30
+ --kind-Class: #a78bfa;
31
+ --kind-Interface: #818cf8;
32
+ --kind-Component: #22d3ee;
33
+ --kind-Route: #34d399;
34
+ --kind-Variable: #fbbf24;
35
+
36
+ --edge-IMPORTS: #6b7280;
37
+ --edge-CALLS: #60a5fa;
38
+ --edge-RENDERS: #22d3ee;
39
+ --edge-INHERITS: #a78bfa;
40
+ --edge-DEFINES: #fbbf24;
41
+ --edge-EXPORTS: #34d399;
42
+ }
43
+
44
+ * { box-sizing: border-box; margin: 0; padding: 0; }
45
+ html, body {
46
+ height: 100%;
47
+ overflow: hidden;
48
+ font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", system-ui, sans-serif;
49
+ font-feature-settings: "cv11", "ss03";
50
+ background: var(--bg-0);
51
+ color: var(--text-0);
52
+ -webkit-font-smoothing: antialiased;
53
+ font-size: 13px;
54
+ line-height: 1.45;
55
+ }
56
+ button, input, select { font-family: inherit; font-size: inherit; color: inherit; }
57
+ button { background: none; border: none; cursor: pointer; }
58
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
59
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
60
+ ::-webkit-scrollbar-thumb:hover { background: var(--border-mid); }
61
+ ::-webkit-scrollbar-track { background: transparent; }
62
+
63
+ /* ───────────────────────── Top bar ───────────────────────── */
64
+ #app { display: grid; grid-template-rows: 48px 1fr; height: 100%; }
65
+ #topbar {
66
+ display: flex; align-items: center; gap: 12px;
67
+ padding: 0 14px;
68
+ background: var(--bg-1);
69
+ border-bottom: 1px solid var(--border);
70
+ z-index: 5;
71
+ }
72
+ #logo {
73
+ display: flex; align-items: center; gap: 8px;
74
+ font-weight: 700; font-size: 14px; letter-spacing: -0.01em;
75
+ color: var(--text-0);
76
+ }
77
+ #logo svg { width: 18px; height: 18px; color: var(--accent); }
78
+ #logo small { color: var(--text-2); font-weight: 400; margin-left: 2px; }
79
+ .divider { width: 1px; height: 22px; background: var(--border); }
80
+
81
+ select, input[type="search"], input[type="text"] {
82
+ background: var(--bg-2);
83
+ border: 1px solid var(--border);
84
+ color: var(--text-0);
85
+ padding: 5px 10px;
86
+ border-radius: 6px;
87
+ font-size: 12.5px;
88
+ outline: none;
89
+ transition: border-color 0.15s, background 0.15s;
90
+ }
91
+ select:hover, input:hover { border-color: var(--border-mid); }
92
+ select:focus, input:focus { border-color: var(--accent); background: var(--bg-3); }
93
+ #repo-select { min-width: 180px; cursor: pointer; }
94
+ #search-bar {
95
+ position: relative; display: flex; align-items: center;
96
+ background: var(--bg-2); border: 1px solid var(--border); border-radius: 6px;
97
+ padding: 0 8px 0 28px; width: 220px; height: 28px;
98
+ transition: border-color 0.15s, background 0.15s;
99
+ }
100
+ #search-bar:focus-within { border-color: var(--accent); background: var(--bg-3); }
101
+ #search-bar svg { position: absolute; left: 8px; width: 14px; height: 14px; color: var(--text-3); }
102
+ #search-bar input {
103
+ background: transparent; border: none; outline: none;
104
+ width: 100%; height: 100%; padding: 0;
105
+ }
106
+ #search-bar input::placeholder { color: var(--text-3); }
107
+ .kbd {
108
+ font-family: ui-monospace, "SF Mono", monospace;
109
+ font-size: 10px; color: var(--text-3);
110
+ background: var(--bg-3); border: 1px solid var(--border);
111
+ border-radius: 3px; padding: 1px 4px; margin-left: 4px;
112
+ }
113
+
114
+ #topbar-right { margin-left: auto; display: flex; align-items: center; gap: 6px; }
115
+ .icon-btn {
116
+ width: 28px; height: 28px;
117
+ display: flex; align-items: center; justify-content: center;
118
+ color: var(--text-2);
119
+ border-radius: 6px;
120
+ transition: background 0.15s, color 0.15s;
121
+ }
122
+ .icon-btn:hover { background: var(--bg-3); color: var(--text-0); }
123
+ .icon-btn.active { background: var(--accent-mid); color: var(--accent); }
124
+ .icon-btn svg { width: 15px; height: 15px; }
125
+
126
+ /* ───────────────────────── Main grid ───────────────────────── */
127
+ #main {
128
+ display: grid;
129
+ grid-template-columns: 260px 1fr;
130
+ height: 100%;
131
+ overflow: hidden;
132
+ transition: grid-template-columns 0.2s ease;
133
+ }
134
+ #main.sidebar-collapsed { grid-template-columns: 0 1fr; }
135
+ #main.sidebar-collapsed #sidebar { transform: translateX(-100%); opacity: 0; }
136
+
137
+ /* ───────────────────────── Sidebar ───────────────────────── */
138
+ #sidebar {
139
+ background: var(--bg-1);
140
+ border-right: 1px solid var(--border);
141
+ overflow-y: auto; overflow-x: hidden;
142
+ padding: 14px;
143
+ transition: transform 0.2s ease, opacity 0.15s ease;
144
+ }
145
+ .section { margin-bottom: 22px; }
146
+ .section-title {
147
+ font-size: 10.5px; font-weight: 700;
148
+ text-transform: uppercase; letter-spacing: 0.06em;
149
+ color: var(--text-3); margin-bottom: 8px;
150
+ display: flex; align-items: center; justify-content: space-between;
151
+ }
152
+ .section-title .count {
153
+ color: var(--text-3); font-weight: 600; font-size: 10px;
154
+ }
155
+ .toggle-row {
156
+ display: flex; align-items: center; justify-content: space-between;
157
+ padding: 5px 8px; border-radius: 5px;
158
+ cursor: pointer; user-select: none;
159
+ transition: background 0.12s;
160
+ }
161
+ .toggle-row:hover { background: var(--bg-2); }
162
+ .toggle-row .toggle-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
163
+ .toggle-row .dot {
164
+ width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
165
+ box-shadow: 0 0 0 2px var(--bg-1);
166
+ }
167
+ .toggle-row .label {
168
+ font-size: 12px; color: var(--text-1);
169
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
170
+ }
171
+ .toggle-row .badge {
172
+ font-size: 10px; font-variant-numeric: tabular-nums;
173
+ color: var(--text-3); padding: 0 5px;
174
+ }
175
+ .toggle-row.off { opacity: 0.35; }
176
+ .toggle-row.off .dot { background: var(--text-3) !important; }
177
+
178
+ .slider-row { margin-bottom: 12px; }
179
+ .slider-row label {
180
+ display: flex; justify-content: space-between;
181
+ font-size: 11.5px; color: var(--text-2); margin-bottom: 4px;
182
+ }
183
+ .slider-row label .value {
184
+ color: var(--text-1); font-variant-numeric: tabular-nums;
185
+ font-family: ui-monospace, "SF Mono", monospace; font-size: 11px;
186
+ }
187
+ input[type="range"] {
188
+ -webkit-appearance: none; appearance: none;
189
+ width: 100%; height: 18px;
190
+ background: transparent; cursor: pointer;
191
+ }
192
+ input[type="range"]::-webkit-slider-runnable-track {
193
+ height: 3px; background: var(--border); border-radius: 2px;
194
+ }
195
+ input[type="range"]::-webkit-slider-thumb {
196
+ -webkit-appearance: none; appearance: none;
197
+ width: 12px; height: 12px; border-radius: 50%;
198
+ background: var(--accent); margin-top: -4.5px;
199
+ border: 2px solid var(--bg-1); cursor: grab;
200
+ }
201
+ input[type="range"]:active::-webkit-slider-thumb { cursor: grabbing; }
202
+
203
+ .checkbox-row {
204
+ display: flex; align-items: center; gap: 8px;
205
+ padding: 5px 8px; border-radius: 5px;
206
+ cursor: pointer; user-select: none;
207
+ transition: background 0.12s;
208
+ }
209
+ .checkbox-row:hover { background: var(--bg-2); }
210
+ .checkbox-row input { accent-color: var(--accent); cursor: pointer; }
211
+ .checkbox-row .label { font-size: 12px; color: var(--text-1); }
212
+
213
+ /* ───────────────────────── Canvas area ───────────────────────── */
214
+ #canvas-wrap { position: relative; overflow: hidden; background: var(--bg-0); }
215
+ #canvas { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; }
216
+ #canvas.dragging { cursor: grabbing; }
217
+ #canvas.node-drag { cursor: crosshair; }
218
+
219
+ /* Subtle dotted grid (Obsidian-style) */
220
+ #canvas-wrap::before {
221
+ content: "";
222
+ position: absolute; inset: 0;
223
+ background-image: radial-gradient(circle, rgba(255,255,255,0.025) 1px, transparent 1px);
224
+ background-size: 28px 28px;
225
+ pointer-events: none;
226
+ }
227
+ /* Vignette */
228
+ #canvas-wrap::after {
229
+ content: "";
230
+ position: absolute; inset: 0;
231
+ background: radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.55) 100%);
232
+ pointer-events: none;
233
+ }
234
+
235
+ /* Stats badge (bottom-left) */
236
+ #stats {
237
+ position: absolute; left: 14px; bottom: 14px;
238
+ background: rgba(15,20,27,0.88); backdrop-filter: blur(8px);
239
+ border: 1px solid var(--border);
240
+ border-radius: 6px; padding: 6px 10px;
241
+ font-size: 11px; color: var(--text-1);
242
+ font-variant-numeric: tabular-nums;
243
+ display: flex; align-items: center; gap: 10px;
244
+ box-shadow: var(--shadow-md);
245
+ }
246
+ #stats .pulse {
247
+ width: 6px; height: 6px; border-radius: 50%;
248
+ background: var(--success); animation: pulse 2s infinite;
249
+ }
250
+ @keyframes pulse {
251
+ 0%, 100% { opacity: 1; transform: scale(1); }
252
+ 50% { opacity: 0.5; transform: scale(1.25); }
253
+ }
254
+
255
+ /* Floating zoom controls (bottom-right) */
256
+ #zoom-controls {
257
+ position: absolute; right: 14px; bottom: 14px;
258
+ background: rgba(15,20,27,0.88); backdrop-filter: blur(8px);
259
+ border: 1px solid var(--border); border-radius: 6px;
260
+ display: flex; align-items: center; padding: 2px;
261
+ box-shadow: var(--shadow-md);
262
+ }
263
+ #zoom-controls button {
264
+ width: 28px; height: 28px;
265
+ display: flex; align-items: center; justify-content: center;
266
+ color: var(--text-1); border-radius: 4px;
267
+ transition: background 0.12s, color 0.12s;
268
+ }
269
+ #zoom-controls button:hover { background: var(--bg-3); color: var(--text-0); }
270
+ #zoom-controls .sep { width: 1px; height: 16px; background: var(--border); margin: 0 2px; }
271
+ #zoom-controls .level {
272
+ padding: 0 8px; font-size: 11px; color: var(--text-2);
273
+ font-variant-numeric: tabular-nums; min-width: 42px; text-align: center;
274
+ }
275
+
276
+ /* Minimap (top-right) */
277
+ #minimap {
278
+ position: absolute; right: 14px; top: 14px;
279
+ width: 160px; height: 110px;
280
+ background: rgba(15,20,27,0.88); backdrop-filter: blur(8px);
281
+ border: 1px solid var(--border); border-radius: 6px;
282
+ overflow: hidden;
283
+ box-shadow: var(--shadow-md);
284
+ cursor: pointer;
285
+ }
286
+ #minimap canvas { display: block; width: 100%; height: 100%; }
287
+
288
+ /* Detail panel (right, slides in) */
289
+ #detail {
290
+ position: absolute; right: 14px; bottom: 60px;
291
+ width: 320px; max-height: calc(100% - 200px);
292
+ background: rgba(15,20,27,0.94); backdrop-filter: blur(12px);
293
+ border: 1px solid var(--border-mid);
294
+ border-radius: 10px;
295
+ box-shadow: var(--shadow-lg);
296
+ overflow: hidden;
297
+ display: flex; flex-direction: column;
298
+ transform: translateX(20px); opacity: 0; pointer-events: none;
299
+ transition: transform 0.18s ease, opacity 0.18s ease;
300
+ }
301
+ #detail.visible { transform: translateX(0); opacity: 1; pointer-events: auto; }
302
+ #detail-header {
303
+ padding: 14px 14px 10px;
304
+ border-bottom: 1px solid var(--border);
305
+ display: flex; align-items: flex-start; gap: 10px;
306
+ }
307
+ #detail-header .meta { flex: 1; min-width: 0; }
308
+ #detail-name {
309
+ font-size: 14px; font-weight: 600; color: var(--text-0);
310
+ font-family: ui-monospace, "SF Mono", monospace;
311
+ word-break: break-all; line-height: 1.3; margin-bottom: 6px;
312
+ }
313
+ .kind-badge {
314
+ display: inline-block; padding: 2px 7px;
315
+ border-radius: 10px; font-size: 10.5px; font-weight: 700;
316
+ letter-spacing: 0.03em;
317
+ }
318
+ #detail-close {
319
+ color: var(--text-3); padding: 2px;
320
+ display: flex; align-items: center;
321
+ }
322
+ #detail-close:hover { color: var(--text-0); }
323
+ #detail-body {
324
+ padding: 12px 14px; overflow-y: auto; flex: 1;
325
+ }
326
+ .detail-meta {
327
+ margin-bottom: 12px; padding-bottom: 12px;
328
+ border-bottom: 1px solid var(--border);
329
+ font-size: 11.5px;
330
+ }
331
+ .detail-meta-row {
332
+ display: flex; justify-content: space-between;
333
+ padding: 2px 0; color: var(--text-2);
334
+ }
335
+ .detail-meta-row .key { color: var(--text-3); }
336
+ .detail-meta-row .val {
337
+ color: var(--text-1);
338
+ font-family: ui-monospace, "SF Mono", monospace; font-size: 11px;
339
+ word-break: break-all; text-align: right; max-width: 70%;
340
+ }
341
+ .detail-meta-row .val.clickable {
342
+ cursor: pointer; transition: color 0.12s;
343
+ }
344
+ .detail-meta-row .val.clickable:hover { color: var(--accent); }
345
+ .edge-group { margin-bottom: 10px; }
346
+ .edge-group-title {
347
+ font-size: 10px; font-weight: 700; text-transform: uppercase;
348
+ letter-spacing: 0.05em; color: var(--text-3); margin-bottom: 4px;
349
+ display: flex; justify-content: space-between;
350
+ }
351
+ .edge-list { list-style: none; }
352
+ .edge-list li {
353
+ display: flex; align-items: center; gap: 6px;
354
+ padding: 4px 6px; border-radius: 4px;
355
+ font-size: 11.5px; color: var(--text-1);
356
+ cursor: pointer;
357
+ transition: background 0.12s, color 0.12s;
358
+ }
359
+ .edge-list li:hover { background: var(--bg-3); color: var(--text-0); }
360
+ .edge-list li .edge-pill {
361
+ font-size: 9px; font-weight: 700;
362
+ padding: 1px 4px; border-radius: 3px; flex-shrink: 0;
363
+ }
364
+ .edge-list li .target-name {
365
+ flex: 1; min-width: 0;
366
+ font-family: ui-monospace, "SF Mono", monospace;
367
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
368
+ }
369
+ .edge-list li .target-kind-dot {
370
+ width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
371
+ }
372
+ .detail-actions {
373
+ padding: 10px 14px; border-top: 1px solid var(--border);
374
+ display: flex; gap: 6px;
375
+ }
376
+ .btn-primary, .btn-secondary {
377
+ flex: 1; padding: 6px 10px;
378
+ font-size: 11.5px; font-weight: 600;
379
+ border-radius: 5px;
380
+ display: flex; align-items: center; justify-content: center; gap: 6px;
381
+ transition: all 0.12s;
382
+ }
383
+ .btn-primary {
384
+ background: var(--accent-mid); color: var(--accent);
385
+ border: 1px solid var(--accent-mid);
386
+ }
387
+ .btn-primary:hover { background: rgba(88,166,255,0.28); }
388
+ .btn-secondary {
389
+ background: var(--bg-3); color: var(--text-1);
390
+ border: 1px solid var(--border);
391
+ }
392
+ .btn-secondary:hover { background: var(--bg-2); color: var(--text-0); }
393
+
394
+ /* Tooltip */
395
+ #tooltip {
396
+ position: fixed; pointer-events: none; z-index: 100;
397
+ background: rgba(15,20,27,0.96); backdrop-filter: blur(8px);
398
+ border: 1px solid var(--border-mid);
399
+ border-radius: 6px; padding: 6px 10px;
400
+ font-size: 11.5px; color: var(--text-0);
401
+ white-space: nowrap;
402
+ box-shadow: var(--shadow-md);
403
+ display: none;
404
+ }
405
+ #tooltip .kind { color: var(--text-2); font-size: 10px; margin-right: 4px; }
406
+
407
+ /* Help overlay */
408
+ #help {
409
+ position: absolute; inset: 0; z-index: 50;
410
+ background: rgba(10,14,20,0.75); backdrop-filter: blur(8px);
411
+ display: none; align-items: center; justify-content: center;
412
+ }
413
+ #help.visible { display: flex; }
414
+ #help-content {
415
+ background: var(--bg-1); border: 1px solid var(--border-mid);
416
+ border-radius: 10px; padding: 24px 28px;
417
+ max-width: 480px; box-shadow: var(--shadow-lg);
418
+ }
419
+ #help-content h2 { font-size: 16px; margin-bottom: 16px; color: var(--text-0); }
420
+ #help-content h3 {
421
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
422
+ color: var(--text-3); margin: 14px 0 6px;
423
+ }
424
+ .shortcut {
425
+ display: flex; justify-content: space-between; align-items: center;
426
+ padding: 5px 0; font-size: 12px; color: var(--text-1);
427
+ }
428
+ .shortcut .keys { display: flex; gap: 4px; }
429
+ .shortcut .keys .kbd {
430
+ background: var(--bg-3); border: 1px solid var(--border-mid);
431
+ color: var(--text-1); font-size: 11px; padding: 2px 6px;
432
+ }
433
+
434
+ /* Overlay (loading / empty state) */
435
+ #overlay {
436
+ position: absolute; inset: 0; z-index: 30;
437
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
438
+ background: var(--bg-0); gap: 14px;
439
+ transition: opacity 0.3s;
440
+ }
441
+ #overlay.hidden { opacity: 0; pointer-events: none; }
442
+ .spinner {
443
+ width: 30px; height: 30px;
444
+ border: 2.5px solid var(--bg-3);
445
+ border-top-color: var(--accent);
446
+ border-radius: 50%;
447
+ animation: spin 0.8s linear infinite;
448
+ }
449
+ @keyframes spin { to { transform: rotate(360deg); } }
450
+ #overlay .msg { font-size: 13px; color: var(--text-2); max-width: 380px; text-align: center; }
451
+ #overlay .empty-icon {
452
+ width: 64px; height: 64px; color: var(--text-3);
453
+ margin-bottom: 8px;
454
+ }
455
+ #overlay code {
456
+ background: var(--bg-2); border: 1px solid var(--border);
457
+ padding: 2px 6px; border-radius: 4px;
458
+ font-family: ui-monospace, "SF Mono", monospace;
459
+ color: var(--accent); font-size: 12px;
460
+ }
461
+ </style>
462
+ </head>
463
+ <body>
464
+
465
+ <div id="app">
466
+ <!-- ──────── Top bar ──────── -->
467
+ <header id="topbar">
468
+ <div id="logo">
469
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
470
+ <circle cx="12" cy="5" r="2.5"/><circle cx="5" cy="19" r="2.5"/><circle cx="19" cy="19" r="2.5"/>
471
+ <line x1="12" y1="7.5" x2="6" y2="16.5"/><line x1="12" y1="7.5" x2="18" y2="16.5"/>
472
+ </svg>
473
+ CodeGraph <small>viewer</small>
474
+ </div>
475
+ <span class="divider"></span>
476
+ <select id="repo-select" title="Repo"><option value="">— select repo —</option></select>
477
+ <div id="search-bar">
478
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
479
+ <circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.5" y2="16.5"/>
480
+ </svg>
481
+ <input id="search" type="search" placeholder="Search symbols…" autocomplete="off" spellcheck="false">
482
+ <span class="kbd">/</span>
483
+ </div>
484
+ <div id="topbar-right">
485
+ <button class="icon-btn" id="toggle-sidebar" title="Toggle sidebar">
486
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
487
+ </button>
488
+ <button class="icon-btn" id="toggle-minimap" title="Toggle minimap">
489
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="1 6 8 3 16 6 23 3 23 18 16 21 8 18 1 21"/><line x1="8" y1="3" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="21"/></svg>
490
+ </button>
491
+ <button class="icon-btn" id="show-help" title="Help (?)">
492
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
493
+ </button>
494
+ </div>
495
+ </header>
496
+
497
+ <!-- ──────── Main grid ──────── -->
498
+ <div id="main">
499
+ <!-- ──────── Sidebar ──────── -->
500
+ <aside id="sidebar">
501
+ <div class="section">
502
+ <div class="section-title">
503
+ <span>Node Types</span>
504
+ <span class="count" id="node-count">0</span>
505
+ </div>
506
+ <div id="node-filters"></div>
507
+ </div>
508
+
509
+ <div class="section">
510
+ <div class="section-title">
511
+ <span>Edge Types</span>
512
+ <span class="count" id="edge-count">0</span>
513
+ </div>
514
+ <div id="edge-filters"></div>
515
+ </div>
516
+
517
+ <div class="section">
518
+ <div class="section-title"><span>Display</span></div>
519
+ <label class="checkbox-row"><input type="checkbox" id="opt-size-by-degree" checked><span class="label">Size by connections</span></label>
520
+ <label class="checkbox-row"><input type="checkbox" id="opt-show-labels" checked><span class="label">Show labels</span></label>
521
+ <label class="checkbox-row"><input type="checkbox" id="opt-curved-edges" checked><span class="label">Curved edges</span></label>
522
+ </div>
523
+
524
+ <div class="section">
525
+ <div class="section-title"><span>Physics</span></div>
526
+ <div class="slider-row">
527
+ <label>Link distance <span class="value" id="v-link-dist">90</span></label>
528
+ <input type="range" id="s-link-dist" min="30" max="200" value="90">
529
+ </div>
530
+ <div class="slider-row">
531
+ <label>Link strength <span class="value" id="v-link-str">40</span></label>
532
+ <input type="range" id="s-link-str" min="0" max="100" value="40">
533
+ </div>
534
+ <div class="slider-row">
535
+ <label>Repel force <span class="value" id="v-repel">90</span></label>
536
+ <input type="range" id="s-repel" min="20" max="200" value="90">
537
+ </div>
538
+ <div class="slider-row">
539
+ <label>Center gravity <span class="value" id="v-gravity">8</span></label>
540
+ <input type="range" id="s-gravity" min="0" max="40" value="8">
541
+ </div>
542
+ </div>
543
+ </aside>
544
+
545
+ <!-- ──────── Canvas area ──────── -->
546
+ <div id="canvas-wrap">
547
+ <canvas id="canvas"></canvas>
548
+
549
+ <div id="minimap"><canvas id="minimap-canvas"></canvas></div>
550
+
551
+ <div id="stats">
552
+ <span class="pulse"></span>
553
+ <span id="stats-text">—</span>
554
+ </div>
555
+
556
+ <div id="zoom-controls">
557
+ <button id="zoom-out" title="Zoom out (-)">
558
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>
559
+ </button>
560
+ <span class="level" id="zoom-level">100%</span>
561
+ <button id="zoom-in" title="Zoom in (+)">
562
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
563
+ </button>
564
+ <span class="sep"></span>
565
+ <button id="zoom-fit" title="Fit to view (F)">
566
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 4 20 10 20"/><polyline points="20 10 20 4 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
567
+ </button>
568
+ </div>
569
+
570
+ <!-- Detail panel -->
571
+ <div id="detail">
572
+ <div id="detail-header">
573
+ <div class="meta">
574
+ <div id="detail-name">—</div>
575
+ <span class="kind-badge" id="detail-kind">—</span>
576
+ </div>
577
+ <button id="detail-close" title="Close (Esc)">
578
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
579
+ </button>
580
+ </div>
581
+ <div id="detail-body">
582
+ <div class="detail-meta">
583
+ <div class="detail-meta-row"><span class="key">Path</span><span class="val clickable" id="detail-path">—</span></div>
584
+ <div class="detail-meta-row"><span class="key">Line</span><span class="val" id="detail-line">—</span></div>
585
+ <div class="detail-meta-row"><span class="key">Connections</span><span class="val" id="detail-degree">—</span></div>
586
+ </div>
587
+ <div id="detail-edges"></div>
588
+ </div>
589
+ <div class="detail-actions">
590
+ <button class="btn-primary" id="btn-focus">
591
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="9"/></svg>
592
+ Focus
593
+ </button>
594
+ <button class="btn-secondary" id="btn-unpin" title="Unpin all">
595
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></svg>
596
+ Unpin
597
+ </button>
598
+ </div>
599
+ </div>
600
+
601
+ <!-- Help overlay -->
602
+ <div id="help">
603
+ <div id="help-content">
604
+ <h2>Keyboard & mouse</h2>
605
+ <h3>Navigation</h3>
606
+ <div class="shortcut"><span>Pan</span><span class="keys"><span class="kbd">Drag canvas</span></span></div>
607
+ <div class="shortcut"><span>Zoom</span><span class="keys"><span class="kbd">Scroll</span><span class="kbd">+</span><span class="kbd">−</span></span></div>
608
+ <div class="shortcut"><span>Fit to view</span><span class="keys"><span class="kbd">F</span><span class="kbd">Double-click</span></span></div>
609
+ <h3>Selection</h3>
610
+ <div class="shortcut"><span>Select node</span><span class="keys"><span class="kbd">Click</span></span></div>
611
+ <div class="shortcut"><span>Pin / move node</span><span class="keys"><span class="kbd">Drag node</span></span></div>
612
+ <div class="shortcut"><span>Deselect</span><span class="keys"><span class="kbd">Esc</span></span></div>
613
+ <h3>Other</h3>
614
+ <div class="shortcut"><span>Focus search</span><span class="keys"><span class="kbd">/</span></span></div>
615
+ <div class="shortcut"><span>Toggle sidebar</span><span class="keys"><span class="kbd">B</span></span></div>
616
+ <div class="shortcut"><span>Help</span><span class="keys"><span class="kbd">?</span></span></div>
617
+ </div>
618
+ </div>
619
+
620
+ <!-- Loading / empty overlay -->
621
+ <div id="overlay">
622
+ <div class="spinner"></div>
623
+ <div class="msg" id="overlay-msg">Connecting…</div>
624
+ </div>
625
+
626
+ <div id="tooltip"></div>
627
+ </div>
628
+ </div>
629
+ </div>
630
+
631
+ <script>
632
+ 'use strict';
633
+
634
+ /* ───────────────────────── Constants ───────────────────────── */
635
+ const NODE_KINDS = ['File','Function','Class','Interface','Component','Route','Variable'];
636
+ const EDGE_KINDS = ['IMPORTS','CALLS','RENDERS','INHERITS','DEFINES','EXPORTS'];
637
+
638
+ const cssVar = (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
639
+ const KIND_COLOR = {}, EDGE_COLOR = {};
640
+ for (const k of NODE_KINDS) KIND_COLOR[k] = cssVar(`--kind-${k}`);
641
+ for (const k of EDGE_KINDS) EDGE_COLOR[k] = cssVar(`--edge-${k}`);
642
+
643
+ /* ───────────────────────── State ───────────────────────── */
644
+ const state = {
645
+ // Graph
646
+ nodes: [], edges: [],
647
+ nodeById: new Map(),
648
+ edgesOut: new Map(),
649
+ edgesIn: new Map(),
650
+ degree: new Map(),
651
+
652
+ // Filters
653
+ visibleKinds: new Set(NODE_KINDS),
654
+ visibleEdges: new Set(EDGE_KINDS),
655
+ searchTerm: '',
656
+
657
+ // Selection
658
+ selected: null,
659
+ hovered: null,
660
+ focusedSet: null, // 2-hop neighborhood ids when focus-mode
661
+
662
+ // Display
663
+ sizeByDegree: true,
664
+ showLabels: true,
665
+ curvedEdges: true,
666
+
667
+ // Physics
668
+ linkDist: 90,
669
+ linkStrength: 0.04,
670
+ repel: 9000,
671
+ gravity: 0.008,
672
+ damp: 0.82,
673
+
674
+ // Viewport
675
+ tx: 0, ty: 0, scale: 1,
676
+ targetScale: 1,
677
+
678
+ // Simulation
679
+ simRunning: false, animId: null, alpha: 1,
680
+
681
+ // Interaction
682
+ draggingNode: null,
683
+ draggingCanvas: false,
684
+ dragStart: null,
685
+ showMinimap: true,
686
+ showSidebar: true,
687
+ };
688
+
689
+ /* ───────────────────────── DOM refs ───────────────────────── */
690
+ const $ = (id) => document.getElementById(id);
691
+ const canvas = $('canvas');
692
+ const ctx = canvas.getContext('2d');
693
+ const miniCanvas = $('minimap-canvas');
694
+ const miniCtx = miniCanvas.getContext('2d');
695
+ const tooltip = $('tooltip');
696
+ const overlay = $('overlay');
697
+ const overlayMsg = $('overlay-msg');
698
+ const repoSel = $('repo-select');
699
+ const searchEl = $('search');
700
+ const nodeFilters = $('node-filters');
701
+ const edgeFilters = $('edge-filters');
702
+ const statsText = $('stats-text');
703
+ const zoomLevel = $('zoom-level');
704
+ const detailEl = $('detail');
705
+ const main = $('main');
706
+ const helpEl = $('help');
707
+ const minimapEl = $('minimap');
708
+
709
+ /* ───────────────────────── Filters UI ───────────────────────── */
710
+ function buildFilters() {
711
+ nodeFilters.innerHTML = '';
712
+ edgeFilters.innerHTML = '';
713
+ for (const kind of NODE_KINDS) {
714
+ const row = document.createElement('div');
715
+ row.className = 'toggle-row';
716
+ row.innerHTML = `
717
+ <div class="toggle-left">
718
+ <span class="dot" style="background:${KIND_COLOR[kind]}"></span>
719
+ <span class="label">${kind}</span>
720
+ </div>
721
+ <span class="badge" data-kind="${kind}">0</span>`;
722
+ row.addEventListener('click', () => {
723
+ if (state.visibleKinds.has(kind)) state.visibleKinds.delete(kind);
724
+ else state.visibleKinds.add(kind);
725
+ row.classList.toggle('off');
726
+ render();
727
+ });
728
+ nodeFilters.appendChild(row);
729
+ }
730
+ for (const kind of EDGE_KINDS) {
731
+ const row = document.createElement('div');
732
+ row.className = 'toggle-row';
733
+ row.innerHTML = `
734
+ <div class="toggle-left">
735
+ <span class="dot" style="background:${EDGE_COLOR[kind]}"></span>
736
+ <span class="label">${kind}</span>
737
+ </div>
738
+ <span class="badge" data-edge="${kind}">0</span>`;
739
+ row.addEventListener('click', () => {
740
+ if (state.visibleEdges.has(kind)) state.visibleEdges.delete(kind);
741
+ else state.visibleEdges.add(kind);
742
+ row.classList.toggle('off');
743
+ render();
744
+ });
745
+ edgeFilters.appendChild(row);
746
+ }
747
+ }
748
+ buildFilters();
749
+
750
+ function updateFilterCounts() {
751
+ const nodeCounts = {}; const edgeCounts = {};
752
+ for (const k of NODE_KINDS) nodeCounts[k] = 0;
753
+ for (const k of EDGE_KINDS) edgeCounts[k] = 0;
754
+ for (const n of state.nodes) nodeCounts[n.kind] = (nodeCounts[n.kind] ?? 0) + 1;
755
+ for (const e of state.edges) edgeCounts[e.kind] = (edgeCounts[e.kind] ?? 0) + 1;
756
+ for (const k of NODE_KINDS) {
757
+ const el = document.querySelector(`[data-kind="${k}"]`);
758
+ if (el) el.textContent = nodeCounts[k];
759
+ }
760
+ for (const k of EDGE_KINDS) {
761
+ const el = document.querySelector(`[data-edge="${k}"]`);
762
+ if (el) el.textContent = edgeCounts[k];
763
+ }
764
+ $('node-count').textContent = state.nodes.length;
765
+ $('edge-count').textContent = state.edges.length;
766
+ }
767
+
768
+ /* ───────────────────────── API ───────────────────────── */
769
+ async function fetchRepos() {
770
+ const r = await fetch('/api/repos');
771
+ if (!r.ok) throw new Error(`/api/repos → ${r.status}`);
772
+ return r.json();
773
+ }
774
+ async function fetchGraph(repoId) {
775
+ const r = await fetch(`/api/graph?repoId=${encodeURIComponent(repoId)}`);
776
+ if (!r.ok) {
777
+ const body = await r.json().catch(() => ({}));
778
+ throw new Error(body.error || `/api/graph → ${r.status}`);
779
+ }
780
+ return r.json();
781
+ }
782
+
783
+ /* ───────────────────────── Graph loading ───────────────────────── */
784
+ function loadGraph(data) {
785
+ stopSim();
786
+ state.selected = null; state.hovered = null; state.focusedSet = null;
787
+ detailEl.classList.remove('visible');
788
+
789
+ const W = canvas.clientWidth || 800, H = canvas.clientHeight || 600;
790
+ // Initial layout: spiral so nothing starts on top of each other
791
+ state.nodes = data.nodes.map((n, i) => {
792
+ const r = 30 + Math.sqrt(i) * 22;
793
+ const a = i * 2.399;
794
+ return { ...n, x: Math.cos(a) * r, y: Math.sin(a) * r, vx: 0, vy: 0, pinned: false };
795
+ });
796
+ state.nodeById = new Map(state.nodes.map(n => [n.id, n]));
797
+
798
+ state.edgesOut = new Map(state.nodes.map(n => [n.id, []]));
799
+ state.edgesIn = new Map(state.nodes.map(n => [n.id, []]));
800
+ state.degree = new Map(state.nodes.map(n => [n.id, 0]));
801
+
802
+ state.edges = [];
803
+ for (const e of data.edges) {
804
+ const a = state.nodeById.get(e.from), b = state.nodeById.get(e.to);
805
+ if (!a || !b) continue;
806
+ state.edges.push({ from: e.from, to: e.to, kind: e.kind, a, b });
807
+ state.edgesOut.get(e.from).push({ kind: e.kind, other: b });
808
+ state.edgesIn.get(e.to).push({ kind: e.kind, other: a });
809
+ state.degree.set(e.from, state.degree.get(e.from) + 1);
810
+ state.degree.set(e.to, state.degree.get(e.to) + 1);
811
+ }
812
+
813
+ statsText.textContent = `${state.nodes.length} nodes · ${state.edges.length} edges`;
814
+ updateFilterCounts();
815
+ resetView();
816
+ state.alpha = 1;
817
+ startSim();
818
+ }
819
+
820
+ /* ───────────────────────── Physics ───────────────────────── */
821
+ function simStep() {
822
+ if (state.alpha < 0.005) { state.alpha = 0; stopSim(); return; }
823
+ const nodes = state.nodes;
824
+ const n = nodes.length;
825
+
826
+ // Repulsion — Barnes-Hut-ish budget: O(n²) only when ≤ 600 nodes,
827
+ // otherwise sample 80% of pairs per tick for perf.
828
+ const sparse = n > 600;
829
+ for (let i = 0; i < n; i++) {
830
+ const a = nodes[i];
831
+ for (let j = i + 1; j < n; j++) {
832
+ if (sparse && (i * j) % 5 === 0) continue;
833
+ const b = nodes[j];
834
+ let dx = b.x - a.x, dy = b.y - a.y;
835
+ const d2 = dx*dx + dy*dy || 1;
836
+ const d = Math.sqrt(d2);
837
+ const f = state.repel / d2 * state.alpha;
838
+ const fx = dx/d * f, fy = dy/d * f;
839
+ a.vx -= fx; a.vy -= fy;
840
+ b.vx += fx; b.vy += fy;
841
+ }
842
+ }
843
+ // Springs
844
+ for (const e of state.edges) {
845
+ const a = e.a, b = e.b;
846
+ const dx = b.x - a.x, dy = b.y - a.y;
847
+ const d = Math.sqrt(dx*dx + dy*dy) || 1;
848
+ const f = (d - state.linkDist) * state.linkStrength * state.alpha;
849
+ const fx = dx/d * f, fy = dy/d * f;
850
+ if (!a.pinned) { a.vx += fx; a.vy += fy; }
851
+ if (!b.pinned) { b.vx -= fx; b.vy -= fy; }
852
+ }
853
+ // Gravity + damp
854
+ for (const node of nodes) {
855
+ if (node.pinned) continue;
856
+ node.vx -= node.x * state.gravity;
857
+ node.vy -= node.y * state.gravity;
858
+ node.vx *= state.damp;
859
+ node.vy *= state.damp;
860
+ node.x += node.vx;
861
+ node.y += node.vy;
862
+ }
863
+ state.alpha *= 0.99;
864
+ }
865
+ function startSim() {
866
+ if (state.simRunning) return;
867
+ state.simRunning = true;
868
+ function loop() {
869
+ if (!state.simRunning) return;
870
+ simStep();
871
+ smoothZoom();
872
+ render();
873
+ state.animId = requestAnimationFrame(loop);
874
+ }
875
+ state.animId = requestAnimationFrame(loop);
876
+ }
877
+ function stopSim() {
878
+ state.simRunning = false;
879
+ if (state.animId) { cancelAnimationFrame(state.animId); state.animId = null; }
880
+ }
881
+ function kickSim(a = 0.3) {
882
+ state.alpha = Math.max(state.alpha, a);
883
+ if (!state.simRunning) startSim();
884
+ }
885
+ function smoothZoom() {
886
+ if (Math.abs(state.scale - state.targetScale) > 0.001) {
887
+ state.scale += (state.targetScale - state.scale) * 0.2;
888
+ }
889
+ }
890
+
891
+ /* ───────────────────────── Node sizing ───────────────────────── */
892
+ const BASE_R = 5.5;
893
+ function nodeRadius(n) {
894
+ if (!state.sizeByDegree) return BASE_R;
895
+ const d = state.degree.get(n.id) ?? 0;
896
+ return BASE_R + Math.min(Math.sqrt(d) * 1.8, 12);
897
+ }
898
+
899
+ /* ───────────────────────── Highlighting ───────────────────────── */
900
+ function neighborSet(node) {
901
+ if (!node) return null;
902
+ const out = new Set([node.id]);
903
+ for (const e of state.edgesOut.get(node.id) ?? []) out.add(e.other.id);
904
+ for (const e of state.edgesIn.get(node.id) ?? []) out.add(e.other.id);
905
+ return out;
906
+ }
907
+ function highlightedSet() {
908
+ if (state.focusedSet) return state.focusedSet;
909
+ if (state.selected) return neighborSet(state.selected);
910
+ if (state.hovered) return neighborSet(state.hovered);
911
+ return null;
912
+ }
913
+
914
+ /* ───────────────────────── Render ───────────────────────── */
915
+ function render() {
916
+ const W = canvas.width, H = canvas.height;
917
+ const dpr = devicePixelRatio || 1;
918
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
919
+ ctx.clearRect(0, 0, W, H);
920
+ ctx.translate(W/2 + state.tx * dpr, H/2 + state.ty * dpr);
921
+ ctx.scale(state.scale * dpr, state.scale * dpr);
922
+
923
+ const s = state.scale;
924
+ const search = state.searchTerm.toLowerCase();
925
+ const hi = highlightedSet();
926
+ const isHi = (id) => hi == null || hi.has(id);
927
+
928
+ zoomLevel.textContent = `${Math.round(s * 100)}%`;
929
+
930
+ /* Edges */
931
+ for (const e of state.edges) {
932
+ if (!state.visibleEdges.has(e.kind)) continue;
933
+ const a = e.a, b = e.b;
934
+ if (!state.visibleKinds.has(a.kind) || !state.visibleKinds.has(b.kind)) continue;
935
+ const highlighted = isHi(a.id) && isHi(b.id);
936
+ const matchSearch = !search ||
937
+ a.name.toLowerCase().includes(search) ||
938
+ b.name.toLowerCase().includes(search);
939
+ const visible = highlighted && matchSearch;
940
+ ctx.globalAlpha = visible ? 0.55 : 0.06;
941
+ ctx.strokeStyle = EDGE_COLOR[e.kind] ?? '#555';
942
+ ctx.lineWidth = (visible ? 1.4 : 1.0) / s;
943
+
944
+ const ra = nodeRadius(a), rb = nodeRadius(b);
945
+ const dx = b.x - a.x, dy = b.y - a.y;
946
+ const d = Math.sqrt(dx*dx + dy*dy) || 1;
947
+ const sx = a.x + dx/d * ra, sy = a.y + dy/d * ra;
948
+ const ex = b.x - dx/d * rb, ey = b.y - dy/d * rb;
949
+
950
+ ctx.beginPath();
951
+ if (state.curvedEdges) {
952
+ // Curve perpendicular by a small offset; positive offset only when from < to id
953
+ // to avoid same-line A↔B overlap.
954
+ const off = e.from < e.to ? 8 : -8;
955
+ const mx = (sx + ex) / 2 + (-dy/d) * off;
956
+ const my = (sy + ey) / 2 + ( dx/d) * off;
957
+ ctx.moveTo(sx, sy);
958
+ ctx.quadraticCurveTo(mx, my, ex, ey);
959
+ } else {
960
+ ctx.moveTo(sx, sy);
961
+ ctx.lineTo(ex, ey);
962
+ }
963
+ ctx.stroke();
964
+
965
+ // Arrow head
966
+ if (visible && s > 0.45) {
967
+ const ang = Math.atan2(ey - (state.curvedEdges ? ((sy+ey)/2 + (-dy/d)*(e.from<e.to?8:-8)) : sy),
968
+ ex - (state.curvedEdges ? ((sx+ex)/2 + (-dy/d)*(e.from<e.to?8:-8)*0) : sx));
969
+ const finalAng = Math.atan2(dy, dx);
970
+ const ar = 4 / s;
971
+ ctx.beginPath();
972
+ ctx.moveTo(ex, ey);
973
+ ctx.lineTo(ex - ar*Math.cos(finalAng - 0.42), ey - ar*Math.sin(finalAng - 0.42));
974
+ ctx.lineTo(ex - ar*Math.cos(finalAng + 0.42), ey - ar*Math.sin(finalAng + 0.42));
975
+ ctx.closePath();
976
+ ctx.fillStyle = EDGE_COLOR[e.kind] ?? '#555';
977
+ ctx.fill();
978
+ }
979
+ }
980
+
981
+ /* Nodes */
982
+ for (const n of state.nodes) {
983
+ if (!state.visibleKinds.has(n.kind)) continue;
984
+ const r = nodeRadius(n) / s;
985
+ const isSel = n === state.selected;
986
+ const isHov = n === state.hovered;
987
+ const highlighted= isHi(n.id);
988
+ const matchSearch= !search || n.name.toLowerCase().includes(search);
989
+ const visible = highlighted && matchSearch;
990
+ const dim = !visible;
991
+ const isMatch = search && n.name.toLowerCase().includes(search);
992
+
993
+ ctx.globalAlpha = dim ? 0.18 : 1;
994
+
995
+ // Glow on selection/hover/match
996
+ if (isSel || isHov || isMatch) {
997
+ const glowR = (isSel ? r * 2.6 : isMatch ? r * 2.0 : r * 1.8);
998
+ const grad = ctx.createRadialGradient(n.x, n.y, r * 0.5, n.x, n.y, glowR);
999
+ const c = isMatch ? 'rgba(251,191,36,0.45)' : `${KIND_COLOR[n.kind]}66`;
1000
+ grad.addColorStop(0, c);
1001
+ grad.addColorStop(1, `${KIND_COLOR[n.kind]}00`);
1002
+ ctx.fillStyle = grad;
1003
+ ctx.beginPath();
1004
+ ctx.arc(n.x, n.y, glowR, 0, Math.PI * 2);
1005
+ ctx.fill();
1006
+ }
1007
+
1008
+ // Body
1009
+ ctx.fillStyle = KIND_COLOR[n.kind] ?? '#888';
1010
+ ctx.beginPath();
1011
+ ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
1012
+ ctx.fill();
1013
+
1014
+ // Ring (selection / search match / pinned)
1015
+ if (isSel) {
1016
+ ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2 / s;
1017
+ ctx.stroke();
1018
+ } else if (isMatch) {
1019
+ ctx.strokeStyle = '#fbbf24'; ctx.lineWidth = 1.7 / s;
1020
+ ctx.stroke();
1021
+ } else if (isHov) {
1022
+ ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 1.3 / s;
1023
+ ctx.stroke();
1024
+ }
1025
+
1026
+ // Pinned indicator: small white dot top-right of the node
1027
+ if (n.pinned) {
1028
+ ctx.fillStyle = '#ffffff';
1029
+ ctx.beginPath();
1030
+ ctx.arc(n.x + r * 0.7, n.y - r * 0.7, 1.4 / s, 0, Math.PI * 2);
1031
+ ctx.fill();
1032
+ }
1033
+
1034
+ // Label — fade by zoom + dim if not highlighted
1035
+ if (state.showLabels && s > 0.5 && !dim) {
1036
+ const labelAlpha = isSel || isHov || isMatch ? 1 : Math.min(1, (s - 0.5) * 2.5);
1037
+ ctx.globalAlpha = labelAlpha;
1038
+ ctx.fillStyle = isSel || isMatch ? '#ffffff' : '#cfd6dd';
1039
+ ctx.font = `${(isSel ? 12 : 11) / s}px ui-monospace,monospace`;
1040
+ ctx.textBaseline = 'middle';
1041
+ ctx.fillText(n.name, n.x + r + 4/s, n.y);
1042
+ }
1043
+ }
1044
+
1045
+ ctx.globalAlpha = 1;
1046
+ renderMinimap();
1047
+ }
1048
+
1049
+ /* ───────────────────────── Minimap ───────────────────────── */
1050
+ function renderMinimap() {
1051
+ if (!state.showMinimap) return;
1052
+ const mw = miniCanvas.width, mh = miniCanvas.height;
1053
+ miniCtx.fillStyle = '#0a0e14';
1054
+ miniCtx.fillRect(0, 0, mw, mh);
1055
+ if (!state.nodes.length) return;
1056
+
1057
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
1058
+ for (const n of state.nodes) {
1059
+ if (n.x < minX) minX = n.x; if (n.x > maxX) maxX = n.x;
1060
+ if (n.y < minY) minY = n.y; if (n.y > maxY) maxY = n.y;
1061
+ }
1062
+ const rangeX = (maxX - minX) || 1, rangeY = (maxY - minY) || 1;
1063
+ const margin = 8;
1064
+ const sx = (mw - margin*2) / rangeX, sy = (mh - margin*2) / rangeY;
1065
+ const sk = Math.min(sx, sy);
1066
+ const ox = mw/2 - ((minX + maxX) / 2) * sk;
1067
+ const oy = mh/2 - ((minY + maxY) / 2) * sk;
1068
+
1069
+ for (const n of state.nodes) {
1070
+ if (!state.visibleKinds.has(n.kind)) continue;
1071
+ miniCtx.fillStyle = KIND_COLOR[n.kind] ?? '#666';
1072
+ miniCtx.fillRect(n.x * sk + ox - 1, n.y * sk + oy - 1, 2, 2);
1073
+ }
1074
+
1075
+ // Viewport rect
1076
+ const W = canvas.clientWidth, H = canvas.clientHeight;
1077
+ const vw = W / state.scale, vh = H / state.scale;
1078
+ const cx = -state.tx / state.scale, cy = -state.ty / state.scale;
1079
+ miniCtx.strokeStyle = 'rgba(255,255,255,0.5)';
1080
+ miniCtx.lineWidth = 1;
1081
+ miniCtx.strokeRect(
1082
+ (cx - vw/2) * sk + ox,
1083
+ (cy - vh/2) * sk + oy,
1084
+ vw * sk, vh * sk,
1085
+ );
1086
+ }
1087
+
1088
+ /* ───────────────────────── Hit-test ───────────────────────── */
1089
+ function toWorld(px, py) {
1090
+ const W = canvas.clientWidth, H = canvas.clientHeight;
1091
+ return {
1092
+ x: (px - W/2 - state.tx) / state.scale,
1093
+ y: (py - H/2 - state.ty) / state.scale,
1094
+ };
1095
+ }
1096
+ function hitTest(px, py) {
1097
+ const w = toWorld(px, py);
1098
+ let best = null, bestD = Infinity;
1099
+ for (let i = state.nodes.length - 1; i >= 0; i--) {
1100
+ const n = state.nodes[i];
1101
+ if (!state.visibleKinds.has(n.kind)) continue;
1102
+ const r = nodeRadius(n) + 5;
1103
+ const dx = n.x - w.x, dy = n.y - w.y;
1104
+ const d2 = dx*dx + dy*dy;
1105
+ if (d2 < r*r && d2 < bestD) { best = n; bestD = d2; }
1106
+ }
1107
+ return best;
1108
+ }
1109
+
1110
+ /* ───────────────────────── Viewport ───────────────────────── */
1111
+ function resetView() { state.tx = 0; state.ty = 0; state.scale = state.targetScale = 1; render(); }
1112
+ function fitView() {
1113
+ if (!state.nodes.length) return;
1114
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
1115
+ for (const n of state.nodes) {
1116
+ if (!state.visibleKinds.has(n.kind)) continue;
1117
+ if (n.x < minX) minX = n.x; if (n.x > maxX) maxX = n.x;
1118
+ if (n.y < minY) minY = n.y; if (n.y > maxY) maxY = n.y;
1119
+ }
1120
+ if (!isFinite(minX)) return;
1121
+ const W = canvas.clientWidth, H = canvas.clientHeight;
1122
+ const mx = (minX + maxX) / 2, my = (minY + maxY) / 2;
1123
+ const rx = (maxX - minX) || 1, ry = (maxY - minY) || 1;
1124
+ state.targetScale = Math.min(0.85 * W / rx, 0.85 * H / ry, 1.6);
1125
+ state.scale = state.targetScale;
1126
+ state.tx = -mx * state.scale;
1127
+ state.ty = -my * state.scale;
1128
+ render();
1129
+ }
1130
+
1131
+ /* ───────────────────────── Detail panel ───────────────────────── */
1132
+ function showDetail(node) {
1133
+ state.selected = node;
1134
+ detailEl.classList.add('visible');
1135
+ $('detail-name').textContent = node.name;
1136
+ const badge = $('detail-kind');
1137
+ badge.textContent = node.kind;
1138
+ badge.style.background = `${KIND_COLOR[node.kind]}26`;
1139
+ badge.style.color = KIND_COLOR[node.kind];
1140
+
1141
+ const path = node.path || '—';
1142
+ const pathEl = $('detail-path');
1143
+ pathEl.textContent = path;
1144
+ pathEl.title = path;
1145
+ pathEl.onclick = path !== '—' ? () => navigator.clipboard?.writeText(path) : null;
1146
+ $('detail-line').textContent = node.lineStart != null && node.lineStart > 0 ? `:${node.lineStart}` : '—';
1147
+ $('detail-degree').textContent = state.degree.get(node.id) ?? 0;
1148
+
1149
+ // Edges grouped
1150
+ const out = state.edgesOut.get(node.id) ?? [];
1151
+ const inp = state.edgesIn.get(node.id) ?? [];
1152
+ const edgesBox = $('detail-edges');
1153
+ edgesBox.innerHTML = '';
1154
+
1155
+ function renderGroup(title, list, dir) {
1156
+ if (!list.length) return;
1157
+ const group = document.createElement('div'); group.className = 'edge-group';
1158
+ const groupTitle = document.createElement('div'); groupTitle.className = 'edge-group-title';
1159
+ groupTitle.innerHTML = `<span>${title}</span><span>${list.length}</span>`;
1160
+ group.appendChild(groupTitle);
1161
+ const ul = document.createElement('ul'); ul.className = 'edge-list';
1162
+ for (const { kind, other } of list.slice(0, 30)) {
1163
+ const li = document.createElement('li');
1164
+ li.innerHTML = `
1165
+ <span class="edge-pill" style="background:${EDGE_COLOR[kind]}33;color:${EDGE_COLOR[kind]}">${kind}</span>
1166
+ <span class="target-kind-dot" style="background:${KIND_COLOR[other.kind]}"></span>
1167
+ <span class="target-name" title="${other.name}">${other.name}</span>`;
1168
+ li.addEventListener('click', () => {
1169
+ showDetail(other);
1170
+ centerOn(other);
1171
+ render();
1172
+ });
1173
+ ul.appendChild(li);
1174
+ }
1175
+ if (list.length > 30) {
1176
+ const more = document.createElement('li');
1177
+ more.style.color = 'var(--text-3)';
1178
+ more.style.justifyContent = 'center';
1179
+ more.style.cursor = 'default';
1180
+ more.textContent = `+${list.length - 30} more`;
1181
+ ul.appendChild(more);
1182
+ }
1183
+ group.appendChild(ul);
1184
+ edgesBox.appendChild(group);
1185
+ }
1186
+ renderGroup('Outgoing', out, 'out');
1187
+ renderGroup('Incoming', inp, 'in');
1188
+ if (!out.length && !inp.length) {
1189
+ const empty = document.createElement('div');
1190
+ empty.style.fontSize = '11.5px';
1191
+ empty.style.color = 'var(--text-3)';
1192
+ empty.style.padding = '8px 0';
1193
+ empty.textContent = 'No connected nodes.';
1194
+ edgesBox.appendChild(empty);
1195
+ }
1196
+ render();
1197
+ }
1198
+ function closeDetail() {
1199
+ state.selected = null;
1200
+ state.focusedSet = null;
1201
+ detailEl.classList.remove('visible');
1202
+ render();
1203
+ }
1204
+ function centerOn(node) {
1205
+ state.tx = -node.x * state.scale;
1206
+ state.ty = -node.y * state.scale;
1207
+ }
1208
+
1209
+ /* ───────────────────────── Focus mode ───────────────────────── */
1210
+ function focusOnSelected() {
1211
+ if (!state.selected) return;
1212
+ const set = new Set([state.selected.id]);
1213
+ const oneHop = neighborSet(state.selected);
1214
+ for (const id of oneHop ?? []) {
1215
+ set.add(id);
1216
+ for (const e of state.edgesOut.get(id) ?? []) set.add(e.other.id);
1217
+ for (const e of state.edgesIn.get(id) ?? []) set.add(e.other.id);
1218
+ }
1219
+ state.focusedSet = set;
1220
+ fitFocused();
1221
+ }
1222
+ function fitFocused() {
1223
+ if (!state.focusedSet) return;
1224
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
1225
+ for (const n of state.nodes) {
1226
+ if (!state.focusedSet.has(n.id)) continue;
1227
+ if (n.x < minX) minX = n.x; if (n.x > maxX) maxX = n.x;
1228
+ if (n.y < minY) minY = n.y; if (n.y > maxY) maxY = n.y;
1229
+ }
1230
+ if (!isFinite(minX)) return;
1231
+ const W = canvas.clientWidth, H = canvas.clientHeight;
1232
+ const mx = (minX + maxX) / 2, my = (minY + maxY) / 2;
1233
+ const rx = (maxX - minX) || 200, ry = (maxY - minY) || 200;
1234
+ state.targetScale = Math.min(0.75 * W / rx, 0.75 * H / ry, 2.5);
1235
+ state.tx = -mx * state.targetScale;
1236
+ state.ty = -my * state.targetScale;
1237
+ }
1238
+ function unpinAll() {
1239
+ for (const n of state.nodes) n.pinned = false;
1240
+ kickSim(0.4);
1241
+ }
1242
+
1243
+ /* ───────────────────────── Pointer events ───────────────────────── */
1244
+ function getCanvasXY(e) {
1245
+ const r = canvas.getBoundingClientRect();
1246
+ const t = e.touches?.[0] ?? e;
1247
+ return { x: t.clientX - r.left, y: t.clientY - r.top };
1248
+ }
1249
+ canvas.addEventListener('pointerdown', (e) => {
1250
+ const { x, y } = getCanvasXY(e);
1251
+ const hit = hitTest(x, y);
1252
+ if (hit) {
1253
+ state.draggingNode = hit;
1254
+ hit.pinned = true;
1255
+ canvas.classList.add('node-drag');
1256
+ showDetail(hit);
1257
+ kickSim(0.2);
1258
+ } else {
1259
+ state.draggingCanvas = true;
1260
+ state.dragStart = { x, y, tx: state.tx, ty: state.ty };
1261
+ canvas.classList.add('dragging');
1262
+ if (state.selected) closeDetail();
1263
+ }
1264
+ canvas.setPointerCapture(e.pointerId);
1265
+ });
1266
+ canvas.addEventListener('pointermove', (e) => {
1267
+ const { x, y } = getCanvasXY(e);
1268
+ if (state.draggingNode) {
1269
+ const w = toWorld(x, y);
1270
+ state.draggingNode.x = w.x; state.draggingNode.y = w.y;
1271
+ state.draggingNode.vx = 0; state.draggingNode.vy = 0;
1272
+ if (!state.simRunning) render();
1273
+ } else if (state.draggingCanvas && state.dragStart) {
1274
+ state.tx = state.dragStart.tx + (x - state.dragStart.x);
1275
+ state.ty = state.dragStart.ty + (y - state.dragStart.y);
1276
+ if (!state.simRunning) render();
1277
+ } else {
1278
+ const hit = hitTest(x, y);
1279
+ if (hit !== state.hovered) {
1280
+ state.hovered = hit;
1281
+ if (!state.simRunning) render();
1282
+ }
1283
+ if (hit) {
1284
+ tooltip.style.display = 'block';
1285
+ tooltip.style.left = `${e.clientX + 14}px`;
1286
+ tooltip.style.top = `${e.clientY - 8}px`;
1287
+ tooltip.innerHTML = `<span class="kind">${hit.kind}</span>${hit.name}`;
1288
+ } else tooltip.style.display = 'none';
1289
+ }
1290
+ });
1291
+ canvas.addEventListener('pointerup', () => {
1292
+ if (state.draggingNode) { state.draggingNode = null; canvas.classList.remove('node-drag'); }
1293
+ state.draggingCanvas = false; state.dragStart = null;
1294
+ canvas.classList.remove('dragging');
1295
+ });
1296
+ canvas.addEventListener('wheel', (e) => {
1297
+ e.preventDefault();
1298
+ const { x, y } = getCanvasXY(e);
1299
+ const W = canvas.clientWidth, H = canvas.clientHeight;
1300
+ const factor = e.deltaY < 0 ? 1.12 : 1/1.12;
1301
+ const ns = Math.max(0.1, Math.min(8, state.scale * factor));
1302
+ const ratio = ns / state.scale;
1303
+ state.tx = (state.tx - (x - W/2)) * ratio + (x - W/2);
1304
+ state.ty = (state.ty - (y - H/2)) * ratio + (y - H/2);
1305
+ state.scale = state.targetScale = ns;
1306
+ if (!state.simRunning) render();
1307
+ }, { passive: false });
1308
+ canvas.addEventListener('dblclick', () => fitView());
1309
+
1310
+ /* Minimap click → recenter */
1311
+ minimapEl.addEventListener('click', (e) => {
1312
+ if (!state.nodes.length) return;
1313
+ const r = miniCanvas.getBoundingClientRect();
1314
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
1315
+ for (const n of state.nodes) {
1316
+ if (n.x < minX) minX = n.x; if (n.x > maxX) maxX = n.x;
1317
+ if (n.y < minY) minY = n.y; if (n.y > maxY) maxY = n.y;
1318
+ }
1319
+ const rx = (maxX - minX) || 1, ry = (maxY - minY) || 1;
1320
+ const margin = 8;
1321
+ const sk = Math.min((miniCanvas.width - margin*2) / rx,
1322
+ (miniCanvas.height - margin*2) / ry);
1323
+ const ox = miniCanvas.width/2 - ((minX + maxX) / 2) * sk;
1324
+ const oy = miniCanvas.height/2 - ((minY + maxY) / 2) * sk;
1325
+ const px = ((e.clientX - r.left) * (miniCanvas.width / r.width) - ox) / sk;
1326
+ const py = ((e.clientY - r.top ) * (miniCanvas.height / r.height) - oy) / sk;
1327
+ state.tx = -px * state.scale;
1328
+ state.ty = -py * state.scale;
1329
+ if (!state.simRunning) render();
1330
+ });
1331
+
1332
+ /* ───────────────────────── Controls ───────────────────────── */
1333
+ $('zoom-in').addEventListener('click', () => zoomBy(1.25));
1334
+ $('zoom-out').addEventListener('click', () => zoomBy(1/1.25));
1335
+ $('zoom-fit').addEventListener('click', () => fitView());
1336
+ $('detail-close').addEventListener('click', closeDetail);
1337
+ $('btn-focus').addEventListener('click', focusOnSelected);
1338
+ $('btn-unpin').addEventListener('click', unpinAll);
1339
+ $('toggle-sidebar').addEventListener('click', () => toggleSidebar());
1340
+ $('toggle-minimap').addEventListener('click', () => {
1341
+ state.showMinimap = !state.showMinimap;
1342
+ minimapEl.style.display = state.showMinimap ? 'block' : 'none';
1343
+ $('toggle-minimap').classList.toggle('active', state.showMinimap);
1344
+ });
1345
+ $('toggle-minimap').classList.add('active');
1346
+ $('show-help').addEventListener('click', () => helpEl.classList.toggle('visible'));
1347
+ helpEl.addEventListener('click', (e) => { if (e.target === helpEl) helpEl.classList.remove('visible'); });
1348
+
1349
+ function zoomBy(factor) {
1350
+ const W = canvas.clientWidth, H = canvas.clientHeight;
1351
+ const ns = Math.max(0.1, Math.min(8, state.scale * factor));
1352
+ const ratio = ns / state.scale;
1353
+ state.tx *= ratio;
1354
+ state.ty *= ratio;
1355
+ state.scale = state.targetScale = ns;
1356
+ if (!state.simRunning) render();
1357
+ }
1358
+ function toggleSidebar() {
1359
+ state.showSidebar = !state.showSidebar;
1360
+ main.classList.toggle('sidebar-collapsed', !state.showSidebar);
1361
+ $('toggle-sidebar').classList.toggle('active', state.showSidebar);
1362
+ setTimeout(resizeCanvas, 200);
1363
+ }
1364
+ $('toggle-sidebar').classList.add('active');
1365
+
1366
+ /* Display toggles */
1367
+ $('opt-size-by-degree').addEventListener('change', (e) => { state.sizeByDegree = e.target.checked; render(); });
1368
+ $('opt-show-labels').addEventListener('change', (e) => { state.showLabels = e.target.checked; render(); });
1369
+ $('opt-curved-edges').addEventListener('change', (e) => { state.curvedEdges = e.target.checked; render(); });
1370
+
1371
+ /* Physics sliders */
1372
+ function bindSlider(id, valueId, key, transform) {
1373
+ const slider = $(id), val = $(valueId);
1374
+ slider.addEventListener('input', () => {
1375
+ const raw = Number(slider.value);
1376
+ state[key] = transform ? transform(raw) : raw;
1377
+ val.textContent = raw;
1378
+ kickSim(0.4);
1379
+ });
1380
+ }
1381
+ bindSlider('s-link-dist', 'v-link-dist', 'linkDist');
1382
+ bindSlider('s-link-str', 'v-link-str', 'linkStrength', (v) => v / 1000);
1383
+ bindSlider('s-repel', 'v-repel', 'repel', (v) => v * 100);
1384
+ bindSlider('s-gravity', 'v-gravity', 'gravity', (v) => v / 1000);
1385
+
1386
+ /* ───────────────────────── Search ───────────────────────── */
1387
+ searchEl.addEventListener('input', () => {
1388
+ state.searchTerm = searchEl.value.trim();
1389
+ if (!state.simRunning) render();
1390
+ });
1391
+
1392
+ /* ───────────────────────── Keyboard ───────────────────────── */
1393
+ document.addEventListener('keydown', (e) => {
1394
+ if (e.target instanceof HTMLInputElement && e.target.type !== 'range' && e.target.type !== 'checkbox') {
1395
+ if (e.key === 'Escape') e.target.blur();
1396
+ return;
1397
+ }
1398
+ if (e.key === '/') { e.preventDefault(); searchEl.focus(); searchEl.select(); }
1399
+ else if (e.key === '?') { e.preventDefault(); helpEl.classList.toggle('visible'); }
1400
+ else if (e.key === 'f' || e.key === 'F') { fitView(); }
1401
+ else if (e.key === 'b' || e.key === 'B') { toggleSidebar(); }
1402
+ else if (e.key === 'Escape') { closeDetail(); helpEl.classList.remove('visible'); }
1403
+ else if (e.key === '+' || e.key === '=') { zoomBy(1.25); }
1404
+ else if (e.key === '-' || e.key === '_') { zoomBy(1/1.25); }
1405
+ });
1406
+
1407
+ /* ───────────────────────── Resize ───────────────────────── */
1408
+ function resizeCanvas() {
1409
+ for (const c of [canvas, miniCanvas]) {
1410
+ const r = c.getBoundingClientRect();
1411
+ c.width = Math.max(1, r.width) * devicePixelRatio;
1412
+ c.height = Math.max(1, r.height) * devicePixelRatio;
1413
+ }
1414
+ if (!state.simRunning) render();
1415
+ }
1416
+ new ResizeObserver(resizeCanvas).observe(canvas);
1417
+ new ResizeObserver(resizeCanvas).observe(miniCanvas);
1418
+
1419
+ /* ───────────────────────── Repo loader ───────────────────────── */
1420
+ async function onRepoChange(repoId) {
1421
+ if (!repoId) return;
1422
+ overlay.classList.remove('hidden');
1423
+ overlayMsg.textContent = 'Loading graph…';
1424
+ try {
1425
+ const data = await fetchGraph(repoId);
1426
+ loadGraph(data);
1427
+ overlay.classList.add('hidden');
1428
+ setTimeout(fitView, 1500);
1429
+ } catch (err) {
1430
+ overlayMsg.innerHTML = `<div style="color:#fbbf24">Error loading graph</div><div style="margin-top:4px;font-size:11px">${err.message}</div>`;
1431
+ }
1432
+ }
1433
+ repoSel.addEventListener('change', () => onRepoChange(repoSel.value));
1434
+
1435
+ /* ───────────────────────── Boot ───────────────────────── */
1436
+ (async () => {
1437
+ overlayMsg.textContent = 'Connecting to CodeGraph…';
1438
+ try {
1439
+ const { repos } = await fetchRepos();
1440
+ if (!repos.length) {
1441
+ overlay.innerHTML = `
1442
+ <svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
1443
+ <circle cx="12" cy="5" r="2.5"/><circle cx="5" cy="19" r="2.5"/><circle cx="19" cy="19" r="2.5"/>
1444
+ <line x1="12" y1="7.5" x2="6" y2="16.5"/><line x1="12" y1="7.5" x2="18" y2="16.5"/>
1445
+ </svg>
1446
+ <div class="msg">No indexed repos found. Run <code>codegraph index &lt;path&gt;</code> to get started.</div>`;
1447
+ return;
1448
+ }
1449
+ for (const id of repos) {
1450
+ const opt = document.createElement('option');
1451
+ opt.value = opt.textContent = id;
1452
+ repoSel.appendChild(opt);
1453
+ }
1454
+ repoSel.value = repos[0];
1455
+ await onRepoChange(repos[0]);
1456
+ } catch (err) {
1457
+ overlay.innerHTML = `
1458
+ <svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
1459
+ <circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
1460
+ </svg>
1461
+ <div class="msg">Cannot reach CodeGraph server. Is <code>codegraph serve</code> running?<br/><br/><span style="color:var(--text-3);font-size:11px">${err.message}</span></div>`;
1462
+ }
1463
+ })();
1464
+ </script>
1465
+ </body>
1466
+ </html>