@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 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
- - **Compound parents** (`.substructure-group`) are generated for each detected substructure
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(); // print and return all captured steps
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.
@@ -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;
@@ -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.log(`[Step ${step.stepNumber}] ${stepName}: ${description}`);
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.neighborhood().nodes().length >= 2);
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.neighborhood().nodes().toArray().filter((n) => n.data('structType') === 'Normal');
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.neighborhood().nodes().length === 1);
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.neighborhood().nodes();
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.neighborhood().nodes().length === 1);
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
- // let diamonds = [];
512
- let parallelId = 0;
513
- // Whether any two nodes have common neighbors
514
- for (let i = 0; i < nodes.length; i++) {
515
- let nodeVecParallel = [];
516
- const u = nodes[i];
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
- if (nodeVecParallel.length >= 2) {
535
- nodeVecParallel.forEach(v => {
536
- nodes.forEach((node) => {
537
- if (node.id() == v) {
538
- node.addClass('substructure-parallel');
539
- node.data('structType', 'Parallel');
540
- // node.data('structColor', '#50C878');
541
- node.data('groupId', 'Parallel' + parallelId);
542
- node.data('structs', {
543
- ...node.data('structs'), Parallel: {
544
- color: '#50C878', groupId: `Parallel_${parallelId}`
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
- console.warn("avoid dead loop");
1180
+ overlapLimitReached = true;
1135
1181
  break;
1136
1182
  }
1137
- // this.captureStep('Anti-collision', 'Eliminate all collisions ' + iter, {iterations});
1138
1183
  }
1139
- this.captureStep('Anti-collision', 'Eliminate all collisions', { iterations });
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
- const nodeIndexMap = new Map();
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 the shortest-path distance matrix between every pair of nodes in the graph (APSP - Floyd Warshall)
1237
+ // [Preprocessing 1] Calculate all-pairs shortest paths using weighted Dijkstra
1188
1238
  // -------------------------------------------------------------
1189
- const distMatrix = Array.from({ length: numNodes }, () => new Array(numNodes).fill(Infinity));
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
- console.warn("Avoid dead loop in overlap removal");
1384
+ overlapLimitReached = true;
1358
1385
  break;
1359
1386
  }
1360
1387
  }
1361
- this.captureStep('Anti-collision', 'Eliminate all collisions', { iterations });
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 (1) {
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.log(`[Step ${step.stepNumber}/${this.steps.length - 1}] ${step.stepName}: ${step.description}`);
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.log('Already at the last step');
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.log('Already at the first step');
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.log('No steps available');
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.1",
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": "tsc && 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
- "dev": "npm run build && concurrently \"tsc --watch\" \"node watch.cjs\" \"http-server dist -p 8080 -c-1\"",
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
  }