@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/LICENSE +15 -0
- package/README.md +123 -0
- package/dist/index.d.ts +115 -0
- package/dist/index.js +2764 -0
- package/package.json +45 -0
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
|
+
};
|