diff --git a/cypress/platform/ipsepcola_sample.html b/cypress/platform/ipsepcola_sample.html
new file mode 100644
index 000000000..295f48e23
--- /dev/null
+++ b/cypress/platform/ipsepcola_sample.html
@@ -0,0 +1,601 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ flowchart
+ A --> B
+ subgraph hello
+ C --> D
+ end
+
+
+
+ flowchart
+ A --> B
+ A --> B
+
+
+ flowchart
+ A[hello] --> A
+
+
+
+ flowchart
+ subgraph C
+ c
+ end
+ A --> C
+
+
+ flowchart
+ subgraph C
+ c
+ end
+ A --> c
+
+
+
+ flowchart TD
+ subgraph D
+ A --> B
+ A --> B
+ B --> A
+ B --> A
+ end
+
+
+flowchart
+subgraph B2
+A --> B --> C
+B --> D
+end
+
+B2 --> X
+
+
+ stateDiagram-v2
+ [*] --> Still
+ Still --> [*]
+ Still --> Moving
+ Moving --> Still
+ Moving --> Crash
+ Crash --> [*]
+
+
+
+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()
+ }
+
+
+flowchart TD
+ P1
+ P1 -->P1.5
+ subgraph P1.5
+ P2
+ P2.5(( A ))
+ P3
+ end
+ P2 --> P4
+ P3 --> P6
+ P1.5 --> P5
+
+
+ %% 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
+
+
+ %% Stadium shape
+flowchart TD
+ A([stadium shape test])
+ A -->|Get money| B([Go shopping])
+ B --> C([Let me think... Do I want something for work, something to spend every free second with, or something to get around?])
+ C -->|One| D([Laptop])
+ C -->|Two| E([iPhone])
+ C -->|Three| F([Car 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;
+
+
+
+ %% should render escaped without html labels
+flowchart TD
+ a["Haiya "]---->b
+
+
+ %% nested subgraphs in reverse order
+flowchart LR
+ a -->b
+ subgraph A
+ B
+ end
+ subgraph B
+ b
+ end
+
+
+ %% 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
+
+
+ %% 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
+
+
+ %% nested subgraphs with outgoing links
+flowchart LR
+ subgraph main
+ subgraph subcontainer
+ subcontainer-child
+ end
+ subcontainer-child--> subcontainer-sibling
+ end
+
+
+ %% 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
+
+
+ %% 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
+
+
+ %% nested subgraphs with outgoing links 4
+flowchart LR
+subgraph A
+a -->b
+end
+subgraph B
+b
+end
+
+
+ %% 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
+
+
+ %% 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
+
+
+ %% More subgraphs
+ flowchart LR
+ subgraph two
+ b1
+ end
+ subgraph three
+ c2
+ end
+
+ three --> two
+ two --> c2
+ note[There are two links in this diagram]
+
+
+ %% 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;
+
+
+ %% labels on edges in a cluster
+flowchart RL
+ subgraph one
+ a1 -- I am a long label --> a2
+ a1 -- Another long label --> a2
+ end
+
+
+ %% 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
+
+
+ %% 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
+
+
+ %% 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
+
+
+
+ flowchart
+ subgraph Z
+ subgraph X
+ a --> b
+ end
+ subgraph Y
+ c --> d
+ end
+ end
+ Y --> X
+ X --> P
+ P --> Y
+
+
+
+ flowchart
+
+ a --> b
+ b --> c
+ b --> d
+ c --> a
+
+
+
+
+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])
+
+
+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])
+
+
+
+classDiagram
+class Controller {
+ +handleRequest(): void
+}
+
+class View {
+ +render(): void
+}
+
+class Model {
+ +getData(): any
+ +setData(data: any): void
+}
+
+Controller --> Model
+Controller --> View
+Model --> View : notifyChange()
+
+
+
+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
+
+
+
+
+
diff --git a/packages/mermaid/src/rendering-util/createGraph.ts b/packages/mermaid/src/rendering-util/createGraph.ts
new file mode 100644
index 000000000..f4ab21aa4
--- /dev/null
+++ b/packages/mermaid/src/rendering-util/createGraph.ts
@@ -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 = 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;
+ edgePaths: D3Selection;
+ edgeLabels: D3Selection;
+ nodes: D3Selection;
+ rootGroups: D3Selection;
+ };
+ nodeElements: Map>;
+}> {
+ 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>();
+
+ // 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);
+ 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);
+ 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,
+ };
+}
diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/adjustLayout.ts b/packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/adjustLayout.ts
new file mode 100644
index 000000000..02ebb3260
--- /dev/null
+++ b/packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/adjustLayout.ts
@@ -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;
+ rootGroups: D3Selection;
+ [key: string]: D3Selection;
+ }
+): Promise {
+ 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
+ );
+ });
+}
diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/applyCola.ts b/packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/applyCola.ts
new file mode 100644
index 000000000..f8fdf54e6
--- /dev/null
+++ b/packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/applyCola.ts
@@ -0,0 +1,1602 @@
+import type { Point } from '../../../types.js';
+import type { Edge, LayoutData, Node } from '../../types.js';
+
+interface ColaOptions {
+ iterations?: number;
+ springLength?: number;
+ springStrength?: number;
+ repulsionStrength?: number;
+ groupAttraction?: number;
+ groupPadding?: number;
+}
+interface GroupBounds {
+ minX: number;
+ minY: number;
+ maxX: number;
+ maxY: number;
+ width: number;
+ height: number;
+ centerX: number;
+ centerY: number;
+}
+export function applyCola(
+ {
+ iterations = 50,
+ springLength = 60,
+ springStrength = 0.2,
+ repulsionStrength = 1500,
+ groupAttraction = 0.01,
+ groupPadding = 15,
+ }: ColaOptions = {},
+ data4Layout: LayoutData
+) {
+ const nodes = data4Layout.nodes;
+ const edges = data4Layout.edges;
+
+ const nodeMap = new Map(nodes.map((node) => [node.id, node]));
+
+ nodes.forEach((node) => {
+ node.x ??= Math.random() * 100;
+ node.y ??= Math.random() * 100;
+ });
+
+ for (let iter = 0; iter < iterations; iter++) {
+ const coolingFactor = 1.2 - (iter / iterations) * 0.8;
+ const displacements: Record = {};
+ const groupCenters = new Map();
+
+ nodes.forEach((node) => {
+ displacements[node.id] = { x: 0, y: 0 };
+
+ if (node.parentId) {
+ const group = groupCenters.get(node.parentId) ?? { x: 0, y: 0, count: 0 };
+ group.x += node.x!;
+ group.y += node.y!;
+ group.count++;
+ groupCenters.set(node.parentId, group);
+ }
+ });
+
+ groupCenters.forEach((group) => {
+ group.x /= group.count;
+ group.y /= group.count;
+ });
+
+ repelNodes(nodes, displacements, coolingFactor, repulsionStrength);
+ defineEdgeLengths(edges, nodeMap, springLength, springStrength, displacements);
+ nodePositioningAfterOperations(
+ nodes,
+ groupCenters,
+ edges,
+ groupAttraction,
+ displacements,
+ coolingFactor
+ );
+ if (iter % 5 === 0) {
+ resolveOverlaps(nodes);
+ }
+ }
+
+ resolveOverlaps(nodes);
+ layoutGroups(data4Layout, groupPadding, nodeMap);
+ resolveGroupOverlaps(data4Layout, groupPadding * 2, nodeMap);
+ adjustEdges(data4Layout, nodeMap);
+ ensureEdgeLabelsInGroups(data4Layout, nodeMap);
+ layoutGroups(data4Layout, groupPadding, nodeMap);
+}
+
+function repelNodes(
+ nodes: Node[],
+ displacements: Record,
+ coolingFactor: number,
+ repulsionStrength: number
+) {
+ for (let i = 0; i < nodes.length; i++) {
+ const n1 = nodes[i];
+
+ const n1Width = n1.width ?? 30;
+ const n1Height = n1.height ?? 30;
+ const n1Radius = Math.max(n1Width, n1Height) / 2;
+
+ for (let j = i + 1; j < nodes.length; j++) {
+ const n2 = nodes[j];
+
+ const n2Width = n2.width ?? 30;
+ const n2Height = n2.height ?? 30;
+ const n2Radius = Math.max(n2Width, n2Height) / 2;
+
+ const pos1 = getPositionForNode(n1);
+ const pos2 = getPositionForNode(n2);
+ const dx = pos1.x - pos2.x;
+ const dy = pos1.y - pos2.y;
+ const dist = Math.sqrt(dx * dx + dy * dy) + 0.01;
+
+ const minDist = n1Radius + n2Radius;
+
+ if (dist < minDist) {
+ const overlap = minDist - dist;
+ const force = overlap * 5 * coolingFactor;
+
+ displacements[n1.id].x += (dx / dist) * force;
+ displacements[n1.id].y += (dy / dist) * force;
+ displacements[n2.id].x -= (dx / dist) * force;
+ displacements[n2.id].y -= (dy / dist) * force;
+ } else {
+ const force = (repulsionStrength / (dist * dist)) * 1.1;
+ displacements[n1.id].x += (dx / dist) * force;
+ displacements[n1.id].y += (dy / dist) * force;
+ displacements[n2.id].x -= (dx / dist) * force;
+ displacements[n2.id].y -= (dy / dist) * force;
+ }
+ }
+ }
+}
+
+function defineEdgeLengths(
+ edges: Edge[],
+ nodeMap: Map,
+ springLength: number,
+ springStrength: number,
+ displacements: Record
+) {
+ edges.forEach((edge: Edge) => {
+ if (!edge.start || !edge.end) {
+ return;
+ }
+
+ const startNode = nodeMap.get(edge.start);
+ const endNode = nodeMap.get(edge.end);
+ if (!startNode || !endNode) {
+ return;
+ }
+
+ const startWidth = startNode.width ?? 30;
+ const startHeight = startNode.height ?? 30;
+ const startRadius = Math.max(startWidth, startHeight) / 2;
+
+ const endWidth = endNode.width ?? 30;
+ const endHeight = endNode.height ?? 30;
+ const endRadius = Math.max(endWidth, endHeight) / 2;
+
+ const pos1 = getPositionForNode(startNode);
+ const pos2 = getPositionForNode(endNode);
+ const dx = pos2.x - pos1.x;
+ const dy = pos2.y - pos1.y;
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
+
+ let baseSpring = springLength;
+ if (edge.isLabelEdge) {
+ baseSpring = baseSpring * -0.09;
+ }
+
+ const adjustedSpringLength = baseSpring + startRadius + endRadius;
+ const delta = dist - adjustedSpringLength;
+ const force = springStrength * delta;
+
+ displacements[startNode.id].x += (dx / dist) * force;
+ displacements[startNode.id].y += (dy / dist) * force;
+ displacements[endNode.id].x -= (dx / dist) * force;
+ displacements[endNode.id].y -= (dy / dist) * force;
+ });
+}
+
+function nodePositioningAfterOperations(
+ nodes: Node[],
+ groupCenters: Map,
+ edges: Edge[],
+ groupAttraction: number,
+ displacements: Record,
+ coolingFactor: number
+) {
+ nodes.forEach((node) => {
+ if (!node.isGroup && node.parentId) {
+ let flag = false;
+ const groupCenter = groupCenters.get(node.parentId);
+ if (!groupCenter) {
+ return;
+ }
+
+ edges.forEach((edge) => {
+ if (node.id == edge.start || node.id == edge.end) {
+ flag = true;
+ }
+ });
+
+ if (flag) {
+ const pos = getPositionForNode(node);
+ const dx = groupCenter.x - pos.x;
+ const dy = groupCenter.y - pos.y;
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
+
+ const force = groupAttraction * dist * 5; //* (dist > 100 ? 2 : 1);
+ displacements[node.id].x += (dx / dist) * force;
+ displacements[node.id].y += (dy / dist) * force;
+ }
+ } else {
+ const pos = getPositionForNode(node);
+ const dx = displacements[node.id]?.x || 0;
+ const dy = displacements[node.id]?.y || 0;
+
+ const maxStep = 10 * coolingFactor;
+ const moveX = Math.max(-maxStep, Math.min(maxStep, dx * coolingFactor));
+ const moveY = Math.max(-maxStep, Math.min(maxStep, dy * coolingFactor));
+
+ node.x = pos.x + moveX;
+ node.y = pos.y + moveY;
+ }
+ });
+}
+
+function ensureEdgeLabelsInGroups(data4Layout: LayoutData, nodeMap: Map) {
+ const edgeLabels = data4Layout.nodes.filter((node) => node.isEdgeLabel);
+
+ edgeLabels.forEach((label) => {
+ if (!label.edgeStart || !label.edgeEnd) {
+ return;
+ }
+
+ const startNode = nodeMap.get(label.edgeStart);
+ const endNode = nodeMap.get(label.edgeEnd);
+
+ if (!startNode || !endNode) {
+ return;
+ }
+
+ if (startNode.parentId && startNode.parentId === endNode.parentId) {
+ label.parentId = startNode.parentId;
+
+ const parentGroup = nodeMap.get(startNode.parentId);
+ if (!parentGroup?.isGroup) {
+ return;
+ }
+
+ const parentHalfWidth = (parentGroup.width || 100) / 2;
+ const parentHalfHeight = (parentGroup.height || 100) / 2;
+
+ const dx = Math.abs(label.x! - parentGroup.x!);
+ const dy = Math.abs(label.y! - parentGroup.y!);
+ const labelHalfWidth = (label.width || 0) / 2;
+ const labelHalfHeight = (label.height || 0) / 2;
+
+ if (dx + labelHalfWidth > parentHalfWidth || dy + labelHalfHeight > parentHalfHeight) {
+ const angle = Math.atan2(label.y! - parentGroup.y!, label.x! - parentGroup.x!);
+ const maxRadiusX = parentHalfWidth - labelHalfWidth - 10;
+ const maxRadiusY = parentHalfHeight - labelHalfHeight - 10;
+ const radius = Math.min(
+ maxRadiusX / Math.abs(Math.cos(angle) || 0.001),
+ maxRadiusY / Math.abs(Math.sin(angle) || 0.001)
+ );
+
+ label.x = parentGroup.x! + Math.cos(angle) * radius;
+ label.y = parentGroup.y! + Math.sin(angle) * radius;
+
+ data4Layout.edges.forEach((edge) => {
+ if (
+ ((edge.start === label.edgeStart && edge.end === label.id) ||
+ (edge.start === label.id && edge.end === label.edgeEnd)) &&
+ edge.points &&
+ edge.points.length > 0
+ ) {
+ if (edge.start === label.id) {
+ edge.points[0] = { x: label.x!, y: label.y! };
+ } else if (edge.end === label.id) {
+ edge.points[edge.points.length - 1] = { x: label.x!, y: label.y! };
+ }
+ }
+ });
+ }
+ }
+ });
+}
+
+function resolveOverlaps(nodes: Node[]) {
+ const nonGroupNodes = nodes.filter((n) => !n.isGroup);
+ const maxIterations = 10;
+
+ for (let iteration = 0; iteration < maxIterations; iteration++) {
+ let hasOverlaps = false;
+
+ for (let i = 0; i < nonGroupNodes.length; i++) {
+ const n1 = nonGroupNodes[i];
+ const n1Width = n1.width ?? 30;
+ const n1Height = n1.height ?? 30;
+ const n1Radius = Math.sqrt(n1Width * n1Width + n1Height * n1Height) / 2 + 12;
+
+ for (let j = i + 1; j < nonGroupNodes.length; j++) {
+ const n2 = nonGroupNodes[j];
+ const n2Width = n2.width ?? 30;
+ const n2Height = n2.height ?? 30;
+ const n2Radius = Math.sqrt(n2Width * n2Width + n2Height * n2Height) / 2 + 12;
+
+ const dx = n2.x! - n1.x!;
+ const dy = n2.y! - n1.y!;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ const minDist = n1Radius + n2Radius;
+
+ if (dist < minDist && dist > 0) {
+ hasOverlaps = true;
+ const overlap = minDist - dist;
+ const moveX = (dx / dist) * overlap * 0.25;
+ const moveY = (dy / dist) * overlap * 0.25;
+
+ n1.x! -= moveX;
+ n1.y! -= moveY;
+ n2.x! += moveX;
+ n2.y! += moveY;
+ }
+ }
+ }
+
+ if (!hasOverlaps) {
+ break;
+ }
+ }
+}
+
+interface LayoutGroupOptions {
+ groupPadding?: number;
+ minHorizontalSpacing?: number;
+ maxIterations?: number;
+ coolingFactor?: number;
+}
+
+function layoutGroups(
+ data4Layout: LayoutData,
+ options: number | LayoutGroupOptions = {},
+ nodeMap: Map
+): void {
+ const config: LayoutGroupOptions =
+ typeof options === 'number' ? { groupPadding: options } : options;
+
+ const { groupPadding = 15, maxIterations = 50, coolingFactor = 1 } = config;
+
+ const groupHierarchy = new Map();
+ const rootGroups: string[] = [];
+
+ data4Layout.nodes.forEach((node) => {
+ if (node.isGroup) {
+ if (!node.parentId) {
+ rootGroups.push(node.id);
+ } else {
+ if (!groupHierarchy.has(node.parentId)) {
+ groupHierarchy.set(node.parentId, []);
+ }
+ groupHierarchy.get(node.parentId)!.push(node.id);
+ }
+ }
+ });
+
+ function calculateGroupBounds(
+ groupNode: Node,
+ children: Node[],
+ edges: Edge[],
+ nodeMap: Map,
+ data4Layout: LayoutData
+ ) {
+ if (children.length === 0) {
+ return {
+ minX: groupNode.x! - groupPadding,
+ minY: groupNode.y! - groupPadding,
+ maxX: groupNode.x! + groupPadding,
+ maxY: groupNode.y! + groupPadding,
+ };
+ }
+
+ const bounds = {
+ minX: Number.POSITIVE_INFINITY,
+ minY: Number.POSITIVE_INFINITY,
+ maxX: Number.NEGATIVE_INFINITY,
+ maxY: Number.NEGATIVE_INFINITY,
+ };
+
+ children.forEach((child) => {
+ const width = child.width ?? (child.isGroup ? 100 : 30);
+ const height = child.height ?? (child.isGroup ? 100 : 30);
+
+ bounds.minX = Math.min(bounds.minX, child.x! - width / 2);
+ bounds.minY = Math.min(bounds.minY, child.y! - height / 2);
+ bounds.maxX = Math.max(bounds.maxX, child.x! + width / 2);
+ bounds.maxY = Math.max(bounds.maxY, child.y! + height / 2);
+ });
+
+ const subgroups = children.filter((c) => c.isGroup);
+ if (subgroups.length > 1) {
+ const xCoords = subgroups.map((g) => g.x!);
+ const yCoords = subgroups.map((g) => g.y!);
+
+ const yVariance = Math.max(...yCoords) - Math.min(...yCoords);
+ if (yVariance < 50) {
+ const sortedGroups = subgroups.sort((a, b) => a.x! - b.x!);
+
+ let totalSubgroupWidth = 0;
+ let prevRight = sortedGroups[0].x! - sortedGroups[0].width! / 2;
+
+ sortedGroups.forEach((group) => {
+ const left = group.x! - group.width! / 2;
+ totalSubgroupWidth += Math.max(0, left - prevRight) + group.width!;
+ prevRight = group.x! + group.width! / 2;
+ });
+
+ const currentWidth = bounds.maxX - bounds.minX;
+ if (totalSubgroupWidth > currentWidth) {
+ const centerX = (Math.min(...xCoords) + Math.max(...xCoords)) / 2;
+ bounds.minX = centerX - totalSubgroupWidth / 2;
+ bounds.maxX = centerX + totalSubgroupWidth / 2;
+ }
+ }
+ }
+
+ data4Layout.nodes.forEach((node) => {
+ if (node.isEdgeLabel && node.edgeStart && node.edgeEnd) {
+ const startNode = nodeMap.get(node.edgeStart);
+ const endNode = nodeMap.get(node.edgeEnd);
+
+ if (startNode?.parentId === groupNode.id && endNode?.parentId === groupNode.id) {
+ const width = node.width ?? 40;
+ const height = node.height ?? 20;
+
+ bounds.minX = Math.min(bounds.minX, node.x! - width / 2);
+ bounds.minY = Math.min(bounds.minY, node.y! - height / 2);
+ bounds.maxX = Math.max(bounds.maxX, node.x! + width / 2);
+ bounds.maxY = Math.max(bounds.maxY, node.y! + height / 2);
+ }
+ }
+ });
+
+ edges.forEach((edge: Edge) => {
+ if (
+ edge.points &&
+ nodeMap.get(edge?.start || '')?.parentId === groupNode.id &&
+ nodeMap.get(edge.end || '')?.parentId === groupNode.id
+ ) {
+ edge.points.forEach((point) => {
+ bounds.minX = Math.min(bounds.minX, point.x);
+ bounds.minY = Math.min(bounds.minY, point.y);
+ bounds.maxX = Math.max(bounds.maxX, point.x);
+ bounds.maxY = Math.max(bounds.maxY, point.y);
+ });
+ }
+ });
+
+ bounds.minX -= groupPadding;
+ bounds.minY -= groupPadding;
+ bounds.maxX += groupPadding;
+ bounds.maxY += groupPadding;
+
+ const minWidth = groupPadding * 2;
+ const minHeight = groupPadding * 2;
+
+ if (bounds.maxX - bounds.minX < minWidth) {
+ const centerX = (bounds.minX + bounds.maxX) / 2;
+ bounds.minX = centerX - minWidth / 2;
+ bounds.maxX = centerX + minWidth / 2;
+ }
+
+ if (bounds.maxY - bounds.minY < minHeight) {
+ const centerY = (bounds.minY + bounds.maxY) / 2;
+ bounds.minY = centerY - minHeight / 2;
+ bounds.maxY = centerY + minHeight / 2;
+ }
+
+ return bounds;
+ }
+
+ function processGroupHierarchy(groupIds: string[]) {
+ groupIds.forEach((groupId) => {
+ const childGroups = groupHierarchy.get(groupId) || [];
+ if (childGroups.length > 0) {
+ processGroupHierarchy(childGroups);
+ }
+ });
+
+ groupIds.forEach((groupId) => {
+ const groupNode = nodeMap.get(groupId);
+ if (!groupNode) {
+ return;
+ }
+
+ const directChildren = data4Layout.nodes.filter((n) => n.parentId === groupId);
+
+ if (directChildren.length === 0) {
+ groupNode.width = groupPadding * 4;
+ groupNode.height = groupPadding * 4;
+ return;
+ }
+
+ const bounds = calculateGroupBounds(
+ groupNode,
+ directChildren,
+ data4Layout.edges,
+ nodeMap,
+ data4Layout
+ );
+
+ groupNode.x = (bounds.minX + bounds.maxX) / 2;
+ groupNode.y = (bounds.minY + bounds.maxY) / 2;
+ groupNode.width = bounds.maxX - bounds.minX + groupPadding * 2;
+ groupNode.height = bounds.maxY - bounds.minY + groupPadding * 2;
+ });
+ }
+
+ processGroupHierarchy(rootGroups);
+
+ for (let iter = 0; iter < maxIterations; iter++) {
+ const cooling = 1 - (iter / maxIterations) * coolingFactor;
+ const displacements = new Map();
+
+ data4Layout.nodes
+ .filter((n) => n.isGroup)
+ .forEach((g) => displacements.set(g.id, { x: 0, y: 0 }));
+
+ const groups = data4Layout.nodes.filter((n) => n.isGroup);
+ for (let i = 0; i < groups.length; i++) {
+ const g1 = groups[i];
+ for (let j = i + 1; j < groups.length; j++) {
+ const g2 = groups[j];
+
+ if (g1.parentId !== g2.parentId) {
+ continue;
+ }
+
+ const dx = g2.x! - g1.x!;
+ const dy = g2.y! - g1.y!;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ const minDist = (g1.width! + g2.width!) / 2 + groupPadding;
+
+ if (dist < minDist) {
+ const force = (minDist - dist) * 0.1 * cooling;
+ const disp1 = displacements.get(g1.id)!;
+ const disp2 = displacements.get(g2.id)!;
+
+ disp1.x -= (dx / dist) * force;
+ disp1.y -= (dy / dist) * force;
+ disp2.x += (dx / dist) * force;
+ disp2.y += (dy / dist) * force;
+ }
+ }
+ }
+
+ groups.forEach((g) => {
+ const disp = displacements.get(g.id)!;
+
+ if (g.parentId) {
+ const parent = nodeMap.get(g.parentId);
+ if (parent?.isGroup) {
+ const parentHalfWidth = parent.width! / 2 - groupPadding;
+ const parentHalfHeight = parent.height! / 2 - groupPadding;
+ const childHalfWidth = g.width! / 2;
+ const childHalfHeight = g.height! / 2;
+
+ const maxDx = parentHalfWidth - childHalfWidth;
+ const maxDy = parentHalfHeight - childHalfHeight;
+
+ const currentDx = g.x! + disp.x - parent.x!;
+ const currentDy = g.y! + disp.y - parent.y!;
+
+ if (Math.abs(currentDx) > maxDx) {
+ disp.x = Math.sign(currentDx) * maxDx + parent.x! - g.x!;
+ }
+
+ if (Math.abs(currentDy) > maxDy) {
+ disp.y = Math.sign(currentDy) * maxDy + parent.y! - g.y!;
+ }
+ }
+ }
+
+ g.x! += disp.x;
+ g.y! += disp.y;
+
+ data4Layout.nodes
+ .filter((n) => n.parentId === g.id && !n.isGroup)
+ .forEach((child) => {
+ child.x! += disp.x;
+ child.y! += disp.y;
+ });
+ });
+ }
+
+ processGroupHierarchy(rootGroups);
+}
+
+function getPositionForNode(node: Node): { x: number; y: number } {
+ return { x: node.x ?? 0, y: node.y ?? 0 };
+}
+
+function adjustEdges(data4Layout: LayoutData, nodeMap: Map) {
+ const edgeCountMap = new Map();
+ const edgeSeenMap = new Map();
+
+ data4Layout.edges.forEach((edge) => {
+ const sortedPair = [edge.start, edge.end].sort();
+ const edgeKey = sortedPair.join('--');
+ edgeCountMap.set(edgeKey, (edgeCountMap.get(edgeKey) || 0) + 1);
+ });
+
+ data4Layout.edges.forEach((edge: Edge, index) => {
+ if (!edge.start || !edge.end) {
+ return;
+ }
+
+ const startNode = nodeMap.get(edge.start);
+ const endNode = nodeMap.get(edge.end);
+ if (!startNode || !endNode || (!startNode.isGroup && !endNode.isGroup)) {
+ return;
+ }
+
+ edge.id = `${edge.start}--${edge.end}--${index}`;
+
+ if (startNode.x != null && startNode.y != null && endNode.x != null && endNode.y != null) {
+ const dx = endNode.x - startNode.x;
+ const dy = endNode.y - startNode.y;
+ const len = Math.sqrt(dx * dx + dy * dy);
+
+ const sortedPair = [edge.start, edge.end].sort();
+ const edgeKey = sortedPair.join('--');
+ const totalCount = edgeCountMap.get(edgeKey)!;
+ const seenCount = edgeSeenMap.get(edgeKey) || 0;
+ edgeSeenMap.set(edgeKey, seenCount + 1);
+ const hasIndirectPath = hasAlternativePath(edge.start, edge.end, data4Layout.edges);
+ const isParallelEdge = totalCount > 1;
+ const isForward = edge.start === sortedPair[0];
+
+ let curveOffset = 0;
+ if (isParallelEdge || hasIndirectPath || !isForward) {
+ let offsetIndex = seenCount - totalCount / 2;
+ if (!isForward) {
+ offsetIndex = -offsetIndex;
+ }
+
+ const isStartGroup = startNode.isGroup;
+ const isEndGroup = endNode.isGroup;
+ const hasValidParentIds =
+ (isStartGroup || startNode.parentId !== undefined) &&
+ (isEndGroup || endNode.parentId !== undefined);
+ if (
+ hasValidParentIds &&
+ (isStartGroup || isEndGroup) &&
+ (edge.start === edge.end || startNode.parentId !== endNode.parentId)
+ ) {
+ if (startNode.parentId !== endNode.parentId) {
+ curveOffset = len * 0.4 * (offsetIndex || 1);
+ } else {
+ curveOffset = len * 0.05 * (offsetIndex || 1);
+ }
+ }
+ // else {
+ // curveOffset = len * 0.01 * (offsetIndex || 1);
+ // }
+ }
+ const mx = (startNode.x + endNode.x) / 2;
+ const my = (startNode.y + endNode.y) / 2;
+ const offsetX = (-dy / len) * curveOffset;
+ const offsetY = (dx / len) * curveOffset;
+ const controlPoint = { x: mx + offsetX, y: my + offsetY };
+ const from = startNode.isGroup
+ ? intersectGroupBox(
+ { x: startNode.x, y: startNode.y },
+ startNode.width ?? 100,
+ startNode.height ?? 100,
+ {
+ x: endNode.x,
+ y: endNode.y,
+ }
+ )
+ : (startNode.intersect?.(controlPoint) ?? { x: startNode.x, y: startNode.y });
+ const to = endNode.isGroup
+ ? intersectGroupBox(
+ { x: endNode.x, y: endNode.y },
+ endNode.width ?? 100,
+ endNode.height ?? 100,
+ {
+ x: startNode.x,
+ y: startNode.y,
+ }
+ )
+ : (endNode.intersect?.(controlPoint) ?? { x: endNode.x, y: endNode.y });
+ edge.points = curveOffset !== 0 ? [from, controlPoint, to] : [from, to];
+ }
+ });
+
+ data4Layout.edges.forEach((edge, index) => {
+ if (!edge.start || !edge.end || edge.start === edge.end) {
+ return;
+ }
+
+ const startNode = nodeMap.get(edge.start);
+ const endNode = nodeMap.get(edge.end);
+ if (!startNode || !endNode || startNode.isGroup || endNode.isGroup) {
+ return;
+ }
+
+ edge.id = `${edge.start}--${edge.end}--${index}`;
+
+ if (startNode.x != null && startNode.y != null && endNode.x != null && endNode.y != null) {
+ let points = calculateInitialEdgePath(
+ startNode,
+ endNode,
+ edge,
+ edgeCountMap,
+ edgeSeenMap,
+ data4Layout
+ );
+
+ const overlappingNodes = findNodesIntersectingEdge(
+ points,
+ data4Layout.nodes,
+ edge.start,
+ edge.end
+ );
+
+ if (overlappingNodes.length > 0) {
+ points = adjustPathToAvoidNodes(points, overlappingNodes, startNode, endNode);
+ }
+
+ edge.points = points;
+ }
+ });
+
+ data4Layout.edges.forEach((edge, index) => {
+ if (!edge.start || !edge.end || edge.start !== edge.end) {
+ return;
+ }
+ const startNode = nodeMap.get(edge.start);
+ const endNode = nodeMap.get(edge.end);
+ if (!startNode || !endNode) {
+ return;
+ }
+
+ edge.id = `${edge.start}--${edge.end}--${index}`;
+
+ if (startNode.x != null && startNode.y != null && endNode.x != null && endNode.y != null) {
+ const loopWidth = (startNode.width || 20) / 4;
+ const center = { x: startNode.x, y: startNode.y };
+
+ const directions = getDirectionUsage(data4Layout.edges, startNode, edge, nodeMap);
+ const directionObject = chooseFreeDirection(directions);
+ const { start, cp1, cp2, end } = getSelfLoopPoints(
+ directionObject.direction,
+ center,
+ startNode,
+ loopWidth,
+ directionObject.count
+ );
+
+ edge.points = [start, cp1, cp2, end];
+ }
+ });
+}
+
+function calculateInitialEdgePath(
+ startNode: Node,
+ endNode: Node,
+ edge: Edge,
+ edgeCountMap: Map,
+ edgeSeenMap: Map,
+ data4Layout: LayoutData
+): { x: number; y: number }[] {
+ let points: { x: number; y: number }[] = [];
+ if (!edge.start || !edge.end) {
+ return points;
+ }
+ if (startNode?.x == null || startNode?.y == null || endNode?.x == null || endNode?.y == null) {
+ return points;
+ }
+ const dx = endNode.x - startNode.x;
+ const dy = endNode.y - startNode.y;
+ const len = Math.sqrt(dx * dx + dy * dy);
+
+ const sortedPair = [edge.start, edge.end].sort();
+ const edgeKey = sortedPair.join('--');
+ const totalCount = edgeCountMap.get(edgeKey)!;
+ const seenCount = edgeSeenMap.get(edgeKey) || 0;
+ edgeSeenMap.set(edgeKey, seenCount + 1);
+
+ const hasIndirectPath = hasAlternativePath(edge.start, edge.end, data4Layout.edges);
+ const isParallelEdge = totalCount > 1;
+ const isForward = edge.start === sortedPair[0];
+
+ let curveOffset = 0;
+
+ if (isParallelEdge || hasIndirectPath || !isForward) {
+ let offsetIndex = seenCount - (totalCount - 1) / 2;
+ if (!isForward) {
+ offsetIndex = -offsetIndex;
+ }
+ curveOffset = len * 0.08 * offsetIndex;
+ }
+
+ const mx = (startNode.x + endNode.x) / 2;
+ const my = (startNode.y + endNode.y) / 2;
+ const offsetX = (-dy / len) * curveOffset;
+ const offsetY = (dx / len) * curveOffset;
+ const controlPoint = { x: mx + offsetX, y: my + offsetY };
+
+ const from = startNode.isEdgeLabel
+ ? { x: startNode.x, y: startNode.y }
+ : getShapeIntersection(startNode, controlPoint);
+ const to = endNode.isGroup
+ ? intersectGroupBox(
+ { x: endNode.x, y: endNode.y },
+ endNode.width ?? 100,
+ endNode.height ?? 100,
+ {
+ x: startNode.x,
+ y: startNode.y,
+ }
+ )
+ : (endNode.intersect?.(controlPoint) ?? { x: endNode.x, y: endNode.y });
+
+ points = curveOffset !== 0 ? [from, controlPoint, to] : [from, to];
+
+ return points;
+}
+
+function findNodesIntersectingEdge(
+ edgePoints: { x: number; y: number }[],
+ nodes: Node[],
+ startNodeId: string,
+ endNodeId: string
+): Node[] {
+ const intersectingNodes: Node[] = [];
+
+ if (edgePoints.length < 2) {
+ return intersectingNodes;
+ }
+
+ const segments: { start: Point; end: Point }[] = [];
+ for (let i = 0; i < edgePoints.length - 1; i++) {
+ segments.push({
+ start: edgePoints[i],
+ end: edgePoints[i + 1],
+ });
+ }
+
+ for (const node of nodes) {
+ if (node.id === startNodeId || node.id === endNodeId || node.isGroup) {
+ continue;
+ }
+
+ if (node.x == null || node.y == null) {
+ continue;
+ }
+
+ const nodeBox = {
+ x: node.x,
+ y: node.y,
+ width: node.width || 30,
+ height: node.height || 30,
+ padding: 10,
+ };
+
+ for (const segment of segments) {
+ if (lineIntersectsNodeBox(segment.start, segment.end, nodeBox)) {
+ intersectingNodes.push(node);
+ break;
+ }
+ }
+ }
+
+ return intersectingNodes;
+}
+
+function lineIntersectsNodeBox(
+ lineStart: { x: number; y: number },
+ lineEnd: { x: number; y: number },
+ nodeBox: any
+): boolean {
+ const box = {
+ left: nodeBox.x - nodeBox.width / 2 - nodeBox.padding,
+ right: nodeBox.x + nodeBox.width / 2 + nodeBox.padding,
+ top: nodeBox.y - nodeBox.height / 2 - nodeBox.padding,
+ bottom: nodeBox.y + nodeBox.height / 2 + nodeBox.padding,
+ };
+
+ return lineIntersectsRectangle(lineStart, lineEnd, box);
+}
+
+function lineIntersectsRectangle(
+ p1: { x: number; y: number },
+ p2: { x: number; y: number },
+ rect: any
+): boolean {
+ if (pointInRectangle(p1, rect) || pointInRectangle(p2, rect)) {
+ return true;
+ }
+
+ const edges = [
+ [
+ { x: rect.left, y: rect.top },
+ { x: rect.right, y: rect.top },
+ ],
+ [
+ { x: rect.right, y: rect.top },
+ { x: rect.right, y: rect.bottom },
+ ],
+ [
+ { x: rect.right, y: rect.bottom },
+ { x: rect.left, y: rect.bottom },
+ ],
+ [
+ { x: rect.left, y: rect.bottom },
+ { x: rect.left, y: rect.top },
+ ],
+ ];
+
+ for (const [edgeStart, edgeEnd] of edges) {
+ if (lineSegmentsIntersect(p1, p2, edgeStart, edgeEnd)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+function pointInRectangle(point: any, rect: any): boolean {
+ return (
+ point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom
+ );
+}
+
+function lineSegmentsIntersect(p1: any, p2: any, p3: any, p4: any): boolean {
+ const denom = (p4.y - p3.y) * (p2.x - p1.x) - (p4.x - p3.x) * (p2.y - p1.y);
+
+ if (denom === 0) {
+ return false;
+ }
+
+ const ua = ((p4.x - p3.x) * (p1.y - p3.y) - (p4.y - p3.y) * (p1.x - p3.x)) / denom;
+ const ub = ((p2.x - p1.x) * (p1.y - p3.y) - (p2.y - p1.y) * (p1.x - p3.x)) / denom;
+
+ return ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1;
+}
+
+function adjustPathToAvoidNodes(
+ points: { x: number; y: number }[],
+ overlappingNodes: Node[],
+ startNode: Node,
+ endNode: Node
+): { x: number; y: number }[] {
+ if (overlappingNodes.length === 0) {
+ return points;
+ }
+
+ let adjustedPoints = [...points];
+
+ for (const node of overlappingNodes) {
+ adjustedPoints = createDetourAroundNode(adjustedPoints, node, startNode, endNode);
+ }
+
+ return adjustedPoints;
+}
+
+function createDetourAroundNode(
+ points: { x: number; y: number }[],
+ obstructingNode: Node,
+ startNode: Node,
+ endNode: Node
+): any[] {
+ let newPoints: { x: number; y: number }[] = [];
+ if (
+ !startNode.x ||
+ !endNode.x ||
+ !obstructingNode.x ||
+ !startNode.y ||
+ !endNode.y ||
+ !obstructingNode.y
+ ) {
+ return newPoints;
+ }
+
+ const nodeRadius = Math.max(obstructingNode.width ?? 30, obstructingNode.height ?? 30) / 2;
+ const detourDistance = nodeRadius + 30;
+
+ const mainDirection = {
+ x: endNode.x - startNode.x,
+ y: endNode.y - startNode.y,
+ };
+ const mainLength = Math.sqrt(
+ mainDirection.x * mainDirection.x + mainDirection.y * mainDirection.y
+ );
+
+ mainDirection.x /= mainLength;
+ mainDirection.y /= mainLength;
+
+ const perpDirection = {
+ x: -mainDirection.y,
+ y: mainDirection.x,
+ };
+
+ const nodeToStart = {
+ x: startNode.x - obstructingNode.x,
+ y: startNode.y - obstructingNode.y,
+ };
+
+ const crossProduct = nodeToStart.x * perpDirection.x + nodeToStart.y * perpDirection.y;
+ const sideMultiplier = crossProduct > 0 ? 1 : -1;
+
+ const detourPoint1 = {
+ x: obstructingNode.x + perpDirection.x * detourDistance * sideMultiplier,
+ y: obstructingNode.y + perpDirection.y * detourDistance * sideMultiplier,
+ };
+
+ const detourPoint2 = {
+ x:
+ obstructingNode.x +
+ perpDirection.x * detourDistance * sideMultiplier +
+ mainDirection.x * nodeRadius,
+ y:
+ obstructingNode.y +
+ perpDirection.y * detourDistance * sideMultiplier +
+ mainDirection.y * nodeRadius,
+ };
+
+ if (points.length === 2) {
+ return [points[0], detourPoint1, points[1]];
+ }
+
+ if (points.length === 3) {
+ return [points[0], detourPoint1, points[2]];
+ }
+
+ const midIndex = Math.floor(points.length / 2);
+ newPoints = [...points];
+ newPoints.splice(midIndex, 0, detourPoint1, detourPoint2);
+
+ return newPoints;
+}
+
+function getShapeIntersection(node: Node, externalPoint: { x: number; y: number }) {
+ const width = node.width ?? 30;
+ const height = node.height ?? 30;
+ const shape = node.shape ?? 'rectangle';
+ const centerX = node.x ?? 0;
+ const centerY = node.y ?? 0;
+
+ const padding = 1.5;
+ const paddedWidth = shape === 'diamond' ? width / 4 : width / 2 + padding;
+ const paddedHeight = shape === 'diamond' ? height / 4 : height / 2 + padding;
+
+ const angle = Math.atan2(externalPoint.y - centerY, externalPoint.x - centerX);
+ if (shape === 'rectangle') {
+ const aspectRatio = height / width;
+ const halfWidth = width / 2 + padding;
+ const halfHeight = height / 2 + padding;
+
+ if (aspectRatio > 2) {
+ const verticalIntersect = {
+ x: centerX + halfHeight * Math.tan(angle),
+ y: centerY + (angle > 0 ? halfHeight : -halfHeight),
+ };
+
+ if (Math.abs(Math.cos(angle)) > 0.5) {
+ const horizontalIntersect = {
+ x: centerX + (Math.cos(angle) > 0 ? halfWidth : -halfWidth),
+ y: centerY + halfWidth * Math.tan(angle),
+ };
+
+ return Math.abs(angle) < Math.PI / 4 ? horizontalIntersect : verticalIntersect;
+ }
+ return verticalIntersect;
+ }
+
+ const ratioX = halfWidth / Math.abs(Math.cos(angle));
+ const ratioY = halfHeight / Math.abs(Math.sin(angle));
+ const ratio = Math.min(ratioX, ratioY);
+
+ return {
+ x: centerX + ratio * Math.cos(angle),
+ y: centerY + ratio * Math.sin(angle),
+ };
+ }
+ if (shape === 'diamond') {
+ const tanTheta = Math.tan(angle);
+ const cotTheta = 1 / tanTheta;
+
+ if (angle >= -Math.PI / 4 && angle < Math.PI / 4) {
+ return {
+ x: centerX + paddedWidth,
+ y: centerY + paddedWidth * tanTheta,
+ };
+ } else if (angle >= Math.PI / 4 && angle < (3 * Math.PI) / 4) {
+ return {
+ x: centerX + paddedHeight * cotTheta,
+ y: centerY + paddedHeight,
+ };
+ } else if (angle >= (-3 * Math.PI) / 4 && angle < -Math.PI / 4) {
+ return {
+ x: centerX - paddedHeight * cotTheta,
+ y: centerY - paddedHeight,
+ };
+ } else {
+ return {
+ x: centerX - paddedWidth,
+ y: centerY - paddedWidth * tanTheta,
+ };
+ }
+ } else if (shape === 'circle') {
+ const radius = Math.min(width, height) / 2 + padding;
+ return {
+ x: centerX + radius * Math.cos(angle),
+ y: centerY + radius * Math.sin(angle),
+ };
+ } else {
+ const ratioX = paddedWidth / Math.abs(Math.cos(angle));
+ const ratioY = paddedHeight / Math.abs(Math.sin(angle));
+ const ratio = Math.min(ratioX, ratioY);
+
+ return {
+ x: centerX + ratio * Math.cos(angle),
+ y: centerY + ratio * Math.sin(angle),
+ };
+ }
+}
+
+function hasAlternativePath(start: string, end: string, edges: Edge[]): boolean {
+ return edges.some(
+ (e1) =>
+ e1.start === start &&
+ e1.end !== end &&
+ edges.some((e2) => e2.start === e1.end && e2.end === end)
+ );
+}
+
+function intersectGroupBox(
+ center: { x: number; y: number },
+ width: number,
+ height: number,
+ externalPoint: { x: number; y: number }
+) {
+ const dx = externalPoint.x - center.x;
+ const dy = externalPoint.y - center.y;
+ const absDx = Math.abs(dx);
+ const absDy = Math.abs(dy);
+ const halfW = width / 2;
+ const halfH = height / 2;
+
+ if (absDx * halfH > absDy * halfW) {
+ const sign = dx > 0 ? 1 : -1;
+ return { x: center.x + sign * halfW, y: center.y + (halfW * dy) / absDx };
+ } else {
+ const sign = dy > 0 ? 1 : -1;
+ return { x: center.x + (halfH * dx) / absDy, y: center.y + sign * halfH };
+ }
+}
+
+function getDirectionUsage(
+ edges: Edge[],
+ node: Node,
+ currentEdge: Edge,
+ nodeMap: Map
+) {
+ const usage = { top: 0, right: 0, bottom: 0, left: 0 };
+
+ edges.forEach((e: Edge) => {
+ if (e.start === currentEdge.start && e.end !== currentEdge.end) {
+ const target = nodeMap.get(e?.end || '');
+ if (target?.x != null && target.y != null) {
+ const angle = Math.atan2(target.y - node.y!, target.x - node.x!);
+ const deg = (angle * 180) / Math.PI;
+
+ if (deg > -45 && deg <= 45) {
+ usage.right++;
+ } else if (deg > 45 && deg <= 135) {
+ usage.bottom++;
+ } else if (deg <= -45 && deg > -135) {
+ usage.top++;
+ } else {
+ usage.left++;
+ }
+ }
+ } else if (e.start === currentEdge.start && e.end === currentEdge.start) {
+ const points = e.points;
+ if (points && points.length === 4) {
+ const cp1 = points[1];
+ const dx = cp1.x - node.x!;
+ const dy = cp1.y - node.y!;
+ const angle = Math.atan2(dy, dx);
+ const deg = (angle * 180) / Math.PI;
+
+ if (deg > -45 && deg <= 45) {
+ usage.right++;
+ } else if (deg > 45 && deg <= 135) {
+ usage.bottom++;
+ } else if (deg <= -45 && deg > -135) {
+ usage.top++;
+ } else {
+ usage.left++;
+ }
+ }
+ }
+ });
+
+ return usage;
+}
+
+function chooseFreeDirection(directions: Record): {
+ direction: string;
+ count: number;
+} {
+ const sorted = Object.entries(directions).sort((a, b) => a[1] - b[1]);
+ return {
+ direction: sorted[0][0],
+ count: sorted[0][1],
+ };
+}
+
+function getSelfLoopPoints(
+ direction: string,
+ center: { x: number; y: number },
+ node: Node,
+ width: number,
+ directionCount = 0
+) {
+ const nodeWidth = node.width ?? 50;
+ const nodeHeight = node.height ?? 40;
+ const radiusX = nodeWidth * 0.6;
+ const radiusY = nodeHeight * 0.6;
+ const cpFactor = 1;
+
+ const verticalOffset = width * 0.3 * directionCount;
+ const horizontalOffset = width * 0.3 * directionCount;
+ const radiusGrowth = 1 + directionCount * 0.25;
+
+ switch (direction) {
+ case 'right':
+ return {
+ start: {
+ x: center.x + nodeWidth / 2,
+ y: center.y - width + verticalOffset,
+ },
+ end: {
+ x: center.x + nodeWidth / 2,
+ y: center.y + width + verticalOffset,
+ },
+ cp1: {
+ x: center.x + nodeWidth / 2 + radiusX * radiusGrowth,
+ y: center.y - width * cpFactor + verticalOffset,
+ },
+ cp2: {
+ x: center.x + nodeWidth / 2 + radiusX * radiusGrowth,
+ y: center.y + width * cpFactor + verticalOffset,
+ },
+ };
+ case 'bottom':
+ return {
+ start: {
+ x: center.x - width + horizontalOffset,
+ y: center.y + nodeHeight / 2,
+ },
+ end: {
+ x: center.x + width + horizontalOffset,
+ y: center.y + nodeHeight / 2,
+ },
+ cp1: {
+ x: center.x - width * cpFactor + horizontalOffset,
+ y: center.y + nodeHeight / 2 + radiusY * radiusGrowth,
+ },
+ cp2: {
+ x: center.x + width * cpFactor + horizontalOffset,
+ y: center.y + nodeHeight / 2 + radiusY * radiusGrowth,
+ },
+ };
+ case 'left':
+ return {
+ start: {
+ x: center.x - nodeWidth / 2,
+ y: center.y + width - verticalOffset,
+ },
+ end: {
+ x: center.x - nodeWidth / 2,
+ y: center.y - width - verticalOffset,
+ },
+ cp1: {
+ x: center.x - nodeWidth / 2 - radiusX * radiusGrowth,
+ y: center.y + width * cpFactor - verticalOffset,
+ },
+ cp2: {
+ x: center.x - nodeWidth / 2 - radiusX * radiusGrowth,
+ y: center.y - width * cpFactor - verticalOffset,
+ },
+ };
+ default:
+ return {
+ start: {
+ x: center.x + width - horizontalOffset,
+ y: center.y - nodeHeight / 2,
+ },
+ end: {
+ x: center.x - width - horizontalOffset,
+ y: center.y - nodeHeight / 2,
+ },
+ cp1: {
+ x: center.x + width * cpFactor - horizontalOffset,
+ y: center.y - nodeHeight / 2 - radiusY * radiusGrowth,
+ },
+ cp2: {
+ x: center.x - width * cpFactor - horizontalOffset,
+ y: center.y - nodeHeight / 2 - radiusY * radiusGrowth,
+ },
+ };
+ }
+}
+
+function resolveGroupOverlaps(
+ data4Layout: LayoutData,
+ groupPadding: number,
+ nodeMap: Map
+): void {
+ const groupNodes = data4Layout.nodes.filter((n) => n.isGroup);
+
+ const groupBounds = new Map();
+
+ groupNodes.forEach((group) => {
+ const bounds = calculateGroupBounds(group, data4Layout, nodeMap, groupPadding);
+ groupBounds.set(group.id, bounds);
+ });
+
+ let maxIterations = 20;
+ let hasOverlaps = true;
+
+ while (hasOverlaps && maxIterations > 0) {
+ hasOverlaps = false;
+ maxIterations--;
+
+ for (let i = 0; i < groupNodes.length; i++) {
+ const group1 = groupNodes[i];
+ const bounds1 = groupBounds.get(group1.id)!;
+
+ for (let j = i + 1; j < groupNodes.length; j++) {
+ const group2 = groupNodes[j];
+ const bounds2 = groupBounds.get(group2.id)!;
+
+ if (group1.parentId === group2.id || group2.parentId === group1.id) {
+ continue;
+ }
+
+ const overlap = calculateOverlap(bounds1, bounds2);
+
+ if (overlap.hasOverlap) {
+ hasOverlaps = true;
+
+ const hasEdgesBetweenGroups = data4Layout.edges.some(
+ (edge) =>
+ (edge.start === group1.id && edge.end === group2.id) ||
+ (edge.start === group2.id && edge.end === group1.id)
+ );
+
+ const separation = hasEdgesBetweenGroups
+ ? calculateSeparationVector(bounds1, bounds2, overlap, groupPadding)
+ : calculateHorizontalSeparationVector(bounds1, bounds2, overlap, groupPadding);
+
+ displaceGroup(group1, separation.group1, data4Layout);
+ displaceGroup(group2, separation.group2, data4Layout);
+
+ groupBounds.set(
+ group1.id,
+ calculateGroupBounds(group1, data4Layout, nodeMap, groupPadding)
+ );
+ groupBounds.set(
+ group2.id,
+ calculateGroupBounds(group2, data4Layout, nodeMap, groupPadding)
+ );
+ } else if (overlap.overlapX > 0 || overlap.overlapY > 0) {
+ hasOverlaps = true;
+
+ const separationDistance = groupPadding * 0.1;
+ let separationX = 0;
+ let separationY = 0;
+
+ if (overlap.overlapX > 0) {
+ const dy = bounds2.centerY - bounds1.centerY;
+ separationY = dy < 0 ? -separationDistance : separationDistance;
+ }
+
+ if (overlap.overlapY > 0) {
+ const dx = bounds2.centerX - bounds1.centerX;
+ separationX = dx < 0 ? -separationDistance : separationDistance;
+ }
+
+ displaceGroup(group1, { x: -separationX / 2, y: -separationY / 2 }, data4Layout);
+ displaceGroup(group2, { x: separationX / 2, y: separationY / 2 }, data4Layout);
+
+ groupBounds.set(
+ group1.id,
+ calculateGroupBounds(group1, data4Layout, nodeMap, groupPadding)
+ );
+ groupBounds.set(
+ group2.id,
+ calculateGroupBounds(group2, data4Layout, nodeMap, groupPadding)
+ );
+ }
+ }
+ }
+ }
+}
+
+function calculateGroupBounds(
+ group: Node,
+ data4Layout: LayoutData,
+ nodeMap: Map,
+ groupPadding: number
+) {
+ const children = data4Layout.nodes.filter((n) => n.parentId === group.id);
+
+ if (children.length === 0) {
+ const width = group.width || groupPadding * 2;
+ const height = group.height || groupPadding * 2;
+ return {
+ minX: group.x! - width / 2,
+ minY: group.y! - height / 2,
+ maxX: group.x! + width / 2,
+ maxY: group.y! + height / 2,
+ width,
+ height,
+ centerX: group.x!,
+ centerY: group.y!,
+ };
+ }
+
+ let minX = Number.POSITIVE_INFINITY,
+ minY = Number.POSITIVE_INFINITY,
+ maxX = Number.NEGATIVE_INFINITY,
+ maxY = Number.NEGATIVE_INFINITY;
+
+ const defaultSubWidth = 100;
+
+ children.forEach((child) => {
+ const width = child.width ?? (child.isGroup ? defaultSubWidth : 30);
+ const height = child.height ?? (child.isGroup ? defaultSubWidth : 30);
+
+ minX = Math.min(minX, child.x! - width / 2);
+ minY = Math.min(minY, child.y! - height / 2);
+ maxX = Math.max(maxX, child.x! + width / 2);
+ maxY = Math.max(maxY, child.y! + height / 2);
+ });
+
+ data4Layout.nodes.forEach((node: Node) => {
+ if (node.isEdgeLabel && node.edgeStart && node.edgeEnd) {
+ const startNode = nodeMap.get(node.edgeStart);
+ const endNode = nodeMap.get(node.edgeEnd);
+
+ if (startNode?.parentId === group.id && endNode?.parentId === group.id) {
+ const width = node.width ?? 40;
+ const height = node.height ?? 20;
+
+ minX = Math.min(minX, node.x! - width / 2);
+ minY = Math.min(minY, node.y! - height / 2);
+ maxX = Math.max(maxX, node.x! + width / 2);
+ maxY = Math.max(maxY, node.y! + height / 2);
+ }
+ }
+ });
+
+ const extraPadding = groupPadding * 1.04;
+ minX -= extraPadding;
+ minY -= extraPadding;
+ maxX += extraPadding;
+ maxY += extraPadding;
+
+ const width = maxX - minX;
+ const height = maxY - minY;
+ const centerX = (minX + maxX) / 2;
+ const centerY = (minY + maxY) / 2;
+
+ group.x = centerX;
+ group.y = centerY;
+ group.width = width;
+ group.height = height;
+
+ return {
+ minX,
+ minY,
+ maxX,
+ maxY,
+ width,
+ height,
+ centerX,
+ centerY,
+ };
+}
+
+function calculateOverlap(
+ bounds1: GroupBounds,
+ bounds2: GroupBounds
+): {
+ hasOverlap: boolean;
+ overlapX: number;
+ overlapY: number;
+} {
+ const overlapX = Math.max(
+ 0,
+ Math.min(bounds1.maxX, bounds2.maxX) - Math.max(bounds1.minX, bounds2.minX)
+ );
+ const overlapY = Math.max(
+ 0,
+ Math.min(bounds1.maxY, bounds2.maxY) - Math.max(bounds1.minY, bounds2.minY)
+ );
+
+ return {
+ hasOverlap: overlapX > 0 && overlapY > 0,
+ overlapX,
+ overlapY,
+ };
+}
+
+function calculateHorizontalSeparationVector(
+ bounds1: GroupBounds,
+ bounds2: GroupBounds,
+ overlap: { overlapX: number; overlapY: number },
+ groupPadding: number
+): {
+ group1: { x: number; y: number };
+ group2: { x: number; y: number };
+} {
+ const dx = bounds2.centerX - bounds1.centerX;
+
+ const horizontalPadding = groupPadding * 4;
+ const separationX = Math.max(overlap.overlapX + horizontalPadding, horizontalPadding);
+
+ const direction = dx < 0 ? -1 : 1;
+
+ return {
+ group1: { x: (-separationX / 2) * direction, y: 0 },
+ group2: { x: (separationX / 2) * direction, y: 0 },
+ };
+}
+
+function calculateSeparationVector(
+ bounds1: GroupBounds,
+ bounds2: GroupBounds,
+ overlap: { overlapX: number; overlapY: number },
+ groupPadding: number
+): {
+ group1: { x: number; y: number };
+ group2: { x: number; y: number };
+} {
+ const dx = bounds2.centerX - bounds1.centerX;
+ const dy = bounds2.centerY - bounds1.centerY;
+
+ let separationX = 0;
+ let separationY = 0;
+
+ if (overlap.overlapX > overlap.overlapY) {
+ separationX = (overlap.overlapX + groupPadding * 2) / 4;
+ if (dx < 0) {
+ separationX = -separationX;
+ }
+ } else {
+ separationY = (overlap.overlapY + groupPadding * 2) / 4;
+ if (dy < 0) {
+ separationY = -separationY;
+ }
+ }
+
+ return {
+ group1: { x: -separationX, y: -separationY },
+ group2: { x: separationX, y: separationY },
+ };
+}
+
+function displaceGroup(
+ group: Node,
+ displacement: { x: number; y: number },
+ data4Layout: LayoutData
+): void {
+ group.x! += displacement.x;
+ group.y! += displacement.y;
+
+ const moveChildren = (parentId: string) => {
+ data4Layout.nodes.forEach((node) => {
+ if (node.parentId === parentId) {
+ node.x! += displacement.x;
+ node.y! += displacement.y;
+
+ if (node.isGroup) {
+ moveChildren(node.id);
+ }
+ }
+ });
+ };
+
+ moveChildren(group.id);
+}
diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/assignInitialPositions.ts b/packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/assignInitialPositions.ts
new file mode 100644
index 000000000..c57b03c87
--- /dev/null
+++ b/packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/assignInitialPositions.ts
@@ -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;
+ });
+}
diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/index.ts b/packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/index.ts
new file mode 100644
index 000000000..5d8e3a3d1
--- /dev/null
+++ b/packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/index.ts
@@ -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 {
+ const element = svg.select('g') as unknown as D3Selection;
+ // 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];
+}
diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/layerAssignment.ts b/packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/layerAssignment.ts
new file mode 100644
index 000000000..8d5700cde
--- /dev/null
+++ b/packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/layerAssignment.ts
@@ -0,0 +1,90 @@
+import type { Edge, LayoutData } from '../../types.js';
+
+export function layerAssignment(data4Layout: LayoutData): void {
+ const removedEdges: Edge[] = [];
+
+ const visited = new Set();
+ const visiting = new Set();
+
+ 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 = {};
+ 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 = {};
+ 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);
+ }
+ }
+ }
+ }
+}
diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/nodeOrdering.ts b/packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/nodeOrdering.ts
new file mode 100644
index 000000000..f4b8a42f6
--- /dev/null
+++ b/packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/nodeOrdering.ts
@@ -0,0 +1,129 @@
+import type { Edge, LayoutData, Node } from '../../types.js';
+
+type LayerMap = Record;
+
+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(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
+): void {
+ const edgeMap = new Map>();
+ 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;
+ });
+}
diff --git a/packages/mermaid/src/rendering-util/render.ts b/packages/mermaid/src/rendering-util/render.ts
index b975e7bf9..46339d560 100644
--- a/packages/mermaid/src/rendering-util/render.ts
+++ b/packages/mermaid/src/rendering-util/render.ts
@@ -39,6 +39,10 @@ const registerDefaultLayoutLoaders = () => {
name: 'dagre',
loader: async () => await import('./layout-algorithms/dagre/index.js'),
},
+ {
+ name: 'ipsecCola',
+ loader: async () => await import('./layout-algorithms/ipsecCola/index.ts'),
+ },
]);
};
diff --git a/packages/mermaid/src/rendering-util/types.ts b/packages/mermaid/src/rendering-util/types.ts
index b11d2f314..01dafb83f 100644
--- a/packages/mermaid/src/rendering-util/types.ts
+++ b/packages/mermaid/src/rendering-util/types.ts
@@ -80,11 +80,23 @@ interface BaseNode {
export interface ClusterNode extends BaseNode {
shape?: ClusterShapeID;
isGroup: true;
+ isEdgeLabel?: boolean;
+ edgeStart?: string;
+ edgeEnd?: string;
+ layer?: number;
+ order?: number;
+ isDummy?: boolean;
}
export interface NonClusterNode extends BaseNode {
shape?: ShapeID;
isGroup: false;
+ isEdgeLabel?: boolean;
+ edgeStart?: string;
+ edgeEnd?: string;
+ layer?: number;
+ order?: number;
+ isDummy?: boolean;
}
// Common properties for any node in the system
@@ -126,6 +138,8 @@ export interface Edge {
thickness?: 'normal' | 'thick' | 'invisible' | 'dotted';
look?: string;
isUserDefinedId?: boolean;
+ isLabelEdge?: boolean;
+ points?: { x: number; y: number }[];
}
export interface RectOptions {
diff --git a/packages/mermaid/tsconfig.json b/packages/mermaid/tsconfig.json
index 447a5bb0d..8b9b5fca6 100644
--- a/packages/mermaid/tsconfig.json
+++ b/packages/mermaid/tsconfig.json
@@ -3,7 +3,9 @@
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
- "types": ["vitest/importMeta", "vitest/globals"]
+ "types": ["vitest/importMeta", "vitest/globals"],
+ "allowImportingTsExtensions": true,
+ "noEmit": true
},
"include": [
"./src/**/*.ts",