esm-imports-analyzer 0.1.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 (48) hide show
  1. package/README.md +113 -0
  2. package/dist/analysis/cycle-detector.d.ts +3 -0
  3. package/dist/analysis/cycle-detector.d.ts.map +1 -0
  4. package/dist/analysis/cycle-detector.js +111 -0
  5. package/dist/analysis/cycle-detector.js.map +1 -0
  6. package/dist/analysis/folder-tree.d.ts +3 -0
  7. package/dist/analysis/folder-tree.d.ts.map +1 -0
  8. package/dist/analysis/folder-tree.js +141 -0
  9. package/dist/analysis/folder-tree.js.map +1 -0
  10. package/dist/analysis/grouper.d.ts +4 -0
  11. package/dist/analysis/grouper.d.ts.map +1 -0
  12. package/dist/analysis/grouper.js +156 -0
  13. package/dist/analysis/grouper.js.map +1 -0
  14. package/dist/analysis/timing.d.ts +9 -0
  15. package/dist/analysis/timing.d.ts.map +1 -0
  16. package/dist/analysis/timing.js +37 -0
  17. package/dist/analysis/timing.js.map +1 -0
  18. package/dist/analysis/tree-builder.d.ts +3 -0
  19. package/dist/analysis/tree-builder.d.ts.map +1 -0
  20. package/dist/analysis/tree-builder.js +47 -0
  21. package/dist/analysis/tree-builder.js.map +1 -0
  22. package/dist/cli.d.ts +3 -0
  23. package/dist/cli.d.ts.map +1 -0
  24. package/dist/cli.js +184 -0
  25. package/dist/cli.js.map +1 -0
  26. package/dist/loader/hooks.d.ts +4 -0
  27. package/dist/loader/hooks.d.ts.map +1 -0
  28. package/dist/loader/hooks.js +79 -0
  29. package/dist/loader/hooks.js.map +1 -0
  30. package/dist/loader/register.d.ts +2 -0
  31. package/dist/loader/register.d.ts.map +1 -0
  32. package/dist/loader/register.js +35 -0
  33. package/dist/loader/register.js.map +1 -0
  34. package/dist/report/generator.d.ts +3 -0
  35. package/dist/report/generator.d.ts.map +1 -0
  36. package/dist/report/generator.js +50 -0
  37. package/dist/report/generator.js.map +1 -0
  38. package/dist/report/template.html +146 -0
  39. package/dist/report/ui/cycles-panel.js +80 -0
  40. package/dist/report/ui/filters.js +13 -0
  41. package/dist/report/ui/graph.js +1310 -0
  42. package/dist/report/ui/styles.css +531 -0
  43. package/dist/report/ui/table.js +209 -0
  44. package/dist/types.d.ts +47 -0
  45. package/dist/types.d.ts.map +1 -0
  46. package/dist/types.js +2 -0
  47. package/dist/types.js.map +1 -0
  48. package/package.json +33 -0
@@ -0,0 +1,1310 @@
1
+ /* global cytoscape, document */
2
+
3
+ // Web Worker source that runs dagre layout off the main thread.
4
+ var DAGRE_WORKER_SRC = [
5
+ 'importScripts("https://unpkg.com/dagre@0.7.4/dist/dagre.js");',
6
+ '',
7
+ 'self.onmessage = function (e) {',
8
+ ' var msg = e.data;',
9
+ ' var g = new dagre.graphlib.Graph({ compound: true, multigraph: true });',
10
+ ' g.setGraph({',
11
+ ' rankdir: msg.rankDir || "TB",',
12
+ ' nodesep: msg.nodeSep || 60,',
13
+ ' edgesep: msg.edgeSep || 20,',
14
+ ' ranksep: msg.rankSep || 80,',
15
+ ' });',
16
+ ' g.setDefaultEdgeLabel(function () { return {}; });',
17
+ '',
18
+ ' for (var i = 0; i < msg.nodes.length; i++) {',
19
+ ' var n = msg.nodes[i];',
20
+ ' g.setNode(n.id, { width: n.width, height: n.height, label: n.id });',
21
+ ' if (n.parent) g.setParent(n.id, n.parent);',
22
+ ' }',
23
+ ' for (var j = 0; j < msg.edges.length; j++) {',
24
+ ' var ed = msg.edges[j];',
25
+ ' g.setEdge(ed.source, ed.target, {}, ed.id);',
26
+ ' }',
27
+ '',
28
+ ' dagre.layout(g);',
29
+ '',
30
+ ' var positions = {};',
31
+ ' g.nodes().forEach(function (id) {',
32
+ ' var nd = g.node(id);',
33
+ ' if (nd) positions[id] = { x: nd.x, y: nd.y };',
34
+ ' });',
35
+ ' self.postMessage(positions);',
36
+ '};',
37
+ ].join('\n');
38
+
39
+ var layoutOverlay = null;
40
+ var collapsedGroups = new Set();
41
+ var autoRelayout = true;
42
+
43
+ // Folder tree state
44
+ var groupFolderTrees = {}; // groupId -> FolderTreeNode[] (top-level children)
45
+ var folderState = {}; // folderNodeId -> { children: FolderTreeNode[], groupId: string }
46
+ var parentFolderOf = {}; // nodeId (file or folder) -> parent folderNodeId | null
47
+ var expandedFolders = new Set();
48
+ var suppressAutoSelect = false;
49
+ var cycleEdgeSet = new Set(); // Set of "source->target" edge IDs that are part of cycles
50
+ // Set of group IDs that have folder trees
51
+ var groupsWithFolderTree = new Set();
52
+
53
+ function clearSearch() {
54
+ var input = document.getElementById('search-input');
55
+ if (input && input.value) {
56
+ input.value = '';
57
+ input.dispatchEvent(new Event('input'));
58
+ }
59
+ }
60
+
61
+ function showOverlay() {
62
+ if (!layoutOverlay) layoutOverlay = document.getElementById('layout-overlay');
63
+ if (layoutOverlay) layoutOverlay.classList.remove('hidden');
64
+ }
65
+
66
+ function hideOverlay() {
67
+ if (!layoutOverlay) layoutOverlay = document.getElementById('layout-overlay');
68
+ if (layoutOverlay) layoutOverlay.classList.add('hidden');
69
+ }
70
+
71
+ function runLayout(cy, callback) {
72
+ showOverlay();
73
+
74
+ var nodes = [];
75
+ var edges = [];
76
+ var visibleNodeIds = new Set();
77
+ var expandedGroupIds = new Set();
78
+
79
+ cy.nodes(':visible').forEach(function (node) {
80
+ var isGroup = node.hasClass('group');
81
+ if (isGroup && !collapsedGroups.has(node.id())) {
82
+ expandedGroupIds.add(node.id());
83
+ visibleNodeIds.add(node.id());
84
+ return;
85
+ }
86
+ visibleNodeIds.add(node.id());
87
+ var parentId = null;
88
+ if (node.parent().length > 0 && expandedGroupIds.has(node.parent().id())) {
89
+ parentId = node.parent().id();
90
+ }
91
+ nodes.push({
92
+ id: node.id(),
93
+ width: node.outerWidth() || 60,
94
+ height: node.outerHeight() || 40,
95
+ parent: parentId,
96
+ });
97
+ });
98
+
99
+ expandedGroupIds.forEach(function (gid) {
100
+ nodes.push({ id: gid, width: 0, height: 0, parent: null });
101
+ });
102
+
103
+ cy.edges(':visible').forEach(function (edge) {
104
+ if (visibleNodeIds.has(edge.source().id()) && visibleNodeIds.has(edge.target().id())) {
105
+ edges.push({ id: edge.id(), source: edge.source().id(), target: edge.target().id() });
106
+ }
107
+ });
108
+
109
+ var blob = new Blob([DAGRE_WORKER_SRC], { type: 'application/javascript' });
110
+ var url = URL.createObjectURL(blob);
111
+ var worker = new Worker(url);
112
+
113
+ worker.onmessage = function (e) {
114
+ var positions = e.data;
115
+ cy.batch(function () {
116
+ cy.nodes().forEach(function (node) {
117
+ if (expandedGroupIds.has(node.id())) return;
118
+ var pos = positions[node.id()];
119
+ if (pos) node.position(pos);
120
+ });
121
+ });
122
+ cy.fit(undefined, 30);
123
+ hideOverlay();
124
+ worker.terminate();
125
+ URL.revokeObjectURL(url);
126
+ if (callback) callback();
127
+ };
128
+
129
+ worker.onerror = function () {
130
+ hideOverlay();
131
+ worker.terminate();
132
+ URL.revokeObjectURL(url);
133
+ if (callback) callback();
134
+ };
135
+
136
+ worker.postMessage({ nodes: nodes, edges: edges, rankDir: 'TB', nodeSep: 60, edgeSep: 20, rankSep: 80 });
137
+ }
138
+
139
+ function maybeRelayout(cy) {
140
+ if (autoRelayout && !suppressAutoSelect) runLayout(cy);
141
+ }
142
+
143
+ function maybeRelayoutWithCallback(cy, callback) {
144
+ if (autoRelayout) {
145
+ runLayout(cy, callback);
146
+ } else if (callback) {
147
+ callback();
148
+ }
149
+ }
150
+
151
+ // Resolve a module URL to its nearest visible ancestor node ID.
152
+ // Walks: module -> parent folder -> ... -> parent group
153
+ function resolveVisibleNode(cy, moduleURL) {
154
+ // Check if the module node itself is visible
155
+ var node = cy.getElementById(moduleURL);
156
+ if (node.length > 0 && node.visible()) return moduleURL;
157
+
158
+ // Walk up through parent folders
159
+ var folderId = parentFolderOf[moduleURL];
160
+ while (folderId) {
161
+ var folderNode = cy.getElementById(folderId);
162
+ if (folderNode.length > 0 && folderNode.visible()) return folderId;
163
+ folderId = parentFolderOf[folderId];
164
+ }
165
+
166
+ // Fall back to parent group
167
+ if (node.length > 0 && node.parent().length > 0) return node.parent().id();
168
+ return moduleURL;
169
+ }
170
+
171
+ function refreshEdgeVisibility(cy) {
172
+ cy.batch(function () {
173
+ cy.edges('.meta-edge').remove();
174
+
175
+ var metaEdges = {};
176
+
177
+ cy.edges().forEach(function (edge) {
178
+ var srcId = edge.source().id();
179
+ var tgtId = edge.target().id();
180
+
181
+ var effectiveSrc = resolveVisibleNode(cy, srcId);
182
+ var effectiveTgt = resolveVisibleNode(cy, tgtId);
183
+
184
+ // Both resolved to themselves = both visible, show the real edge
185
+ if (effectiveSrc === srcId && effectiveTgt === tgtId) {
186
+ edge.show();
187
+ return;
188
+ }
189
+
190
+ // At least one resolved to a different node — hide real edge, collect meta-edge
191
+ edge.hide();
192
+
193
+ if (effectiveSrc === effectiveTgt) return;
194
+
195
+ var key = effectiveSrc + '||' + effectiveTgt;
196
+ if (!metaEdges[key]) {
197
+ metaEdges[key] = { source: effectiveSrc, target: effectiveTgt, count: 0 };
198
+ }
199
+ metaEdges[key].count++;
200
+ });
201
+
202
+ var toAdd = [];
203
+ var keys = Object.keys(metaEdges);
204
+ for (var i = 0; i < keys.length; i++) {
205
+ var me = metaEdges[keys[i]];
206
+ toAdd.push({
207
+ group: 'edges',
208
+ data: {
209
+ id: 'meta-' + keys[i],
210
+ source: me.source,
211
+ target: me.target,
212
+ specifier: me.count + (me.count === 1 ? ' import' : ' imports'),
213
+ },
214
+ classes: 'meta-edge',
215
+ });
216
+ }
217
+ if (toAdd.length > 0) cy.add(toAdd);
218
+ });
219
+ }
220
+
221
+ function selectAndHighlight(cy, node) {
222
+ cy.nodes().unselect();
223
+ node.select();
224
+ applySelectionHighlight(cy);
225
+ }
226
+
227
+ // Expand a group: show top-level folder tree children (or all children if no tree)
228
+ function expandGroup(cy, groupNode) {
229
+ collapsedGroups.delete(groupNode.id());
230
+ groupNode.removeClass('collapsed');
231
+
232
+ var gid = groupNode.data('groupId');
233
+ var tree = groupFolderTrees[gid];
234
+
235
+ cy.batch(function () {
236
+ if (tree && tree.length > 0) {
237
+ // Show only top-level folder tree children
238
+ for (var i = 0; i < tree.length; i++) {
239
+ cy.getElementById(tree[i].id).show();
240
+ }
241
+ } else {
242
+ // No folder tree — show all children flat
243
+ groupNode.children().show();
244
+ }
245
+ });
246
+
247
+ refreshEdgeVisibility(cy);
248
+ maybeRelayout(cy);
249
+ if (!suppressAutoSelect) selectAndHighlight(cy, groupNode);
250
+ }
251
+
252
+ // Collapse a group: hide ALL children, reset folder expansion state
253
+ function collapseGroup(cy, groupNode) {
254
+ collapsedGroups.add(groupNode.id());
255
+ groupNode.addClass('collapsed');
256
+
257
+ var gid = groupNode.data('groupId');
258
+
259
+ cy.batch(function () {
260
+ groupNode.children().hide();
261
+ });
262
+
263
+ // Reset folder expansion state for this group
264
+ var toDelete = [];
265
+ expandedFolders.forEach(function (fid) {
266
+ var fs = folderState[fid];
267
+ if (fs && fs.groupId === gid) toDelete.push(fid);
268
+ });
269
+ for (var i = 0; i < toDelete.length; i++) {
270
+ expandedFolders.delete(toDelete[i]);
271
+ }
272
+
273
+ refreshEdgeVisibility(cy);
274
+ maybeRelayout(cy);
275
+ if (!suppressAutoSelect) selectAndHighlight(cy, groupNode);
276
+ }
277
+
278
+ // Expand a folder: hide the folder node, show its children
279
+ function expandFolder(cy, folderNodeId) {
280
+ var state = folderState[folderNodeId];
281
+ if (!state) return;
282
+
283
+ expandedFolders.add(folderNodeId);
284
+
285
+ var childIds = [];
286
+ cy.batch(function () {
287
+ cy.getElementById(folderNodeId).hide();
288
+ for (var i = 0; i < state.children.length; i++) {
289
+ var childId = state.children[i].id;
290
+ cy.getElementById(childId).show();
291
+ childIds.push(childId);
292
+ }
293
+ });
294
+
295
+ refreshEdgeVisibility(cy);
296
+ maybeRelayout(cy);
297
+
298
+ if (!suppressAutoSelect) {
299
+ // Select the newly revealed children
300
+ cy.nodes().unselect();
301
+ for (var j = 0; j < childIds.length; j++) {
302
+ cy.getElementById(childIds[j]).select();
303
+ }
304
+ applySelectionHighlight(cy);
305
+ }
306
+ }
307
+
308
+ // Ensure a module node is visible by expanding its group and ancestor folders
309
+ function revealModule(cy, moduleURL) {
310
+ var node = cy.getElementById(moduleURL);
311
+ if (node.length === 0) return;
312
+
313
+ // Expand parent group if collapsed
314
+ if (node.parent().length > 0 && collapsedGroups.has(node.parent().id())) {
315
+ expandGroup(cy, node.parent());
316
+ }
317
+
318
+ // Collect ancestor folders that need expanding (outermost first)
319
+ var chain = [];
320
+ var fid = parentFolderOf[moduleURL];
321
+ while (fid) {
322
+ if (!expandedFolders.has(fid)) chain.push(fid);
323
+ fid = parentFolderOf[fid];
324
+ }
325
+ chain.reverse();
326
+ for (var i = 0; i < chain.length; i++) {
327
+ expandFolder(cy, chain[i]);
328
+ }
329
+ }
330
+
331
+ function collapseAll(cy) {
332
+ cy.nodes('.group').forEach(function (groupNode) {
333
+ if (groupNode.data('moduleCount') <= 1) return;
334
+ collapsedGroups.add(groupNode.id());
335
+ groupNode.addClass('collapsed');
336
+ });
337
+ expandedFolders.clear();
338
+ cy.batch(function () {
339
+ cy.nodes('.group').forEach(function (groupNode) {
340
+ if (groupNode.data('moduleCount') <= 1) return;
341
+ groupNode.children().hide();
342
+ });
343
+ });
344
+ refreshEdgeVisibility(cy);
345
+ }
346
+
347
+ function expandAll(cy) {
348
+ cy.batch(function () {
349
+ // Expand all groups
350
+ cy.nodes('.group').forEach(function (groupNode) {
351
+ collapsedGroups.delete(groupNode.id());
352
+ groupNode.removeClass('collapsed');
353
+ });
354
+
355
+ // Expand all folders: hide folder nodes, show their children
356
+ var changed = true;
357
+ while (changed) {
358
+ changed = false;
359
+ cy.nodes('.folder:visible').forEach(function (fNode) {
360
+ var state = folderState[fNode.id()];
361
+ if (state && !expandedFolders.has(fNode.id())) {
362
+ expandedFolders.add(fNode.id());
363
+ fNode.hide();
364
+ for (var i = 0; i < state.children.length; i++) {
365
+ cy.getElementById(state.children[i].id).show();
366
+ }
367
+ changed = true;
368
+ }
369
+ });
370
+ }
371
+
372
+ // Show any remaining module nodes that might still be hidden
373
+ cy.nodes('.module').forEach(function (node) {
374
+ node.show();
375
+ });
376
+ });
377
+
378
+ refreshEdgeVisibility(cy);
379
+ }
380
+
381
+ // --- Directional selection highlighting ---
382
+ var HL_CLASSES = ['hl-selected', 'hl-outgoing', 'hl-incoming', 'hl-cycle', 'dimmed'];
383
+
384
+ function clearSelectionHighlight(cy) {
385
+ for (var i = 0; i < HL_CLASSES.length; i++) {
386
+ cy.elements().removeClass(HL_CLASSES[i]);
387
+ }
388
+ }
389
+
390
+ function applySelectionHighlight(cy) {
391
+ var selected = cy.nodes(':selected');
392
+ if (selected.length === 0) {
393
+ clearSelectionHighlight(cy);
394
+ return;
395
+ }
396
+
397
+ for (var i = 0; i < HL_CLASSES.length; i++) {
398
+ cy.elements().removeClass(HL_CLASSES[i]);
399
+ }
400
+
401
+ var highlighted = cy.collection();
402
+
403
+ function isSelectedOrParent(n) {
404
+ return n.hasClass('hl-selected');
405
+ }
406
+
407
+ function traceEdges(node) {
408
+ // Outgoing edges from this node
409
+ var outEdges = node.outgoers('edge:visible');
410
+ outEdges.addClass('hl-outgoing');
411
+ highlighted = highlighted.union(outEdges);
412
+ outEdges.targets().forEach(function (t) {
413
+ if (!isSelectedOrParent(t)) t.addClass('hl-outgoing');
414
+ highlighted = highlighted.union(t);
415
+ });
416
+
417
+ // Incoming edges to this node
418
+ var inEdges = node.incomers('edge:visible');
419
+ inEdges.addClass('hl-incoming');
420
+ highlighted = highlighted.union(inEdges);
421
+ inEdges.sources().forEach(function (s) {
422
+ if (!isSelectedOrParent(s) && !s.hasClass('hl-outgoing')) s.addClass('hl-incoming');
423
+ highlighted = highlighted.union(s);
424
+ });
425
+
426
+ // Follow meta-edges for group and folder nodes
427
+ if (node.hasClass('group') || node.hasClass('folder')) {
428
+ node.connectedEdges('.meta-edge:visible').forEach(function (me) {
429
+ var isOut = me.source().id() === node.id();
430
+ if (isOut) {
431
+ me.addClass('hl-outgoing');
432
+ if (!isSelectedOrParent(me.target())) me.target().addClass('hl-outgoing');
433
+ } else {
434
+ me.addClass('hl-incoming');
435
+ if (!isSelectedOrParent(me.source())) me.source().addClass('hl-incoming');
436
+ }
437
+ highlighted = highlighted.union(me).union(me.source()).union(me.target());
438
+ });
439
+ }
440
+ }
441
+
442
+ selected.forEach(function (node) {
443
+ var isExpandedGroup = node.hasClass('group') && !collapsedGroups.has(node.id());
444
+
445
+ node.addClass('hl-selected');
446
+ highlighted = highlighted.union(node);
447
+
448
+ // Trace direct edges from this node
449
+ traceEdges(node);
450
+
451
+ if (isExpandedGroup) {
452
+ // Include all visible children and their internal edges
453
+ var children = node.children(':visible');
454
+ children.forEach(function (child) {
455
+ highlighted = highlighted.union(child);
456
+ });
457
+ children.connectedEdges(':visible').forEach(function (e) {
458
+ highlighted = highlighted.union(e);
459
+ });
460
+
461
+ // Also trace outgoing/incoming from each visible child to external nodes
462
+ children.forEach(function (child) {
463
+ traceEdges(child);
464
+ });
465
+ }
466
+ });
467
+
468
+ // Mark cycle edges among the highlighted edges
469
+ highlighted.edges().forEach(function (edge) {
470
+ if (cycleEdgeSet.has(edge.id())) {
471
+ edge.addClass('hl-cycle');
472
+ }
473
+ });
474
+
475
+ cy.elements().not(highlighted).addClass('dimmed');
476
+ highlighted.forEach(function (ele) {
477
+ if (ele.isNode() && ele.parent().length > 0) {
478
+ ele.parent().removeClass('dimmed');
479
+ }
480
+ });
481
+ }
482
+
483
+ // --- Build folder tree elements ---
484
+ // Walk a FolderTreeNode[] recursively and create cytoscape elements for all
485
+ // folders and files. All start hidden; expand logic controls visibility.
486
+ function buildFolderElements(elements, treeNodes, groupNodeId, groupId, parentFolderId) {
487
+ for (var i = 0; i < treeNodes.length; i++) {
488
+ var tn = treeNodes[i];
489
+ if (tn.type === 'folder') {
490
+ elements.push({
491
+ group: 'nodes',
492
+ data: {
493
+ id: tn.id,
494
+ label: tn.label,
495
+ parent: groupNodeId,
496
+ isFolder: true,
497
+ groupId: groupId,
498
+ },
499
+ classes: 'folder',
500
+ });
501
+ folderState[tn.id] = { children: tn.children, groupId: groupId };
502
+ parentFolderOf[tn.id] = parentFolderId;
503
+ buildFolderElements(elements, tn.children, groupNodeId, groupId, tn.id);
504
+ } else {
505
+ // File node — it may already exist from the module creation loop.
506
+ // We record its parentFolder mapping regardless.
507
+ parentFolderOf[tn.moduleURL || tn.id] = parentFolderId;
508
+ }
509
+ }
510
+ }
511
+
512
+ function initGraph(data) {
513
+ var container = document.getElementById('cy');
514
+ var tooltip = document.getElementById('graph-tooltip');
515
+
516
+ var elements = [];
517
+ var moduleSet = new Set();
518
+ var groupMap = new Map();
519
+
520
+ for (var gi = 0; gi < data.groups.length; gi++) {
521
+ var group = data.groups[gi];
522
+ for (var gmi = 0; gmi < group.modules.length; gmi++) {
523
+ groupMap.set(group.modules[gmi], group);
524
+ }
525
+ if (group.folderTree && group.folderTree.length > 0) {
526
+ groupFolderTrees[group.id] = group.folderTree;
527
+ groupsWithFolderTree.add(group.id);
528
+ }
529
+ }
530
+
531
+
532
+ // Add group (compound) nodes
533
+ for (var gni = 0; gni < data.groups.length; gni++) {
534
+ var grp = data.groups[gni];
535
+ var groupNodeId = 'group-' + grp.id;
536
+ elements.push({
537
+ group: 'nodes',
538
+ data: {
539
+ id: groupNodeId,
540
+ label: grp.label + ' (' + grp.modules.length + ' modules)',
541
+ isGroup: true,
542
+ groupId: grp.id,
543
+ moduleCount: grp.modules.length,
544
+ },
545
+ classes: grp.isNodeModules ? 'group node-modules' : 'group',
546
+ });
547
+
548
+ // Build folder tree elements for this group
549
+ if (grp.folderTree && grp.folderTree.length > 0) {
550
+ buildFolderElements(elements, grp.folderTree, groupNodeId, grp.id, null);
551
+ }
552
+ }
553
+
554
+ // Build a set of module URLs that appear in folder trees as files
555
+ // (to set their label from the tree instead of the specifier)
556
+ var folderTreeFileLabels = {};
557
+ function collectFileLabels(treeNodes) {
558
+ for (var i = 0; i < treeNodes.length; i++) {
559
+ var tn = treeNodes[i];
560
+ if (tn.type === 'file' && tn.moduleURL) {
561
+ folderTreeFileLabels[tn.moduleURL] = tn.label;
562
+ } else if (tn.children) {
563
+ collectFileLabels(tn.children);
564
+ }
565
+ }
566
+ }
567
+ for (var gli = 0; gli < data.groups.length; gli++) {
568
+ if (data.groups[gli].folderTree) collectFileLabels(data.groups[gli].folderTree);
569
+ }
570
+
571
+ // Build parent lookup from recorded data (first occurrence per URL)
572
+ var parentByURL = {};
573
+ for (var pbi = 0; pbi < data.modules.length; pbi++) {
574
+ var pm = data.modules[pbi];
575
+ if (!(pm.resolvedURL in parentByURL)) {
576
+ parentByURL[pm.resolvedURL] = pm.parentURL || null;
577
+ }
578
+ }
579
+
580
+ function getImportPath(moduleURL) {
581
+ var chain = [moduleURL];
582
+ var current = moduleURL;
583
+ var visited = {};
584
+ visited[current] = true;
585
+ while (parentByURL[current]) {
586
+ current = parentByURL[current];
587
+ if (visited[current]) break; // avoid infinite loop on cycles
588
+ visited[current] = true;
589
+ chain.push(current);
590
+ }
591
+ chain.reverse();
592
+ return chain.map(function (u) {
593
+ return u.startsWith('file://') ? u.slice(7) : u;
594
+ }).join(' -> ');
595
+ }
596
+
597
+ function getModuleURLsForNode(node) {
598
+ var urls = [];
599
+ if (node.hasClass('module')) {
600
+ urls.push(node.id());
601
+ } else if (node.hasClass('group')) {
602
+ node.descendants('.module').forEach(function (m) { urls.push(m.id()); });
603
+ // Also include hidden modules from collapsed groups
604
+ var gid = node.data('groupId');
605
+ var grp = null;
606
+ for (var i = 0; i < data.groups.length; i++) {
607
+ if (data.groups[i].id === gid) { grp = data.groups[i]; break; }
608
+ }
609
+ if (grp) {
610
+ for (var j = 0; j < grp.modules.length; j++) {
611
+ if (urls.indexOf(grp.modules[j]) === -1) urls.push(grp.modules[j]);
612
+ }
613
+ }
614
+ } else if (node.hasClass('folder')) {
615
+ (function collectFolderModules(fid) {
616
+ var state = folderState[fid];
617
+ if (!state) return;
618
+ for (var i = 0; i < state.children.length; i++) {
619
+ var child = state.children[i];
620
+ if (child.type === 'file' && child.moduleURL) {
621
+ urls.push(child.moduleURL);
622
+ } else if (child.type === 'folder') {
623
+ collectFolderModules(child.id);
624
+ }
625
+ }
626
+ })(node.id());
627
+ }
628
+ return urls;
629
+ }
630
+
631
+ // Track unique modules (first occurrence)
632
+ var seenModules = new Set();
633
+ for (var mni = 0; mni < data.modules.length; mni++) {
634
+ var mod = data.modules[mni];
635
+ if (seenModules.has(mod.resolvedURL)) continue;
636
+ seenModules.add(mod.resolvedURL);
637
+
638
+ var modGroup = groupMap.get(mod.resolvedURL);
639
+ var totalTime = mod.totalImportTime || 0;
640
+ var isBuiltin = mod.resolvedURL.startsWith('node:');
641
+
642
+ // Use folder tree label if available
643
+ var label = folderTreeFileLabels[mod.resolvedURL] || mod.specifier;
644
+
645
+ elements.push({
646
+ group: 'nodes',
647
+ data: {
648
+ id: mod.resolvedURL,
649
+ label: label,
650
+ parent: modGroup ? 'group-' + modGroup.id : undefined,
651
+ totalTime: totalTime,
652
+ fullPath: mod.resolvedURL,
653
+ isBuiltin: isBuiltin,
654
+ },
655
+ classes: isBuiltin ? 'module builtin' : 'module',
656
+ });
657
+ moduleSet.add(mod.resolvedURL);
658
+ }
659
+
660
+ // Add edges (deduplicate)
661
+ var edgeSet = new Set();
662
+ for (var eni = 0; eni < data.modules.length; eni++) {
663
+ var eMod = data.modules[eni];
664
+ if (eMod.parentURL && moduleSet.has(eMod.parentURL) && moduleSet.has(eMod.resolvedURL)) {
665
+ var edgeId = eMod.parentURL + '->' + eMod.resolvedURL;
666
+ if (edgeSet.has(edgeId)) continue;
667
+ edgeSet.add(edgeId);
668
+
669
+ elements.push({
670
+ group: 'edges',
671
+ data: {
672
+ id: edgeId,
673
+ source: eMod.parentURL,
674
+ target: eMod.resolvedURL,
675
+ specifier: eMod.specifier,
676
+ },
677
+ });
678
+ }
679
+ }
680
+
681
+ // Build cycle edge lookup
682
+ cycleEdgeSet.clear();
683
+ if (data.cycles) {
684
+ for (var ci = 0; ci < data.cycles.length; ci++) {
685
+ var cycle = data.cycles[ci];
686
+ for (var cj = 0; cj < cycle.modules.length; cj++) {
687
+ var nextIdx = (cj + 1) % cycle.modules.length;
688
+ cycleEdgeSet.add(cycle.modules[cj] + '->' + cycle.modules[nextIdx]);
689
+ }
690
+ }
691
+ }
692
+
693
+ var cy = cytoscape({
694
+ container: container,
695
+ elements: elements,
696
+ style: [
697
+ {
698
+ selector: 'node.group',
699
+ style: {
700
+ 'background-opacity': 0.75,
701
+ 'background-color': '#313147',
702
+ 'border-color': '#45475a',
703
+ 'border-width': 1,
704
+ 'label': 'data(label)',
705
+ 'text-valign': 'center',
706
+ 'text-halign': 'center',
707
+ 'font-size': '11px',
708
+ 'color': '#a6adc8',
709
+ 'padding': '24px',
710
+ 'shape': 'round-rectangle',
711
+ 'min-width': '80px',
712
+ 'min-height': '30px',
713
+ },
714
+ },
715
+ {
716
+ selector: 'node.group:parent',
717
+ style: {
718
+ 'text-valign': 'top',
719
+ 'text-margin-y': '-4px',
720
+ },
721
+ },
722
+ {
723
+ selector: 'node.group.collapsed',
724
+ style: {
725
+ 'text-valign': 'center',
726
+ 'text-halign': 'center',
727
+ 'border-width': 2,
728
+ 'border-color': '#585b70',
729
+ 'width': function (ele) {
730
+ var label = ele.data('label') || '';
731
+ return Math.max(80, label.length * 6.5 + 24);
732
+ },
733
+ },
734
+ },
735
+ {
736
+ selector: 'node.group.node-modules',
737
+ style: {
738
+ 'background-color': '#282839',
739
+ 'border-style': 'dashed',
740
+ },
741
+ },
742
+ {
743
+ selector: 'node.folder',
744
+ style: {
745
+ 'shape': 'round-rectangle',
746
+ 'background-color': '#3b3b55',
747
+ 'border-color': '#585b70',
748
+ 'border-width': 1,
749
+ 'label': 'data(label)',
750
+ 'font-size': '10px',
751
+ 'color': '#a6adc8',
752
+ 'text-valign': 'center',
753
+ 'text-halign': 'center',
754
+ 'width': '80px',
755
+ 'height': '30px',
756
+ },
757
+ },
758
+ {
759
+ selector: 'node.module',
760
+ style: {
761
+ 'label': 'data(label)',
762
+ 'font-size': '10px',
763
+ 'color': '#cdd6f4',
764
+ 'text-valign': 'bottom',
765
+ 'text-halign': 'center',
766
+ 'text-margin-y': '4px',
767
+ 'width': 24,
768
+ 'height': 24,
769
+ 'background-color': '#89b4fa',
770
+ 'border-width': 1,
771
+ 'border-color': '#45475a',
772
+ },
773
+ },
774
+ {
775
+ selector: 'node.builtin',
776
+ style: {
777
+ 'background-color': '#45475a',
778
+ 'color': '#6c7086',
779
+ },
780
+ },
781
+ {
782
+ selector: 'edge',
783
+ style: {
784
+ 'width': 1,
785
+ 'line-color': '#585b70',
786
+ 'target-arrow-color': '#585b70',
787
+ 'target-arrow-shape': 'triangle',
788
+ 'curve-style': 'taxi',
789
+ 'taxi-direction': 'downward',
790
+ 'taxi-turn': '20px',
791
+ 'arrow-scale': 0.7,
792
+ },
793
+ },
794
+ {
795
+ selector: 'edge.meta-edge',
796
+ style: {
797
+ 'width': 2,
798
+ 'line-color': '#7f849c',
799
+ 'target-arrow-color': '#7f849c',
800
+ 'target-arrow-shape': 'triangle',
801
+ 'curve-style': 'bezier',
802
+ 'arrow-scale': 0.8,
803
+ 'label': 'data(specifier)',
804
+ 'font-size': '9px',
805
+ 'color': '#6c7086',
806
+ 'text-rotation': 'autorotate',
807
+ 'text-margin-y': '-8px',
808
+ },
809
+ },
810
+ {
811
+ selector: 'edge.cycle-highlight',
812
+ style: {
813
+ 'line-color': '#fab387',
814
+ 'target-arrow-color': '#fab387',
815
+ 'width': 3,
816
+ 'z-index': 10,
817
+ 'curve-style': 'bezier',
818
+ },
819
+ },
820
+ {
821
+ selector: 'node.cycle-highlight',
822
+ style: { 'border-color': '#fab387', 'border-width': 3 },
823
+ },
824
+ {
825
+ selector: 'node.hl-selected',
826
+ style: { 'border-color': '#cba6f7', 'border-width': 3 },
827
+ },
828
+ {
829
+ selector: 'node.hl-outgoing',
830
+ style: { 'border-color': '#89b4fa', 'border-width': 3 },
831
+ },
832
+ {
833
+ selector: 'edge.hl-outgoing',
834
+ style: { 'line-color': '#89b4fa', 'target-arrow-color': '#89b4fa', 'width': 2, 'z-index': 5 },
835
+ },
836
+ {
837
+ selector: 'node.hl-incoming',
838
+ style: { 'border-color': '#a6e3a1', 'border-width': 3 },
839
+ },
840
+ {
841
+ selector: 'edge.hl-incoming',
842
+ style: { 'line-color': '#a6e3a1', 'target-arrow-color': '#a6e3a1', 'width': 2, 'z-index': 5 },
843
+ },
844
+ {
845
+ selector: 'edge.hl-cycle',
846
+ style: { 'line-color': '#f9e2af', 'target-arrow-color': '#f9e2af', 'width': 2, 'z-index': 6 },
847
+ },
848
+ {
849
+ selector: 'node.dimmed',
850
+ style: { 'opacity': 0.2 },
851
+ },
852
+ {
853
+ selector: 'edge.dimmed',
854
+ style: { 'opacity': 0.08 },
855
+ },
856
+ {
857
+ selector: ':selected',
858
+ style: { 'border-color': '#cba6f7', 'border-width': 3 },
859
+ },
860
+ ],
861
+ layout: { name: 'preset' },
862
+ minZoom: 0.05,
863
+ maxZoom: 5,
864
+ textureOnViewport: false,
865
+ pixelRatio: 'auto',
866
+ });
867
+
868
+ // Collapse all groups then run initial layout
869
+ collapseAll(cy);
870
+ runLayout(cy);
871
+
872
+ // Double-click: expand/collapse groups, expand folders, zoom into modules
873
+ cy.on('dbltap', 'node', function (e) {
874
+ var node = e.target;
875
+ if (node.hasClass('group')) {
876
+ if (node.data('moduleCount') <= 1) return;
877
+ if (collapsedGroups.has(node.id())) {
878
+ expandGroup(cy, node);
879
+ } else {
880
+ collapseGroup(cy, node);
881
+ }
882
+ } else if (node.hasClass('folder')) {
883
+ expandFolder(cy, node.id());
884
+ } else if (node.hasClass('module')) {
885
+ cy.animate({ center: { eles: node }, zoom: 1.2, duration: 300 });
886
+ }
887
+ });
888
+
889
+ // Tooltip handling
890
+ cy.on('mouseover', 'node.module', function (e) {
891
+ var d = e.target.data();
892
+ var timeStr = d.totalTime ? d.totalTime.toFixed(2) + ' ms' : '\u2014';
893
+ tooltip.innerHTML =
894
+ '<div class="tooltip-path">' + escapeHtml(d.fullPath) + '</div>' +
895
+ '<div class="tooltip-time">' + timeStr + '</div>';
896
+ tooltip.style.display = 'block';
897
+ });
898
+
899
+ cy.on('mouseover', 'node.folder', function (e) {
900
+ tooltip.innerHTML = '<div class="tooltip-path">' + escapeHtml(e.target.data('label')) + '</div>';
901
+ tooltip.style.display = 'block';
902
+ });
903
+
904
+ cy.on('mousemove', 'node.module, node.folder', function (e) {
905
+ var pos = e.renderedPosition || e.position;
906
+ tooltip.style.left = (pos.x + 16) + 'px';
907
+ tooltip.style.top = (pos.y + 16) + 'px';
908
+ });
909
+
910
+ cy.on('mouseout', 'node.module, node.folder', function () {
911
+ tooltip.style.display = 'none';
912
+ });
913
+
914
+ // Save selection on mousedown (before Cytoscape auto-selects)
915
+ var preTapSelectedIds = {};
916
+ cy.on('tapstart', 'node', function () {
917
+ preTapSelectedIds = {};
918
+ cy.nodes(':selected').forEach(function (n) { preTapSelectedIds[n.id()] = true; });
919
+ });
920
+
921
+ // Single click selects. Shift/Ctrl/Cmd-click toggles.
922
+ // Deferred via setTimeout to run AFTER Cytoscape's own post-tap selection processing.
923
+ cy.on('tap', 'node', function (e) {
924
+ clearSearch();
925
+ var node = e.target;
926
+ var originalEvent = e.originalEvent;
927
+ var additive = originalEvent && (originalEvent.shiftKey || originalEvent.metaKey || originalEvent.ctrlKey);
928
+ var wasSelected = preTapSelectedIds[node.id()] === true;
929
+ var savedIds = preTapSelectedIds;
930
+
931
+ setTimeout(function () {
932
+ if (additive) {
933
+ // Restore pre-tap state, then toggle the clicked node
934
+ cy.nodes().unselect();
935
+ for (var id in savedIds) {
936
+ cy.getElementById(id).select();
937
+ }
938
+ if (wasSelected) { node.unselect(); } else { node.select(); }
939
+ } else {
940
+ cy.nodes().unselect();
941
+ node.select();
942
+ }
943
+ applySelectionHighlight(cy);
944
+ }, 0);
945
+ });
946
+
947
+ cy.on('tap', function (e) {
948
+ if (e.target === cy) {
949
+ clearSearch();
950
+ cy.nodes().unselect();
951
+ clearSelectionHighlight(cy);
952
+ }
953
+ });
954
+
955
+ cy.on('dbltap', function (e) {
956
+ if (e.target === cy) {
957
+ clearSearch();
958
+ cy.nodes().unselect();
959
+ clearSelectionHighlight(cy);
960
+ cy.animate({ fit: { eles: cy.elements(), padding: 30 }, duration: 300 });
961
+ }
962
+ });
963
+
964
+ // Context menu (right-click)
965
+ var ctxMenu = document.createElement('div');
966
+ ctxMenu.className = 'graph-context-menu';
967
+ ctxMenu.style.display = 'none';
968
+ document.querySelector('.graph-container').appendChild(ctxMenu);
969
+
970
+ function hideContextMenu() {
971
+ ctxMenu.style.display = 'none';
972
+ }
973
+
974
+ function getNodePath(node) {
975
+ var d = node.data();
976
+ if (d.fullPath) {
977
+ // Module node — strip file:// prefix
978
+ if (d.fullPath.startsWith('file://')) return d.fullPath.slice(7);
979
+ return d.fullPath;
980
+ }
981
+ if (d.groupId && d.isGroup) return d.groupId;
982
+ if (d.isFolder) {
983
+ // Folder ID is ftree::<groupId>::<relativePath> — return groupId + / + relative
984
+ var parts = (d.id || '').split('::');
985
+ if (parts.length >= 3) return parts[1] + '/' + parts.slice(2).join('::');
986
+ return d.id;
987
+ }
988
+ return d.id || '';
989
+ }
990
+
991
+ cy.on('cxttap', 'node', function (e) {
992
+ e.originalEvent.preventDefault();
993
+ var node = e.target;
994
+ var path = getNodePath(node);
995
+ var pos = e.renderedPosition || e.position;
996
+
997
+ ctxMenu.innerHTML = '';
998
+
999
+ function menuItem(label, tooltip, onclick) {
1000
+ var el = document.createElement('div');
1001
+ el.className = 'graph-context-menu-item';
1002
+ el.innerHTML = escapeHtml(label) + ' <span class="menu-info-icon" title="' + escapeAttr(tooltip) + '">\u24D8</span>';
1003
+ el.addEventListener('click', onclick);
1004
+ ctxMenu.appendChild(el);
1005
+ }
1006
+
1007
+ function copyToClipboard(text) {
1008
+ var ta = document.createElement('textarea');
1009
+ ta.value = text;
1010
+ ta.style.position = 'fixed';
1011
+ ta.style.opacity = '0';
1012
+ document.body.appendChild(ta);
1013
+ ta.select();
1014
+ document.execCommand('copy');
1015
+ document.body.removeChild(ta);
1016
+ }
1017
+
1018
+ menuItem('Expand importer files', 'Reveal all modules that import this node', function () {
1019
+ hideContextMenu();
1020
+ expandImporters(cy, node);
1021
+ });
1022
+
1023
+ menuItem('Expand imported files', 'Reveal all modules imported by this node', function () {
1024
+ hideContextMenu();
1025
+ expandImported(cy, node);
1026
+ });
1027
+
1028
+ menuItem('Copy absolute path', 'Copy the filesystem path to clipboard', function () {
1029
+ copyToClipboard(path);
1030
+ hideContextMenu();
1031
+ });
1032
+
1033
+ menuItem('Copy import paths', 'Copy the full import chain from root to each file in this node', function () {
1034
+ var urls = getModuleURLsForNode(node);
1035
+ var paths = [];
1036
+ for (var i = 0; i < urls.length; i++) {
1037
+ paths.push(getImportPath(urls[i]));
1038
+ }
1039
+ copyToClipboard(paths.join('\n\n'));
1040
+ hideContextMenu();
1041
+ });
1042
+
1043
+ ctxMenu.style.left = pos.x + 'px';
1044
+ ctxMenu.style.top = pos.y + 'px';
1045
+ ctxMenu.style.display = 'block';
1046
+ });
1047
+
1048
+ cy.on('tap', function () { hideContextMenu(); });
1049
+ document.addEventListener('click', function () { hideContextMenu(); });
1050
+
1051
+ function escapeHtml(str) {
1052
+ var div = document.createElement('div');
1053
+ div.textContent = str;
1054
+ return div.innerHTML;
1055
+ }
1056
+
1057
+ function escapeAttr(str) {
1058
+ return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1059
+ }
1060
+
1061
+ return cy;
1062
+ }
1063
+
1064
+ function expandImporters(cy, node) {
1065
+ // Collect all module URLs belonging to this node
1066
+ var targetURLs = new Set();
1067
+ if (node.hasClass('module')) {
1068
+ targetURLs.add(node.id());
1069
+ } else if (node.hasClass('group')) {
1070
+ // All modules in this group (including hidden ones)
1071
+ node.descendants('.module').forEach(function (m) { targetURLs.add(m.id()); });
1072
+ } else if (node.hasClass('folder')) {
1073
+ // Modules that are children of this folder (recursively via folder state)
1074
+ function collectFolderModules(fid) {
1075
+ var state = folderState[fid];
1076
+ if (!state) return;
1077
+ for (var i = 0; i < state.children.length; i++) {
1078
+ var child = state.children[i];
1079
+ if (child.type === 'file' && child.moduleURL) {
1080
+ targetURLs.add(child.moduleURL);
1081
+ } else if (child.type === 'folder') {
1082
+ collectFolderModules(child.id);
1083
+ }
1084
+ }
1085
+ }
1086
+ collectFolderModules(node.id());
1087
+ }
1088
+
1089
+ // Find all importer module URLs (sources of edges targeting our modules)
1090
+ var importerURLs = new Set();
1091
+ cy.edges().forEach(function (edge) {
1092
+ if (targetURLs.has(edge.data('target'))) {
1093
+ var src = edge.data('source');
1094
+ if (!targetURLs.has(src)) {
1095
+ importerURLs.add(src);
1096
+ }
1097
+ }
1098
+ });
1099
+
1100
+ if (importerURLs.size === 0) return;
1101
+
1102
+ // Reveal each importer (minimal expansion)
1103
+ suppressAutoSelect = true;
1104
+ importerURLs.forEach(function (url) {
1105
+ revealModule(cy, url);
1106
+ });
1107
+ refreshEdgeVisibility(cy);
1108
+ suppressAutoSelect = false;
1109
+
1110
+ var nodeId = node.id();
1111
+ maybeRelayoutWithCallback(cy, function () {
1112
+ var n = cy.getElementById(nodeId);
1113
+ cy.nodes().unselect();
1114
+ n.select();
1115
+ applySelectionHighlight(cy);
1116
+ });
1117
+ }
1118
+
1119
+ function expandImported(cy, node) {
1120
+ // Collect all module URLs belonging to this node
1121
+ var sourceURLs = new Set();
1122
+ if (node.hasClass('module')) {
1123
+ sourceURLs.add(node.id());
1124
+ } else if (node.hasClass('group')) {
1125
+ node.descendants('.module').forEach(function (m) { sourceURLs.add(m.id()); });
1126
+ } else if (node.hasClass('folder')) {
1127
+ function collectFolderModules(fid) {
1128
+ var state = folderState[fid];
1129
+ if (!state) return;
1130
+ for (var i = 0; i < state.children.length; i++) {
1131
+ var child = state.children[i];
1132
+ if (child.type === 'file' && child.moduleURL) {
1133
+ sourceURLs.add(child.moduleURL);
1134
+ } else if (child.type === 'folder') {
1135
+ collectFolderModules(child.id);
1136
+ }
1137
+ }
1138
+ }
1139
+ collectFolderModules(node.id());
1140
+ }
1141
+
1142
+ // Find all imported module URLs (targets of edges from our modules)
1143
+ var importedURLs = new Set();
1144
+ cy.edges().forEach(function (edge) {
1145
+ if (sourceURLs.has(edge.data('source'))) {
1146
+ var tgt = edge.data('target');
1147
+ if (!sourceURLs.has(tgt)) {
1148
+ importedURLs.add(tgt);
1149
+ }
1150
+ }
1151
+ });
1152
+
1153
+ if (importedURLs.size === 0) return;
1154
+
1155
+ // Reveal each imported module (minimal expansion)
1156
+ suppressAutoSelect = true;
1157
+ importedURLs.forEach(function (url) {
1158
+ revealModule(cy, url);
1159
+ });
1160
+ refreshEdgeVisibility(cy);
1161
+ suppressAutoSelect = false;
1162
+
1163
+ var nodeId = node.id();
1164
+ maybeRelayoutWithCallback(cy, function () {
1165
+ var n = cy.getElementById(nodeId);
1166
+ cy.nodes().unselect();
1167
+ n.select();
1168
+ applySelectionHighlight(cy);
1169
+ });
1170
+ }
1171
+
1172
+ function highlightCycle(cy, cycle) {
1173
+ clearSearch();
1174
+ clearSelectionHighlight(cy);
1175
+ cy.elements().removeClass('cycle-highlight');
1176
+ cy.nodes().unselect();
1177
+
1178
+ suppressAutoSelect = true;
1179
+ // Collapse all, then reveal just enough for cycle members
1180
+ collapseAll(cy);
1181
+ for (var k = 0; k < cycle.modules.length; k++) {
1182
+ revealModule(cy, cycle.modules[k]);
1183
+ }
1184
+ refreshEdgeVisibility(cy);
1185
+ suppressAutoSelect = false;
1186
+
1187
+ maybeRelayoutWithCallback(cy, function () {
1188
+ // Apply cycle highlight after layout completes
1189
+ var nodes = [];
1190
+ for (var i = 0; i < cycle.modules.length; i++) {
1191
+ var node = cy.getElementById(cycle.modules[i]);
1192
+ if (node.length > 0) {
1193
+ node.addClass('cycle-highlight');
1194
+ nodes.push(node);
1195
+ }
1196
+
1197
+ var nextIdx = (i + 1) % cycle.modules.length;
1198
+ var edgeId = cycle.modules[i] + '->' + cycle.modules[nextIdx];
1199
+ var edge = cy.getElementById(edgeId);
1200
+ if (edge.length > 0) {
1201
+ edge.show();
1202
+ edge.addClass('cycle-highlight');
1203
+ }
1204
+ }
1205
+
1206
+ if (nodes.length > 0) {
1207
+ var collection = cy.collection();
1208
+ for (var j = 0; j < nodes.length; j++) {
1209
+ collection = collection.union(nodes[j]);
1210
+ }
1211
+ // Also include the parent groups so the zoom shows context
1212
+ var withParents = collection;
1213
+ collection.forEach(function (n) {
1214
+ if (n.parent().length > 0) {
1215
+ withParents = withParents.union(n.parent());
1216
+ }
1217
+ });
1218
+ cy.animate({ fit: { eles: withParents, padding: 40 }, duration: 300 });
1219
+ }
1220
+ });
1221
+ }
1222
+
1223
+ function clearHighlights(cy) {
1224
+ cy.elements().removeClass('cycle-highlight');
1225
+ }
1226
+
1227
+ function zoomToNode(cy, resolvedURL) {
1228
+ var node = cy.getElementById(resolvedURL);
1229
+ if (node.length > 0) {
1230
+ revealModule(cy, resolvedURL);
1231
+ cy.nodes().unselect();
1232
+ node.select();
1233
+ applySelectionHighlight(cy);
1234
+ cy.animate({ center: { eles: node }, zoom: 2, duration: 300 });
1235
+ }
1236
+ }
1237
+
1238
+ function focusOnNode(cy, resolvedURL) {
1239
+ clearSearch();
1240
+ var node = cy.getElementById(resolvedURL);
1241
+ if (node.length === 0) return;
1242
+
1243
+ // If the module node is already visible, just select + zoom
1244
+ if (node.visible()) {
1245
+ cy.nodes().unselect();
1246
+ node.select();
1247
+ applySelectionHighlight(cy);
1248
+ cy.animate({ center: { eles: node }, zoom: 2, duration: 300 });
1249
+ return;
1250
+ }
1251
+
1252
+ // Otherwise: collapse all, reveal just enough, relayout, then select + zoom
1253
+ suppressAutoSelect = true;
1254
+ collapseAll(cy);
1255
+ revealModule(cy, resolvedURL);
1256
+ refreshEdgeVisibility(cy);
1257
+ suppressAutoSelect = false;
1258
+ maybeRelayoutWithCallback(cy, function () {
1259
+ var n = cy.getElementById(resolvedURL);
1260
+ cy.nodes().unselect();
1261
+ n.select();
1262
+ applySelectionHighlight(cy);
1263
+ cy.animate({ center: { eles: n }, zoom: 2, duration: 300 });
1264
+ });
1265
+ }
1266
+
1267
+ // Search: highlight matching nodes in the graph. If a match is inside a
1268
+ // collapsed group or folder, highlight that collapsed ancestor instead.
1269
+ function filterBySearch(cy, query) {
1270
+ if (!query) {
1271
+ cy.nodes().unselect();
1272
+ clearSelectionHighlight(cy);
1273
+ return;
1274
+ }
1275
+
1276
+ var lowerQuery = query.toLowerCase();
1277
+ var toSelect = new Set();
1278
+
1279
+ // Check every module node (visible or not)
1280
+ cy.nodes('.module').forEach(function (node) {
1281
+ var label = (node.data('label') || '').toLowerCase();
1282
+ var path = (node.data('fullPath') || '').toLowerCase();
1283
+ if (label.indexOf(lowerQuery) !== -1 || path.indexOf(lowerQuery) !== -1) {
1284
+ // Resolve to the nearest visible ancestor
1285
+ toSelect.add(resolveVisibleNode(cy, node.id()));
1286
+ }
1287
+ });
1288
+
1289
+ // Also check folder nodes by label
1290
+ cy.nodes('.folder').forEach(function (node) {
1291
+ var label = (node.data('label') || '').toLowerCase();
1292
+ if (label.indexOf(lowerQuery) !== -1) {
1293
+ if (node.visible()) {
1294
+ toSelect.add(node.id());
1295
+ } else {
1296
+ toSelect.add(resolveVisibleNode(cy, node.id()));
1297
+ }
1298
+ }
1299
+ });
1300
+
1301
+ // Select all matching visible nodes
1302
+ cy.nodes().unselect();
1303
+ toSelect.forEach(function (nodeId) {
1304
+ var node = cy.getElementById(nodeId);
1305
+ if (node.length > 0 && node.visible()) node.select();
1306
+ });
1307
+ applySelectionHighlight(cy);
1308
+ }
1309
+
1310
+