@intact-ebi/cytoscape-lumina 1.0.0 → 1.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/README.md CHANGED
@@ -71,7 +71,7 @@ Options are split by who uses them:
71
71
  - **Force-only** options go under `force: { ... }` and are ignored when `layoutAlgorithm` is `'stress'`.
72
72
  - **Stress-only** options go under `stress: { ... }` and are ignored when `layoutAlgorithm` is `'force'`.
73
73
 
74
- Internally these map to upper-case constants (e.g. `idealLength` → `IDEAL_LENGTH`); `DEFAULT_PARAMS` and `resolveParams` are exported if you need them.
74
+ `DEFAULT_PARAMS` and `resolveParams` are exported for consumers that need the complete resolved configuration.
75
75
 
76
76
  ### Algorithm
77
77
 
@@ -107,13 +107,14 @@ Internally these map to upper-case constants (e.g. `idealLength` → `IDEAL_LENG
107
107
  | --- | --- | --- |
108
108
  | `weightExponent` | `2` | Stress weights are `w_ij = 1 / d_ij ^ weightExponent`; `2` is classic stress majorization. |
109
109
 
110
+ 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`.
111
+
110
112
  ### Structure detection
111
113
 
112
114
  | Option | Default | Description |
113
115
  | --- | --- | --- |
114
116
  | `minStarLeaves` | `3` | Minimum leaf neighbours for a node to become a star centre. |
115
117
  | `minCycleLength` | `3` | Minimum number of nodes in a detected cycle. |
116
- | `maxCycleLength` | `30` | Reserved. Currently **not enforced** by the cycle search. |
117
118
  | `minChainLength` | `2` | Minimum number of nodes in a chain. |
118
119
  | `minParallelNeighbors` | `2` | Minimum shared neighbours for nodes to count as a parallel group. |
119
120
 
@@ -136,10 +137,6 @@ Internally these map to upper-case constants (e.g. `idealLength` → `IDEAL_LENG
136
137
  | `substructureLayout` | `false` | When `false` (default), each substructure is arranged internally (circle, rings, grid, ...). When `true`, that step is skipped. |
137
138
  | `stepByStep` | `false` | Record a snapshot after each stage (see below). |
138
139
 
139
- ### Backward compatibility
140
-
141
- The old flat force options (`repulsion`, `springK`, `useAngularForce`, `angularStrength`) and the legacy upper-case `params: { ... }` object are still accepted. Nested `force` / `stress` objects take priority.
142
-
143
140
  ## Step-by-step debugging
144
141
 
145
142
  Set `stepByStep: true`, keep a reference to the layout, and run it:
@@ -148,7 +145,7 @@ Set `stepByStep: true`, keep a reference to the layout, and run it:
148
145
  const layout = cy.layout({ name: 'lumina', stepByStep: true });
149
146
  layout.run();
150
147
 
151
- layout.listSteps(); // print and return all captured steps
148
+ const steps = layout.listSteps(); // return all captured steps
152
149
  layout.goToStep(3); // jump to a step
153
150
  layout.nextStep();
154
151
  layout.prevStep();
@@ -1,78 +1,66 @@
1
1
  import cytoscape from 'cytoscape';
2
2
  export type LayoutAlgorithm = 'force' | 'stress';
3
- /** Parameters shared by every algorithm */
4
- export interface CommonParams {
5
- LAYOUT_ALGORITHM: LayoutAlgorithm;
3
+ /** Options used only by the force-directed solver. */
4
+ export interface LuminaForceOptions {
5
+ /** Node-node repulsion strength. */
6
+ repulsion?: number;
7
+ /** Spring stiffness of edges. */
8
+ springK?: number;
9
+ /** Multiplier applied to repulsion for overlapping virtual nodes. */
10
+ overlapRepulsionFactor?: number;
11
+ /** Exponent used by the simulated-annealing cooling function. */
12
+ coolingExponent?: number;
13
+ /** Whether to spread leaf virtual nodes evenly around their hub. */
14
+ useAngularForce?: boolean;
15
+ angularStrength?: number;
16
+ angularMaxForce?: number;
17
+ }
18
+ /** Options used only by the stress-majorization solver. */
19
+ export interface LuminaStressOptions {
20
+ /** Exponent in the stress weight formula: w = 1 / distance ^ exponent. */
21
+ weightExponent?: number;
22
+ }
23
+ /** Public LUMINA options, excluding Cytoscape's required layout name. */
24
+ export interface LuminaOptions {
25
+ layoutAlgorithm?: LayoutAlgorithm;
6
26
  /** Ideal surface-to-surface gap between connected virtual nodes
7
27
  * (force: spring rest length; stress: base of the target distance;
8
- * both: radius of the initial radial placement of leaf vnodes) */
9
- IDEAL_LENGTH: number;
10
- /** Max iterations of the main solver loop */
11
- ITERATIONS: number;
12
- /** Stop early when the max movement of any vnode is below this (px) */
13
- CONVERGENCE_THRESHOLD: number;
14
- /** Extra gap enforced between vnodes in the anti-collision pass.
15
- * null => use IDEAL_LENGTH */
16
- COLLISION_PADDING: number | null;
17
- /** Safety cap for the anti-collision loop */
18
- MAX_OVERLAP_ITERATIONS: number;
19
- MIN_STAR_LEAVES: number;
20
- MIN_CYCLE_LENGTH: number;
21
- MAX_CYCLE_LENGTH: number;
22
- MIN_CHAIN_LENGTH: number;
23
- MIN_PARALLEL_NEIGHBORS: number;
24
- CYCLE_NODE_SPACING: number;
25
- STAR_RING_SPACING: number;
26
- STAR_BASE_NODES_PER_RING: number;
27
- CHAIN_MIN_RADIUS: number;
28
- LEAF_NODE_DISTANCE: number;
29
- RANDOMIZE_INITIAL_POSITIONS: boolean;
30
- SPREAD_V_NODES: boolean;
31
- SUBSTRUCTURE_LAYOUT: boolean;
32
- STEP_BY_STEP: boolean;
28
+ * both: radius of the initial radial placement of leaf virtual nodes). */
29
+ idealLength?: number;
30
+ iterations?: number;
31
+ convergenceThreshold?: number;
32
+ /** Extra gap in the anti-collision pass. Null uses `idealLength`. */
33
+ collisionPadding?: number | null;
34
+ maxOverlapIterations?: number;
35
+ minStarLeaves?: number;
36
+ minCycleLength?: number;
37
+ minChainLength?: number;
38
+ minParallelNeighbors?: number;
39
+ cycleNodeSpacing?: number;
40
+ starRingSpacing?: number;
41
+ starBaseNodesPerRing?: number;
42
+ chainMinRadius?: number;
43
+ leafNodeDistance?: number;
44
+ randomizeInitialPositions?: boolean;
45
+ spreadVNodes?: boolean;
46
+ substructureLayout?: boolean;
47
+ stepByStep?: boolean;
48
+ force?: LuminaForceOptions;
49
+ stress?: LuminaStressOptions;
33
50
  }
34
- /** Only for the 'force' algorithm */
35
- export interface ForceParams {
36
- /** Node-node repulsion strength */
37
- REPULSION: number;
38
- /** Spring stiffness of edges */
39
- SPRING_K: number;
40
- /** Overlapping vnodes repel REPULSION * this factor harder */
41
- OVERLAP_REPULSION_FACTOR: number;
42
- /** Simulated-annealing cooling: cooling = (1 - iter/ITERATIONS) ^ this */
43
- COOLING_EXPONENT: number;
44
- /** Angular force that spreads leaf vnodes evenly around a hub */
45
- USE_ANGULAR_FORCE: boolean;
46
- ANGULAR_STRENGTH: number;
47
- ANGULAR_MAX_FORCE: number;
51
+ /** Options passed to `cy.layout()` for the LUMINA layout. */
52
+ export interface LuminaLayoutOptions extends LuminaOptions, cytoscape.BaseLayoutOptions {
53
+ name: 'lumina' | 'substructure-layout';
48
54
  }
49
- /** Only for the 'stress' algorithm */
50
- export interface StressParams {
51
- /** weight w_ij = 1 / d_ij ^ WEIGHT_EXPONENT (2 = classic stress majorization) */
52
- WEIGHT_EXPONENT: number;
55
+ /** Fully resolved options used internally by the layout. */
56
+ export interface LuminaParameters extends Required<Omit<LuminaOptions, 'collisionPadding' | 'force' | 'stress'>> {
57
+ collisionPadding: number;
58
+ force: Required<LuminaForceOptions>;
59
+ stress: Required<LuminaStressOptions>;
53
60
  }
54
- export interface LayoutParameters extends CommonParams {
55
- force: ForceParams;
56
- stress: StressParams;
57
- }
58
- export declare const DEFAULT_PARAMS: LayoutParameters;
59
- /**
60
- * Build the final parameters from Cytoscape layout options.
61
- *
62
- * cy.layout({
63
- * name: 'lumina',
64
- * layoutAlgorithm: 'force',
65
- * idealLength: 100, // shared
66
- * iterations: 600, // shared
67
- * force: { repulsion: 300, springK: 0.15, useAngularForce: true },
68
- * stress: { weightExponent: 2 }
69
- * })
70
- *
71
- * Backward compatibility: the old flat force options (repulsion, springK,
72
- * angularStrength, useAngularForce) and the legacy `params` object (flat,
73
- * upper-case) are still accepted. Nested `force` / `stress` take priority.
74
- */
75
- export declare function resolveParams(options: any): LayoutParameters;
61
+ export declare const DEFAULT_PARAMS: LuminaParameters;
62
+ /** Resolve optional public settings into the complete internal configuration. */
63
+ export declare function resolveParams(options?: LuminaOptions): LuminaParameters;
76
64
  export interface LayoutStep {
77
65
  stepNumber: number;
78
66
  stepName: string;
@@ -152,4 +140,8 @@ export { LuminaLayout, LuminaLayout as SubstructureLayout };
152
140
  /**
153
141
  * Enhanced force-directed layout with structure awareness
154
142
  */
155
- declare function LuminaLayout(this: any, options: any): void;
143
+ declare function LuminaLayout(this: any, options: LuminaLayoutOptions & {
144
+ cy?: cytoscape.Core;
145
+ eles?: cytoscape.CollectionReturnValue;
146
+ boundingBox?: cytoscape.BoundingBox12 | cytoscape.BoundingBoxWH;
147
+ }): void;
@@ -1,143 +1,61 @@
1
+ import FastPriorityQueue from 'fastpriorityqueue';
1
2
  export const DEFAULT_PARAMS = {
2
- LAYOUT_ALGORITHM: 'stress',
3
+ layoutAlgorithm: 'stress',
3
4
  // shared solver
4
- IDEAL_LENGTH: 100,
5
- ITERATIONS: 600,
6
- CONVERGENCE_THRESHOLD: 0.5,
7
- COLLISION_PADDING: null,
8
- MAX_OVERLAP_ITERATIONS: 300,
9
- // structure detection
10
- MIN_STAR_LEAVES: 3,
11
- MIN_CYCLE_LENGTH: 3,
12
- MAX_CYCLE_LENGTH: 30,
13
- MIN_CHAIN_LENGTH: 2,
14
- MIN_PARALLEL_NEIGHBORS: 2,
5
+ idealLength: 100,
6
+ iterations: 600,
7
+ convergenceThreshold: 0.5,
8
+ collisionPadding: 100,
9
+ maxOverlapIterations: 300,
10
+ // structure deteciton
11
+ minStarLeaves: 3,
12
+ minCycleLength: 3,
13
+ minChainLength: 2,
14
+ minParallelNeighbors: 2,
15
15
  // substructure spacing
16
- CYCLE_NODE_SPACING: 100,
17
- STAR_RING_SPACING: 100,
18
- STAR_BASE_NODES_PER_RING: 6,
19
- CHAIN_MIN_RADIUS: 150,
20
- LEAF_NODE_DISTANCE: 200,
21
- // flags
22
- RANDOMIZE_INITIAL_POSITIONS: false,
23
- SPREAD_V_NODES: true,
24
- SUBSTRUCTURE_LAYOUT: false,
25
- STEP_BY_STEP: false,
16
+ cycleNodeSpacing: 100,
17
+ starRingSpacing: 100,
18
+ starBaseNodesPerRing: 6,
19
+ chainMinRadius: 150,
20
+ leafNodeDistance: 200,
21
+ randomizeInitialPositions: false,
22
+ spreadVNodes: true,
23
+ substructureLayout: false,
24
+ stepByStep: false,
26
25
  force: {
27
- REPULSION: 300,
28
- SPRING_K: 0.15,
29
- OVERLAP_REPULSION_FACTOR: 5,
30
- COOLING_EXPONENT: 2,
31
- USE_ANGULAR_FORCE: false,
32
- ANGULAR_STRENGTH: 0.2,
33
- ANGULAR_MAX_FORCE: 2.0
26
+ repulsion: 300,
27
+ springK: 0.15,
28
+ overlapRepulsionFactor: 5,
29
+ coolingExponent: 2,
30
+ useAngularForce: false,
31
+ angularStrength: 0.2,
32
+ angularMaxForce: 2.0
34
33
  },
35
34
  stress: {
36
- WEIGHT_EXPONENT: 2
35
+ weightExponent: 2
37
36
  }
38
37
  };
39
- // ----------------------------------------------------------------------------
40
- // Public (Cytoscape camelCase) option names -> internal names
41
- // ----------------------------------------------------------------------------
42
- const COMMON_KEYS = {
43
- layoutAlgorithm: 'LAYOUT_ALGORITHM',
44
- idealLength: 'IDEAL_LENGTH',
45
- iterations: 'ITERATIONS',
46
- convergenceThreshold: 'CONVERGENCE_THRESHOLD',
47
- collisionPadding: 'COLLISION_PADDING',
48
- maxOverlapIterations: 'MAX_OVERLAP_ITERATIONS',
49
- minStarLeaves: 'MIN_STAR_LEAVES',
50
- minCycleLength: 'MIN_CYCLE_LENGTH',
51
- maxCycleLength: 'MAX_CYCLE_LENGTH',
52
- minChainLength: 'MIN_CHAIN_LENGTH',
53
- minParallelNeighbors: 'MIN_PARALLEL_NEIGHBORS',
54
- cycleNodeSpacing: 'CYCLE_NODE_SPACING',
55
- starRingSpacing: 'STAR_RING_SPACING',
56
- starBaseNodesPerRing: 'STAR_BASE_NODES_PER_RING',
57
- chainMinRadius: 'CHAIN_MIN_RADIUS',
58
- leafNodeDistance: 'LEAF_NODE_DISTANCE',
59
- randomizeInitialPositions: 'RANDOMIZE_INITIAL_POSITIONS',
60
- spreadVNodes: 'SPREAD_V_NODES',
61
- substructureLayout: 'SUBSTRUCTURE_LAYOUT',
62
- stepByStep: 'STEP_BY_STEP'
63
- };
64
- const FORCE_KEYS = {
65
- repulsion: 'REPULSION',
66
- springK: 'SPRING_K',
67
- overlapRepulsionFactor: 'OVERLAP_REPULSION_FACTOR',
68
- coolingExponent: 'COOLING_EXPONENT',
69
- useAngularForce: 'USE_ANGULAR_FORCE',
70
- angularStrength: 'ANGULAR_STRENGTH',
71
- angularMaxForce: 'ANGULAR_MAX_FORCE'
72
- };
73
- const STRESS_KEYS = {
74
- weightExponent: 'WEIGHT_EXPONENT'
75
- };
76
- function pick(src, map) {
77
- const out = {};
78
- if (!src)
79
- return out;
80
- for (const pub of Object.keys(map)) {
81
- if (src[pub] !== undefined)
82
- out[map[pub]] = src[pub];
38
+ function applyDefaults(defaults, overrides) {
39
+ const resolved = { ...defaults };
40
+ for (const key of Object.keys(defaults)) {
41
+ if (overrides?.[key] !== undefined) {
42
+ resolved[key] = overrides[key];
43
+ }
83
44
  }
84
- return out;
45
+ return resolved;
85
46
  }
86
- /**
87
- * Build the final parameters from Cytoscape layout options.
88
- *
89
- * cy.layout({
90
- * name: 'lumina',
91
- * layoutAlgorithm: 'force',
92
- * idealLength: 100, // shared
93
- * iterations: 600, // shared
94
- * force: { repulsion: 300, springK: 0.15, useAngularForce: true },
95
- * stress: { weightExponent: 2 }
96
- * })
97
- *
98
- * Backward compatibility: the old flat force options (repulsion, springK,
99
- * angularStrength, useAngularForce) and the legacy `params` object (flat,
100
- * upper-case) are still accepted. Nested `force` / `stress` take priority.
101
- */
102
- export function resolveParams(options) {
103
- options = options || {};
104
- const legacy = options.params || {};
105
- // flat legacy force options at top level
106
- const legacyForce = pick(options, FORCE_KEYS);
107
- // legacy upper-case flat `params`: route each key to the right group
108
- const legacyCommon = {};
109
- const legacyForceUpper = {};
110
- const legacyStressUpper = {};
111
- Object.keys(legacy).forEach((k) => {
112
- if (k in DEFAULT_PARAMS.force)
113
- legacyForceUpper[k] = legacy[k];
114
- else if (k in DEFAULT_PARAMS.stress)
115
- legacyStressUpper[k] = legacy[k];
116
- else if (k !== 'force' && k !== 'stress')
117
- legacyCommon[k] = legacy[k];
118
- });
119
- const params = {
120
- ...DEFAULT_PARAMS,
121
- ...legacyCommon,
122
- ...pick(options, COMMON_KEYS),
123
- force: {
124
- ...DEFAULT_PARAMS.force,
125
- ...legacyForceUpper,
126
- ...(legacy.force || {}),
127
- ...legacyForce,
128
- ...pick(options.force, FORCE_KEYS)
129
- },
130
- stress: {
131
- ...DEFAULT_PARAMS.stress,
132
- ...legacyStressUpper,
133
- ...(legacy.stress || {}),
134
- ...pick(options.stress, STRESS_KEYS)
135
- }
47
+ /** Resolve optional public settings into the complete internal configuration. */
48
+ export function resolveParams(options = {}) {
49
+ const { collisionPadding: _defaultCollisionPadding, force: defaultForce, stress: defaultStress, ...commonDefaults } = DEFAULT_PARAMS;
50
+ const common = applyDefaults(commonDefaults, options);
51
+ const idealLength = options.idealLength ?? common.idealLength;
52
+ return {
53
+ ...common,
54
+ idealLength,
55
+ collisionPadding: options.collisionPadding ?? idealLength,
56
+ force: applyDefaults(defaultForce, options.force),
57
+ stress: applyDefaults(defaultStress, options.stress)
136
58
  };
137
- if (params.COLLISION_PADDING == null) {
138
- params.COLLISION_PADDING = params.IDEAL_LENGTH;
139
- }
140
- return params;
141
59
  }
142
60
  class VNode {
143
61
  constructor(id, x, y, radius) {
@@ -193,6 +111,52 @@ class VEdge {
193
111
  };
194
112
  }
195
113
  }
114
+ /** Dense stress distances from repeated weighted Dijkstra. */
115
+ function computeStressDistances(nodes, edges, idealLength) {
116
+ const numNodes = nodes.length;
117
+ const nodeIndexMap = new Map();
118
+ nodes.forEach((node, index) => nodeIndexMap.set(node.id, index));
119
+ const adjacency = Array.from({ length: numNodes }, () => []);
120
+ edges.forEach(edge => {
121
+ if (!edge.source || !edge.target) {
122
+ return;
123
+ }
124
+ const source = nodeIndexMap.get(edge.source.id);
125
+ const target = nodeIndexMap.get(edge.target.id);
126
+ if (source === undefined || target === undefined) {
127
+ return;
128
+ }
129
+ const distance = idealLength + edge.source.radius + edge.target.radius;
130
+ if (distance < 0 || !Number.isFinite(distance)) {
131
+ throw new RangeError('cytoscape-lumina: stress edge lengths must be finite and non-negative');
132
+ }
133
+ adjacency[source].push({ node: target, distance });
134
+ adjacency[target].push({ node: source, distance });
135
+ });
136
+ const distances = Array.from({ length: numNodes }, (_, index) => {
137
+ const row = new Array(numNodes).fill(Infinity);
138
+ row[index] = 0;
139
+ return row;
140
+ });
141
+ for (let source = 0; source < numNodes; source++) {
142
+ const row = distances[source];
143
+ const heap = new FastPriorityQueue((a, b) => a.distance < b.distance || (a.distance === b.distance && a.node < b.node));
144
+ heap.add({ node: source, distance: 0 });
145
+ let current;
146
+ while ((current = heap.poll()) !== undefined) {
147
+ if (current.distance !== row[current.node])
148
+ continue;
149
+ for (const neighbor of adjacency[current.node]) {
150
+ const candidate = current.distance + neighbor.distance;
151
+ if (candidate < row[neighbor.node]) {
152
+ row[neighbor.node] = candidate;
153
+ heap.add({ node: neighbor.node, distance: candidate });
154
+ }
155
+ }
156
+ }
157
+ }
158
+ return distances;
159
+ }
196
160
  /**
197
161
  * Register this layout as a Cytoscape.js layout extension.
198
162
  *
@@ -241,15 +205,12 @@ export { LuminaLayout, LuminaLayout as SubstructureLayout };
241
205
  function LuminaLayout(options) {
242
206
  this.options = options || {};
243
207
  this.cy = this.options.cy;
244
- this.eles = this.options.eles || this.cy.elements();
245
- this.boundingBox = this.options.boundingBox;
246
- this.stopped = false;
247
208
  if (!this.cy) {
248
209
  throw new Error('cytoscape-lumina: options.cy is required');
249
210
  }
250
- // Public Cytoscape.js options use camelCase; shared options are flat,
251
- // algorithm-specific ones live under `force: {...}` / `stress: {...}`.
252
- // See the parameter section at the top of this file. The legacy `params` object is still accepted.
211
+ this.eles = this.options.eles || this.cy.elements();
212
+ this.boundingBox = this.options.boundingBox;
213
+ this.stopped = false;
253
214
  this.params = resolveParams(this.options);
254
215
  // Instance-specific arrays instead of global
255
216
  this.vnodes = [];
@@ -262,7 +223,7 @@ function LuminaLayout(options) {
262
223
  * Capture a snapshot of the current layout state
263
224
  */
264
225
  LuminaLayout.prototype.captureStep = function (stepName, description, metadata) {
265
- if (!this.params.STEP_BY_STEP)
226
+ if (!this.params.stepByStep)
266
227
  return;
267
228
  const nodes = this.cy.nodes();
268
229
  const nodePositions = {};
@@ -289,18 +250,8 @@ LuminaLayout.prototype.captureStep = function (stepName, description, metadata)
289
250
  };
290
251
  this.steps.push(step);
291
252
  this.currentStepIndex = this.steps.length - 1;
292
- console.log(`[Step ${step.stepNumber}] ${stepName}: ${description}`);
253
+ console.debug(`[Step ${step.stepNumber}] ${stepName}: ${description}`);
293
254
  };
294
- /////////////// Check whether two node arrays are identical, ignoring order
295
- function areNodesEqual(arr1, arr2) {
296
- // 1. Different lengths mean they are definitely different
297
- if (arr1.length !== arr2.length)
298
- return false;
299
- // 2. Extract all IDs and put them into a Set
300
- const ids1 = new Set(arr1.map((node) => node.id()));
301
- // 3. Check whether every ID in arr2 exists in the Set
302
- return arr2.every(node => ids1.has(node.id()));
303
- }
304
255
  ////////////////sum length of all edges ////////////////
305
256
  function totalEdgeLength(edges) {
306
257
  let total = 0;
@@ -379,6 +330,17 @@ function layoutRectangular(nodes, center, dirVector, rowSpacing = 60, colSpacing
379
330
  }
380
331
  LuminaLayout.prototype.identifyStructures = function (nodes) {
381
332
  const params = this.params;
333
+ // Topology stays fixed during detection; structure markers are filtered dynamically.
334
+ const neighborhoodCache = new Map();
335
+ const neighborsOf = (node) => {
336
+ const id = node.id();
337
+ let neighbors = neighborhoodCache.get(id);
338
+ if (neighbors === undefined) {
339
+ neighbors = node.neighborhood().nodes();
340
+ neighborhoodCache.set(id, neighbors);
341
+ }
342
+ return neighbors;
343
+ };
382
344
  // 0. Reset all markers
383
345
  nodes.data('structType', 'Normal');
384
346
  // nodes.data('structColor', '#999999');
@@ -390,7 +352,7 @@ LuminaLayout.prototype.identifyStructures = function (nodes) {
390
352
  ///////////////////////////// 2. detecting Cycle - by DFS algorithm /////////////////
391
353
  const allCycles = []; // each sub-array represents an independent cycle
392
354
  if (1) {
393
- const normalNodes = nodes.toArray().filter((n) => n.data('structType') === 'Normal' && n.neighborhood().nodes().length >= 2);
355
+ const normalNodes = nodes.toArray().filter((n) => n.data('structType') === 'Normal' && neighborsOf(n).length >= 2);
394
356
  const seenCycles = new Set();
395
357
  // To avoid duplicate cycles (like A-B-C and B-C-A), we sort and stringify for a check
396
358
  normalNodes.forEach((startNode, startIndex) => {
@@ -398,12 +360,12 @@ LuminaLayout.prototype.identifyStructures = function (nodes) {
398
360
  // This is a massive optimization to prevent finding the same cycle N times
399
361
  const startId = startNode.id();
400
362
  const findCycles = (u, parent, path) => {
401
- const neighbors = u.neighborhood().nodes().toArray().filter((n) => n.data('structType') === 'Normal');
363
+ const neighbors = neighborsOf(u).toArray().filter((n) => n.data('structType') === 'Normal');
402
364
  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
403
365
  for (const v of neighbors) {
404
366
  const vId = v.id();
405
367
  // 1. Found a cycle back to our specific START node
406
- if (vId === startId && path.length >= params.MIN_CYCLE_LENGTH) {
368
+ if (vId === startId && path.length >= params.minCycleLength) {
407
369
  const cycle = [...path];
408
370
  const sortedKey = [...cycle].sort().join(',');
409
371
  if (!seenCycles.has(sortedKey)) {
@@ -479,11 +441,10 @@ LuminaLayout.prototype.identifyStructures = function (nodes) {
479
441
  });
480
442
  circleIndex++;
481
443
  });
482
- console.log("num of cycles: ", filteredCycles.length);
483
444
  const chains = [];
484
445
  const processedNodeIds = new Set(); // avoid processing nodes more than once
485
446
  // 2. Find all leaf nodes (Normal type with degree 1)
486
- const leafNodes = nodes.filter((n) => n.data('structType') === 'Normal' && n.neighborhood().nodes().length === 1);
447
+ const leafNodes = nodes.filter((n) => n.data('structType') === 'Normal' && neighborsOf(n).length === 1);
487
448
  let chainId = 0;
488
449
  leafNodes.forEach((leaf) => {
489
450
  if (processedNodeIds.has(leaf.id()))
@@ -496,8 +457,7 @@ LuminaLayout.prototype.identifyStructures = function (nodes) {
496
457
  currentChainNodes.push(currentNode);
497
458
  processedNodeIds.add(currentNode.id());
498
459
  // Find the next neighbor
499
- const neighbors = currentNode
500
- .neighborhood('node')
460
+ const neighbors = neighborsOf(currentNode)
501
461
  .filter((n) => n.id() !== currentNode.id() &&
502
462
  n.data('structType') === 'Normal' &&
503
463
  !processedNodeIds.has(n.id()));
@@ -506,8 +466,7 @@ LuminaLayout.prototype.identifyStructures = function (nodes) {
506
466
  // 2. The neighbor must not have a high degree (if degree > 2, a branching point has been reached and the chain ends)
507
467
  if (neighbors.length === 1) {
508
468
  const nextNode = neighbors[0];
509
- const nextNodeNeighborCount = nextNode
510
- .neighborhood('node')
469
+ const nextNodeNeighborCount = neighborsOf(nextNode)
511
470
  .filter((n) => n.id() !== nextNode.id())
512
471
  .length;
513
472
  if (nextNodeNeighborCount > 2) {
@@ -521,7 +480,7 @@ LuminaLayout.prototype.identifyStructures = function (nodes) {
521
480
  }
522
481
  }
523
482
  // 4. Save the chain that was found
524
- if (currentChainNodes.length >= params.MIN_CHAIN_LENGTH) {
483
+ if (currentChainNodes.length >= params.minChainLength) {
525
484
  nodeId = 0;
526
485
  currentChainNodes.forEach((node) => {
527
486
  node.addClass('substructure-chain');
@@ -561,12 +520,12 @@ LuminaLayout.prototype.identifyStructures = function (nodes) {
561
520
  ///////////////////////////////// Star structure ///////////
562
521
  let starIndex = 0;
563
522
  nodes.forEach((node) => {
564
- const neighbors = node.neighborhood().nodes();
523
+ const neighbors = neighborsOf(node);
565
524
  // Number of distinct neighbors
566
525
  const neighborCount = neighbors.length;
567
526
  // Check whether the neighbors consist only of distinct neighbor nodes
568
- const leafNeighbors = neighbors.filter((n) => n.neighborhood().nodes().length === 1);
569
- if (leafNeighbors.length >= params.MIN_STAR_LEAVES && neighborCount >= params.MIN_STAR_LEAVES) {
527
+ const leafNeighbors = neighbors.filter((n) => neighborsOf(n).length === 1);
528
+ if (leafNeighbors.length >= params.minStarLeaves && neighborCount >= params.minStarLeaves) {
570
529
  if (node.data('structType') !== 'Cycle' && node.data('structType') !== 'Chain') {
571
530
  const groupId = 'Star_' + starIndex;
572
531
  node.addClass(['substructure-star', 'substructure-star-center']);
@@ -594,56 +553,57 @@ LuminaLayout.prototype.identifyStructures = function (nodes) {
594
553
  }
595
554
  });
596
555
  /////////////////////////////// Find parallel/diamond structures ///////////////////////////////////
597
- // let diamonds = [];
598
- let parallelId = 0;
599
- // Whether any two nodes have common neighbors
600
- for (let i = 0; i < nodes.length; i++) {
601
- let nodeVecParallel = [];
602
- const u = nodes[i];
603
- const u1 = u.neighborhood().nodes();
604
- if (u.data('structType') === 'Normal') {
605
- for (let j = 0; j < nodes.length; j++) {
606
- if (i === j)
607
- continue;
608
- const v = nodes[j];
609
- if (v.data('structType') === 'Normal') {
610
- const v1 = v.neighborhood().nodes();
611
- if (areNodesEqual(v1, u1) && v1.length >= params.MIN_PARALLEL_NEIGHBORS && u1.length >= params.MIN_PARALLEL_NEIGHBORS) {
612
- if (nodeVecParallel.length === 0) {
613
- nodeVecParallel.push(u.id());
614
- }
615
- nodeVecParallel.push(v.id());
616
- }
617
- }
618
- }
556
+ const nodesById = new Map();
557
+ const parallelGroups = new Map();
558
+ nodes.forEach((node) => {
559
+ nodesById.set(node.id(), node);
560
+ if (node.data("structType") !== "Normal") {
561
+ return;
619
562
  }
620
- if (nodeVecParallel.length >= 2) {
621
- nodeVecParallel.forEach(v => {
622
- nodes.forEach((node) => {
623
- if (node.id() == v) {
624
- node.addClass('substructure-parallel');
625
- node.data('structType', 'Parallel');
626
- // node.data('structColor', '#50C878');
627
- node.data('groupId', 'Parallel' + parallelId);
628
- node.data('structs', {
629
- ...node.data('structs'), Parallel: {
630
- color: '#50C878', groupId: `Parallel_${parallelId}`
631
- }
632
- });
633
- }
634
- });
635
- });
636
- u1.forEach((v1) => {
637
- nodes.forEach((node) => {
638
- if (node.id() == v1.id()) {
639
- node.data('parallelGroupIdVec', [...node.data('parallelGroupIdVec'), 'Parallel' + parallelId]);
640
- }
641
- });
642
- });
643
- parallelId++;
563
+ const neighbors = neighborsOf(node);
564
+ if (!(neighbors.length >= params.minParallelNeighbors)) {
565
+ return;
644
566
  }
567
+ // Encode a sorted copy so IDs cannot collide and cached neighbor order stays intact.
568
+ const signature = JSON.stringify(neighbors.map((neighbor) => neighbor.id()).sort());
569
+ const group = parallelGroups.get(signature);
570
+ if (group) {
571
+ group.push(node);
572
+ }
573
+ else {
574
+ parallelGroups.set(signature, [node]);
575
+ }
576
+ });
577
+ // Map insertion order preserves group numbering by the first eligible member.
578
+ let parallelId = 0;
579
+ for (const group of parallelGroups.values()) {
580
+ if (group.length < 2)
581
+ continue;
582
+ const groupId = "Parallel" + parallelId;
583
+ group.forEach((node) => {
584
+ node.addClass("substructure-parallel");
585
+ node.data("structType", "Parallel");
586
+ node.data("groupId", groupId);
587
+ node.data("structs", {
588
+ ...node.data("structs"),
589
+ Parallel: {
590
+ color: "#50C878",
591
+ groupId: `Parallel_${parallelId}`,
592
+ },
593
+ });
594
+ });
595
+ neighborsOf(group[0]).forEach((neighbor) => {
596
+ // Neighborhoods may include nodes outside the supplied collection.
597
+ const node = nodesById.get(neighbor.id());
598
+ if (node) {
599
+ node.data("parallelGroupIdVec", [
600
+ ...node.data("parallelGroupIdVec"),
601
+ groupId,
602
+ ]);
603
+ }
604
+ });
605
+ parallelId++;
645
606
  }
646
- console.log("num of chains: ", chains.length);
647
607
  };
648
608
  LuminaLayout.prototype.run = function () {
649
609
  this.stopped = false;
@@ -669,7 +629,6 @@ LuminaLayout.prototype.run = function () {
669
629
  components.forEach((component, index) => {
670
630
  this.vnodes = [];
671
631
  this.vedges = [];
672
- console.log(`independent net: ${index + 1}`);
673
632
  // 1. get user defined boundingBox,if not then default cy container
674
633
  const bb = this.boundingBox || this.cy.extent();
675
634
  const width = bb.x2 - bb.x1;
@@ -691,15 +650,13 @@ LuminaLayout.prototype.run = function () {
691
650
  return true;
692
651
  });
693
652
  // 2. NOW define your collections to get the updated state
694
- if (params.RANDOMIZE_INITIAL_POSITIONS) {
653
+ if (params.randomizeInitialPositions) {
695
654
  nodes.forEach((node) => {
696
655
  node.position({
697
656
  x: bb.x1 + Math.random() * width, y: bb.y1 + Math.random() * height
698
657
  });
699
658
  });
700
659
  }
701
- console.log("num of nodes: " + nodes.length);
702
- console.log("num of edges: " + edgeSet.length);
703
660
  // Capture initial state
704
661
  this.captureStep('Initial', 'Initial node positions before any layout', {
705
662
  nodeCount: nodes.length, edgeCount: edgeSet.length
@@ -905,8 +862,8 @@ LuminaLayout.prototype.run = function () {
905
862
  this.vnodes.forEach((v1) => {
906
863
  if (v1.type == 'Star') {
907
864
  const allMemberNode = v1.nodes.filter((node) => node.data('structType') !== 'Star-Center');
908
- const ringSpacing = params.STAR_RING_SPACING;
909
- const baseNodesInFirstRing = params.STAR_BASE_NODES_PER_RING;
865
+ const ringSpacing = params.starRingSpacing;
866
+ const baseNodesInFirstRing = params.starBaseNodesPerRing;
910
867
  // 1. Pre-calculate how many nodes go into each ring
911
868
  const rings = [];
912
869
  let tempNodes = [...allMemberNode];
@@ -921,7 +878,7 @@ LuminaLayout.prototype.run = function () {
921
878
  }
922
879
  else if (v1.type == 'Cycle') {
923
880
  const count = v1.nodes.length;
924
- const k = params.CYCLE_NODE_SPACING;
881
+ const k = params.cycleNodeSpacing;
925
882
  v1.radius = (count * k) / (2 * Math.PI);
926
883
  }
927
884
  else if (v1.type == 'Parallel') {
@@ -949,8 +906,8 @@ LuminaLayout.prototype.run = function () {
949
906
  }
950
907
  else if (v1.type == 'Chain') {
951
908
  const count = v1.nodes.length;
952
- const miniMumRadius = params.CHAIN_MIN_RADIUS;
953
- v1.radius = Math.max((count * params.CYCLE_NODE_SPACING) / (2 * Math.PI), miniMumRadius);
909
+ const miniMumRadius = params.chainMinRadius;
910
+ v1.radius = Math.max((count * params.cycleNodeSpacing) / (2 * Math.PI), miniMumRadius);
954
911
  }
955
912
  else {
956
913
  v1.radius = 10;
@@ -959,21 +916,20 @@ LuminaLayout.prototype.run = function () {
959
916
  }
960
917
  this.captureStep('Virtual Nodes Positioned', 'Virtual node centers and radii calculated', null);
961
918
  //******************** virtual node force layout ************************
962
- // Uses: shared params (IDEAL_LENGTH, ITERATIONS, CONVERGENCE_THRESHOLD,
963
- // COLLISION_PADDING) + force-only params (params.force.*)
964
- if (params.LAYOUT_ALGORITHM === 'force') {
919
+ // Shared settings are on params; solver-specific settings are nested.
920
+ if (params.layoutAlgorithm === 'force') {
965
921
  const fp = params.force;
966
- const IDEAL_LENGTH = params.IDEAL_LENGTH;
967
- const REPULSION = fp.REPULSION;
968
- const SPRING_K = fp.SPRING_K;
969
- const ITERATIONS = params.ITERATIONS;
922
+ const idealLength = params.idealLength;
923
+ const repulsion = fp.repulsion;
924
+ const springK = fp.springK;
925
+ const iterations = params.iterations;
970
926
  var colisionFlag = true;
971
927
  let iter = 0;
972
928
  var maxAttractMove = 10e10;
973
929
  var maxRepulsetMove = 10e10;
974
930
  var numOfCollision = 0;
975
931
  // Only consider the whole graph truly stationary when the maximum movement of every node is below this threshold
976
- const ENERGY_THRESHOLD = params.CONVERGENCE_THRESHOLD;
932
+ const convergenceThreshold = params.convergenceThreshold;
977
933
  // Build the adjacency list in advance so it can be reused seamlessly for both "initial ordering" and the "later angular force"
978
934
  const adj = new Map();
979
935
  this.vedges.forEach((e) => {
@@ -1000,13 +956,13 @@ LuminaLayout.prototype.run = function () {
1000
956
  // Also give them an initial star-shaped radial geometric distribution that is guaranteed not to cross
1001
957
  leafNeighbors.forEach((nb, index) => {
1002
958
  const initAngle = (Math.PI * 2 / leafNeighbors.length) * index;
1003
- nb.center_x = node.center_x + Math.cos(initAngle) * IDEAL_LENGTH;
1004
- nb.center_y = node.center_y + Math.sin(initAngle) * IDEAL_LENGTH;
959
+ nb.center_x = node.center_x + Math.cos(initAngle) * idealLength;
960
+ nb.center_y = node.center_y + Math.sin(initAngle) * idealLength;
1005
961
  });
1006
962
  }
1007
963
  }
1008
964
  });
1009
- while (iter < ITERATIONS) {
965
+ while (iter < iterations) {
1010
966
  if (this.stopped) {
1011
967
  break;
1012
968
  }
@@ -1039,7 +995,7 @@ LuminaLayout.prototype.run = function () {
1039
995
  }
1040
996
  }
1041
997
  // Calculate the current global cooling factor (the core simulated-annealing control)
1042
- const cooling = Math.pow(1 - iter / ITERATIONS, fp.COOLING_EXPONENT);
998
+ const cooling = Math.pow(1 - iter / iterations, fp.coolingExponent);
1043
999
  /* ---------- A. Attraction (Spring) ---------- */
1044
1000
  if (1) {
1045
1001
  this.vedges.forEach((e) => {
@@ -1051,9 +1007,9 @@ LuminaLayout.prototype.run = function () {
1051
1007
  const dy = t.center_y - s.center_y;
1052
1008
  const centerDist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
1053
1009
  const surfaceDist = centerDist - (s.radius + t.radius);
1054
- const delta = Math.max(0, surfaceDist - IDEAL_LENGTH);
1010
+ const delta = Math.max(0, surfaceDist - idealLength);
1055
1011
  // Apply the cooling factor to the attractive force
1056
- let force = SPRING_K * delta * cooling;
1012
+ let force = springK * delta * cooling;
1057
1013
  const fx = (force * dx) / centerDist;
1058
1014
  const fy = (force * dy) / centerDist;
1059
1015
  s.center_x += fx;
@@ -1065,7 +1021,7 @@ LuminaLayout.prototype.run = function () {
1065
1021
  }
1066
1022
  /* ---------- B. Repulsion ---------- */
1067
1023
  if (1) {
1068
- const MIN_GAP = 0.1;
1024
+ const minGap = 0.1;
1069
1025
  for (let i = 0; i < this.vnodes.length; i++) {
1070
1026
  for (let j = i + 1; j < this.vnodes.length; j++) {
1071
1027
  const n1 = this.vnodes[i];
@@ -1081,15 +1037,15 @@ LuminaLayout.prototype.run = function () {
1081
1037
  let force = 0;
1082
1038
  if (centerDist < minDistance) {
1083
1039
  const overlap = minDistance - centerDist;
1084
- force = (REPULSION * fp.OVERLAP_REPULSION_FACTOR) * (overlap / (centerDist + MIN_GAP));
1040
+ force = (repulsion * fp.overlapRepulsionFactor) * (overlap / (centerDist + minGap));
1085
1041
  }
1086
1042
  else {
1087
1043
  const gap = centerDist - minDistance;
1088
- force = REPULSION / (gap * gap + 20);
1044
+ force = repulsion / (gap * gap + 20);
1089
1045
  }
1090
1046
  // Apply the cooling factor to the repulsive force
1091
1047
  force *= cooling;
1092
- const maxForceLimit = REPULSION * 2 * cooling;
1048
+ const maxForceLimit = repulsion * 2 * cooling;
1093
1049
  if (force > maxForceLimit)
1094
1050
  force = maxForceLimit;
1095
1051
  const fx = (force * dx) / centerDist;
@@ -1103,10 +1059,10 @@ LuminaLayout.prototype.run = function () {
1103
1059
  }
1104
1060
  }
1105
1061
  /* ---------- C. Angular Repulsion ---------- */
1106
- if (fp.USE_ANGULAR_FORCE) {
1062
+ if (fp.useAngularForce) {
1107
1063
  const norm = (a) => Math.atan2(Math.sin(a), Math.cos(a));
1108
- const ANGULAR_STRENGTH = fp.ANGULAR_STRENGTH;
1109
- const MAX_FORCE = fp.ANGULAR_MAX_FORCE;
1064
+ const angularStrength = fp.angularStrength;
1065
+ const maxForce = fp.angularMaxForce;
1110
1066
  this.vnodes.forEach((node) => {
1111
1067
  if (!node._permanentOrder || node._permanentOrder.length < 2) {
1112
1068
  return;
@@ -1139,8 +1095,8 @@ LuminaLayout.prototype.run = function () {
1139
1095
  continue;
1140
1096
  }
1141
1097
  const gapError = idealGap - gap;
1142
- let force = gapError * ANGULAR_STRENGTH;
1143
- force = Math.min(force, MAX_FORCE);
1098
+ let force = gapError * angularStrength;
1099
+ force = Math.min(force, maxForce);
1144
1100
  // Tangential direction of the left item
1145
1101
  const ltx = -left.dy / left.dist;
1146
1102
  const lty = left.dx / left.dist;
@@ -1160,7 +1116,7 @@ LuminaLayout.prototype.run = function () {
1160
1116
  }
1161
1117
  // [Exit criterion] Only allow an early safe exit when all movement energy in the graph has completely settled and become extremely small
1162
1118
  const totalMaxMovement = Math.max(maxAttractMove, maxRepulsetMove, maxAngularMove);
1163
- if (totalMaxMovement < ENERGY_THRESHOLD && iter > 10) {
1119
+ if (totalMaxMovement < convergenceThreshold && iter > 10) {
1164
1120
  break;
1165
1121
  }
1166
1122
  }
@@ -1174,8 +1130,9 @@ LuminaLayout.prototype.run = function () {
1174
1130
  if (1) {
1175
1131
  colisionFlag = true;
1176
1132
  numOfCollision = 0;
1177
- const padding = params.COLLISION_PADDING;
1133
+ const padding = params.collisionPadding;
1178
1134
  let iter = 0;
1135
+ let overlapLimitReached = false;
1179
1136
  while (colisionFlag) {
1180
1137
  if (this.stopped) {
1181
1138
  break;
@@ -1218,12 +1175,17 @@ LuminaLayout.prototype.run = function () {
1218
1175
  }
1219
1176
  // [Optimization 3] Safety valve: exit directly if the iteration count becomes too large (typically when nodes are extremely dense)
1220
1177
  if (numOfCollision > 10000) {
1221
- console.warn("avoid dead loop");
1178
+ overlapLimitReached = true;
1222
1179
  break;
1223
1180
  }
1224
- // this.captureStep('Anti-collision', 'Eliminate all collisions ' + iter, { iterations: ITERATIONS });
1225
1181
  }
1226
- this.captureStep('Anti-collision', 'Eliminate all collisions', { iterations: ITERATIONS });
1182
+ this.captureStep("Anti-collision", overlapLimitReached
1183
+ ? "Overlap removal stopped at safety limit"
1184
+ : "Eliminate all collisions", {
1185
+ iterations,
1186
+ overlapIterations: iter,
1187
+ overlapLimitReached,
1188
+ });
1227
1189
  if (1) {
1228
1190
  numOfCollision = 0;
1229
1191
  for (let i = 0; i < this.vnodes.length; i++) {
@@ -1233,32 +1195,30 @@ LuminaLayout.prototype.run = function () {
1233
1195
  let dx = n1.center_x - n2.center_x;
1234
1196
  let dy = n1.center_y - n2.center_y;
1235
1197
  const centerDist = Math.sqrt(dx * dx + dy * dy);
1236
- if (centerDist < (IDEAL_LENGTH + n1.radius + n2.radius)) {
1198
+ if (centerDist < (idealLength + n1.radius + n2.radius)) {
1237
1199
  numOfCollision++;
1238
1200
  }
1239
1201
  }
1240
1202
  }
1241
1203
  }
1242
1204
  }
1243
- this.captureStep('Virtual Node Layout', 'Force-directed layout applied to virtual nodes', { iterations: ITERATIONS });
1205
+ this.captureStep('Virtual Node Layout', 'Force-directed layout applied to virtual nodes', { iterations });
1244
1206
  }
1245
1207
  //******************** stress force layout ************************
1246
1208
  // Stress Majorization algorithm
1247
1209
  // All-Pairs shortest-path calculation and Guttman Transform (weighted Laplacian updates),
1248
1210
  // while retaining the polar-coordinate initialization for leaf nodes and the Anti-Collision post-processing in the original code
1249
- // Uses: shared params (IDEAL_LENGTH, ITERATIONS, CONVERGENCE_THRESHOLD,
1250
- // COLLISION_PADDING, MAX_OVERLAP_ITERATIONS) + stress-only params (params.stress.*)
1251
- if (params.LAYOUT_ALGORITHM === 'stress') {
1211
+ // Shared settings are on params; solver-specific settings are nested.
1212
+ if (params.layoutAlgorithm === 'stress') {
1252
1213
  const sp = params.stress;
1253
- const IDEAL_LENGTH = params.IDEAL_LENGTH;
1254
- const ITERATIONS = params.ITERATIONS;
1255
- const ENERGY_THRESHOLD = params.CONVERGENCE_THRESHOLD;
1214
+ const idealLength = params.idealLength;
1215
+ const iterations = params.iterations;
1216
+ const convergenceThreshold = params.convergenceThreshold;
1256
1217
  const numNodes = this.vnodes.length;
1257
1218
  if (numNodes === 0)
1258
1219
  return;
1259
1220
  // Index mapping: ID -> array index, for matrix operations
1260
- const nodeIndexMap = new Map();
1261
- this.vnodes.forEach((v, i) => nodeIndexMap.set(v.id, i));
1221
+ // The distance helper builds the index alongside its weighted adjacency.
1262
1222
  // Extract the adjacency list for reuse during initialization
1263
1223
  const adj = new Map();
1264
1224
  this.vedges.forEach((e) => {
@@ -1272,42 +1232,18 @@ LuminaLayout.prototype.run = function () {
1272
1232
  adj.get(e.target.id).push(e.source);
1273
1233
  });
1274
1234
  // -------------------------------------------------------------
1275
- // [Preprocessing 1] Calculate the shortest-path distance matrix between every pair of nodes in the graph (APSP - Floyd Warshall)
1235
+ // [Preprocessing 1] Calculate all-pairs shortest paths using weighted Dijkstra
1276
1236
  // -------------------------------------------------------------
1277
- const distMatrix = Array.from({ length: numNodes }, () => new Array(numNodes).fill(Infinity));
1237
+ const distMatrix = computeStressDistances(this.vnodes, this.vedges, idealLength);
1278
1238
  const weightMatrix = Array.from({ length: numNodes }, () => new Array(numNodes).fill(0));
1279
- for (let i = 0; i < numNodes; i++)
1280
- distMatrix[i][i] = 0;
1281
- // Assign the ideal distance baseline based on real edges
1282
- this.vedges.forEach((e) => {
1283
- if (!e.source || !e.target)
1284
- return;
1285
- const u = nodeIndexMap.get(e.source.id);
1286
- const v = nodeIndexMap.get(e.target.id);
1287
- if (u !== undefined && v !== undefined) {
1288
- const idealDist = IDEAL_LENGTH + e.source.radius + e.target.radius;
1289
- distMatrix[u][v] = Math.min(distMatrix[u][v], idealDist);
1290
- distMatrix[v][u] = Math.min(distMatrix[v][u], idealDist);
1291
- }
1292
- });
1293
- // Use Floyd-Warshall to compute shortest paths between all node pairs
1294
- for (let k = 0; k < numNodes; k++) {
1295
- for (let i = 0; i < numNodes; i++) {
1296
- for (let j = 0; j < numNodes; j++) {
1297
- if (distMatrix[i][k] + distMatrix[k][j] < distMatrix[i][j]) {
1298
- distMatrix[i][j] = distMatrix[i][k] + distMatrix[k][j];
1299
- }
1300
- }
1301
- }
1302
- }
1303
- // Calculate the weight matrix W_ij = 1 / (d_ij ^ WEIGHT_EXPONENT)
1239
+ // Calculate the weight matrix W_ij = 1 / (d_ij ^ weightExponent)
1304
1240
  for (let i = 0; i < numNodes; i++) {
1305
1241
  for (let j = 0; j < numNodes; j++) {
1306
1242
  if (i !== j && distMatrix[i][j] !== Infinity) {
1307
1243
  // Account for the actual node radius to prevent excessive crowding
1308
1244
  const minR = this.vnodes[i].radius + this.vnodes[j].radius;
1309
1245
  const d = Math.max(distMatrix[i][j], minR);
1310
- weightMatrix[i][j] = 1 / Math.pow(d, sp.WEIGHT_EXPONENT);
1246
+ weightMatrix[i][j] = 1 / Math.pow(d, sp.weightExponent);
1311
1247
  }
1312
1248
  }
1313
1249
  }
@@ -1326,8 +1262,8 @@ LuminaLayout.prototype.run = function () {
1326
1262
  node._permanentOrder = leafNeighbors.map((a) => a.id);
1327
1263
  leafNeighbors.forEach((nb, index) => {
1328
1264
  const initAngle = (Math.PI * 2 / leafNeighbors.length) * index;
1329
- nb.center_x = node.center_x + Math.cos(initAngle) * IDEAL_LENGTH;
1330
- nb.center_y = node.center_y + Math.sin(initAngle) * IDEAL_LENGTH;
1265
+ nb.center_x = node.center_x + Math.cos(initAngle) * idealLength;
1266
+ nb.center_y = node.center_y + Math.sin(initAngle) * idealLength;
1331
1267
  });
1332
1268
  }
1333
1269
  }
@@ -1336,7 +1272,7 @@ LuminaLayout.prototype.run = function () {
1336
1272
  // [Main loop] Stress Majorization iteration (Guttman Transform)
1337
1273
  // -------------------------------------------------------------
1338
1274
  let iter = 0;
1339
- while (iter < ITERATIONS) {
1275
+ while (iter < iterations) {
1340
1276
  if (this.stopped) {
1341
1277
  break;
1342
1278
  }
@@ -1390,7 +1326,7 @@ LuminaLayout.prototype.run = function () {
1390
1326
  vi.center_y = nextY[i];
1391
1327
  }
1392
1328
  // Check the convergence condition and exit early
1393
- if (maxStressMove < ENERGY_THRESHOLD && iter > 10) {
1329
+ if (maxStressMove < convergenceThreshold && iter > 10) {
1394
1330
  break;
1395
1331
  }
1396
1332
  }
@@ -1407,8 +1343,9 @@ LuminaLayout.prototype.run = function () {
1407
1343
  if (1) {
1408
1344
  let colisionFlag = true;
1409
1345
  let numOfCollision = 0;
1410
- const padding = params.COLLISION_PADDING;
1346
+ const padding = params.collisionPadding;
1411
1347
  let overlapIter = 0;
1348
+ let overlapLimitReached = false;
1412
1349
  while (colisionFlag) {
1413
1350
  if (this.stopped) {
1414
1351
  break;
@@ -1441,18 +1378,24 @@ LuminaLayout.prototype.run = function () {
1441
1378
  }
1442
1379
  }
1443
1380
  }
1444
- if (numOfCollision > 10000 || overlapIter > params.MAX_OVERLAP_ITERATIONS) {
1445
- console.warn("Avoid dead loop in overlap removal");
1381
+ if (numOfCollision > 10000 || overlapIter > params.maxOverlapIterations) {
1382
+ overlapLimitReached = true;
1446
1383
  break;
1447
1384
  }
1448
1385
  }
1449
- this.captureStep('Anti-collision', 'Eliminate all collisions', { iterations: ITERATIONS });
1386
+ this.captureStep("Anti-collision", overlapLimitReached
1387
+ ? "Overlap removal stopped at safety limit"
1388
+ : "Eliminate all collisions", {
1389
+ iterations,
1390
+ overlapIterations: overlapIter,
1391
+ overlapLimitReached,
1392
+ });
1450
1393
  }
1451
- this.captureStep('Virtual Node Layout', 'Stress Majorization layout applied to virtual nodes', { iterations: ITERATIONS });
1394
+ this.captureStep('Virtual Node Layout', 'Stress Majorization layout applied to virtual nodes', { iterations });
1452
1395
  }
1453
1396
  /////////////////////////////////////////////////////////////////////////////
1454
1397
  if (1) {
1455
- if (params.SPREAD_V_NODES) {
1398
+ if (params.spreadVNodes) {
1456
1399
  this.vnodes.forEach((v) => {
1457
1400
  v.nodes.forEach((n) => {
1458
1401
  n.position().x = v.center_x + Math.random() * 5;
@@ -1463,15 +1406,15 @@ LuminaLayout.prototype.run = function () {
1463
1406
  }
1464
1407
  }
1465
1408
  ///////////////////// Delete the old layout so that only virtual-node VNode layout remains ////////////////
1466
- if (!params.SUBSTRUCTURE_LAYOUT) {
1467
- const IDEAL_LENGTH = params.LEAF_NODE_DISTANCE;
1409
+ if (!params.substructureLayout) {
1410
+ const idealLength = params.leafNodeDistance;
1468
1411
  this.vnodes.forEach((v) => {
1469
1412
  if (v.type == 'Star') {
1470
1413
  // if(targetNode){ // star is an independent star, not attached to a Cycle
1471
1414
  if (1) {
1472
1415
  const allMemberNode = v.nodes.filter((node) => node.data('structType') !== 'Star-Center');
1473
- const ringSpacing = params.STAR_RING_SPACING;
1474
- const baseNodesInFirstRing = params.STAR_BASE_NODES_PER_RING;
1416
+ const ringSpacing = params.starRingSpacing;
1417
+ const baseNodesInFirstRing = params.starBaseNodesPerRing;
1475
1418
  // 1. Pre-calculate how many nodes go into each ring
1476
1419
  const rings = [];
1477
1420
  let tempNodes = [...allMemberNode];
@@ -1540,10 +1483,10 @@ LuminaLayout.prototype.run = function () {
1540
1483
  cx /= count;
1541
1484
  cy /= count;
1542
1485
  // 2. Special handling: exactly 3 nodes form a strict equilateral triangle
1543
- // (do not use CHAIN_MIN_RADIUS, so the triangle is not artificially enlarged)
1486
+ // (do not use chainMinRadius, so the triangle is not artificially enlarged)
1544
1487
  if (count === 3) {
1545
1488
  // CYCLE_NODE_SPACING represents the desired node spacing (triangle side length)
1546
- const sideLength = params.CYCLE_NODE_SPACING;
1489
+ const sideLength = params.cycleNodeSpacing;
1547
1490
  // Circumradius of the equilateral triangle: radius = side / sqrt(3)
1548
1491
  const radius = sideLength / Math.sqrt(3);
1549
1492
  // 3. Search for the best rotation angle (0°, 10°, ... 350°),
@@ -1580,8 +1523,8 @@ LuminaLayout.prototype.run = function () {
1580
1523
  // 3. count > 3
1581
1524
  // gNodes[0] -> center node, gNodes[1...] -> circumference nodes
1582
1525
  // 4. Calculate the standard radius based on the number of nodes
1583
- const miniMumRadius = params.CHAIN_MIN_RADIUS;
1584
- const radius = Math.max((count * params.CYCLE_NODE_SPACING) / (2 * Math.PI), miniMumRadius);
1526
+ const miniMumRadius = params.chainMinRadius;
1527
+ const radius = Math.max((count * params.cycleNodeSpacing) / (2 * Math.PI), miniMumRadius);
1585
1528
  // 5. Circumference nodes
1586
1529
  const sorted = gNodes.slice(1);
1587
1530
  // 6. Search for the best rotation angle
@@ -1648,7 +1591,7 @@ LuminaLayout.prototype.run = function () {
1648
1591
  }
1649
1592
  // 2. Calculate the standard radius based on the number of nodes (to keep node spacing close to k)
1650
1593
  const count = v.nodes.length;
1651
- const k = params.CYCLE_NODE_SPACING;
1594
+ const k = params.cycleNodeSpacing;
1652
1595
  const radius = (count * k) / (2 * Math.PI);
1653
1596
  // 3. Sort to prevent nodes from flickering around the circumference
1654
1597
  const sorted = v.nodes;
@@ -1713,14 +1656,14 @@ LuminaLayout.prototype.run = function () {
1713
1656
  let bestRotate = 0; //Find the best rotation angle
1714
1657
  let aroundNodesVec = [];
1715
1658
  nodes.forEach((nd, i) => {
1716
- 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) {
1659
+ if (((nd.position().x - fatherPos.x) * (nd.position().x - fatherPos.x) + (nd.position().y - fatherPos.y) * (nd.position().y - fatherPos.y)) < 2 * idealLength * idealLength) {
1717
1660
  aroundNodesVec.push(nd);
1718
1661
  }
1719
1662
  });
1720
1663
  for (let rotate = 0; rotate <= 360; rotate = rotate + 10) {
1721
1664
  n.position({
1722
- x: fatherPos.x + Math.cos(rotate * 3.14 / 180) * IDEAL_LENGTH,
1723
- y: fatherPos.y + Math.sin(rotate * 3.14 / 180) * IDEAL_LENGTH
1665
+ x: fatherPos.x + Math.cos(rotate * 3.14 / 180) * idealLength,
1666
+ y: fatherPos.y + Math.sin(rotate * 3.14 / 180) * idealLength
1724
1667
  });
1725
1668
  let totalLength = 0;
1726
1669
  aroundNodesVec.forEach((nd, i) => {
@@ -1736,8 +1679,8 @@ LuminaLayout.prototype.run = function () {
1736
1679
  }
1737
1680
  //
1738
1681
  n.position({
1739
- x: fatherPos.x + Math.cos(bestRotate * 3.14 / 180) * IDEAL_LENGTH,
1740
- y: fatherPos.y + Math.sin(bestRotate * 3.14 / 180) * IDEAL_LENGTH
1682
+ x: fatherPos.x + Math.cos(bestRotate * 3.14 / 180) * idealLength,
1683
+ y: fatherPos.y + Math.sin(bestRotate * 3.14 / 180) * idealLength
1741
1684
  });
1742
1685
  }
1743
1686
  });
@@ -1758,34 +1701,6 @@ LuminaLayout.prototype.run = function () {
1758
1701
  nodes[2].position().y = 90;
1759
1702
  }
1760
1703
  rotateNetworkToMinimumBoundingBox(nodes);
1761
- if (1) {
1762
- let maxDis = 0;
1763
- let maxS = 0;
1764
- let maxT = 0;
1765
- let minDist = 10e10;
1766
- let minS = 0;
1767
- let minT = 0;
1768
- let avgDis = 0;
1769
- edgeSet.forEach((e) => {
1770
- const s = e.source();
1771
- const t = e.target();
1772
- const distance = Math.sqrt(Math.pow(s.position().x - t.position().x, 2) + Math.pow(s.position().y - t.position().y, 2));
1773
- if (maxDis < distance) {
1774
- maxDis = distance;
1775
- maxS = s.id();
1776
- maxT = t.id();
1777
- }
1778
- if (minDist > distance) {
1779
- minDist = distance;
1780
- minS = s.id();
1781
- minT = t.id();
1782
- }
1783
- avgDis += distance;
1784
- });
1785
- console.log("maxDis:", maxDis, " ", maxS, "->", maxT);
1786
- console.log("minDist:", minDist, " ", minS, "->", minT);
1787
- console.log("avgDis:", avgDis / edgeSet.length);
1788
- }
1789
1704
  const bb2 = nodes.boundingBox();
1790
1705
  networkInfos.push({
1791
1706
  component: component,
@@ -2357,7 +2272,7 @@ LuminaLayout.prototype.packNetworks = function (networks) {
2357
2272
  * Navigate to a specific step in step-by-step mode
2358
2273
  */
2359
2274
  LuminaLayout.prototype.goToStep = function (stepIndex) {
2360
- if (!this.params.STEP_BY_STEP || this.steps.length === 0) {
2275
+ if (!this.params.stepByStep || this.steps.length === 0) {
2361
2276
  console.warn('Step-by-step mode is not enabled or no steps have been captured');
2362
2277
  return this;
2363
2278
  }
@@ -2377,7 +2292,7 @@ LuminaLayout.prototype.goToStep = function (stepIndex) {
2377
2292
  });
2378
2293
  // Visualize virtual nodes if they exist at this step
2379
2294
  this.visualizeVirtualNodes(step);
2380
- console.log(`[Step ${step.stepNumber}/${this.steps.length - 1}] ${step.stepName}: ${step.description}`);
2295
+ console.debug(`[Step ${step.stepNumber}/${this.steps.length - 1}] ${step.stepName}: ${step.description}`);
2381
2296
  this.cy.fit(null, 50);
2382
2297
  return this;
2383
2298
  };
@@ -2389,7 +2304,7 @@ LuminaLayout.prototype.nextStep = function () {
2389
2304
  return this.goToStep(this.currentStepIndex + 1);
2390
2305
  }
2391
2306
  else {
2392
- console.log('Already at the last step');
2307
+ console.debug("Already at the last step");
2393
2308
  return this;
2394
2309
  }
2395
2310
  };
@@ -2401,7 +2316,7 @@ LuminaLayout.prototype.prevStep = function () {
2401
2316
  return this.goToStep(this.currentStepIndex - 1);
2402
2317
  }
2403
2318
  else {
2404
- console.log('Already at the first step');
2319
+ console.debug("Already at the first step");
2405
2320
  return this;
2406
2321
  }
2407
2322
  };
@@ -2409,15 +2324,10 @@ LuminaLayout.prototype.prevStep = function () {
2409
2324
  * Get information about all steps
2410
2325
  */
2411
2326
  LuminaLayout.prototype.listSteps = function () {
2412
- if (!this.params.STEP_BY_STEP || this.steps.length === 0) {
2413
- console.log('No steps available');
2327
+ if (!this.params.stepByStep || this.steps.length === 0) {
2328
+ console.debug("No steps available");
2414
2329
  return [];
2415
2330
  }
2416
- console.log(`Total steps: ${this.steps.length}`);
2417
- this.steps.forEach((step, index) => {
2418
- const current = index === this.currentStepIndex ? ' ← CURRENT' : '';
2419
- console.log(` [${index}] ${step.stepName}: ${step.description}${current}`);
2420
- });
2421
2331
  return this.steps.map((s) => ({
2422
2332
  stepNumber: s.stepNumber,
2423
2333
  stepName: s.stepName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intact-ebi/cytoscape-lumina",
3
- "version": "1.0.0",
3
+ "version": "1.1.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
  }