@intact-ebi/cytoscape-lumina 1.0.1 → 1.2.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/README.md +16 -3
- package/dist/lumina-layout.d.ts +2 -0
- package/dist/lumina-layout.js +145 -145
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Written in TypeScript. Registered under the layout name `lumina` (the legacy nam
|
|
|
14
14
|
- Leaves that don't belong to a chain are placed on a circle around their parent
|
|
15
15
|
- **Two interchangeable solvers** for the virtual-node graph: `force` and `stress`
|
|
16
16
|
- **Multiple connected components** are each laid out independently, rotated to their minimum bounding box, and packed into rows that match the canvas aspect ratio
|
|
17
|
-
- **
|
|
17
|
+
- **Optional compound parents** (`.substructure-group`) can be generated for each detected substructure using `createSubstructureGroups`. When enabled, this modifies the `parent` data of nodes belonging to these groups and may affect existing compound-node relationships.
|
|
18
18
|
- **Step-by-step mode** to inspect and replay every stage of the pipeline
|
|
19
19
|
|
|
20
20
|
## How it works
|
|
@@ -45,6 +45,7 @@ registerLumina(cytoscape);
|
|
|
45
45
|
|
|
46
46
|
const cy = cytoscape({ container: document.getElementById('cy'), elements: [/* ... */] });
|
|
47
47
|
|
|
48
|
+
```ts
|
|
48
49
|
cy.layout({
|
|
49
50
|
name: 'lumina',
|
|
50
51
|
layoutAlgorithm: 'stress', // 'stress' (default) or 'force'
|
|
@@ -57,10 +58,17 @@ cy.layout({
|
|
|
57
58
|
force: { repulsion: 300, springK: 0.15, useAngularForce: true },
|
|
58
59
|
|
|
59
60
|
// used only when layoutAlgorithm is 'stress'
|
|
60
|
-
stress: { weightExponent: 2 }
|
|
61
|
+
stress: { weightExponent: 2 },
|
|
62
|
+
|
|
63
|
+
// create compound parent nodes for detected substructures
|
|
64
|
+
// Set to false to avoid creating substructure group parents
|
|
65
|
+
createSubstructureGroups: true
|
|
61
66
|
}).run();
|
|
62
67
|
```
|
|
63
68
|
|
|
69
|
+
Set `createSubstructureGroups: false` to disable the creation of compound parent nodes.
|
|
70
|
+
|
|
71
|
+
|
|
64
72
|
Registration is idempotent, so it is safe to call more than once (e.g. with hot reload).
|
|
65
73
|
|
|
66
74
|
## Options
|
|
@@ -107,6 +115,8 @@ Options are split by who uses them:
|
|
|
107
115
|
| --- | --- | --- |
|
|
108
116
|
| `weightExponent` | `2` | Stress weights are `w_ij = 1 / d_ij ^ weightExponent`; `2` is classic stress majorization. |
|
|
109
117
|
|
|
118
|
+
Stress shortest paths use weighted Dijkstra. Computed edge lengths (`idealLength` plus both virtual-node radii) must be finite and non-negative; invalid lengths throw a `RangeError`.
|
|
119
|
+
|
|
110
120
|
### Structure detection
|
|
111
121
|
|
|
112
122
|
| Option | Default | Description |
|
|
@@ -133,6 +143,7 @@ Options are split by who uses them:
|
|
|
133
143
|
| `randomizeInitialPositions` | `false` | Randomize node positions inside the bounding box before the layout starts. |
|
|
134
144
|
| `spreadVNodes` | `true` | After solving, scatter real nodes slightly around their virtual node centre. |
|
|
135
145
|
| `substructureLayout` | `false` | When `false` (default), each substructure is arranged internally (circle, rings, grid, ...). When `true`, that step is skipped. |
|
|
146
|
+
| `createSubstructureGroups` | `true` | Whether to create compound parent nodes (`.substructure-group`) for detected substructures. When enabled, the layout modifies the `parent` data of grouped nodes, which may overwrite or alter existing compound-node relationships. Set to `false` to disable compound parent creation. |
|
|
136
147
|
| `stepByStep` | `false` | Record a snapshot after each stage (see below). |
|
|
137
148
|
|
|
138
149
|
## Step-by-step debugging
|
|
@@ -143,7 +154,7 @@ Set `stepByStep: true`, keep a reference to the layout, and run it:
|
|
|
143
154
|
const layout = cy.layout({ name: 'lumina', stepByStep: true });
|
|
144
155
|
layout.run();
|
|
145
156
|
|
|
146
|
-
layout.listSteps();
|
|
157
|
+
const steps = layout.listSteps(); // return all captured steps
|
|
147
158
|
layout.goToStep(3); // jump to a step
|
|
148
159
|
layout.nextStep();
|
|
149
160
|
layout.prevStep();
|
|
@@ -162,3 +173,5 @@ Each step restores real-node positions and overlays the virtual nodes (transluce
|
|
|
162
173
|
- Nodes with 8 or more neighbours are skipped during cycle search, for performance.
|
|
163
174
|
- Self-loops and duplicate edges are ignored by the layout.
|
|
164
175
|
- Generated compound parents have the class `substructure-group` and ids of the form `<componentIndex>_<groupId>`; they are removed and regenerated on each run.
|
|
176
|
+
* **Node `parent` data may be modified:** When `createSubstructureGroups` is `true` (the default), LUMINA creates compound parent nodes for detected substructures and assigns child nodes to those parents by modifying their `parent` data. If your Cytoscape.js graph already uses compound nodes or existing `parent` relationships, these relationships may be changed. Set `createSubstructureGroups: false` if you do not want LUMINA to create substructure group parents or modify node `parent` data for this purpose. Check your graph's existing compound-node structure before enabling this option.
|
|
177
|
+
* Generated compound parents have the class `substructure-group` and IDs of the form `<componentIndex>_<groupId>`. When enabled, they are removed and regenerated on each run.
|
package/dist/lumina-layout.d.ts
CHANGED
|
@@ -47,6 +47,7 @@ export interface LuminaOptions {
|
|
|
47
47
|
stepByStep?: boolean;
|
|
48
48
|
force?: LuminaForceOptions;
|
|
49
49
|
stress?: LuminaStressOptions;
|
|
50
|
+
createSubstructureGroups?: boolean;
|
|
50
51
|
}
|
|
51
52
|
/** Options passed to `cy.layout()` for the LUMINA layout. */
|
|
52
53
|
export interface LuminaLayoutOptions extends LuminaOptions, cytoscape.BaseLayoutOptions {
|
|
@@ -133,6 +134,7 @@ declare class VEdge {
|
|
|
133
134
|
* iterations: 600, // shared
|
|
134
135
|
* force: { repulsion: 300, springK: 0.15 }, // force only
|
|
135
136
|
* stress: { weightExponent: 2 } // stress only
|
|
137
|
+
* createSubstructureGroups: true
|
|
136
138
|
* }).run();
|
|
137
139
|
*/
|
|
138
140
|
export default function register(cytoscapeInstance: any): void;
|
package/dist/lumina-layout.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import FastPriorityQueue from 'fastpriorityqueue';
|
|
1
2
|
export const DEFAULT_PARAMS = {
|
|
2
3
|
layoutAlgorithm: 'stress',
|
|
3
4
|
// shared solver
|
|
@@ -28,11 +29,12 @@ export const DEFAULT_PARAMS = {
|
|
|
28
29
|
coolingExponent: 2,
|
|
29
30
|
useAngularForce: false,
|
|
30
31
|
angularStrength: 0.2,
|
|
31
|
-
angularMaxForce: 2.0
|
|
32
|
+
angularMaxForce: 2.0,
|
|
32
33
|
},
|
|
33
34
|
stress: {
|
|
34
|
-
weightExponent: 2
|
|
35
|
-
}
|
|
35
|
+
weightExponent: 2,
|
|
36
|
+
},
|
|
37
|
+
createSubstructureGroups: false,
|
|
36
38
|
};
|
|
37
39
|
function applyDefaults(defaults, overrides) {
|
|
38
40
|
const resolved = { ...defaults };
|
|
@@ -110,6 +112,52 @@ class VEdge {
|
|
|
110
112
|
};
|
|
111
113
|
}
|
|
112
114
|
}
|
|
115
|
+
/** Dense stress distances from repeated weighted Dijkstra. */
|
|
116
|
+
function computeStressDistances(nodes, edges, idealLength) {
|
|
117
|
+
const numNodes = nodes.length;
|
|
118
|
+
const nodeIndexMap = new Map();
|
|
119
|
+
nodes.forEach((node, index) => nodeIndexMap.set(node.id, index));
|
|
120
|
+
const adjacency = Array.from({ length: numNodes }, () => []);
|
|
121
|
+
edges.forEach(edge => {
|
|
122
|
+
if (!edge.source || !edge.target) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const source = nodeIndexMap.get(edge.source.id);
|
|
126
|
+
const target = nodeIndexMap.get(edge.target.id);
|
|
127
|
+
if (source === undefined || target === undefined) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const distance = idealLength + edge.source.radius + edge.target.radius;
|
|
131
|
+
if (distance < 0 || !Number.isFinite(distance)) {
|
|
132
|
+
throw new RangeError('cytoscape-lumina: stress edge lengths must be finite and non-negative');
|
|
133
|
+
}
|
|
134
|
+
adjacency[source].push({ node: target, distance });
|
|
135
|
+
adjacency[target].push({ node: source, distance });
|
|
136
|
+
});
|
|
137
|
+
const distances = Array.from({ length: numNodes }, (_, index) => {
|
|
138
|
+
const row = new Array(numNodes).fill(Infinity);
|
|
139
|
+
row[index] = 0;
|
|
140
|
+
return row;
|
|
141
|
+
});
|
|
142
|
+
for (let source = 0; source < numNodes; source++) {
|
|
143
|
+
const row = distances[source];
|
|
144
|
+
const heap = new FastPriorityQueue((a, b) => a.distance < b.distance || (a.distance === b.distance && a.node < b.node));
|
|
145
|
+
heap.add({ node: source, distance: 0 });
|
|
146
|
+
let current;
|
|
147
|
+
while ((current = heap.poll()) !== undefined) {
|
|
148
|
+
if (current.distance !== row[current.node])
|
|
149
|
+
continue;
|
|
150
|
+
for (const neighbor of adjacency[current.node]) {
|
|
151
|
+
const candidate = current.distance + neighbor.distance;
|
|
152
|
+
if (candidate < row[neighbor.node]) {
|
|
153
|
+
row[neighbor.node] = candidate;
|
|
154
|
+
heap.add({ node: neighbor.node, distance: candidate });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return distances;
|
|
160
|
+
}
|
|
113
161
|
/**
|
|
114
162
|
* Register this layout as a Cytoscape.js layout extension.
|
|
115
163
|
*
|
|
@@ -126,6 +174,7 @@ class VEdge {
|
|
|
126
174
|
* iterations: 600, // shared
|
|
127
175
|
* force: { repulsion: 300, springK: 0.15 }, // force only
|
|
128
176
|
* stress: { weightExponent: 2 } // stress only
|
|
177
|
+
* createSubstructureGroups: true
|
|
129
178
|
* }).run();
|
|
130
179
|
*/
|
|
131
180
|
export default function register(cytoscapeInstance) {
|
|
@@ -203,18 +252,8 @@ LuminaLayout.prototype.captureStep = function (stepName, description, metadata)
|
|
|
203
252
|
};
|
|
204
253
|
this.steps.push(step);
|
|
205
254
|
this.currentStepIndex = this.steps.length - 1;
|
|
206
|
-
console.
|
|
255
|
+
console.debug(`[Step ${step.stepNumber}] ${stepName}: ${description}`);
|
|
207
256
|
};
|
|
208
|
-
/////////////// Check whether two node arrays are identical, ignoring order
|
|
209
|
-
function areNodesEqual(arr1, arr2) {
|
|
210
|
-
// 1. Different lengths mean they are definitely different
|
|
211
|
-
if (arr1.length !== arr2.length)
|
|
212
|
-
return false;
|
|
213
|
-
// 2. Extract all IDs and put them into a Set
|
|
214
|
-
const ids1 = new Set(arr1.map((node) => node.id()));
|
|
215
|
-
// 3. Check whether every ID in arr2 exists in the Set
|
|
216
|
-
return arr2.every(node => ids1.has(node.id()));
|
|
217
|
-
}
|
|
218
257
|
////////////////sum length of all edges ////////////////
|
|
219
258
|
function totalEdgeLength(edges) {
|
|
220
259
|
let total = 0;
|
|
@@ -293,6 +332,17 @@ function layoutRectangular(nodes, center, dirVector, rowSpacing = 60, colSpacing
|
|
|
293
332
|
}
|
|
294
333
|
LuminaLayout.prototype.identifyStructures = function (nodes) {
|
|
295
334
|
const params = this.params;
|
|
335
|
+
// Topology stays fixed during detection; structure markers are filtered dynamically.
|
|
336
|
+
const neighborhoodCache = new Map();
|
|
337
|
+
const neighborsOf = (node) => {
|
|
338
|
+
const id = node.id();
|
|
339
|
+
let neighbors = neighborhoodCache.get(id);
|
|
340
|
+
if (neighbors === undefined) {
|
|
341
|
+
neighbors = node.neighborhood().nodes();
|
|
342
|
+
neighborhoodCache.set(id, neighbors);
|
|
343
|
+
}
|
|
344
|
+
return neighbors;
|
|
345
|
+
};
|
|
296
346
|
// 0. Reset all markers
|
|
297
347
|
nodes.data('structType', 'Normal');
|
|
298
348
|
// nodes.data('structColor', '#999999');
|
|
@@ -304,7 +354,7 @@ LuminaLayout.prototype.identifyStructures = function (nodes) {
|
|
|
304
354
|
///////////////////////////// 2. detecting Cycle - by DFS algorithm /////////////////
|
|
305
355
|
const allCycles = []; // each sub-array represents an independent cycle
|
|
306
356
|
if (1) {
|
|
307
|
-
const normalNodes = nodes.toArray().filter((n) => n.data('structType') === 'Normal' && n
|
|
357
|
+
const normalNodes = nodes.toArray().filter((n) => n.data('structType') === 'Normal' && neighborsOf(n).length >= 2);
|
|
308
358
|
const seenCycles = new Set();
|
|
309
359
|
// To avoid duplicate cycles (like A-B-C and B-C-A), we sort and stringify for a check
|
|
310
360
|
normalNodes.forEach((startNode, startIndex) => {
|
|
@@ -312,7 +362,7 @@ LuminaLayout.prototype.identifyStructures = function (nodes) {
|
|
|
312
362
|
// This is a massive optimization to prevent finding the same cycle N times
|
|
313
363
|
const startId = startNode.id();
|
|
314
364
|
const findCycles = (u, parent, path) => {
|
|
315
|
-
const neighbors = u
|
|
365
|
+
const neighbors = neighborsOf(u).toArray().filter((n) => n.data('structType') === 'Normal');
|
|
316
366
|
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
|
|
317
367
|
for (const v of neighbors) {
|
|
318
368
|
const vId = v.id();
|
|
@@ -393,11 +443,10 @@ LuminaLayout.prototype.identifyStructures = function (nodes) {
|
|
|
393
443
|
});
|
|
394
444
|
circleIndex++;
|
|
395
445
|
});
|
|
396
|
-
console.log("num of cycles: ", filteredCycles.length);
|
|
397
446
|
const chains = [];
|
|
398
447
|
const processedNodeIds = new Set(); // avoid processing nodes more than once
|
|
399
448
|
// 2. Find all leaf nodes (Normal type with degree 1)
|
|
400
|
-
const leafNodes = nodes.filter((n) => n.data('structType') === 'Normal' && n
|
|
449
|
+
const leafNodes = nodes.filter((n) => n.data('structType') === 'Normal' && neighborsOf(n).length === 1);
|
|
401
450
|
let chainId = 0;
|
|
402
451
|
leafNodes.forEach((leaf) => {
|
|
403
452
|
if (processedNodeIds.has(leaf.id()))
|
|
@@ -410,8 +459,7 @@ LuminaLayout.prototype.identifyStructures = function (nodes) {
|
|
|
410
459
|
currentChainNodes.push(currentNode);
|
|
411
460
|
processedNodeIds.add(currentNode.id());
|
|
412
461
|
// Find the next neighbor
|
|
413
|
-
const neighbors = currentNode
|
|
414
|
-
.neighborhood('node')
|
|
462
|
+
const neighbors = neighborsOf(currentNode)
|
|
415
463
|
.filter((n) => n.id() !== currentNode.id() &&
|
|
416
464
|
n.data('structType') === 'Normal' &&
|
|
417
465
|
!processedNodeIds.has(n.id()));
|
|
@@ -420,8 +468,7 @@ LuminaLayout.prototype.identifyStructures = function (nodes) {
|
|
|
420
468
|
// 2. The neighbor must not have a high degree (if degree > 2, a branching point has been reached and the chain ends)
|
|
421
469
|
if (neighbors.length === 1) {
|
|
422
470
|
const nextNode = neighbors[0];
|
|
423
|
-
const nextNodeNeighborCount = nextNode
|
|
424
|
-
.neighborhood('node')
|
|
471
|
+
const nextNodeNeighborCount = neighborsOf(nextNode)
|
|
425
472
|
.filter((n) => n.id() !== nextNode.id())
|
|
426
473
|
.length;
|
|
427
474
|
if (nextNodeNeighborCount > 2) {
|
|
@@ -475,11 +522,11 @@ LuminaLayout.prototype.identifyStructures = function (nodes) {
|
|
|
475
522
|
///////////////////////////////// Star structure ///////////
|
|
476
523
|
let starIndex = 0;
|
|
477
524
|
nodes.forEach((node) => {
|
|
478
|
-
const neighbors = node
|
|
525
|
+
const neighbors = neighborsOf(node);
|
|
479
526
|
// Number of distinct neighbors
|
|
480
527
|
const neighborCount = neighbors.length;
|
|
481
528
|
// Check whether the neighbors consist only of distinct neighbor nodes
|
|
482
|
-
const leafNeighbors = neighbors.filter((n) => n
|
|
529
|
+
const leafNeighbors = neighbors.filter((n) => neighborsOf(n).length === 1);
|
|
483
530
|
if (leafNeighbors.length >= params.minStarLeaves && neighborCount >= params.minStarLeaves) {
|
|
484
531
|
if (node.data('structType') !== 'Cycle' && node.data('structType') !== 'Chain') {
|
|
485
532
|
const groupId = 'Star_' + starIndex;
|
|
@@ -508,56 +555,57 @@ LuminaLayout.prototype.identifyStructures = function (nodes) {
|
|
|
508
555
|
}
|
|
509
556
|
});
|
|
510
557
|
/////////////////////////////// Find parallel/diamond structures ///////////////////////////////////
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
const u1 = u.neighborhood().nodes();
|
|
518
|
-
if (u.data('structType') === 'Normal') {
|
|
519
|
-
for (let j = 0; j < nodes.length; j++) {
|
|
520
|
-
if (i === j)
|
|
521
|
-
continue;
|
|
522
|
-
const v = nodes[j];
|
|
523
|
-
if (v.data('structType') === 'Normal') {
|
|
524
|
-
const v1 = v.neighborhood().nodes();
|
|
525
|
-
if (areNodesEqual(v1, u1) && v1.length >= params.minParallelNeighbors && u1.length >= params.minParallelNeighbors) {
|
|
526
|
-
if (nodeVecParallel.length === 0) {
|
|
527
|
-
nodeVecParallel.push(u.id());
|
|
528
|
-
}
|
|
529
|
-
nodeVecParallel.push(v.id());
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|
|
558
|
+
const nodesById = new Map();
|
|
559
|
+
const parallelGroups = new Map();
|
|
560
|
+
nodes.forEach((node) => {
|
|
561
|
+
nodesById.set(node.id(), node);
|
|
562
|
+
if (node.data("structType") !== "Normal") {
|
|
563
|
+
return;
|
|
533
564
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
});
|
|
547
|
-
}
|
|
548
|
-
});
|
|
549
|
-
});
|
|
550
|
-
u1.forEach((v1) => {
|
|
551
|
-
nodes.forEach((node) => {
|
|
552
|
-
if (node.id() == v1.id()) {
|
|
553
|
-
node.data('parallelGroupIdVec', [...node.data('parallelGroupIdVec'), 'Parallel' + parallelId]);
|
|
554
|
-
}
|
|
555
|
-
});
|
|
556
|
-
});
|
|
557
|
-
parallelId++;
|
|
565
|
+
const neighbors = neighborsOf(node);
|
|
566
|
+
if (!(neighbors.length >= params.minParallelNeighbors)) {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
// Encode a sorted copy so IDs cannot collide and cached neighbor order stays intact.
|
|
570
|
+
const signature = JSON.stringify(neighbors.map((neighbor) => neighbor.id()).sort());
|
|
571
|
+
const group = parallelGroups.get(signature);
|
|
572
|
+
if (group) {
|
|
573
|
+
group.push(node);
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
parallelGroups.set(signature, [node]);
|
|
558
577
|
}
|
|
578
|
+
});
|
|
579
|
+
// Map insertion order preserves group numbering by the first eligible member.
|
|
580
|
+
let parallelId = 0;
|
|
581
|
+
for (const group of parallelGroups.values()) {
|
|
582
|
+
if (group.length < 2)
|
|
583
|
+
continue;
|
|
584
|
+
const groupId = "Parallel" + parallelId;
|
|
585
|
+
group.forEach((node) => {
|
|
586
|
+
node.addClass("substructure-parallel");
|
|
587
|
+
node.data("structType", "Parallel");
|
|
588
|
+
node.data("groupId", groupId);
|
|
589
|
+
node.data("structs", {
|
|
590
|
+
...node.data("structs"),
|
|
591
|
+
Parallel: {
|
|
592
|
+
color: "#50C878",
|
|
593
|
+
groupId: `Parallel_${parallelId}`,
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
neighborsOf(group[0]).forEach((neighbor) => {
|
|
598
|
+
// Neighborhoods may include nodes outside the supplied collection.
|
|
599
|
+
const node = nodesById.get(neighbor.id());
|
|
600
|
+
if (node) {
|
|
601
|
+
node.data("parallelGroupIdVec", [
|
|
602
|
+
...node.data("parallelGroupIdVec"),
|
|
603
|
+
groupId,
|
|
604
|
+
]);
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
parallelId++;
|
|
559
608
|
}
|
|
560
|
-
console.log("num of chains: ", chains.length);
|
|
561
609
|
};
|
|
562
610
|
LuminaLayout.prototype.run = function () {
|
|
563
611
|
this.stopped = false;
|
|
@@ -583,7 +631,6 @@ LuminaLayout.prototype.run = function () {
|
|
|
583
631
|
components.forEach((component, index) => {
|
|
584
632
|
this.vnodes = [];
|
|
585
633
|
this.vedges = [];
|
|
586
|
-
console.log(`independent net: ${index + 1}`);
|
|
587
634
|
// 1. get user defined boundingBox,if not then default cy container
|
|
588
635
|
const bb = this.boundingBox || this.cy.extent();
|
|
589
636
|
const width = bb.x2 - bb.x1;
|
|
@@ -612,8 +659,6 @@ LuminaLayout.prototype.run = function () {
|
|
|
612
659
|
});
|
|
613
660
|
});
|
|
614
661
|
}
|
|
615
|
-
console.log("num of nodes: " + nodes.length);
|
|
616
|
-
console.log("num of edges: " + edgeSet.length);
|
|
617
662
|
// Capture initial state
|
|
618
663
|
this.captureStep('Initial', 'Initial node positions before any layout', {
|
|
619
664
|
nodeCount: nodes.length, edgeCount: edgeSet.length
|
|
@@ -1089,6 +1134,7 @@ LuminaLayout.prototype.run = function () {
|
|
|
1089
1134
|
numOfCollision = 0;
|
|
1090
1135
|
const padding = params.collisionPadding;
|
|
1091
1136
|
let iter = 0;
|
|
1137
|
+
let overlapLimitReached = false;
|
|
1092
1138
|
while (colisionFlag) {
|
|
1093
1139
|
if (this.stopped) {
|
|
1094
1140
|
break;
|
|
@@ -1131,12 +1177,17 @@ LuminaLayout.prototype.run = function () {
|
|
|
1131
1177
|
}
|
|
1132
1178
|
// [Optimization 3] Safety valve: exit directly if the iteration count becomes too large (typically when nodes are extremely dense)
|
|
1133
1179
|
if (numOfCollision > 10000) {
|
|
1134
|
-
|
|
1180
|
+
overlapLimitReached = true;
|
|
1135
1181
|
break;
|
|
1136
1182
|
}
|
|
1137
|
-
// this.captureStep('Anti-collision', 'Eliminate all collisions ' + iter, {iterations});
|
|
1138
1183
|
}
|
|
1139
|
-
this.captureStep(
|
|
1184
|
+
this.captureStep("Anti-collision", overlapLimitReached
|
|
1185
|
+
? "Overlap removal stopped at safety limit"
|
|
1186
|
+
: "Eliminate all collisions", {
|
|
1187
|
+
iterations,
|
|
1188
|
+
overlapIterations: iter,
|
|
1189
|
+
overlapLimitReached,
|
|
1190
|
+
});
|
|
1140
1191
|
if (1) {
|
|
1141
1192
|
numOfCollision = 0;
|
|
1142
1193
|
for (let i = 0; i < this.vnodes.length; i++) {
|
|
@@ -1169,8 +1220,7 @@ LuminaLayout.prototype.run = function () {
|
|
|
1169
1220
|
if (numNodes === 0)
|
|
1170
1221
|
return;
|
|
1171
1222
|
// Index mapping: ID -> array index, for matrix operations
|
|
1172
|
-
|
|
1173
|
-
this.vnodes.forEach((v, i) => nodeIndexMap.set(v.id, i));
|
|
1223
|
+
// The distance helper builds the index alongside its weighted adjacency.
|
|
1174
1224
|
// Extract the adjacency list for reuse during initialization
|
|
1175
1225
|
const adj = new Map();
|
|
1176
1226
|
this.vedges.forEach((e) => {
|
|
@@ -1184,34 +1234,10 @@ LuminaLayout.prototype.run = function () {
|
|
|
1184
1234
|
adj.get(e.target.id).push(e.source);
|
|
1185
1235
|
});
|
|
1186
1236
|
// -------------------------------------------------------------
|
|
1187
|
-
// [Preprocessing 1] Calculate
|
|
1237
|
+
// [Preprocessing 1] Calculate all-pairs shortest paths using weighted Dijkstra
|
|
1188
1238
|
// -------------------------------------------------------------
|
|
1189
|
-
const distMatrix =
|
|
1239
|
+
const distMatrix = computeStressDistances(this.vnodes, this.vedges, idealLength);
|
|
1190
1240
|
const weightMatrix = Array.from({ length: numNodes }, () => new Array(numNodes).fill(0));
|
|
1191
|
-
for (let i = 0; i < numNodes; i++)
|
|
1192
|
-
distMatrix[i][i] = 0;
|
|
1193
|
-
// Assign the ideal distance baseline based on real edges
|
|
1194
|
-
this.vedges.forEach((e) => {
|
|
1195
|
-
if (!e.source || !e.target)
|
|
1196
|
-
return;
|
|
1197
|
-
const u = nodeIndexMap.get(e.source.id);
|
|
1198
|
-
const v = nodeIndexMap.get(e.target.id);
|
|
1199
|
-
if (u !== undefined && v !== undefined) {
|
|
1200
|
-
const idealDist = idealLength + e.source.radius + e.target.radius;
|
|
1201
|
-
distMatrix[u][v] = Math.min(distMatrix[u][v], idealDist);
|
|
1202
|
-
distMatrix[v][u] = Math.min(distMatrix[v][u], idealDist);
|
|
1203
|
-
}
|
|
1204
|
-
});
|
|
1205
|
-
// Use Floyd-Warshall to compute shortest paths between all node pairs
|
|
1206
|
-
for (let k = 0; k < numNodes; k++) {
|
|
1207
|
-
for (let i = 0; i < numNodes; i++) {
|
|
1208
|
-
for (let j = 0; j < numNodes; j++) {
|
|
1209
|
-
if (distMatrix[i][k] + distMatrix[k][j] < distMatrix[i][j]) {
|
|
1210
|
-
distMatrix[i][j] = distMatrix[i][k] + distMatrix[k][j];
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
}
|
|
1215
1241
|
// Calculate the weight matrix W_ij = 1 / (d_ij ^ weightExponent)
|
|
1216
1242
|
for (let i = 0; i < numNodes; i++) {
|
|
1217
1243
|
for (let j = 0; j < numNodes; j++) {
|
|
@@ -1321,6 +1347,7 @@ LuminaLayout.prototype.run = function () {
|
|
|
1321
1347
|
let numOfCollision = 0;
|
|
1322
1348
|
const padding = params.collisionPadding;
|
|
1323
1349
|
let overlapIter = 0;
|
|
1350
|
+
let overlapLimitReached = false;
|
|
1324
1351
|
while (colisionFlag) {
|
|
1325
1352
|
if (this.stopped) {
|
|
1326
1353
|
break;
|
|
@@ -1354,11 +1381,17 @@ LuminaLayout.prototype.run = function () {
|
|
|
1354
1381
|
}
|
|
1355
1382
|
}
|
|
1356
1383
|
if (numOfCollision > 10000 || overlapIter > params.maxOverlapIterations) {
|
|
1357
|
-
|
|
1384
|
+
overlapLimitReached = true;
|
|
1358
1385
|
break;
|
|
1359
1386
|
}
|
|
1360
1387
|
}
|
|
1361
|
-
this.captureStep(
|
|
1388
|
+
this.captureStep("Anti-collision", overlapLimitReached
|
|
1389
|
+
? "Overlap removal stopped at safety limit"
|
|
1390
|
+
: "Eliminate all collisions", {
|
|
1391
|
+
iterations,
|
|
1392
|
+
overlapIterations: overlapIter,
|
|
1393
|
+
overlapLimitReached,
|
|
1394
|
+
});
|
|
1362
1395
|
}
|
|
1363
1396
|
this.captureStep('Virtual Node Layout', 'Stress Majorization layout applied to virtual nodes', { iterations });
|
|
1364
1397
|
}
|
|
@@ -1670,34 +1703,6 @@ LuminaLayout.prototype.run = function () {
|
|
|
1670
1703
|
nodes[2].position().y = 90;
|
|
1671
1704
|
}
|
|
1672
1705
|
rotateNetworkToMinimumBoundingBox(nodes);
|
|
1673
|
-
if (1) {
|
|
1674
|
-
let maxDis = 0;
|
|
1675
|
-
let maxS = 0;
|
|
1676
|
-
let maxT = 0;
|
|
1677
|
-
let minDist = 10e10;
|
|
1678
|
-
let minS = 0;
|
|
1679
|
-
let minT = 0;
|
|
1680
|
-
let avgDis = 0;
|
|
1681
|
-
edgeSet.forEach((e) => {
|
|
1682
|
-
const s = e.source();
|
|
1683
|
-
const t = e.target();
|
|
1684
|
-
const distance = Math.sqrt(Math.pow(s.position().x - t.position().x, 2) + Math.pow(s.position().y - t.position().y, 2));
|
|
1685
|
-
if (maxDis < distance) {
|
|
1686
|
-
maxDis = distance;
|
|
1687
|
-
maxS = s.id();
|
|
1688
|
-
maxT = t.id();
|
|
1689
|
-
}
|
|
1690
|
-
if (minDist > distance) {
|
|
1691
|
-
minDist = distance;
|
|
1692
|
-
minS = s.id();
|
|
1693
|
-
minT = t.id();
|
|
1694
|
-
}
|
|
1695
|
-
avgDis += distance;
|
|
1696
|
-
});
|
|
1697
|
-
console.log("maxDis:", maxDis, " ", maxS, "->", maxT);
|
|
1698
|
-
console.log("minDist:", minDist, " ", minS, "->", minT);
|
|
1699
|
-
console.log("avgDis:", avgDis / edgeSet.length);
|
|
1700
|
-
}
|
|
1701
1706
|
const bb2 = nodes.boundingBox();
|
|
1702
1707
|
networkInfos.push({
|
|
1703
1708
|
component: component,
|
|
@@ -1710,7 +1715,7 @@ LuminaLayout.prototype.run = function () {
|
|
|
1710
1715
|
});
|
|
1711
1716
|
});
|
|
1712
1717
|
this.packNetworks(networkInfos);
|
|
1713
|
-
if (
|
|
1718
|
+
if (this.params.createSubstructureGroups) {
|
|
1714
1719
|
networkInfos.forEach((networkInfo, index) => {
|
|
1715
1720
|
const nodes = networkInfo.nodes;
|
|
1716
1721
|
// All parent IDs in the current network
|
|
@@ -2289,7 +2294,7 @@ LuminaLayout.prototype.goToStep = function (stepIndex) {
|
|
|
2289
2294
|
});
|
|
2290
2295
|
// Visualize virtual nodes if they exist at this step
|
|
2291
2296
|
this.visualizeVirtualNodes(step);
|
|
2292
|
-
console.
|
|
2297
|
+
console.debug(`[Step ${step.stepNumber}/${this.steps.length - 1}] ${step.stepName}: ${step.description}`);
|
|
2293
2298
|
this.cy.fit(null, 50);
|
|
2294
2299
|
return this;
|
|
2295
2300
|
};
|
|
@@ -2301,7 +2306,7 @@ LuminaLayout.prototype.nextStep = function () {
|
|
|
2301
2306
|
return this.goToStep(this.currentStepIndex + 1);
|
|
2302
2307
|
}
|
|
2303
2308
|
else {
|
|
2304
|
-
console.
|
|
2309
|
+
console.debug("Already at the last step");
|
|
2305
2310
|
return this;
|
|
2306
2311
|
}
|
|
2307
2312
|
};
|
|
@@ -2313,7 +2318,7 @@ LuminaLayout.prototype.prevStep = function () {
|
|
|
2313
2318
|
return this.goToStep(this.currentStepIndex - 1);
|
|
2314
2319
|
}
|
|
2315
2320
|
else {
|
|
2316
|
-
console.
|
|
2321
|
+
console.debug("Already at the first step");
|
|
2317
2322
|
return this;
|
|
2318
2323
|
}
|
|
2319
2324
|
};
|
|
@@ -2322,14 +2327,9 @@ LuminaLayout.prototype.prevStep = function () {
|
|
|
2322
2327
|
*/
|
|
2323
2328
|
LuminaLayout.prototype.listSteps = function () {
|
|
2324
2329
|
if (!this.params.stepByStep || this.steps.length === 0) {
|
|
2325
|
-
console.
|
|
2330
|
+
console.debug("No steps available");
|
|
2326
2331
|
return [];
|
|
2327
2332
|
}
|
|
2328
|
-
console.log(`Total steps: ${this.steps.length}`);
|
|
2329
|
-
this.steps.forEach((step, index) => {
|
|
2330
|
-
const current = index === this.currentStepIndex ? ' ← CURRENT' : '';
|
|
2331
|
-
console.log(` [${index}] ${step.stepName}: ${step.description}${current}`);
|
|
2332
|
-
});
|
|
2333
2333
|
return this.steps.map((s) => ({
|
|
2334
2334
|
stepNumber: s.stepNumber,
|
|
2335
2335
|
stepName: s.stepName,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intact-ebi/cytoscape-lumina",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "LUMINA: a structure-aware Cytoscape.js layout that detects cycles, chains, stars and parallel groups and lays them out with force-directed or stress-majorization solvers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
"registry": "https://registry.npmjs.org/"
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
|
-
"build": "
|
|
30
|
-
"
|
|
29
|
+
"build": "npm run build:lib && npm run build:browser && cp demo/favicon.ico dist/favicon.ico && cp demo/index.html dist/index.html && cp node_modules/cytoscape/dist/cytoscape.min.js dist/ && if [ -d demo/data ]; then cp -r demo/data dist/data; fi && touch dist/.nojekyll",
|
|
30
|
+
"build:browser": "esbuild src/index.ts --bundle --format=esm --platform=browser --target=es2020 --external:cytoscape --outfile=dist/lumina-browser.js",
|
|
31
|
+
"dev": "npm run build && concurrently \"tsc --watch\" \"npm run build:browser -- --watch\" \"node watch.cjs\" \"http-server dist -p 8080 -c-1\"",
|
|
31
32
|
"deploy": "./deploy.sh",
|
|
32
33
|
"build:lib": "tsc",
|
|
33
34
|
"local-publish": "npm run build:lib && yalc push",
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
"devDependencies": {
|
|
38
39
|
"concurrently": "^9.2.4",
|
|
39
40
|
"cytoscape": "^3.33.1",
|
|
41
|
+
"esbuild": "^0.28.2",
|
|
40
42
|
"gh-pages": "^6.3.0",
|
|
41
43
|
"http-server": "^14.1.1",
|
|
42
44
|
"typescript": "^5.9.3",
|
|
@@ -65,5 +67,8 @@
|
|
|
65
67
|
},
|
|
66
68
|
"allowScripts": {
|
|
67
69
|
"fsevents@2.3.3": true
|
|
70
|
+
},
|
|
71
|
+
"dependencies": {
|
|
72
|
+
"fastpriorityqueue": "^0.8.0"
|
|
68
73
|
}
|
|
69
74
|
}
|