Added support for ipsep-cola layout algorithm

This commit is contained in:
shubham-mermaid
2025-06-09 20:38:52 +05:30
parent 8a703bd09f
commit 3a8952ebe0
11 changed files with 2735 additions and 1 deletions

View File

@@ -0,0 +1,601 @@
<html>
<head>
<link href="https://fonts.googleapis.com/css?family=Montserrat&display=swap" rel="stylesheet" />
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/font-awesome.min.css"
/>
<link
href="https://cdn.jsdelivr.net/npm/@mdi/font@6.9.96/css/materialdesignicons.min.css"
rel="stylesheet"
/>
<link
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css?family=Noto+Sans+SC&display=swap"
rel="stylesheet"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Kalam:wght@300;400;700&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Caveat:wght@400..700&family=Kalam:wght@300;400;700&family=Rubik+Mono+One&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Kalam:wght@300;400;700&family=Rubik+Mono+One&display=swap"
rel="stylesheet"
/>
<style>
body {
/* background: #333; */
font-family: 'Arial';
}
h1 {
color: grey;
}
.mermaid2 {
display: none;
}
svg {
border: 3px solid #300;
margin: 10px;
}
pre {
width: 100%;
}
</style>
</head>
<body>
<pre id="diagram4" class="mermaid">
flowchart
A --> B
subgraph hello
C --> D
end
</pre
>
<pre id="diagram4" class="mermaid">
flowchart
A --> B
A --> B
</pre
>
<pre id="diagram4" class="mermaid">
flowchart
A[hello] --> A
</pre
>
<pre id="diagram4" class="mermaid">
flowchart
subgraph C
c
end
A --> C
</pre
>
<pre id="diagram4" class="mermaid">
flowchart
subgraph C
c
end
A --> c
</pre
>
<pre id="diagram4" class="mermaid">
flowchart TD
subgraph D
A --> B
A --> B
B --> A
B --> A
end
</pre
>
<pre id="diagram4" class="mermaid">
flowchart
subgraph B2
A --> B --> C
B --> D
end
B2 --> X
</pre
>
<pre id="diagram4" class="mermaid">
stateDiagram-v2
[*] --> Still
Still --> [*]
Still --> Moving
Moving --> Still
Moving --> Crash
Crash --> [*]
</pre
>
<pre id="diagram4" class="mermaid">
classDiagram
Animal <|-- Duck
Animal <|-- Fish
Animal <|-- Zebra
Animal : +int age
Animal : +String gender
Animal: +isMammal()
Animal: +mate()
class Duck{
+String beakColor
+swim()
+quack()
}
class Fish{
-int sizeInFeet
-canEat()
}
class Zebra{
+bool is_wild
+run()
}
</pre
>
<pre id="diagram4" class="mermaid">
flowchart TD
P1
P1 -->P1.5
subgraph P1.5
P2
P2.5(( A ))
P3
end
P2 --> P4
P3 --> P6
P1.5 --> P5
</pre>
<pre id="diagram4" class="mermaid">
%% Length of edges
flowchart TD
L1 --- L2
L2 --- C
M1 ---> C
R1 .-> R2
R2 <.-> C
C -->|Label 1| E1
C <-- Label 2 ---> E2
C ----> E3
C <-...-> E4
C ======> E5
</pre>
<pre id="diagram4" class="mermaid">
%% Stadium shape
flowchart TD
A([stadium shape test])
A -->|Get money| B([Go shopping])
B --> C([Let me think...<br />Do I want something for work,<br />something to spend every free second with,<br />or something to get around?])
C -->|One| D([Laptop])
C -->|Two| E([iPhone])
C -->|Three| F([Car<br/>wroom wroom])
click A "index.html#link-clicked" "link test"
click B testClick "click test"
classDef someclass fill:#f96;
class A someclass;
class C someclass;
</pre>
<pre id="diagram4" class="mermaid">
%% should render escaped without html labels
flowchart TD
a["<strong>Haiya</strong>"]---->b
</pre>
<pre id="diagram4" class="mermaid">
%% nested subgraphs in reverse order
flowchart LR
a -->b
subgraph A
B
end
subgraph B
b
end
</pre>
<pre id="diagram4" class="mermaid">
%% nested subgraphs in several levels
flowchart LR
b-->B
a-->c
subgraph O
A
end
subgraph B
c
end
subgraph A
a
b
B
end
</pre>
<pre id="diagram4" class="mermaid">
%% nested subgraphs with edges in and out
flowchart LR
internet
nat
routeur
lb1
lb2
compute1
compute2
subgraph project
routeur
nat
subgraph subnet1
compute1
lb1
end
subgraph subnet2
compute2
lb2
end
end
internet --> routeur
routeur --> subnet1 & subnet2
subnet1 & subnet2 --> nat --> internet
</pre>
<pre id="diagram4" class="mermaid">
%% nested subgraphs with outgoing links
flowchart LR
subgraph main
subgraph subcontainer
subcontainer-child
end
subcontainer-child--> subcontainer-sibling
end
</pre>
<pre id="diagram4" class="mermaid">
%% nested subgraphs with ingoing links
flowchart LR
subgraph one[One]
subgraph sub_one[Sub One]
_sub_one
end
subgraph sub_two[Sub Two]
_sub_two
end
_one
end
%% here, either the first or the second one
sub_one --> sub_two
_one --> b
</pre>
<pre id="diagram4" class="mermaid">
%% nested subgraphs with outgoing links 3
flowchart LR
subgraph container_Beta
process_C-->Process_D
end
subgraph container_Alpha
process_A-->process_B
process_A-->|messages|process_C
end
process_B-->|via_AWSBatch|container_Beta
</pre>
<pre id="diagram4" class="mermaid">
%% nested subgraphs with outgoing links 4
flowchart LR
subgraph A
a -->b
end
subgraph B
b
end
</pre>
<pre id="diagram4" class="mermaid">
%% nested subgraphs with outgoing links 2
flowchart LR
c1-->a2
subgraph one
a1-->a2
end
subgraph two
b1-->b2
end
subgraph three
c1-->c2
end
one --> two
three --> two
two --> c2
</pre>
<pre id="diagram4" class="mermaid">
%% nested subgraphs with outgoing links 5
flowchart LR
subgraph container_Beta
process_C-->Process_D
end
subgraph container_Alpha
process_A-->process_B
process_B-->|via_AWSBatch|container_Beta
process_A-->|messages|process_C
end
</pre>
<pre id="diagram4" class="mermaid">
%% More subgraphs
flowchart LR
subgraph two
b1
end
subgraph three
c2
end
three --> two
two --> c2
note[There are two links in this diagram]
</pre>
<pre id="diagram4" class="mermaid">
%% nested subgraphs with outgoing links 5
flowchart LR
A[red text] -->|default style| B(blue text)
C([red text]) -->|default style| D[[blue text]]
E[(red text)] -->|default style| F((blue text))
G>red text] -->|default style| H{blue text}
I{{red text}} -->|default style| J[/blue text/]
K[\\ red text\\] -->|default style| L[/blue text\\]
M[\\ red text/] -->|default style| N[blue text];
O(((red text))) -->|default style| P(((blue text)));
linkStyle default color:Sienna;
style A stroke:#ff0000,fill:#ffcccc,color:#ff0000;
style B stroke:#0000ff,fill:#ccccff,color:#0000ff;
style C stroke:#ff0000,fill:#ffcccc,color:#ff0000;
style D stroke:#0000ff,fill:#ccccff,color:#0000ff;
style E stroke:#ff0000,fill:#ffcccc,color:#ff0000;
style F stroke:#0000ff,fill:#ccccff,color:#0000ff;
style G stroke:#ff0000,fill:#ffcccc,color:#ff0000;
style H stroke:#0000ff,fill:#ccccff,color:#0000ff;
style I stroke:#ff0000,fill:#ffcccc,color:#ff0000;
style J stroke:#0000ff,fill:#ccccff,color:#0000ff;
style K stroke:#ff0000,fill:#ffcccc,color:#ff0000;
style L stroke:#0000ff,fill:#ccccff,color:#0000ff;
style M stroke:#ff0000,fill:#ffcccc,color:#ff0000;
style N stroke:#0000ff,fill:#ccccff,color:#0000ff;
style O stroke:#ff0000,fill:#ffcccc,color:#ff0000;
style P stroke:#0000ff,fill:#ccccff,color:#0000ff;
</pre>
<pre id="diagram4" class="mermaid">
%% labels on edges in a cluster
flowchart RL
subgraph one
a1 -- I am a long label --> a2
a1 -- Another long label --> a2
end
</pre>
<pre id="diagram4" class="mermaid">
%% labels on edges in a cluster
flowchart RL
subgraph one
a1[Iam a node with a super long label] -- I am a long label --> a2[I am another node with a mega long label]
a1 -- Another long label --> a2
a3 --> a1 & a2 & a3 & a4
a1 --> a4
end
</pre>
<pre id="diagram4" class="mermaid">
%% labels on edges in a cluster
flowchart RL
subgraph one
a1[Iam a node with a super long label]
a2[I am another node with a mega long label]
a3[I am a node with a super long label]
a4[I am another node with a mega long label]
a1 -- Another long label --> a2
a3 --> a1 & a2 & a3 & a4
a1 --> a4
end
</pre>
<pre id="diagram4" class="mermaid">
%% labels on edges in a cluster
flowchart RL
a1[I am a node with a super long label. I am a node with a super long label. I am a node with a super long label. I am a node with a super long label. I am a node with a super long label. ]
a2[I am another node with a mega long label]
a3[I am a node with a super long label]
a4[I am another node with a mega long label]
a1 & a2 & a3 & a4 --> a5 & a6 & a7 & a8 & a9 & a10
</pre>
<pre id="diagram4" class="mermaid">
flowchart
subgraph Z
subgraph X
a --> b
end
subgraph Y
c --> d
end
end
Y --> X
X --> P
P --> Y
</pre
>
<pre id="diagram4" class="mermaid">
flowchart
a --> b
b --> c
b --> d
c --> a
</pre
>
<pre id="diagram3" class="mermaid">
flowchart TD
Start([Start]) --> Prep[Preparation Step]
Prep --> Split{Ready to Process?}
Split -->|Yes| T1[Task A]
Split -->|Yes| T2[Task B]
T1 --> Merge
T2 --> Merge
Merge((Join Results)) --> Finalize[Finalize Process]
Finalize --> End([End])
</pre
>
<pre id="diagram4" class="mermaid">
flowchart TD
A[Start Build] --> B[Compile Source]
B --> C[Test Suite]
C --> D{Tests Passed?}
D -->|No| E[Notify Developer]
E --> A
D -->|Yes| F[Build Docker Image]
subgraph Deploy Pipeline
F --> G[Deploy to Staging]
G --> H[Run Integration Tests]
H --> I{Tests Passed?}
I -->|No| J[Rollback & Alert]
I -->|Yes| K[Deploy to Production]
end
K --> L([Success])
</pre
>
<pre class="mermaid">
classDiagram
class Controller {
+handleRequest(): void
}
class View {
+render(): void
}
class Model {
+getData(): any
+setData(data: any): void
}
Controller --> Model
Controller --> View
Model --> View : notifyChange()
</pre
>
<pre class="mermaid">
classDiagram
class AuthService {
+login(username: string, password: string): boolean
+logout(): void
+register(): void
}
class User {
-username: string
-password: string
-role: Role
+changePassword(): void
}
class Role {
-name: string
-permissions: string[]
+hasPermission(): boolean
}
AuthService --> User
User --> Role
</pre
>
<script type="module">
import mermaid from './mermaid.esm.mjs';
import layouts from './mermaid-layout-elk.esm.mjs';
const staticBellIconPack = {
prefix: 'fa6-regular',
icons: {
bell: {
body: '<path fill="currentColor" d="M224 0c-17.7 0-32 14.3-32 32v19.2C119 66 64 130.6 64 208v25.4c0 45.4-15.5 89.5-43.8 124.9L5.3 377c-5.8 7.2-6.9 17.1-2.9 25.4S14.8 416 24 416h400c9.2 0 17.6-5.3 21.6-13.6s2.9-18.2-2.9-25.4l-14.9-18.6c-28.3-35.5-43.8-79.6-43.8-125V208c0-77.4-55-142-128-156.8V32c0-17.7-14.3-32-32-32m0 96c61.9 0 112 50.1 112 112v25.4c0 47.9 13.9 94.6 39.7 134.6H72.3c25.8-40 39.7-86.7 39.7-134.6V208c0-61.9 50.1-112 112-112m64 352H160c0 17 6.7 33.3 18.7 45.3S207 512 224 512s33.3-6.7 45.3-18.7S288 465 288 448"/>',
width: 448,
},
},
width: 512,
height: 512,
};
mermaid.registerIconPacks([
{
name: 'logos',
loader: () =>
fetch('https://unpkg.com/@iconify-json/logos@1/icons.json').then((res) => res.json()),
},
{
name: 'fa',
loader: () => staticBellIconPack,
},
]);
mermaid.registerLayoutLoaders(layouts);
mermaid.parseError = function (err, hash) {
console.error('Mermaid error: ', err);
};
window.callback = function () {
alert('A callback was triggered');
};
function callback() {
alert('It worked');
}
await mermaid.initialize({
theme: 'redux-dark',
// theme: 'default',
// theme: 'forest',
handDrawnSeed: 12,
look: 'classic ',
// 'elk.nodePlacement.strategy': 'NETWORK_SIMPLEX',
// layout: 'dagre',
layout: 'ipsecCola',
// layout: 'elk',
// layout: 'sugiyama',
// htmlLabels: false,
flowchart: { titleTopMargin: 10 },
// fontFamily: 'Caveat',
// fontFamily: 'Kalam',
// fontFamily: 'courier',
fontFamily: 'arial',
sequence: {
actorFontFamily: 'courier',
noteFontFamily: 'courier',
messageFontFamily: 'courier',
},
kanban: {
htmlLabels: false,
},
fontSize: 12,
logLevel: 0,
securityLevel: 'loose',
callback,
});
mermaid.parseError = function (err, hash) {
console.error('In parse error:');
console.error(err);
};
</script>
</body>
</html>

View File

@@ -0,0 +1,148 @@
import type { Selection } from 'd3';
import * as graphlib from 'dagre-d3-es/src/graphlib/index.js';
import type { LayoutData, NonClusterNode } from './types.js';
import { getConfig } from '../diagram-api/diagramAPI.js';
import { insertNode } from './rendering-elements/nodes.js';
type D3Selection<T extends SVGElement = SVGElement> = Selection<
T,
unknown,
Element | null,
unknown
>;
/**
* Creates a graph by merging the graph construction and DOM element insertion.
*
* This function creates the graph, inserts the SVG groups (clusters, edgePaths, edgeLabels, nodes)
* into the provided element, and uses `insertNode` to add nodes to the diagram. Node dimensions
* are computed using each node's bounding box.
*
* @param element - The D3 selection in which the SVG groups are inserted.
* @param data4Layout - The layout data containing nodes and edges.
* @returns A promise resolving to an object containing the graph and the inserted groups.
*/
export async function createGraphWithElements(
element: D3Selection,
data4Layout: LayoutData
): Promise<{
graph: graphlib.Graph;
groups: {
clusters: D3Selection<SVGGElement>;
edgePaths: D3Selection<SVGGElement>;
edgeLabels: D3Selection<SVGGElement>;
nodes: D3Selection<SVGGElement>;
rootGroups: D3Selection<SVGGElement>;
};
nodeElements: Map<string, D3Selection<SVGElement | SVGGElement>>;
}> {
const graph = new graphlib.Graph({
multigraph: true,
compound: true,
});
const edgesToProcess = [...data4Layout.edges];
const config = getConfig();
// Create groups for clusters, edge paths, edge labels, and nodes.
const rootGroups = element.insert('g').attr('class', 'root');
const clusters = rootGroups.insert('g').attr('class', 'clusters');
const edgePaths = rootGroups.insert('g').attr('class', 'edges edgePath');
const edgeLabels = rootGroups.insert('g').attr('class', 'edgeLabels');
const nodesGroup = rootGroups.insert('g').attr('class', 'nodes');
const nodeElements = new Map<string, D3Selection<SVGElement | SVGGElement>>();
// Insert nodes into the DOM and add them to the graph.
for (const node of data4Layout.nodes) {
if (node.isGroup) {
graph.setNode(node.id, { ...node });
} else {
const childNodeEl = await insertNode(nodesGroup, node, { config, dir: node.dir });
const boundingBox = childNodeEl.node()?.getBBox() ?? { width: 0, height: 0 };
nodeElements.set(node.id, childNodeEl as D3Selection<SVGElement | SVGGElement>);
node.width = boundingBox.width;
node.height = boundingBox.height;
graph.setNode(node.id, { ...node });
}
}
// Add edges to the graph.
for (const edge of edgesToProcess) {
if (edge.label && edge.label?.length > 0) {
// Create a label node for the edge
const startNode = data4Layout.nodes.find((n) => n.id == edge.start);
const labelNodeId = `edge-label-${edge.start}-${edge.end}-${edge.id}`;
const labelNode: NonClusterNode = {
id: labelNodeId,
label: edge.label,
edgeStart: edge.start ?? '',
edgeEnd: edge.end ?? '',
shape: 'labelRect',
width: 0,
height: 0,
isEdgeLabel: true,
isDummy: true,
parentId: undefined,
isGroup: false,
layer: 0,
order: 0,
...(startNode?.dir ? { dir: startNode.dir } : {}),
};
// Insert the label node into the DOM
const labelNodeEl = await insertNode(nodesGroup, labelNode, { config, dir: startNode?.dir });
const boundingBox = labelNodeEl.node()?.getBBox() ?? { width: 0, height: 0 };
// Update node dimensions
labelNode.width = boundingBox.width;
labelNode.height = boundingBox.height;
// Add to graph and tracking maps
graph.setNode(labelNodeId, { ...labelNode });
nodeElements.set(labelNodeId, labelNodeEl as D3Selection<SVGElement | SVGGElement>);
data4Layout.nodes.push(labelNode);
// Create two edges to replace the original one
const edgeToLabel = {
...edge,
id: `${edge.id}-to-label`,
end: labelNodeId,
label: undefined,
isLabelEdge: true,
arrowTypeEnd: 'none',
arrowTypeStart: 'none',
};
const edgeFromLabel = {
...edge,
id: `${edge.id}-from-label`,
start: labelNodeId,
end: edge.end,
label: undefined,
isLabelEdge: true,
arrowTypeStart: 'none',
arrowTypeEnd: 'arrow_point',
};
graph.setEdge(edgeToLabel.id, edgeToLabel.start, edgeToLabel.end, { ...edgeToLabel });
graph.setEdge(edgeFromLabel.id, edgeFromLabel.start, edgeFromLabel.end, { ...edgeFromLabel });
data4Layout.edges.push(edgeToLabel, edgeFromLabel);
const edgeIdToRemove = edge.id;
data4Layout.edges = data4Layout.edges.filter((edge) => edge.id !== edgeIdToRemove);
const indexInOriginal = data4Layout.edges.findIndex((e) => e.id === edge.id);
if (indexInOriginal !== -1) {
data4Layout.edges.splice(indexInOriginal, 1);
}
} else {
// Regular edge without label
graph.setEdge(edge.id, edge.start, edge.end, { ...edge });
const edgeExists = data4Layout.edges.some((existingEdge) => existingEdge.id === edge.id);
if (!edgeExists) {
data4Layout.edges.push(edge);
}
}
}
return {
graph,
groups: { clusters, edgePaths, edgeLabels, nodes: nodesGroup, rootGroups },
nodeElements,
};
}

View File

@@ -0,0 +1,34 @@
import type { LayoutData } from '../../types.js';
import type { D3Selection } from '../../../types.js';
import { insertCluster } from '../../rendering-elements/clusters.js';
import { insertEdge } from '../../rendering-elements/edges.js';
import { positionNode } from '../../rendering-elements/nodes.js';
export async function adjustLayout(
data4Layout: LayoutData,
groups: {
edgePaths: D3Selection<SVGGElement>;
rootGroups: D3Selection<SVGGElement>;
[key: string]: D3Selection<SVGGElement>;
}
): Promise<void> {
for (const node of data4Layout.nodes) {
if (node.isGroup) {
await insertCluster(groups.clusters, node);
} else {
positionNode(node);
}
}
data4Layout.edges.forEach((edge) => {
insertEdge(
groups.edgePaths,
{ ...edge },
{},
data4Layout.type,
edge.start,
edge.end,
data4Layout.diagramId
);
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
import type { LayoutData, Node } from '../../types.js';
/**
* Assigns initial x and y positions to each node
* based on its rank and order.
*
* @param nodeSpacing - Horizontal spacing between nodes
* @param layerHeight - Vertical spacing between layers
* @param data4Layout - Layout data used to update node positions
*/
export function assignInitialPositions(
nodeSpacing: number,
layerHeight: number,
data4Layout: LayoutData
): void {
data4Layout.nodes.forEach((node: Node) => {
const layer = node.layer ?? 0;
const order = node.order ?? 0;
const x = order * nodeSpacing;
const y = layer * layerHeight;
node.x = x;
node.y = y;
});
}

View File

@@ -0,0 +1,83 @@
import insertMarkers from '../../rendering-elements/markers.js';
import { clear as clearGraphlib } from '../dagre/mermaid-graphlib.js';
import { clear as clearNodes } from '../../rendering-elements/nodes.js';
import { clear as clearClusters } from '../../rendering-elements/clusters.js';
import { clear as clearEdges } from '../../rendering-elements/edges.js';
import type { LayoutData, Node } from '../../types.js';
import { adjustLayout } from './adjustLayout.js';
import { layerAssignment } from './layerAssignment.js';
import { assignNodeOrder } from './nodeOrdering.js';
import { assignInitialPositions } from './assignInitialPositions.js';
import { applyCola } from './applyCola.js';
import { createGraphWithElements } from '../../createGraph.js';
import type { D3Selection } from '../../../types.js';
import type { SVG } from '../../../mermaid.js';
export async function render(data4Layout: LayoutData, svg: SVG): Promise<void> {
const element = svg.select('g') as unknown as D3Selection<SVGElement>;
// Insert markers and clear previous elements
insertMarkers(element, data4Layout.markers, data4Layout.type, data4Layout.diagramId);
clearNodes();
clearEdges();
clearClusters();
clearGraphlib();
// Create the graph and insert the SVG groups and nodes
const { groups } = await createGraphWithElements(element, data4Layout);
// layer assignment
layerAssignment(data4Layout);
// assign node order using barycenter heuristic method
assignNodeOrder(1, data4Layout);
// assign initial coordinates
assignInitialPositions(100, 130, data4Layout);
const nodesCount = data4Layout.nodes.length;
const edgesCount = data4Layout.edges.length;
const groupNodes = data4Layout.nodes.filter((node) => {
if (node.isGroup) {
return node;
}
});
let iteration = nodesCount + edgesCount;
if (groupNodes.length > 0) {
iteration = iteration * 5;
}
applyCola(
{
iterations: iteration * 4,
springLength: 80,
springStrength: 0.1,
repulsionStrength: 70000,
},
data4Layout
);
data4Layout.nodes = sortGroupNodesToEnd(data4Layout.nodes);
await adjustLayout(data4Layout, groups);
}
function sortGroupNodesToEnd(nodes: Node[]): Node[] {
const nonGroupNodes = nodes.filter((n) => !n.isGroup);
const groupNodes = nodes
.filter((n) => n.isGroup)
.map((n) => {
const width = typeof n.width === 'number' ? n.width : 0;
const height = typeof n.height === 'number' ? n.height : 0;
return {
...n,
_area: width * height,
};
})
.sort((a, b) => b._area - a._area)
.map((n, idx) => {
const { _area, ...cleanNode } = n;
cleanNode.order = nonGroupNodes.length + idx;
return cleanNode;
});
return [...nonGroupNodes, ...groupNodes];
}

View File

@@ -0,0 +1,90 @@
import type { Edge, LayoutData } from '../../types.js';
export function layerAssignment(data4Layout: LayoutData): void {
const removedEdges: Edge[] = [];
const visited = new Set<string>();
const visiting = new Set<string>();
function dfs(nodeId: string): void {
visited.add(nodeId);
visiting.add(nodeId);
const outbound = data4Layout.edges.filter((e) => e.start === nodeId);
for (const edge of outbound) {
const neighbor = edge.end!;
if (!visited.has(neighbor)) {
dfs(neighbor);
} else if (visiting.has(neighbor)) {
// Cycle detected: temporarily remove this edge
removedEdges.push(edge);
}
}
visiting.delete(nodeId);
}
// Remove cycles using DFS
for (const node of data4Layout.nodes) {
if (!visited.has(node.id)) {
dfs(node.id);
}
}
// Filter out removed edges temporarily
const workingEdges = data4Layout.edges.filter((e) => !removedEdges.includes(e));
// Build in-degree map
const inDegree: Record<string, number> = {};
for (const node of data4Layout.nodes) {
inDegree[node.id] = 0;
}
for (const edge of workingEdges) {
if (edge.end) {
inDegree[edge.end]++;
}
}
// Queue of nodes with in-degree 0
const queue: string[] = [];
for (const nodeId in inDegree) {
if (inDegree[nodeId] === 0) {
queue.push(nodeId);
}
}
// Map to store calculated ranks/layers
const ranks: Record<string, number> = {};
while (queue.length > 0) {
const nodeId = queue.shift()!;
const parents = workingEdges.filter((e) => e.end === nodeId).map((e) => e.start!);
const layoutNode = data4Layout.nodes.find((n) => n.id === nodeId);
if (layoutNode?.parentId && parents.length == 0) {
const parentNode = data4Layout.nodes.find((n) => n.id === layoutNode.parentId);
if (!parentNode?.layer) {
parents.push(parentNode?.id ?? '');
}
}
const parentRanks = parents.map((p) => ranks[p] ?? 0);
const rank = parentRanks.length ? Math.min(...parentRanks) + 1 : 0;
ranks[nodeId] = rank;
// Update layer in data4Layout.nodes
if (layoutNode) {
layoutNode.layer = rank + 1;
}
// Decrement in-degree of children
for (const edge of workingEdges) {
if (edge.start === nodeId && edge.end) {
inDegree[edge.end]--;
if (inDegree[edge.end] === 0) {
queue.push(edge.end);
}
}
}
}
}

View File

@@ -0,0 +1,129 @@
import type { Edge, LayoutData, Node } from '../../types.js';
type LayerMap = Record<number, Node[]>;
function groupNodesByLayer(nodes: Node[]): LayerMap {
const layers: LayerMap = {};
nodes.forEach((node: Node) => {
if (node.isGroup) {
return;
}
const layer = node.layer ?? 0;
if (!layers[layer]) {
layers[layer] = [];
}
layers[layer].push(node);
});
return layers;
}
/**
* Assign horizontal ordering to nodes, excluding group nodes from ordering.
* Groups are assigned `order` after real nodes are sorted.
*/
export function assignNodeOrder(iterations: number, data4Layout: LayoutData): void {
const nodes = data4Layout.nodes;
const edges = data4Layout.edges;
const nodeMap = new Map<string, Node>(nodes.map((n) => [n.id, n]));
const isLayered = nodes.some((n) => n.layer !== undefined);
if (isLayered) {
const layers = groupNodesByLayer(nodes);
const sortedLayers = Object.keys(layers)
.map(Number)
.sort((a, b) => a - b);
// Initial order
for (const layer of sortedLayers) {
layers[layer].forEach((node, index) => {
node.order = index;
});
}
// Barycenter iterations
for (let i = 0; i < iterations; i++) {
for (let l = 1; l < sortedLayers.length; l++) {
sortLayerByBarycenter(layers[sortedLayers[l]], 'inbound', edges, nodeMap);
}
for (let l = sortedLayers.length - 2; l >= 0; l--) {
sortLayerByBarycenter(layers[sortedLayers[l]], 'outbound', edges, nodeMap);
}
}
// Assign order to group nodes at the end
for (const node of nodes) {
if (node.isGroup) {
const childOrders = nodes
.filter((n) => n.parentId === node.id)
.map((n) => nodeMap.get(n.id)?.order)
.filter((o): o is number => typeof o === 'number');
node.order = childOrders.length
? childOrders.reduce((a, b) => a + b, 0) / childOrders.length
: nodes.length;
}
}
}
}
function sortLayerByBarycenter(
layerNodes: Node[],
direction: 'inbound' | 'outbound' | 'both',
edges: Edge[],
nodeMap: Map<string, Node>
): void {
const edgeMap = new Map<string, Set<string>>();
edges.forEach((e: Edge) => {
if (e.start && e.end) {
if (!edgeMap.has(e.start)) {
edgeMap.set(e.start, new Set());
}
edgeMap.get(e.start)?.add(e.end);
}
});
const baryCenters = layerNodes.map((node, originalIndex) => {
const neighborOrders: number[] = [];
edges.forEach((edge) => {
if (direction === 'inbound' && edge.end === node.id) {
const source = nodeMap.get(edge.start ?? '');
if (source?.order !== undefined) {
neighborOrders.push(source.order);
}
} else if (direction === 'outbound' && edge.start === node.id) {
const target = nodeMap.get(edge.end ?? '');
if (target?.order !== undefined) {
neighborOrders.push(target.order);
}
} else if (direction === 'both' && (edge.start === node.id || edge.end === node.id)) {
const neighborId = edge.start === node.id ? edge.end : edge.start;
const neighbor = nodeMap.get(neighborId ?? '');
if (neighbor?.order !== undefined) {
neighborOrders.push(neighbor.order);
}
}
});
const barycenter =
neighborOrders.length === 0
? Infinity // Push unconnected nodes to the end
: neighborOrders.reduce((sum, o) => sum + o, 0) / neighborOrders.length;
return { node, barycenter, originalIndex };
});
baryCenters.sort((a, b) => {
if (a.barycenter !== b.barycenter) {
return a.barycenter - b.barycenter;
}
// Stable tie-breaker based on original index
return a.originalIndex - b.originalIndex;
});
baryCenters.forEach((entry, index) => {
entry.node.order = index;
});
}

View File

@@ -39,6 +39,10 @@ const registerDefaultLayoutLoaders = () => {
name: 'dagre', name: 'dagre',
loader: async () => await import('./layout-algorithms/dagre/index.js'), loader: async () => await import('./layout-algorithms/dagre/index.js'),
}, },
{
name: 'ipsecCola',
loader: async () => await import('./layout-algorithms/ipsecCola/index.ts'),
},
]); ]);
}; };

View File

@@ -80,11 +80,23 @@ interface BaseNode {
export interface ClusterNode extends BaseNode { export interface ClusterNode extends BaseNode {
shape?: ClusterShapeID; shape?: ClusterShapeID;
isGroup: true; isGroup: true;
isEdgeLabel?: boolean;
edgeStart?: string;
edgeEnd?: string;
layer?: number;
order?: number;
isDummy?: boolean;
} }
export interface NonClusterNode extends BaseNode { export interface NonClusterNode extends BaseNode {
shape?: ShapeID; shape?: ShapeID;
isGroup: false; isGroup: false;
isEdgeLabel?: boolean;
edgeStart?: string;
edgeEnd?: string;
layer?: number;
order?: number;
isDummy?: boolean;
} }
// Common properties for any node in the system // Common properties for any node in the system
@@ -126,6 +138,8 @@ export interface Edge {
thickness?: 'normal' | 'thick' | 'invisible' | 'dotted'; thickness?: 'normal' | 'thick' | 'invisible' | 'dotted';
look?: string; look?: string;
isUserDefinedId?: boolean; isUserDefinedId?: boolean;
isLabelEdge?: boolean;
points?: { x: number; y: number }[];
} }
export interface RectOptions { export interface RectOptions {

View File

@@ -3,7 +3,9 @@
"compilerOptions": { "compilerOptions": {
"rootDir": "./src", "rootDir": "./src",
"outDir": "./dist", "outDir": "./dist",
"types": ["vitest/importMeta", "vitest/globals"] "types": ["vitest/importMeta", "vitest/globals"],
"allowImportingTsExtensions": true,
"noEmit": true
}, },
"include": [ "include": [
"./src/**/*.ts", "./src/**/*.ts",