From 3a8952ebe0932aac2e1cd3237654585a67c069ee Mon Sep 17 00:00:00 2001 From: shubham-mermaid Date: Mon, 9 Jun 2025 20:38:52 +0530 Subject: [PATCH] Added support for ipsep-cola layout algorithm --- cypress/platform/ipsepcola_sample.html | 601 +++++++ .../mermaid/src/rendering-util/createGraph.ts | 148 ++ .../ipsecCola/adjustLayout.ts | 34 + .../layout-algorithms/ipsecCola/applyCola.ts | 1602 +++++++++++++++++ .../ipsecCola/assignInitialPositions.ts | 27 + .../layout-algorithms/ipsecCola/index.ts | 83 + .../ipsecCola/layerAssignment.ts | 90 + .../ipsecCola/nodeOrdering.ts | 129 ++ packages/mermaid/src/rendering-util/render.ts | 4 + packages/mermaid/src/rendering-util/types.ts | 14 + packages/mermaid/tsconfig.json | 4 +- 11 files changed, 2735 insertions(+), 1 deletion(-) create mode 100644 cypress/platform/ipsepcola_sample.html create mode 100644 packages/mermaid/src/rendering-util/createGraph.ts create mode 100644 packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/adjustLayout.ts create mode 100644 packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/applyCola.ts create mode 100644 packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/assignInitialPositions.ts create mode 100644 packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/index.ts create mode 100644 packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/layerAssignment.ts create mode 100644 packages/mermaid/src/rendering-util/layout-algorithms/ipsecCola/nodeOrdering.ts 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",