codeui-python 0.1.0__py3-none-any.whl → 0.1.1__py3-none-any.whl

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 (49) hide show
  1. codeui/__init__.py +1 -1
  2. codeui/agent/context.py +19 -3
  3. codeui/analysis/dead_code.py +13 -2
  4. codeui/analysis/undefined.py +70 -0
  5. codeui/analysis/unresolved_import.py +34 -4
  6. codeui/cli/main.py +101 -7
  7. codeui/core/cache.py +54 -0
  8. codeui/core/graph.py +233 -3
  9. codeui/core/override.py +1 -0
  10. codeui/core/resolver.py +51 -37
  11. codeui/errors.py +10 -0
  12. codeui/lang/base.py +5 -2
  13. codeui/lang/cpp.py +271 -0
  14. codeui/lang/csharp.py +262 -0
  15. codeui/lang/dart.py +278 -0
  16. codeui/lang/generic.py +6 -4
  17. codeui/lang/go.py +5 -2
  18. codeui/lang/java.py +320 -0
  19. codeui/lang/kotlin.py +303 -0
  20. codeui/lang/php.py +275 -0
  21. codeui/lang/python.py +49 -7
  22. codeui/lang/registry.py +22 -2
  23. codeui/lang/ruby.py +270 -0
  24. codeui/lang/rust.py +5 -2
  25. codeui/lang/scala.py +290 -0
  26. codeui/lang/swift.py +276 -0
  27. codeui/lang/ts.py +99 -8
  28. codeui/report/sarif_emitter.py +2 -1
  29. codeui/server/server.py +156 -85
  30. codeui/server/static/css/styles.css +1609 -0
  31. codeui/server/static/index.html +199 -0
  32. codeui/server/static/js/api.js +485 -0
  33. codeui/server/static/js/app.js +1044 -0
  34. codeui/server/static/js/graph_canvas.js +1485 -0
  35. codeui/server/static/js/minimap.js +383 -0
  36. codeui/server/static/js/sidebar.js +268 -0
  37. codeui/server/static/js/state.js +83 -0
  38. codeui/server/static/js/subgraph_window.js +1208 -0
  39. codeui/server/static/js/theme.js +332 -0
  40. codeui/tracer/__init__.py +4 -1
  41. codeui/tracer/tracer.py +109 -118
  42. codeui_python-0.1.1.dist-info/METADATA +171 -0
  43. codeui_python-0.1.1.dist-info/RECORD +71 -0
  44. {codeui_python-0.1.0.dist-info → codeui_python-0.1.1.dist-info}/licenses/LICENSE +1 -1
  45. codeui_python-0.1.0.dist-info/METADATA +0 -191
  46. codeui_python-0.1.0.dist-info/RECORD +0 -52
  47. {codeui_python-0.1.0.dist-info → codeui_python-0.1.1.dist-info}/WHEEL +0 -0
  48. {codeui_python-0.1.0.dist-info → codeui_python-0.1.1.dist-info}/entry_points.txt +0 -0
  49. {codeui_python-0.1.0.dist-info → codeui_python-0.1.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1485 @@
1
+ /**
2
+ * High performance HTML5 2D Canvas Graph Engine with multi-layout algorithms,
3
+ * neighborhood isolation, path tracing, cluster hulls, and fluid transitions.
4
+ */
5
+ class GraphCanvas {
6
+ constructor(canvasId, isMain = false) {
7
+ this.canvas = typeof canvasId === "string" ? document.getElementById(canvasId) : canvasId;
8
+ if (!this.canvas) {
9
+ console.warn(`GraphCanvas: Canvas element '${canvasId}' not found.`);
10
+ return;
11
+ }
12
+ this.ctx = this.canvas.getContext("2d");
13
+ if (!this.ctx) {
14
+ console.warn(`GraphCanvas: Unable to obtain 2D context for '${canvasId}'.`);
15
+ return;
16
+ }
17
+ this.isMain = isMain;
18
+ this.nodes = [];
19
+ this.links = [];
20
+ this.transform = { x: 0, y: 0, k: 1 };
21
+ this.isInteractive = true;
22
+ this.dragNode = null;
23
+ this.hoverNode = null;
24
+ this.isPanning = false;
25
+ this.dragStartPos = null;
26
+ this.lastMouse = { x: 0, y: 0 };
27
+ this.charge = -250;
28
+ this.alpha = 1.0;
29
+ this.isRunning = true;
30
+ this.animFrameId = null;
31
+ this.animatingLayout = false;
32
+ this.pulsePhase = 0;
33
+
34
+ this.touchStartDist = 0;
35
+ this.touchStartScale = 1;
36
+ this.touchStartCenter = { x: 0, y: 0 };
37
+ this.touchStartPos = null;
38
+ this.lastTouchEndTime = 0;
39
+
40
+ this.setupCanvas();
41
+ this.setupEvents();
42
+ this.miniMap = isMain ? new MiniMap(this) : null;
43
+ this.startLoop();
44
+ }
45
+
46
+ setupCanvas() {
47
+ if (!this.canvas) return;
48
+ this.resizeListener = () => {
49
+ const parent = this.canvas.parentElement;
50
+ const rect = parent ? parent.getBoundingClientRect() : null;
51
+ const w = Math.max(100, Math.floor(rect && rect.width > 0 ? rect.width : (this.canvas.clientWidth || window.innerWidth)));
52
+ const h = Math.max(100, Math.floor(rect && rect.height > 0 ? rect.height : (this.canvas.clientHeight || window.innerHeight)));
53
+ this.canvas.width = w * window.devicePixelRatio;
54
+ this.canvas.height = h * window.devicePixelRatio;
55
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
56
+ this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
57
+ if (this.miniMap) this.miniMap.update();
58
+ };
59
+ window.addEventListener("resize", this.resizeListener);
60
+ this.resizeListener();
61
+ }
62
+
63
+ handleResize() {
64
+ if (typeof this.resizeListener === "function") {
65
+ this.resizeListener();
66
+ }
67
+ }
68
+
69
+ selectAndCenterNode(nodeId) {
70
+ if (!nodeId || !this.nodes) return;
71
+ const node = this.nodes.find(n => n.id === nodeId);
72
+ if (node) {
73
+ if (typeof this.isolateNeighborhood === "function") {
74
+ this.isolateNeighborhood(node.id);
75
+ }
76
+ this.smoothPanZoomTo(node.x, node.y, 1.8);
77
+ }
78
+ }
79
+
80
+ destroy() {
81
+ this.isRunning = false;
82
+ if (this.animFrameId) {
83
+ cancelAnimationFrame(this.animFrameId);
84
+ this.animFrameId = null;
85
+ }
86
+ if (this.resizeListener) {
87
+ window.removeEventListener("resize", this.resizeListener);
88
+ }
89
+ if (this.onWindowMouseUp) {
90
+ window.removeEventListener("mouseup", this.onWindowMouseUp);
91
+ }
92
+ }
93
+
94
+ setData(nodes, links) {
95
+ if (!this.canvas || !this.ctx) return;
96
+ const nodeMap = new Map();
97
+ const existingMap = new Map((this.nodes || []).map(n => [n.id, n]));
98
+
99
+ const w = (this.canvas.width / window.devicePixelRatio) || 800;
100
+ const h = (this.canvas.height / window.devicePixelRatio) || 600;
101
+ const cx = w / 2;
102
+ const cy = h / 2;
103
+
104
+ this.nodes = (nodes || []).map((n, index) => {
105
+ const existing = existingMap.get(n.id);
106
+ let initX = existing && isFinite(existing.x) ? existing.x : null;
107
+ let initY = existing && isFinite(existing.y) ? existing.y : null;
108
+
109
+ if (initX === null || initY === null) {
110
+ // Golden spiral distribution to prevent node overlap & zero-distance divide
111
+ const angle = index * 2.399963;
112
+ const radius = 22 * Math.sqrt(index + 1);
113
+ initX = cx + Math.cos(angle) * radius + (Math.random() - 0.5) * 8;
114
+ initY = cy + Math.sin(angle) * radius + (Math.random() - 0.5) * 8;
115
+ }
116
+
117
+ const node = {
118
+ ...n,
119
+ x: initX,
120
+ y: initY,
121
+ vx: 0,
122
+ vy: 0,
123
+ radius: n.radius || (appState.graphMode === "files" ? 8 : 6)
124
+ };
125
+ nodeMap.set(n.id, node);
126
+ return node;
127
+ });
128
+
129
+ this.links = [];
130
+ (links || []).forEach(l => {
131
+ const src = typeof l.source === "object" ? l.source : nodeMap.get(l.source);
132
+ const tgt = typeof l.target === "object" ? l.target : nodeMap.get(l.target);
133
+ if (src && tgt && src !== tgt) {
134
+ this.links.push({
135
+ source: src,
136
+ target: tgt,
137
+ weight: l.weight || 1,
138
+ kind: l.kind || "imports"
139
+ });
140
+ }
141
+ });
142
+
143
+ if (!this.isMain) {
144
+ this.computeSubgraphLayout();
145
+ this.alpha = 0.18;
146
+ setTimeout(() => {
147
+ if (this.canvas) this.fitToScreen();
148
+ }, 60);
149
+ } else if (appState.layoutMode !== "force") {
150
+ this.computeLayout(appState.layoutMode, false);
151
+ } else {
152
+ this.alpha = 1.0;
153
+ }
154
+
155
+ if (this.miniMap) this.miniMap.update();
156
+ }
157
+
158
+ computeSubgraphLayout() {
159
+ if (!this.nodes || this.nodes.length === 0) return;
160
+ const w = (this.canvas.width / window.devicePixelRatio) || 500;
161
+ const h = (this.canvas.height / window.devicePixelRatio) || 350;
162
+ const cx = w / 2;
163
+ const cy = h / 2;
164
+
165
+ const count = this.nodes.length;
166
+ if (count === 1) {
167
+ this.nodes[0].x = cx;
168
+ this.nodes[0].y = cy;
169
+ this.nodes[0].vx = 0;
170
+ this.nodes[0].vy = 0;
171
+ this.fitToScreen();
172
+ return;
173
+ }
174
+
175
+ const localNodes = this.nodes.filter(n => !n.isExternal);
176
+ const externalNodes = this.nodes.filter(n => n.isExternal);
177
+
178
+ if (externalNodes.length > 0 && localNodes.length > 0) {
179
+ const incomingIds = new Set(this.links.filter(l => l.target && !l.target.isExternal && l.source && l.source.isExternal).map(l => l.source.id));
180
+ const outgoingIds = new Set(this.links.filter(l => l.source && !l.source.isExternal && l.target && l.target.isExternal).map(l => l.target.id));
181
+
182
+ const incomingNodes = externalNodes.filter(n => incomingIds.has(n.id));
183
+ const outgoingNodes = externalNodes.filter(n => outgoingIds.has(n.id) && !incomingIds.has(n.id));
184
+ const otherExtNodes = externalNodes.filter(n => !incomingIds.has(n.id) && !outgoingIds.has(n.id));
185
+
186
+ const localCount = localNodes.length;
187
+ const localRadius = Math.max(45, Math.min(w * 0.22, h * 0.26, 40 + localCount * 12));
188
+ localNodes.forEach((node, i) => {
189
+ const angle = (i / Math.max(1, localCount)) * Math.PI * 2 - Math.PI / 2;
190
+ node.x = cx + Math.cos(angle) * localRadius;
191
+ node.y = cy + Math.sin(angle) * localRadius;
192
+ node.vx = 0;
193
+ node.vy = 0;
194
+ });
195
+
196
+ const inCount = incomingNodes.length;
197
+ if (inCount > 0) {
198
+ const inSpacing = Math.max(32, Math.min(48, (h - 40) / Math.max(1, inCount)));
199
+ const inStartY = cy - ((inCount - 1) * inSpacing) / 2;
200
+ const inX = Math.max(45, cx - localRadius - 65);
201
+ incomingNodes.forEach((node, i) => {
202
+ node.x = inX;
203
+ node.y = inStartY + i * inSpacing;
204
+ node.vx = 0;
205
+ node.vy = 0;
206
+ });
207
+ }
208
+
209
+ const allOut = [...outgoingNodes, ...otherExtNodes];
210
+ const outCount = allOut.length;
211
+ if (outCount > 0) {
212
+ const outSpacing = Math.max(32, Math.min(48, (h - 40) / Math.max(1, outCount)));
213
+ const outStartY = cy - ((outCount - 1) * outSpacing) / 2;
214
+ const outX = Math.min(w - 45, cx + localRadius + 65);
215
+ allOut.forEach((node, i) => {
216
+ node.x = outX;
217
+ node.y = outStartY + i * outSpacing;
218
+ node.vx = 0;
219
+ node.vy = 0;
220
+ });
221
+ }
222
+
223
+ this.fitToScreen();
224
+ return;
225
+ }
226
+
227
+ const classes = this.nodes.filter(n => n.kind === "class" || n.kind === "interface" || n.kind === "struct");
228
+ const functions = this.nodes.filter(n => n.kind === "function" || n.kind === "method");
229
+ const variables = this.nodes.filter(n => n.kind === "variable" || n.kind === "constant" || n.kind === "property" || n.kind === "type_alias");
230
+ const others = this.nodes.filter(n => !classes.includes(n) && !functions.includes(n) && !variables.includes(n));
231
+
232
+ if (classes.length > 0) {
233
+ const rows = [classes, functions, [...variables, ...others]].filter(r => r.length > 0);
234
+ const rowHeight = Math.min(110, (h - 80) / Math.max(1, rows.length));
235
+ const startY = cy - ((rows.length - 1) * rowHeight) / 2;
236
+
237
+ rows.forEach((row, rIdx) => {
238
+ const y = startY + rIdx * rowHeight;
239
+ const rowCount = row.length;
240
+ const spacing = Math.min(140, (w - 80) / Math.max(1, rowCount));
241
+ const startX = cx - ((rowCount - 1) * spacing) / 2;
242
+ row.forEach((node, idx) => {
243
+ node.x = startX + idx * spacing;
244
+ node.y = y;
245
+ node.vx = 0;
246
+ node.vy = 0;
247
+ });
248
+ });
249
+ } else {
250
+ const radius = Math.min(w * 0.35, h * 0.35, 45 + count * 15);
251
+ this.nodes.forEach((node, i) => {
252
+ const angle = (i / count) * Math.PI * 2 - Math.PI / 2;
253
+ node.x = cx + Math.cos(angle) * radius;
254
+ node.y = cy + Math.sin(angle) * radius;
255
+ node.vx = 0;
256
+ node.vy = 0;
257
+ });
258
+ }
259
+ this.fitToScreen();
260
+ }
261
+
262
+ setupEvents() {
263
+ const getPos = e => {
264
+ const rect = this.canvas.getBoundingClientRect();
265
+ return {
266
+ x: (e.clientX - rect.left - this.transform.x) / this.transform.k,
267
+ y: (e.clientY - rect.top - this.transform.y) / this.transform.k,
268
+ rawX: e.clientX - rect.left,
269
+ rawY: e.clientY - rect.top,
270
+ screenX: e.clientX,
271
+ screenY: e.clientY
272
+ };
273
+ };
274
+
275
+ const findNode = (x, y) => {
276
+ for (let i = this.nodes.length - 1; i >= 0; i--) {
277
+ const n = this.nodes[i];
278
+ const dx = n.x - x;
279
+ const dy = n.y - y;
280
+ const hitRadius = (n.radius || 8) + 8;
281
+ if (dx * dx + dy * dy < hitRadius * hitRadius) return n;
282
+ }
283
+ return null;
284
+ };
285
+
286
+ this.canvas.addEventListener("mousedown", e => {
287
+ if (this.isMain && this.isInteractive === false) return;
288
+ const pos = getPos(e);
289
+ this.dragNode = findNode(pos.x, pos.y);
290
+ this.dragStartPos = { x: pos.x, y: pos.y, screenX: e.clientX, screenY: e.clientY };
291
+ this.hasMovedDrag = false;
292
+ if (this.dragNode) {
293
+ this.dragNode.fx = this.dragNode.x;
294
+ this.dragNode.fy = this.dragNode.y;
295
+ } else {
296
+ this.isPanning = true;
297
+ this.lastMouse = { x: pos.rawX, y: pos.rawY };
298
+ }
299
+ });
300
+
301
+ this.canvas.addEventListener("mousemove", e => {
302
+ if (this.isMain && this.isInteractive === false) return;
303
+ const pos = getPos(e);
304
+ if (this.dragNode) {
305
+ const startX = this.dragStartPos ? this.dragStartPos.screenX : e.clientX;
306
+ const startY = this.dragStartPos ? this.dragStartPos.screenY : e.clientY;
307
+ const dist = Math.hypot(e.clientX - startX, e.clientY - startY);
308
+ if (dist > 6) {
309
+ this.hasMovedDrag = true;
310
+ this.dragNode.fx = pos.x;
311
+ this.dragNode.fy = pos.y;
312
+ this.dragNode.x = pos.x;
313
+ this.dragNode.y = pos.y;
314
+ this.dragNode.vx = 0;
315
+ this.dragNode.vy = 0;
316
+ if (!this.isMain || appState.layoutMode === "force") {
317
+ this.alpha = Math.max(this.alpha, 0.25);
318
+ }
319
+ }
320
+ } else if (this.isPanning) {
321
+ const startX = this.dragStartPos ? this.dragStartPos.screenX : (this.lastMouse ? this.lastMouse.x : e.clientX);
322
+ const startY = this.dragStartPos ? this.dragStartPos.screenY : (this.lastMouse ? this.lastMouse.y : e.clientY);
323
+ const dist = Math.hypot(e.clientX - startX, e.clientY - startY);
324
+ if (dist > 6) {
325
+ this.hasMovedDrag = true;
326
+ }
327
+ const prevX = this.lastMouse ? this.lastMouse.x : pos.rawX;
328
+ const prevY = this.lastMouse ? this.lastMouse.y : pos.rawY;
329
+ this.transform.x += pos.rawX - prevX;
330
+ this.transform.y += pos.rawY - prevY;
331
+ this.lastMouse = { x: pos.rawX, y: pos.rawY };
332
+ if (this.miniMap) this.miniMap.update();
333
+ } else {
334
+ const prevHover = this.hoverNode;
335
+ this.hoverNode = findNode(pos.x, pos.y);
336
+ this.canvas.style.cursor = this.hoverNode ? "pointer" : "grab";
337
+ if (this.hoverNode !== prevHover) {
338
+ this.updateHoverTooltip(this.hoverNode, pos.screenX, pos.screenY);
339
+ }
340
+ }
341
+ });
342
+
343
+ this.canvas.addEventListener("mouseleave", () => {
344
+ this.hoverNode = null;
345
+ this.hideHoverTooltip();
346
+ });
347
+
348
+ this.onWindowMouseUp = () => {
349
+ if (this.dragNode) {
350
+ this.dragNode.fx = null;
351
+ this.dragNode.fy = null;
352
+ this.dragNode.vx = 0;
353
+ this.dragNode.vy = 0;
354
+ this.dragNode = null;
355
+ if (!this.isMain || appState.layoutMode === "force") {
356
+ this.alpha = Math.max(this.alpha, 0.2);
357
+ }
358
+ }
359
+ this.isPanning = false;
360
+ this.dragStartPos = null;
361
+ };
362
+ window.addEventListener("mouseup", this.onWindowMouseUp);
363
+
364
+ const handleNodeActivation = (node, isDoubleClick = false) => {
365
+ if (!node) return;
366
+ if (this.isMain) {
367
+ if (appState.tracerActive) {
368
+ App.handleTracerNodeClick(node.id);
369
+ return;
370
+ }
371
+
372
+ appState.selectedFileNodeId = node.id;
373
+ this.isolateNeighborhood(node.id);
374
+ Sidebar.highlightSelectedCard(node.id);
375
+
376
+ const isSymbolNode = appState.graphMode === "symbols" ||
377
+ node.type === "symbol" ||
378
+ Boolean(node.rawSymbol) ||
379
+ (typeof node.id === "string" && node.id.includes("::"));
380
+
381
+ let targetFile = appState.graphMode === "files" ? node.id : (node.fileId || (typeof node.id === "string" ? node.id.split("::")[0] : ""));
382
+ let targetSymbol = isSymbolNode ? node.id : null;
383
+ if (node.rawSymbol && node.rawSymbol.location && node.rawSymbol.location.file_id) {
384
+ targetFile = node.rawSymbol.location.file_id;
385
+ targetSymbol = node.rawSymbol.id;
386
+ }
387
+
388
+ if (isDoubleClick) {
389
+ SubGraphManager.openWindow(targetFile, targetSymbol, null, "", "graph");
390
+ }
391
+ } else {
392
+ this.selectedNodeId = node.id;
393
+ this.isolateNeighborhood(node.id);
394
+ if (isDoubleClick && this.onNodeDoubleClick) {
395
+ this.onNodeDoubleClick(node);
396
+ } else if (this.onNodeSelected) {
397
+ this.onNodeSelected(node);
398
+ }
399
+ }
400
+ };
401
+
402
+ const handleEmptyCanvasClick = () => {
403
+ this.hasMovedDrag = false;
404
+ this.hoverNode = null;
405
+ this.hideHoverTooltip();
406
+ this.clearNeighborhoodIsolation();
407
+ if (this.isMain) {
408
+ Sidebar.clearSelection();
409
+ if (!appState.tracerActive) {
410
+ App.hideNodeHUD();
411
+ }
412
+ } else if (this.onEmptyCanvasClick) {
413
+ this.onEmptyCanvasClick();
414
+ }
415
+ };
416
+
417
+ let lastClickTime = 0;
418
+ let lastClickNodeId = null;
419
+ let lastDblClickTime = 0;
420
+
421
+ this.canvas.addEventListener("click", e => {
422
+ if (this.isMain && this.isInteractive === false) return;
423
+ if (this.lastTouchEndTime && (Date.now() - this.lastTouchEndTime < 450)) {
424
+ return;
425
+ }
426
+ if (this.hasMovedDrag) {
427
+ this.hasMovedDrag = false;
428
+ return;
429
+ }
430
+ const pos = getPos(e);
431
+ const node = findNode(pos.x, pos.y);
432
+ const now = Date.now();
433
+
434
+ if (node) {
435
+ const timeDiff = now - lastClickTime;
436
+ const isSlowDblClick = (lastClickNodeId === node.id) && (timeDiff < 650);
437
+
438
+ if (isSlowDblClick) {
439
+ lastClickTime = 0;
440
+ lastClickNodeId = null;
441
+ lastDblClickTime = now;
442
+ handleNodeActivation(node, true);
443
+ } else {
444
+ lastClickTime = now;
445
+ lastClickNodeId = node.id;
446
+ handleNodeActivation(node, false);
447
+ }
448
+ } else {
449
+ lastClickTime = 0;
450
+ lastClickNodeId = null;
451
+ handleEmptyCanvasClick();
452
+ }
453
+ this.lastClickedNodeId = node?.id || null;
454
+ });
455
+
456
+ this.canvas.addEventListener("dblclick", e => {
457
+ if (this.isMain && this.isInteractive === false) return;
458
+ if (this.hasMovedDrag) return;
459
+ const now = Date.now();
460
+ if (now - lastDblClickTime < 450) {
461
+ return;
462
+ }
463
+ const pos = getPos(e);
464
+ const node = findNode(pos.x, pos.y);
465
+ if (node) {
466
+ lastDblClickTime = now;
467
+ lastClickTime = 0;
468
+ lastClickNodeId = null;
469
+ handleNodeActivation(node, true);
470
+ }
471
+ });
472
+
473
+ this.canvas.addEventListener("wheel", e => {
474
+ if (this.isMain && this.isInteractive === false) return;
475
+ e.preventDefault();
476
+ const rect = this.canvas.getBoundingClientRect();
477
+ const mouseX = e.clientX - rect.left;
478
+ const mouseY = e.clientY - rect.top;
479
+ const zoomFactor = e.deltaY < 0 ? 1.12 : 0.88;
480
+ const newK = Math.max(0.05, Math.min(10, this.transform.k * zoomFactor));
481
+
482
+ this.transform.x = mouseX - (mouseX - this.transform.x) * (newK / this.transform.k);
483
+ this.transform.y = mouseY - (mouseY - this.transform.y) * (newK / this.transform.k);
484
+ this.transform.k = newK;
485
+ if (this.miniMap) this.miniMap.update();
486
+ }, { passive: false });
487
+
488
+ this.canvas.addEventListener("touchstart", e => {
489
+ if (this.isMain && this.isInteractive === false) return;
490
+ if (e.touches.length === 1) {
491
+ const rect = this.canvas.getBoundingClientRect();
492
+ const rawX = e.touches[0].clientX - rect.left;
493
+ const rawY = e.touches[0].clientY - rect.top;
494
+ const worldX = (rawX - this.transform.x) / this.transform.k;
495
+ const worldY = (rawY - this.transform.y) / this.transform.k;
496
+ this.dragNode = findNode(worldX, worldY);
497
+ this.touchStartPos = { x: worldX, y: worldY, rawX, rawY, screenX: e.touches[0].clientX, screenY: e.touches[0].clientY };
498
+ this.hasMovedDrag = false;
499
+ if (this.dragNode) {
500
+ this.dragNode.fx = this.dragNode.x;
501
+ this.dragNode.fy = this.dragNode.y;
502
+ } else {
503
+ this.isPanning = true;
504
+ this.lastMouse = { x: rawX, y: rawY };
505
+ }
506
+ } else if (e.touches.length === 2) {
507
+ e.preventDefault();
508
+ const rect = this.canvas.getBoundingClientRect();
509
+ const t1 = e.touches[0];
510
+ const t2 = e.touches[1];
511
+ this.touchStartDist = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
512
+ this.touchStartScale = this.transform.k;
513
+ this.touchStartCenter = {
514
+ x: (t1.clientX + t2.clientX) / 2 - rect.left,
515
+ y: (t1.clientY + t2.clientY) / 2 - rect.top
516
+ };
517
+ }
518
+ }, { passive: false });
519
+
520
+ this.canvas.addEventListener("touchmove", e => {
521
+ if (this.isMain && this.isInteractive === false) return;
522
+ if (e.cancelable && (this.isPanning || this.dragNode || e.touches.length > 1)) {
523
+ e.preventDefault();
524
+ }
525
+ if (e.touches.length === 1) {
526
+ const rect = this.canvas.getBoundingClientRect();
527
+ const rawX = e.touches[0].clientX - rect.left;
528
+ const rawY = e.touches[0].clientY - rect.top;
529
+ const worldX = (rawX - this.transform.x) / this.transform.k;
530
+ const worldY = (rawY - this.transform.y) / this.transform.k;
531
+ const dist = Math.hypot(e.touches[0].clientX - (this.touchStartPos ? this.touchStartPos.screenX : rawX), e.touches[0].clientY - (this.touchStartPos ? this.touchStartPos.screenY : rawY));
532
+ if (dist > 6) {
533
+ this.hasMovedDrag = true;
534
+ }
535
+ if (this.dragNode) {
536
+ if (this.hasMovedDrag) {
537
+ this.dragNode.fx = worldX;
538
+ this.dragNode.fy = worldY;
539
+ this.dragNode.x = worldX;
540
+ this.dragNode.y = worldY;
541
+ this.dragNode.vx = 0;
542
+ this.dragNode.vy = 0;
543
+ }
544
+ } else if (this.isPanning) {
545
+ this.transform.x += rawX - this.lastMouse.x;
546
+ this.transform.y += rawY - this.lastMouse.y;
547
+ this.lastMouse = { x: rawX, y: rawY };
548
+ if (this.miniMap) this.miniMap.update();
549
+ }
550
+ } else if (e.touches.length === 2) {
551
+ e.preventDefault();
552
+ const rect = this.canvas.getBoundingClientRect();
553
+ const t1 = e.touches[0];
554
+ const t2 = e.touches[1];
555
+ const currentDist = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
556
+ if (this.touchStartDist > 0) {
557
+ const factor = currentDist / this.touchStartDist;
558
+ const newK = Math.max(0.05, Math.min(10, this.touchStartScale * factor));
559
+ const cx = this.touchStartCenter.x;
560
+ const cy = this.touchStartCenter.y;
561
+ this.transform.x = cx - (cx - this.transform.x) * (newK / this.transform.k);
562
+ this.transform.y = cy - (cy - this.transform.y) * (newK / this.transform.k);
563
+ this.transform.k = newK;
564
+ if (this.miniMap) this.miniMap.update();
565
+ }
566
+ }
567
+ }, { passive: false });
568
+
569
+ this.canvas.addEventListener("touchend", () => {
570
+ this.lastTouchEndTime = Date.now();
571
+ if (this.dragNode) {
572
+ this.dragNode.fx = null;
573
+ this.dragNode.fy = null;
574
+ this.dragNode.vx = 0;
575
+ this.dragNode.vy = 0;
576
+ this.dragNode = null;
577
+ }
578
+ this.isPanning = false;
579
+ if (!this.hasMovedDrag && this.touchStartPos) {
580
+ const node = findNode(this.touchStartPos.x, this.touchStartPos.y);
581
+ const now = Date.now();
582
+ if (node) {
583
+ const timeDiff = now - lastClickTime;
584
+ const isSlowDblClick = (lastClickNodeId === node.id) && (timeDiff < 650);
585
+ if (isSlowDblClick) {
586
+ lastClickTime = 0;
587
+ lastClickNodeId = null;
588
+ lastDblClickTime = now;
589
+ handleNodeActivation(node, true);
590
+ } else {
591
+ lastClickTime = now;
592
+ lastClickNodeId = node.id;
593
+ handleNodeActivation(node, false);
594
+ }
595
+ } else {
596
+ lastClickTime = 0;
597
+ lastClickNodeId = null;
598
+ handleEmptyCanvasClick();
599
+ }
600
+ this.lastClickedNodeId = node?.id || null;
601
+ }
602
+ this.touchStartDist = 0;
603
+ this.touchStartPos = null;
604
+ });
605
+
606
+ this.canvas.addEventListener("touchcancel", () => {
607
+ if (this.dragNode) {
608
+ this.dragNode.fx = null;
609
+ this.dragNode.fy = null;
610
+ this.dragNode.vx = 0;
611
+ this.dragNode.vy = 0;
612
+ this.dragNode = null;
613
+ }
614
+ this.isPanning = false;
615
+ this.touchStartDist = 0;
616
+ this.touchStartPos = null;
617
+ });
618
+ }
619
+
620
+ setCharge(val) {
621
+ this.charge = parseFloat(val);
622
+ if (this.isMain && appState.layoutMode === "force") {
623
+ this.alpha = 0.4;
624
+ }
625
+ }
626
+
627
+ setLayout(layoutName) {
628
+ appState.layoutMode = layoutName;
629
+ if (layoutName === "force") {
630
+ this.alpha = 1.0;
631
+ this.isRunning = true;
632
+ } else {
633
+ this.computeLayout(layoutName, true);
634
+ }
635
+ }
636
+
637
+ computeLayout(layoutName, animate = true) {
638
+ if (!this.nodes || this.nodes.length === 0) return;
639
+ const w = this.canvas.width / window.devicePixelRatio;
640
+ const h = this.canvas.height / window.devicePixelRatio;
641
+ const cx = w / 2;
642
+ const cy = h / 2;
643
+
644
+ const targets = new Map();
645
+
646
+ if (layoutName === "hierarchical") {
647
+ const layerOrder = { entry: 0, frontend: 1, backend: 2, shared: 3, test: 4, config: 4, other: 3 };
648
+ const groups = new Map();
649
+ this.nodes.forEach(n => {
650
+ const layer = n.layer || "other";
651
+ const rank = layerOrder[layer] !== undefined ? layerOrder[layer] : 3;
652
+ if (!groups.has(rank)) groups.set(rank, []);
653
+ groups.get(rank).push(n);
654
+ });
655
+
656
+ const sortedRanks = Array.from(groups.keys()).sort((a, b) => a - b);
657
+ let currentY = 80;
658
+
659
+ sortedRanks.forEach((rank) => {
660
+ const rowNodes = groups.get(rank) || [];
661
+ const maxPerSubRow = 6;
662
+ const subRows = Math.ceil(rowNodes.length / maxPerSubRow);
663
+ const spacing = 120;
664
+
665
+ for (let sr = 0; sr < subRows; sr++) {
666
+ const slice = rowNodes.slice(sr * maxPerSubRow, (sr + 1) * maxPerSubRow);
667
+ const itemsInSubRow = slice.length;
668
+ const startX = cx - ((itemsInSubRow - 1) * spacing) / 2;
669
+ slice.forEach((node, idx) => {
670
+ targets.set(node.id, { x: startX + idx * spacing, y: currentY });
671
+ });
672
+ currentY += 75;
673
+ }
674
+ currentY += 45;
675
+ });
676
+ } else if (layoutName === "radial") {
677
+ const layerRings = { entry: 0, frontend: 1, backend: 1, shared: 2, other: 2, test: 3, config: 3 };
678
+ const rings = new Map();
679
+ this.nodes.forEach(n => {
680
+ const ring = layerRings[n.layer] || 2;
681
+ if (!rings.has(ring)) rings.set(ring, []);
682
+ rings.get(ring).push(n);
683
+ });
684
+
685
+ rings.forEach((ringNodes, ringIdx) => {
686
+ const count = ringNodes.length;
687
+ const radius = ringIdx === 0 ? (count === 1 ? 0 : 65) : (100 + ringIdx * 125 + Math.min(180, count * 6));
688
+ ringNodes.forEach((node, i) => {
689
+ const angle = (i / count) * Math.PI * 2 - Math.PI / 2;
690
+ targets.set(node.id, { x: cx + Math.cos(angle) * radius, y: cy + Math.sin(angle) * radius });
691
+ });
692
+ });
693
+ } else if (layoutName === "clusters") {
694
+ if (this.isMain && appState.graphMode === "symbols") {
695
+ // Hierarchical clustering: Main Folder -> Subfolder -> File Subcluster -> Symbols
696
+ const hierarchy = new Map();
697
+ this.nodes.forEach(n => {
698
+ const mf = n.mainFolder || "root";
699
+ const sf = n.subfolder || "root";
700
+ const fid = n.fileId || "root";
701
+ if (!hierarchy.has(mf)) hierarchy.set(mf, new Map());
702
+ const subMap = hierarchy.get(mf);
703
+ if (!subMap.has(sf)) subMap.set(sf, new Map());
704
+ const fileMap = subMap.get(sf);
705
+ if (!fileMap.has(fid)) fileMap.set(fid, []);
706
+ fileMap.get(fid).push(n);
707
+ });
708
+
709
+ const mainFolders = Array.from(hierarchy.keys());
710
+ const mfCount = mainFolders.length;
711
+ const mfRadius = Math.max(220, Math.min(w, h) * 0.44 + mfCount * 28);
712
+
713
+ mainFolders.forEach((mf, mfIdx) => {
714
+ const mfAngle = (mfIdx / Math.max(1, mfCount)) * Math.PI * 2 - Math.PI / 2;
715
+ const mfX = cx + Math.cos(mfAngle) * mfRadius;
716
+ const mfY = cy + Math.sin(mfAngle) * mfRadius;
717
+
718
+ const subMap = hierarchy.get(mf);
719
+ const subfolders = Array.from(subMap.keys());
720
+ const sfCount = subfolders.length;
721
+ const sfRadius = Math.max(110, Math.sqrt(sfCount) * 80);
722
+
723
+ subfolders.forEach((sf, sfIdx) => {
724
+ const sfAngle = (sfIdx / Math.max(1, sfCount)) * Math.PI * 2;
725
+ const sfX = mfX + Math.cos(sfAngle) * sfRadius;
726
+ const sfY = mfY + Math.sin(sfAngle) * sfRadius;
727
+
728
+ const fileMap = subMap.get(sf);
729
+ const files = Array.from(fileMap.keys());
730
+ const fileCount = files.length;
731
+ const fileRadius = Math.max(70, Math.sqrt(fileCount) * 60);
732
+
733
+ files.forEach((fid, fIdx) => {
734
+ const fAngle = (fIdx / Math.max(1, fileCount)) * Math.PI * 2;
735
+ const fileX = sfX + Math.cos(fAngle) * fileRadius;
736
+ const fileY = sfY + Math.sin(fAngle) * fileRadius;
737
+
738
+ const symNodes = fileMap.get(fid) || [];
739
+ const symCount = symNodes.length;
740
+ const symRadius = symCount <= 1 ? 0 : Math.max(45, 28 + symCount * 12);
741
+
742
+ symNodes.forEach((node, sIdx) => {
743
+ const sAngle = (sIdx / Math.max(1, symCount)) * Math.PI * 2;
744
+ targets.set(node.id, {
745
+ x: fileX + Math.cos(sAngle) * symRadius,
746
+ y: fileY + Math.sin(sAngle) * symRadius
747
+ });
748
+ });
749
+ });
750
+ });
751
+ });
752
+ } else {
753
+ const clusters = new Map();
754
+ this.nodes.forEach(n => {
755
+ const feat = n.feature || "root";
756
+ if (!clusters.has(feat)) clusters.set(feat, []);
757
+ clusters.get(feat).push(n);
758
+ });
759
+
760
+ const featKeys = Array.from(clusters.keys());
761
+ const clusterCount = featKeys.length;
762
+ const clusterRadius = Math.max(140, Math.min(w, h) * 0.38 + clusterCount * 12);
763
+
764
+ featKeys.forEach((feat, cIdx) => {
765
+ const clusterAngle = (cIdx / clusterCount) * Math.PI * 2 - Math.PI / 2;
766
+ const clusterX = cx + Math.cos(clusterAngle) * clusterRadius;
767
+ const clusterY = cy + Math.sin(clusterAngle) * clusterRadius;
768
+ const cNodes = clusters.get(feat);
769
+ const subCount = cNodes.length;
770
+ const subRadius = Math.max(50, Math.sqrt(subCount) * 30);
771
+
772
+ cNodes.forEach((node, sIdx) => {
773
+ const subAngle = (sIdx / subCount) * Math.PI * 2;
774
+ targets.set(node.id, {
775
+ x: clusterX + Math.cos(subAngle) * subRadius,
776
+ y: clusterY + Math.sin(subAngle) * subRadius
777
+ });
778
+ });
779
+ });
780
+ }
781
+ }
782
+
783
+ if (animate) {
784
+ this.animateToPositions(targets);
785
+ } else {
786
+ this.nodes.forEach(n => {
787
+ const t = targets.get(n.id);
788
+ if (t) {
789
+ n.x = t.x;
790
+ n.y = t.y;
791
+ n.vx = 0;
792
+ n.vy = 0;
793
+ }
794
+ });
795
+ this.fitToScreen();
796
+ }
797
+ }
798
+
799
+ animateToPositions(targets) {
800
+ this.animatingLayout = true;
801
+ const startPos = new Map(this.nodes.map(n => [n.id, { x: n.x, y: n.y }]));
802
+ const duration = 380;
803
+ const startTime = performance.now();
804
+
805
+ const step = now => {
806
+ const progress = Math.min(1, (now - startTime) / duration);
807
+ const ease = progress < 0.5 ? 4 * progress * progress * progress : 1 - Math.pow(-2 * progress + 2, 3) / 2;
808
+
809
+ this.nodes.forEach(n => {
810
+ const start = startPos.get(n.id);
811
+ const tgt = targets.get(n.id);
812
+ if (start && tgt) {
813
+ n.x = start.x + (tgt.x - start.x) * ease;
814
+ n.y = start.y + (tgt.y - start.y) * ease;
815
+ n.vx = 0;
816
+ n.vy = 0;
817
+ }
818
+ });
819
+
820
+ if (this.miniMap) this.miniMap.update();
821
+
822
+ if (progress < 1) {
823
+ requestAnimationFrame(step);
824
+ } else {
825
+ this.animatingLayout = false;
826
+ this.fitToScreen();
827
+ }
828
+ };
829
+ requestAnimationFrame(step);
830
+ }
831
+
832
+ isolateNeighborhood(target, maxDepth = 10) {
833
+ if (!target) return;
834
+ const nodeId = typeof target === "object" ? target.id : target;
835
+ if (!nodeId) return;
836
+
837
+ const reachable = new Map();
838
+ reachable.set(nodeId, { depth: 0, direction: "root" });
839
+
840
+ const forwardAdj = new Map();
841
+ const reverseAdj = new Map();
842
+
843
+ this.links.forEach(l => {
844
+ const s = typeof l.source === "object" ? l.source.id : l.source;
845
+ const t = typeof l.target === "object" ? l.target.id : l.target;
846
+ if (!s || !t) return;
847
+ if (!forwardAdj.has(s)) forwardAdj.set(s, []);
848
+ forwardAdj.get(s).push(t);
849
+
850
+ if (!reverseAdj.has(t)) reverseAdj.set(t, []);
851
+ reverseAdj.get(t).push(s);
852
+ });
853
+
854
+ const forwardQueue = [{ id: nodeId, depth: 0 }];
855
+ while (forwardQueue.length > 0) {
856
+ const curr = forwardQueue.shift();
857
+ if (curr.depth >= maxDepth) continue;
858
+ const neighbors = forwardAdj.get(curr.id) || [];
859
+ for (const next of neighbors) {
860
+ if (!reachable.has(next)) {
861
+ reachable.set(next, { depth: curr.depth + 1, direction: "downstream" });
862
+ forwardQueue.push({ id: next, depth: curr.depth + 1 });
863
+ }
864
+ }
865
+ }
866
+
867
+ const reverseQueue = [{ id: nodeId, depth: 0 }];
868
+ while (reverseQueue.length > 0) {
869
+ const curr = reverseQueue.shift();
870
+ if (curr.depth >= maxDepth) continue;
871
+ const neighbors = reverseAdj.get(curr.id) || [];
872
+ for (const next of neighbors) {
873
+ if (!reachable.has(next)) {
874
+ reachable.set(next, { depth: curr.depth + 1, direction: "upstream" });
875
+ reverseQueue.push({ id: next, depth: curr.depth + 1 });
876
+ }
877
+ }
878
+ }
879
+
880
+ this.focusedNeighborhood = reachable;
881
+ this.selectedNodeId = nodeId;
882
+ if (this.isMain) {
883
+ appState.focusedNeighborhood = reachable;
884
+ }
885
+ }
886
+
887
+ clearNeighborhoodIsolation() {
888
+ this.focusedNeighborhood = null;
889
+ this.selectedNodeId = null;
890
+ if (this.isMain) {
891
+ appState.focusedNeighborhood = null;
892
+ appState.selectedFileNodeId = null;
893
+ }
894
+ this.lastClickedNodeId = null;
895
+ }
896
+
897
+ smoothPanZoomTo(worldX, worldY, targetK = 1.8) {
898
+ const w = this.canvas.width / window.devicePixelRatio;
899
+ const h = this.canvas.height / window.devicePixelRatio;
900
+ const startX = this.transform.x;
901
+ const startY = this.transform.y;
902
+ const startK = this.transform.k;
903
+
904
+ const endX = w / 2 - worldX * targetK;
905
+ const endY = h / 2 - worldY * targetK;
906
+ const duration = 400;
907
+ const startTime = performance.now();
908
+
909
+ const step = now => {
910
+ const p = Math.min(1, (now - startTime) / duration);
911
+ const ease = 1 - Math.pow(1 - p, 3);
912
+ this.transform.x = startX + (endX - startX) * ease;
913
+ this.transform.y = startY + (endY - startY) * ease;
914
+ this.transform.k = startK + (targetK - startK) * ease;
915
+ if (this.miniMap) this.miniMap.update();
916
+ if (p < 1) requestAnimationFrame(step);
917
+ };
918
+ requestAnimationFrame(step);
919
+ }
920
+
921
+ updateHoverTooltip(node, screenX, screenY) {
922
+ let tooltip = document.getElementById("canvas-node-tooltip");
923
+ if (!tooltip) {
924
+ tooltip = document.createElement("div");
925
+ tooltip.id = "canvas-node-tooltip";
926
+ tooltip.className = "canvas-tooltip";
927
+ document.body.appendChild(tooltip);
928
+ }
929
+
930
+ if (!node) {
931
+ tooltip.style.display = "none";
932
+ return;
933
+ }
934
+
935
+ const callers = this.links.filter(l => l.target === node).length;
936
+ const callees = this.links.filter(l => l.source === node).length;
937
+
938
+ let extBadge = "";
939
+ if (node.isExternal) {
940
+ extBadge = `<div style="font-size:0.65rem; color:#c084fc; font-weight:700; margin-bottom:2px;">🌐 External Cross-File Node</div>`;
941
+ }
942
+
943
+ let kindBadge = "";
944
+ if (node.isLoneFile || node.kind === "file") {
945
+ kindBadge = `<span class="badge" style="background:#475569; color:#f1f5f9;">FILE</span>`;
946
+ } else if (node.kind) {
947
+ kindBadge = `<span class="badge" style="background:var(--accent); color:#ffffff;">${node.kind.toUpperCase()}</span>`;
948
+ }
949
+
950
+ tooltip.innerHTML = `
951
+ ${extBadge}
952
+ <div style="font-weight:700; color:var(--text-main); display:flex; align-items:center; gap:6px;">
953
+ <span>${node.label}</span>
954
+ ${kindBadge}
955
+ </div>
956
+ <div style="font-size:0.68rem; color:var(--text-muted); margin-bottom:4px;">${node.id}</div>
957
+ <div style="display:flex; gap:6px; font-size:0.68rem;">
958
+ <span class="badge ${node.layer || 'shared'}">${node.layer || 'other'}</span>
959
+ <span style="color:var(--text-dim);">In: ${callers} · Out: ${callees}</span>
960
+ </div>
961
+ ${node.isExternal && node.fileId ? `<div style="font-size:0.64rem; color:var(--accent); margin-top:4px;">Double-click to open ${node.fileId.split('/').pop()}</div>` : ''}
962
+ `;
963
+ if (typeof screenX === "number" && typeof screenY === "number") {
964
+ tooltip.style.left = `${screenX + 14}px`;
965
+ tooltip.style.top = `${screenY + 14}px`;
966
+ }
967
+ tooltip.style.display = "block";
968
+ }
969
+
970
+ hideHoverTooltip() {
971
+ const tooltip = document.getElementById("canvas-node-tooltip");
972
+ if (tooltip) tooltip.style.display = "none";
973
+ }
974
+
975
+ startLoop() {
976
+ this.isRunning = true;
977
+ const step = () => {
978
+ if (!this.isRunning) return;
979
+ this.pulsePhase = (this.pulsePhase + 0.04) % (Math.PI * 2);
980
+ if (!this.animatingLayout) {
981
+ if (this.isMain) {
982
+ if (appState.layoutMode === "force") this.updatePhysics();
983
+ } else {
984
+ this.updatePhysics();
985
+ }
986
+ }
987
+ this.render();
988
+ this.animFrameId = requestAnimationFrame(step);
989
+ };
990
+ this.animFrameId = requestAnimationFrame(step);
991
+ }
992
+
993
+ updatePhysics() {
994
+ if (this.alpha < 0.003 || !this.nodes || this.nodes.length === 0) return;
995
+ if (this.isMain && appState.layoutMode !== "force") return;
996
+ this.alpha *= 0.965;
997
+ if (this.alpha < 0.003) {
998
+ this.alpha = 0;
999
+ return;
1000
+ }
1001
+
1002
+ const count = this.nodes.length;
1003
+ const isSymMode = this.isMain && appState.graphMode === "symbols";
1004
+ const repulsionStrength = !this.isMain ? 650 : (Math.abs(this.charge || (isSymMode ? 340 : 250)) * (isSymMode ? 22 : 16));
1005
+ const maxPhysicsNodes = Math.min(count, 350);
1006
+
1007
+ // 1. Coulomb Repulsion with distance threshold & node capping for fluid 60FPS
1008
+ for (let i = 0; i < maxPhysicsNodes; i++) {
1009
+ const n1 = this.nodes[i];
1010
+ for (let j = i + 1; j < maxPhysicsNodes; j++) {
1011
+ const n2 = this.nodes[j];
1012
+ let dx = n2.x - n1.x;
1013
+ let dy = n2.y - n1.y;
1014
+ if (Math.abs(dx) > 600 || Math.abs(dy) > 600) continue;
1015
+ if (dx === 0 && dy === 0) {
1016
+ dx = (Math.random() - 0.5) * 2;
1017
+ dy = (Math.random() - 0.5) * 2;
1018
+ }
1019
+ const dist = Math.sqrt(dx * dx + dy * dy);
1020
+ if (dist > 600) continue;
1021
+ const distClamped = Math.max(10, dist);
1022
+
1023
+ const repForce = (repulsionStrength * this.alpha) / (distClamped * distClamped);
1024
+ const force = Math.min(35, repForce);
1025
+ const fx = (dx / distClamped) * force;
1026
+ const fy = (dy / distClamped) * force;
1027
+
1028
+ // Push n1 away from n2 (-fx), and push n2 away from n1 (+fx)
1029
+ if (!n1.fx) { n1.vx -= fx; n1.vy -= fy; }
1030
+ if (!n2.fx) { n2.vx += fx; n2.vy += fy; }
1031
+
1032
+ // Elastic anti-overlap separation
1033
+ const basePadding = !this.isMain ? 18 : (isSymMode ? 64 : 42);
1034
+ const minDistance = (n1.radius || 8) + (n2.radius || 8) + basePadding;
1035
+ if (dist < minDistance) {
1036
+ const overlap = minDistance - dist;
1037
+ const sepPush = overlap * 0.55 * Math.max(0.35, this.alpha);
1038
+ const sx = (dx / distClamped) * sepPush;
1039
+ const sy = (dy / distClamped) * sepPush;
1040
+ if (!n1.fx) { n1.vx -= sx; n1.vy -= sy; }
1041
+ if (!n2.fx) { n2.vx += sx; n2.vy += sy; }
1042
+ }
1043
+ }
1044
+ }
1045
+
1046
+ // 2. Link spring attraction
1047
+ this.links.forEach(l => {
1048
+ if (!l.source || !l.target) return;
1049
+ let dx = l.target.x - l.source.x;
1050
+ let dy = l.target.y - l.source.y;
1051
+ if (dx === 0 && dy === 0) {
1052
+ dx = (Math.random() - 0.5);
1053
+ dy = (Math.random() - 0.5);
1054
+ }
1055
+ const dist = Math.sqrt(dx * dx + dy * dy);
1056
+ const distClamped = Math.max(1, dist);
1057
+ const targetDist = !this.isMain ? (this.focusedNeighborhood ? 46 : 58) : (isSymMode ? 120 : 105);
1058
+ const rawForce = (distClamped - targetDist) * 0.02 * this.alpha;
1059
+ const force = Math.max(-18, Math.min(18, rawForce));
1060
+ const fx = (dx / distClamped) * force;
1061
+ const fy = (dy / distClamped) * force;
1062
+
1063
+ if (!l.source.fx) { l.source.vx += fx; l.source.vy += fy; }
1064
+ if (!l.target.fx) { l.target.vx -= fx; l.target.vy -= fy; }
1065
+ });
1066
+
1067
+ // 2b. Hierarchical cluster attraction in symbols mode (pulls symbols toward their file centroid)
1068
+ if (this.isMain && appState.graphMode === "symbols" && this.nodes.length > 0) {
1069
+ const fileCentroids = new Map();
1070
+ this.nodes.forEach(n => {
1071
+ const fid = n.fileId || n.folder || "root";
1072
+ if (!fileCentroids.has(fid)) {
1073
+ fileCentroids.set(fid, { sumX: 0, sumY: 0, count: 0 });
1074
+ }
1075
+ const c = fileCentroids.get(fid);
1076
+ c.sumX += n.x;
1077
+ c.sumY += n.y;
1078
+ c.count += 1;
1079
+ });
1080
+
1081
+ const clusterPull = 0.00018 * this.alpha;
1082
+ this.nodes.forEach(n => {
1083
+ if (n.fx) return;
1084
+ const fid = n.fileId || n.folder || "root";
1085
+ const c = fileCentroids.get(fid);
1086
+ if (c && c.count > 1) {
1087
+ const avgX = c.sumX / c.count;
1088
+ const avgY = c.sumY / c.count;
1089
+ n.vx += (avgX - n.x) * clusterPull;
1090
+ n.vy += (avgY - n.y) * clusterPull;
1091
+ }
1092
+ });
1093
+ }
1094
+
1095
+ // 3. Gentle Center Gravity & Velocity Integration
1096
+ const w = (this.canvas.width / window.devicePixelRatio) || this.canvas.clientWidth || window.innerWidth;
1097
+ const h = (this.canvas.height / window.devicePixelRatio) || this.canvas.clientHeight || window.innerHeight;
1098
+ const cx = w / 2;
1099
+ const cy = h / 2;
1100
+ const maxVelocity = 8;
1101
+ const gravityStrength = !this.isMain ? 0.0014 : (this.isMain ? 0.00015 : 0.00035);
1102
+
1103
+ this.nodes.forEach(n => {
1104
+ if (!n.fx) {
1105
+ n.vx += (cx - n.x) * gravityStrength * this.alpha;
1106
+ n.vy += (cy - n.y) * gravityStrength * this.alpha;
1107
+ n.vx *= 0.80;
1108
+ n.vy *= 0.80;
1109
+
1110
+ const v = Math.hypot(n.vx, n.vy);
1111
+ if (v > maxVelocity) {
1112
+ n.vx = (n.vx / v) * maxVelocity;
1113
+ n.vy = (n.vy / v) * maxVelocity;
1114
+ }
1115
+
1116
+ if (!isFinite(n.x) || !isFinite(n.y) || isNaN(n.x) || isNaN(n.y)) {
1117
+ n.x = cx + (Math.random() - 0.5) * 100;
1118
+ n.y = cy + (Math.random() - 0.5) * 100;
1119
+ n.vx = 0;
1120
+ n.vy = 0;
1121
+ } else {
1122
+ n.x += n.vx;
1123
+ n.y += n.vy;
1124
+ }
1125
+ }
1126
+ });
1127
+
1128
+ if (this.miniMap) {
1129
+ this.miniMap.update();
1130
+ }
1131
+ }
1132
+
1133
+ drawNodePath(n, radiusOffset = 0) {
1134
+ const kind = (n.kind || (n.isLoneFile ? "file" : "")).toLowerCase();
1135
+ let baseR = n.radius || 8;
1136
+ if (!this.isMain && baseR < 10) baseR = 10;
1137
+ const r = baseR + radiusOffset;
1138
+ this.ctx.beginPath();
1139
+
1140
+ if (kind === "class" || kind === "struct") {
1141
+ for (let i = 0; i < 6; i++) {
1142
+ const angle = (i * Math.PI) / 3 - Math.PI / 6;
1143
+ const px = n.x + r * Math.cos(angle);
1144
+ const py = n.y + r * Math.sin(angle);
1145
+ if (i === 0) this.ctx.moveTo(px, py);
1146
+ else this.ctx.lineTo(px, py);
1147
+ }
1148
+ this.ctx.closePath();
1149
+ } else if (kind === "method") {
1150
+ const hr = r * 1.15;
1151
+ this.ctx.moveTo(n.x, n.y - hr);
1152
+ this.ctx.lineTo(n.x + hr, n.y);
1153
+ this.ctx.lineTo(n.x, n.y + hr);
1154
+ this.ctx.lineTo(n.x - hr, n.y);
1155
+ this.ctx.closePath();
1156
+ } else if (kind === "interface" || kind === "enum" || kind === "type_alias" || kind === "type") {
1157
+ for (let i = 0; i < 8; i++) {
1158
+ const angle = (i * Math.PI) / 4 - Math.PI / 8;
1159
+ const px = n.x + r * Math.cos(angle);
1160
+ const py = n.y + r * Math.sin(angle);
1161
+ if (i === 0) this.ctx.moveTo(px, py);
1162
+ else this.ctx.lineTo(px, py);
1163
+ }
1164
+ this.ctx.closePath();
1165
+ } else if (kind === "file" || n.isLoneFile) {
1166
+ const w = r * 1.8;
1167
+ const h = r * 1.4;
1168
+ if (this.ctx.roundRect) {
1169
+ this.ctx.roundRect(n.x - w / 2, n.y - h / 2, w, h, 3);
1170
+ } else {
1171
+ this.ctx.rect(n.x - w / 2, n.y - h / 2, w, h);
1172
+ }
1173
+ this.ctx.closePath();
1174
+ } else if (kind === "variable" || kind === "constant" || kind === "property") {
1175
+ const vr = Math.max(3.5, r * 0.7);
1176
+ this.ctx.arc(n.x, n.y, vr, 0, Math.PI * 2);
1177
+ } else {
1178
+ this.ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
1179
+ }
1180
+ }
1181
+
1182
+ render() {
1183
+ const w = this.canvas.width / window.devicePixelRatio;
1184
+ const h = this.canvas.height / window.devicePixelRatio;
1185
+ this.ctx.clearRect(0, 0, w, h);
1186
+
1187
+ this.ctx.save();
1188
+ this.ctx.translate(this.transform.x, this.transform.y);
1189
+ this.ctx.scale(this.transform.k, this.transform.k);
1190
+
1191
+ const focused = this.isMain ? appState.focusedNeighborhood : this.focusedNeighborhood;
1192
+ const selNodeId = this.isMain ? appState.selectedFileNodeId : (this.selectedNodeId || (this.focusedNeighborhood ? this.focusedNeighborhood.keys().next().value : null));
1193
+ const unusedNodeIds = appState.unusedHighlightMode ? appState.getUnusedNodeIds() : null;
1194
+ const tracePath = appState.activeTracePath || [];
1195
+ const isTracePathEdge = (srcId, tgtId) => {
1196
+ for (let i = 0; i < tracePath.length - 1; i++) {
1197
+ if (tracePath[i] === srcId && tracePath[i + 1] === tgtId) return true;
1198
+ if (tracePath[i] === tgtId && tracePath[i + 1] === srcId) return true;
1199
+ }
1200
+ return false;
1201
+ };
1202
+
1203
+ // 1. Draw Cluster Boundaries if enabled
1204
+ if (appState.layoutMode === "clusters" && appState.clusterHullsMode) {
1205
+ this.renderClusterHulls();
1206
+ }
1207
+
1208
+ const themeColors = window.ThemeManager ? window.ThemeManager.getCanvasColors() : {
1209
+ nodeText: "#f1f5f9",
1210
+ nodeTextDim: "#94a3b8",
1211
+ nodeTextExternal: "#e9d5ff",
1212
+ nodeTextExternalFile: "#c084fc",
1213
+ nodeHoverRing: "#f8fafc",
1214
+ nodeSelectedRing: "#38bdf8",
1215
+ linkStroke: "rgba(71, 85, 105, 0.35)",
1216
+ linkStrokeDim: "rgba(71, 85, 105, 0.05)"
1217
+ };
1218
+
1219
+ this.links.forEach(l => {
1220
+ const isTrace = isTracePathEdge(l.source.id, l.target.id);
1221
+ const isHovered = this.hoverNode && (l.source === this.hoverNode || l.target === this.hoverNode);
1222
+ const srcFocus = focused ? focused.get(l.source.id) : null;
1223
+ const tgtFocus = focused ? focused.get(l.target.id) : null;
1224
+ const isConnectedToFocus = !!(srcFocus && tgtFocus);
1225
+
1226
+ let strokeColor = themeColors.linkStroke;
1227
+ let lineWidth = 1.0;
1228
+
1229
+ if (isTrace) {
1230
+ strokeColor = themeColors.nodeSelectedRing || "#38bdf8";
1231
+ lineWidth = 3.0;
1232
+ } else if (isHovered) {
1233
+ strokeColor = themeColors.nodeSelectedRing || "#38bdf8";
1234
+ lineWidth = 2.2;
1235
+ } else if (isConnectedToFocus) {
1236
+ const minDepth = Math.min(srcFocus.depth, tgtFocus.depth);
1237
+ lineWidth = minDepth === 0 ? 2.2 : 1.4;
1238
+ strokeColor = (srcFocus.direction === "upstream" || tgtFocus.direction === "upstream") ? (themeColors.nodeTextExternalFile || "#c084fc") : (themeColors.nodeSelectedRing || "#38bdf8");
1239
+ } else if (focused) {
1240
+ strokeColor = themeColors.linkStrokeDim;
1241
+ }
1242
+
1243
+ this.ctx.strokeStyle = strokeColor;
1244
+ this.ctx.lineWidth = lineWidth;
1245
+ this.ctx.beginPath();
1246
+ this.ctx.moveTo(l.source.x, l.source.y);
1247
+ this.ctx.lineTo(l.target.x, l.target.y);
1248
+ this.ctx.stroke();
1249
+
1250
+ const dx = l.target.x - l.source.x;
1251
+ const dy = l.target.y - l.source.y;
1252
+ const angle = Math.atan2(dy, dx);
1253
+ const headlen = 5;
1254
+ const targetX = l.target.x - Math.cos(angle) * (l.target.radius + 2);
1255
+ const targetY = l.target.y - Math.sin(angle) * (l.target.radius + 2);
1256
+
1257
+ this.ctx.fillStyle = strokeColor;
1258
+ this.ctx.beginPath();
1259
+ this.ctx.moveTo(targetX, targetY);
1260
+ this.ctx.lineTo(targetX - headlen * Math.cos(angle - Math.PI / 6), targetY - headlen * Math.sin(angle - Math.PI / 6));
1261
+ this.ctx.lineTo(targetX - headlen * Math.cos(angle + Math.PI / 6), targetY - headlen * Math.sin(angle + Math.PI / 6));
1262
+ this.ctx.fill();
1263
+
1264
+ if (isTrace) {
1265
+ const t = (Math.sin(this.pulsePhase) + 1) / 2;
1266
+ const px = l.source.x + (l.target.x - l.source.x) * t;
1267
+ const py = l.source.y + (l.target.y - l.source.y) * t;
1268
+ this.ctx.beginPath();
1269
+ this.ctx.arc(px, py, 3.5, 0, Math.PI * 2);
1270
+ this.ctx.fillStyle = "#ffffff";
1271
+ this.ctx.shadowColor = themeColors.nodeSelectedRing || "#38bdf8";
1272
+ this.ctx.shadowBlur = 8;
1273
+ this.ctx.fill();
1274
+ this.ctx.shadowBlur = 0;
1275
+ }
1276
+ });
1277
+
1278
+ this.nodes.forEach(n => {
1279
+ const isSelected = selNodeId && n.id === selNodeId;
1280
+ const isHovered = this.hoverNode === n;
1281
+ const isUnused = unusedNodeIds && unusedNodeIds.has(n.id);
1282
+ const focusInfo = focused ? focused.get(n.id) : null;
1283
+ const isInFocus = !focused || !!focusInfo;
1284
+ const isTraced = tracePath.includes(n.id);
1285
+
1286
+ let nodeColor;
1287
+ if (appState.colorMode === "feature") {
1288
+ nodeColor = appState.getFeatureColor(n.feature);
1289
+ } else {
1290
+ nodeColor = appState.LAYER_COLORS[n.layer] || "#0284c7";
1291
+ }
1292
+
1293
+ if (isUnused && !isSelected && !isTraced) {
1294
+ nodeColor = "#64748b";
1295
+ }
1296
+
1297
+ this.ctx.save();
1298
+ if (!isInFocus) {
1299
+ this.ctx.globalAlpha = 0.08;
1300
+ } else if (focused && focusInfo) {
1301
+ if (focusInfo.depth === 0) {
1302
+ this.ctx.globalAlpha = 1.0;
1303
+ } else {
1304
+ this.ctx.globalAlpha = Math.max(0.35, 1.0 - (focusInfo.depth - 1) * 0.15);
1305
+ }
1306
+ } else {
1307
+ this.ctx.globalAlpha = 1.0;
1308
+ }
1309
+
1310
+ this.drawNodePath(n, 0);
1311
+ this.ctx.fillStyle = nodeColor;
1312
+ this.ctx.fill();
1313
+
1314
+ if (n.isExternal) {
1315
+ this.ctx.save();
1316
+ this.ctx.setLineDash([3, 2]);
1317
+ this.drawNodePath(n, 3.5);
1318
+ this.ctx.strokeStyle = themeColors.nodeTextExternalFile || "#c084fc";
1319
+ this.ctx.lineWidth = 1.6;
1320
+ this.ctx.stroke();
1321
+ this.ctx.restore();
1322
+ }
1323
+
1324
+ if (isTraced) {
1325
+ this.drawNodePath(n, 5);
1326
+ this.ctx.strokeStyle = themeColors.nodeSelectedRing || "#38bdf8";
1327
+ this.ctx.lineWidth = 3;
1328
+ this.ctx.stroke();
1329
+ } else if (isSelected) {
1330
+ this.drawNodePath(n, 4);
1331
+ this.ctx.strokeStyle = themeColors.nodeSelectedRing || "#38bdf8";
1332
+ this.ctx.lineWidth = 2.5;
1333
+ this.ctx.stroke();
1334
+ } else if (isHovered) {
1335
+ this.drawNodePath(n, 2.5);
1336
+ this.ctx.strokeStyle = themeColors.nodeHoverRing || "#f8fafc";
1337
+ this.ctx.lineWidth = 1.5;
1338
+ this.ctx.stroke();
1339
+ }
1340
+
1341
+ if (this.transform.k > 0.45 || isHovered || isSelected || isTraced || isInFocus) {
1342
+ this.ctx.font = isSelected || isTraced ? "bold 11px sans-serif" : "10px sans-serif";
1343
+ const textColor = isUnused
1344
+ ? themeColors.nodeTextDim
1345
+ : (isSelected || isTraced
1346
+ ? themeColors.nodeSelectedRing
1347
+ : (n.isExternal ? themeColors.nodeTextExternal : themeColors.nodeText));
1348
+ this.ctx.fillStyle = textColor;
1349
+ this.ctx.textAlign = "center";
1350
+ this.ctx.fillText(n.label, n.x, n.y + n.radius + 12);
1351
+
1352
+ if (n.isExternal && n.fileId && (this.transform.k > 0.4 || isHovered || isSelected || isInFocus)) {
1353
+ const extFile = n.fileId.split("/").pop();
1354
+ this.ctx.font = "8px sans-serif";
1355
+ this.ctx.fillStyle = themeColors.nodeTextExternalFile || "#c084fc";
1356
+ this.ctx.fillText(`[${extFile}]`, n.x, n.y + n.radius + 22);
1357
+ }
1358
+ }
1359
+
1360
+ this.ctx.restore();
1361
+ });
1362
+
1363
+ this.ctx.restore();
1364
+ }
1365
+
1366
+ renderClusterHulls() {
1367
+ const clusters = new Map();
1368
+ const isSymbolsMode = this.isMain && appState.graphMode === "symbols";
1369
+
1370
+ this.nodes.forEach(n => {
1371
+ const clusterKey = isSymbolsMode ? (n.fileId || n.folder || "root") : (n.feature || "root");
1372
+ if (!clusters.has(clusterKey)) clusters.set(clusterKey, []);
1373
+ clusters.get(clusterKey).push(n);
1374
+ });
1375
+
1376
+ clusters.forEach((nodes, key) => {
1377
+ if (nodes.length < (isSymbolsMode ? 1 : 2)) return;
1378
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
1379
+ nodes.forEach(n => {
1380
+ if (n.x < minX) minX = n.x;
1381
+ if (n.x > maxX) maxX = n.x;
1382
+ if (n.y < minY) minY = n.y;
1383
+ if (n.y > maxY) maxY = n.y;
1384
+ });
1385
+
1386
+ const pad = isSymbolsMode ? 18 : 24;
1387
+ const sampleNode = nodes[0];
1388
+ const color = isSymbolsMode
1389
+ ? (sampleNode.layer ? (appState.LAYER_COLORS[sampleNode.layer] || "#38bdf8") : "#38bdf8")
1390
+ : appState.getFeatureColor(key);
1391
+ const label = isSymbolsMode ? key.split("/").pop() : key;
1392
+
1393
+ this.ctx.save();
1394
+ this.ctx.fillStyle = color;
1395
+ this.ctx.globalAlpha = 0.05;
1396
+ this.ctx.strokeStyle = color;
1397
+ this.ctx.lineWidth = 1;
1398
+ this.ctx.beginPath();
1399
+ this.ctx.roundRect(minX - pad, minY - pad, maxX - minX + pad * 2, maxY - minY + pad * 2, 10);
1400
+ this.ctx.fill();
1401
+ this.ctx.globalAlpha = 0.22;
1402
+ this.ctx.stroke();
1403
+
1404
+ this.ctx.globalAlpha = 0.7;
1405
+ this.ctx.font = "bold 9px sans-serif";
1406
+ this.ctx.fillStyle = color;
1407
+ this.ctx.textAlign = "left";
1408
+ this.ctx.fillText(label, minX - pad + 8, minY - pad + 14);
1409
+ this.ctx.restore();
1410
+ });
1411
+ }
1412
+
1413
+ zoomIn() {
1414
+ this.zoomBy(1.3);
1415
+ }
1416
+
1417
+ zoomOut() {
1418
+ this.zoomBy(0.75);
1419
+ }
1420
+
1421
+ zoomBy(factor) {
1422
+ const w = this.canvas.width / window.devicePixelRatio;
1423
+ const h = this.canvas.height / window.devicePixelRatio;
1424
+ const cx = w / 2;
1425
+ const cy = h / 2;
1426
+ const newK = Math.max(0.05, Math.min(10, this.transform.k * factor));
1427
+
1428
+ this.transform.x = cx - (cx - this.transform.x) * (newK / this.transform.k);
1429
+ this.transform.y = cy - (cy - this.transform.y) * (newK / this.transform.k);
1430
+ this.transform.k = newK;
1431
+ if (this.miniMap) this.miniMap.update();
1432
+ }
1433
+
1434
+ panTo(worldX, worldY) {
1435
+ const w = this.canvas.width / window.devicePixelRatio;
1436
+ const h = this.canvas.height / window.devicePixelRatio;
1437
+ this.transform.x = w / 2 - worldX * this.transform.k;
1438
+ this.transform.y = h / 2 - worldY * this.transform.k;
1439
+ if (this.miniMap) this.miniMap.update();
1440
+ }
1441
+
1442
+ resetView() {
1443
+ this.fitToScreen();
1444
+ }
1445
+
1446
+ fitToScreen() {
1447
+ if (!this.canvas) return;
1448
+ const validNodes = (this.nodes || []).filter(n => isFinite(n.x) && isFinite(n.y));
1449
+ if (validNodes.length === 0) {
1450
+ this.transform = { x: 0, y: 0, k: 1 };
1451
+ if (this.miniMap) this.miniMap.update();
1452
+ return;
1453
+ }
1454
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
1455
+ validNodes.forEach(n => {
1456
+ if (n.x < minX) minX = n.x;
1457
+ if (n.x > maxX) maxX = n.x;
1458
+ if (n.y < minY) minY = n.y;
1459
+ if (n.y > maxY) maxY = n.y;
1460
+ });
1461
+
1462
+ if (minX === Infinity || !isFinite(minX)) {
1463
+ minX = -100; maxX = 100; minY = -100; maxY = 100;
1464
+ }
1465
+
1466
+ const pad = 60;
1467
+ const spanX = Math.max(100, maxX - minX + pad * 2);
1468
+ const spanY = Math.max(100, maxY - minY + pad * 2);
1469
+
1470
+ const w = (this.canvas.width / window.devicePixelRatio) || this.canvas.clientWidth || window.innerWidth;
1471
+ const h = (this.canvas.height / window.devicePixelRatio) || this.canvas.clientHeight || window.innerHeight;
1472
+
1473
+ const k = Math.min(w / spanX, h / spanY, 1.8);
1474
+ const midX = (minX + maxX) / 2;
1475
+ const midY = (minY + maxY) / 2;
1476
+
1477
+ const minK = !this.isMain ? 0.45 : 0.15;
1478
+ this.transform.k = Math.max(minK, Math.min(1.8, isFinite(k) ? k : 1));
1479
+ this.transform.x = w / 2 - midX * this.transform.k;
1480
+ this.transform.y = h / 2 - midY * this.transform.k;
1481
+ if (this.miniMap) this.miniMap.update();
1482
+ }
1483
+ }
1484
+
1485
+ window.GraphCanvas = GraphCanvas;