@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 +4 -7
- package/dist/lumina-layout.d.ts +61 -69
- package/dist/lumina-layout.js +255 -345
- package/package.json +8 -3
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
|
-
|
|
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();
|
|
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();
|
package/dist/lumina-layout.d.ts
CHANGED
|
@@ -1,78 +1,66 @@
|
|
|
1
1
|
import cytoscape from 'cytoscape';
|
|
2
2
|
export type LayoutAlgorithm = 'force' | 'stress';
|
|
3
|
-
/**
|
|
4
|
-
export interface
|
|
5
|
-
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
/**
|
|
35
|
-
export interface
|
|
36
|
-
|
|
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
|
-
/**
|
|
50
|
-
export interface
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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:
|
|
143
|
+
declare function LuminaLayout(this: any, options: LuminaLayoutOptions & {
|
|
144
|
+
cy?: cytoscape.Core;
|
|
145
|
+
eles?: cytoscape.CollectionReturnValue;
|
|
146
|
+
boundingBox?: cytoscape.BoundingBox12 | cytoscape.BoundingBoxWH;
|
|
147
|
+
}): void;
|
package/dist/lumina-layout.js
CHANGED
|
@@ -1,143 +1,61 @@
|
|
|
1
|
+
import FastPriorityQueue from 'fastpriorityqueue';
|
|
1
2
|
export const DEFAULT_PARAMS = {
|
|
2
|
-
|
|
3
|
+
layoutAlgorithm: 'stress',
|
|
3
4
|
// shared solver
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
// structure
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
35
|
+
weightExponent: 2
|
|
37
36
|
}
|
|
38
37
|
};
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
45
|
+
return resolved;
|
|
85
46
|
}
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
569
|
-
if (leafNeighbors.length >= params.
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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.
|
|
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.
|
|
909
|
-
const baseNodesInFirstRing = params.
|
|
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.
|
|
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.
|
|
953
|
-
v1.radius = Math.max((count * params.
|
|
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
|
-
//
|
|
963
|
-
|
|
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
|
|
967
|
-
const
|
|
968
|
-
const
|
|
969
|
-
const
|
|
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
|
|
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) *
|
|
1004
|
-
nb.center_y = node.center_y + Math.sin(initAngle) *
|
|
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 <
|
|
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 /
|
|
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 -
|
|
1010
|
+
const delta = Math.max(0, surfaceDist - idealLength);
|
|
1055
1011
|
// Apply the cooling factor to the attractive force
|
|
1056
|
-
let force =
|
|
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
|
|
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 = (
|
|
1040
|
+
force = (repulsion * fp.overlapRepulsionFactor) * (overlap / (centerDist + minGap));
|
|
1085
1041
|
}
|
|
1086
1042
|
else {
|
|
1087
1043
|
const gap = centerDist - minDistance;
|
|
1088
|
-
force =
|
|
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 =
|
|
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.
|
|
1062
|
+
if (fp.useAngularForce) {
|
|
1107
1063
|
const norm = (a) => Math.atan2(Math.sin(a), Math.cos(a));
|
|
1108
|
-
const
|
|
1109
|
-
const
|
|
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 *
|
|
1143
|
-
force = Math.min(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 <
|
|
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.
|
|
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
|
-
|
|
1178
|
+
overlapLimitReached = true;
|
|
1222
1179
|
break;
|
|
1223
1180
|
}
|
|
1224
|
-
// this.captureStep('Anti-collision', 'Eliminate all collisions ' + iter, { iterations: ITERATIONS });
|
|
1225
1181
|
}
|
|
1226
|
-
this.captureStep(
|
|
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 < (
|
|
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
|
|
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
|
-
//
|
|
1250
|
-
|
|
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
|
|
1254
|
-
const
|
|
1255
|
-
const
|
|
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
|
-
|
|
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
|
|
1235
|
+
// [Preprocessing 1] Calculate all-pairs shortest paths using weighted Dijkstra
|
|
1276
1236
|
// -------------------------------------------------------------
|
|
1277
|
-
const distMatrix =
|
|
1237
|
+
const distMatrix = computeStressDistances(this.vnodes, this.vedges, idealLength);
|
|
1278
1238
|
const weightMatrix = Array.from({ length: numNodes }, () => new Array(numNodes).fill(0));
|
|
1279
|
-
|
|
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.
|
|
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) *
|
|
1330
|
-
nb.center_y = node.center_y + Math.sin(initAngle) *
|
|
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 <
|
|
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 <
|
|
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.
|
|
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.
|
|
1445
|
-
|
|
1381
|
+
if (numOfCollision > 10000 || overlapIter > params.maxOverlapIterations) {
|
|
1382
|
+
overlapLimitReached = true;
|
|
1446
1383
|
break;
|
|
1447
1384
|
}
|
|
1448
1385
|
}
|
|
1449
|
-
this.captureStep(
|
|
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
|
|
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.
|
|
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.
|
|
1467
|
-
const
|
|
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.
|
|
1474
|
-
const baseNodesInFirstRing = params.
|
|
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
|
|
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.
|
|
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.
|
|
1584
|
-
const radius = Math.max((count * params.
|
|
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.
|
|
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 *
|
|
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) *
|
|
1723
|
-
y: fatherPos.y + Math.sin(rotate * 3.14 / 180) *
|
|
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) *
|
|
1740
|
-
y: fatherPos.y + Math.sin(bestRotate * 3.14 / 180) *
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
2413
|
-
console.
|
|
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.
|
|
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": "
|
|
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
|
}
|