@intact-ebi/cytoscape-lumina 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.
package/dist/index.js ADDED
@@ -0,0 +1,2764 @@
1
+ // @ts-ignore
2
+ export const DEFAULT_PARAMS = {
3
+ // Force-directed parameters
4
+ IDEAL_LENGTH: 100,
5
+ REPULSION: 300,
6
+ SPRING_K: 0.15,
7
+ ITERATIONS: 600,
8
+ ANGULAR_STRENGTH: 0.2,
9
+ CENTER_GRAVITY: 0.01,
10
+ USE_ANGULAR_FORCE: false,
11
+ RANDOMIZE_INITIAL_POSITIONS: false,
12
+ // Structure detection parameters
13
+ MIN_STAR_LEAVES: 3,
14
+ MIN_CYCLE_LENGTH: 3,
15
+ MAX_CYCLE_LENGTH: 30,
16
+ MIN_CHAIN_LENGTH: 2,
17
+ MIN_PARALLEL_NEIGHBORS: 2,
18
+ // Layout spacing parameters
19
+ CYCLE_NODE_SPACING: 100,
20
+ STAR_RING_SPACING: 100,
21
+ STAR_BASE_NODES_PER_RING: 6,
22
+ CHAIN_MIN_RADIUS: 150,
23
+ PARALLEL_GAP: 80,
24
+ LEAF_NODE_DISTANCE: 200,
25
+ // Virtual node parameters
26
+ VNODE_RADIUS_MULTIPLIER: 0.2,
27
+ // VNODE_IDEAL_LENGTH: 100,
28
+ VNODE_REPULSION: 10000,
29
+ VNODE_SPRING_K: 0.15,
30
+ VNODE_ITERATIONS: 1000,
31
+ VNODE_ANGULAR_STRENGTH: 0.1,
32
+ // Layout control flags
33
+ SPREAD_V_NODES: true,
34
+ SUBSTRUCTURE_LAYOUT: false,
35
+ ENABLE_INITIAL_FORCE_LAYOUT: false,
36
+ // Step-by-step mode
37
+ STEP_BY_STEP: false,
38
+ LAYOUT_ALGORITHM: 'stress',
39
+ SHOW_ALL_EDGES: true
40
+ };
41
+ class VNode {
42
+ constructor(id, x, y, radius) {
43
+ this.rotate_angle = 0;
44
+ this.nodes = []; // stores node objects
45
+ this.neighbors = [];
46
+ this.id = id;
47
+ this.center_x = x;
48
+ this.center_y = y;
49
+ this.radius = radius;
50
+ // 2. Initialization: leave unassigned, so the value is undefined
51
+ this._permanentOrder = undefined;
52
+ }
53
+ get position() {
54
+ return { x: this.center_x, y: this.center_y };
55
+ }
56
+ setPosition(x, y) {
57
+ this.center_x = x;
58
+ this.center_y = y;
59
+ }
60
+ addNode(node) {
61
+ this.nodes.push(node);
62
+ }
63
+ setPermanentOrder(order) {
64
+ this._permanentOrder = [...order];
65
+ }
66
+ getPermanentOrder() {
67
+ return this._permanentOrder;
68
+ }
69
+ toSnapshot() {
70
+ return {
71
+ id: this.id,
72
+ type: this.type,
73
+ center_x: this.center_x,
74
+ center_y: this.center_y,
75
+ radius: this.radius,
76
+ rotate_angle: this.rotate_angle,
77
+ nodeIds: this.nodes
78
+ };
79
+ }
80
+ }
81
+ class VEdge {
82
+ // weight is number of edges between source node and target node,
83
+ // normally is 1, but can be 2 or more for like parallel edges
84
+ constructor(source, target, weight = 1) {
85
+ this.source = source;
86
+ this.target = target;
87
+ this.weight = weight;
88
+ }
89
+ toSnapshot() {
90
+ return {
91
+ source: this.source.id, target: this.target.id, weight: this.weight
92
+ };
93
+ }
94
+ }
95
+ // export default function register(cytoscape: any) {
96
+ // if (!cytoscape) return;
97
+ // cytoscape('layout', 'SubstructureLayout', SubstructureLayout);
98
+ // }
99
+ /**
100
+ * Register this layout as a Cytoscape.js layout extension.
101
+ *
102
+ * Usage:
103
+ * import cytoscape from 'cytoscape';
104
+ * import substructureLayout from './cytoscape-substructure-layout';
105
+ *
106
+ * substructureLayout(cytoscape);
107
+ *
108
+ * cy.layout({
109
+ * name: 'substructure-layout',
110
+ * layoutAlgorithm: 'force',
111
+ * idealLength: 100,
112
+ * repulsion: 10000,
113
+ * springK: 0.15,
114
+ * iterations: 400
115
+ * }).run();
116
+ */
117
+ export default function register(cytoscapeInstance) {
118
+ if (!cytoscapeInstance) {
119
+ throw new Error('cytoscape-substructure-layout: Cytoscape.js instance is required');
120
+ }
121
+ // Avoid duplicate registration when the plugin is initialized more than once.
122
+ try {
123
+ cytoscapeInstance('layout', 'substructure-layout', SubstructureLayout);
124
+ }
125
+ catch (error) {
126
+ // Cytoscape normally throws if an extension with the same name is already
127
+ // registered. Keeping registration idempotent makes the plugin easier to use
128
+ // in hot-reload/demo environments.
129
+ const message = error instanceof Error ? error.message : String(error);
130
+ if (!/already|exists|registered/i.test(message)) {
131
+ throw error;
132
+ }
133
+ }
134
+ }
135
+ // Optional named export for consumers that prefer: import { register } ...
136
+ export { SubstructureLayout };
137
+ /////////////////////////////////////////////////////////////////////////////////////////////////
138
+ /**
139
+ * Enhanced force-directed layout with structure awareness
140
+ */
141
+ function SubstructureLayout(options) {
142
+ this.options = options || {};
143
+ this.cy = this.options.cy;
144
+ this.eles = this.options.eles || this.cy.elements();
145
+ this.boundingBox = this.options.boundingBox;
146
+ this.stopped = false;
147
+ if (!this.cy) {
148
+ throw new Error('cytoscape-substructure-layout: options.cy is required');
149
+ }
150
+ // Public Cytoscape.js options use camelCase. The original algorithm uses
151
+ // uppercase internal parameters, so normalize the public API here.
152
+ // `params` is still accepted for backward compatibility with the old demo.
153
+ const p = this.options.params || {};
154
+ const publicParams = {
155
+ IDEAL_LENGTH: this.options.idealLength,
156
+ REPULSION: this.options.repulsion,
157
+ SPRING_K: this.options.springK,
158
+ ITERATIONS: this.options.iterations,
159
+ ANGULAR_STRENGTH: this.options.angularStrength,
160
+ CENTER_GRAVITY: this.options.centerGravity,
161
+ USE_ANGULAR_FORCE: this.options.useAngularForce,
162
+ RANDOMIZE_INITIAL_POSITIONS: this.options.randomizeInitialPositions,
163
+ MIN_STAR_LEAVES: this.options.minStarLeaves,
164
+ MIN_CYCLE_LENGTH: this.options.minCycleLength,
165
+ MAX_CYCLE_LENGTH: this.options.maxCycleLength,
166
+ MIN_CHAIN_LENGTH: this.options.minChainLength,
167
+ MIN_PARALLEL_NEIGHBORS: this.options.minParallelNeighbors,
168
+ CYCLE_NODE_SPACING: this.options.cycleNodeSpacing,
169
+ STAR_RING_SPACING: this.options.starRingSpacing,
170
+ STAR_BASE_NODES_PER_RING: this.options.starBaseNodesPerRing,
171
+ CHAIN_MIN_RADIUS: this.options.chainMinRadius,
172
+ PARALLEL_GAP: this.options.parallelGap,
173
+ LEAF_NODE_DISTANCE: this.options.leafNodeDistance,
174
+ VNODE_RADIUS_MULTIPLIER: this.options.vnodeRadiusMultiplier,
175
+ VNODE_REPULSION: this.options.vnodeRepulsion,
176
+ VNODE_SPRING_K: this.options.vnodeSpringK,
177
+ VNODE_ITERATIONS: this.options.vnodeIterations,
178
+ VNODE_ANGULAR_STRENGTH: this.options.vnodeAngularStrength,
179
+ SPREAD_V_NODES: this.options.spreadVNodes,
180
+ SUBSTRUCTURE_LAYOUT: this.options.substructureLayout,
181
+ ENABLE_INITIAL_FORCE_LAYOUT: this.options.enableInitialForceLayout,
182
+ STEP_BY_STEP: this.options.stepByStep,
183
+ // LAYOUT_ALGORITHM: 'stress',
184
+ // SHOW_ALL_EDGES: true
185
+ };
186
+ // Merge defaults -> legacy params -> public camelCase options. Undefined
187
+ // public values are ignored so defaults are preserved.
188
+ const camelToInternal = {};
189
+ Object.keys(publicParams).forEach((key) => {
190
+ const value = publicParams[key];
191
+ if (value !== undefined) {
192
+ camelToInternal[key] = value;
193
+ }
194
+ });
195
+ this.params = {
196
+ ...DEFAULT_PARAMS, ...p, ...camelToInternal
197
+ };
198
+ // Instance-specific arrays instead of global
199
+ this.vnodes = [];
200
+ this.vedges = [];
201
+ // Step-by-step mode data
202
+ this.steps = [];
203
+ this.currentStepIndex = -1;
204
+ }
205
+ /**
206
+ * Capture a snapshot of the current layout state
207
+ */
208
+ SubstructureLayout.prototype.captureStep = function (stepName, description, metadata) {
209
+ if (!this.params.STEP_BY_STEP)
210
+ return;
211
+ const nodes = this.cy.nodes();
212
+ const nodePositions = {};
213
+ nodes.forEach((node) => {
214
+ nodePositions[node.id()] = {
215
+ x: node.position().x, y: node.position().y
216
+ };
217
+ });
218
+ // Deep clone virtual nodes and edges if they exist
219
+ const virtualNodes = this.vnodes.length > 0 ? JSON.parse(JSON.stringify(this.vnodes.map((v) => ({
220
+ id: v.id,
221
+ type: v.type,
222
+ center_x: v.center_x,
223
+ center_y: v.center_y,
224
+ radius: v.radius,
225
+ rotate_angle: v.rotate_angle,
226
+ nodeIds: v.nodes?.map((n) => n.id()) || []
227
+ })))) : undefined;
228
+ const virtualEdges = this.vedges.length > 0 ? JSON.parse(JSON.stringify(this.vedges.map((e) => ({
229
+ source: e.source.id, target: e.target.id
230
+ })))) : undefined;
231
+ const step = {
232
+ stepNumber: this.steps.length, stepName, description, nodePositions, virtualNodes, virtualEdges, metadata
233
+ };
234
+ this.steps.push(step);
235
+ this.currentStepIndex = this.steps.length - 1;
236
+ console.log(`[Step ${step.stepNumber}] ${stepName}: ${description}`);
237
+ };
238
+ /////////////// Check whether two node arrays are identical, ignoring order
239
+ function areNodesEqual(arr1, arr2) {
240
+ // 1. Different lengths mean they are definitely different
241
+ if (arr1.length !== arr2.length)
242
+ return false;
243
+ // 2. Extract all IDs and put them into a Set
244
+ const ids1 = new Set(arr1.map((node) => node.id()));
245
+ // 3. Check whether every ID in arr2 exists in the Set
246
+ return arr2.every(node => ids1.has(node.id()));
247
+ }
248
+ ////////////////sum length of all edges ////////////////
249
+ function totalEdgeLength(edges) {
250
+ let total = 0;
251
+ edges.forEach((edge) => {
252
+ const s = edge.source();
253
+ const t = edge.target();
254
+ if (s && t) {
255
+ const dx = (t.position().x || 0) - (s.position().x || 0);
256
+ const dy = (t.position().y || 0) - (s.position().y || 0);
257
+ const length = Math.sqrt(dx * dx + dy * dy);
258
+ total += length;
259
+ }
260
+ });
261
+ return total;
262
+ }
263
+ /**
264
+ * Arrange the Node array into a rectangular matrix
265
+ *
266
+ * dirVector: direction of the long side of the rectangle
267
+ * colSpacing: distance between nodes along the long-side direction
268
+ * rowSpacing: distance between rows along the short-side direction
269
+ * cols: number of nodes per row; calculated automatically if omitted
270
+ */
271
+ function layoutRectangular(nodes, center, dirVector, rowSpacing = 60, colSpacing = 60, cols) {
272
+ const n = nodes.length;
273
+ if (n === 0) {
274
+ return;
275
+ }
276
+ // ============================================================
277
+ // 1. Determine matrix dimensions
278
+ // ============================================================
279
+ let finalCols;
280
+ if (cols !== undefined && cols > 0) {
281
+ finalCols = Math.min(cols, n);
282
+ }
283
+ else {
284
+ finalCols = Math.ceil(Math.sqrt(n));
285
+ }
286
+ let rows = Math.ceil(n / finalCols);
287
+ // A small number of nodes are placed in a single row
288
+ if (n < 5) {
289
+ finalCols = 1;
290
+ rows = n;
291
+ }
292
+ // ============================================================
293
+ // 2. Calculate the two unit direction vectors
294
+ //
295
+ // uLong = long-side direction = dirVector
296
+ // uShort = short-side direction = perpendicular to dirVector
297
+ // ============================================================
298
+ const magnitude = Math.sqrt(dirVector.x * dirVector.x + dirVector.y * dirVector.y);
299
+ const safeMagnitude = Math.max(magnitude, 0.000001);
300
+ const uLong = {
301
+ x: dirVector.x / safeMagnitude, y: dirVector.y / safeMagnitude
302
+ };
303
+ const uShort = {
304
+ x: -uLong.y, y: uLong.x
305
+ };
306
+ // ============================================================
307
+ // 3. Iterate over all Nodes
308
+ // ============================================================
309
+ nodes.forEach((node, i) => {
310
+ // Row containing the current node
311
+ const row = Math.floor(i / finalCols);
312
+ // Column containing the current node
313
+ const col = i % finalCols;
314
+ // --------------------------------------------------------
315
+ // Actual number of nodes in the current row
316
+ // --------------------------------------------------------
317
+ const isLastRow = row === rows - 1;
318
+ const nodesInThisRow = isLastRow ? (n % finalCols || finalCols) : finalCols;
319
+ // ========================================================
320
+ // 4. Position along the long-side direction
321
+ // Arrange symmetrically around center
322
+ // ========================================================
323
+ const longWidth = (nodesInThisRow - 1) * colSpacing;
324
+ const offsetLong = col * colSpacing - longWidth / 2;
325
+ // ========================================================
326
+ // 5. Position along the short-side direction
327
+ //
328
+ // Center each row as a whole
329
+ // ========================================================
330
+ const totalShortHeight = (rows - 1) * rowSpacing;
331
+ const offsetShort = row * rowSpacing - totalShortHeight / 2;
332
+ // ========================================================
333
+ // 6. Calculate the final coordinates
334
+ //
335
+ // position =
336
+ // center
337
+ // + offset along the long-side direction
338
+ // + offset along the short-side direction
339
+ // ========================================================
340
+ const x = center.x + offsetLong * uLong.x + offsetShort * uShort.x;
341
+ const y = center.y + offsetLong * uLong.y + offsetShort * uShort.y;
342
+ node.position({
343
+ x, y
344
+ });
345
+ });
346
+ }
347
+ SubstructureLayout.prototype.identifyStructures = function (nodes) {
348
+ const params = this.params;
349
+ // 0. Reset all markers
350
+ nodes.data('structType', 'Normal');
351
+ // nodes.data('structColor', '#999999');
352
+ nodes.data('groupId', null); // Added: reset group ID for cycles
353
+ nodes.data('innerId', null); // index inner a circle
354
+ nodes.data('parallelGroupIdVec', []);
355
+ // improve the above 'data' definition
356
+ nodes.data('structs', {});
357
+ ///////////////////////////// 2. detecting Cycle - by DFS algorithm /////////////////
358
+ const allCycles = []; // each sub-array represents an independent cycle
359
+ if (1) {
360
+ const normalNodes = nodes.toArray().filter((n) => n.data('structType') === 'Normal' && n.neighborhood().nodes().length >= 2);
361
+ const seenCycles = new Set();
362
+ // To avoid duplicate cycles (like A-B-C and B-C-A), we sort and stringify for a check
363
+ normalNodes.forEach((startNode, startIndex) => {
364
+ // We only find cycles where startNode is the node with the lowest index
365
+ // This is a massive optimization to prevent finding the same cycle N times
366
+ const startId = startNode.id();
367
+ const findCycles = (u, parent, path) => {
368
+ const neighbors = u.neighborhood().nodes().toArray().filter((n) => n.data('structType') === 'Normal');
369
+ if (neighbors.length < 8) { // If a node has too many neighbors, it is unlikely to be part of a cycle; this mainly improves efficiency and prevents the search from getting stuck here
370
+ for (const v of neighbors) {
371
+ const vId = v.id();
372
+ // 1. Found a cycle back to our specific START node
373
+ if (vId === startId && path.length >= params.MIN_CYCLE_LENGTH) {
374
+ const cycle = [...path];
375
+ const sortedKey = [...cycle].sort().join(',');
376
+ if (!seenCycles.has(sortedKey)) {
377
+ allCycles.push(cycle);
378
+ seenCycles.add(sortedKey);
379
+ }
380
+ continue;
381
+ }
382
+ // 2. Optimization: only visit nodes with higher index than startNode
383
+ // and nodes NOT already in the current path
384
+ const vIdx = normalNodes.findIndex(node => node.id() === vId); // index in nodes array
385
+ if (vIdx > startIndex && !path.includes(vId)) {
386
+ // define circle has less than 30 nodes, this is for large graph efficiency
387
+ // if(path.length < 5) {
388
+ findCycles(v, u, [...path, vId]);
389
+ // }
390
+ }
391
+ }
392
+ }
393
+ };
394
+ findCycles(startNode, null, [startId]);
395
+ });
396
+ }
397
+ // --- 2. Secondary filtering: remove cycles with an overlap of >= 2 nodes ---
398
+ const filteredCycles = [];
399
+ allCycles.sort((a, b) => b.length - a.length);
400
+ // It is recommended to sort cycles by size first; smaller cycles are usually more meaningful because fundamental cycles tend to be shorter
401
+ allCycles.forEach((currentCycle) => {
402
+ const currentSet = new Set(currentCycle);
403
+ // Check whether the current cycle overlaps any saved cycle by two or more nodes
404
+ const isRedundant = filteredCycles.some(existingCycle => {
405
+ let overlapCount = 0;
406
+ for (const nodeId of existingCycle) {
407
+ if (currentSet.has(nodeId)) {
408
+ overlapCount++;
409
+ }
410
+ // Performance optimization: stop counting as soon as the overlap reaches 2
411
+ if (overlapCount >= 2)
412
+ return true;
413
+ }
414
+ return false;
415
+ });
416
+ if ((!isRedundant && currentCycle.length > 2 && currentCycle.length != 4)) {
417
+ filteredCycles.push(currentCycle);
418
+ }
419
+ if ((!isRedundant && currentCycle.length == 4)) {
420
+ const subNodes = currentCycle.map(id => nodes.toArray().find((n) => n.id() === id));
421
+ if (!((subNodes[0]?.degree(false) == 2 && subNodes[2]?.degree(false) == 2 && subNodes[1]?.degree(false) != 2 && subNodes[3]?.degree(false) != 2) || (subNodes[0]?.degree(false) == 2 && subNodes[2]?.degree(false) == 2 && subNodes[1]?.degree(false) == 2 && subNodes[3]?.degree(false) == 2))) {
422
+ filteredCycles.push(currentCycle);
423
+ }
424
+ }
425
+ });
426
+ let circleIndex = 1; // why 1 works ????
427
+ filteredCycles.forEach((currentCycle) => {
428
+ let innerIndex = 0;
429
+ currentCycle.forEach((circle_node) => {
430
+ nodes.forEach((node) => {
431
+ if (node.data('structType') === 'Normal' && node.id() === circle_node) {
432
+ node.addClass('substructure-cycle');
433
+ node.data('structType', 'Cycle');
434
+ // node.data('structColor', '#2196F3');
435
+ node.data('groupId', 'Cycle_' + circleIndex);
436
+ node.data('innerId', innerIndex);
437
+ // for multiple struct types, we use a 'structs' object to store the data
438
+ node.data('structs', {
439
+ ...node.data('structs'), Cycle: {
440
+ color: '#2196F3', groupId: 'Cycle_' + circleIndex, innerId: innerIndex
441
+ }
442
+ });
443
+ innerIndex++;
444
+ }
445
+ });
446
+ });
447
+ circleIndex++;
448
+ });
449
+ console.log("num of cycles: ", filteredCycles.length);
450
+ const chains = [];
451
+ const processedNodeIds = new Set(); // avoid processing nodes more than once
452
+ // 2. Find all leaf nodes (Normal type with degree 1)
453
+ const leafNodes = nodes.filter((n) => n.data('structType') === 'Normal' && n.neighborhood().nodes().length === 1);
454
+ let chainId = 0;
455
+ leafNodes.forEach((leaf) => {
456
+ if (processedNodeIds.has(leaf.id()))
457
+ return;
458
+ const currentChainNodes = [];
459
+ let currentNode = leaf;
460
+ let nodeId = 0;
461
+ // 3. Trace inward along the chain
462
+ while (currentNode) {
463
+ currentChainNodes.push(currentNode);
464
+ processedNodeIds.add(currentNode.id());
465
+ // Find the next neighbor
466
+ const neighbors = currentNode
467
+ .neighborhood('node')
468
+ .filter((n) => n.id() !== currentNode.id() &&
469
+ n.data('structType') === 'Normal' &&
470
+ !processedNodeIds.has(n.id()));
471
+ // Chain continuation conditions:
472
+ // 1. There is exactly one unvisited Normal neighbor
473
+ // 2. The neighbor must not have a high degree (if degree > 2, a branching point has been reached and the chain ends)
474
+ if (neighbors.length === 1) {
475
+ const nextNode = neighbors[0];
476
+ const nextNodeNeighborCount = nextNode
477
+ .neighborhood('node')
478
+ .filter((n) => n.id() !== nextNode.id())
479
+ .length;
480
+ if (nextNodeNeighborCount > 2) {
481
+ break;
482
+ }
483
+ currentNode = nextNode;
484
+ }
485
+ else {
486
+ // No neighbors or multiple neighbors (branching), so the chain ends
487
+ currentNode = null;
488
+ }
489
+ }
490
+ // 4. Save the chain that was found
491
+ if (currentChainNodes.length >= params.MIN_CHAIN_LENGTH) {
492
+ nodeId = 0;
493
+ currentChainNodes.forEach((node) => {
494
+ node.addClass('substructure-chain');
495
+ node.data('structType', 'Chain');
496
+ // node.data('structColor', '#FFF176');
497
+ node.data('groupId', 'Chain_' + chainId);
498
+ node.data('innerId', nodeId);
499
+ node.data('structs', {
500
+ ...node.data('structs'), Chain: {
501
+ color: '#FFF176', groupId: 'Chain_' + chainId, innerId: nodeId
502
+ }
503
+ });
504
+ nodeId++;
505
+ });
506
+ chainId++;
507
+ chains.push({
508
+ chainId: `chain_${leaf.id()}`, // name it using the leaf node ID
509
+ nodes: currentChainNodes
510
+ });
511
+ }
512
+ else {
513
+ /////////////////////////////// leafs that not in a chain ///////////////////////////
514
+ currentChainNodes.forEach((node) => {
515
+ node.data('structType', 'LeafButNotChain');
516
+ // node.data('structColor', '#aaa');
517
+ node.data('groupId', null);
518
+ node.data('innerId', null);
519
+ node.data('structs', {
520
+ ...node.data('structs'), LeafButNotChain: {
521
+ color: '#aaa', groupId: null, innerId: null
522
+ }
523
+ });
524
+ nodeId++;
525
+ });
526
+ }
527
+ });
528
+ ///////////////////////////////// Star structure ///////////
529
+ let starIndex = 0;
530
+ nodes.forEach((node) => {
531
+ const neighbors = node.neighborhood().nodes();
532
+ // Number of distinct neighbors
533
+ const neighborCount = neighbors.length;
534
+ // Check whether the neighbors consist only of distinct neighbor nodes
535
+ const leafNeighbors = neighbors.filter((n) => n.neighborhood().nodes().length === 1);
536
+ if (leafNeighbors.length >= params.MIN_STAR_LEAVES && neighborCount >= params.MIN_STAR_LEAVES) {
537
+ if (node.data('structType') !== 'Cycle' && node.data('structType') !== 'Chain') {
538
+ const groupId = 'Star_' + starIndex;
539
+ node.addClass(['substructure-star', 'substructure-star-center']);
540
+ node.data('structType', 'Star-Center');
541
+ // node.data('structColor', '#F48FB1');
542
+ node.data('groupId', groupId);
543
+ node.data('structs', {
544
+ ...node.data('structs'), Star: {
545
+ role: 'Center', color: '#F48FB1', groupId
546
+ }
547
+ });
548
+ leafNeighbors.forEach((leaf) => {
549
+ leaf.addClass(['substructure-star', 'substructure-member']);
550
+ leaf.data('structType', 'Star-Member');
551
+ // leaf.data('structColor', '#F48FB1');
552
+ leaf.data('groupId', groupId);
553
+ leaf.data('structs', {
554
+ ...leaf.data('structs'), Star: {
555
+ role: 'Member', color: '#F48FB1', groupId
556
+ }
557
+ });
558
+ });
559
+ starIndex++;
560
+ }
561
+ }
562
+ });
563
+ /////////////////////////////// Find parallel/diamond structures ///////////////////////////////////
564
+ // let diamonds = [];
565
+ let parallelId = 0;
566
+ // Whether any two nodes have common neighbors
567
+ for (let i = 0; i < nodes.length; i++) {
568
+ let nodeVecParallel = [];
569
+ const u = nodes[i];
570
+ const u1 = u.neighborhood().nodes();
571
+ if (u.data('structType') === 'Normal') {
572
+ for (let j = 0; j < nodes.length; j++) {
573
+ if (i === j)
574
+ continue;
575
+ const v = nodes[j];
576
+ if (v.data('structType') === 'Normal') {
577
+ const v1 = v.neighborhood().nodes();
578
+ if (areNodesEqual(v1, u1) && v1.length >= params.MIN_PARALLEL_NEIGHBORS && u1.length >= params.MIN_PARALLEL_NEIGHBORS) {
579
+ if (nodeVecParallel.length === 0) {
580
+ nodeVecParallel.push(u.id());
581
+ }
582
+ nodeVecParallel.push(v.id());
583
+ }
584
+ }
585
+ }
586
+ }
587
+ if (nodeVecParallel.length >= 2) {
588
+ nodeVecParallel.forEach(v => {
589
+ nodes.forEach((node) => {
590
+ if (node.id() == v) {
591
+ node.addClass('substructure-parallel');
592
+ node.data('structType', 'Parallel');
593
+ // node.data('structColor', '#50C878');
594
+ node.data('groupId', 'Parallel' + parallelId);
595
+ node.data('structs', {
596
+ ...node.data('structs'), Parallel: {
597
+ color: '#50C878', groupId: `Parallel_${parallelId}`
598
+ }
599
+ });
600
+ }
601
+ });
602
+ });
603
+ u1.forEach((v1) => {
604
+ nodes.forEach((node) => {
605
+ if (node.id() == v1.id()) {
606
+ node.data('parallelGroupIdVec', [...node.data('parallelGroupIdVec'), 'Parallel' + parallelId]);
607
+ }
608
+ });
609
+ });
610
+ parallelId++;
611
+ }
612
+ }
613
+ console.log("num of chains: ", chains.length);
614
+ };
615
+ SubstructureLayout.prototype.run = function () {
616
+ this.stopped = false;
617
+ // ============================================================
618
+ // Clean up compound parents generated by previous layout run
619
+ // ============================================================
620
+ const oldParents = this.cy.nodes('.substructure-group');
621
+ oldParents.forEach((parent) => {
622
+ // Move children back to root level first
623
+ parent.children().forEach((child) => {
624
+ child.move({
625
+ parent: null
626
+ });
627
+ });
628
+ });
629
+ // Now it is safe to remove the generated parents
630
+ oldParents.remove();
631
+ this.trigger({
632
+ type: 'layoutstart',
633
+ layout: this
634
+ });
635
+ const params = this.params;
636
+ const components = this.cy.elements().components();
637
+ const networkInfos = [];
638
+ components.forEach((component, index) => {
639
+ this.vnodes = [];
640
+ this.vedges = [];
641
+ console.log(`independent net: ${index + 1}`);
642
+ // 1. get user defined boundingBox,if not then default cy container
643
+ const bb = this.boundingBox || this.cy.extent();
644
+ const width = bb.x2 - bb.x1;
645
+ const height = bb.y2 - bb.y1;
646
+ // none parent nodes
647
+ let nodes = component.nodes().filter((node) => !node.data('isParent'));
648
+ const seenPairs = new Set();
649
+ const edgeSet = this.eles.edges().filter((edge) => {
650
+ const sourceId = edge.source().id();
651
+ const targetId = edge.target().id();
652
+ // remove self-loop from layout
653
+ if (sourceId === targetId)
654
+ return false;
655
+ const pairKey = [sourceId, targetId].sort().join('---');
656
+ // remove duplicate edge from layout
657
+ if (seenPairs.has(pairKey))
658
+ return false;
659
+ seenPairs.add(pairKey);
660
+ return true;
661
+ });
662
+ // 2. NOW define your collections to get the updated state
663
+ if (params.RANDOMIZE_INITIAL_POSITIONS) {
664
+ nodes.forEach((node) => {
665
+ node.position({
666
+ x: bb.x1 + Math.random() * width, y: bb.y1 + Math.random() * height
667
+ });
668
+ });
669
+ }
670
+ console.log("num of nodes: " + nodes.length);
671
+ console.log("num of edges: " + edgeSet.length);
672
+ // Capture initial state
673
+ this.captureStep('Initial', 'Initial node positions before any layout', {
674
+ nodeCount: nodes.length, edgeCount: edgeSet.length
675
+ });
676
+ // 1. Identify structures (mark structType and use components to separate independent cycles)
677
+ this.identifyStructures(nodes);
678
+ this.captureStep('Structure Detection', 'Structures identified (Stars, Cycles, Chains, Parallel)', {
679
+ stars: nodes.filter((n) => n.data('structType')?.startsWith('Star')).length,
680
+ cycles: nodes.filter((n) => n.data('structType') === 'Cycle').length,
681
+ chains: nodes.filter((n) => n.data('structType') === 'Chain').length,
682
+ parallel: nodes.filter((n) => n.data('structType') === 'Parallel').length
683
+ });
684
+ //************ Classify and save each type into the virtual-node array *///////////
685
+ if (1) {
686
+ nodes.forEach((n) => {
687
+ if (n.data('structType') === 'Normal' || n.data('structType') === 'LeafButNotChain') {
688
+ let nodesarray = [];
689
+ nodesarray.push(n);
690
+ this.vnodes.push({
691
+ type: 'Normal',
692
+ id: n.id(),
693
+ center_x: n.data.x,
694
+ center_y: n.data.y,
695
+ radius: 1,
696
+ rotate_angle: 0,
697
+ nodes: nodesarray
698
+ });
699
+ // }else if (n.data('structType') === 'Cycle') {
700
+ }
701
+ else if (n.data('structs')?.Cycle != null) {
702
+ let flag = true;
703
+ this.vnodes.forEach((vp) => {
704
+ // if (vp.type === 'Cycle' && vp.id === n.data('groupId')) {
705
+ if (vp.type === 'Cycle' && vp.id === n.data('structs')?.Cycle?.groupId) { // for cycle that has star
706
+ vp.nodes.push(n);
707
+ flag = false;
708
+ }
709
+ });
710
+ if (flag) { // not saved yet
711
+ let nodesarray = [];
712
+ nodesarray.push(n);
713
+ this.vnodes.push({
714
+ type: 'Cycle',
715
+ id: n.data("groupId"),
716
+ center_x: n.data.x,
717
+ center_y: n.data.y,
718
+ radius: 1,
719
+ rotate_angle: 0,
720
+ nodes: nodesarray
721
+ });
722
+ }
723
+ }
724
+ else if (n.data('structType') === 'Chain') {
725
+ let flag = true;
726
+ this.vnodes.forEach((vp) => {
727
+ if (vp.type === 'Chain' && vp.id === n.data('groupId')) {
728
+ vp.nodes.push(n);
729
+ flag = false;
730
+ }
731
+ });
732
+ if (flag) { // not saved yet
733
+ let nodesarray = [];
734
+ nodesarray.push(n);
735
+ this.vnodes.push({
736
+ type: 'Chain',
737
+ id: n.data("groupId"),
738
+ center_x: n.data.x,
739
+ center_y: n.data.y,
740
+ radius: 1,
741
+ rotate_angle: 0,
742
+ nodes: nodesarray
743
+ });
744
+ }
745
+ }
746
+ else if (n.data('structType') === 'Parallel') {
747
+ let flag = true;
748
+ this.vnodes.forEach((vp) => {
749
+ if (vp.type === 'Parallel' && vp.id === n.data('groupId')) {
750
+ vp.nodes.push(n);
751
+ flag = false;
752
+ }
753
+ });
754
+ if (flag) { // not saved yet
755
+ let nodesarray = [];
756
+ nodesarray.push(n);
757
+ this.vnodes.push({
758
+ type: 'Parallel',
759
+ id: n.data("groupId"),
760
+ center_x: n.data.x,
761
+ center_y: n.data.y,
762
+ radius: 1,
763
+ rotate_angle: 0,
764
+ nodes: nodesarray
765
+ });
766
+ }
767
+ }
768
+ else if (n.data('structType') == 'Star-Center' || n.data('structType') == 'Star-Member') {
769
+ // } else if ((n.data('structType') == 'Star-Center' || n.data('structType') == 'Star-Member' ) && (n.data('structs')?.Cycle == null ) ) {
770
+ let flag = true;
771
+ this.vnodes.forEach((vp) => {
772
+ if (vp.type === 'Star' && vp.id === n.data('groupId')) {
773
+ vp.nodes.push(n);
774
+ flag = false;
775
+ }
776
+ });
777
+ if (flag) { // not saved yet
778
+ let nodesarray = [];
779
+ nodesarray.push(n);
780
+ this.vnodes.push({
781
+ type: 'Star',
782
+ id: n.data("groupId"),
783
+ center_x: n.data.x,
784
+ center_y: n.data.y,
785
+ radius: 1,
786
+ rotate_angle: 0,
787
+ nodes: nodesarray
788
+ });
789
+ }
790
+ }
791
+ });
792
+ }
793
+ this.captureStep('Virtual Nodes Created', 'Virtual nodes created for structures', { vnodeCount: this.vnodes.length });
794
+ //construct virtual edges for virtual nodes
795
+ if (1) {
796
+ // --------------------------------------------------------
797
+ // 1. Build mapping: real Node ID -> VNode
798
+ // --------------------------------------------------------
799
+ const nodeToVNode = new Map();
800
+ for (const vnode of this.vnodes) {
801
+ if (!vnode.nodes)
802
+ continue;
803
+ for (const node of vnode.nodes) {
804
+ // Should Star-Member participate in VNode edge construction?
805
+ // Keep this consistent with the original logic:
806
+ //
807
+ // In the original code, when actually creating edges:
808
+ // if (n1.data("structType") != 'Star-Member')
809
+ //
810
+ // Therefore, Star-Member can be ignored here.
811
+ if (node.data("structType") === "Star-Member") {
812
+ continue;
813
+ }
814
+ nodeToVNode.set(node.id(), vnode);
815
+ }
816
+ }
817
+ // --------------------------------------------------------
818
+ // 2. Traverse the real Edges
819
+ //
820
+ // Determine directly:
821
+ //
822
+ // realNode1 -> VNode1
823
+ // realNode2 -> VNode2
824
+ //
825
+ // Then:
826
+ //
827
+ // VNode1 <-> VNode2 : connectionCount++
828
+ // --------------------------------------------------------
829
+ const connectionMap = new Map();
830
+ for (const edge of edgeSet) {
831
+ const sourceNode = edge.source();
832
+ const targetNode = edge.target();
833
+ const sourceId = sourceNode.id();
834
+ const targetId = targetNode.id();
835
+ const sourceVNode = nodeToVNode.get(sourceId);
836
+ const targetVNode = nodeToVNode.get(targetId);
837
+ // If the corresponding VNode cannot be found, skip it
838
+ if (!sourceVNode || !targetVNode) {
839
+ continue;
840
+ }
841
+ // ----------------------------------------------------
842
+ // Edge is inside the same VNode
843
+ //
844
+ // Original logic:
845
+ //
846
+ // count == 2
847
+ //
848
+ // This means both ends of the edge are in the same VNode,
849
+ // so no virtual edge is created.
850
+ // ----------------------------------------------------
851
+ if (sourceVNode === targetVNode) {
852
+ continue;
853
+ }
854
+ // ----------------------------------------------------
855
+ // To ensure that:
856
+ //
857
+ // A -> B
858
+ // B -> A
859
+ //
860
+ // A -> B and B -> A are treated as the same VEdge
861
+ //
862
+ // Use the VNode index / ID to build a unique key
863
+ // ----------------------------------------------------
864
+ const id1 = sourceVNode.id;
865
+ const id2 = targetVNode.id;
866
+ const key = id1 < id2 ? `${id1}---${id2}` : `${id2}---${id1}`;
867
+ const existing = connectionMap.get(key);
868
+ if (existing) {
869
+ existing.count++;
870
+ }
871
+ else {
872
+ connectionMap.set(key, {
873
+ source: id1 < id2 ? sourceVNode : targetVNode,
874
+ target: id1 < id2 ? targetVNode : sourceVNode,
875
+ count: 1
876
+ });
877
+ }
878
+ }
879
+ // --------------------------------------------------------
880
+ // 3. Create VEdges based on connectionMap
881
+ // --------------------------------------------------------
882
+ connectionMap.forEach(({ source, target, count }) => {
883
+ const vedge = new VEdge(source, target, count);
884
+ this.vedges.push(vedge);
885
+ });
886
+ }
887
+ this.captureStep('Virtual Edges Created', 'Virtual edges created between virtual nodes', { vedgeCount: this.vedges.length });
888
+ ///////////////////////////////////////////////////////////////////////////
889
+ /////////////////////////update virtual nodes's neighbors////////////////////////////////
890
+ this.vedges.forEach((vedge) => {
891
+ vedge.source.neighbors = vedge.source.neighbors || [];
892
+ vedge.target.neighbors = vedge.target.neighbors || [];
893
+ vedge.source.neighbors.push(vedge.target);
894
+ vedge.target.neighbors.push(vedge.source);
895
+ });
896
+ ///////////////////////////////// Update the centers of virtual nodes //////////////////////////
897
+ this.vnodes.forEach((v1) => {
898
+ if (v1.nodes && v1.nodes.length > 0) {
899
+ const sumX = v1.nodes.reduce((acc, curr) => acc + (curr.position().x || 0), 0);
900
+ const sumY = v1.nodes.reduce((acc, curr) => acc + (curr.position().y || 0), 0);
901
+ v1.center_x = sumX / v1.nodes.length;
902
+ v1.center_y = sumY / v1.nodes.length;
903
+ }
904
+ else {
905
+ v1.center_x = v1.center_x || 0;
906
+ v1.center_y = v1.center_y || 0;
907
+ }
908
+ });
909
+ //////////////////////////////// Update the radius of virtual nodes /////////////////////////////
910
+ if (1) {
911
+ this.vnodes.forEach((v1) => {
912
+ if (v1.type == 'Star') {
913
+ const allMemberNode = v1.nodes.filter((node) => node.data('structType') !== 'Star-Center');
914
+ const ringSpacing = params.STAR_RING_SPACING;
915
+ const baseNodesInFirstRing = params.STAR_BASE_NODES_PER_RING;
916
+ // 1. Pre-calculate how many nodes go into each ring
917
+ const rings = [];
918
+ let tempNodes = [...allMemberNode];
919
+ let currentRingSize = baseNodesInFirstRing;
920
+ while (tempNodes.length > 0) {
921
+ // Take the next chunk of nodes for this ring
922
+ rings.push(tempNodes.splice(0, currentRingSize));
923
+ // Increase capacity for the next ring
924
+ currentRingSize += baseNodesInFirstRing;
925
+ }
926
+ v1.radius = (rings.length + 0) * ringSpacing;
927
+ }
928
+ else if (v1.type == 'Cycle') {
929
+ const count = v1.nodes.length;
930
+ const k = params.CYCLE_NODE_SPACING;
931
+ v1.radius = (count * k) / (2 * Math.PI);
932
+ }
933
+ else if (v1.type == 'Parallel') {
934
+ // v1.radius = 300;
935
+ const n = v1.nodes.length;
936
+ if (n === 0) {
937
+ v1.radius = 0;
938
+ }
939
+ else {
940
+ // 1. Mirror the core row/column logic of the original layout function
941
+ let finalCols = Math.ceil(Math.sqrt(n));
942
+ if (n < 3)
943
+ finalCols = n;
944
+ const rows = Math.ceil(n / finalCols);
945
+ // 2. Use the spacing values passed to the layout (assuming both are 60)
946
+ const colSpacing = 60;
947
+ const rowSpacing = 60;
948
+ // 3. Calculate the grid width and height (center-to-edge distances)
949
+ const totalWidth = (finalCols - 1) * colSpacing;
950
+ const totalHeight = (rows - 1) * rowSpacing;
951
+ // 4. Use the Pythagorean theorem to calculate the distance from the center to a corner, plus a safety margin for one node itself (e.g. 20)
952
+ const nodeSelfRadius = 20;
953
+ v1.radius = Math.sqrt((totalWidth / 2) ** 2 + (totalHeight / 2) ** 2) + nodeSelfRadius;
954
+ }
955
+ }
956
+ else if (v1.type == 'Chain') {
957
+ const count = v1.nodes.length;
958
+ const miniMumRadius = params.CHAIN_MIN_RADIUS;
959
+ v1.radius = Math.max((count * params.CYCLE_NODE_SPACING) / (2 * Math.PI), miniMumRadius);
960
+ }
961
+ else {
962
+ v1.radius = 10;
963
+ }
964
+ });
965
+ }
966
+ this.captureStep('Virtual Nodes Positioned', 'Virtual node centers and radii calculated', null);
967
+ //******************** virtual node force layout ************************
968
+ if (params.LAYOUT_ALGORITHM === 'force') {
969
+ const IDEAL_LENGTH = params.IDEAL_LENGTH;
970
+ // const IDEAL_LENGTH=3000;
971
+ const REPULSION = params.REPULSION;
972
+ const SPRING_K = params.SPRING_K;
973
+ const ITERATIONS = params.ITERATIONS;
974
+ // const ITERATIONS =2000;
975
+ // const ANGULAR_STRENGTH = params.ANGULAR_STRENGTH;
976
+ //const USE_ANGULAR_FORCE = params.USE_ANGULAR_FORCE;
977
+ var colisionFlag = true;
978
+ let iter = 0;
979
+ var maxAttractMove = 10e10;
980
+ var maxRepulsetMove = 10e10;
981
+ var numOfCollision = 0;
982
+ // Adjust the exit threshold: only consider the whole graph truly stationary when the maximum movement of every node is below 0.5 pixels
983
+ const ENERGY_THRESHOLD = 0.5;
984
+ // Build the adjacency list in advance so it can be reused seamlessly for both "initial ordering" and the "later angular force"
985
+ const adj = new Map();
986
+ this.vedges.forEach((e) => {
987
+ if (!e.source || !e.target)
988
+ return;
989
+ if (!adj.has(e.source.id))
990
+ adj.set(e.source.id, []);
991
+ if (!adj.has(e.target.id))
992
+ adj.set(e.target.id, []);
993
+ adj.get(e.source.id).push(e.target);
994
+ adj.get(e.target.id).push(e.source);
995
+ });
996
+ this.vnodes.forEach((node) => {
997
+ if (!node._permanentOrder) {
998
+ const allNeighbors = adj.get(node.id) || [];
999
+ const leafNeighbors = allNeighbors.filter((nb) => {
1000
+ const nbEdges = adj.get(nb.id) || [];
1001
+ return nbEdges.length <= 2;
1002
+ });
1003
+ if (leafNeighbors.length >= 2) {
1004
+ // Sort to establish a clean initial topological ordering
1005
+ leafNeighbors.sort((a, b) => a.id.localeCompare(b.id));
1006
+ node._permanentOrder = leafNeighbors.map((a) => a.id);
1007
+ // Also give them an initial star-shaped radial geometric distribution that is guaranteed not to cross
1008
+ leafNeighbors.forEach((nb, index) => {
1009
+ const initAngle = (Math.PI * 2 / leafNeighbors.length) * index;
1010
+ nb.center_x = node.center_x + Math.cos(initAngle) * IDEAL_LENGTH;
1011
+ nb.center_y = node.center_y + Math.sin(initAngle) * IDEAL_LENGTH;
1012
+ });
1013
+ }
1014
+ }
1015
+ });
1016
+ while (iter < ITERATIONS) {
1017
+ if (this.stopped) {
1018
+ break;
1019
+ }
1020
+ iter++;
1021
+ // Reset total movement-energy statistics before each iteration
1022
+ maxAttractMove = 0;
1023
+ maxRepulsetMove = 0;
1024
+ let maxAngularMove = 0;
1025
+ numOfCollision = 0;
1026
+ ////////////////////// Place each node at the position of its corresponding virtual node
1027
+ this.vnodes.forEach((v) => {
1028
+ v.nodes.forEach((n) => {
1029
+ n.position().x = v.center_x + Math.random() * 5;
1030
+ n.position().y = v.center_y + Math.random() * 5;
1031
+ });
1032
+ });
1033
+ // [Core correction] Perform all physical calculations in virtual space; no longer repeatedly shuffle real nodes randomly within each frame
1034
+ this.captureStep('Virtual Nodes Positioned step ' + iter, 'Virtual node centers and radii calculated', null);
1035
+ // Count the current number of collisions
1036
+ for (let i = 0; i < this.vnodes.length; i++) {
1037
+ for (let j = i + 1; j < this.vnodes.length; j++) {
1038
+ const n1 = this.vnodes[i];
1039
+ const n2 = this.vnodes[j];
1040
+ let dx = n1.center_x - n2.center_x;
1041
+ let dy = n1.center_y - n2.center_y;
1042
+ const centerDist = Math.sqrt(dx * dx + dy * dy) || 1;
1043
+ if (centerDist < (n1.radius + n2.radius)) {
1044
+ numOfCollision++;
1045
+ }
1046
+ }
1047
+ }
1048
+ // Calculate the current global cooling factor (the core simulated-annealing control)
1049
+ const cooling = Math.pow(1 - iter / ITERATIONS, 2);
1050
+ /* ---------- A. Attraction (Spring) ---------- */
1051
+ if (1) {
1052
+ this.vedges.forEach((e) => {
1053
+ const s = e.source;
1054
+ const t = e.target;
1055
+ if (!s || !t)
1056
+ return;
1057
+ const dx = t.center_x - s.center_x;
1058
+ const dy = t.center_y - s.center_y;
1059
+ const centerDist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
1060
+ const surfaceDist = centerDist - (s.radius + t.radius);
1061
+ const delta = Math.max(0, surfaceDist - IDEAL_LENGTH);
1062
+ // Apply the cooling factor to the attractive force
1063
+ let force = SPRING_K * delta * cooling;
1064
+ const fx = (force * dx) / centerDist;
1065
+ const fy = (force * dy) / centerDist;
1066
+ s.center_x += fx;
1067
+ s.center_y += fy;
1068
+ t.center_x -= fx;
1069
+ t.center_y -= fy;
1070
+ maxAttractMove = Math.max(maxAttractMove, Math.abs(fx), Math.abs(fy));
1071
+ });
1072
+ }
1073
+ /* ---------- B. Repulsion ---------- */
1074
+ if (1) {
1075
+ const MIN_GAP = 0.1;
1076
+ for (let i = 0; i < this.vnodes.length; i++) {
1077
+ for (let j = i + 1; j < this.vnodes.length; j++) {
1078
+ const n1 = this.vnodes[i];
1079
+ const n2 = this.vnodes[j];
1080
+ let dx = n1.center_x - n2.center_x;
1081
+ let dy = n1.center_y - n2.center_y;
1082
+ if (dx === 0 && dy === 0) {
1083
+ dx = (Math.random() - 0.5) * 0.1;
1084
+ dy = (Math.random() - 0.5) * 0.1;
1085
+ }
1086
+ const centerDist = Math.sqrt(dx * dx + dy * dy) || 1;
1087
+ const minDistance = n1.radius + n2.radius;
1088
+ let force = 0;
1089
+ if (centerDist < minDistance) {
1090
+ const overlap = minDistance - centerDist;
1091
+ force = (REPULSION * 5) * (overlap / (centerDist + MIN_GAP));
1092
+ }
1093
+ else {
1094
+ const gap = centerDist - minDistance;
1095
+ force = REPULSION / (gap * gap + 20);
1096
+ }
1097
+ // Apply the cooling factor to the repulsive force
1098
+ force *= cooling;
1099
+ const maxForceLimit = REPULSION * 2 * cooling;
1100
+ if (force > maxForceLimit)
1101
+ force = maxForceLimit;
1102
+ const fx = (force * dx) / centerDist;
1103
+ const fy = (force * dy) / centerDist;
1104
+ this.vnodes[i].center_x += fx;
1105
+ this.vnodes[i].center_y += fy;
1106
+ this.vnodes[j].center_x -= fx;
1107
+ this.vnodes[j].center_y -= fy;
1108
+ maxRepulsetMove = Math.max(maxRepulsetMove, Math.abs(fx), Math.abs(fy));
1109
+ }
1110
+ }
1111
+ }
1112
+ /* ---------- C. Angular Repulsion ---------- */
1113
+ if (params.USE_ANGULAR_FORCE) {
1114
+ const norm = (a) => Math.atan2(Math.sin(a), Math.cos(a));
1115
+ const ANGULAR_STRENGTH = params.ANGULAR_STRENGTH;
1116
+ const MAX_FORCE = 2.0;
1117
+ this.vnodes.forEach((node) => {
1118
+ if (!node._permanentOrder || node._permanentOrder.length < 2) {
1119
+ return;
1120
+ }
1121
+ const orderMap = new Map(node._permanentOrder.map((id, index) => [id, index]));
1122
+ const leaves = this.vnodes.filter((v) => orderMap.has(v.id));
1123
+ const count = leaves.length;
1124
+ if (count < 2) {
1125
+ return;
1126
+ }
1127
+ const idealGap = (Math.PI * 2) / count;
1128
+ const items = leaves
1129
+ .map((nb) => {
1130
+ const dx = nb.center_x - node.center_x;
1131
+ const dy = nb.center_y - node.center_y;
1132
+ return {
1133
+ nb, dx, dy, dist: Math.sqrt(dx * dx + dy * dy) || 1, angle: Math.atan2(dy, dx)
1134
+ };
1135
+ })
1136
+ .sort((a, b) => a.angle - b.angle);
1137
+ for (let i = 0; i < count; i++) {
1138
+ const left = items[i];
1139
+ const right = items[(i + 1) % count];
1140
+ let gap = norm(right.angle - left.angle);
1141
+ if (gap < 0) {
1142
+ gap += Math.PI * 2;
1143
+ }
1144
+ // The gap is already large enough
1145
+ if (gap >= idealGap) {
1146
+ continue;
1147
+ }
1148
+ const gapError = idealGap - gap;
1149
+ let force = gapError * ANGULAR_STRENGTH;
1150
+ force = Math.min(force, MAX_FORCE);
1151
+ // Tangential direction of the left item
1152
+ const ltx = -left.dy / left.dist;
1153
+ const lty = left.dx / left.dist;
1154
+ // Tangential direction of the right item
1155
+ const rtx = -right.dy / right.dist;
1156
+ const rty = right.dx / right.dist;
1157
+ // Push them apart in opposite directions
1158
+ left.nb.center_x -= ltx * force;
1159
+ left.nb.center_y -= lty * force;
1160
+ right.nb.center_x += rtx * force;
1161
+ right.nb.center_y += rty * force;
1162
+ if (typeof maxAngularMove !== "undefined") {
1163
+ maxAngularMove = Math.max(maxAngularMove, force);
1164
+ }
1165
+ }
1166
+ });
1167
+ }
1168
+ // [Exit criterion] Only allow an early safe exit when all movement energy in the graph has completely settled and become extremely small
1169
+ const totalMaxMovement = Math.max(maxAttractMove, maxRepulsetMove, maxAngularMove);
1170
+ if (totalMaxMovement < ENERGY_THRESHOLD && iter > 10) {
1171
+ break;
1172
+ }
1173
+ }
1174
+ this.vnodes.forEach((v) => {
1175
+ v.nodes.forEach((n) => {
1176
+ n.position().x = v.center_x;
1177
+ n.position().y = v.center_y;
1178
+ });
1179
+ });
1180
+ //********************** anti-collision step
1181
+ if (1) {
1182
+ colisionFlag = true;
1183
+ numOfCollision = 0;
1184
+ const padding = IDEAL_LENGTH;
1185
+ let iter = 0;
1186
+ while (colisionFlag) {
1187
+ if (this.stopped) {
1188
+ break;
1189
+ }
1190
+ iter++;
1191
+ colisionFlag = false;
1192
+ for (let i = 0; i < this.vnodes.length; i++) {
1193
+ for (let j = i + 1; j < this.vnodes.length; j++) {
1194
+ const n1 = this.vnodes[i];
1195
+ const n2 = this.vnodes[j];
1196
+ let dx = n1.center_x - n2.center_x;
1197
+ let dy = n1.center_y - n2.center_y;
1198
+ // if two nodes are exactly the same position
1199
+ if (dx === 0 && dy === 0) {
1200
+ dx = (Math.random() - 0.5) * 0.1;
1201
+ dy = (Math.random() - 0.5) * 0.1;
1202
+ }
1203
+ const centerDist = Math.sqrt(dx * dx + dy * dy) || 1;
1204
+ const r1 = n1.radius;
1205
+ const r2 = n2.radius;
1206
+ const minDistance = r1 + r2;
1207
+ if (centerDist < (minDistance + padding)) {
1208
+ colisionFlag = true;
1209
+ numOfCollision++;
1210
+ // 1.2 Calculate the physical overlap distance
1211
+ const overlap = minDistance + padding - centerDist;
1212
+ // 1.3 Calculate the normalized direction vector
1213
+ let nx = dx / centerDist;
1214
+ let ny = dy / centerDist;
1215
+ // 1.4 Move each node half the distance in opposite directions (50%)
1216
+ const moveDistance = overlap / 2;
1217
+ const offsetX = nx * moveDistance;
1218
+ const offsetY = ny * moveDistance;
1219
+ n1.center_x += offsetX;
1220
+ n1.center_y += offsetY;
1221
+ n2.center_x -= offsetX;
1222
+ n2.center_y -= offsetY;
1223
+ }
1224
+ }
1225
+ }
1226
+ // [Optimization 3] Safety valve: exit directly if the iteration count becomes too large (typically when nodes are extremely dense)
1227
+ if (numOfCollision > 10000) {
1228
+ console.warn("avoid dead loop");
1229
+ break;
1230
+ }
1231
+ // this.captureStep('Anti-collision', 'Eliminate all collisions ' + iter, { iterations: ITERATIONS });
1232
+ }
1233
+ this.captureStep('Anti-collision', 'Eliminate all collisions', { iterations: ITERATIONS });
1234
+ if (1) {
1235
+ numOfCollision = 0;
1236
+ for (let i = 0; i < this.vnodes.length; i++) {
1237
+ for (let j = i + 1; j < this.vnodes.length; j++) {
1238
+ const n1 = this.vnodes[i];
1239
+ const n2 = this.vnodes[j];
1240
+ let dx = n1.center_x - n2.center_x;
1241
+ let dy = n1.center_y - n2.center_y;
1242
+ const centerDist = Math.sqrt(dx * dx + dy * dy);
1243
+ if (centerDist < (IDEAL_LENGTH + n1.radius + n2.radius)) {
1244
+ numOfCollision++;
1245
+ }
1246
+ }
1247
+ }
1248
+ }
1249
+ }
1250
+ this.captureStep('Virtual Node Layout', 'Force-directed layout applied to virtual nodes', { iterations: ITERATIONS });
1251
+ }
1252
+ //******************** stress force layout ************************
1253
+ // Stress Majorization algorithm
1254
+ // All-Pairs shortest-path calculation and Guttman Transform (weighted Laplacian updates),
1255
+ // while retaining the polar-coordinate initialization for leaf nodes and the Anti-Collision post-processing in the original code
1256
+ if (params.LAYOUT_ALGORITHM === 'stress') {
1257
+ const IDEAL_LENGTH = params.IDEAL_LENGTH;
1258
+ const ITERATIONS = params.ITERATIONS;
1259
+ const ENERGY_THRESHOLD = 0.5;
1260
+ const numNodes = this.vnodes.length;
1261
+ if (numNodes === 0)
1262
+ return;
1263
+ // Index mapping: ID -> array index, for matrix operations
1264
+ const nodeIndexMap = new Map();
1265
+ this.vnodes.forEach((v, i) => nodeIndexMap.set(v.id, i));
1266
+ // Extract the adjacency list for reuse during initialization
1267
+ const adj = new Map();
1268
+ this.vedges.forEach((e) => {
1269
+ if (!e.source || !e.target)
1270
+ return;
1271
+ if (!adj.has(e.source.id))
1272
+ adj.set(e.source.id, []);
1273
+ if (!adj.has(e.target.id))
1274
+ adj.set(e.target.id, []);
1275
+ adj.get(e.source.id).push(e.target);
1276
+ adj.get(e.target.id).push(e.source);
1277
+ });
1278
+ // -------------------------------------------------------------
1279
+ // [Preprocessing 1] Calculate the shortest-path distance matrix between every pair of nodes in the graph (APSP - Floyd Warshall)
1280
+ // -------------------------------------------------------------
1281
+ const distMatrix = Array.from({ length: numNodes }, () => new Array(numNodes).fill(Infinity));
1282
+ const weightMatrix = Array.from({ length: numNodes }, () => new Array(numNodes).fill(0));
1283
+ for (let i = 0; i < numNodes; i++)
1284
+ distMatrix[i][i] = 0;
1285
+ // Assign the ideal distance baseline based on real edges
1286
+ this.vedges.forEach((e) => {
1287
+ if (!e.source || !e.target)
1288
+ return;
1289
+ const u = nodeIndexMap.get(e.source.id);
1290
+ const v = nodeIndexMap.get(e.target.id);
1291
+ if (u !== undefined && v !== undefined) {
1292
+ const idealDist = IDEAL_LENGTH + e.source.radius + e.target.radius;
1293
+ distMatrix[u][v] = Math.min(distMatrix[u][v], idealDist);
1294
+ distMatrix[v][u] = Math.min(distMatrix[v][u], idealDist);
1295
+ }
1296
+ });
1297
+ // Use Floyd-Warshall to compute shortest paths between all node pairs
1298
+ for (let k = 0; k < numNodes; k++) {
1299
+ for (let i = 0; i < numNodes; i++) {
1300
+ for (let j = 0; j < numNodes; j++) {
1301
+ if (distMatrix[i][k] + distMatrix[k][j] < distMatrix[i][j]) {
1302
+ distMatrix[i][j] = distMatrix[i][k] + distMatrix[k][j];
1303
+ }
1304
+ }
1305
+ }
1306
+ }
1307
+ // Calculate the weight matrix W_ij = 1 / (d_ij ^ 2)
1308
+ for (let i = 0; i < numNodes; i++) {
1309
+ for (let j = 0; j < numNodes; j++) {
1310
+ if (i !== j && distMatrix[i][j] !== Infinity) {
1311
+ // Account for the actual node radius to prevent excessive crowding
1312
+ const minR = this.vnodes[i].radius + this.vnodes[j].radius;
1313
+ const d = Math.max(distMatrix[i][j], minR);
1314
+ weightMatrix[i][j] = 1 / (d * d);
1315
+ }
1316
+ }
1317
+ }
1318
+ // -------------------------------------------------------------
1319
+ // [Preprocessing 2] Establish topological ordering and initial coordinates
1320
+ // -------------------------------------------------------------
1321
+ this.vnodes.forEach((node) => {
1322
+ if (!node._permanentOrder) {
1323
+ const allNeighbors = adj.get(node.id) || [];
1324
+ const leafNeighbors = allNeighbors.filter((nb) => {
1325
+ const nbEdges = adj.get(nb.id) || [];
1326
+ return nbEdges.length <= 2;
1327
+ });
1328
+ if (leafNeighbors.length >= 2) {
1329
+ leafNeighbors.sort((a, b) => a.id.localeCompare(b.id));
1330
+ node._permanentOrder = leafNeighbors.map((a) => a.id);
1331
+ leafNeighbors.forEach((nb, index) => {
1332
+ const initAngle = (Math.PI * 2 / leafNeighbors.length) * index;
1333
+ nb.center_x = node.center_x + Math.cos(initAngle) * IDEAL_LENGTH;
1334
+ nb.center_y = node.center_y + Math.sin(initAngle) * IDEAL_LENGTH;
1335
+ });
1336
+ }
1337
+ }
1338
+ });
1339
+ // -------------------------------------------------------------
1340
+ // [Main loop] Stress Majorization iteration (Guttman Transform)
1341
+ // -------------------------------------------------------------
1342
+ let iter = 0;
1343
+ while (iter < ITERATIONS) {
1344
+ if (this.stopped) {
1345
+ break;
1346
+ }
1347
+ iter++;
1348
+ let maxStressMove = 0;
1349
+ // Temporary array for the new coordinates calculated in this iteration
1350
+ const nextX = new Float64Array(numNodes);
1351
+ const nextY = new Float64Array(numNodes);
1352
+ for (let i = 0; i < numNodes; i++) {
1353
+ const vi = this.vnodes[i];
1354
+ let sumWeight = 0;
1355
+ let sumX = 0;
1356
+ let sumY = 0;
1357
+ for (let j = 0; j < numNodes; j++) {
1358
+ if (i === j)
1359
+ continue;
1360
+ const vj = this.vnodes[j];
1361
+ const wij = weightMatrix[i][j];
1362
+ if (wij === 0)
1363
+ continue;
1364
+ const dij = distMatrix[i][j];
1365
+ let dx = vi.center_x - vj.center_x;
1366
+ let dy = vi.center_y - vj.center_y;
1367
+ if (dx === 0 && dy === 0) {
1368
+ dx = (Math.random() - 0.5) * 0.1;
1369
+ dy = (Math.random() - 0.5) * 0.1;
1370
+ }
1371
+ const currentDist = Math.sqrt(dx * dx + dy * dy) || 1;
1372
+ // Guttman Transform formula: calculate the weighted average obtained by projecting/pulling all other nodes j toward/from i according to the ideal distance dij
1373
+ const invDist = dij / currentDist;
1374
+ sumX += wij * (vj.center_x + dx * invDist);
1375
+ sumY += wij * (vj.center_y + dy * invDist);
1376
+ sumWeight += wij;
1377
+ }
1378
+ if (sumWeight > 0) {
1379
+ nextX[i] = sumX / sumWeight;
1380
+ nextY[i] = sumY / sumWeight;
1381
+ }
1382
+ else {
1383
+ nextX[i] = vi.center_x;
1384
+ nextY[i] = vi.center_y;
1385
+ }
1386
+ }
1387
+ // Apply coordinate updates and track the maximum displacement
1388
+ for (let i = 0; i < numNodes; i++) {
1389
+ const vi = this.vnodes[i];
1390
+ const moveX = Math.abs(nextX[i] - vi.center_x);
1391
+ const moveY = Math.abs(nextY[i] - vi.center_y);
1392
+ maxStressMove = Math.max(maxStressMove, moveX, moveY);
1393
+ vi.center_x = nextX[i];
1394
+ vi.center_y = nextY[i];
1395
+ }
1396
+ // Check the convergence condition and exit early
1397
+ if (maxStressMove < ENERGY_THRESHOLD && iter > 10) {
1398
+ break;
1399
+ }
1400
+ }
1401
+ // Map the calculated virtual coordinates back to the real nodes
1402
+ this.vnodes.forEach((v) => {
1403
+ v.nodes.forEach((n) => {
1404
+ n.position().x = v.center_x;
1405
+ n.position().y = v.center_y;
1406
+ });
1407
+ });
1408
+ // -------------------------------------------------------------
1409
+ // [Post-processing] Collision / overlap removal (Node Overlap Removal)
1410
+ // -------------------------------------------------------------
1411
+ if (1) {
1412
+ let colisionFlag = true;
1413
+ let numOfCollision = 0;
1414
+ const padding = IDEAL_LENGTH;
1415
+ let overlapIter = 0;
1416
+ while (colisionFlag) {
1417
+ if (this.stopped) {
1418
+ break;
1419
+ }
1420
+ overlapIter++;
1421
+ colisionFlag = false;
1422
+ for (let i = 0; i < this.vnodes.length; i++) {
1423
+ for (let j = i + 1; j < this.vnodes.length; j++) {
1424
+ const n1 = this.vnodes[i];
1425
+ const n2 = this.vnodes[j];
1426
+ let dx = n1.center_x - n2.center_x;
1427
+ let dy = n1.center_y - n2.center_y;
1428
+ if (dx === 0 && dy === 0) {
1429
+ dx = (Math.random() - 0.5) * 0.1;
1430
+ dy = (Math.random() - 0.5) * 0.1;
1431
+ }
1432
+ const centerDist = Math.sqrt(dx * dx + dy * dy) || 1;
1433
+ const minDistance = n1.radius + n2.radius;
1434
+ if (centerDist < (minDistance + padding)) {
1435
+ colisionFlag = true;
1436
+ numOfCollision++;
1437
+ const overlap = minDistance + padding - centerDist;
1438
+ const nx = dx / centerDist;
1439
+ const ny = dy / centerDist;
1440
+ const moveDistance = overlap / 2;
1441
+ n1.center_x += nx * moveDistance;
1442
+ n1.center_y += ny * moveDistance;
1443
+ n2.center_x -= nx * moveDistance;
1444
+ n2.center_y -= ny * moveDistance;
1445
+ }
1446
+ }
1447
+ }
1448
+ if (numOfCollision > 10000 || overlapIter > 300) {
1449
+ console.warn("Avoid dead loop in overlap removal");
1450
+ break;
1451
+ }
1452
+ }
1453
+ this.captureStep('Anti-collision', 'Eliminate all collisions', { iterations: ITERATIONS });
1454
+ }
1455
+ this.captureStep('Virtual Node Layout', 'Stress Majorization layout applied to virtual nodes', { iterations: ITERATIONS });
1456
+ }
1457
+ /////////////////////////////////////////////////////////////////////////////
1458
+ if (1) {
1459
+ if (params.SPREAD_V_NODES) {
1460
+ this.vnodes.forEach((v) => {
1461
+ v.nodes.forEach((n) => {
1462
+ n.position().x = v.center_x + Math.random() * 5;
1463
+ n.position().y = v.center_y + Math.random() * 5;
1464
+ });
1465
+ });
1466
+ this.captureStep('Nodes Spread to VNode Centers', 'Real nodes spread to their virtual node centers', null);
1467
+ }
1468
+ }
1469
+ ///////////////////// Delete the old layout so that only virtual-node VNode layout remains ////////////////
1470
+ if (!params.SUBSTRUCTURE_LAYOUT) {
1471
+ const IDEAL_LENGTH = params.LEAF_NODE_DISTANCE;
1472
+ this.vnodes.forEach((v) => {
1473
+ if (v.type == 'Star') {
1474
+ // if(targetNode){ // star is an independent star, not attached to a Cycle
1475
+ if (1) {
1476
+ const allMemberNode = v.nodes.filter((node) => node.data('structType') !== 'Star-Center');
1477
+ const ringSpacing = params.STAR_RING_SPACING;
1478
+ const baseNodesInFirstRing = params.STAR_BASE_NODES_PER_RING;
1479
+ // 1. Pre-calculate how many nodes go into each ring
1480
+ const rings = [];
1481
+ let tempNodes = [...allMemberNode];
1482
+ let currentRingSize = baseNodesInFirstRing;
1483
+ while (tempNodes.length > 0) {
1484
+ // Take the next chunk of nodes for this ring
1485
+ rings.push(tempNodes.splice(0, currentRingSize));
1486
+ // Increase capacity for the next ring
1487
+ currentRingSize += baseNodesInFirstRing;
1488
+ }
1489
+ // If the last ring contains only one node,
1490
+ // move it to the previous ring.
1491
+ if (rings.length > 1 && rings[rings.length - 1].length === 1) {
1492
+ const lastNode = rings.pop()[0];
1493
+ rings[rings.length - 1].push(lastNode);
1494
+ }
1495
+ // 2. Position the nodes ring by ring
1496
+ rings.forEach((ringNodes, ringIdx) => {
1497
+ const ringNumber = ringIdx + 1;
1498
+ const radius = ringNumber * ringSpacing;
1499
+ const totalInThisRing = ringNodes.length; // This is the key for even distribution
1500
+ ringNodes.forEach((node, nodeIdx) => {
1501
+ // Evenly distribute based on actual count in THIS ring
1502
+ let angle = ringIdx * Math.PI / 12.0 + (nodeIdx / totalInThisRing) * 2 * Math.PI;
1503
+ // Stagger every ring
1504
+ angle += (Math.PI / totalInThisRing);
1505
+ node.position({
1506
+ x: v.center_x + Math.cos(angle) * radius, y: v.center_y + Math.sin(angle) * radius
1507
+ });
1508
+ });
1509
+ });
1510
+ }
1511
+ }
1512
+ else if (v.type == 'Chain') {
1513
+ // 1. Still use a temporary object to group nodes by groupId
1514
+ const chainGroups = {};
1515
+ v.nodes.forEach((node) => {
1516
+ if (node.data('structType') == 'Chain') {
1517
+ const groupId = node.data('groupId');
1518
+ if (!chainGroups[groupId]) {
1519
+ chainGroups[groupId] = [];
1520
+ }
1521
+ if (node.neighborhood().nodes().length === 1) { // Put the leaf node of the chain first, to identify which node is the chain leaf
1522
+ chainGroups[groupId].unshift(node); //Insert the element at the beginning of the array and shift the existing elements back.
1523
+ }
1524
+ else {
1525
+ chainGroups[groupId].push(node);
1526
+ }
1527
+ }
1528
+ });
1529
+ for (const groupId in chainGroups) {
1530
+ if (chainGroups.hasOwnProperty(groupId)) {
1531
+ const gNodes = chainGroups[groupId];
1532
+ gNodes.sort((a, b) => {
1533
+ return Number(a.data('innerId')) - Number(b.data('innerId'));
1534
+ });
1535
+ const count = gNodes.length;
1536
+ if (count >= 2) {
1537
+ // ============================================================
1538
+ // 1. Calculate the arithmetic mean center of the current node group
1539
+ // ============================================================
1540
+ let cx = 0;
1541
+ let cy = 0;
1542
+ gNodes.forEach((n) => {
1543
+ cx += n.position().x;
1544
+ cy += n.position().y;
1545
+ });
1546
+ cx /= count;
1547
+ cy /= count;
1548
+ // ============================================================
1549
+ // 2. Special handling: exactly 3 nodes
1550
+ //
1551
+ // Three nodes form a strict equilateral triangle
1552
+ //
1553
+ // Note:
1554
+ // Do not use CHAIN_MIN_RADIUS
1555
+ // Prevent the triangle from being artificially enlarged
1556
+ // ============================================================
1557
+ if (count === 3) {
1558
+ // --------------------------------------------------------
1559
+ // Triangle side length
1560
+ //
1561
+ // CYCLE_NODE_SPACING represents the desired node spacing
1562
+ // --------------------------------------------------------
1563
+ const sideLength = params.CYCLE_NODE_SPACING;
1564
+ // --------------------------------------------------------
1565
+ // Circumradius of the equilateral triangle
1566
+ //
1567
+ // side = sqrt(3) * radius
1568
+ //
1569
+ // Therefore:
1570
+ //
1571
+ // radius = side / sqrt(3)
1572
+ // --------------------------------------------------------
1573
+ const radius = sideLength / Math.sqrt(3);
1574
+ // ========================================================
1575
+ // 3. Search for the best rotation angle
1576
+ //
1577
+ // Use exactly the same strategy as for a normal network
1578
+ //
1579
+ // 0°
1580
+ // 10°
1581
+ // 20°
1582
+ // ...
1583
+ // 350°
1584
+ //
1585
+ // For each calculation:
1586
+ //
1587
+ // totalEdgeLength(edges)
1588
+ //
1589
+ // Find the minimum value
1590
+ // ========================================================
1591
+ let minTotalLength = Number.POSITIVE_INFINITY;
1592
+ let bestRotate = 0;
1593
+ for (let rotate = 0; rotate < 360; rotate += 10) {
1594
+ // ----------------------------------------------------
1595
+ // Three nodes are evenly distributed around the circumference
1596
+ //
1597
+ // Between every pair of nodes:
1598
+ //
1599
+ // 120°
1600
+ //
1601
+ // Therefore, an equilateral triangle is naturally formed
1602
+ // ----------------------------------------------------
1603
+ gNodes.forEach((n, i) => {
1604
+ const angle = i * 2 * Math.PI / 3 + rotate * Math.PI / 180;
1605
+ n.position({
1606
+ x: cx + Math.cos(angle) * radius,
1607
+ y: cy + Math.sin(angle) * radius
1608
+ });
1609
+ });
1610
+ // ----------------------------------------------------
1611
+ // Calculate the total edge length at the current rotation angle
1612
+ // ----------------------------------------------------
1613
+ const totalLength = totalEdgeLength(edgeSet);
1614
+ // ----------------------------------------------------
1615
+ // Save the best rotation
1616
+ // ----------------------------------------------------
1617
+ if (totalLength < minTotalLength) {
1618
+ minTotalLength = totalLength;
1619
+ bestRotate = rotate;
1620
+ }
1621
+ }
1622
+ // ========================================================
1623
+ // 4. Reapply positions using the best rotation angle
1624
+ // ========================================================
1625
+ gNodes.forEach((n, i) => {
1626
+ const angle = i * 2 * Math.PI / 3 + bestRotate * Math.PI / 180;
1627
+ n.position({
1628
+ x: cx + Math.cos(angle) * radius,
1629
+ y: cy + Math.sin(angle) * radius
1630
+ });
1631
+ });
1632
+ return;
1633
+ }
1634
+ // ============================================================
1635
+ // 3. count > 3
1636
+ //
1637
+ // Keep the original layout strategy:
1638
+ //
1639
+ // gNodes[0]
1640
+ // ↓
1641
+ // Center node
1642
+ //
1643
+ // gNodes[1...]
1644
+ // ↓
1645
+ // Circumference nodes
1646
+ // ============================================================
1647
+ // ============================================================
1648
+ // 4. Calculate the standard radius based on the number of nodes
1649
+ // ============================================================
1650
+ const miniMumRadius = params.CHAIN_MIN_RADIUS;
1651
+ const radius = Math.max((count * params.CYCLE_NODE_SPACING) / (2 * Math.PI), miniMumRadius);
1652
+ // ============================================================
1653
+ // 5. Sort / circumference nodes
1654
+ // ============================================================
1655
+ const sorted = gNodes.slice(1);
1656
+ // ============================================================
1657
+ // 6. Search for the best rotation angle
1658
+ // ============================================================
1659
+ let minTotalLength = Number.POSITIVE_INFINITY;
1660
+ let bestRotate = 0;
1661
+ for (let rotate = 0; rotate < 360; rotate += 10) {
1662
+ // --------------------------------------------------------
1663
+ // Forcefully overwrite the coordinates of circumference nodes
1664
+ // --------------------------------------------------------
1665
+ sorted.forEach((n, i) => {
1666
+ const angle = (i / sorted.length) * 2 * Math.PI + rotate * Math.PI / 180;
1667
+ n.position({
1668
+ x: cx + Math.cos(angle) * radius,
1669
+ y: cy + Math.sin(angle) * radius
1670
+ });
1671
+ });
1672
+ // --------------------------------------------------------
1673
+ // Calculate the total edge length at the current rotation
1674
+ // --------------------------------------------------------
1675
+ const totalLength = totalEdgeLength(edgeSet);
1676
+ // --------------------------------------------------------
1677
+ // Save the best rotation
1678
+ // --------------------------------------------------------
1679
+ if (totalLength < minTotalLength) {
1680
+ minTotalLength = totalLength;
1681
+ bestRotate = rotate;
1682
+ }
1683
+ }
1684
+ // ============================================================
1685
+ // 7. Apply the best rotation angle
1686
+ // ============================================================
1687
+ sorted.forEach((n, i) => {
1688
+ const angle = (i / sorted.length) * 2 * Math.PI + bestRotate * Math.PI / 180;
1689
+ n.position({
1690
+ x: cx + Math.cos(angle) * radius,
1691
+ y: cy + Math.sin(angle) * radius
1692
+ });
1693
+ });
1694
+ // ============================================================
1695
+ // 8. Place the first node at the center
1696
+ // ============================================================
1697
+ gNodes[0].position({
1698
+ x: cx, y: cy
1699
+ });
1700
+ }
1701
+ }
1702
+ }
1703
+ }
1704
+ else if (v.type == 'Parallel') {
1705
+ let endVec = [];
1706
+ nodes.forEach((n, i) => {
1707
+ if (n.data('parallelGroupIdVec').includes(v.id) && n.data('structType') != 'Parallel') {
1708
+ // Extract the endpoint nodes of the same group
1709
+ endVec.push(n);
1710
+ }
1711
+ });
1712
+ if (endVec.length >= 2) { // should be >= 2; otherwise an error may occur
1713
+ // There is a node array; distribute all nodes in it evenly along the perpendicular bisector of the two points n1 and n2
1714
+ const p1 = endVec[0].position();
1715
+ const p2 = endVec[1].position();
1716
+ const diff = { x: p2.x - p1.x, y: p2.y - p1.y };
1717
+ layoutRectangular(v.nodes, { x: v.center_x, y: v.center_y }, diff);
1718
+ }
1719
+ }
1720
+ if (v.type == 'Cycle') {
1721
+ // 1. Check that nodes exists and is not empty
1722
+ if (v.nodes && v.nodes.length > 0) {
1723
+ v.nodes.sort((a, b) => {
1724
+ // Assume innerId is a number. If it is a string, localeCompare can be used
1725
+ const idA = a.data('innerId') ?? 0;
1726
+ const idB = b.data('innerId') ?? 0;
1727
+ return idA - idB; // sort in ascending order
1728
+ });
1729
+ }
1730
+ // 2. Calculate the standard radius based on the number of nodes (to keep node spacing close to k)
1731
+ const count = v.nodes.length;
1732
+ const k = params.CYCLE_NODE_SPACING;
1733
+ const radius = (count * k) / (2 * Math.PI);
1734
+ // 3. Sort to prevent nodes from flickering around the circumference
1735
+ const sorted = v.nodes;
1736
+ const sortedReverse = sorted.slice().reverse();
1737
+ let reverseFlag = false;
1738
+ let minTotalLength = 10e10;
1739
+ let bestRotate = 0; //Find the best rotation angle
1740
+ //clock-wise
1741
+ for (let rotate = 0; rotate < 360; rotate = rotate + 10) {
1742
+ // 4. Forcefully overwrite coordinates: this guarantees a "perfect circle"
1743
+ sorted.forEach((n, i) => {
1744
+ const angle = (i / count) * 2 * Math.PI + rotate;
1745
+ n.position({
1746
+ x: v.center_x + Math.cos(angle) * radius, y: v.center_y + Math.sin(angle) * radius
1747
+ });
1748
+ });
1749
+ const totalLength = totalEdgeLength(edgeSet);
1750
+ if (minTotalLength > totalLength) {
1751
+ minTotalLength = totalLength;
1752
+ bestRotate = rotate;
1753
+ }
1754
+ }
1755
+ //anti-clock-wise
1756
+ for (let rotate = 0; rotate < 360; rotate = rotate + 10) {
1757
+ // 4. Forcefully overwrite coordinates: this guarantees a "perfect circle"
1758
+ sortedReverse.forEach((n, i) => {
1759
+ const angle = (i / count) * 2 * Math.PI + rotate;
1760
+ n.position({
1761
+ x: v.center_x + Math.cos(angle) * radius, y: v.center_y + Math.sin(angle) * radius
1762
+ });
1763
+ });
1764
+ const totalLength = totalEdgeLength(edgeSet);
1765
+ if (minTotalLength > totalLength) {
1766
+ reverseFlag = true;
1767
+ minTotalLength = totalLength;
1768
+ bestRotate = rotate;
1769
+ }
1770
+ }
1771
+ // 4. Forcefully overwrite coordinates: this guarantees a "perfect circle"
1772
+ if (!reverseFlag) {
1773
+ sorted.forEach((n, i) => {
1774
+ const angle = (i / count) * 2 * Math.PI + bestRotate;
1775
+ n.position({
1776
+ x: v.center_x + Math.cos(angle) * radius, y: v.center_y + Math.sin(angle) * radius
1777
+ });
1778
+ });
1779
+ }
1780
+ else {
1781
+ sortedReverse.forEach((n, i) => {
1782
+ const angle = (i / count) * 2 * Math.PI + bestRotate;
1783
+ n.position({
1784
+ x: v.center_x + Math.cos(angle) * radius, y: v.center_y + Math.sin(angle) * radius
1785
+ });
1786
+ });
1787
+ }
1788
+ }
1789
+ });
1790
+ nodes.forEach((n, i) => {
1791
+ if (n.data('structType') == 'LeafButNotChain') {
1792
+ let fatherPos = n.neighborhood().nodes().first().position();
1793
+ let maxTotalLength = 10e10;
1794
+ let bestRotate = 0; //Find the best rotation angle
1795
+ let aroundNodesVec = [];
1796
+ nodes.forEach((nd, i) => {
1797
+ if (((nd.position().x - fatherPos.x) * (nd.position().x - fatherPos.x) + (nd.position().y - fatherPos.y) * (nd.position().y - fatherPos.y)) < 2 * IDEAL_LENGTH * IDEAL_LENGTH) {
1798
+ aroundNodesVec.push(nd);
1799
+ }
1800
+ });
1801
+ for (let rotate = 0; rotate <= 360; rotate = rotate + 10) {
1802
+ n.position({
1803
+ x: fatherPos.x + Math.cos(rotate * 3.14 / 180) * IDEAL_LENGTH,
1804
+ y: fatherPos.y + Math.sin(rotate * 3.14 / 180) * IDEAL_LENGTH
1805
+ });
1806
+ let totalLength = 0;
1807
+ aroundNodesVec.forEach((nd, i) => {
1808
+ if (nd.id() != n.id()) {
1809
+ const tmpDis = 1 / Math.sqrt((nd.position().x - n.position().x) * (nd.position().x - n.position().x) + (nd.position().y - n.position().y) * (nd.position().y - n.position().y));
1810
+ totalLength += tmpDis;
1811
+ }
1812
+ });
1813
+ if (maxTotalLength > totalLength) {
1814
+ maxTotalLength = totalLength;
1815
+ bestRotate = rotate;
1816
+ }
1817
+ }
1818
+ //
1819
+ n.position({
1820
+ x: fatherPos.x + Math.cos(bestRotate * 3.14 / 180) * IDEAL_LENGTH,
1821
+ y: fatherPos.y + Math.sin(bestRotate * 3.14 / 180) * IDEAL_LENGTH
1822
+ });
1823
+ }
1824
+ });
1825
+ this.captureStep('Substructure Layout', 'Individual structures laid out (Cycles, Stars, Chains, Parallel)', null);
1826
+ }
1827
+ if (nodes.length == 2) {
1828
+ nodes[0].position().x = 0;
1829
+ nodes[0].position().y = 0;
1830
+ nodes[1].position().x = 100;
1831
+ nodes[1].position().y = 0;
1832
+ }
1833
+ if (nodes.length == 3) {
1834
+ nodes[0].position().x = 0;
1835
+ nodes[0].position().y = 0;
1836
+ nodes[1].position().x = 100;
1837
+ nodes[1].position().y = 0;
1838
+ nodes[2].position().x = 50;
1839
+ nodes[2].position().y = 90;
1840
+ }
1841
+ rotateNetworkToMinimumBoundingBox(nodes);
1842
+ if (1) {
1843
+ let maxDis = 0;
1844
+ let maxS = 0;
1845
+ let maxT = 0;
1846
+ let minDist = 10e10;
1847
+ let minS = 0;
1848
+ let minT = 0;
1849
+ let avgDis = 0;
1850
+ edgeSet.forEach((e) => {
1851
+ const s = e.source();
1852
+ const t = e.target();
1853
+ const distance = Math.sqrt(Math.pow(s.position().x - t.position().x, 2) + Math.pow(s.position().y - t.position().y, 2));
1854
+ if (maxDis < distance) {
1855
+ maxDis = distance;
1856
+ maxS = s.id();
1857
+ maxT = t.id();
1858
+ }
1859
+ if (minDist > distance) {
1860
+ minDist = distance;
1861
+ minS = s.id();
1862
+ minT = t.id();
1863
+ }
1864
+ avgDis += distance;
1865
+ });
1866
+ console.log("maxDis:", maxDis, " ", maxS, "->", maxT);
1867
+ console.log("minDist:", minDist, " ", minS, "->", minT);
1868
+ console.log("avgDis:", avgDis / edgeSet.length);
1869
+ }
1870
+ const bb2 = nodes.boundingBox();
1871
+ networkInfos.push({
1872
+ component: component,
1873
+ nodes: nodes,
1874
+ index: index,
1875
+ nodeCount: nodes.length,
1876
+ width: bb2.w,
1877
+ height: bb2.h,
1878
+ bb: bb2
1879
+ });
1880
+ });
1881
+ this.packNetworks(networkInfos);
1882
+ if (1) {
1883
+ networkInfos.forEach((networkInfo, index) => {
1884
+ const nodes = networkInfo.nodes;
1885
+ // All parent IDs in the current network
1886
+ const parentIds = new Set();
1887
+ nodes.forEach((node) => {
1888
+ const groupId = node.data('groupId');
1889
+ if (groupId) {
1890
+ parentIds.add(index + "_" + groupId);
1891
+ }
1892
+ });
1893
+ // ========================================
1894
+ // Create parent
1895
+ // ========================================
1896
+ parentIds.forEach((parentId) => {
1897
+ // Parent already exists
1898
+ if (!this.cy.getElementById(parentId).empty()) {
1899
+ return;
1900
+ }
1901
+ // Find children
1902
+ const children = nodes.filter((node) => {
1903
+ return (index + "_" + node.data('groupId')) === parentId;
1904
+ });
1905
+ if (children.length === 0) {
1906
+ return;
1907
+ }
1908
+ // ========================================
1909
+ // ① Save the original positions of the children
1910
+ // ========================================
1911
+ const originalPositions = new Map();
1912
+ children.forEach((node) => {
1913
+ const pos = node.position();
1914
+ originalPositions.set(node.id(), {
1915
+ x: pos.x, y: pos.y
1916
+ });
1917
+ });
1918
+ // ========================================
1919
+ // ② Calculate the bounding box from the original positions
1920
+ // ========================================
1921
+ const bb = children.boundingBox();
1922
+ const centerX = (bb.x1 + bb.x2) / 2;
1923
+ const centerY = (bb.y1 + bb.y2) / 2;
1924
+ // ========================================
1925
+ // ③ Create the parent
1926
+ // ========================================
1927
+ const parent = this.cy.add({
1928
+ group: 'nodes',
1929
+ data: {
1930
+ id: parentId, label: parentId
1931
+ },
1932
+ position: {
1933
+ x: centerX, y: centerY
1934
+ },
1935
+ classes: 'substructure-group'
1936
+ }).first();
1937
+ // ========================================
1938
+ // ④ Establish the compound relationship
1939
+ // ========================================
1940
+ children.forEach((node) => {
1941
+ node.move({
1942
+ parent: parentId
1943
+ });
1944
+ });
1945
+ // ========================================
1946
+ // ⑤ Restore the original absolute positions of the children
1947
+ // ========================================
1948
+ children.forEach((node) => {
1949
+ const original = originalPositions.get(node.id());
1950
+ if (!original) {
1951
+ return;
1952
+ }
1953
+ node.position({
1954
+ x: original.x, y: original.y
1955
+ });
1956
+ });
1957
+ // ========================================
1958
+ // ⑥ Restore the center position of the parent
1959
+ // ========================================
1960
+ parent.position({
1961
+ x: centerX, y: centerY
1962
+ });
1963
+ });
1964
+ });
1965
+ }
1966
+ this.captureStep('Final', 'Final layout complete', null);
1967
+ this.cy.fit(null, 50);
1968
+ this.trigger({
1969
+ type: 'layoutstop'
1970
+ });
1971
+ return this;
1972
+ };
1973
+ SubstructureLayout.prototype.destroy = function () {
1974
+ this.stop();
1975
+ const nodes = this.eles ? this.eles.nodes() : (this.cy ? this.cy.nodes() : null);
1976
+ if (nodes && nodes.length > 0) {
1977
+ nodes.forEach((node) => {
1978
+ node.removeClass([
1979
+ 'substructure-cycle',
1980
+ 'substructure-chain',
1981
+ 'substructure-star',
1982
+ 'substructure-star-center',
1983
+ 'substructure-member',
1984
+ 'substructure-parallel'
1985
+ ].join(' '));
1986
+ node.removeData('structType');
1987
+ node.removeData('groupId');
1988
+ node.removeData('innerId');
1989
+ node.removeData('parallelGroupIdVec');
1990
+ node.removeData('structs');
1991
+ });
1992
+ }
1993
+ if (this.cy && this._onEventHandler) {
1994
+ this.cy.off('tap', this._onEventHandler);
1995
+ this._onEventHandler = null;
1996
+ }
1997
+ this.vnodes = [];
1998
+ this.vedges = [];
1999
+ this.steps = [];
2000
+ this.currentStepIndex = -1;
2001
+ if (this.eles) {
2002
+ this.eles.emit('layoutdestroy');
2003
+ }
2004
+ return this;
2005
+ };
2006
+ SubstructureLayout.prototype.stop = function () {
2007
+ if (this.stopped) {
2008
+ return this;
2009
+ }
2010
+ this.stopped = true;
2011
+ if (this.frameId) {
2012
+ cancelAnimationFrame(this.frameId);
2013
+ this.frameId = null;
2014
+ }
2015
+ this.trigger({
2016
+ type: 'layoutstop',
2017
+ layout: this
2018
+ });
2019
+ return this;
2020
+ };
2021
+ /**
2022
+ * get minimum bounding box , then rotate bounding box and make network rotate to rectangle
2023
+ * @param nodes
2024
+ */
2025
+ function rotateNetworkToMinimumBoundingBox(nodes) {
2026
+ if (!nodes || nodes.length === 0) {
2027
+ return null;
2028
+ }
2029
+ // ============================================================
2030
+ // 1. get four corner position for each node in network
2031
+ // ============================================================
2032
+ const points = [];
2033
+ nodes.forEach((node) => {
2034
+ const pos = node.position();
2035
+ const width = node.outerWidth();
2036
+ const height = node.outerHeight();
2037
+ const halfWidth = width / 2;
2038
+ const halfHeight = height / 2;
2039
+ points.push({
2040
+ x: pos.x - halfWidth,
2041
+ y: pos.y - halfHeight
2042
+ });
2043
+ points.push({
2044
+ x: pos.x + halfWidth,
2045
+ y: pos.y - halfHeight
2046
+ });
2047
+ points.push({
2048
+ x: pos.x + halfWidth,
2049
+ y: pos.y + halfHeight
2050
+ });
2051
+ points.push({
2052
+ x: pos.x - halfWidth,
2053
+ y: pos.y + halfHeight
2054
+ });
2055
+ });
2056
+ // ============================================================
2057
+ // 2. Convex Hull
2058
+ // ============================================================
2059
+ const sortedPoints = [...points].sort((a, b) => {
2060
+ if (a.x !== b.x) {
2061
+ return (a.x - b.x);
2062
+ }
2063
+ return (a.y - b.y);
2064
+ });
2065
+ const cross = (o, a, b) => {
2066
+ return ((a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x));
2067
+ };
2068
+ const lower = [];
2069
+ for (const p of sortedPoints) {
2070
+ while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {
2071
+ lower.pop();
2072
+ }
2073
+ lower.push(p);
2074
+ }
2075
+ const upper = [];
2076
+ for (let i = sortedPoints.length - 1; i >= 0; i--) {
2077
+ const p = sortedPoints[i];
2078
+ while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {
2079
+ upper.pop();
2080
+ }
2081
+ upper.push(p);
2082
+ }
2083
+ lower.pop();
2084
+ upper.pop();
2085
+ const hull = lower.concat(upper);
2086
+ if (hull.length < 2) {
2087
+ return null;
2088
+ }
2089
+ // ============================================================
2090
+ // 3. search Minimum Area Bounding Rectangle
2091
+ // ============================================================
2092
+ let bestArea = Number.POSITIVE_INFINITY;
2093
+ let bestWidth = 0;
2094
+ let bestHeight = 0;
2095
+ let bestAngle = 0;
2096
+ let bestCenterX = 0;
2097
+ let bestCenterY = 0;
2098
+ for (let i = 0; i < hull.length; i++) {
2099
+ const p1 = hull[i];
2100
+ const p2 = hull[(i + 1) % hull.length];
2101
+ // --------------------------------------------------------
2102
+ // convex hull
2103
+ // --------------------------------------------------------
2104
+ const edgeAngle = Math.atan2(p2.y - p1.y, p2.x - p1.x);
2105
+ // --------------------------------------------------------
2106
+ // rotate to X axis
2107
+ // --------------------------------------------------------
2108
+ const cos = Math.cos(-edgeAngle);
2109
+ const sin = Math.sin(-edgeAngle);
2110
+ let minX = Number.POSITIVE_INFINITY;
2111
+ let maxX = Number.NEGATIVE_INFINITY;
2112
+ let minY = Number.POSITIVE_INFINITY;
2113
+ let maxY = Number.NEGATIVE_INFINITY;
2114
+ // --------------------------------------------------------
2115
+ // rotate all hull
2116
+ // --------------------------------------------------------
2117
+ hull.forEach((p) => {
2118
+ const x = p.x * cos - p.y * sin;
2119
+ const y = p.x * sin + p.y * cos;
2120
+ minX = Math.min(minX, x);
2121
+ maxX = Math.max(maxX, x);
2122
+ minY = Math.min(minY, y);
2123
+ maxY = Math.max(maxY, y);
2124
+ });
2125
+ const width = maxX - minX;
2126
+ const height = maxY - minY;
2127
+ const area = width * height;
2128
+ // --------------------------------------------------------
2129
+ // search for minimum rectangle
2130
+ // --------------------------------------------------------
2131
+ if (area < bestArea) {
2132
+ bestArea = area;
2133
+ bestWidth = width;
2134
+ bestHeight = height;
2135
+ bestAngle = edgeAngle;
2136
+ const centerXRot = (minX + maxX) / 2;
2137
+ const centerYRot = (minY + maxY) / 2;
2138
+ // ----------------------------------------------------
2139
+ // rotate
2140
+ // ----------------------------------------------------
2141
+ bestCenterX = centerXRot * Math.cos(edgeAngle) - centerYRot * Math.sin(edgeAngle);
2142
+ bestCenterY = centerXRot * Math.sin(edgeAngle) + centerYRot * Math.cos(edgeAngle);
2143
+ }
2144
+ }
2145
+ // ============================================================
2146
+ // 4. make sure:
2147
+ //
2148
+ // width >= height
2149
+ //
2150
+ // ============================================================
2151
+ if (bestHeight > bestWidth) {
2152
+ const temp = bestWidth;
2153
+ bestWidth = bestHeight;
2154
+ bestHeight = temp;
2155
+ // --------------------------------------------------------
2156
+ // rotate 90°
2157
+ // --------------------------------------------------------
2158
+ bestAngle += Math.PI / 2;
2159
+ }
2160
+ // ============================================================
2161
+ // 5. Normalize angle
2162
+ //
2163
+ // [-PI, PI]
2164
+ // ============================================================
2165
+ while (bestAngle > Math.PI) {
2166
+ bestAngle -= 2 * Math.PI;
2167
+ }
2168
+ while (bestAngle < -Math.PI) {
2169
+ bestAngle += 2 * Math.PI;
2170
+ }
2171
+ // ============================================================
2172
+ // 6. rotate network according to bounding box center
2173
+ // ============================================================
2174
+ const cos = Math.cos(-bestAngle);
2175
+ const sin = Math.sin(-bestAngle);
2176
+ nodes.forEach((node) => {
2177
+ const pos = node.position();
2178
+ const dx = pos.x - bestCenterX;
2179
+ const dy = pos.y - bestCenterY;
2180
+ // ----------------------------------------------------
2181
+ // rotate
2182
+ // -bestAngle
2183
+ // ----------------------------------------------------
2184
+ const newX = bestCenterX + dx * cos - dy * sin;
2185
+ const newY = bestCenterY + dx * sin + dy * cos;
2186
+ node.position({
2187
+ x: newX,
2188
+ y: newY
2189
+ });
2190
+ });
2191
+ // ============================================================
2192
+ // 7. re-calculate Bounding Box after rotation
2193
+ // ============================================================
2194
+ const finalBB = nodes.boundingBox();
2195
+ return {
2196
+ width: finalBB.w,
2197
+ height: finalBB.h,
2198
+ area: finalBB.w * finalBB.h,
2199
+ angle: bestAngle,
2200
+ centerX: bestCenterX,
2201
+ centerY: bestCenterY
2202
+ };
2203
+ }
2204
+ // If there are multiple networks, organize them into multiple rows.
2205
+ // Networks with similar sizes are preferentially placed in the same row.
2206
+ SubstructureLayout.prototype.packNetworks = function (networks) {
2207
+ if (!networks || networks.length === 0) {
2208
+ return;
2209
+ }
2210
+ // ============================================================
2211
+ // 1. Canvas
2212
+ // ============================================================
2213
+ const container = this.cy.container();
2214
+ const canvasWidth = container?.clientWidth ?? 1200;
2215
+ const canvasHeight = container?.clientHeight ?? 800;
2216
+ const canvasAspect = canvasWidth / canvasHeight;
2217
+ // ============================================================
2218
+ // 2. Parameters
2219
+ // ============================================================
2220
+ const H_GAP = 60;
2221
+ const V_GAP = 80;
2222
+ const SEARCH_STEPS = 300;
2223
+ // ------------------------------------------------------------
2224
+ // Weight
2225
+ //
2226
+ // aspect: overall ratio
2227
+ //
2228
+ // similarity: network similarity on the same row
2229
+ //
2230
+ // balance: make sure not too crowd on one row
2231
+ // ------------------------------------------------------------
2232
+ const ASPECT_WEIGHT = 0.60;
2233
+ const SIZE_SIMILARITY_WEIGHT = 0.30;
2234
+ const ROW_BALANCE_WEIGHT = 0.10;
2235
+ // ============================================================
2236
+ // 3. Get real bounding boxes
2237
+ // ============================================================
2238
+ networks.forEach((network) => {
2239
+ const bb = network.nodes.boundingBox();
2240
+ network.x1 = bb.x1;
2241
+ network.y1 = bb.y1;
2242
+ network.x2 = bb.x2;
2243
+ network.y2 = bb.y2;
2244
+ network.width = bb.w;
2245
+ network.height = bb.h;
2246
+ network.centerX = (bb.x1 + bb.x2) / 2;
2247
+ network.centerY = (bb.y1 + bb.y2) / 2;
2248
+ network.area = Math.max(network.width * network.height, 1);
2249
+ network.size = Math.sqrt(network.area);
2250
+ network.aspect = network.width / Math.max(network.height, 1);
2251
+ });
2252
+ // ============================================================
2253
+ // 4. Sort
2254
+ //
2255
+ // large → small
2256
+ // ============================================================
2257
+ const sortedNetworks = [...networks].sort((a, b) => {
2258
+ if (a.nodeCount !== b.nodeCount) {
2259
+ return (b.nodeCount - a.nodeCount);
2260
+ }
2261
+ return (b.size - a.size);
2262
+ });
2263
+ // ============================================================
2264
+ // 5. Size normalization
2265
+ //
2266
+ // use log(size) to calculate difference
2267
+ //
2268
+ // so:
2269
+ //
2270
+ // 100 → 200
2271
+ //
2272
+ // and
2273
+ //
2274
+ // 500 → 1000
2275
+ //
2276
+ // are same 2 times difference。
2277
+ // ============================================================
2278
+ const allSizes = sortedNetworks.map((n) => Math.log(Math.max(n.size, 1)));
2279
+ const minLogSize = Math.min(...allSizes);
2280
+ const maxLogSize = Math.max(...allSizes);
2281
+ const sizeRange = Math.max(maxLogSize - minLogSize, 0.0001);
2282
+ sortedNetworks.forEach((network) => {
2283
+ network.normalizedSize = (Math.log(Math.max(network.size, 1)) - minLogSize) / sizeRange;
2284
+ });
2285
+ // ============================================================
2286
+ // 6. Width range
2287
+ // ============================================================
2288
+ const maxNetworkWidth = Math.max(...sortedNetworks.map((n) => n.width));
2289
+ const totalNetworkWidth = sortedNetworks.reduce((sum, n) => {
2290
+ return (sum + n.width);
2291
+ }, 0);
2292
+ const minWidth = maxNetworkWidth;
2293
+ const maxWidth = totalNetworkWidth + H_GAP * Math.max(sortedNetworks.length - 1, 0);
2294
+ // ============================================================
2295
+ // 7. Make rows
2296
+ // ============================================================
2297
+ const makeRows = (widthLimit) => {
2298
+ const rows = [];
2299
+ let currentRow = [];
2300
+ let currentWidth = 0;
2301
+ sortedNetworks.forEach((network) => {
2302
+ const networkWidth = network.width;
2303
+ // =================================================
2304
+ // First network
2305
+ // =================================================
2306
+ if (currentRow.length === 0) {
2307
+ currentRow.push(network);
2308
+ currentWidth = networkWidth;
2309
+ return;
2310
+ }
2311
+ // =================================================
2312
+ // Width check
2313
+ // =================================================
2314
+ const requiredWidth = currentWidth + H_GAP + networkWidth;
2315
+ if (requiredWidth > widthLimit) {
2316
+ rows.push(currentRow);
2317
+ currentRow = [network];
2318
+ currentWidth = networkWidth;
2319
+ return;
2320
+ }
2321
+ // =================================================
2322
+ // Size similarity check
2323
+ // =================================================
2324
+ const currentMeanSize = currentRow.reduce((sum, n) => sum + n.normalizedSize, 0) / currentRow.length;
2325
+ const sizeDifference = Math.abs(network.normalizedSize - currentMeanSize);
2326
+ // -------------------------------------------------
2327
+ // Avoid placing vastly different networks on the same line.
2328
+ // -------------------------------------------------
2329
+ const SIZE_THRESHOLD = 0.35;
2330
+ if (sizeDifference > SIZE_THRESHOLD && currentRow.length >= 2) {
2331
+ rows.push(currentRow);
2332
+ currentRow = [network];
2333
+ currentWidth = networkWidth;
2334
+ return;
2335
+ }
2336
+ currentRow.push(network);
2337
+ currentWidth = requiredWidth;
2338
+ });
2339
+ // =========================================================
2340
+ // Last row
2341
+ // =========================================================
2342
+ if (currentRow.length > 0) {
2343
+ rows.push(currentRow);
2344
+ }
2345
+ return rows;
2346
+ };
2347
+ // ============================================================
2348
+ // 8. Calculate layout size
2349
+ // ============================================================
2350
+ const calculateLayoutSize = (rows) => {
2351
+ let totalWidth = 0;
2352
+ let totalHeight = 0;
2353
+ rows.forEach((row, rowIndex) => {
2354
+ let rowWidth = 0;
2355
+ let rowHeight = 0;
2356
+ row.forEach((network, index) => {
2357
+ rowWidth += network.width;
2358
+ if (index < row.length - 1) {
2359
+ rowWidth += H_GAP;
2360
+ }
2361
+ rowHeight = Math.max(rowHeight, network.height);
2362
+ });
2363
+ totalWidth = Math.max(totalWidth, rowWidth);
2364
+ totalHeight += rowHeight;
2365
+ if (rowIndex < rows.length - 1) {
2366
+ totalHeight += V_GAP;
2367
+ }
2368
+ });
2369
+ return {
2370
+ width: totalWidth, height: totalHeight
2371
+ };
2372
+ };
2373
+ // ============================================================
2374
+ // 9. Calculate row size similarity score
2375
+ //
2376
+ // 0 = good
2377
+ // 1 = bad
2378
+ //
2379
+ // using row max/min。
2380
+ //
2381
+ // case:
2382
+ //
2383
+ // 100, 110, 120 → good
2384
+ //
2385
+ // 100, 500, 1000 → bad
2386
+ // ============================================================
2387
+ const calculateSizeSimilarity = (rows) => {
2388
+ if (rows.length === 0) {
2389
+ return 1;
2390
+ }
2391
+ let totalPenalty = 0;
2392
+ let totalWeight = 0;
2393
+ rows.forEach((row) => {
2394
+ if (row.length <= 1) {
2395
+ return;
2396
+ }
2397
+ const sizes = row.map((n) => Math.log(Math.max(n.size, 1)));
2398
+ const min = Math.min(...sizes);
2399
+ const max = Math.max(...sizes);
2400
+ const difference = max - min;
2401
+ const weight = row.length;
2402
+ totalPenalty += difference * weight;
2403
+ totalWeight += weight;
2404
+ });
2405
+ if (totalWeight === 0) {
2406
+ return 0;
2407
+ }
2408
+ return (totalPenalty / totalWeight / Math.max(sizeRange, 0.0001));
2409
+ };
2410
+ // ============================================================
2411
+ // 10. Row balance score
2412
+ //
2413
+ // avoid extreme case like:
2414
+ //
2415
+ // Row 1: 6 networks
2416
+ // Row 2: 1 network
2417
+ // ============================================================
2418
+ const calculateRowBalance = (rows) => {
2419
+ if (rows.length <= 1) {
2420
+ return 0;
2421
+ }
2422
+ const counts = rows.map((row) => row.length);
2423
+ const mean = counts.reduce((a, b) => a + b, 0) / counts.length;
2424
+ if (mean <= 0) {
2425
+ return 0;
2426
+ }
2427
+ const variance = counts.reduce((sum, count) => {
2428
+ return (sum + Math.pow(count - mean, 2));
2429
+ }, 0) / counts.length;
2430
+ return (Math.sqrt(variance) / mean);
2431
+ };
2432
+ // ============================================================
2433
+ // 11. Search best layout
2434
+ // ============================================================
2435
+ let bestRows = [];
2436
+ let bestScore = Number.POSITIVE_INFINITY;
2437
+ let bestWidth = 0;
2438
+ let bestHeight = 0;
2439
+ let bestAspect = 0;
2440
+ let bestSimilarity = 0;
2441
+ for (let i = 0; i < SEARCH_STEPS; i++) {
2442
+ const testWidth = minWidth + (maxWidth - minWidth) * i / Math.max(SEARCH_STEPS - 1, 1);
2443
+ // ========================================================
2444
+ // Make rows
2445
+ // ========================================================
2446
+ const rows = makeRows(testWidth);
2447
+ if (!rows || rows.length === 0) {
2448
+ continue;
2449
+ }
2450
+ // ========================================================
2451
+ // Layout size
2452
+ // ========================================================
2453
+ const layoutSize = calculateLayoutSize(rows);
2454
+ const layoutWidth = layoutSize.width;
2455
+ const layoutHeight = layoutSize.height;
2456
+ if (layoutWidth <= 0 || layoutHeight <= 0) {
2457
+ continue;
2458
+ }
2459
+ // ========================================================
2460
+ // Layout aspect
2461
+ // ========================================================
2462
+ const layoutAspect = layoutWidth / layoutHeight;
2463
+ // ========================================================
2464
+ // Aspect error
2465
+ // ========================================================
2466
+ const aspectError = Math.abs(Math.log(layoutAspect / canvasAspect));
2467
+ // ========================================================
2468
+ // Size similarity
2469
+ // ========================================================
2470
+ const similarityError = calculateSizeSimilarity(rows);
2471
+ // ========================================================
2472
+ // Row balance
2473
+ // ========================================================
2474
+ const rowBalanceError = calculateRowBalance(rows);
2475
+ // ========================================================
2476
+ // Row count
2477
+ //
2478
+ // Very small penalty
2479
+ // ========================================================
2480
+ const rowPenalty = rows.length / Math.max(sortedNetworks.length, 1);
2481
+ // ========================================================
2482
+ // Final score
2483
+ //
2484
+ // Key point:
2485
+ //
2486
+ // Aspect ratio is still the primary objective
2487
+ //
2488
+ // But size similarity clearly participates in the optimization.
2489
+ // ========================================================
2490
+ const score = aspectError * ASPECT_WEIGHT *
2491
+ 100
2492
+ +
2493
+ similarityError * SIZE_SIMILARITY_WEIGHT * 10
2494
+ +
2495
+ rowBalanceError * ROW_BALANCE_WEIGHT
2496
+ +
2497
+ rowPenalty * 0.01;
2498
+ // ========================================================
2499
+ // Save best
2500
+ // ========================================================
2501
+ if (score < bestScore) {
2502
+ bestScore = score;
2503
+ bestRows = rows;
2504
+ bestWidth = layoutWidth;
2505
+ bestHeight = layoutHeight;
2506
+ bestAspect = layoutAspect;
2507
+ bestSimilarity = similarityError;
2508
+ }
2509
+ }
2510
+ // ============================================================
2511
+ // 12. Fallback
2512
+ // ============================================================
2513
+ if (bestRows.length === 0) {
2514
+ bestRows = makeRows(minWidth);
2515
+ const fallbackSize = calculateLayoutSize(bestRows);
2516
+ bestWidth = fallbackSize.width;
2517
+ bestHeight = fallbackSize.height;
2518
+ bestAspect = bestWidth / Math.max(bestHeight, 1);
2519
+ bestSimilarity = calculateSizeSimilarity(bestRows);
2520
+ }
2521
+ // ============================================================
2522
+ // 13. Final order
2523
+ //
2524
+ // search:
2525
+ //
2526
+ // large → small
2527
+ //
2528
+ // display:
2529
+ //
2530
+ // small → large
2531
+ // ============================================================
2532
+ const finalRows = bestRows.map((row) => {
2533
+ return [...row].reverse();
2534
+ });
2535
+ // ============================================================
2536
+ // 16. Calculate row infos
2537
+ // ============================================================
2538
+ const rowInfos = [];
2539
+ finalRows.forEach((row) => {
2540
+ let rowWidth = 0;
2541
+ let rowHeight = 0;
2542
+ row.forEach((network, index) => {
2543
+ rowWidth += network.width;
2544
+ if (index < row.length - 1) {
2545
+ rowWidth += H_GAP;
2546
+ }
2547
+ rowHeight = Math.max(rowHeight, network.height);
2548
+ });
2549
+ rowInfos.push({
2550
+ row, width: rowWidth, height: rowHeight
2551
+ });
2552
+ });
2553
+ // ============================================================
2554
+ // 17. Final layout size
2555
+ // ============================================================
2556
+ let totalLayoutWidth = 0;
2557
+ let totalLayoutHeight = 0;
2558
+ rowInfos.forEach((info, index) => {
2559
+ totalLayoutWidth = Math.max(totalLayoutWidth, info.width);
2560
+ totalLayoutHeight += info.height;
2561
+ if (index < rowInfos.length - 1) {
2562
+ totalLayoutHeight += V_GAP;
2563
+ }
2564
+ });
2565
+ const finalAspect = totalLayoutWidth / Math.max(totalLayoutHeight, 1);
2566
+ // ============================================================
2567
+ // 19. Center layout
2568
+ // ============================================================
2569
+ const layoutStartX = (canvasWidth - totalLayoutWidth) / 2;
2570
+ const layoutStartY = (canvasHeight - totalLayoutHeight) / 2;
2571
+ // ============================================================
2572
+ // 20. Pack networks
2573
+ // ============================================================
2574
+ let currentY = layoutStartY;
2575
+ rowInfos.forEach((info) => {
2576
+ const row = info.row;
2577
+ let currentX = layoutStartX;
2578
+ row.forEach((network) => {
2579
+ // =================================================
2580
+ // Current bounding box
2581
+ // =================================================
2582
+ const bb = network.nodes.boundingBox();
2583
+ // =================================================
2584
+ // Move X
2585
+ // =================================================
2586
+ const dx = currentX - bb.x1;
2587
+ // =================================================
2588
+ // Move Y
2589
+ // =================================================
2590
+ const networkCenterY = (bb.y1 + bb.y2) / 2;
2591
+ const rowCenterY = currentY + info.height / 2;
2592
+ const dy = rowCenterY - networkCenterY;
2593
+ // =================================================
2594
+ // Move nodes
2595
+ // =================================================
2596
+ network.nodes.forEach((node) => {
2597
+ const pos = node.position();
2598
+ node.position({
2599
+ x: pos.x + dx,
2600
+ y: pos.y + dy
2601
+ });
2602
+ });
2603
+ // =================================================
2604
+ // Next network
2605
+ // =================================================
2606
+ currentX += network.width + H_GAP;
2607
+ });
2608
+ // ========================================================
2609
+ // Next row
2610
+ // ========================================================
2611
+ currentY += info.height + V_GAP;
2612
+ });
2613
+ // ============================================================
2614
+ // 22. Final network positions
2615
+ // ============================================================
2616
+ finalRows.forEach((row, rowIndex) => {
2617
+ row.forEach((network) => {
2618
+ const bb = network.nodes.boundingBox();
2619
+ });
2620
+ });
2621
+ };
2622
+ /**
2623
+ * Navigate to a specific step in step-by-step mode
2624
+ */
2625
+ SubstructureLayout.prototype.goToStep = function (stepIndex) {
2626
+ if (!this.params.STEP_BY_STEP || this.steps.length === 0) {
2627
+ console.warn('Step-by-step mode is not enabled or no steps have been captured');
2628
+ return this;
2629
+ }
2630
+ if (stepIndex < 0 || stepIndex >= this.steps.length) {
2631
+ console.error(`Invalid step index: ${stepIndex}. Valid range: 0-${this.steps.length - 1}`);
2632
+ return this;
2633
+ }
2634
+ const step = this.steps[stepIndex];
2635
+ this.currentStepIndex = stepIndex;
2636
+ // Restore node positions
2637
+ const nodes = this.cy.nodes();
2638
+ nodes.forEach((node) => {
2639
+ const pos = step.nodePositions[node.id()];
2640
+ if (pos) {
2641
+ node.position({ x: pos.x, y: pos.y });
2642
+ }
2643
+ });
2644
+ // Visualize virtual nodes if they exist at this step
2645
+ this.visualizeVirtualNodes(step);
2646
+ console.log(`[Step ${step.stepNumber}/${this.steps.length - 1}] ${step.stepName}: ${step.description}`);
2647
+ this.cy.fit(null, 50);
2648
+ return this;
2649
+ };
2650
+ /**
2651
+ * Go to the next step
2652
+ */
2653
+ SubstructureLayout.prototype.nextStep = function () {
2654
+ if (this.currentStepIndex < this.steps.length - 1) {
2655
+ return this.goToStep(this.currentStepIndex + 1);
2656
+ }
2657
+ else {
2658
+ console.log('Already at the last step');
2659
+ return this;
2660
+ }
2661
+ };
2662
+ /**
2663
+ * Go to the previous step
2664
+ */
2665
+ SubstructureLayout.prototype.prevStep = function () {
2666
+ if (this.currentStepIndex > 0) {
2667
+ return this.goToStep(this.currentStepIndex - 1);
2668
+ }
2669
+ else {
2670
+ console.log('Already at the first step');
2671
+ return this;
2672
+ }
2673
+ };
2674
+ /**
2675
+ * Get information about all steps
2676
+ */
2677
+ SubstructureLayout.prototype.listSteps = function () {
2678
+ if (!this.params.STEP_BY_STEP || this.steps.length === 0) {
2679
+ console.log('No steps available');
2680
+ return [];
2681
+ }
2682
+ console.log(`Total steps: ${this.steps.length}`);
2683
+ this.steps.forEach((step, index) => {
2684
+ const current = index === this.currentStepIndex ? ' ← CURRENT' : '';
2685
+ console.log(` [${index}] ${step.stepName}: ${step.description}${current}`);
2686
+ });
2687
+ return this.steps.map((s) => ({
2688
+ stepNumber: s.stepNumber,
2689
+ stepName: s.stepName,
2690
+ description: s.description,
2691
+ hasVirtualNodes: !!s.virtualNodes,
2692
+ hasVirtualEdges: !!s.virtualEdges
2693
+ }));
2694
+ };
2695
+ /**
2696
+ * Visualize virtual nodes and edges on the canvas
2697
+ */
2698
+ SubstructureLayout.prototype.visualizeVirtualNodes = function (step) {
2699
+ // Remove previous virtual node visualizations
2700
+ this.cy.$('.virtual-node, .virtual-edge').remove();
2701
+ if (!step.virtualNodes || step.virtualNodes.length === 0) {
2702
+ return;
2703
+ }
2704
+ const virtualElements = [];
2705
+ // Add virtual nodes as semi-transparent overlay nodes
2706
+ step.virtualNodes.forEach((vnode) => {
2707
+ virtualElements.push({
2708
+ group: 'nodes', data: {
2709
+ id: `vnode_${vnode.id}`, label: `VNode: ${vnode.type}`, isVirtual: true
2710
+ }, position: {
2711
+ x: vnode.center_x, y: vnode.center_y
2712
+ }, classes: 'virtual-node', style: {
2713
+ 'width': vnode.radius * 2,
2714
+ 'height': vnode.radius * 2,
2715
+ 'background-color': this.getVirtualNodeColor(vnode.type),
2716
+ 'background-opacity': 0.3,
2717
+ 'border-width': 2,
2718
+ 'border-color': this.getVirtualNodeColor(vnode.type),
2719
+ 'border-opacity': 0.6,
2720
+ 'label': `V:${vnode.type}`,
2721
+ 'font-size': 8,
2722
+ 'text-valign': 'center',
2723
+ 'text-halign': 'center',
2724
+ 'color': '#000',
2725
+ 'text-opacity': 0.7
2726
+ }
2727
+ });
2728
+ });
2729
+ // Add virtual edges
2730
+ if (step.virtualEdges && step.virtualEdges.length > 0) {
2731
+ step.virtualEdges.forEach((vedge, idx) => {
2732
+ virtualElements.push({
2733
+ group: 'edges', data: {
2734
+ id: `vedge_${idx}`,
2735
+ source: `vnode_${vedge.source}`,
2736
+ target: `vnode_${vedge.target}`,
2737
+ isVirtual: true
2738
+ }, classes: 'virtual-edge', style: {
2739
+ 'width': 2, 'line-color': '#ff9800', 'line-style': 'dashed', 'opacity': 0.5
2740
+ }
2741
+ });
2742
+ });
2743
+ }
2744
+ // Add virtual elements to the graph
2745
+ if (virtualElements.length > 0) {
2746
+ this.cy.add(virtualElements);
2747
+ }
2748
+ };
2749
+ /**
2750
+ * Get color for virtual node based on type
2751
+ */
2752
+ SubstructureLayout.prototype.getVirtualNodeColor = function (type) {
2753
+ const colorMap = {
2754
+ 'Normal': '#999999', 'Cycle': '#2196F3', 'Star': '#F48FB1', 'Chain': '#FFF176', 'Parallel': '#50C878'
2755
+ };
2756
+ return colorMap[type] || '#999999';
2757
+ };
2758
+ /**
2759
+ * Clear all virtual node visualizations
2760
+ */
2761
+ SubstructureLayout.prototype.clearVirtualNodes = function () {
2762
+ this.cy.$('.virtual-node, .virtual-edge').remove();
2763
+ return this;
2764
+ };