@syke1/mcp-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +112 -0
  2. package/dist/ai/analyzer.d.ts +3 -0
  3. package/dist/ai/analyzer.js +120 -0
  4. package/dist/ai/realtime-analyzer.d.ts +20 -0
  5. package/dist/ai/realtime-analyzer.js +182 -0
  6. package/dist/graph.d.ts +13 -0
  7. package/dist/graph.js +105 -0
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.js +518 -0
  10. package/dist/languages/cpp.d.ts +2 -0
  11. package/dist/languages/cpp.js +109 -0
  12. package/dist/languages/dart.d.ts +2 -0
  13. package/dist/languages/dart.js +162 -0
  14. package/dist/languages/go.d.ts +2 -0
  15. package/dist/languages/go.js +111 -0
  16. package/dist/languages/java.d.ts +2 -0
  17. package/dist/languages/java.js +113 -0
  18. package/dist/languages/plugin.d.ts +20 -0
  19. package/dist/languages/plugin.js +148 -0
  20. package/dist/languages/python.d.ts +2 -0
  21. package/dist/languages/python.js +129 -0
  22. package/dist/languages/ruby.d.ts +2 -0
  23. package/dist/languages/ruby.js +97 -0
  24. package/dist/languages/rust.d.ts +2 -0
  25. package/dist/languages/rust.js +121 -0
  26. package/dist/languages/typescript.d.ts +2 -0
  27. package/dist/languages/typescript.js +138 -0
  28. package/dist/license/validator.d.ts +23 -0
  29. package/dist/license/validator.js +297 -0
  30. package/dist/tools/analyze-impact.d.ts +23 -0
  31. package/dist/tools/analyze-impact.js +102 -0
  32. package/dist/tools/gate-build.d.ts +25 -0
  33. package/dist/tools/gate-build.js +243 -0
  34. package/dist/watcher/file-cache.d.ts +56 -0
  35. package/dist/watcher/file-cache.js +241 -0
  36. package/dist/web/public/app.js +2398 -0
  37. package/dist/web/public/index.html +258 -0
  38. package/dist/web/public/style.css +1827 -0
  39. package/dist/web/server.d.ts +29 -0
  40. package/dist/web/server.js +744 -0
  41. package/package.json +50 -0
@@ -0,0 +1,2398 @@
1
+ // SYKE Dashboard — Advanced v2.1
2
+
3
+ let Graph = null;
4
+ let graphData = null;
5
+ let selectedFile = null;
6
+ let autoRotate = false;
7
+ let highlightNodes = new Set();
8
+ let highlightLinks = new Set();
9
+ let selectedNodeId = null;
10
+ let hiddenLayers = new Set();
11
+ let hiddenNodes = new Set();
12
+ let pathMode = false;
13
+ let pathFrom = null;
14
+ let pathTo = null;
15
+ let contextNode = null;
16
+ let crawlAnimationId = null;
17
+ let crawlData = null;
18
+ let modifyingNodes = new Set(); // nodes currently being modified by AI
19
+ let heartbeatNodes = new Map(); // nodeId → { riskLevel, startTime, interval }
20
+ let diffScrollAnim = null; // animation for diff scroll
21
+
22
+ const LAYER_HEX = {
23
+ FE: "#00d4ff", BE: "#c084fc", DB: "#ff6b35",
24
+ API: "#00ffaa", CONFIG: "#ffd700", UTIL: "#ff69b4",
25
+ };
26
+
27
+ const LAYER_KEYS = ["FE", "BE", "DB", "API", "CONFIG", "UTIL"];
28
+
29
+ const LAYER_CENTERS = {
30
+ FE: { x: -800, y: 300, z: -300 },
31
+ BE: { x: 800, y: 300, z: 300 },
32
+ DB: { x: 0, y: -700, z: 600 },
33
+ API: { x: 700, y: -400, z: -600 },
34
+ CONFIG: { x: -700, y: 800, z: 500 },
35
+ UTIL: { x: 0, y: 50, z: 0 },
36
+ };
37
+
38
+ // ═══════════════════════════════════════════
39
+ // INIT
40
+ // ═══════════════════════════════════════════
41
+ document.addEventListener("DOMContentLoaded", async () => {
42
+ console.log("[SYKE] init v2.1");
43
+ await loadProjectInfo();
44
+ await loadGraph();
45
+ await loadHubFiles();
46
+ setupEventListeners();
47
+ setupKeyboardShortcuts();
48
+ setupContextMenu();
49
+ setupTabs();
50
+ setupProjectModal();
51
+ initSSE();
52
+ });
53
+
54
+ // ═══════════════════════════════════════════
55
+ // GRAPH LOADING
56
+ // ═══════════════════════════════════════════
57
+ async function loadGraph() {
58
+ const res = await fetch("/api/graph");
59
+ const raw = await res.json();
60
+ console.log("[SYKE]", raw.nodes.length, "nodes", raw.edges.length, "edges");
61
+
62
+ const nodes = raw.nodes.map(n => {
63
+ const layer = n.data.layer || "UTIL";
64
+ const c = LAYER_CENTERS[layer] || LAYER_CENTERS.UTIL;
65
+ return {
66
+ id: n.data.id, label: n.data.label, fullPath: n.data.fullPath,
67
+ riskLevel: n.data.riskLevel, dependentCount: n.data.dependentCount,
68
+ lineCount: n.data.lineCount || 0, importsCount: n.data.importsCount || 0,
69
+ depth: n.data.depth || 0, group: n.data.group,
70
+ layer, action: n.data.action || "X", env: n.data.env || "PROD",
71
+ x: c.x + (Math.random() - 0.5) * 200,
72
+ y: c.y + (Math.random() - 0.5) * 200,
73
+ z: c.z + (Math.random() - 0.5) * 200,
74
+ };
75
+ });
76
+ const links = raw.edges.map(e => ({ source: e.data.source, target: e.data.target }));
77
+ graphData = { nodes, links };
78
+
79
+ const layerCounts = {};
80
+ nodes.forEach(n => { layerCounts[n.layer] = (layerCounts[n.layer] || 0) + 1; });
81
+
82
+ document.getElementById("stat-files").textContent = nodes.length;
83
+ document.getElementById("stat-edges").textContent = links.length;
84
+ const highRisk = nodes.filter(n => n.riskLevel === "HIGH").length;
85
+ document.getElementById("stat-high").textContent = highRisk;
86
+
87
+ const container = document.getElementById("3d-graph");
88
+
89
+ Graph = ForceGraph3D()(container)
90
+ .width(window.innerWidth - 380)
91
+ .height(window.innerHeight - 100)
92
+ .graphData(graphData)
93
+ .backgroundColor("#050a18")
94
+ .showNavInfo(false)
95
+
96
+ .nodeColor(node => getNodeColor(node))
97
+ .nodeVal(node => {
98
+ if (!isNodeVisible(node)) return 0.001;
99
+ const base = Math.max(2, Math.sqrt(node.lineCount / 3));
100
+ const hb = heartbeatNodes.get(node.id);
101
+ if (hb) {
102
+ const elapsed = Date.now() - hb.startTime;
103
+ const period = hb.riskLevel === "CRITICAL" ? 400 : hb.riskLevel === "HIGH" ? 600 : 900;
104
+ const t = (elapsed % period) / period;
105
+ const spike = Math.max(0, 1 - Math.abs(t - 0.15) * 10, 1 - Math.abs(t - 0.35) * 12);
106
+ return base * (1 + spike * 0.6);
107
+ }
108
+ if (modifyingNodes.has(node.id)) {
109
+ const pulse = 0.5 + 0.5 * Math.sin(Date.now() / 200);
110
+ return base * (1 + pulse * 0.4);
111
+ }
112
+ return base;
113
+ })
114
+ .nodeOpacity(1.0)
115
+ .nodeResolution(16)
116
+ .nodeVisibility(node => isNodeVisible(node))
117
+ .nodeLabel(node => {
118
+ const c = LAYER_HEX[node.layer] || "#ccc";
119
+ return `<div style="background:rgba(5,10,24,0.95);border:1px solid ${c};padding:6px 12px;border-radius:3px;font-family:Consolas,monospace;font-size:12px;color:#fff;text-shadow:none">
120
+ <span style="color:${c};font-weight:700">[${node.layer}]</span> ${node.fullPath}<br>
121
+ <span style="color:${c}">${node.lineCount} lines</span> &middot; ${node.dependentCount} deps &middot; depth ${node.depth} &middot; ${node.importsCount} imports &middot; ${node.riskLevel}
122
+ </div>`;
123
+ })
124
+
125
+ .linkColor(link => getLinkColor(link))
126
+ .linkWidth(link => highlightLinks.has(link) ? 1.2 : 0.4)
127
+ .linkOpacity(0.8)
128
+ .linkVisibility(link => isLinkVisible(link))
129
+ .linkCurvature(link => 0.12 + (hc(getSrcId(link) + getTgtId(link)) % 20) * 0.01)
130
+ .linkCurveRotation(link => (hc(getTgtId(link) + getSrcId(link)) % 628) / 100)
131
+
132
+ .linkDirectionalParticles(link => {
133
+ if (highlightLinks.has(link)) return 10;
134
+ const isActive = modifyingNodes.size > 0 || heartbeatNodes.size > 0;
135
+ return isActive ? 1 : 4;
136
+ })
137
+ .linkDirectionalParticleWidth(link => {
138
+ if (highlightLinks.has(link)) return 2.0;
139
+ const isActive = modifyingNodes.size > 0 || heartbeatNodes.size > 0;
140
+ return isActive ? 0.3 : 0.8;
141
+ })
142
+ .linkDirectionalParticleSpeed(0.005)
143
+ .linkDirectionalParticleColor(link => {
144
+ if (highlightLinks.has(link)) return "#ff2d55";
145
+ const isActive = modifyingNodes.size > 0 || heartbeatNodes.size > 0;
146
+ if (isActive) return "rgba(100,120,150,0.15)";
147
+ return LAYER_HEX[srcLayer(link)] || "#ff69b4";
148
+ })
149
+
150
+ .linkDirectionalArrowLength(2)
151
+ .linkDirectionalArrowRelPos(1)
152
+ .linkDirectionalArrowColor(link => {
153
+ if (highlightLinks.has(link)) return "#ff2d55";
154
+ return rgba(LAYER_HEX[srcLayer(link)] || "#ff69b4", 0.4);
155
+ })
156
+
157
+ .onNodeClick(node => {
158
+ if (pathMode) { handlePathNodeClick(node); return; }
159
+ handleNodeClick(node);
160
+ })
161
+ .onNodeHover(node => handleNodeHover(node))
162
+ .onNodeRightClick((node, event) => showContextMenu(node, event))
163
+ .onBackgroundClick(() => { hideContextMenu(); handleBackgroundClick(); })
164
+ .onBackgroundRightClick(() => hideContextMenu())
165
+
166
+ .enableNodeDrag(true)
167
+ .onNodeDrag(node => { stopUser(); })
168
+ .onNodeDragEnd(node => {
169
+ node.fx = undefined;
170
+ node.fy = undefined;
171
+ node.fz = undefined;
172
+ })
173
+
174
+ .d3AlphaDecay(0.015)
175
+ .d3VelocityDecay(0.35)
176
+ .warmupTicks(100)
177
+ .cooldownTicks(500)
178
+ .enablePointerInteraction(true);
179
+
180
+ Graph.d3Force("cluster", clusterForce(0.25));
181
+ Graph.d3Force("charge").strength(-25);
182
+ Graph.d3Force("link")
183
+ .distance(l => srcLayer(l) === tgtLayer(l) ? 35 : 150)
184
+ .strength(l => srcLayer(l) === tgtLayer(l) ? 0.8 : 0.2);
185
+
186
+ Graph.cameraPosition({ x: 0, y: 0, z: 1600 });
187
+
188
+ setTimeout(() => {
189
+ try {
190
+ const scene = Graph.scene();
191
+ if (!scene) return;
192
+ scene.add(new THREE.AmbientLight(0xffffff, 8));
193
+ scene.add(new THREE.PointLight(0xffffff, 3, 5000));
194
+ scene.fog = new THREE.FogExp2(0x050a18, 0.00012);
195
+ console.log("[SYKE] Scene ready");
196
+ } catch(e) { console.warn(e); }
197
+ }, 500);
198
+
199
+ setTimeout(() => {
200
+ autoRotate = true;
201
+ document.getElementById("btn-auto-rotate").classList.add("active");
202
+ startAutoRotate();
203
+ }, 3500);
204
+
205
+ container.addEventListener("wheel", stopUser);
206
+ container.addEventListener("mousedown", stopUser);
207
+ container.addEventListener("touchstart", stopUser);
208
+
209
+ window.addEventListener("resize", () => {
210
+ if (Graph) Graph.width(window.innerWidth - 380).height(window.innerHeight - 100);
211
+ });
212
+
213
+ buildLegend(layerCounts);
214
+ createNodeLabels();
215
+ updateLabelsLoop();
216
+ }
217
+
218
+ // ═══════════════════════════════════════════
219
+ // VISIBILITY FILTERS
220
+ // ═══════════════════════════════════════════
221
+ function isNodeVisible(node) {
222
+ if (hiddenLayers.has(node.layer)) return false;
223
+ if (hiddenNodes.has(node.id)) return false;
224
+ return true;
225
+ }
226
+
227
+ function isLinkVisible(link) {
228
+ const src = getNodeById(getSrcId(link));
229
+ const tgt = getNodeById(getTgtId(link));
230
+ if (!src || !tgt) return false;
231
+ return isNodeVisible(src) && isNodeVisible(tgt);
232
+ }
233
+
234
+ function getNodeById(id) {
235
+ return graphData.nodes.find(n => n.id === id);
236
+ }
237
+
238
+ // ═══════════════════════════════════════════
239
+ // HTML NODE LABELS
240
+ // ═══════════════════════════════════════════
241
+ function createNodeLabels() {
242
+ const container = document.getElementById("node-labels");
243
+ container.innerHTML = "";
244
+ graphData.nodes.forEach(node => {
245
+ const el = document.createElement("div");
246
+ el.className = "node-lbl";
247
+ el.id = "lbl-" + node.id.replace(/[\/\\.]/g, "_");
248
+ const col = LAYER_HEX[node.layer] || "#00d4ff";
249
+ el.style.borderColor = col;
250
+ el.innerHTML = `<span style="color:${col}">${node.lineCount}L</span> ${node.dependentCount}D` +
251
+ `<br><span class="lbl-dim">dp${node.depth} im${node.importsCount}</span>`;
252
+ container.appendChild(el);
253
+ });
254
+ }
255
+
256
+ function updateLabelsLoop() {
257
+ if (!Graph) return;
258
+ const graphRect = document.getElementById("graph-panel").getBoundingClientRect();
259
+
260
+ graphData.nodes.forEach(node => {
261
+ const el = document.getElementById("lbl-" + node.id.replace(/[\/\\.]/g, "_"));
262
+ if (!el) return;
263
+
264
+ if (!isNodeVisible(node)) { el.style.display = "none"; return; }
265
+
266
+ const coords = Graph.graph2ScreenCoords(node.x || 0, node.y || 0, node.z || 0);
267
+ if (!coords) { el.style.display = "none"; return; }
268
+
269
+ const sx = coords.x;
270
+ const sy = coords.y;
271
+
272
+ if (sx < -50 || sx > graphRect.width + 50 || sy < -50 || sy > graphRect.height + 50) {
273
+ el.style.display = "none";
274
+ } else {
275
+ el.style.display = "";
276
+ el.style.left = sx + "px";
277
+ el.style.top = (sy - 18) + "px";
278
+
279
+ if (highlightNodes.size > 0 && !highlightNodes.has(node.id)) {
280
+ const isActive = modifyingNodes.size > 0 || heartbeatNodes.size > 0;
281
+ el.style.opacity = isActive ? "0.06" : "0.15";
282
+ } else {
283
+ el.style.opacity = "1";
284
+ }
285
+ }
286
+ });
287
+
288
+ requestAnimationFrame(updateLabelsLoop);
289
+ }
290
+
291
+ // ═══════════════════════════════════════════
292
+ // COLORS
293
+ // ═══════════════════════════════════════════
294
+ function getNodeColor(node) {
295
+ // Heartbeat pulse: connected nodes pulsing by risk level
296
+ const hb = heartbeatNodes.get(node.id);
297
+ if (hb) {
298
+ const elapsed = Date.now() - hb.startTime;
299
+ // Heartbeat: sharp spike then fade, like a real heartbeat
300
+ const period = hb.riskLevel === "CRITICAL" ? 400 : hb.riskLevel === "HIGH" ? 600 : 900;
301
+ const t = (elapsed % period) / period;
302
+ // Double-spike heartbeat waveform
303
+ const spike1 = Math.max(0, 1 - Math.abs(t - 0.15) * 10);
304
+ const spike2 = Math.max(0, 1 - Math.abs(t - 0.35) * 12);
305
+ const beat = Math.max(spike1, spike2);
306
+
307
+ const colors = {
308
+ CRITICAL: [255, 0, 40],
309
+ HIGH: [255, 45, 85],
310
+ MEDIUM: [255, 159, 10],
311
+ LOW: [48, 209, 88],
312
+ SAFE: [48, 209, 88],
313
+ };
314
+ const [cr, cg, cb] = colors[hb.riskLevel] || colors.MEDIUM;
315
+ const base = LAYER_HEX[node.layer] || "#ff69b4";
316
+ const br = parseInt(base.slice(1,3),16);
317
+ const bg = parseInt(base.slice(3,5),16);
318
+ const bb = parseInt(base.slice(5,7),16);
319
+ const r = Math.round(br + (cr - br) * beat);
320
+ const g = Math.round(bg + (cg - bg) * beat);
321
+ const b = Math.round(bb + (cb - bb) * beat);
322
+ return `rgb(${r},${g},${b})`;
323
+ }
324
+
325
+ // AI is modifying this node → bright pulsing white/orange
326
+ if (modifyingNodes.has(node.id)) {
327
+ const t = Date.now() / 200;
328
+ const pulse = 0.5 + 0.5 * Math.sin(t);
329
+ return `rgb(255,${Math.round(180 + pulse * 75)},${Math.round(50 + pulse * 50)})`;
330
+ }
331
+ if (node.id === selectedNodeId) return "#ffffff";
332
+ const base = LAYER_HEX[node.layer] || "#ff69b4";
333
+ if (highlightNodes.size > 0 && !highlightNodes.has(node.id)) {
334
+ // When actively modifying → dim harder so protagonists stand out
335
+ const isActive = modifyingNodes.size > 0 || heartbeatNodes.size > 0;
336
+ return dimHex(base, isActive ? 0.12 : 0.35);
337
+ }
338
+ return base;
339
+ }
340
+
341
+ // Start heartbeat on connected nodes with risk-based coloring
342
+ function startHeartbeat(nodeIds, riskLevel) {
343
+ const now = Date.now();
344
+ for (const id of nodeIds) {
345
+ heartbeatNodes.set(id, { riskLevel, startTime: now });
346
+ }
347
+ // Ensure continuous visual refresh for heartbeat
348
+ if (!window._heartbeatRAF) {
349
+ function heartbeatLoop() {
350
+ if (heartbeatNodes.size > 0 && Graph) {
351
+ Graph.nodeColor(Graph.nodeColor());
352
+ Graph.nodeVal(Graph.nodeVal());
353
+ }
354
+ window._heartbeatRAF = requestAnimationFrame(heartbeatLoop);
355
+ }
356
+ heartbeatLoop();
357
+ }
358
+ }
359
+
360
+ function stopHeartbeat(nodeIds) {
361
+ for (const id of nodeIds) {
362
+ heartbeatNodes.delete(id);
363
+ }
364
+ if (heartbeatNodes.size === 0 && window._heartbeatRAF) {
365
+ cancelAnimationFrame(window._heartbeatRAF);
366
+ window._heartbeatRAF = null;
367
+ // Restore normal brightness when all heartbeats done
368
+ refreshGraph();
369
+ }
370
+ }
371
+
372
+ function stopAllHeartbeats() {
373
+ heartbeatNodes.clear();
374
+ if (window._heartbeatRAF) {
375
+ cancelAnimationFrame(window._heartbeatRAF);
376
+ window._heartbeatRAF = null;
377
+ }
378
+ }
379
+
380
+ // Focus camera on a specific node (smooth transition)
381
+ function focusCameraOnNode(nodeId) {
382
+ const node = graphData?.nodes.find(n => n.id === nodeId);
383
+ if (!node || !Graph) return;
384
+ stopUser(); // stop auto-rotate
385
+ const nx = node.x || 0, ny = node.y || 0, nz = node.z || 0;
386
+ const d = Math.max(1, Math.hypot(nx, ny, nz));
387
+ Graph.cameraPosition(
388
+ { x: nx + 200 * nx / d, y: ny + 200 * ny / d, z: nz + 200 * nz / d },
389
+ { x: nx, y: ny, z: nz },
390
+ 1200
391
+ );
392
+ }
393
+
394
+ function dimHex(hex, factor) {
395
+ const r = Math.round(parseInt(hex.slice(1,3),16) * factor);
396
+ const g = Math.round(parseInt(hex.slice(3,5),16) * factor);
397
+ const b = Math.round(parseInt(hex.slice(5,7),16) * factor);
398
+ return `#${r.toString(16).padStart(2,"0")}${g.toString(16).padStart(2,"0")}${b.toString(16).padStart(2,"0")}`;
399
+ }
400
+
401
+ function getLinkColor(link) {
402
+ if (highlightLinks.has(link)) return "#ff2d55";
403
+ if (highlightNodes.size > 0 && !highlightLinks.has(link)) {
404
+ const isActive = modifyingNodes.size > 0 || heartbeatNodes.size > 0;
405
+ return isActive ? "rgba(60,70,90,0.03)" : "rgba(100,120,150,0.06)";
406
+ }
407
+ return rgba(LAYER_HEX[srcLayer(link)] || "#ff69b4", 0.25);
408
+ }
409
+
410
+ // ═══════════════════════════════════════════
411
+ // CLUSTER FORCE
412
+ // ═══════════════════════════════════════════
413
+ function clusterForce(str) {
414
+ const f = (alpha) => {
415
+ if (!graphData) return;
416
+ const k = alpha * str;
417
+ for (const n of graphData.nodes) {
418
+ const c = LAYER_CENTERS[n.layer] || LAYER_CENTERS.UTIL;
419
+ if (n.x != null) n.vx = (n.vx || 0) + (c.x - n.x) * k;
420
+ if (n.y != null) n.vy = (n.vy || 0) + (c.y - n.y) * k;
421
+ if (n.z != null) n.vz = (n.vz || 0) + (c.z - n.z) * k;
422
+ }
423
+ };
424
+ f.initialize = () => {};
425
+ return f;
426
+ }
427
+
428
+ // ═══════════════════════════════════════════
429
+ // HELPERS
430
+ // ═══════════════════════════════════════════
431
+ function getSrcId(l) { return (typeof l.source === "object" && l.source) ? l.source.id : l.source; }
432
+ function getTgtId(l) { return (typeof l.target === "object" && l.target) ? l.target.id : l.target; }
433
+ function srcLayer(l) { const n = graphData.nodes.find(x => x.id === getSrcId(l)); return n ? n.layer : "UTIL"; }
434
+ function tgtLayer(l) { const n = graphData.nodes.find(x => x.id === getTgtId(l)); return n ? n.layer : "UTIL"; }
435
+ function hc(s) { let h=0; for(let i=0;i<s.length;i++){h=((h<<5)-h)+s.charCodeAt(i);h|=0;} return Math.abs(h); }
436
+ function rgba(hex,a) { return `rgba(${parseInt(hex.slice(1,3),16)},${parseInt(hex.slice(3,5),16)},${parseInt(hex.slice(5,7),16)},${a})`; }
437
+
438
+ // ═══════════════════════════════════════════
439
+ // LEGEND (clickable layer filter)
440
+ // ═══════════════════════════════════════════
441
+ function buildLegend(counts) {
442
+ const el = document.getElementById("layer-legend");
443
+ if (!el) return;
444
+ let h = "";
445
+ Object.entries(LAYER_HEX).forEach(([layer, color]) => {
446
+ const c = counts[layer] || 0;
447
+ if (!c) return;
448
+ const filtered = hiddenLayers.has(layer) ? " filtered" : "";
449
+ h += `<div class="legend-item${filtered}" data-layer="${layer}">
450
+ <span class="legend-dot" style="background:${color};box-shadow:0 0 10px ${color}"></span>
451
+ <span class="legend-label" style="color:${color}">${layer}</span>
452
+ <span class="legend-count">${c}</span></div>`;
453
+ });
454
+ el.innerHTML = h;
455
+ el.querySelectorAll(".legend-item").forEach(item => {
456
+ item.addEventListener("click", (e) => {
457
+ if (e.shiftKey) {
458
+ // Shift+click: isolate this layer only
459
+ const layer = item.dataset.layer;
460
+ const allHidden = LAYER_KEYS.every(l => l === layer || hiddenLayers.has(l));
461
+ if (allHidden) {
462
+ // All others hidden → show all
463
+ hiddenLayers.clear();
464
+ } else {
465
+ hiddenLayers.clear();
466
+ LAYER_KEYS.forEach(l => { if (l !== layer) hiddenLayers.add(l); });
467
+ }
468
+ } else {
469
+ // Regular click: toggle this layer
470
+ const layer = item.dataset.layer;
471
+ if (hiddenLayers.has(layer)) {
472
+ hiddenLayers.delete(layer);
473
+ } else {
474
+ hiddenLayers.add(layer);
475
+ }
476
+ }
477
+ // Update legend visual
478
+ el.querySelectorAll(".legend-item").forEach(li => {
479
+ li.classList.toggle("filtered", hiddenLayers.has(li.dataset.layer));
480
+ });
481
+ refreshGraph();
482
+ });
483
+ });
484
+ }
485
+
486
+ // ═══════════════════════════════════════════
487
+ // AUTO-ROTATE
488
+ // ═══════════════════════════════════════════
489
+ let rotAngle = 0, rotRAF = null;
490
+ function startAutoRotate() {
491
+ if (!autoRotate || !Graph) return;
492
+ rotAngle += 0.0005;
493
+ Graph.cameraPosition({
494
+ x: 1600 * Math.sin(rotAngle),
495
+ y: 200 + Math.sin(rotAngle * 0.3) * 100,
496
+ z: 1600 * Math.cos(rotAngle),
497
+ });
498
+ rotRAF = requestAnimationFrame(startAutoRotate);
499
+ }
500
+ function stopAutoRotate() { if (rotRAF) { cancelAnimationFrame(rotRAF); rotRAF = null; } }
501
+ function stopUser() {
502
+ if (autoRotate) {
503
+ autoRotate = false; stopAutoRotate();
504
+ document.getElementById("btn-auto-rotate").classList.remove("active");
505
+ }
506
+ }
507
+
508
+ // ═══════════════════════════════════════════
509
+ // HOVER
510
+ // ═══════════════════════════════════════════
511
+ function handleNodeHover(node) {
512
+ const tt = document.getElementById("node-tooltip");
513
+ if (node) {
514
+ document.getElementById("3d-graph").style.cursor = pathMode ? "crosshair" : "pointer";
515
+ tt.classList.remove("hidden");
516
+ const c = LAYER_HEX[node.layer] || "#ccc";
517
+ tt.innerHTML = `<span style="color:${c};font-weight:700">[${node.layer}]</span> ${node.fullPath} <span style="color:${c}">${node.lineCount}L</span> ${node.dependentCount}D`;
518
+ } else {
519
+ document.getElementById("3d-graph").style.cursor = pathMode ? "crosshair" : "grab";
520
+ tt.classList.add("hidden");
521
+ }
522
+ }
523
+ document.addEventListener("mousemove", e => {
524
+ const t = document.getElementById("node-tooltip");
525
+ if (!t.classList.contains("hidden")) { t.style.left=(e.clientX+16)+"px"; t.style.top=(e.clientY-10)+"px"; }
526
+ });
527
+
528
+ // ═══════════════════════════════════════════
529
+ // CLICK HANDLING
530
+ // ═══════════════════════════════════════════
531
+ async function handleNodeClick(node) {
532
+ if (!node) return;
533
+ hideContextMenu();
534
+ selectedFile = node.id; selectedNodeId = node.id;
535
+ stopUser();
536
+ try {
537
+ const nx=node.x||0, ny=node.y||0, nz=node.z||0;
538
+ const d = Math.max(1, Math.hypot(nx,ny,nz));
539
+ Graph.cameraPosition(
540
+ { x: nx+150*nx/d, y: ny+150*ny/d, z: nz+150*nz/d },
541
+ { x: nx, y: ny, z: nz }, 1200
542
+ );
543
+ } catch(e) {}
544
+ refreshGraph();
545
+ await showImpact(node.id, node);
546
+ // Auto-load code preview
547
+ loadCodePreview(node.id);
548
+ // Auto-load simulation
549
+ loadSimulation(node.id);
550
+ // Start Star Wars code crawl
551
+ startCodeCrawl(node.id);
552
+ }
553
+
554
+ function handleBackgroundClick() {
555
+ selectedFile = null; selectedNodeId = null;
556
+ highlightNodes.clear(); highlightLinks.clear();
557
+ document.getElementById("file-info-content").innerHTML = '<p class="placeholder">Select a node to identify target</p>';
558
+ document.getElementById("impact-content").innerHTML = '<p class="placeholder">Select a node to trace impact chain</p>';
559
+ document.getElementById("ai-content").innerHTML = '<p class="placeholder">Select target, then request AI analysis</p>';
560
+ document.getElementById("code-content").innerHTML = '<p class="placeholder">Select a node to preview source code</p>';
561
+ document.getElementById("sim-content").innerHTML = '<p class="placeholder">Select a node, then switch to SIM tab</p>';
562
+ document.getElementById("btn-ai-analyze").disabled = true;
563
+ stopCodeCrawl();
564
+ refreshGraph();
565
+ }
566
+
567
+ // ═══════════════════════════════════════════
568
+ // IMPACT ANALYSIS
569
+ // ═══════════════════════════════════════════
570
+ async function showImpact(fileId, nd) {
571
+ const col = LAYER_HEX[nd.layer] || "#999";
572
+ document.getElementById("file-info-content").innerHTML = `
573
+ <div class="file-detail"><span class="label">PATH </span><span class="value">${nd.fullPath||fileId}</span></div>
574
+ <div class="file-detail"><span class="label">LAYER </span><span class="layer-badge" style="color:${col};border-color:${col}">${nd.layer}</span>
575
+ &nbsp;<span class="label">LINES </span><span class="value">${nd.lineCount}</span>
576
+ &nbsp;<span class="label">ACTION </span><span class="value">${nd.action}</span></div>
577
+ <div class="file-detail"><span class="label">RISK </span><span class="risk-badge ${nd.riskLevel}">${nd.riskLevel}</span>
578
+ &nbsp;<span class="label">DEPS </span><span class="value">${nd.dependentCount}</span>
579
+ &nbsp;<span class="label">DEPTH </span><span class="value">${nd.depth}</span>
580
+ &nbsp;<span class="label">IMPORTS </span><span class="value">${nd.importsCount}</span></div>`;
581
+ document.getElementById("btn-ai-analyze").disabled = false;
582
+ document.getElementById("ai-content").innerHTML = '<p class="placeholder">Click ANALYZE for AI intel</p>';
583
+ document.getElementById("impact-content").innerHTML = '<div class="loading"><div class="spinner"></div>TRACING...</div>';
584
+
585
+ try {
586
+ const res = await fetch("/api/impact/" + fileId);
587
+ const impact = await res.json();
588
+ if (!res.ok) { document.getElementById("impact-content").innerHTML = `<p class="placeholder">ERROR: ${impact.error}</p>`; return; }
589
+
590
+ highlightNodes.clear(); highlightLinks.clear(); highlightNodes.add(fileId);
591
+ if (impact.directDependents) impact.directDependents.forEach(d => highlightNodes.add(d));
592
+ if (impact.transitiveDependents) impact.transitiveDependents.forEach(t => highlightNodes.add(t));
593
+ graphData.links.forEach(l => {
594
+ if (highlightNodes.has(getSrcId(l)) && highlightNodes.has(getTgtId(l))) highlightLinks.add(l);
595
+ });
596
+ refreshGraph();
597
+
598
+ let html = `<div class="file-detail"><span class="label">IMPACTED </span><span class="value" style="color:#ff2d55">${impact.totalImpacted}</span></div>`;
599
+ if (impact.directDependents && impact.directDependents.length > 0) {
600
+ html += `<div style="margin-top:10px;font-size:10px;color:#ff2d55;font-weight:600;letter-spacing:2px">DIRECT (${impact.directDependents.length})</div>`;
601
+ for (const d of impact.directDependents) {
602
+ const dn = graphData.nodes.find(n => n.id === d);
603
+ const dc = dn ? LAYER_HEX[dn.layer]||"#999" : "#999";
604
+ html += `<div class="impact-item direct" data-file="${d}"><span style="color:${dc};margin-right:4px">[${dn?dn.layer:"?"}]</span>${d}</div>`;
605
+ }
606
+ }
607
+ if (impact.transitiveDependents && impact.transitiveDependents.length > 0) {
608
+ html += `<div style="margin-top:10px;font-size:10px;color:#ff9f0a;font-weight:600;letter-spacing:2px">TRANSITIVE (${impact.transitiveDependents.length})</div>`;
609
+ for (const t of impact.transitiveDependents) {
610
+ const tn = graphData.nodes.find(n => n.id === t);
611
+ const tc = tn ? LAYER_HEX[tn.layer]||"#999" : "#999";
612
+ html += `<div class="impact-item transitive" data-file="${t}"><span style="color:${tc};margin-right:4px">[${tn?tn.layer:"?"}]</span>${t}</div>`;
613
+ }
614
+ }
615
+ if (impact.totalImpacted === 0) html += '<p class="placeholder" style="margin-top:10px">SAFE TO MODIFY.</p>';
616
+ document.getElementById("impact-content").innerHTML = html;
617
+ document.querySelectorAll(".impact-item").forEach(el => {
618
+ el.addEventListener("click", () => {
619
+ const tn = graphData.nodes.find(n => n.id === el.dataset.file);
620
+ if (tn && Graph) {
621
+ const d = Math.max(1, Math.hypot(tn.x||1,tn.y||1,tn.z||1));
622
+ Graph.cameraPosition(
623
+ {x:(tn.x||0)+100*(tn.x||1)/d, y:(tn.y||0)+100*(tn.y||1)/d, z:(tn.z||0)+100*(tn.z||1)/d},
624
+ {x:tn.x||0, y:tn.y||0, z:tn.z||0}, 800
625
+ );
626
+ }
627
+ });
628
+ });
629
+ } catch(err) {
630
+ document.getElementById("impact-content").innerHTML = `<p class="placeholder">ERROR: ${err.message}</p>`;
631
+ }
632
+ }
633
+
634
+ function refreshGraph() {
635
+ if (!Graph) return;
636
+ Graph.nodeColor(Graph.nodeColor())
637
+ .nodeVal(Graph.nodeVal())
638
+ .nodeVisibility(Graph.nodeVisibility())
639
+ .linkColor(Graph.linkColor()).linkWidth(Graph.linkWidth())
640
+ .linkVisibility(Graph.linkVisibility())
641
+ .linkDirectionalParticles(Graph.linkDirectionalParticles())
642
+ .linkDirectionalParticleWidth(Graph.linkDirectionalParticleWidth())
643
+ .linkDirectionalParticleColor(Graph.linkDirectionalParticleColor())
644
+ .linkDirectionalArrowColor(Graph.linkDirectionalArrowColor());
645
+ }
646
+
647
+ // ═══════════════════════════════════════════
648
+ // AI ANALYSIS
649
+ // ═══════════════════════════════════════════
650
+ async function runAIAnalysis() {
651
+ if (!selectedFile) return;
652
+ const p = document.getElementById("ai-content");
653
+ p.innerHTML = '<div class="loading"><div class="spinner"></div>GEMINI AI PROCESSING...</div>';
654
+ try {
655
+ const r = await fetch("/api/ai-analyze", {
656
+ method: "POST", headers: {"Content-Type":"application/json"},
657
+ body: JSON.stringify({ file: selectedFile }),
658
+ });
659
+ const j = await r.json();
660
+ p.innerHTML = r.ok ? renderMD(j.analysis) : `<p class="placeholder">ERROR: ${j.error}</p>`;
661
+ } catch(e) { p.innerHTML = `<p class="placeholder">ERROR: ${e.message}</p>`; }
662
+ }
663
+
664
+ function renderMD(t) {
665
+ return t.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")
666
+ .replace(/^## (.+)$/gm,"<h2>$1</h2>").replace(/^### (.+)$/gm,"<h3>$1</h3>")
667
+ .replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>").replace(/`([^`]+)`/g,"<code>$1</code>")
668
+ .replace(/^- (.+)$/gm,"<li>$1</li>").replace(/(<li>.*<\/li>)/gs,"<ul>$1</ul>")
669
+ .replace(/\n\n/g,"<br><br>").replace(/\n/g,"<br>");
670
+ }
671
+
672
+ // ═══════════════════════════════════════════
673
+ // CODE PREVIEW
674
+ // ═══════════════════════════════════════════
675
+ async function loadCodePreview(fileId) {
676
+ const el = document.getElementById("code-content");
677
+ el.innerHTML = '<div class="loading"><div class="spinner"></div>LOADING...</div>';
678
+ try {
679
+ const res = await fetch("/api/file-content/" + fileId);
680
+ const data = await res.json();
681
+ if (!res.ok) { el.innerHTML = `<p class="placeholder">ERROR: ${data.error}</p>`; return; }
682
+
683
+ const lines = data.content.split("\n");
684
+ let html = '<div class="code-block">';
685
+ lines.forEach((line, i) => {
686
+ const escaped = line.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
687
+ html += `<span class="line-num">${i+1}</span>${escaped}\n`;
688
+ });
689
+ html += '</div>';
690
+ if (data.truncated) html += '<p class="placeholder" style="margin-top:8px">File truncated at 500 lines</p>';
691
+ el.innerHTML = html;
692
+ } catch(e) {
693
+ el.innerHTML = `<p class="placeholder">ERROR: ${e.message}</p>`;
694
+ }
695
+ }
696
+
697
+ // ═══════════════════════════════════════════
698
+ // DELETION SIMULATION
699
+ // ═══════════════════════════════════════════
700
+ async function loadSimulation(fileId) {
701
+ const el = document.getElementById("sim-content");
702
+ el.innerHTML = '<div class="loading"><div class="spinner"></div>SIMULATING...</div>';
703
+ try {
704
+ const res = await fetch("/api/simulate-delete/" + fileId);
705
+ const data = await res.json();
706
+ if (!res.ok) { el.innerHTML = `<p class="placeholder">ERROR: ${data.error}</p>`; return; }
707
+
708
+ let html = `<div class="file-detail"><span class="label">TARGET </span><span class="value">${data.deletedFile}</span></div>`;
709
+ html += `<div class="sim-severity ${data.severity}">${data.severity} IMPACT</div>`;
710
+
711
+ html += '<div class="sim-section"><h4>BROKEN IMPORTS (' + data.brokenCount + ')</h4>';
712
+ if (data.brokenImports.length === 0) {
713
+ html += '<p class="placeholder">No files import this directly</p>';
714
+ } else {
715
+ data.brokenImports.forEach(f => {
716
+ html += `<div class="sim-file" data-file="${f}">${f}</div>`;
717
+ });
718
+ }
719
+ html += '</div>';
720
+
721
+ html += '<div class="sim-section"><h4>CASCADE AFFECTED (' + data.cascadeCount + ')</h4>';
722
+ if (data.cascadeFiles.length === 0) {
723
+ html += '<p class="placeholder">No cascade impact</p>';
724
+ } else {
725
+ data.cascadeFiles.slice(0, 30).forEach(f => {
726
+ html += `<div class="sim-file" data-file="${f}">${f}</div>`;
727
+ });
728
+ if (data.cascadeFiles.length > 30) html += `<p class="placeholder">...and ${data.cascadeFiles.length - 30} more</p>`;
729
+ }
730
+ html += '</div>';
731
+
732
+ if (data.orphanedCount > 0) {
733
+ html += '<div class="sim-section"><h4>ORPHANED FILES (' + data.orphanedCount + ')</h4>';
734
+ data.orphanedFiles.forEach(f => {
735
+ html += `<div class="sim-file" data-file="${f}">${f}</div>`;
736
+ });
737
+ html += '</div>';
738
+ }
739
+
740
+ el.innerHTML = html;
741
+
742
+ // Click to navigate
743
+ el.querySelectorAll(".sim-file").forEach(sf => {
744
+ sf.addEventListener("click", () => {
745
+ const node = graphData.nodes.find(n => n.id === sf.dataset.file);
746
+ if (node) handleNodeClick(node);
747
+ });
748
+ });
749
+ } catch(e) {
750
+ el.innerHTML = `<p class="placeholder">ERROR: ${e.message}</p>`;
751
+ }
752
+ }
753
+
754
+ // ═══════════════════════════════════════════
755
+ // PATH MODE (shortest path between two nodes)
756
+ // ═══════════════════════════════════════════
757
+ function enterPathMode() {
758
+ pathMode = true;
759
+ pathFrom = null;
760
+ pathTo = null;
761
+ document.getElementById("btn-path-mode").classList.add("active");
762
+ document.getElementById("path-indicator").classList.remove("hidden");
763
+ document.getElementById("path-status").textContent = "SELECT FIRST NODE";
764
+ document.getElementById("3d-graph").style.cursor = "crosshair";
765
+ }
766
+
767
+ function exitPathMode() {
768
+ pathMode = false;
769
+ pathFrom = null;
770
+ pathTo = null;
771
+ document.getElementById("btn-path-mode").classList.remove("active");
772
+ document.getElementById("path-indicator").classList.add("hidden");
773
+ document.getElementById("3d-graph").style.cursor = "grab";
774
+ highlightNodes.clear();
775
+ highlightLinks.clear();
776
+ selectedNodeId = null;
777
+ refreshGraph();
778
+ }
779
+
780
+ async function handlePathNodeClick(node) {
781
+ if (!pathFrom) {
782
+ pathFrom = node.id;
783
+ selectedNodeId = node.id;
784
+ highlightNodes.clear();
785
+ highlightNodes.add(node.id);
786
+ refreshGraph();
787
+ document.getElementById("path-status").textContent = `FROM: ${node.label} → SELECT SECOND NODE`;
788
+ } else if (!pathTo) {
789
+ pathTo = node.id;
790
+ document.getElementById("path-status").textContent = `TRACING PATH...`;
791
+ await findShortestPath(pathFrom, pathTo);
792
+ }
793
+ }
794
+
795
+ async function findShortestPath(from, to) {
796
+ try {
797
+ const res = await fetch(`/api/shortest-path?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
798
+ const data = await res.json();
799
+
800
+ if (data.distance < 0) {
801
+ document.getElementById("path-status").textContent = "NO PATH FOUND";
802
+ setTimeout(exitPathMode, 2000);
803
+ return;
804
+ }
805
+
806
+ // Highlight path
807
+ highlightNodes.clear();
808
+ highlightLinks.clear();
809
+ data.path.forEach(p => highlightNodes.add(p));
810
+
811
+ // Highlight links along the path
812
+ for (let i = 0; i < data.path.length - 1; i++) {
813
+ graphData.links.forEach(l => {
814
+ const src = getSrcId(l), tgt = getTgtId(l);
815
+ if ((src === data.path[i] && tgt === data.path[i+1]) ||
816
+ (src === data.path[i+1] && tgt === data.path[i])) {
817
+ highlightLinks.add(l);
818
+ }
819
+ });
820
+ }
821
+
822
+ selectedNodeId = null;
823
+ refreshGraph();
824
+ document.getElementById("path-status").textContent = `PATH: ${data.distance} hops (${data.path.length} nodes)`;
825
+
826
+ // Show path details in impact panel
827
+ let html = `<div style="margin-bottom:10px;font-size:10px;color:#ffd700;font-weight:600;letter-spacing:2px">SHORTEST PATH (${data.distance} hops)</div>`;
828
+ data.path.forEach((p, i) => {
829
+ const n = graphData.nodes.find(x => x.id === p);
830
+ const c = n ? LAYER_HEX[n.layer] || "#999" : "#999";
831
+ const arrow = i < data.path.length - 1 ? ' <span style="color:#ffd700">→</span>' : '';
832
+ html += `<div class="impact-item" data-file="${p}" style="color:${c}">[${n?n.layer:"?"}] ${p}${arrow}</div>`;
833
+ });
834
+ document.getElementById("impact-content").innerHTML = html;
835
+
836
+ // Focus camera on midpoint
837
+ const midIdx = Math.floor(data.path.length / 2);
838
+ const midNode = graphData.nodes.find(n => n.id === data.path[midIdx]);
839
+ if (midNode) {
840
+ Graph.cameraPosition(
841
+ { x: (midNode.x||0)+300, y: (midNode.y||0)+200, z: (midNode.z||0)+300 },
842
+ { x: midNode.x||0, y: midNode.y||0, z: midNode.z||0 }, 1200
843
+ );
844
+ }
845
+
846
+ // Auto-exit path mode after 10s
847
+ setTimeout(() => {
848
+ if (pathMode) exitPathMode();
849
+ }, 15000);
850
+ } catch(e) {
851
+ document.getElementById("path-status").textContent = "ERROR: " + e.message;
852
+ setTimeout(exitPathMode, 2000);
853
+ }
854
+ }
855
+
856
+ // ═══════════════════════════════════════════
857
+ // CONTEXT MENU (right-click)
858
+ // ═══════════════════════════════════════════
859
+ function setupContextMenu() {
860
+ document.addEventListener("click", () => hideContextMenu());
861
+ }
862
+
863
+ function showContextMenu(node, event) {
864
+ if (!node) return;
865
+ event.preventDefault();
866
+ contextNode = node;
867
+ const menu = document.getElementById("context-menu");
868
+ menu.classList.remove("hidden");
869
+ menu.style.left = event.clientX + "px";
870
+ menu.style.top = event.clientY + "px";
871
+
872
+ // Ensure menu doesn't go off-screen
873
+ const rect = menu.getBoundingClientRect();
874
+ if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 10) + "px";
875
+ if (rect.bottom > window.innerHeight) menu.style.top = (window.innerHeight - rect.height - 10) + "px";
876
+ }
877
+
878
+ function hideContextMenu() {
879
+ document.getElementById("context-menu").classList.add("hidden");
880
+ }
881
+
882
+ function handleContextAction(action) {
883
+ if (!contextNode) return;
884
+ hideContextMenu();
885
+
886
+ switch(action) {
887
+ case "focus":
888
+ stopUser();
889
+ Graph.cameraPosition(
890
+ { x: (contextNode.x||0)+120, y: (contextNode.y||0)+80, z: (contextNode.z||0)+120 },
891
+ { x: contextNode.x||0, y: contextNode.y||0, z: contextNode.z||0 }, 800
892
+ );
893
+ break;
894
+ case "impact":
895
+ handleNodeClick(contextNode);
896
+ switchTab("info");
897
+ break;
898
+ case "ai":
899
+ selectedFile = contextNode.id;
900
+ selectedNodeId = contextNode.id;
901
+ switchTab("info");
902
+ runAIAnalysis();
903
+ break;
904
+ case "code":
905
+ selectedFile = contextNode.id;
906
+ selectedNodeId = contextNode.id;
907
+ loadCodePreview(contextNode.id);
908
+ switchTab("code");
909
+ break;
910
+ case "simulate":
911
+ selectedFile = contextNode.id;
912
+ selectedNodeId = contextNode.id;
913
+ loadSimulation(contextNode.id);
914
+ switchTab("simulate");
915
+ break;
916
+ case "path-from":
917
+ enterPathMode();
918
+ pathFrom = contextNode.id;
919
+ selectedNodeId = contextNode.id;
920
+ highlightNodes.clear();
921
+ highlightNodes.add(contextNode.id);
922
+ refreshGraph();
923
+ document.getElementById("path-status").textContent = `FROM: ${contextNode.label} → SELECT SECOND NODE`;
924
+ break;
925
+ case "path-to":
926
+ if (selectedFile && selectedFile !== contextNode.id) {
927
+ enterPathMode();
928
+ pathFrom = selectedFile;
929
+ pathTo = contextNode.id;
930
+ findShortestPath(pathFrom, pathTo);
931
+ }
932
+ break;
933
+ case "isolate":
934
+ // Show only nodes in same layer
935
+ hiddenLayers.clear();
936
+ LAYER_KEYS.forEach(l => { if (l !== contextNode.layer) hiddenLayers.add(l); });
937
+ document.querySelectorAll(".legend-item").forEach(li => {
938
+ li.classList.toggle("filtered", hiddenLayers.has(li.dataset.layer));
939
+ });
940
+ refreshGraph();
941
+ break;
942
+ case "hide":
943
+ hiddenNodes.add(contextNode.id);
944
+ if (selectedNodeId === contextNode.id) {
945
+ selectedFile = null;
946
+ selectedNodeId = null;
947
+ highlightNodes.clear();
948
+ highlightLinks.clear();
949
+ }
950
+ refreshGraph();
951
+ break;
952
+ }
953
+ }
954
+
955
+ // ═══════════════════════════════════════════
956
+ // TABS
957
+ // ═══════════════════════════════════════════
958
+ function setupTabs() {
959
+ document.querySelectorAll(".panel-tab").forEach(tab => {
960
+ tab.addEventListener("click", () => switchTab(tab.dataset.tab));
961
+ });
962
+ }
963
+
964
+ function switchTab(tabName) {
965
+ document.querySelectorAll(".panel-tab").forEach(t => t.classList.toggle("active", t.dataset.tab === tabName));
966
+ document.querySelectorAll(".tab-content").forEach(c => c.classList.toggle("active", c.id === "tab-" + tabName));
967
+ }
968
+
969
+ // ═══════════════════════════════════════════
970
+ // STATS CHARTS
971
+ // ═══════════════════════════════════════════
972
+ function showStats() {
973
+ document.getElementById("stats-overlay").classList.remove("hidden");
974
+ renderLayerChart();
975
+ renderRiskChart();
976
+ renderLinesChart();
977
+ renderDepsChart();
978
+ renderStatsSummary();
979
+ }
980
+
981
+ function renderLayerChart() {
982
+ const canvas = document.getElementById("chart-layers");
983
+ const ctx = canvas.getContext("2d");
984
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
985
+
986
+ const counts = {};
987
+ graphData.nodes.forEach(n => { counts[n.layer] = (counts[n.layer] || 0) + 1; });
988
+
989
+ const entries = Object.entries(counts).sort((a,b) => b[1] - a[1]);
990
+ const maxVal = Math.max(...entries.map(e => e[1]));
991
+ const barH = 22;
992
+ const gap = 6;
993
+ const labelW = 55;
994
+ const barMaxW = canvas.width - labelW - 40;
995
+
996
+ entries.forEach(([layer, count], i) => {
997
+ const y = i * (barH + gap) + 10;
998
+ const w = (count / maxVal) * barMaxW;
999
+ const color = LAYER_HEX[layer] || "#999";
1000
+
1001
+ ctx.fillStyle = color;
1002
+ ctx.globalAlpha = 0.3;
1003
+ ctx.fillRect(labelW, y, w, barH);
1004
+ ctx.globalAlpha = 1;
1005
+ ctx.fillStyle = color;
1006
+ ctx.fillRect(labelW, y, 3, barH);
1007
+
1008
+ ctx.fillStyle = color;
1009
+ ctx.font = "bold 10px Consolas";
1010
+ ctx.textAlign = "right";
1011
+ ctx.fillText(layer, labelW - 8, y + 15);
1012
+
1013
+ ctx.fillStyle = "#c8d6e5";
1014
+ ctx.textAlign = "left";
1015
+ ctx.fillText(count.toString(), labelW + w + 6, y + 15);
1016
+ });
1017
+ }
1018
+
1019
+ function renderRiskChart() {
1020
+ const canvas = document.getElementById("chart-risk");
1021
+ const ctx = canvas.getContext("2d");
1022
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1023
+
1024
+ const counts = { HIGH: 0, MEDIUM: 0, LOW: 0, NONE: 0 };
1025
+ graphData.nodes.forEach(n => { counts[n.riskLevel] = (counts[n.riskLevel] || 0) + 1; });
1026
+
1027
+ const colors = { HIGH: "#ff2d55", MEDIUM: "#ff9f0a", LOW: "#30d158", NONE: "#3a4f6f" };
1028
+ const total = graphData.nodes.length;
1029
+ const cx = 90, cy = 90, r = 70;
1030
+ let startAngle = -Math.PI / 2;
1031
+
1032
+ Object.entries(counts).forEach(([level, count]) => {
1033
+ if (count === 0) return;
1034
+ const sliceAngle = (count / total) * Math.PI * 2;
1035
+ ctx.beginPath();
1036
+ ctx.moveTo(cx, cy);
1037
+ ctx.arc(cx, cy, r, startAngle, startAngle + sliceAngle);
1038
+ ctx.closePath();
1039
+ ctx.fillStyle = colors[level];
1040
+ ctx.globalAlpha = 0.6;
1041
+ ctx.fill();
1042
+ ctx.globalAlpha = 1;
1043
+ ctx.strokeStyle = "#050a18";
1044
+ ctx.lineWidth = 2;
1045
+ ctx.stroke();
1046
+
1047
+ // Label
1048
+ const midAngle = startAngle + sliceAngle / 2;
1049
+ const lx = cx + (r + 20) * Math.cos(midAngle);
1050
+ const ly = cy + (r + 20) * Math.sin(midAngle);
1051
+ ctx.fillStyle = colors[level];
1052
+ ctx.font = "bold 9px Consolas";
1053
+ ctx.textAlign = "center";
1054
+ ctx.fillText(`${level}:${count}`, lx, ly);
1055
+
1056
+ startAngle += sliceAngle;
1057
+ });
1058
+ }
1059
+
1060
+ function renderLinesChart() {
1061
+ const canvas = document.getElementById("chart-lines");
1062
+ const ctx = canvas.getContext("2d");
1063
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1064
+
1065
+ const sorted = [...graphData.nodes].sort((a,b) => b.lineCount - a.lineCount).slice(0, 10);
1066
+ const maxVal = sorted[0]?.lineCount || 1;
1067
+ const barH = 14;
1068
+ const gap = 4;
1069
+ const barMaxW = canvas.width - 50;
1070
+
1071
+ sorted.forEach((n, i) => {
1072
+ const y = i * (barH + gap) + 4;
1073
+ const w = (n.lineCount / maxVal) * barMaxW;
1074
+ const color = LAYER_HEX[n.layer] || "#999";
1075
+
1076
+ ctx.fillStyle = color;
1077
+ ctx.globalAlpha = 0.25;
1078
+ ctx.fillRect(0, y, w, barH);
1079
+ ctx.globalAlpha = 1;
1080
+
1081
+ ctx.fillStyle = "#c8d6e5";
1082
+ ctx.font = "9px Consolas";
1083
+ ctx.textAlign = "left";
1084
+ const name = n.label.length > 25 ? n.label.slice(0, 22) + "..." : n.label;
1085
+ ctx.fillText(name, 4, y + 11);
1086
+
1087
+ ctx.fillStyle = color;
1088
+ ctx.textAlign = "left";
1089
+ ctx.fillText(n.lineCount.toString(), w + 4, y + 11);
1090
+ });
1091
+ }
1092
+
1093
+ function renderDepsChart() {
1094
+ const canvas = document.getElementById("chart-deps");
1095
+ const ctx = canvas.getContext("2d");
1096
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1097
+
1098
+ const sorted = [...graphData.nodes].sort((a,b) => b.dependentCount - a.dependentCount).slice(0, 10);
1099
+ const maxVal = sorted[0]?.dependentCount || 1;
1100
+ const barH = 14;
1101
+ const gap = 4;
1102
+ const barMaxW = canvas.width - 50;
1103
+
1104
+ sorted.forEach((n, i) => {
1105
+ const y = i * (barH + gap) + 4;
1106
+ const w = (n.dependentCount / maxVal) * barMaxW;
1107
+ const color = n.riskLevel === "HIGH" ? "#ff2d55" : n.riskLevel === "MEDIUM" ? "#ff9f0a" : "#30d158";
1108
+
1109
+ ctx.fillStyle = color;
1110
+ ctx.globalAlpha = 0.25;
1111
+ ctx.fillRect(0, y, w, barH);
1112
+ ctx.globalAlpha = 1;
1113
+
1114
+ ctx.fillStyle = "#c8d6e5";
1115
+ ctx.font = "9px Consolas";
1116
+ ctx.textAlign = "left";
1117
+ const name = n.label.length > 25 ? n.label.slice(0, 22) + "..." : n.label;
1118
+ ctx.fillText(name, 4, y + 11);
1119
+
1120
+ ctx.fillStyle = color;
1121
+ ctx.textAlign = "left";
1122
+ ctx.fillText(n.dependentCount.toString(), w + 4, y + 11);
1123
+ });
1124
+ }
1125
+
1126
+ function renderStatsSummary() {
1127
+ const totalLines = graphData.nodes.reduce((s, n) => s + n.lineCount, 0);
1128
+ const avgLines = Math.round(totalLines / graphData.nodes.length);
1129
+ const maxDeps = Math.max(...graphData.nodes.map(n => n.dependentCount));
1130
+ const avgDeps = (graphData.nodes.reduce((s, n) => s + n.dependentCount, 0) / graphData.nodes.length).toFixed(1);
1131
+ const maxDepth = Math.max(...graphData.nodes.map(n => n.depth));
1132
+ const isolated = graphData.nodes.filter(n => n.dependentCount === 0 && n.importsCount === 0).length;
1133
+
1134
+ document.getElementById("stats-summary").innerHTML = `
1135
+ <strong style="color:#00d4ff">TOTAL LINES:</strong> ${totalLines.toLocaleString()} &middot;
1136
+ <strong style="color:#00d4ff">AVG:</strong> ${avgLines} lines/file &middot;
1137
+ <strong style="color:#00d4ff">MAX DEPS:</strong> ${maxDeps} &middot;
1138
+ <strong style="color:#00d4ff">AVG DEPS:</strong> ${avgDeps} &middot;
1139
+ <strong style="color:#00d4ff">MAX DEPTH:</strong> ${maxDepth} &middot;
1140
+ <strong style="color:#00d4ff">ISOLATED:</strong> ${isolated} files
1141
+ `;
1142
+ }
1143
+
1144
+ // ═══════════════════════════════════════════
1145
+ // CYCLES DETECTION
1146
+ // ═══════════════════════════════════════════
1147
+ async function detectCycles() {
1148
+ document.getElementById("cycles-overlay").classList.remove("hidden");
1149
+ document.getElementById("cycles-content").innerHTML = '<div class="loading"><div class="spinner"></div>SCANNING...</div>';
1150
+
1151
+ try {
1152
+ const res = await fetch("/api/cycles");
1153
+ const data = await res.json();
1154
+
1155
+ if (data.count === 0) {
1156
+ document.getElementById("cycles-content").innerHTML = '<div class="no-cycles">NO CIRCULAR DEPENDENCIES DETECTED</div>';
1157
+ return;
1158
+ }
1159
+
1160
+ let html = `<p style="margin-bottom:12px;font-size:11px;color:#ff2d55">${data.count} cycle(s) detected:</p>`;
1161
+ data.cycles.forEach((cycle, i) => {
1162
+ html += `<div class="cycle-item"><h4>CYCLE ${i+1}</h4><div class="cycle-path">`;
1163
+ cycle.forEach((file, j) => {
1164
+ if (j > 0) html += '<span class="arrow">→</span>';
1165
+ html += `<span class="file" data-file="${file}">${file}</span>`;
1166
+ });
1167
+ html += '</div></div>';
1168
+ });
1169
+
1170
+ document.getElementById("cycles-content").innerHTML = html;
1171
+
1172
+ // Highlight all cycle nodes
1173
+ highlightNodes.clear();
1174
+ highlightLinks.clear();
1175
+ data.cycles.forEach(cycle => {
1176
+ cycle.forEach(f => highlightNodes.add(f));
1177
+ });
1178
+ refreshGraph();
1179
+
1180
+ // Click to navigate
1181
+ document.querySelectorAll("#cycles-content .file").forEach(el => {
1182
+ el.addEventListener("click", () => {
1183
+ const node = graphData.nodes.find(n => n.id === el.dataset.file);
1184
+ if (node) {
1185
+ stopUser();
1186
+ Graph.cameraPosition(
1187
+ { x: (node.x||0)+120, y: (node.y||0)+80, z: (node.z||0)+120 },
1188
+ { x: node.x||0, y: node.y||0, z: node.z||0 }, 800
1189
+ );
1190
+ }
1191
+ });
1192
+ });
1193
+ } catch(e) {
1194
+ document.getElementById("cycles-content").innerHTML = `<p class="placeholder">ERROR: ${e.message}</p>`;
1195
+ }
1196
+ }
1197
+
1198
+ // ═══════════════════════════════════════════
1199
+ // KEYBOARD SHORTCUTS
1200
+ // ═══════════════════════════════════════════
1201
+ function setupKeyboardShortcuts() {
1202
+ document.addEventListener("keydown", e => {
1203
+ // Don't trigger if typing in search
1204
+ if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
1205
+
1206
+ switch(e.key.toLowerCase()) {
1207
+ case "r":
1208
+ resetView();
1209
+ break;
1210
+ case "a":
1211
+ toggleAutoRotate();
1212
+ break;
1213
+ case "p":
1214
+ if (pathMode) exitPathMode();
1215
+ else enterPathMode();
1216
+ break;
1217
+ case "f":
1218
+ if (selectedNodeId) {
1219
+ const node = graphData.nodes.find(n => n.id === selectedNodeId);
1220
+ if (node) {
1221
+ stopUser();
1222
+ Graph.cameraPosition(
1223
+ { x: (node.x||0)+120, y: (node.y||0)+80, z: (node.z||0)+120 },
1224
+ { x: node.x||0, y: node.y||0, z: node.z||0 }, 800
1225
+ );
1226
+ }
1227
+ }
1228
+ break;
1229
+ case "c":
1230
+ if (selectedFile) { loadCodePreview(selectedFile); switchTab("code"); }
1231
+ break;
1232
+ case "s":
1233
+ if (selectedFile) { loadSimulation(selectedFile); switchTab("simulate"); }
1234
+ break;
1235
+ case "d":
1236
+ detectCycles();
1237
+ break;
1238
+ case "escape":
1239
+ if (pathMode) { exitPathMode(); return; }
1240
+ if (!document.getElementById("stats-overlay").classList.contains("hidden")) {
1241
+ document.getElementById("stats-overlay").classList.add("hidden"); return;
1242
+ }
1243
+ if (!document.getElementById("shortcuts-overlay").classList.contains("hidden")) {
1244
+ document.getElementById("shortcuts-overlay").classList.add("hidden"); return;
1245
+ }
1246
+ if (!document.getElementById("cycles-overlay").classList.contains("hidden")) {
1247
+ document.getElementById("cycles-overlay").classList.add("hidden");
1248
+ highlightNodes.clear(); highlightLinks.clear();
1249
+ refreshGraph();
1250
+ return;
1251
+ }
1252
+ handleBackgroundClick();
1253
+ break;
1254
+ case "/": case "?":
1255
+ e.preventDefault();
1256
+ document.getElementById("shortcuts-overlay").classList.toggle("hidden");
1257
+ break;
1258
+ case "1": toggleLayerByIndex(0); break;
1259
+ case "2": toggleLayerByIndex(1); break;
1260
+ case "3": toggleLayerByIndex(2); break;
1261
+ case "4": toggleLayerByIndex(3); break;
1262
+ case "5": toggleLayerByIndex(4); break;
1263
+ case "6": toggleLayerByIndex(5); break;
1264
+ }
1265
+ });
1266
+ }
1267
+
1268
+ function toggleLayerByIndex(idx) {
1269
+ const layer = LAYER_KEYS[idx];
1270
+ if (!layer) return;
1271
+ if (hiddenLayers.has(layer)) hiddenLayers.delete(layer);
1272
+ else hiddenLayers.add(layer);
1273
+ document.querySelectorAll(".legend-item").forEach(li => {
1274
+ li.classList.toggle("filtered", hiddenLayers.has(li.dataset.layer));
1275
+ });
1276
+ refreshGraph();
1277
+ }
1278
+
1279
+ function resetView() {
1280
+ selectedFile = null; selectedNodeId = null;
1281
+ highlightNodes.clear(); highlightLinks.clear();
1282
+ hiddenLayers.clear(); hiddenNodes.clear();
1283
+ document.querySelectorAll(".legend-item").forEach(li => li.classList.remove("filtered"));
1284
+ document.getElementById("file-info-content").innerHTML = '<p class="placeholder">Select a node to identify target</p>';
1285
+ document.getElementById("impact-content").innerHTML = '<p class="placeholder">Select a node to trace impact chain</p>';
1286
+ document.getElementById("ai-content").innerHTML = '<p class="placeholder">Select target, then request AI analysis</p>';
1287
+ document.getElementById("code-content").innerHTML = '<p class="placeholder">Select a node to preview source code</p>';
1288
+ document.getElementById("sim-content").innerHTML = '<p class="placeholder">Select a node, then switch to SIM tab</p>';
1289
+ document.getElementById("btn-ai-analyze").disabled = true;
1290
+ if (pathMode) exitPathMode();
1291
+ stopCodeCrawl();
1292
+ refreshGraph();
1293
+ Graph.cameraPosition({ x:0,y:0,z:1600 }, { x:0,y:0,z:0 }, 1000);
1294
+ }
1295
+
1296
+ function toggleAutoRotate() {
1297
+ autoRotate = !autoRotate;
1298
+ document.getElementById("btn-auto-rotate").classList.toggle("active", autoRotate);
1299
+ if (autoRotate) startAutoRotate(); else stopAutoRotate();
1300
+ }
1301
+
1302
+ // ═══════════════════════════════════════════
1303
+ // HUB FILES
1304
+ // ═══════════════════════════════════════════
1305
+ async function loadHubFiles() {
1306
+ try {
1307
+ const r = await fetch("/api/hub-files?top=15"); const d = await r.json();
1308
+ const c = document.getElementById("hub-content"); let h = "";
1309
+ d.hubs.forEach((hub,i) => {
1310
+ h += `<div class="hub-item" data-file="${hub.relativePath}">
1311
+ <span class="hub-rank">${i+1}.</span><span class="hub-name">${hub.relativePath}</span>
1312
+ <span class="hub-count">${hub.dependentCount}</span><span class="risk-badge ${hub.riskLevel}">${hub.riskLevel}</span></div>`;
1313
+ });
1314
+ c.innerHTML = h;
1315
+ c.querySelectorAll(".hub-item").forEach(el => {
1316
+ el.addEventListener("click", () => { const n = graphData.nodes.find(n => n.id === el.dataset.file); if (n) handleNodeClick(n); });
1317
+ });
1318
+ } catch(e) { console.error("Hub:", e); }
1319
+ }
1320
+
1321
+ // ═══════════════════════════════════════════
1322
+ // EVENT LISTENERS
1323
+ // ═══════════════════════════════════════════
1324
+ function setupEventListeners() {
1325
+ document.getElementById("btn-ai-analyze").addEventListener("click", runAIAnalysis);
1326
+
1327
+ document.getElementById("btn-reset").addEventListener("click", resetView);
1328
+
1329
+ document.getElementById("btn-auto-rotate").addEventListener("click", toggleAutoRotate);
1330
+
1331
+ document.getElementById("btn-path-mode").addEventListener("click", () => {
1332
+ if (pathMode) exitPathMode();
1333
+ else enterPathMode();
1334
+ });
1335
+
1336
+ document.getElementById("btn-cancel-path").addEventListener("click", exitPathMode);
1337
+
1338
+ document.getElementById("search-input").addEventListener("input", e => {
1339
+ const q = e.target.value.toLowerCase();
1340
+ highlightNodes.clear(); highlightLinks.clear(); selectedNodeId = null;
1341
+ if (q) {
1342
+ graphData.nodes.forEach(n => {
1343
+ if (n.fullPath.toLowerCase().includes(q) || n.layer.toLowerCase() === q || n.label.toLowerCase().includes(q)) highlightNodes.add(n.id);
1344
+ });
1345
+ if (highlightNodes.size > 0) {
1346
+ const f = graphData.nodes.find(n => n.id === highlightNodes.values().next().value);
1347
+ if (f && f.x != null) {
1348
+ stopUser();
1349
+ const d = Math.max(1, Math.hypot(f.x,f.y||0,f.z||0));
1350
+ Graph.cameraPosition({x:f.x+200*f.x/d,y:(f.y||0)+200*(f.y||1)/d,z:(f.z||0)+200*(f.z||1)/d},{x:f.x,y:f.y||0,z:f.z||0},600);
1351
+ }
1352
+ }
1353
+ }
1354
+ refreshGraph();
1355
+ });
1356
+
1357
+ document.getElementById("btn-toggle-hub").addEventListener("click", () => {
1358
+ document.getElementById("hub-drawer").classList.toggle("collapsed");
1359
+ });
1360
+
1361
+ // Stats
1362
+ document.getElementById("btn-stats").addEventListener("click", showStats);
1363
+ document.getElementById("btn-close-stats").addEventListener("click", () => {
1364
+ document.getElementById("stats-overlay").classList.add("hidden");
1365
+ });
1366
+
1367
+ // Shortcuts
1368
+ document.getElementById("btn-shortcuts").addEventListener("click", () => {
1369
+ document.getElementById("shortcuts-overlay").classList.toggle("hidden");
1370
+ });
1371
+ document.getElementById("btn-close-shortcuts").addEventListener("click", () => {
1372
+ document.getElementById("shortcuts-overlay").classList.add("hidden");
1373
+ });
1374
+
1375
+ // Cycles
1376
+ document.getElementById("btn-cycles").addEventListener("click", detectCycles);
1377
+ document.getElementById("btn-close-cycles").addEventListener("click", () => {
1378
+ document.getElementById("cycles-overlay").classList.add("hidden");
1379
+ highlightNodes.clear(); highlightLinks.clear();
1380
+ refreshGraph();
1381
+ });
1382
+
1383
+ // Context menu actions
1384
+ document.querySelectorAll(".ctx-item").forEach(item => {
1385
+ item.addEventListener("click", (e) => {
1386
+ e.stopPropagation();
1387
+ handleContextAction(item.dataset.action);
1388
+ });
1389
+ });
1390
+
1391
+ // Close overlays on background click
1392
+ ["stats-overlay", "shortcuts-overlay", "cycles-overlay"].forEach(id => {
1393
+ document.getElementById(id).addEventListener("click", (e) => {
1394
+ if (e.target.id === id) document.getElementById(id).classList.add("hidden");
1395
+ });
1396
+ });
1397
+
1398
+ // ── Resizable Right Panel ──
1399
+ setupResizeHandle();
1400
+ }
1401
+
1402
+ // ═══════════════════════════════════════════
1403
+ // DART SYNTAX HIGHLIGHTING (VS Code Dark+)
1404
+ // ═══════════════════════════════════════════
1405
+ function highlightDart(line) {
1406
+ // Escape HTML first
1407
+ let s = line.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1408
+
1409
+ // Full-line comment
1410
+ if (/^\s*\/\//.test(s)) {
1411
+ return `<span class="cmt">${s}</span>`;
1412
+ }
1413
+
1414
+ // Tokenize with regex replacements (order matters)
1415
+ // Strings (single and double quoted)
1416
+ s = s.replace(/(&#39;[^&#39;]*&#39;|&quot;[^&quot;]*&quot;|'[^']*'|"[^"]*")/g, '<span class="str">$1</span>');
1417
+
1418
+ // Annotations (@override, @required, etc)
1419
+ s = s.replace(/(@\w+)/g, '<span class="ann">$1</span>');
1420
+
1421
+ // Numbers
1422
+ s = s.replace(/\b(\d+\.?\d*)\b/g, '<span class="num">$1</span>');
1423
+
1424
+ // Dart keywords
1425
+ s = s.replace(/\b(import|export|library|part|show|hide|as|if|else|for|while|do|switch|case|break|continue|return|yield|async|await|try|catch|finally|throw|rethrow|new|const|final|var|late|required|static|abstract|class|extends|implements|with|mixin|enum|typedef|void|dynamic|super|this|is|in|true|false|null|factory|get|set|operator|external|covariant)\b/g, '<span class="kw">$1</span>');
1426
+
1427
+ // Dart types (capitalized words that look like types)
1428
+ s = s.replace(/\b(String|int|double|bool|num|List|Map|Set|Future|Stream|Widget|BuildContext|State|Key|Color|Text|Container|Column|Row|Scaffold|Navigator|Provider|Ref|Notifier|Override|Object|Function|Type|Iterable|Duration|DateTime|File|Directory|Uri)\b/g, '<span class="type">$1</span>');
1429
+
1430
+ // Function calls: word followed by (
1431
+ s = s.replace(/\b([a-z_]\w*)\s*(?=\()/g, '<span class="fn">$1</span>');
1432
+
1433
+ // Punctuation
1434
+ s = s.replace(/([{}()\[\];,])/g, '<span class="punc">$1</span>');
1435
+
1436
+ return s;
1437
+ }
1438
+
1439
+ // ═══════════════════════════════════════════
1440
+ // CODE CRAWL (auto-scroll + manual scrollbar)
1441
+ // ═══════════════════════════════════════════
1442
+ let crawlAutoScroll = true;
1443
+ let crawlUserTimer = null;
1444
+
1445
+ async function startCodeCrawl(fileId) {
1446
+ stopCodeCrawl();
1447
+
1448
+ const crawlEl = document.getElementById("code-crawl");
1449
+ const contentEl = document.getElementById("crawl-content");
1450
+ const viewport = document.getElementById("crawl-viewport");
1451
+ const headerName = document.getElementById("crawl-file-name");
1452
+ const headerStatus = document.getElementById("crawl-status");
1453
+
1454
+ headerName.textContent = fileId;
1455
+ headerStatus.textContent = "LOADING";
1456
+ headerStatus.className = "";
1457
+ contentEl.innerHTML = "";
1458
+ crawlEl.classList.add("active");
1459
+ crawlAutoScroll = true;
1460
+
1461
+ try {
1462
+ const res = await fetch("/api/connected-code", {
1463
+ method: "POST",
1464
+ headers: { "Content-Type": "application/json" },
1465
+ body: JSON.stringify({ file: fileId, maxFiles: 5, maxLinesPerFile: 40 }),
1466
+ });
1467
+ const data = await res.json();
1468
+ if (!res.ok) { headerStatus.textContent = "ERROR"; return; }
1469
+
1470
+ crawlData = data.files;
1471
+ headerStatus.textContent = `${data.files.length} FILES`;
1472
+
1473
+ // Build HTML with syntax highlighting
1474
+ let html = "";
1475
+ data.files.forEach((file) => {
1476
+ const col = LAYER_HEX[file.layer] || "#00d4ff";
1477
+ html += `<div class="crawl-separator">`;
1478
+ html += `<span class="sep-path" style="color:${col}">${file.path}</span>`;
1479
+ html += `<span class="sep-layer" style="color:${col}">[${file.layer}] ${file.lineCount}L</span>`;
1480
+ html += `</div>`;
1481
+ file.lines.forEach((line, li) => {
1482
+ html += `<div class="crawl-line" data-file="${file.path}" data-line="${li+1}">`;
1483
+ html += `<span class="cl-num">${li+1}</span>${highlightDart(line)}`;
1484
+ html += `</div>`;
1485
+ });
1486
+ });
1487
+ contentEl.innerHTML = html;
1488
+
1489
+ // User scrolls → pause auto, resume after 3s idle
1490
+ viewport.addEventListener("wheel", pauseAutoScroll, { passive: true });
1491
+ viewport.addEventListener("mousedown", pauseAutoScroll);
1492
+ viewport.addEventListener("touchstart", pauseAutoScroll);
1493
+
1494
+ // Start auto-scroll
1495
+ viewport.scrollTop = 0;
1496
+ crawlAnimationId = requestAnimationFrame(crawlLoop);
1497
+ } catch(e) {
1498
+ headerStatus.textContent = "ERROR";
1499
+ }
1500
+ }
1501
+
1502
+ function crawlLoop() {
1503
+ if (!crawlAutoScroll) { crawlAnimationId = requestAnimationFrame(crawlLoop); return; }
1504
+ const vp = document.getElementById("crawl-viewport");
1505
+ if (!vp) return;
1506
+ vp.scrollTop += 0.5;
1507
+ // Loop back to top when reached bottom
1508
+ if (vp.scrollTop >= vp.scrollHeight - vp.clientHeight) {
1509
+ vp.scrollTop = 0;
1510
+ }
1511
+ crawlAnimationId = requestAnimationFrame(crawlLoop);
1512
+ }
1513
+
1514
+ function pauseAutoScroll() {
1515
+ crawlAutoScroll = false;
1516
+ if (crawlUserTimer) clearTimeout(crawlUserTimer);
1517
+ crawlUserTimer = setTimeout(() => { crawlAutoScroll = true; }, 3000);
1518
+ }
1519
+
1520
+ function stopCodeCrawl() {
1521
+ if (crawlAnimationId) { cancelAnimationFrame(crawlAnimationId); crawlAnimationId = null; }
1522
+ if (crawlUserTimer) { clearTimeout(crawlUserTimer); crawlUserTimer = null; }
1523
+ const vp = document.getElementById("crawl-viewport");
1524
+ if (vp) { vp.removeEventListener("wheel", pauseAutoScroll); vp.removeEventListener("mousedown", pauseAutoScroll); vp.removeEventListener("touchstart", pauseAutoScroll); }
1525
+ document.getElementById("code-crawl").classList.remove("active");
1526
+ crawlData = null;
1527
+ }
1528
+
1529
+ // ═══════════════════════════════════════════
1530
+ // REAL-TIME DIFF VIEWER (SSE-driven)
1531
+ // ═══════════════════════════════════════════
1532
+ function showRealtimeDiff(data) {
1533
+ const crawlEl = document.getElementById("code-crawl");
1534
+ const contentEl = document.getElementById("crawl-content");
1535
+ const viewport = document.getElementById("crawl-viewport");
1536
+ const headerName = document.getElementById("crawl-file-name");
1537
+ const headerStatus = document.getElementById("crawl-status");
1538
+
1539
+ // Stop any existing crawl animation
1540
+ if (crawlAnimationId) { cancelAnimationFrame(crawlAnimationId); crawlAnimationId = null; }
1541
+
1542
+ // Set header
1543
+ const fileName = data.file.split("/").pop();
1544
+ headerName.textContent = data.file;
1545
+ headerStatus.textContent = data.type.toUpperCase();
1546
+ headerStatus.className = "modifying";
1547
+ crawlEl.classList.add("active");
1548
+
1549
+ // Build a set of changed line numbers for highlighting
1550
+ const changedLines = new Map(); // lineNum → {type, old, new}
1551
+ if (data.diff) {
1552
+ data.diff.forEach(d => {
1553
+ changedLines.set(d.line, d);
1554
+ });
1555
+ }
1556
+
1557
+ let html = "";
1558
+
1559
+ if (data.type === "deleted") {
1560
+ // File was deleted — show old content as all red
1561
+ html += `<div class="diff-banner diff-deleted">FILE DELETED</div>`;
1562
+ } else if (data.type === "added") {
1563
+ // New file — show all lines as green
1564
+ html += `<div class="diff-banner diff-added">NEW FILE</div>`;
1565
+ if (data.newContent) {
1566
+ data.newContent.forEach((line, i) => {
1567
+ const lineNum = i + 1;
1568
+ html += `<div class="crawl-line added" data-file="${data.file}" data-line="${lineNum}">`;
1569
+ html += `<span class="cl-num">${lineNum}</span>`;
1570
+ html += `<span class="diff-marker">+</span>`;
1571
+ html += highlightDart(line);
1572
+ html += `</div>`;
1573
+ });
1574
+ }
1575
+ } else {
1576
+ // Modified — show full file with diff highlights
1577
+ html += `<div class="diff-banner diff-modified">${data.diffCount} LINES CHANGED</div>`;
1578
+
1579
+ if (data.newContent) {
1580
+ data.newContent.forEach((line, i) => {
1581
+ const lineNum = i + 1;
1582
+ const change = changedLines.get(lineNum);
1583
+ let cls = "";
1584
+ let marker = " ";
1585
+
1586
+ if (change) {
1587
+ if (change.type === "added") {
1588
+ cls = "added";
1589
+ marker = "+";
1590
+ } else if (change.type === "removed") {
1591
+ cls = "removed";
1592
+ marker = "-";
1593
+ } else if (change.type === "changed") {
1594
+ cls = "changed";
1595
+ marker = "~";
1596
+ }
1597
+ }
1598
+
1599
+ // If this line was changed, also show the old line above it (strikethrough red)
1600
+ if (change && change.type === "changed" && change.old !== undefined) {
1601
+ html += `<div class="crawl-line removed" data-file="${data.file}" data-line="${lineNum}">`;
1602
+ html += `<span class="cl-num">${lineNum}</span>`;
1603
+ html += `<span class="diff-marker">-</span>`;
1604
+ html += `<span class="old-code">${highlightDart(change.old)}</span>`;
1605
+ html += `</div>`;
1606
+ }
1607
+
1608
+ html += `<div class="crawl-line ${cls}" data-file="${data.file}" data-line="${lineNum}">`;
1609
+ html += `<span class="cl-num">${lineNum}</span>`;
1610
+ if (marker !== " ") html += `<span class="diff-marker">${marker}</span>`;
1611
+ html += highlightDart(line);
1612
+ html += `</div>`;
1613
+ });
1614
+ }
1615
+
1616
+ // Show removed lines that are beyond the new content length
1617
+ data.diff?.forEach(d => {
1618
+ if (d.type === "removed" && d.line > (data.newContent?.length || 0)) {
1619
+ html += `<div class="crawl-line removed" data-file="${data.file}" data-line="${d.line}">`;
1620
+ html += `<span class="cl-num">${d.line}</span>`;
1621
+ html += `<span class="diff-marker">-</span>`;
1622
+ html += `<span class="old-code">${highlightDart(d.old || "")}</span>`;
1623
+ html += `</div>`;
1624
+ }
1625
+ });
1626
+ }
1627
+
1628
+ contentEl.innerHTML = html;
1629
+
1630
+ // ── Animated diff scroll: sweep through file, pause on changes ──
1631
+ if (diffScrollAnim) cancelAnimationFrame(diffScrollAnim);
1632
+ viewport.scrollTop = 0;
1633
+
1634
+ const changedEls = contentEl.querySelectorAll(".crawl-line.added, .crawl-line.changed, .crawl-line.removed");
1635
+ if (changedEls.length > 0) {
1636
+ let currentIdx = 0;
1637
+ let pauseUntil = 0;
1638
+ let userPaused = false;
1639
+
1640
+ function diffScrollLoop() {
1641
+ if (userPaused) { diffScrollAnim = requestAnimationFrame(diffScrollLoop); return; }
1642
+ const now = Date.now();
1643
+ if (now < pauseUntil) { diffScrollAnim = requestAnimationFrame(diffScrollLoop); return; }
1644
+
1645
+ if (currentIdx < changedEls.length) {
1646
+ const el = changedEls[currentIdx];
1647
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
1648
+ // Flash effect on the line
1649
+ el.classList.add("diff-flash-active");
1650
+ setTimeout(() => el.classList.remove("diff-flash-active"), 800);
1651
+ currentIdx++;
1652
+ pauseUntil = now + 1500; // pause 1.5s on each change
1653
+ } else {
1654
+ // Loop: go back to first change after a longer pause
1655
+ currentIdx = 0;
1656
+ pauseUntil = now + 3000;
1657
+ }
1658
+ diffScrollAnim = requestAnimationFrame(diffScrollLoop);
1659
+ }
1660
+
1661
+ // Start after brief initial delay
1662
+ setTimeout(() => { diffScrollAnim = requestAnimationFrame(diffScrollLoop); }, 500);
1663
+
1664
+ // User scroll pauses the auto-animation
1665
+ const pauseHandler = () => {
1666
+ userPaused = true;
1667
+ if (crawlUserTimer) clearTimeout(crawlUserTimer);
1668
+ crawlUserTimer = setTimeout(() => { userPaused = false; }, 4000);
1669
+ };
1670
+ viewport.addEventListener("wheel", pauseHandler, { passive: true });
1671
+ viewport.addEventListener("mousedown", pauseHandler);
1672
+ } else {
1673
+ viewport.scrollTop = 0;
1674
+ }
1675
+ }
1676
+
1677
+ function updateDiffWithAnalysis(analysis) {
1678
+ const headerStatus = document.getElementById("crawl-status");
1679
+ if (!headerStatus) return;
1680
+
1681
+ const riskColors = {
1682
+ CRITICAL: "#ff0040",
1683
+ HIGH: "#ff2d55",
1684
+ MEDIUM: "#ff9f0a",
1685
+ LOW: "#30d158",
1686
+ SAFE: "#30d158",
1687
+ };
1688
+ const color = riskColors[analysis.riskLevel] || "#c8d6e5";
1689
+ headerStatus.textContent = `${analysis.riskLevel} — ${analysis.summary || "분석 완료"}`;
1690
+ headerStatus.style.color = color;
1691
+ headerStatus.className = "";
1692
+
1693
+ // Add analysis summary at top of diff
1694
+ const contentEl = document.getElementById("crawl-content");
1695
+ if (contentEl && analysis.summary) {
1696
+ const banner = document.createElement("div");
1697
+ banner.className = `diff-banner diff-analysis`;
1698
+ banner.style.borderColor = color;
1699
+ banner.style.color = color;
1700
+ let text = `AI: ${analysis.summary}`;
1701
+ if (analysis.suggestion) text += ` | ${analysis.suggestion}`;
1702
+ banner.textContent = text;
1703
+
1704
+ // Insert after the first banner
1705
+ const firstBanner = contentEl.querySelector(".diff-banner");
1706
+ if (firstBanner && firstBanner.nextSibling) {
1707
+ contentEl.insertBefore(banner, firstBanner.nextSibling);
1708
+ } else {
1709
+ contentEl.prepend(banner);
1710
+ }
1711
+ }
1712
+ }
1713
+
1714
+ // ═══════════════════════════════════════════
1715
+ // ═══════════════════════════════════════════
1716
+ // AUDIT RESULT (shown after analysis completes)
1717
+ // ═══════════════════════════════════════════
1718
+ function showAuditResult(analysis) {
1719
+ const crawlEl = document.getElementById("code-crawl");
1720
+ const contentEl = document.getElementById("crawl-content");
1721
+ const headerName = document.getElementById("crawl-file-name");
1722
+ const headerStatus = document.getElementById("crawl-status");
1723
+
1724
+ // Stop any diff scroll animation
1725
+ if (diffScrollAnim) { cancelAnimationFrame(diffScrollAnim); diffScrollAnim = null; }
1726
+
1727
+ const isSafe = analysis.riskLevel === "SAFE" || analysis.riskLevel === "LOW";
1728
+
1729
+ if (isSafe) {
1730
+ // ── ALL CLEAR: clean audit passed ──
1731
+ headerName.textContent = "AUDIT COMPLETE";
1732
+ headerStatus.textContent = "ALL CLEAR";
1733
+ headerStatus.style.color = "#30d158";
1734
+ headerStatus.className = "";
1735
+
1736
+ contentEl.innerHTML = `
1737
+ <div class="audit-result audit-clear">
1738
+ <div class="audit-icon">✓</div>
1739
+ <div class="audit-title">안전하게 수정되었습니다</div>
1740
+ <div class="audit-detail">파일 간, DB 간, 구조 간 아무런 문제 없습니다.</div>
1741
+ <div class="audit-file">${analysis.file}</div>
1742
+ <div class="audit-meta">${analysis.affectedNodes?.length || 0} files checked · ${analysis.analysisMs}ms</div>
1743
+ </div>`;
1744
+
1745
+ // Fade out after 8s
1746
+ setTimeout(() => {
1747
+ crawlEl.classList.remove("active");
1748
+ contentEl.innerHTML = "";
1749
+ headerName.textContent = "";
1750
+ headerStatus.textContent = "";
1751
+ headerStatus.style.color = "";
1752
+ }, 8000);
1753
+ } else {
1754
+ // ── WARNING: issues found — keep showing ──
1755
+ headerName.textContent = analysis.file;
1756
+ const riskColors = { CRITICAL: "#ff0040", HIGH: "#ff2d55", MEDIUM: "#ff9f0a" };
1757
+ headerStatus.textContent = `${analysis.riskLevel} — 확인 필요`;
1758
+ headerStatus.style.color = riskColors[analysis.riskLevel] || "#ff9f0a";
1759
+ headerStatus.className = "";
1760
+
1761
+ // Keep existing diff content but prepend a warning summary
1762
+ const banner = document.createElement("div");
1763
+ banner.className = "audit-result audit-warning";
1764
+ banner.innerHTML = `
1765
+ <div class="audit-icon">!</div>
1766
+ <div class="audit-title">${analysis.riskLevel} — Attention Required</div>
1767
+ <div class="audit-detail">${analysis.summary}</div>
1768
+ ${analysis.suggestion ? `<div class="audit-suggestion">${analysis.suggestion}</div>` : ""}
1769
+ <div class="audit-wait-notice">Security warnings are expected during active modifications. Please wait until all changes are complete before taking action.</div>
1770
+ `;
1771
+ contentEl.prepend(banner);
1772
+ }
1773
+ }
1774
+
1775
+ // REAL-TIME AI CODE MODIFICATION API
1776
+ // ═══════════════════════════════════════════
1777
+
1778
+ window.syke = {
1779
+ // Mark a node as "being modified" — pulses orange
1780
+ startModifying(fileId) {
1781
+ modifyingNodes.add(fileId);
1782
+ const statusEl = document.getElementById("crawl-status");
1783
+ statusEl.textContent = "AI MODIFYING";
1784
+ statusEl.className = "modifying";
1785
+ // Continuous color refresh for pulsing effect
1786
+ if (!this._pulseInterval) {
1787
+ this._pulseInterval = setInterval(() => {
1788
+ if (modifyingNodes.size > 0 && Graph) {
1789
+ Graph.nodeColor(Graph.nodeColor());
1790
+ }
1791
+ }, 100);
1792
+ }
1793
+ // Dim non-protagonist nodes/particles immediately
1794
+ refreshGraph();
1795
+ },
1796
+
1797
+ // Mark a node as done modifying
1798
+ stopModifying(fileId) {
1799
+ modifyingNodes.delete(fileId);
1800
+ if (modifyingNodes.size === 0) {
1801
+ const statusEl = document.getElementById("crawl-status");
1802
+ statusEl.textContent = "IDLE";
1803
+ statusEl.className = "";
1804
+ if (this._pulseInterval) {
1805
+ clearInterval(this._pulseInterval);
1806
+ this._pulseInterval = null;
1807
+ }
1808
+ // Restore all nodes/particles to normal
1809
+ refreshGraph();
1810
+ }
1811
+ },
1812
+
1813
+ // Push a code change into the crawl
1814
+ // type: "modified" | "added" | "deleted" | "error-line"
1815
+ pushCodeChange({ file, line, type, content }) {
1816
+ const contentEl = document.getElementById("crawl-content");
1817
+ if (!contentEl) return;
1818
+
1819
+ // Find existing line and highlight it
1820
+ const existing = contentEl.querySelector(
1821
+ `.crawl-line[data-file="${file}"][data-line="${line}"]`
1822
+ );
1823
+ if (existing) {
1824
+ existing.className = `crawl-line ${type}`;
1825
+ if (content !== undefined) {
1826
+ const escaped = content.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1827
+ existing.innerHTML = `<span class="cl-num">${line}</span>${escaped}`;
1828
+ }
1829
+ return;
1830
+ }
1831
+
1832
+ // If line not found in current crawl, inject it at the top
1833
+ const col = LAYER_HEX[this._getLayer(file)] || "#00d4ff";
1834
+ const escaped = (content || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1835
+ const div = document.createElement("div");
1836
+ div.className = `crawl-line ${type}`;
1837
+ div.dataset.file = file;
1838
+ div.dataset.line = line;
1839
+ div.innerHTML = `<span class="cl-num">${line}</span><span style="color:${col};opacity:0.4;margin-right:4px">[${file.split("/").pop()}]</span>${escaped}`;
1840
+
1841
+ // Insert at the beginning of crawl
1842
+ if (contentEl.firstChild) {
1843
+ contentEl.insertBefore(div, contentEl.firstChild);
1844
+ } else {
1845
+ contentEl.appendChild(div);
1846
+ }
1847
+
1848
+ // Update total height
1849
+ crawlTotalHeight = contentEl.scrollHeight;
1850
+ },
1851
+
1852
+ // Push an error notification
1853
+ pushError({ file, line, message }) {
1854
+ this.pushCodeChange({
1855
+ file,
1856
+ line: line || 0,
1857
+ type: "error-line",
1858
+ content: `// ERROR: ${message}`,
1859
+ });
1860
+
1861
+ // Also flash the node
1862
+ const node = graphData?.nodes.find(n => n.id === file);
1863
+ if (node) {
1864
+ modifyingNodes.add(file);
1865
+ setTimeout(() => {
1866
+ modifyingNodes.delete(file);
1867
+ if (Graph) Graph.nodeColor(Graph.nodeColor());
1868
+ }, 3000);
1869
+ }
1870
+ },
1871
+
1872
+ // Helper
1873
+ _getLayer(fileId) {
1874
+ const node = graphData?.nodes.find(n => n.id === fileId);
1875
+ return node ? node.layer : "UTIL";
1876
+ },
1877
+
1878
+ _pulseInterval: null,
1879
+ };
1880
+
1881
+ // ═══════════════════════════════════════════
1882
+ // SSE: REAL-TIME FILE MONITORING
1883
+ // ═══════════════════════════════════════════
1884
+ let sseSource = null;
1885
+ let sseReconnectTimer = null;
1886
+ const realtimeLog = []; // recent events for panel
1887
+
1888
+ function initSSE() {
1889
+ if (sseSource) { sseSource.close(); sseSource = null; }
1890
+
1891
+ sseSource = new EventSource("/api/events");
1892
+
1893
+ sseSource.addEventListener("connected", (e) => {
1894
+ const data = JSON.parse(e.data);
1895
+ console.log("[SYKE:SSE] Connected, cache:", data.cacheSize, "files");
1896
+ updateSSEStatus("LIVE", "connected");
1897
+ });
1898
+
1899
+ sseSource.addEventListener("file-change", (e) => {
1900
+ const data = JSON.parse(e.data);
1901
+ console.log("[SYKE:SSE] File change:", data.file, data.type, data.diffCount, "diffs");
1902
+
1903
+ // ── 1. Auto-select the modified node in 3D graph ──
1904
+ selectedFile = data.file;
1905
+ selectedNodeId = data.file;
1906
+ highlightNodes.clear();
1907
+ highlightNodes.add(data.file);
1908
+ if (data.connectedNodes) {
1909
+ data.connectedNodes.forEach(id => highlightNodes.add(id));
1910
+ }
1911
+
1912
+ // ── 2. Focus camera on the modified node ──
1913
+ focusCameraOnNode(data.file);
1914
+
1915
+ // ── 3. Pulse the modified node (bright orange) ──
1916
+ window.syke.startModifying(data.file);
1917
+
1918
+ // ── 4. Start heartbeat on connected nodes (risk TBD, will upgrade after analysis) ──
1919
+ if (data.connectedNodes && data.connectedNodes.length > 0) {
1920
+ startHeartbeat(data.connectedNodes, "MEDIUM"); // temporary risk, upgraded after analysis
1921
+ }
1922
+
1923
+ // ── 5. Show real-time diff with animated scroll ──
1924
+ showRealtimeDiff(data);
1925
+
1926
+ // ── 6. Highlight connected links ──
1927
+ highlightLinks.clear();
1928
+ graphData.links.forEach(l => {
1929
+ if (highlightNodes.has(getSrcId(l)) && highlightNodes.has(getTgtId(l))) highlightLinks.add(l);
1930
+ });
1931
+ refreshGraph();
1932
+
1933
+ addRealtimeEvent({
1934
+ type: "change",
1935
+ file: data.file,
1936
+ changeType: data.type,
1937
+ diffCount: data.diffCount,
1938
+ timestamp: data.timestamp,
1939
+ });
1940
+
1941
+ updateSSEStatus("CHANGE DETECTED", "warning");
1942
+ });
1943
+
1944
+ sseSource.addEventListener("analysis-start", (e) => {
1945
+ const data = JSON.parse(e.data);
1946
+ console.log("[SYKE:SSE] AI analyzing:", data.file);
1947
+ updateSSEStatus("AI ANALYZING...", "analyzing");
1948
+
1949
+ addRealtimeEvent({
1950
+ type: "analyzing",
1951
+ file: data.file,
1952
+ timestamp: Date.now(),
1953
+ });
1954
+ });
1955
+
1956
+ sseSource.addEventListener("analysis-result", (e) => {
1957
+ const analysis = JSON.parse(e.data);
1958
+ console.log("[SYKE:SSE] Analysis result:", analysis.file, analysis.riskLevel);
1959
+
1960
+ // ── Stop modifying pulse on the changed file ──
1961
+ setTimeout(() => window.syke.stopModifying(analysis.file), 2000);
1962
+
1963
+ // ── Upgrade heartbeat to real risk level from Gemini ──
1964
+ // Stop the temporary MEDIUM heartbeat and restart with actual risk
1965
+ const connectedIds = analysis.affectedNodes || [];
1966
+ stopHeartbeat(connectedIds);
1967
+
1968
+ if (connectedIds.length > 0 && analysis.riskLevel !== "SAFE") {
1969
+ startHeartbeat(connectedIds, analysis.riskLevel);
1970
+
1971
+ // Duration based on severity
1972
+ const duration = analysis.riskLevel === "CRITICAL" ? 15000
1973
+ : analysis.riskLevel === "HIGH" ? 10000
1974
+ : analysis.riskLevel === "MEDIUM" ? 6000
1975
+ : 3000;
1976
+
1977
+ setTimeout(() => stopHeartbeat(connectedIds), duration);
1978
+ }
1979
+
1980
+ // Show warnings in code crawl
1981
+ if (analysis.warnings && analysis.warnings.length > 0) {
1982
+ analysis.warnings.forEach(w => {
1983
+ window.syke.pushError({ file: analysis.file, line: 0, message: w });
1984
+ });
1985
+ }
1986
+
1987
+ // Update diff view with analysis risk badge
1988
+ updateDiffWithAnalysis(analysis);
1989
+
1990
+ addRealtimeEvent({
1991
+ type: "result",
1992
+ file: analysis.file,
1993
+ riskLevel: analysis.riskLevel,
1994
+ summary: analysis.summary,
1995
+ brokenImports: analysis.brokenImports,
1996
+ sideEffects: analysis.sideEffects,
1997
+ warnings: analysis.warnings,
1998
+ suggestion: analysis.suggestion,
1999
+ affectedCount: analysis.affectedNodes?.length || 0,
2000
+ analysisMs: analysis.analysisMs,
2001
+ timestamp: analysis.timestamp,
2002
+ });
2003
+
2004
+ // Update status with risk level
2005
+ const statusMap = {
2006
+ CRITICAL: ["CRITICAL RISK", "critical"],
2007
+ HIGH: ["HIGH RISK", "danger"],
2008
+ MEDIUM: ["MEDIUM RISK", "warning"],
2009
+ LOW: ["LOW RISK", "safe"],
2010
+ SAFE: ["SAFE", "connected"],
2011
+ };
2012
+ const [text, cls] = statusMap[analysis.riskLevel] || ["ANALYZED", "connected"];
2013
+ updateSSEStatus(text, cls);
2014
+
2015
+ // ── Auto-clear code crawl + show ALL CLEAR after analysis ──
2016
+ const isSafe = analysis.riskLevel === "SAFE" || analysis.riskLevel === "LOW";
2017
+ const clearDelay = analysis.riskLevel === "CRITICAL" ? 15000
2018
+ : analysis.riskLevel === "HIGH" ? 10000
2019
+ : analysis.riskLevel === "MEDIUM" ? 8000
2020
+ : 5000; // SAFE/LOW: 5s then clear
2021
+
2022
+ setTimeout(() => {
2023
+ // Clear code crawl panel — show ALL CLEAR or warning summary
2024
+ showAuditResult(analysis);
2025
+ // Clear selected node highlight
2026
+ if (isSafe) {
2027
+ highlightNodes.clear();
2028
+ highlightLinks.clear();
2029
+ selectedFile = null;
2030
+ selectedNodeId = null;
2031
+ refreshGraph();
2032
+ }
2033
+ updateSSEStatus("LIVE", "connected");
2034
+ }, clearDelay);
2035
+
2036
+ // Update the realtime panel
2037
+ renderRealtimePanel();
2038
+ });
2039
+
2040
+ sseSource.addEventListener("analysis-error", (e) => {
2041
+ const data = JSON.parse(e.data);
2042
+ console.error("[SYKE:SSE] Analysis error:", data.file, data.error);
2043
+ window.syke.stopModifying(data.file);
2044
+ updateSSEStatus("AI ERROR", "critical");
2045
+ setTimeout(() => updateSSEStatus("LIVE", "connected"), 5000);
2046
+ });
2047
+
2048
+ sseSource.addEventListener("graph-rebuild", (e) => {
2049
+ const data = JSON.parse(e.data);
2050
+ console.log("[SYKE:SSE] Graph rebuild triggered:", data.reason, data.file);
2051
+ // Reload graph data after a short delay
2052
+ setTimeout(async () => {
2053
+ await loadGraph();
2054
+ await loadHubFiles();
2055
+ }, 1000);
2056
+ });
2057
+
2058
+ sseSource.addEventListener("project-switched", (e) => {
2059
+ const data = JSON.parse(e.data);
2060
+ console.log("[SYKE:SSE] Project switched:", data.projectRoot);
2061
+ loadProjectInfo();
2062
+ loadGraph();
2063
+ loadHubFiles();
2064
+ updateSSEStatus("PROJECT LOADED", "connected");
2065
+ });
2066
+
2067
+ sseSource.onerror = () => {
2068
+ console.warn("[SYKE:SSE] Connection error, reconnecting...");
2069
+ updateSSEStatus("OFFLINE", "offline");
2070
+ sseSource.close();
2071
+ sseSource = null;
2072
+ if (sseReconnectTimer) clearTimeout(sseReconnectTimer);
2073
+ sseReconnectTimer = setTimeout(initSSE, 3000);
2074
+ };
2075
+ }
2076
+
2077
+ function updateSSEStatus(text, className) {
2078
+ const dot = document.querySelector(".pulse-dot");
2079
+ const indicator = document.getElementById("sse-status");
2080
+ if (dot) {
2081
+ dot.className = "pulse-dot " + (className || "");
2082
+ }
2083
+ if (indicator) {
2084
+ indicator.textContent = text;
2085
+ indicator.className = "sse-indicator " + (className || "");
2086
+ }
2087
+ }
2088
+
2089
+ function addRealtimeEvent(event) {
2090
+ realtimeLog.unshift(event);
2091
+ if (realtimeLog.length > 50) realtimeLog.pop();
2092
+ renderRealtimePanel();
2093
+ }
2094
+
2095
+ function renderRealtimePanel() {
2096
+ const panel = document.getElementById("realtime-log");
2097
+ if (!panel) return;
2098
+
2099
+ if (realtimeLog.length === 0) {
2100
+ panel.innerHTML = '<p class="placeholder">Waiting for file changes...</p>';
2101
+ return;
2102
+ }
2103
+
2104
+ let html = "";
2105
+ for (const evt of realtimeLog.slice(0, 20)) {
2106
+ const time = new Date(evt.timestamp).toLocaleTimeString();
2107
+
2108
+ if (evt.type === "change") {
2109
+ const icon = evt.changeType === "added" ? "+" : evt.changeType === "deleted" ? "x" : "~";
2110
+ html += `<div class="rt-event rt-${evt.changeType}">
2111
+ <span class="rt-time">${time}</span>
2112
+ <span class="rt-icon">${icon}</span>
2113
+ <span class="rt-file">${evt.file}</span>
2114
+ <span class="rt-diff">${evt.diffCount} changes</span>
2115
+ </div>`;
2116
+ } else if (evt.type === "analyzing") {
2117
+ html += `<div class="rt-event rt-analyzing">
2118
+ <span class="rt-time">${time}</span>
2119
+ <span class="rt-icon">&#9881;</span>
2120
+ <span class="rt-msg">AI analyzing ${evt.file}...</span>
2121
+ </div>`;
2122
+ } else if (evt.type === "result") {
2123
+ const riskCls = evt.riskLevel === "CRITICAL" ? "critical" : evt.riskLevel === "HIGH" ? "danger" : evt.riskLevel === "MEDIUM" ? "warning" : "safe";
2124
+ html += `<div class="rt-event rt-result rt-${riskCls}">
2125
+ <span class="rt-time">${time}</span>
2126
+ <span class="rt-risk ${riskCls}">${evt.riskLevel}</span>
2127
+ <span class="rt-file">${evt.file}</span>
2128
+ <span class="rt-ms">${evt.analysisMs}ms</span>
2129
+ </div>`;
2130
+ if (evt.summary) {
2131
+ html += `<div class="rt-detail">${evt.summary}</div>`;
2132
+ }
2133
+ if (evt.brokenImports && evt.brokenImports.length > 0) {
2134
+ html += `<div class="rt-detail rt-broken">BROKEN: ${evt.brokenImports.join(", ")}</div>`;
2135
+ }
2136
+ if (evt.warnings && evt.warnings.length > 0) {
2137
+ evt.warnings.forEach(w => {
2138
+ html += `<div class="rt-detail rt-warn">${w}</div>`;
2139
+ });
2140
+ }
2141
+ if (evt.suggestion) {
2142
+ html += `<div class="rt-detail rt-suggestion">${evt.suggestion}</div>`;
2143
+ }
2144
+ }
2145
+ }
2146
+
2147
+ panel.innerHTML = html;
2148
+ }
2149
+
2150
+ // ═══════════════════════════════════════════
2151
+ // RESIZABLE RIGHT PANEL
2152
+ // ═══════════════════════════════════════════
2153
+ function setupResizeHandle() {
2154
+ const handle = document.getElementById("resize-handle");
2155
+ const panel = document.getElementById("right-panel");
2156
+ const hub = document.getElementById("hub-drawer");
2157
+ if (!handle || !panel) return;
2158
+
2159
+ let startX = 0;
2160
+ let startW = 0;
2161
+
2162
+ function onMove(e) {
2163
+ e.preventDefault();
2164
+ const dx = startX - e.clientX;
2165
+ const newW = Math.max(250, Math.min(900, startW + dx));
2166
+ panel.style.flex = "0 0 " + newW + "px";
2167
+ panel.style.width = newW + "px";
2168
+ if (hub) hub.style.right = newW + "px";
2169
+ if (Graph) {
2170
+ const gp = document.getElementById("graph-panel");
2171
+ if (gp) Graph.width(gp.clientWidth);
2172
+ }
2173
+ }
2174
+
2175
+ function onUp() {
2176
+ window.removeEventListener("mousemove", onMove, true);
2177
+ window.removeEventListener("mouseup", onUp, true);
2178
+ handle.classList.remove("dragging");
2179
+ document.body.style.cursor = "";
2180
+ document.body.style.userSelect = "";
2181
+ document.body.style.pointerEvents = "";
2182
+ const overlay = document.getElementById("resize-overlay");
2183
+ if (overlay) overlay.remove();
2184
+ if (Graph) {
2185
+ const gp = document.getElementById("graph-panel");
2186
+ if (gp) Graph.width(gp.clientWidth);
2187
+ }
2188
+ }
2189
+
2190
+ handle.addEventListener("mousedown", (e) => {
2191
+ e.preventDefault();
2192
+ e.stopPropagation();
2193
+ startX = e.clientX;
2194
+ startW = panel.offsetWidth;
2195
+ handle.classList.add("dragging");
2196
+ document.body.style.cursor = "col-resize";
2197
+ document.body.style.userSelect = "none";
2198
+ // Full-screen overlay prevents canvas from stealing events
2199
+ const overlay = document.createElement("div");
2200
+ overlay.id = "resize-overlay";
2201
+ overlay.style.cssText = "position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:9999;cursor:col-resize;background:transparent;";
2202
+ document.body.appendChild(overlay);
2203
+ // Capture-phase listeners on window — nothing can intercept
2204
+ window.addEventListener("mousemove", onMove, true);
2205
+ window.addEventListener("mouseup", onUp, true);
2206
+ });
2207
+ }
2208
+
2209
+ // ═══════════════════════════════════════════
2210
+ // PROJECT SELECTOR
2211
+ // ═══════════════════════════════════════════
2212
+ async function loadProjectInfo() {
2213
+ try {
2214
+ const res = await fetch("/api/project-info");
2215
+ const info = await res.json();
2216
+ const el = document.getElementById("current-project");
2217
+ if (el) {
2218
+ const short = info.projectRoot.length > 50
2219
+ ? "..." + info.projectRoot.slice(-47)
2220
+ : info.projectRoot;
2221
+ el.textContent = short;
2222
+ el.title = info.projectRoot + " | " + info.languages.join(", ") + " | " + info.fileCount + " files";
2223
+ }
2224
+ } catch (e) {
2225
+ console.warn("[SYKE] Failed to load project info:", e);
2226
+ }
2227
+ }
2228
+
2229
+ let browsePath = null; // current path in folder browser
2230
+
2231
+ async function browseDir(dirPath) {
2232
+ const listEl = document.getElementById("browse-dir-list");
2233
+ const pathEl = document.getElementById("browse-current-path");
2234
+ const infoEl = document.getElementById("project-detect-info");
2235
+ const loadBtn = document.getElementById("btn-project-load");
2236
+ const upBtn = document.getElementById("btn-browse-up");
2237
+
2238
+ if (listEl) listEl.innerHTML = '<div class="browse-empty"><div class="spinner"></div> SCANNING...</div>';
2239
+
2240
+ try {
2241
+ const url = dirPath ? `/api/browse-dirs?path=${encodeURIComponent(dirPath)}` : "/api/browse-dirs";
2242
+ const res = await fetch(url);
2243
+ const data = await res.json();
2244
+
2245
+ if (!res.ok) {
2246
+ if (listEl) listEl.innerHTML = `<div class="browse-empty">ERROR: ${data.error}</div>`;
2247
+ return;
2248
+ }
2249
+
2250
+ browsePath = data.current;
2251
+ if (pathEl) {
2252
+ pathEl.textContent = data.current;
2253
+ pathEl.title = data.current;
2254
+ }
2255
+
2256
+ // Up button
2257
+ if (upBtn) upBtn.disabled = !data.parent;
2258
+
2259
+ // Always allow selection, show project detection as hint
2260
+ if (loadBtn) loadBtn.disabled = false;
2261
+ if (data.isProject) {
2262
+ if (infoEl) {
2263
+ infoEl.className = "project-detected";
2264
+ infoEl.textContent = "PROJECT DETECTED (" + data.detectedMarker + ")";
2265
+ }
2266
+ } else {
2267
+ if (infoEl) {
2268
+ infoEl.className = "";
2269
+ infoEl.textContent = "";
2270
+ }
2271
+ }
2272
+
2273
+ // Render directory list
2274
+ if (!data.dirs || data.dirs.length === 0) {
2275
+ if (listEl) listEl.innerHTML = '<div class="browse-empty">NO SUBDIRECTORIES</div>';
2276
+ return;
2277
+ }
2278
+
2279
+ let html = "";
2280
+ for (const dir of data.dirs) {
2281
+ const fullPath = data.current.replace(/\\/g, "/").replace(/\/$/, "") + "/" + dir;
2282
+ html += '<div class="browse-dir-item" data-path="' + fullPath.replace(/"/g, "&quot;") + '">' +
2283
+ '<span class="dir-icon">&#128193;</span>' +
2284
+ '<span class="dir-name">' + dir + '</span></div>';
2285
+ }
2286
+ if (listEl) {
2287
+ listEl.innerHTML = html;
2288
+ listEl.querySelectorAll(".browse-dir-item").forEach(function(item) {
2289
+ item.addEventListener("click", function() { browseDir(item.dataset.path); });
2290
+ });
2291
+ }
2292
+ } catch (e) {
2293
+ if (listEl) listEl.innerHTML = '<div class="browse-empty">NETWORK ERROR</div>';
2294
+ }
2295
+ }
2296
+
2297
+ async function switchProject(projectPath) {
2298
+ const infoEl = document.getElementById("project-detect-info");
2299
+ const loadBtn = document.getElementById("btn-project-load");
2300
+
2301
+ if (infoEl) {
2302
+ infoEl.className = "";
2303
+ infoEl.innerHTML = '<div class="project-loading"><div class="spinner"></div>LOADING PROJECT...</div>';
2304
+ }
2305
+ if (loadBtn) loadBtn.disabled = true;
2306
+
2307
+ try {
2308
+ const res = await fetch("/api/switch-project", {
2309
+ method: "POST",
2310
+ headers: { "Content-Type": "application/json" },
2311
+ body: JSON.stringify({ projectRoot: projectPath }),
2312
+ });
2313
+ const data = await res.json();
2314
+
2315
+ if (!res.ok) {
2316
+ if (infoEl) {
2317
+ infoEl.className = "error";
2318
+ infoEl.textContent = data.error || "Failed to load project";
2319
+ }
2320
+ if (loadBtn) loadBtn.disabled = false;
2321
+ return;
2322
+ }
2323
+
2324
+ if (infoEl) {
2325
+ infoEl.className = "success";
2326
+ infoEl.textContent = "LOADED: " + data.languages.join(", ") + " | " + data.fileCount + " files | " + data.edgeCount + " edges";
2327
+ }
2328
+
2329
+ setTimeout(function() {
2330
+ document.getElementById("project-modal").classList.add("hidden");
2331
+ if (loadBtn) loadBtn.disabled = false;
2332
+ }, 800);
2333
+
2334
+ await loadProjectInfo();
2335
+ await loadGraph();
2336
+ await loadHubFiles();
2337
+ handleBackgroundClick();
2338
+
2339
+ } catch (e) {
2340
+ if (infoEl) {
2341
+ infoEl.className = "error";
2342
+ infoEl.textContent = "NETWORK ERROR: " + e.message;
2343
+ }
2344
+ if (loadBtn) loadBtn.disabled = false;
2345
+ }
2346
+ }
2347
+
2348
+ function setupProjectModal() {
2349
+ const openBtn = document.getElementById("btn-change-project");
2350
+ const modal = document.getElementById("project-modal");
2351
+ const loadBtn = document.getElementById("btn-project-load");
2352
+ const cancelBtn = document.getElementById("btn-project-cancel");
2353
+ const upBtn = document.getElementById("btn-browse-up");
2354
+
2355
+ if (!openBtn || !modal) return;
2356
+
2357
+ openBtn.addEventListener("click", async function() {
2358
+ modal.classList.remove("hidden");
2359
+ // Start from current project's parent dir
2360
+ try {
2361
+ const res = await fetch("/api/project-info");
2362
+ const info = await res.json();
2363
+ const startPath = info.projectRoot.replace(/[/\\][^/\\]+$/, "");
2364
+ browseDir(startPath);
2365
+ } catch (_) {
2366
+ browseDir(null);
2367
+ }
2368
+ });
2369
+
2370
+ if (cancelBtn) {
2371
+ cancelBtn.addEventListener("click", function() { modal.classList.add("hidden"); });
2372
+ }
2373
+
2374
+ if (upBtn) {
2375
+ upBtn.addEventListener("click", function() {
2376
+ if (browsePath) {
2377
+ const parent = browsePath.replace(/[/\\][^/\\]+$/, "");
2378
+ if (parent && parent !== browsePath) browseDir(parent);
2379
+ }
2380
+ });
2381
+ }
2382
+
2383
+ if (loadBtn) {
2384
+ loadBtn.addEventListener("click", function() {
2385
+ if (browsePath) switchProject(browsePath);
2386
+ });
2387
+ }
2388
+
2389
+ document.addEventListener("keydown", function(e) {
2390
+ if (e.key === "Escape" && !modal.classList.contains("hidden")) {
2391
+ modal.classList.add("hidden");
2392
+ }
2393
+ });
2394
+
2395
+ modal.addEventListener("click", function(e) {
2396
+ if (e.target === modal) modal.classList.add("hidden");
2397
+ });
2398
+ }