diff --git a/.build/common.ts b/.build/common.ts index efd0e3a85..2497d443f 100644 --- a/.build/common.ts +++ b/.build/common.ts @@ -33,6 +33,11 @@ export const packageOptions = { packageName: 'mermaid-layout-elk', file: 'layouts.ts', }, + 'mermaid-layout-tidy-tree': { + name: 'mermaid-layout-tidy-tree', + packageName: 'mermaid-layout-tidy-tree', + file: 'index.ts', + }, examples: { name: 'mermaid-examples', packageName: 'examples', diff --git a/.changeset/hungry-guests-drive.md b/.changeset/hungry-guests-drive.md new file mode 100644 index 000000000..1b0e0a07b --- /dev/null +++ b/.changeset/hungry-guests-drive.md @@ -0,0 +1,7 @@ +--- +'mermaid': minor +'@mermaid-js/layout-tidy-tree': minor +'@mermaid-js/layout-elk': minor +--- + +feat: Update mindmap rendering to support multiple layouts, improved edge intersections, and new shapes diff --git a/.cspell/mermaid-terms.txt b/.cspell/mermaid-terms.txt index b0cfa0a1d..6900c15b0 100644 --- a/.cspell/mermaid-terms.txt +++ b/.cspell/mermaid-terms.txt @@ -5,6 +5,7 @@ bmatrix braintree catmull compositTitleSize +cose curv doublecircle elems diff --git a/.cspell/misc-terms.txt b/.cspell/misc-terms.txt index 1820e3c86..2906a02fa 100644 --- a/.cspell/misc-terms.txt +++ b/.cspell/misc-terms.txt @@ -1,4 +1,5 @@ BRANDES +Buzan circo handDrawn KOEPF diff --git a/.github/workflows/validate-lockfile.yml b/.github/workflows/validate-lockfile.yml index 6eb0a63ca..59a6df96d 100644 --- a/.github/workflows/validate-lockfile.yml +++ b/.github/workflows/validate-lockfile.yml @@ -35,7 +35,7 @@ jobs: # 2) No unwanted vitepress paths if grep -qF 'packages/mermaid/src/vitepress' pnpm-lock.yaml; then - issues+=("• Disallowed path 'packages/mermaid/src/vitepress' present. Run `rm -rf packages/mermaid/src/vitepress && pnpm install` to regenerate.") + issues+=("• Disallowed path 'packages/mermaid/src/vitepress' present. Run \`rm -rf packages/mermaid/src/vitepress && pnpm install\` to regenerate.") fi # 3) Lockfile only changes when package.json changes diff --git a/.gitignore b/.gitignore index 7448f2a81..7eb55d5cb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ coverage/ .idea/ .pnpm-store/ +.instructions/ dist v8-compile-cache-0 diff --git a/cypress/integration/rendering/mindmap-tidy-tree.spec.js b/cypress/integration/rendering/mindmap-tidy-tree.spec.js new file mode 100644 index 000000000..e111c281a --- /dev/null +++ b/cypress/integration/rendering/mindmap-tidy-tree.spec.js @@ -0,0 +1,79 @@ +import { imgSnapshotTest } from '../../helpers/util.ts'; + +describe('Mindmap Tidy Tree', () => { + it('1-tidy-tree: should render a simple mindmap without children', () => { + imgSnapshotTest( + ` --- + config: + layout: tidy-tree + --- + mindmap + root((mindmap)) + A + B + ` + ); + }); + it('2-tidy-tree: should render a simple mindmap', () => { + imgSnapshotTest( + ` --- + config: + layout: tidy-tree + --- + mindmap + root((mindmap is a long thing)) + A + B + C + D + ` + ); + }); + it('3-tidy-tree: should render a mindmap with different shapes', () => { + imgSnapshotTest( + ` --- + config: + layout: tidy-tree + --- + mindmap + root((mindmap)) + Origins + Long history + ::icon(fa fa-book) + Popularisation + British popular psychology author Tony Buzan + Research + On effectiveness<br/>and features + On Automatic creation + Uses + Creative techniques + Strategic planning + Argument mapping + Tools + id)I am a cloud( + id))I am a bang(( + Tools + ` + ); + }); + it('4-tidy-tree: should render a mindmap with children', () => { + imgSnapshotTest( + ` --- + config: + layout: tidy-tree + --- + mindmap + ((This is a mindmap)) + child1 + grandchild 1 + grandchild 2 + child2 + grandchild 3 + grandchild 4 + child3 + grandchild 5 + grandchild 6 + ` + ); + }); +}); diff --git a/cypress/integration/rendering/mindmap.spec.ts b/cypress/integration/rendering/mindmap.spec.ts index d76e58c56..ff8e297b1 100644 --- a/cypress/integration/rendering/mindmap.spec.ts +++ b/cypress/integration/rendering/mindmap.spec.ts @@ -159,12 +159,10 @@ root }); it('square shape', () => { imgSnapshotTest( - ` -mindmap + `mindmap root[ The root - ] - `, + ]`, {}, undefined, shouldHaveRoot @@ -172,12 +170,10 @@ mindmap }); it('rounded rect shape', () => { imgSnapshotTest( - ` -mindmap + `mindmap root(( The root - )) - `, + ))`, {}, undefined, shouldHaveRoot @@ -185,12 +181,10 @@ mindmap }); it('circle shape', () => { imgSnapshotTest( - ` -mindmap + `mindmap root( The root - ) - `, + )`, {}, undefined, shouldHaveRoot @@ -198,10 +192,8 @@ mindmap }); it('default shape', () => { imgSnapshotTest( - ` -mindmap - The root - `, + `mindmap + The root`, {}, undefined, shouldHaveRoot @@ -209,12 +201,10 @@ mindmap }); it('adding children', () => { imgSnapshotTest( - ` -mindmap + `mindmap The root child1 - child2 - `, + child2`, {}, undefined, shouldHaveRoot @@ -222,13 +212,11 @@ mindmap }); it('adding grand children', () => { imgSnapshotTest( - ` -mindmap + `mindmap The root child1 child2 - child3 - `, + child3`, {}, undefined, shouldHaveRoot diff --git a/cypress/platform/knsv2.html b/cypress/platform/knsv2.html index eb5528844..fc33a58b4 100644 --- a/cypress/platform/knsv2.html +++ b/cypress/platform/knsv2.html @@ -130,6 +130,76 @@ +
+      ---
+      config:
+        layout: tidy-tree
+      ---
+      mindmap
+      root((mindmap))
+        Origins
+          Long history
+          ::icon(fa fa-book)
+          Popularisation
+            British popular psychology author Tony Buzan
+        Research
+          On effectiveness
and features + On Automatic creation + Uses + Creative techniques + Strategic planning + Argument mapping + Tools + Pen and paper + Mermaid + +
+
+      ---
+      config:
+        layout: tidy-tree
+      ---
+      mindmap
+      root((mindmap is a long thing))
+        A
+        B
+        C
+        D
+      
+
+      ---
+      config:
+        layout: tidy-tree
+      ---
+      mindmap
+      root((mindmap))
+        A
+        B
+      
+
+      ---
+      config:
+        layout: tidy-tree
+      ---
+      mindmap
+      root((mindmap))
+        A
+          a
+            apa[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+            apa2[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+          b
+          c
+          d
+        B
+            apa3[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+        D
+          apa5[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+          apa4[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+
+    
+
 ---
 config:
@@ -191,8 +261,145 @@ treemap
           "Item B2": 25
     
+      ---
+      config:
+        layout: tidy-tree
+      ---
+      mindmap
+      root((mindmap))
+        a
+          apa[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+          apa2[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+        b
+          apa3[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+          apa4[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+
+
+    
+
+      ---
+      config:
+        layout: tidy-tree
+      ---
+      flowchart TB
+          A --> n0["1"]
+          A --> n1["2"]
+          A --> n2["3"]
+          A --> n3["4"] --> Q & R & S & T
+    
+
+      ---
+      config:
+        layout: elk
+      ---
+      flowchart TB
+          A --> n0["1"]
+          A --> n1["2"]
+          A --> n2["3"]
+          A --> n3["4"] --> Q & R & S & T
+    
+
+      ---
+      config:
+        layout: dagre
+      ---
+      mindmap
+      root((mindmap is a long thing))
+        Origins
+          Long history
+          ::icon(fa fa-book)
+          Popularisation
+            British popular psychology author Tony Buzan
+        Research
+          On effectiveness<br/>and features
+          On Automatic creation
+            Uses
+                Creative techniques
+                Strategic planning
+                Argument mapping
+        Tools
+          Pen and paper
+          Mermaid
+    
+
+      ---
+      config:
+        layout: cose-bilkent
+      ---
+      mindmap
+      root((mindmap))
+        Origins
+          Long history
+          ::icon(fa fa-book)
+          Popularisation
+            British popular psychology author Tony Buzan
+        Research
+          On effectiveness<br/>and features
+          On Automatic creation
+            Uses
+                Creative techniques
+                Strategic planning
+                Argument mapping
+        Tools
+          Pen and paper
+          Mermaid
+    
+
+      ---
+      config:
+        layout: elk
+      ---
+      mindmap
+      root((mindmap))
+        Origins
+          Long history
+          ::icon(fa fa-book)
+          Popularisation
+            British popular psychology author Tony Buzan
+        Research
+          On effectiveness<br/>and features
+          On Automatic creation
+            Uses
+                Creative techniques
+                Strategic planning
+                Argument mapping
+        Tools
+          Pen and paper
+          Mermaid
+    
+
+      ---
+      config:
+        layout: cose-bilkent
+      ---
       flowchart LR
-        AB["apa@apa@"] --> B(("`apa@apa`"))
+      root{mindmap} --- Origins --- Europe
+      Origins --> Asia
+      root --- Background --- Rich
+      Background --- Poor
+      subgraph apa
+        Background
+        Poor
+      end
+
+
+
+
+    
+
+      ---
+      config:
+        layout: elk
+      ---
+      flowchart LR
+      root{mindmap} --- Origins --- Europe
+      Origins --> Asia
+      root --- Background --- Rich
+      Background --- Poor
+
+
+
+
     
       flowchart
@@ -274,6 +481,44 @@ config:
     
 ---
+config:
+  layout: elk
+---
+      flowchart LR
+      a
+      subgraph s0["APA"]
+      subgraph s8["APA"]
+      subgraph s1["APA"]
+        D{"X"}
+        E[Q]
+      end
+      subgraph s3["BAPA"]
+        F[Q]
+        I
+      end
+            D --> I
+            D --> I
+            D --> I
+
+      I{"X"}
+      end
+      end
+    
+
+---
+config:
+  layout: elk
+---
+      flowchart LR
+      a
+        D{"Use the editor"}
+
+      D -- Mermaid js --> I{"fa:fa-code Text"}
+      D-->I
+      D-->I
+    
+
+---
 config:
   layout: elk
 ---
diff --git a/cypress/platform/mindmap-layouts.html b/cypress/platform/mindmap-layouts.html
new file mode 100644
index 000000000..0aef65d42
--- /dev/null
+++ b/cypress/platform/mindmap-layouts.html
@@ -0,0 +1,376 @@
+
+
+  
+    
+    
+    Mermaid Quick Test Page
+    
+    
+  
+
+  
+    
+ ---
+      config:
+        layout: tidy-tree
+      ---
+      mindmap
+      root((mindmap))
+        A
+        B
+    
+
+ ---
+      config:
+        layout: dagre
+      ---
+      mindmap
+      root((mindmap))
+        A
+        B
+    
+
+ ---
+      config:
+        layout: elk
+      ---
+      mindmap
+      root((mindmap))
+        A
+        B
+    
+
+ ---
+      config:
+        layout: cose-bilkent
+      ---
+      mindmap
+      root((mindmap))
+        A
+        B
+    
+
+    ---
+      config:
+        layout: tidy-tree
+      ---
+      mindmap
+      root((mindmap is a long thing))
+        A
+        B
+        C
+        D
+    
+
+    ---
+      config:
+        layout: dagre
+      ---
+      mindmap
+      root((mindmap is a long thing))
+        A
+        B
+        C
+        D
+    
+
+    ---
+      config:
+        layout: elk
+      ---
+      mindmap
+      root((mindmap is a long thing))
+        A
+        B
+        C
+        D
+    
+
+    ---
+      config:
+        layout: cose-bilkent
+      ---
+      mindmap
+      root((mindmap is a long thing))
+        A
+        B
+        C
+        D
+    
+ +
+    ---
+      config:
+        layout: tidy-tree
+      ---
+      mindmap
+      root((mindmap))
+        Origins
+          Long history
+          ::icon(fa fa-book)
+          Popularisation
+            British popular psychology author Tony Buzan
+        Research
+          On effectiveness<br/>and features
+          On Automatic creation
+            Uses
+                Creative techniques
+                Strategic planning
+                Argument mapping
+        Tools
+              id)I am a cloud(
+                  id))I am a bang((
+                    Tools
+    
+
+    ---
+      config:
+        layout: dagre
+      ---
+      mindmap
+      root((mindmap))
+        Origins
+          Long history
+          ::icon(fa fa-book)
+          Popularisation
+            British popular psychology author Tony Buzan
+        Research
+          On effectiveness<br/>and features
+          On Automatic creation
+            Uses
+                Creative techniques
+                Strategic planning
+                Argument mapping
+        Tools
+              id)I am a cloud(
+                  id))I am a bang((
+                    Tools
+    
+
+    ---
+      config:
+        layout: elk
+      ---
+      mindmap
+      root((mindmap))
+        Origins
+          Long history
+          ::icon(fa fa-book)
+          Popularisation
+            British popular psychology author Tony Buzan
+        Research
+          On effectiveness<br/>and features
+          On Automatic creation
+            Uses
+                Creative techniques
+                Strategic planning
+                Argument mapping
+        Tools
+              id)I am a cloud(
+                  id))I am a bang((
+                    Tools
+    
+
+    ---
+      config:
+        layout: cose-bilkent
+      ---
+      mindmap
+      root((mindmap))
+        Origins
+          Long history
+          ::icon(fa fa-book)
+          Popularisation
+            British popular psychology author Tony Buzan
+        Research
+          On effectiveness<br/>and features
+          On Automatic creation
+            Uses
+                Creative techniques
+                Strategic planning
+                Argument mapping
+        Tools
+              id)I am a cloud(
+                  id))I am a bang((
+                    Tools
+    
+
+      ---
+      config:
+        layout: tidy-tree
+      ---
+      mindmap
+      root((mindmap))
+        A
+          a
+            apa[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+            apa2[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+          b
+          c
+          d
+        B
+            apa3[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+        D
+          apa5[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+          apa4[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+
+    
+
+      ---
+      config:
+        layout: dagre
+      ---
+      mindmap
+      root((mindmap))
+        A
+          a
+            apa[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+            apa2[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+          b
+          c
+          d
+        B
+            apa3[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+        D
+          apa5[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+          apa4[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+
+    
+
+      ---
+      config:
+        layout: elk
+      ---
+      mindmap
+      root((mindmap))
+        A
+          a
+            apa[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+            apa2[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+          b
+          c
+          d
+        B
+            apa3[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+        D
+          apa5[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+          apa4[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+
+    
+
+      ---
+      config:
+        layout: cose-bilkent
+      ---
+      mindmap
+      root((mindmap))
+        A
+          a
+            apa[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+            apa2[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+          b
+          c
+          d
+        B
+            apa3[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+        D
+          apa5[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+          apa4[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
+
+    
+ +
+ ---
+      config:
+        layout: tidy-tree
+      ---
+      mindmap
+      ((This is a mindmap))
+        child1
+         grandchild 1
+         grandchild 2
+        child2
+         grandchild 3
+         grandchild 4
+        child3
+         grandchild 5
+         grandchild 6
+      
+    
+ +
+ ---
+      config:
+        layout: dagre
+      ---
+      mindmap
+      ((This is a mindmap))
+        child1
+         grandchild 1
+         grandchild 2
+        child2
+         grandchild 3
+         grandchild 4
+        child3
+         grandchild 5
+         grandchild 6
+      
+    
+ +
+ ---
+      config:
+        layout: elk
+      ---
+      mindmap
+      ((This is a mindmap))
+        child1
+         grandchild 1
+         grandchild 2
+        child2
+         grandchild 3
+         grandchild 4
+        child3
+         grandchild 5
+         grandchild 6
+      
+    
+ +
+ ---
+      config:
+        layout: cose-bilkent
+      ---
+      mindmap
+      ((This is a mindmap))
+        child1
+         grandchild 1
+         grandchild 2
+        child2
+         grandchild 3
+         grandchild 4
+        child3
+         grandchild 5
+         grandchild 6
+      
+    
+ +
+ + + diff --git a/cypress/platform/viewer.js b/cypress/platform/viewer.js index 7ff95e163..de7dcafe8 100644 --- a/cypress/platform/viewer.js +++ b/cypress/platform/viewer.js @@ -1,5 +1,6 @@ import externalExample from './mermaid-example-diagram.esm.mjs'; import layouts from './mermaid-layout-elk.esm.mjs'; +import tidyTree from './mermaid-layout-tidy-tree.esm.mjs'; import zenUml from './mermaid-zenuml.esm.mjs'; import mermaid from './mermaid.esm.mjs'; @@ -65,6 +66,7 @@ const contentLoaded = async function () { await mermaid.registerExternalDiagrams([externalExample, zenUml]); mermaid.registerLayoutLoaders(layouts); + mermaid.registerLayoutLoaders(tidyTree); mermaid.initialize(graphObj.mermaid); /** * CC-BY-4.0 diff --git a/docs/config/layouts.md b/docs/config/layouts.md new file mode 100644 index 000000000..18e9c9423 --- /dev/null +++ b/docs/config/layouts.md @@ -0,0 +1,40 @@ +> **Warning** +> +> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT. +> +> ## Please edit the corresponding file in [/packages/mermaid/src/docs/config/layouts.md](../../packages/mermaid/src/docs/config/layouts.md). + +# Layouts + +This page lists the available layout algorithms supported in Mermaid diagrams. + +## Supported Layouts + +- **elk**: [ELK (Eclipse Layout Kernel)](https://www.eclipse.org/elk/) +- **tidy-tree**: Tidy tree layout for hierarchical diagrams [Tidy Tree Configuration](/config/tidy-tree) +- **cose-bilkent**: Cose Bilkent layout for force-directed graphs +- **dagre**: Dagre layout for layered graphs + +## How to Use + +You can specify the layout in your diagram's YAML config or initialization options. For example: + +```mermaid-example +--- +config: + layout: elk +--- +graph TD; + A-->B; + B-->C; +``` + +```mermaid +--- +config: + layout: elk +--- +graph TD; + A-->B; + B-->C; +``` diff --git a/docs/config/setup/mermaid/README.md b/docs/config/setup/mermaid/README.md index 3e2cd7a28..653d90592 100644 --- a/docs/config/setup/mermaid/README.md +++ b/docs/config/setup/mermaid/README.md @@ -10,10 +10,6 @@ # mermaid -## Classes - -- [UnknownDiagramError](classes/UnknownDiagramError.md) - ## Interfaces - [DetailedError](interfaces/DetailedError.md) @@ -27,6 +23,7 @@ - [RenderOptions](interfaces/RenderOptions.md) - [RenderResult](interfaces/RenderResult.md) - [RunOptions](interfaces/RunOptions.md) +- [UnknownDiagramError](interfaces/UnknownDiagramError.md) ## Type Aliases diff --git a/docs/config/setup/mermaid/classes/UnknownDiagramError.md b/docs/config/setup/mermaid/classes/UnknownDiagramError.md deleted file mode 100644 index c077f0e34..000000000 --- a/docs/config/setup/mermaid/classes/UnknownDiagramError.md +++ /dev/null @@ -1,159 +0,0 @@ -> **Warning** -> -> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT. -> -> ## Please edit the corresponding file in [/packages/mermaid/src/docs/config/setup/mermaid/classes/UnknownDiagramError.md](../../../../../packages/mermaid/src/docs/config/setup/mermaid/classes/UnknownDiagramError.md). - -[**mermaid**](../../README.md) - ---- - -# Class: UnknownDiagramError - -Defined in: [packages/mermaid/src/errors.ts:1](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/errors.ts#L1) - -## Extends - -- `Error` - -## Constructors - -### new UnknownDiagramError() - -> **new UnknownDiagramError**(`message`): [`UnknownDiagramError`](UnknownDiagramError.md) - -Defined in: [packages/mermaid/src/errors.ts:2](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/errors.ts#L2) - -#### Parameters - -##### message - -`string` - -#### Returns - -[`UnknownDiagramError`](UnknownDiagramError.md) - -#### Overrides - -`Error.constructor` - -## Properties - -### cause? - -> `optional` **cause**: `unknown` - -Defined in: node_modules/.pnpm/typescript\@5.7.3/node_modules/typescript/lib/lib.es2022.error.d.ts:26 - -#### Inherited from - -`Error.cause` - ---- - -### message - -> **message**: `string` - -Defined in: node_modules/.pnpm/typescript\@5.7.3/node_modules/typescript/lib/lib.es5.d.ts:1077 - -#### Inherited from - -`Error.message` - ---- - -### name - -> **name**: `string` - -Defined in: node_modules/.pnpm/typescript\@5.7.3/node_modules/typescript/lib/lib.es5.d.ts:1076 - -#### Inherited from - -`Error.name` - ---- - -### stack? - -> `optional` **stack**: `string` - -Defined in: node_modules/.pnpm/typescript\@5.7.3/node_modules/typescript/lib/lib.es5.d.ts:1078 - -#### Inherited from - -`Error.stack` - ---- - -### prepareStackTrace()? - -> `static` `optional` **prepareStackTrace**: (`err`, `stackTraces`) => `any` - -Defined in: node_modules/.pnpm/@types+node\@22.13.5/node_modules/@types/node/globals.d.ts:143 - -Optional override for formatting stack traces - -#### Parameters - -##### err - -`Error` - -##### stackTraces - -`CallSite`\[] - -#### Returns - -`any` - -#### See - - - -#### Inherited from - -`Error.prepareStackTrace` - ---- - -### stackTraceLimit - -> `static` **stackTraceLimit**: `number` - -Defined in: node_modules/.pnpm/@types+node\@22.13.5/node_modules/@types/node/globals.d.ts:145 - -#### Inherited from - -`Error.stackTraceLimit` - -## Methods - -### captureStackTrace() - -> `static` **captureStackTrace**(`targetObject`, `constructorOpt`?): `void` - -Defined in: node_modules/.pnpm/@types+node\@22.13.5/node_modules/@types/node/globals.d.ts:136 - -Create .stack property on a target object - -#### Parameters - -##### targetObject - -`object` - -##### constructorOpt? - -`Function` - -#### Returns - -`void` - -#### Inherited from - -`Error.captureStackTrace` diff --git a/docs/config/setup/mermaid/interfaces/LayoutData.md b/docs/config/setup/mermaid/interfaces/LayoutData.md index b4c88454e..32bef322c 100644 --- a/docs/config/setup/mermaid/interfaces/LayoutData.md +++ b/docs/config/setup/mermaid/interfaces/LayoutData.md @@ -10,7 +10,7 @@ # Interface: LayoutData -Defined in: [packages/mermaid/src/rendering-util/types.ts:145](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L145) +Defined in: [packages/mermaid/src/rendering-util/types.ts:168](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L168) ## Indexable @@ -22,7 +22,7 @@ Defined in: [packages/mermaid/src/rendering-util/types.ts:145](https://github.co > **config**: [`MermaidConfig`](MermaidConfig.md) -Defined in: [packages/mermaid/src/rendering-util/types.ts:148](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L148) +Defined in: [packages/mermaid/src/rendering-util/types.ts:171](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L171) --- @@ -30,7 +30,7 @@ Defined in: [packages/mermaid/src/rendering-util/types.ts:148](https://github.co > **edges**: `Edge`\[] -Defined in: [packages/mermaid/src/rendering-util/types.ts:147](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L147) +Defined in: [packages/mermaid/src/rendering-util/types.ts:170](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L170) --- @@ -38,4 +38,4 @@ Defined in: [packages/mermaid/src/rendering-util/types.ts:147](https://github.co > **nodes**: `Node`\[] -Defined in: [packages/mermaid/src/rendering-util/types.ts:146](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L146) +Defined in: [packages/mermaid/src/rendering-util/types.ts:169](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L169) diff --git a/docs/config/setup/mermaid/interfaces/Mermaid.md b/docs/config/setup/mermaid/interfaces/Mermaid.md index fd15b306b..0c63d140a 100644 --- a/docs/config/setup/mermaid/interfaces/Mermaid.md +++ b/docs/config/setup/mermaid/interfaces/Mermaid.md @@ -32,7 +32,7 @@ page. ### detectType() -> **detectType**: (`text`, `config`?) => `string` +> **detectType**: (`text`, `config?`) => `string` Defined in: [packages/mermaid/src/mermaid.ts:449](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L449) @@ -105,7 +105,7 @@ An array of objects with the id of the diagram. ### ~~init()~~ -> **init**: (`config`?, `nodes`?, `callback`?) => `Promise`<`void`> +> **init**: (`config?`, `nodes?`, `callback?`) => `Promise`<`void`> Defined in: [packages/mermaid/src/mermaid.ts:442](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L442) @@ -117,7 +117,7 @@ Defined in: [packages/mermaid/src/mermaid.ts:442](https://github.com/mermaid-js/ [`MermaidConfig`](MermaidConfig.md) -**Deprecated**, please set configuration in [initialize](Mermaid.md#initialize). +**Deprecated**, please set configuration in [initialize](#initialize). ##### nodes? @@ -141,13 +141,13 @@ Called once for each rendered diagram's id. #### Deprecated -Use [initialize](Mermaid.md#initialize) and [run](Mermaid.md#run) instead. +Use [initialize](#initialize) and [run](#run) instead. Renders the mermaid diagrams #### Deprecated -Use [initialize](Mermaid.md#initialize) and [run](Mermaid.md#run) instead. +Use [initialize](#initialize) and [run](#run) instead. --- @@ -176,7 +176,7 @@ Configuration object for mermaid. ### ~~mermaidAPI~~ -> **mermaidAPI**: `Readonly`<{ `defaultConfig`: [`MermaidConfig`](MermaidConfig.md); `getConfig`: () => [`MermaidConfig`](MermaidConfig.md); `getDiagramFromText`: (`text`, `metadata`) => `Promise`<`Diagram`>; `getSiteConfig`: () => [`MermaidConfig`](MermaidConfig.md); `globalReset`: () => `void`; `initialize`: (`userOptions`) => `void`; `parse`: (`text`, `parseOptions`) => `Promise`<`false` | [`ParseResult`](ParseResult.md)>(`text`, `parseOptions`?) => `Promise`<[`ParseResult`](ParseResult.md)>; `render`: (`id`, `text`, `svgContainingElement`?) => `Promise`<[`RenderResult`](RenderResult.md)>; `reset`: () => `void`; `setConfig`: (`conf`) => [`MermaidConfig`](MermaidConfig.md); `updateSiteConfig`: (`conf`) => [`MermaidConfig`](MermaidConfig.md); }> +> **mermaidAPI**: `Readonly`<{ `defaultConfig`: [`MermaidConfig`](MermaidConfig.md); `getConfig`: () => [`MermaidConfig`](MermaidConfig.md); `getDiagramFromText`: (`text`, `metadata`) => `Promise`<`Diagram`>; `getSiteConfig`: () => [`MermaidConfig`](MermaidConfig.md); `globalReset`: () => `void`; `initialize`: (`userOptions`) => `void`; `parse`: {(`text`, `parseOptions`): `Promise`<`false` | [`ParseResult`](ParseResult.md)>; (`text`, `parseOptions?`): `Promise`<[`ParseResult`](ParseResult.md)>; }; `render`: (`id`, `text`, `svgContainingElement?`) => `Promise`<[`RenderResult`](RenderResult.md)>; `reset`: () => `void`; `setConfig`: (`conf`) => [`MermaidConfig`](MermaidConfig.md); `updateSiteConfig`: (`conf`) => [`MermaidConfig`](MermaidConfig.md); }> Defined in: [packages/mermaid/src/mermaid.ts:436](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L436) @@ -184,73 +184,81 @@ Defined in: [packages/mermaid/src/mermaid.ts:436](https://github.com/mermaid-js/ #### Deprecated -Use [parse](Mermaid.md#parse) and [render](Mermaid.md#render) instead. Please [open a discussion](https://github.com/mermaid-js/mermaid/discussions) if your use case does not fit the new API. +Use [parse](#parse) and [render](#render) instead. Please [open a discussion](https://github.com/mermaid-js/mermaid/discussions) if your use case does not fit the new API. --- ### parse() -> **parse**: (`text`, `parseOptions`) => `Promise`<`false` | [`ParseResult`](ParseResult.md)>(`text`, `parseOptions`?) => `Promise`<[`ParseResult`](ParseResult.md)> +> **parse**: {(`text`, `parseOptions`): `Promise`<`false` | [`ParseResult`](ParseResult.md)>; (`text`, `parseOptions?`): `Promise`<[`ParseResult`](ParseResult.md)>; } Defined in: [packages/mermaid/src/mermaid.ts:437](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L437) +#### Call Signature + +> (`text`, `parseOptions`): `Promise`<`false` | [`ParseResult`](ParseResult.md)> + Parse the text and validate the syntax. -#### Parameters +##### Parameters -##### text +###### text `string` The mermaid diagram definition. -##### parseOptions +###### parseOptions [`ParseOptions`](ParseOptions.md) & `object` Options for parsing. -#### Returns +##### Returns `Promise`<`false` | [`ParseResult`](ParseResult.md)> An object with the `diagramType` set to type of the diagram if valid. Otherwise `false` if parseOptions.suppressErrors is `true`. -#### See +##### See [ParseOptions](ParseOptions.md) -#### Throws +##### Throws Error if the diagram is invalid and parseOptions.suppressErrors is false or not set. +#### Call Signature + +> (`text`, `parseOptions?`): `Promise`<[`ParseResult`](ParseResult.md)> + Parse the text and validate the syntax. -#### Parameters +##### Parameters -##### text +###### text `string` The mermaid diagram definition. -##### parseOptions? +###### parseOptions? [`ParseOptions`](ParseOptions.md) Options for parsing. -#### Returns +##### Returns `Promise`<[`ParseResult`](ParseResult.md)> An object with the `diagramType` set to type of the diagram if valid. Otherwise `false` if parseOptions.suppressErrors is `true`. -#### See +##### See [ParseOptions](ParseOptions.md) -#### Throws +##### Throws Error if the diagram is invalid and parseOptions.suppressErrors is false or not set. @@ -332,7 +340,7 @@ Defined in: [packages/mermaid/src/mermaid.ts:444](https://github.com/mermaid-js/ ### render() -> **render**: (`id`, `text`, `svgContainingElement`?) => `Promise`<[`RenderResult`](RenderResult.md)> +> **render**: (`id`, `text`, `svgContainingElement?`) => `Promise`<[`RenderResult`](RenderResult.md)> Defined in: [packages/mermaid/src/mermaid.ts:438](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L438) diff --git a/docs/config/setup/mermaid/interfaces/ParseOptions.md b/docs/config/setup/mermaid/interfaces/ParseOptions.md index e068a91fb..628da0da0 100644 --- a/docs/config/setup/mermaid/interfaces/ParseOptions.md +++ b/docs/config/setup/mermaid/interfaces/ParseOptions.md @@ -10,7 +10,7 @@ # Interface: ParseOptions -Defined in: [packages/mermaid/src/types.ts:84](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L84) +Defined in: [packages/mermaid/src/types.ts:88](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L88) ## Properties @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/types.ts:84](https://github.com/mermaid-js/mer > `optional` **suppressErrors**: `boolean` -Defined in: [packages/mermaid/src/types.ts:89](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L89) +Defined in: [packages/mermaid/src/types.ts:93](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L93) If `true`, parse will return `false` instead of throwing error when the diagram is invalid. The `parseError` function will not be called. diff --git a/docs/config/setup/mermaid/interfaces/ParseResult.md b/docs/config/setup/mermaid/interfaces/ParseResult.md index 1651a6fa9..0e200aa95 100644 --- a/docs/config/setup/mermaid/interfaces/ParseResult.md +++ b/docs/config/setup/mermaid/interfaces/ParseResult.md @@ -10,7 +10,7 @@ # Interface: ParseResult -Defined in: [packages/mermaid/src/types.ts:92](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L92) +Defined in: [packages/mermaid/src/types.ts:96](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L96) ## Properties @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/types.ts:92](https://github.com/mermaid-js/mer > **config**: [`MermaidConfig`](MermaidConfig.md) -Defined in: [packages/mermaid/src/types.ts:100](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L100) +Defined in: [packages/mermaid/src/types.ts:104](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L104) The config passed as YAML frontmatter or directives @@ -28,6 +28,6 @@ The config passed as YAML frontmatter or directives > **diagramType**: `string` -Defined in: [packages/mermaid/src/types.ts:96](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L96) +Defined in: [packages/mermaid/src/types.ts:100](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L100) The diagram type, e.g. 'flowchart', 'sequence', etc. diff --git a/docs/config/setup/mermaid/interfaces/RenderResult.md b/docs/config/setup/mermaid/interfaces/RenderResult.md index c0e5496b8..237c51de2 100644 --- a/docs/config/setup/mermaid/interfaces/RenderResult.md +++ b/docs/config/setup/mermaid/interfaces/RenderResult.md @@ -10,7 +10,7 @@ # Interface: RenderResult -Defined in: [packages/mermaid/src/types.ts:110](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L110) +Defined in: [packages/mermaid/src/types.ts:114](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L114) ## Properties @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/types.ts:110](https://github.com/mermaid-js/me > `optional` **bindFunctions**: (`element`) => `void` -Defined in: [packages/mermaid/src/types.ts:128](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L128) +Defined in: [packages/mermaid/src/types.ts:132](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L132) Bind function to be called after the svg has been inserted into the DOM. This is necessary for adding event listeners to the elements in the svg. @@ -45,7 +45,7 @@ bindFunctions?.(div); // To call bindFunctions only if it's present. > **diagramType**: `string` -Defined in: [packages/mermaid/src/types.ts:118](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L118) +Defined in: [packages/mermaid/src/types.ts:122](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L122) The diagram type, e.g. 'flowchart', 'sequence', etc. @@ -55,6 +55,6 @@ The diagram type, e.g. 'flowchart', 'sequence', etc. > **svg**: `string` -Defined in: [packages/mermaid/src/types.ts:114](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L114) +Defined in: [packages/mermaid/src/types.ts:118](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L118) The svg code for the rendered graph. diff --git a/docs/config/setup/mermaid/interfaces/UnknownDiagramError.md b/docs/config/setup/mermaid/interfaces/UnknownDiagramError.md new file mode 100644 index 000000000..2415d77ee --- /dev/null +++ b/docs/config/setup/mermaid/interfaces/UnknownDiagramError.md @@ -0,0 +1,65 @@ +> **Warning** +> +> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT. +> +> ## Please edit the corresponding file in [/packages/mermaid/src/docs/config/setup/mermaid/interfaces/UnknownDiagramError.md](../../../../../packages/mermaid/src/docs/config/setup/mermaid/interfaces/UnknownDiagramError.md). + +[**mermaid**](../../README.md) + +--- + +# Interface: UnknownDiagramError + +Defined in: [packages/mermaid/src/errors.ts:1](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/errors.ts#L1) + +## Extends + +- `Error` + +## Properties + +### cause? + +> `optional` **cause**: `unknown` + +Defined in: node_modules/.pnpm/typescript\@5.7.3/node_modules/typescript/lib/lib.es2022.error.d.ts:26 + +#### Inherited from + +`Error.cause` + +--- + +### message + +> **message**: `string` + +Defined in: node_modules/.pnpm/typescript\@5.7.3/node_modules/typescript/lib/lib.es5.d.ts:1077 + +#### Inherited from + +`Error.message` + +--- + +### name + +> **name**: `string` + +Defined in: node_modules/.pnpm/typescript\@5.7.3/node_modules/typescript/lib/lib.es5.d.ts:1076 + +#### Inherited from + +`Error.name` + +--- + +### stack? + +> `optional` **stack**: `string` + +Defined in: node_modules/.pnpm/typescript\@5.7.3/node_modules/typescript/lib/lib.es5.d.ts:1078 + +#### Inherited from + +`Error.stack` diff --git a/docs/config/setup/mermaid/type-aliases/InternalHelpers.md b/docs/config/setup/mermaid/type-aliases/InternalHelpers.md index 6baf786fe..bfaeabd12 100644 --- a/docs/config/setup/mermaid/type-aliases/InternalHelpers.md +++ b/docs/config/setup/mermaid/type-aliases/InternalHelpers.md @@ -10,6 +10,6 @@ # Type Alias: InternalHelpers -> **InternalHelpers**: _typeof_ `internalHelpers` +> **InternalHelpers** = _typeof_ `internalHelpers` Defined in: [packages/mermaid/src/internals.ts:33](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/internals.ts#L33) diff --git a/docs/config/setup/mermaid/type-aliases/ParseErrorFunction.md b/docs/config/setup/mermaid/type-aliases/ParseErrorFunction.md index 78f27854c..dd5938478 100644 --- a/docs/config/setup/mermaid/type-aliases/ParseErrorFunction.md +++ b/docs/config/setup/mermaid/type-aliases/ParseErrorFunction.md @@ -10,7 +10,7 @@ # Type Alias: ParseErrorFunction() -> **ParseErrorFunction**: (`err`, `hash`?) => `void` +> **ParseErrorFunction** = (`err`, `hash?`) => `void` Defined in: [packages/mermaid/src/Diagram.ts:10](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/Diagram.ts#L10) diff --git a/docs/config/setup/mermaid/type-aliases/SVG.md b/docs/config/setup/mermaid/type-aliases/SVG.md index 8bfb7bda0..184f3e2cd 100644 --- a/docs/config/setup/mermaid/type-aliases/SVG.md +++ b/docs/config/setup/mermaid/type-aliases/SVG.md @@ -10,6 +10,6 @@ # Type Alias: SVG -> **SVG**: `d3.Selection`<`SVGSVGElement`, `unknown`, `Element` | `null`, `unknown`> +> **SVG** = `d3.Selection`<`SVGSVGElement`, `unknown`, `Element` | `null`, `unknown`> Defined in: [packages/mermaid/src/diagram-api/types.ts:126](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L126) diff --git a/docs/config/setup/mermaid/type-aliases/SVGGroup.md b/docs/config/setup/mermaid/type-aliases/SVGGroup.md index 5e53052fd..8d673aafb 100644 --- a/docs/config/setup/mermaid/type-aliases/SVGGroup.md +++ b/docs/config/setup/mermaid/type-aliases/SVGGroup.md @@ -10,6 +10,6 @@ # Type Alias: SVGGroup -> **SVGGroup**: `d3.Selection`<`SVGGElement`, `unknown`, `Element` | `null`, `unknown`> +> **SVGGroup** = `d3.Selection`<`SVGGElement`, `unknown`, `Element` | `null`, `unknown`> Defined in: [packages/mermaid/src/diagram-api/types.ts:128](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L128) diff --git a/docs/config/tidy-tree.md b/docs/config/tidy-tree.md new file mode 100644 index 000000000..1d4227596 --- /dev/null +++ b/docs/config/tidy-tree.md @@ -0,0 +1,89 @@ +> **Warning** +> +> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT. +> +> ## Please edit the corresponding file in [/packages/mermaid/src/docs/config/tidy-tree.md](../../packages/mermaid/src/docs/config/tidy-tree.md). + +# Tidy-tree Layout + +The **tidy-tree** layout arranges nodes in a hierarchical, tree-like structure. It is especially useful for diagrams where parent-child relationships are important, such as mindmaps. + +## Features + +- Organizes nodes in a tidy, non-overlapping tree +- Ideal for mindmaps and hierarchical data +- Automatically adjusts spacing for readability + +## Example Usage + +```mermaid-example +--- +config: + layout: tidy-tree +--- +mindmap +root((mindmap is a long thing)) + A + B + C + D +``` + +```mermaid +--- +config: + layout: tidy-tree +--- +mindmap +root((mindmap is a long thing)) + A + B + C + D +``` + +```mermaid-example +--- +config: + layout: tidy-tree +--- +mindmap +root((mindmap)) + Origins + Long history + ::icon(fa fa-book) + Popularisation + British popular psychology author Tony Buzan + Research + On effectiveness
and features + On Automatic creation + Uses + Creative techniques + Strategic planning + Argument mapping +``` + +```mermaid +--- +config: + layout: tidy-tree +--- +mindmap +root((mindmap)) + Origins + Long history + ::icon(fa fa-book) + Popularisation + British popular psychology author Tony Buzan + Research + On effectiveness
and features + On Automatic creation + Uses + Creative techniques + Strategic planning + Argument mapping +``` + +## Note + +- Currently, tidy-tree is primarily supported for mindmap diagrams. diff --git a/docs/syntax/flowchart.md b/docs/syntax/flowchart.md index 08c145f6f..23c34509c 100644 --- a/docs/syntax/flowchart.md +++ b/docs/syntax/flowchart.md @@ -326,7 +326,9 @@ Below is a comprehensive list of the newly introduced shapes and their correspon | **Semantic Name** | **Shape Name** | **Short Name** | **Description** | **Alias Supported** | | --------------------------------- | ---------------------- | -------------- | ------------------------------ | ---------------------------------------------------------------- | +| Bang | Bang | `bang` | Bang | `bang` | | Card | Notched Rectangle | `notch-rect` | Represents a card | `card`, `notched-rectangle` | +| Cloud | Cloud | `cloud` | cloud | `cloud` | | Collate | Hourglass | `hourglass` | Represents a collate operation | `collate`, `hourglass` | | Com Link | Lightning Bolt | `bolt` | Communication link | `com-link`, `lightning-bolt` | | Comment | Curly Brace | `brace` | Adds a comment | `brace-l`, `comment` | diff --git a/docs/syntax/mindmap.md b/docs/syntax/mindmap.md index 1adaa2c49..844b3293c 100644 --- a/docs/syntax/mindmap.md +++ b/docs/syntax/mindmap.md @@ -314,3 +314,22 @@ You can also refer the [implementation in the live editor](https://github.com/me cspell:locale en,en-gb cspell:ignore Buzan ---> + +## Layouts + +Mermaid also supports a Tidy Tree layout for mindmaps. + +``` +--- +config: + layout: tidy-tree +--- +mindmap +root((mindmap is a long thing)) + A + B + C + D +``` + +Instructions to add and register tidy-tree layout are present in [Tidy Tree Configuration](/config/tidy-tree) diff --git a/docs/syntax/xyChart.md b/docs/syntax/xyChart.md index dec16a518..742a4f18a 100644 --- a/docs/syntax/xyChart.md +++ b/docs/syntax/xyChart.md @@ -138,7 +138,7 @@ xychart ## Chart Theme Variables -Themes for xychart resides inside xychart attribute so to set the variables use this syntax: +Themes for xychart reside inside the `xychart` attribute, allowing customization through the following syntax: ```yaml --- @@ -163,6 +163,52 @@ config: | yAxisLineColor | Color of the y-axis line | | plotColorPalette | String of colors separated by comma e.g. "#f3456, #43445" | +### Setting Colors for Lines and Bars + +To set the color for lines and bars, use the `plotColorPalette` parameter. Colors in the palette will correspond sequentially to the elements in your chart (e.g., first bar/line will use the first color specified in the palette). + +```mermaid-example +--- +config: + themeVariables: + xyChart: + plotColorPalette: '#000000, #0000FF, #00FF00, #FF0000' +--- +xychart +title "Different Colors in xyChart" +x-axis "categoriesX" ["Category 1", "Category 2", "Category 3", "Category 4"] +y-axis "valuesY" 0 --> 50 +%% Black line +line [10,20,30,40] +%% Blue bar +bar [20,30,25,35] +%% Green bar +bar [15,25,20,30] +%% Red line +line [5,15,25,35] +``` + +```mermaid +--- +config: + themeVariables: + xyChart: + plotColorPalette: '#000000, #0000FF, #00FF00, #FF0000' +--- +xychart +title "Different Colors in xyChart" +x-axis "categoriesX" ["Category 1", "Category 2", "Category 3", "Category 4"] +y-axis "valuesY" 0 --> 50 +%% Black line +line [10,20,30,40] +%% Blue bar +bar [20,30,25,35] +%% Green bar +bar [15,25,20,30] +%% Red line +line [5,15,25,35] +``` + ## Example on config and theme ```mermaid-example diff --git a/eslint.config.js b/eslint.config.js index 7a144ee00..416fca2c6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,6 +17,7 @@ export default tseslint.config( ...tseslint.configs.stylisticTypeChecked, { ignores: [ + '**/*.d.ts', '**/dist/', '**/node_modules/', '.git/', diff --git a/packages/mermaid-layout-elk/README.md b/packages/mermaid-layout-elk/README.md index ab3289d6e..eec287263 100644 --- a/packages/mermaid-layout-elk/README.md +++ b/packages/mermaid-layout-elk/README.md @@ -2,7 +2,7 @@ This package provides a layout engine for Mermaid based on the [ELK](https://www.eclipse.org/elk/) layout engine. -> [!NOTE] +> [!NOTE] > The ELK Layout engine will not be available in all providers that support mermaid by default. > The websites will have to install the `@mermaid-js/layout-elk` package to use the ELK layout engine. @@ -69,4 +69,4 @@ mermaid.registerLayoutLoaders(elkLayouts); - `elk.mrtree`: Multi-root tree layout - `elk.sporeOverlap`: Spore overlap layout - + diff --git a/packages/mermaid-layout-elk/src/find-common-ancestor.d.ts b/packages/mermaid-layout-elk/src/find-common-ancestor.d.ts new file mode 100644 index 000000000..db94f42c9 --- /dev/null +++ b/packages/mermaid-layout-elk/src/find-common-ancestor.d.ts @@ -0,0 +1,9 @@ +export interface TreeData { + parentById: Record; + childrenById: Record; +} +export declare const findCommonAncestor: ( + id1: string, + id2: string, + { parentById }: TreeData +) => string; diff --git a/packages/mermaid-layout-elk/src/render.ts b/packages/mermaid-layout-elk/src/render.ts index d1c44b67f..d7d6974f5 100644 --- a/packages/mermaid-layout-elk/src/render.ts +++ b/packages/mermaid-layout-elk/src/render.ts @@ -4,7 +4,8 @@ import type { InternalHelpers, LayoutData, RenderOptions, SVG, SVGGroup } from ' import { type TreeData, findCommonAncestor } from './find-common-ancestor.js'; type Node = LayoutData['nodes'][number]; - +// Used to calculate distances in order to avoid floating number rounding issues when comparing floating numbers +const epsilon = 0.0001; interface LabelData { width: number; height: number; @@ -13,11 +14,20 @@ interface LabelData { } interface NodeWithVertex extends Omit { - children?: unknown[]; + children?: LayoutData['nodes']; labelData?: LabelData; domId?: Node['domId'] | SVGGroup | d3.Selection; } - +interface Point { + x: number; + y: number; +} +function distance(p1?: Point, p2?: Point): number { + if (!p1 || !p2) { + return 0; + } + return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); +} export const render = async ( data4Layout: LayoutData, svg: SVG, @@ -51,15 +61,30 @@ export const render = async ( // Add the element to the DOM if (!node.isGroup) { + // Create a clean node object for ELK with only the properties it expects const child: NodeWithVertex = { - ...node, + id: node.id, + width: node.width, + height: node.height, + // Store the original node data for later use + label: node.label, + isGroup: node.isGroup, + shape: node.shape, + padding: node.padding, + cssClasses: node.cssClasses, + cssStyles: node.cssStyles, + look: node.look, + // Include parentId for subgraph processing + parentId: node.parentId, }; graph.children.push(child); nodeDb[node.id] = child; const childNodeEl = await insertNode(nodeEl, node, { config, dir: node.dir }); const boundingBox = childNodeEl.node()!.getBBox(); + // Store the domId separately for rendering, not in the ELK graph child.domId = childNodeEl; + child.calcIntersect = node.calcIntersect; child.width = boundingBox.width; child.height = boundingBox.height; } else { @@ -459,302 +484,6 @@ export const render = async ( } } - function intersectLine( - p1: { y: number; x: number }, - p2: { y: number; x: number }, - q1: { x: any; y: any }, - q2: { x: any; y: any } - ) { - log.debug('UIO intersectLine', p1, p2, q1, q2); - // Algorithm from J. Avro, (ed.) Graphics Gems, No 2, Morgan Kaufmann, 1994, - // p7 and p473. - - // let a1, a2, b1, b2, c1, c2; - // let r1, r2, r3, r4; - // let denom, offset, num; - // let x, y; - - // Compute a1, b1, c1, where line joining points 1 and 2 is F(x,y) = a1 x + - // b1 y + c1 = 0. - const a1 = p2.y - p1.y; - const b1 = p1.x - p2.x; - const c1 = p2.x * p1.y - p1.x * p2.y; - - // Compute r3 and r4. - const r3 = a1 * q1.x + b1 * q1.y + c1; - const r4 = a1 * q2.x + b1 * q2.y + c1; - - const epsilon = 1e-6; - - // Check signs of r3 and r4. If both point 3 and point 4 lie on - // same side of line 1, the line segments do not intersect. - if (r3 !== 0 && r4 !== 0 && sameSign(r3, r4)) { - return /*DON'T_INTERSECT*/; - } - - // Compute a2, b2, c2 where line joining points 3 and 4 is G(x,y) = a2 x + b2 y + c2 = 0 - const a2 = q2.y - q1.y; - const b2 = q1.x - q2.x; - const c2 = q2.x * q1.y - q1.x * q2.y; - - // Compute r1 and r2 - const r1 = a2 * p1.x + b2 * p1.y + c2; - const r2 = a2 * p2.x + b2 * p2.y + c2; - - // Check signs of r1 and r2. If both point 1 and point 2 lie - // on same side of second line segment, the line segments do - // not intersect. - if (Math.abs(r1) < epsilon && Math.abs(r2) < epsilon && sameSign(r1, r2)) { - return /*DON'T_INTERSECT*/; - } - - // Line segments intersect: compute intersection point. - const denom = a1 * b2 - a2 * b1; - if (denom === 0) { - return /*COLLINEAR*/; - } - - const offset = Math.abs(denom / 2); - - // The denom/2 is to get rounding instead of truncating. It - // is added or subtracted to the numerator, depending upon the - // sign of the numerator. - let num = b1 * c2 - b2 * c1; - const x = num < 0 ? (num - offset) / denom : (num + offset) / denom; - - num = a2 * c1 - a1 * c2; - const y = num < 0 ? (num - offset) / denom : (num + offset) / denom; - - return { x: x, y: y }; - } - - function sameSign(r1: number, r2: number) { - return r1 * r2 > 0; - } - const diamondIntersection = ( - bounds: { x: any; y: any; width: any; height: any }, - outsidePoint: { x: number; y: number }, - insidePoint: any - ) => { - const x1 = bounds.x; - const y1 = bounds.y; - - const w = bounds.width; //+ bounds.padding; - const h = bounds.height; // + bounds.padding; - - const polyPoints = [ - { x: x1, y: y1 - h / 2 }, - { x: x1 + w / 2, y: y1 }, - { x: x1, y: y1 + h / 2 }, - { x: x1 - w / 2, y: y1 }, - ]; - log.debug( - `APA16 diamondIntersection calc abc89: - outsidePoint: ${JSON.stringify(outsidePoint)} - insidePoint : ${JSON.stringify(insidePoint)} - node-bounds : x:${bounds.x} y:${bounds.y} w:${bounds.width} h:${bounds.height}`, - JSON.stringify(polyPoints) - ); - - const intersections = []; - - let minX = Number.POSITIVE_INFINITY; - let minY = Number.POSITIVE_INFINITY; - - polyPoints.forEach(function (entry) { - minX = Math.min(minX, entry.x); - minY = Math.min(minY, entry.y); - }); - - const left = x1 - w / 2 - minX; - const top = y1 - h / 2 - minY; - - for (let i = 0; i < polyPoints.length; i++) { - const p1 = polyPoints[i]; - const p2 = polyPoints[i < polyPoints.length - 1 ? i + 1 : 0]; - const intersect = intersectLine( - bounds, - outsidePoint, - { x: left + p1.x, y: top + p1.y }, - { x: left + p2.x, y: top + p2.y } - ); - - if (intersect) { - intersections.push(intersect); - } - } - - if (!intersections.length) { - return bounds; - } - - log.debug('UIO intersections', intersections); - - if (intersections.length > 1) { - // More intersections, find the one nearest to edge end point - intersections.sort(function (p, q) { - const pdx = p.x - outsidePoint.x; - const pdy = p.y - outsidePoint.y; - const distp = Math.sqrt(pdx * pdx + pdy * pdy); - - const qdx = q.x - outsidePoint.x; - const qdy = q.y - outsidePoint.y; - const distq = Math.sqrt(qdx * qdx + qdy * qdy); - - return distp < distq ? -1 : distp === distq ? 0 : 1; - }); - } - - return intersections[0]; - }; - - const intersection = ( - node: { x: any; y: any; width: number; height: number }, - outsidePoint: { x: number; y: number }, - insidePoint: { x: number; y: number } - ) => { - log.debug(`intersection calc abc89: - outsidePoint: ${JSON.stringify(outsidePoint)} - insidePoint : ${JSON.stringify(insidePoint)} - node : x:${node.x} y:${node.y} w:${node.width} h:${node.height}`); - const x = node.x; - const y = node.y; - - const dx = Math.abs(x - insidePoint.x); - // const dy = Math.abs(y - insidePoint.y); - const w = node.width / 2; - let r = insidePoint.x < outsidePoint.x ? w - dx : w + dx; - const h = node.height / 2; - - const Q = Math.abs(outsidePoint.y - insidePoint.y); - const R = Math.abs(outsidePoint.x - insidePoint.x); - - if (Math.abs(y - outsidePoint.y) * w > Math.abs(x - outsidePoint.x) * h) { - // Intersection is top or bottom of rect. - const q = insidePoint.y < outsidePoint.y ? outsidePoint.y - h - y : y - h - outsidePoint.y; - r = (R * q) / Q; - const res = { - x: insidePoint.x < outsidePoint.x ? insidePoint.x + r : insidePoint.x - R + r, - y: insidePoint.y < outsidePoint.y ? insidePoint.y + Q - q : insidePoint.y - Q + q, - }; - - if (r === 0) { - res.x = outsidePoint.x; - res.y = outsidePoint.y; - } - if (R === 0) { - res.x = outsidePoint.x; - } - if (Q === 0) { - res.y = outsidePoint.y; - } - - log.debug(`abc89 topp/bott calc, Q ${Q}, q ${q}, R ${R}, r ${r}`, res); // cspell: disable-line - - return res; - } else { - // Intersection on sides of rect - if (insidePoint.x < outsidePoint.x) { - r = outsidePoint.x - w - x; - } else { - // r = outsidePoint.x - w - x; - r = x - w - outsidePoint.x; - } - const q = (Q * r) / R; - // OK let _x = insidePoint.x < outsidePoint.x ? insidePoint.x + R - r : insidePoint.x + dx - w; - // OK let _x = insidePoint.x < outsidePoint.x ? insidePoint.x + R - r : outsidePoint.x + r; - let _x = insidePoint.x < outsidePoint.x ? insidePoint.x + R - r : insidePoint.x - R + r; - // let _x = insidePoint.x < outsidePoint.x ? insidePoint.x + R - r : outsidePoint.x + r; - let _y = insidePoint.y < outsidePoint.y ? insidePoint.y + q : insidePoint.y - q; - log.debug(`sides calc abc89, Q ${Q}, q ${q}, R ${R}, r ${r}`, { _x, _y }); - if (r === 0) { - _x = outsidePoint.x; - _y = outsidePoint.y; - } - if (R === 0) { - _x = outsidePoint.x; - } - if (Q === 0) { - _y = outsidePoint.y; - } - - return { x: _x, y: _y }; - } - }; - const outsideNode = ( - node: { x: any; y: any; width: number; height: number }, - point: { x: number; y: number } - ) => { - const x = node.x; - const y = node.y; - const dx = Math.abs(point.x - x); - const dy = Math.abs(point.y - y); - const w = node.width / 2; - const h = node.height / 2; - if (dx >= w || dy >= h) { - return true; - } - return false; - }; - /** - * This function will page a path and node where the last point(s) in the path is inside the node - * and return an update path ending by the border of the node. - */ - const cutPathAtIntersect = ( - _points: any[], - bounds: { x: any; y: any; width: any; height: any; padding: any }, - isDiamond: boolean - ) => { - log.debug('APA18 cutPathAtIntersect Points:', _points, 'node:', bounds, 'isDiamond', isDiamond); - const points: any[] = []; - let lastPointOutside = _points[0]; - let isInside = false; - _points.forEach((point: any) => { - // check if point is inside the boundary rect - if (!outsideNode(bounds, point) && !isInside) { - // First point inside the rect found - // Calc the intersection coord between the point and the last point outside the rect - let inter; - - if (isDiamond) { - const inter2 = diamondIntersection(bounds, lastPointOutside, point); - const distance = Math.sqrt( - (lastPointOutside.x - inter2.x) ** 2 + (lastPointOutside.y - inter2.y) ** 2 - ); - if (distance > 1) { - inter = inter2; - } - } - if (!inter) { - inter = intersection(bounds, lastPointOutside, point); - } - - // Check case where the intersection is the same as the last point - let pointPresent = false; - points.forEach((p) => { - pointPresent = pointPresent || (p.x === inter.x && p.y === inter.y); - }); - // if (!pointPresent) { - if (!points.some((e) => e.x === inter.x && e.y === inter.y)) { - points.push(inter); - } else { - log.debug('abc88 no intersect', inter, points); - } - // points.push(inter); - isInside = true; - } else { - // Outside - log.debug('abc88 outside', point, lastPointOutside, points); - lastPointOutside = point; - // points.push(point); - if (!isInside) { - points.push(point); - } - } - }); - return points; - }; - // @ts-ignore - ELK is not typed const elk = new ELK(); const element = svg.select('g'); @@ -869,11 +598,16 @@ export const render = async ( delete node.height; } }); - elkGraph.edges.forEach((edge: any) => { + log.debug('APA01 processing edges, count:', elkGraph.edges.length); + elkGraph.edges.forEach((edge: any, index: number) => { + log.debug('APA01 processing edge', index, ':', edge); const source = edge.sources[0]; const target = edge.targets[0]; + log.debug('APA01 source:', source, 'target:', target); + log.debug('APA01 nodeDb[source]:', nodeDb[source]); + log.debug('APA01 nodeDb[target]:', nodeDb[target]); - if (nodeDb[source].parentId !== nodeDb[target].parentId) { + if (nodeDb[source] && nodeDb[target] && nodeDb[source].parentId !== nodeDb[target].parentId) { const ancestorId = findCommonAncestor(source, target, parentLookupDb); // an edge that breaks a subgraph has been identified, set configuration accordingly setIncludeChildrenPolicy(source, ancestorId); @@ -881,7 +615,37 @@ export const render = async ( } }); - const g = await elk.layout(elkGraph); + log.debug('APA01 before'); + log.debug('APA01 elkGraph structure:', JSON.stringify(elkGraph, null, 2)); + log.debug('APA01 elkGraph.children length:', elkGraph.children?.length); + log.debug('APA01 elkGraph.edges length:', elkGraph.edges?.length); + + // Validate that all edge references exist as nodes + elkGraph.edges?.forEach((edge: any, index: number) => { + log.debug(`APA01 validating edge ${index}:`, edge); + if (edge.sources) { + edge.sources.forEach((sourceId: any) => { + const sourceExists = elkGraph.children?.some((child: any) => child.id === sourceId); + log.debug(`APA01 source ${sourceId} exists:`, sourceExists); + }); + } + if (edge.targets) { + edge.targets.forEach((targetId: any) => { + const targetExists = elkGraph.children?.some((child: any) => child.id === targetId); + log.debug(`APA01 target ${targetId} exists:`, targetExists); + }); + } + }); + + let g; + try { + g = await elk.layout(elkGraph); + log.debug('APA01 after - success'); + log.debug('APA01 layout result:', JSON.stringify(g, null, 2)); + } catch (error) { + log.error('APA01 ELK layout error:', error); + throw error; + } // debugger; await drawNodes(0, 0, g.children, svg, subGraphsEl, 0); @@ -969,42 +733,37 @@ export const render = async ( startNode.innerHTML ); } - if (startNode.shape === 'diamond' || startNode.shape === 'diam') { - edge.points.unshift({ - x: startNode.offset.posX + startNode.width / 2, - y: startNode.offset.posY + startNode.height / 2, - }); - } - if (endNode.shape === 'diamond' || endNode.shape === 'diam') { - edge.points.push({ - x: endNode.offset.posX + endNode.width / 2, - y: endNode.offset.posY + endNode.height / 2, - }); - } - edge.points = cutPathAtIntersect( - edge.points.reverse(), - { - x: startNode.offset.posX + startNode.width / 2, - y: startNode.offset.posY + startNode.height / 2, - width: sw, - height: startNode.height, - padding: startNode.padding, - }, - startNode.shape === 'diamond' || startNode.shape === 'diam' - ).reverse(); + if (startNode.calcIntersect) { + const intersection = startNode.calcIntersect( + { + x: startNode.offset.posX + startNode.width / 2, + y: startNode.offset.posY + startNode.height / 2, + width: startNode.width, + height: startNode.height, + }, + edge.points[0] + ); - edge.points = cutPathAtIntersect( - edge.points, - { - x: endNode.offset.posX + endNode.width / 2, - y: endNode.offset.posY + endNode.height / 2, - width: ew, - height: endNode.height, - padding: endNode.padding, - }, - endNode.shape === 'diamond' || endNode.shape === 'diam' - ); + if (distance(intersection, edge.points[0]) > epsilon) { + edge.points.unshift(intersection); + } + } + if (endNode.calcIntersect) { + const intersection = endNode.calcIntersect( + { + x: endNode.offset.posX + endNode.width / 2, + y: endNode.offset.posY + endNode.height / 2, + width: endNode.width, + height: endNode.height, + }, + edge.points[edge.points.length - 1] + ); + + if (distance(intersection, edge.points[edge.points.length - 1]) > epsilon) { + edge.points.push(intersection); + } + } const paths = insertEdge( edgesEl, @@ -1015,7 +774,6 @@ export const render = async ( endNode, data4Layout.diagramId ); - log.info('APA12 edge points after insert', JSON.stringify(edge.points)); edge.x = edge.labels[0].x + offset.x + edge.labels[0].width / 2; edge.y = edge.labels[0].y + offset.y + edge.labels[0].height / 2; diff --git a/packages/mermaid-layout-elk/tsconfig.json b/packages/mermaid-layout-elk/tsconfig.json index 0d701cede..8f83e2bad 100644 --- a/packages/mermaid-layout-elk/tsconfig.json +++ b/packages/mermaid-layout-elk/tsconfig.json @@ -5,6 +5,6 @@ "outDir": "./dist", "types": ["vitest/importMeta", "vitest/globals"] }, - "include": ["./src/**/*.ts"], + "include": ["./src/**/*.ts", "./src/**/*.d.ts"], "typeRoots": ["./src/types"] } diff --git a/packages/mermaid-layout-tidy-tree/README.md b/packages/mermaid-layout-tidy-tree/README.md new file mode 100644 index 000000000..e8ae05f4c --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/README.md @@ -0,0 +1,59 @@ +# @mermaid-js/layout-tidy-tree + +This package provides a bidirectional tidy tree layout engine for Mermaid based on the non-layered-tidy-tree-layout algorithm. + +> [!NOTE] +> The Tidy Tree Layout engine will not be available in all providers that support mermaid by default. +> The websites will have to install the @mermaid-js/layout-tidy-tree package to use the Tidy Tree layout engine. + +## Usage + +``` +--- +config: + layout: tidy-tree +--- +mindmap +root((mindmap)) + A + B +``` + +### With bundlers + +```sh +npm install @mermaid-js/layout-tidy-tree +``` + +```ts +import mermaid from 'mermaid'; +import tidyTreeLayouts from '@mermaid-js/layout-tidy-tree'; + +mermaid.registerLayoutLoaders(tidyTreeLayouts); +``` + +### With CDN + +```html + +``` + +## Tidy Tree Layout Overview + +tidy-tree: The bidirectional tidy tree layout + +The bidirectional tidy tree layout algorithm creates two separate trees that grow horizontally in opposite directions from a central root node: +Left tree: grows horizontally to the left (children alternate: 1st, 3rd, 5th...) +Right tree: grows horizontally to the right (children alternate: 2nd, 4th, 6th...) + +This creates a balanced, symmetric layout that is ideal for mindmaps, organizational charts, and other tree-based diagrams. + +Layout Structure: +[Child 3] ← [Child 1] ← [Root] → [Child 2] → [Child 4] +↓ ↓ ↓ ↓ +[GrandChild] [GrandChild] [GrandChild] [GrandChild] diff --git a/packages/mermaid-layout-tidy-tree/package.json b/packages/mermaid-layout-tidy-tree/package.json new file mode 100644 index 000000000..d8c3ed965 --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/package.json @@ -0,0 +1,46 @@ +{ + "name": "@mermaid-js/layout-tidy-tree", + "version": "0.1.0", + "description": "Tidy-tree layout engine for mermaid", + "module": "dist/mermaid-layout-tidy-tree.core.mjs", + "types": "dist/layouts.d.ts", + "type": "module", + "exports": { + ".": { + "import": "./dist/mermaid-layout-tidy-tree.core.mjs", + "types": "./dist/layouts.d.ts" + }, + "./": "./" + }, + "keywords": [ + "diagram", + "markdown", + "tidy-tree", + "mermaid", + "layout" + ], + "scripts": {}, + "repository": { + "type": "git", + "url": "https://github.com/mermaid-js/mermaid" + }, + "contributors": [ + "Knut Sveidqvist", + "Sidharth Vinod" + ], + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "non-layered-tidy-tree-layout": "^2.0.2" + }, + "devDependencies": { + "@types/d3": "^7.4.3", + "mermaid": "workspace:^" + }, + "peerDependencies": { + "mermaid": "^11.0.2" + }, + "files": [ + "dist" + ] +} diff --git a/packages/mermaid-layout-tidy-tree/src/index.ts b/packages/mermaid-layout-tidy-tree/src/index.ts new file mode 100644 index 000000000..2be1b59e6 --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/src/index.ts @@ -0,0 +1,50 @@ +/** + * Bidirectional Tidy-Tree Layout Algorithm for Generic Diagrams + * + * This module provides a layout algorithm implementation using the + * non-layered-tidy-tree-layout algorithm for positioning nodes and edges + * in tree structures with a bidirectional approach. + * + * The algorithm creates two separate trees that grow horizontally in opposite + * directions from a central root node: + * - Left tree: grows horizontally to the left (children alternate: 1st, 3rd, 5th...) + * - Right tree: grows horizontally to the right (children alternate: 2nd, 4th, 6th...) + * + * This creates a balanced, symmetric layout that is ideal for mindmaps, + * organizational charts, and other tree-based diagrams. + * + * The algorithm follows the unified rendering pattern and can be used + * by any diagram type that provides compatible LayoutData. + */ + +/** + * Render function for the bidirectional tidy-tree layout algorithm + * + * This function follows the unified rendering pattern used by all layout algorithms. + * It takes LayoutData, inserts nodes into DOM, runs the bidirectional tidy-tree layout algorithm, + * and renders the positioned elements to the SVG. + * + * Features: + * - Alternates root children between left and right trees + * - Left tree grows horizontally to the left (rotated 90° counterclockwise) + * - Right tree grows horizontally to the right (rotated 90° clockwise) + * - Uses tidy-tree algorithm for optimal spacing within each tree + * - Creates symmetric, balanced layouts + * - Maintains proper edge connections between all nodes + * + * Layout Structure: + * ``` + * [Child 3] ← [Child 1] ← [Root] → [Child 2] → [Child 4] + * ↓ ↓ ↓ ↓ + * [GrandChild] [GrandChild] [GrandChild] [GrandChild] + * ``` + * + * @param layoutData - Layout data containing nodes, edges, and configuration + * @param svg - SVG element to render to + * @param helpers - Internal helper functions for rendering + * @param options - Rendering options + */ +export { default } from './layouts.js'; +export * from './types.js'; +export * from './layout.js'; +export { render } from './render.js'; diff --git a/packages/mermaid-layout-tidy-tree/src/layout.test.ts b/packages/mermaid-layout-tidy-tree/src/layout.test.ts new file mode 100644 index 000000000..2b3b79b37 --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/src/layout.test.ts @@ -0,0 +1,409 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { executeTidyTreeLayout, validateLayoutData } from './layout.js'; +import type { LayoutResult } from './types.js'; +import type { LayoutData, MermaidConfig } from 'mermaid'; + +// Mock non-layered-tidy-tree-layout +vi.mock('non-layered-tidy-tree-layout', () => ({ + BoundingBox: vi.fn().mockImplementation(() => ({})), + Layout: vi.fn().mockImplementation(() => ({ + layout: vi.fn().mockImplementation((treeData) => { + const result = { ...treeData }; + + if (result.id?.toString().startsWith('virtual-root')) { + result.x = 0; + result.y = 0; + } else { + result.x = 100; + result.y = 50; + } + + if (result.children) { + result.children.forEach((child: any, index: number) => { + child.x = 50 + index * 100; + child.y = 100; + + if (child.children) { + child.children.forEach((grandchild: any, gIndex: number) => { + grandchild.x = 25 + gIndex * 50; + grandchild.y = 200; + }); + } + }); + } + + return { + result, + boundingBox: { + left: 0, + right: 200, + top: 0, + bottom: 250, + }, + }; + }), + })), +})); + +describe('Tidy-Tree Layout Algorithm', () => { + let mockConfig: MermaidConfig; + let mockLayoutData: LayoutData; + + beforeEach(() => { + mockConfig = { + theme: 'default', + } as MermaidConfig; + + mockLayoutData = { + nodes: [ + { + id: 'root', + label: 'Root', + isGroup: false, + shape: 'rect', + width: 100, + height: 50, + padding: 10, + x: 0, + y: 0, + cssClasses: '', + cssStyles: [], + look: 'default', + }, + { + id: 'child1', + label: 'Child 1', + isGroup: false, + shape: 'rect', + width: 80, + height: 40, + padding: 10, + x: 0, + y: 0, + cssClasses: '', + cssStyles: [], + look: 'default', + }, + { + id: 'child2', + label: 'Child 2', + isGroup: false, + shape: 'rect', + width: 80, + height: 40, + padding: 10, + x: 0, + y: 0, + cssClasses: '', + cssStyles: [], + look: 'default', + }, + { + id: 'child3', + label: 'Child 3', + isGroup: false, + shape: 'rect', + width: 80, + height: 40, + padding: 10, + x: 0, + y: 0, + cssClasses: '', + cssStyles: [], + look: 'default', + }, + { + id: 'child4', + label: 'Child 4', + isGroup: false, + shape: 'rect', + width: 80, + height: 40, + padding: 10, + x: 0, + y: 0, + cssClasses: '', + cssStyles: [], + look: 'default', + }, + ], + edges: [ + { + id: 'root_child1', + start: 'root', + end: 'child1', + type: 'edge', + classes: '', + style: [], + animate: false, + arrowTypeEnd: 'arrow_point', + arrowTypeStart: 'none', + }, + { + id: 'root_child2', + start: 'root', + end: 'child2', + type: 'edge', + classes: '', + style: [], + animate: false, + arrowTypeEnd: 'arrow_point', + arrowTypeStart: 'none', + }, + { + id: 'root_child3', + start: 'root', + end: 'child3', + type: 'edge', + classes: '', + style: [], + animate: false, + arrowTypeEnd: 'arrow_point', + arrowTypeStart: 'none', + }, + { + id: 'root_child4', + start: 'root', + end: 'child4', + type: 'edge', + classes: '', + style: [], + animate: false, + arrowTypeEnd: 'arrow_point', + arrowTypeStart: 'none', + }, + ], + config: mockConfig, + direction: 'TB', + type: 'test', + diagramId: 'test-diagram', + markers: [], + }; + }); + + describe('validateLayoutData', () => { + it('should validate correct layout data', () => { + expect(() => validateLayoutData(mockLayoutData)).not.toThrow(); + }); + + it('should throw error for missing data', () => { + expect(() => validateLayoutData(null as any)).toThrow('Layout data is required'); + }); + + it('should throw error for missing config', () => { + const invalidData = { ...mockLayoutData, config: null as any }; + expect(() => validateLayoutData(invalidData)).toThrow('Configuration is required'); + }); + + it('should throw error for invalid nodes array', () => { + const invalidData = { ...mockLayoutData, nodes: null as any }; + expect(() => validateLayoutData(invalidData)).toThrow('Nodes array is required'); + }); + + it('should throw error for invalid edges array', () => { + const invalidData = { ...mockLayoutData, edges: null as any }; + expect(() => validateLayoutData(invalidData)).toThrow('Edges array is required'); + }); + }); + + describe('executeTidyTreeLayout function', () => { + it('should execute layout algorithm successfully', async () => { + const result: LayoutResult = await executeTidyTreeLayout(mockLayoutData); + + expect(result).toBeDefined(); + expect(result.nodes).toBeDefined(); + expect(result.edges).toBeDefined(); + expect(Array.isArray(result.nodes)).toBe(true); + expect(Array.isArray(result.edges)).toBe(true); + }); + + it('should return positioned nodes with coordinates', async () => { + const result: LayoutResult = await executeTidyTreeLayout(mockLayoutData); + + expect(result.nodes.length).toBeGreaterThan(0); + result.nodes.forEach((node) => { + expect(node.x).toBeDefined(); + expect(node.y).toBeDefined(); + expect(typeof node.x).toBe('number'); + expect(typeof node.y).toBe('number'); + }); + }); + + it('should return positioned edges with coordinates', async () => { + const result: LayoutResult = await executeTidyTreeLayout(mockLayoutData); + + expect(result.edges.length).toBeGreaterThan(0); + result.edges.forEach((edge) => { + expect(edge.startX).toBeDefined(); + expect(edge.startY).toBeDefined(); + expect(edge.midX).toBeDefined(); + expect(edge.midY).toBeDefined(); + expect(edge.endX).toBeDefined(); + expect(edge.endY).toBeDefined(); + }); + }); + + it('should handle empty layout data gracefully', async () => { + const emptyData: LayoutData = { + ...mockLayoutData, + nodes: [], + edges: [], + }; + + await expect(executeTidyTreeLayout(emptyData)).rejects.toThrow( + 'No nodes found in layout data' + ); + }); + + it('should throw error for missing nodes', async () => { + const invalidData = { ...mockLayoutData, nodes: [] }; + + await expect(executeTidyTreeLayout(invalidData)).rejects.toThrow( + 'No nodes found in layout data' + ); + }); + + it('should handle empty edges (single node tree)', async () => { + const singleNodeData = { + ...mockLayoutData, + edges: [], + nodes: [mockLayoutData.nodes[0]], + }; + + const result = await executeTidyTreeLayout(singleNodeData); + expect(result).toBeDefined(); + expect(result.nodes).toHaveLength(1); + expect(result.edges).toHaveLength(0); + }); + + it('should create bidirectional dual-tree layout with alternating left/right children', async () => { + const result = await executeTidyTreeLayout(mockLayoutData); + + expect(result).toBeDefined(); + expect(result.nodes).toHaveLength(5); + + const rootNode = result.nodes.find((node) => node.id === 'root'); + expect(rootNode).toBeDefined(); + expect(rootNode!.x).toBe(0); + expect(rootNode!.y).toBe(20); + + const child1 = result.nodes.find((node) => node.id === 'child1'); + const child2 = result.nodes.find((node) => node.id === 'child2'); + const child3 = result.nodes.find((node) => node.id === 'child3'); + const child4 = result.nodes.find((node) => node.id === 'child4'); + + expect(child1).toBeDefined(); + expect(child2).toBeDefined(); + expect(child3).toBeDefined(); + expect(child4).toBeDefined(); + + expect(child1!.x).toBeLessThan(rootNode!.x); + expect(child2!.x).toBeGreaterThan(rootNode!.x); + expect(child3!.x).toBeLessThan(rootNode!.x); + expect(child4!.x).toBeGreaterThan(rootNode!.x); + + expect(child1!.x).toBeLessThan(-100); + expect(child3!.x).toBeLessThan(-100); + + expect(child2!.x).toBeGreaterThan(100); + expect(child4!.x).toBeGreaterThan(100); + }); + + it('should correctly transpose coordinates to prevent high nodes from covering nodes above them', async () => { + const testData = { + ...mockLayoutData, + nodes: [ + { + id: 'root', + label: 'Root', + isGroup: false, + shape: 'rect' as const, + width: 100, + height: 50, + padding: 10, + x: 0, + y: 0, + cssClasses: '', + cssStyles: [], + look: 'default', + }, + { + id: 'tall-child', + label: 'Tall Child', + isGroup: false, + shape: 'rect' as const, + width: 80, + height: 120, + padding: 10, + x: 0, + y: 0, + cssClasses: '', + cssStyles: [], + look: 'default', + }, + { + id: 'short-child', + label: 'Short Child', + isGroup: false, + shape: 'rect' as const, + width: 80, + height: 30, + padding: 10, + x: 0, + y: 0, + cssClasses: '', + cssStyles: [], + look: 'default', + }, + ], + edges: [ + { + id: 'root_tall', + start: 'root', + end: 'tall-child', + type: 'edge', + classes: '', + style: [], + animate: false, + arrowTypeEnd: 'arrow_point', + arrowTypeStart: 'none', + }, + { + id: 'root_short', + start: 'root', + end: 'short-child', + type: 'edge', + classes: '', + style: [], + animate: false, + arrowTypeEnd: 'arrow_point', + arrowTypeStart: 'none', + }, + ], + }; + + const result = await executeTidyTreeLayout(testData); + + expect(result).toBeDefined(); + expect(result.nodes).toHaveLength(3); + + const rootNode = result.nodes.find((node) => node.id === 'root'); + const tallChild = result.nodes.find((node) => node.id === 'tall-child'); + const shortChild = result.nodes.find((node) => node.id === 'short-child'); + + expect(rootNode).toBeDefined(); + expect(tallChild).toBeDefined(); + expect(shortChild).toBeDefined(); + + expect(tallChild!.x).not.toBe(shortChild!.x); + + expect(tallChild!.width).toBe(80); + expect(tallChild!.height).toBe(120); + expect(shortChild!.width).toBe(80); + expect(shortChild!.height).toBe(30); + + const yDifference = Math.abs(tallChild!.y - shortChild!.y); + expect(yDifference).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/packages/mermaid-layout-tidy-tree/src/layout.ts b/packages/mermaid-layout-tidy-tree/src/layout.ts new file mode 100644 index 000000000..6cc06a9ab --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/src/layout.ts @@ -0,0 +1,629 @@ +import type { LayoutData } from 'mermaid'; +import type { Bounds, Point } from 'mermaid/src/types.js'; +import { BoundingBox, Layout } from 'non-layered-tidy-tree-layout'; +import type { + Edge, + LayoutResult, + Node, + PositionedEdge, + PositionedNode, + TidyTreeNode, +} from './types.js'; + +/** + * Execute the tidy-tree layout algorithm on generic layout data + * + * This function takes layout data and uses the non-layered-tidy-tree-layout + * algorithm to calculate optimal node positions for tree structures. + * + * @param data - The layout data containing nodes, edges, and configuration + * @param config - Mermaid configuration object + * @returns Promise resolving to layout result with positioned nodes and edges + */ +export function executeTidyTreeLayout(data: LayoutData): Promise { + let intersectionShift = 50; + + return new Promise((resolve, reject) => { + try { + if (!data.nodes || !Array.isArray(data.nodes) || data.nodes.length === 0) { + throw new Error('No nodes found in layout data'); + } + + if (!data.edges || !Array.isArray(data.edges)) { + data.edges = []; + } + + const { leftTree, rightTree, rootNode } = convertToDualTreeFormat(data); + + const gap = 20; + const bottomPadding = 40; + intersectionShift = 30; + + const bb = new BoundingBox(gap, bottomPadding); + const layout = new Layout(bb); + + let leftResult = null; + let rightResult = null; + + if (leftTree) { + const leftLayoutResult = layout.layout(leftTree); + leftResult = leftLayoutResult.result; + } + + if (rightTree) { + const rightLayoutResult = layout.layout(rightTree); + rightResult = rightLayoutResult.result; + } + + const positionedNodes = combineAndPositionTrees(rootNode, leftResult, rightResult); + const positionedEdges = calculateEdgePositions( + data.edges, + positionedNodes, + intersectionShift + ); + resolve({ + nodes: positionedNodes, + edges: positionedEdges, + }); + } catch (error) { + reject(error); + } + }); +} + +/** + * Convert LayoutData to dual-tree format (left and right trees) + * + * This function builds two separate tree structures from the nodes and edges, + * alternating children between left and right trees. + */ +function convertToDualTreeFormat(data: LayoutData): { + leftTree: TidyTreeNode | null; + rightTree: TidyTreeNode | null; + rootNode: TidyTreeNode; +} { + const { nodes, edges } = data; + + const nodeMap = new Map(); + nodes.forEach((node) => nodeMap.set(node.id, node)); + + const children = new Map(); + const parents = new Map(); + + edges.forEach((edge) => { + const parentId = edge.start; + const childId = edge.end; + + if (parentId && childId) { + if (!children.has(parentId)) { + children.set(parentId, []); + } + children.get(parentId)!.push(childId); + parents.set(childId, parentId); + } + }); + + const rootNodeData = nodes.find((node) => !parents.has(node.id)); + if (!rootNodeData && nodes.length === 0) { + throw new Error('No nodes available to create tree'); + } + + const actualRoot = rootNodeData ?? nodes[0]; + + const rootNode: TidyTreeNode = { + id: actualRoot.id, + width: actualRoot.width ?? 100, + height: actualRoot.height ?? 50, + _originalNode: actualRoot, + }; + + const rootChildren = children.get(actualRoot.id) ?? []; + const leftChildren: string[] = []; + const rightChildren: string[] = []; + + rootChildren.forEach((childId, index) => { + if (index % 2 === 0) { + leftChildren.push(childId); + } else { + rightChildren.push(childId); + } + }); + + const leftTree = leftChildren.length > 0 ? buildSubTree(leftChildren, children, nodeMap) : null; + + const rightTree = + rightChildren.length > 0 ? buildSubTree(rightChildren, children, nodeMap) : null; + + return { leftTree, rightTree, rootNode }; +} + +/** + * Build a subtree from a list of root children + * For horizontal trees, we need to transpose width/height since the tree will be rotated 90° + */ +function buildSubTree( + rootChildren: string[], + children: Map, + nodeMap: Map +): TidyTreeNode { + const virtualRoot: TidyTreeNode = { + id: `virtual-root-${Math.random()}`, + width: 1, + height: 1, + children: rootChildren + .map((childId) => nodeMap.get(childId)) + .filter((child): child is Node => child !== undefined) + .map((child) => convertNodeToTidyTreeTransposed(child, children, nodeMap)), + }; + + return virtualRoot; +} + +/** + * Recursively convert a node and its children to tidy-tree format + * This version transposes width/height for horizontal tree layout + */ +function convertNodeToTidyTreeTransposed( + node: Node, + children: Map, + nodeMap: Map +): TidyTreeNode { + const childIds = children.get(node.id) ?? []; + const childNodes = childIds + .map((childId) => nodeMap.get(childId)) + .filter((child): child is Node => child !== undefined) + .map((child) => convertNodeToTidyTreeTransposed(child, children, nodeMap)); + + return { + id: node.id, + width: node.height ?? 50, + height: node.width ?? 100, + children: childNodes.length > 0 ? childNodes : undefined, + _originalNode: node, + }; +} +/** + * Combine and position the left and right trees around the root node + * Creates a bidirectional layout where left tree grows left and right tree grows right + */ +function combineAndPositionTrees( + rootNode: TidyTreeNode, + leftResult: TidyTreeNode | null, + rightResult: TidyTreeNode | null +): PositionedNode[] { + const positionedNodes: PositionedNode[] = []; + + const rootX = 0; + const rootY = 0; + + const treeSpacing = rootNode.width / 2 + 30; + const leftTreeNodes: PositionedNode[] = []; + const rightTreeNodes: PositionedNode[] = []; + + if (leftResult?.children) { + positionLeftTreeBidirectional(leftResult.children, leftTreeNodes, rootX - treeSpacing, rootY); + } + + if (rightResult?.children) { + positionRightTreeBidirectional( + rightResult.children, + rightTreeNodes, + rootX + treeSpacing, + rootY + ); + } + + let leftTreeCenterY = 0; + let rightTreeCenterY = 0; + + if (leftTreeNodes.length > 0) { + const leftTreeXPositions = [...new Set(leftTreeNodes.map((node) => node.x))].sort( + (a, b) => b - a + ); + const firstLevelLeftX = leftTreeXPositions[0]; + const firstLevelLeftNodes = leftTreeNodes.filter((node) => node.x === firstLevelLeftX); + + if (firstLevelLeftNodes.length > 0) { + const leftMinY = Math.min( + ...firstLevelLeftNodes.map((node) => node.y - (node.height ?? 50) / 2) + ); + const leftMaxY = Math.max( + ...firstLevelLeftNodes.map((node) => node.y + (node.height ?? 50) / 2) + ); + leftTreeCenterY = (leftMinY + leftMaxY) / 2; + } + } + + if (rightTreeNodes.length > 0) { + const rightTreeXPositions = [...new Set(rightTreeNodes.map((node) => node.x))].sort( + (a, b) => a - b + ); + const firstLevelRightX = rightTreeXPositions[0]; + const firstLevelRightNodes = rightTreeNodes.filter((node) => node.x === firstLevelRightX); + + if (firstLevelRightNodes.length > 0) { + const rightMinY = Math.min( + ...firstLevelRightNodes.map((node) => node.y - (node.height ?? 50) / 2) + ); + const rightMaxY = Math.max( + ...firstLevelRightNodes.map((node) => node.y + (node.height ?? 50) / 2) + ); + rightTreeCenterY = (rightMinY + rightMaxY) / 2; + } + } + + const leftTreeOffset = -leftTreeCenterY; + const rightTreeOffset = -rightTreeCenterY; + + positionedNodes.push({ + id: String(rootNode.id), + x: rootX, + y: rootY + 20, + section: 'root', + width: rootNode._originalNode?.width ?? rootNode.width, + height: rootNode._originalNode?.height ?? rootNode.height, + originalNode: rootNode._originalNode, + }); + + const leftTreeNodesWithOffset = leftTreeNodes.map((node) => ({ + id: node.id, + x: node.x - (node.width ?? 0) / 2, + y: node.y + leftTreeOffset + (node.height ?? 0) / 2, + section: 'left' as const, + width: node.width, + height: node.height, + originalNode: node.originalNode, + })); + + const rightTreeNodesWithOffset = rightTreeNodes.map((node) => ({ + id: node.id, + x: node.x + (node.width ?? 0) / 2, + y: node.y + rightTreeOffset + (node.height ?? 0) / 2, + section: 'right' as const, + width: node.width, + height: node.height, + originalNode: node.originalNode, + })); + + positionedNodes.push(...leftTreeNodesWithOffset); + positionedNodes.push(...rightTreeNodesWithOffset); + + return positionedNodes; +} + +/** + * Position nodes from the left tree in a bidirectional layout (grows to the left) + * Rotates the tree 90 degrees counterclockwise so it grows horizontally to the left + */ +function positionLeftTreeBidirectional( + nodes: TidyTreeNode[], + positionedNodes: PositionedNode[], + offsetX: number, + offsetY: number +): void { + nodes.forEach((node) => { + const distanceFromRoot = node.y ?? 0; + const verticalPosition = node.x ?? 0; + + const originalWidth = node._originalNode?.width ?? 100; + const originalHeight = node._originalNode?.height ?? 50; + + const adjustedY = offsetY + verticalPosition; + + positionedNodes.push({ + id: String(node.id), + x: offsetX - distanceFromRoot, + y: adjustedY, + width: originalWidth, + height: originalHeight, + originalNode: node._originalNode, + }); + + if (node.children) { + positionLeftTreeBidirectional(node.children, positionedNodes, offsetX, offsetY); + } + }); +} + +/** + * Position nodes from the right tree in a bidirectional layout (grows to the right) + * Rotates the tree 90 degrees clockwise so it grows horizontally to the right + */ +function positionRightTreeBidirectional( + nodes: TidyTreeNode[], + positionedNodes: PositionedNode[], + offsetX: number, + offsetY: number +): void { + nodes.forEach((node) => { + const distanceFromRoot = node.y ?? 0; + const verticalPosition = node.x ?? 0; + + const originalWidth = node._originalNode?.width ?? 100; + const originalHeight = node._originalNode?.height ?? 50; + + const adjustedY = offsetY + verticalPosition; + + positionedNodes.push({ + id: String(node.id), + x: offsetX + distanceFromRoot, + y: adjustedY, + width: originalWidth, + height: originalHeight, + originalNode: node._originalNode, + }); + + if (node.children) { + positionRightTreeBidirectional(node.children, positionedNodes, offsetX, offsetY); + } + }); +} + +/** + * Calculate the intersection point of a line with a circle + * @param circle - Circle coordinates and radius + * @param lineStart - Starting point of the line + * @param lineEnd - Ending point of the line + * @returns The intersection point + */ +function computeCircleEdgeIntersection(circle: Bounds, lineStart: Point, lineEnd: Point): Point { + const radius = Math.min(circle.width, circle.height) / 2; + + const dx = lineEnd.x - lineStart.x; + const dy = lineEnd.y - lineStart.y; + const length = Math.sqrt(dx * dx + dy * dy); + + if (length === 0) { + return lineStart; + } + + const nx = dx / length; + const ny = dy / length; + + return { + x: circle.x - nx * radius, + y: circle.y - ny * radius, + }; +} + +function intersection(node: PositionedNode, outsidePoint: Point, insidePoint: Point): Point { + const x = node.x; + const y = node.y; + + if (!node.width || !node.height) { + return { x: outsidePoint.x, y: outsidePoint.y }; + } + const dx = Math.abs(x - insidePoint.x); + const w = node?.width / 2; + let r = insidePoint.x < outsidePoint.x ? w - dx : w + dx; + const h = node.height / 2; + + const Q = Math.abs(outsidePoint.y - insidePoint.y); + const R = Math.abs(outsidePoint.x - insidePoint.x); + + if (Math.abs(y - outsidePoint.y) * w > Math.abs(x - outsidePoint.x) * h) { + // Intersection is top or bottom of rect. + const q = insidePoint.y < outsidePoint.y ? outsidePoint.y - h - y : y - h - outsidePoint.y; + r = (R * q) / Q; + const res = { + x: insidePoint.x < outsidePoint.x ? insidePoint.x + r : insidePoint.x - R + r, + y: insidePoint.y < outsidePoint.y ? insidePoint.y + Q - q : insidePoint.y - Q + q, + }; + + if (r === 0) { + res.x = outsidePoint.x; + res.y = outsidePoint.y; + } + if (R === 0) { + res.x = outsidePoint.x; + } + if (Q === 0) { + res.y = outsidePoint.y; + } + + return res; + } else { + if (insidePoint.x < outsidePoint.x) { + r = outsidePoint.x - w - x; + } else { + r = x - w - outsidePoint.x; + } + const q = (Q * r) / R; + let _x = insidePoint.x < outsidePoint.x ? insidePoint.x + R - r : insidePoint.x - R + r; + let _y = insidePoint.y < outsidePoint.y ? insidePoint.y + q : insidePoint.y - q; + + if (r === 0) { + _x = outsidePoint.x; + _y = outsidePoint.y; + } + if (R === 0) { + _x = outsidePoint.x; + } + if (Q === 0) { + _y = outsidePoint.y; + } + + return { x: _x, y: _y }; + } +} + +/** + * Calculate edge positions based on positioned nodes + * Now includes tree membership and node dimensions for precise edge calculations + * Edges now stop at shape boundaries instead of extending to centers + */ +function calculateEdgePositions( + edges: Edge[], + positionedNodes: PositionedNode[], + intersectionShift: number +): PositionedEdge[] { + const nodeInfo = new Map(); + positionedNodes.forEach((node) => { + nodeInfo.set(node.id, node); + }); + + return edges.map((edge) => { + const sourceNode = nodeInfo.get(edge.start ?? ''); + const targetNode = nodeInfo.get(edge.end ?? ''); + + if (!sourceNode || !targetNode) { + return { + id: edge.id, + source: edge.start ?? '', + target: edge.end ?? '', + startX: 0, + startY: 0, + midX: 0, + midY: 0, + endX: 0, + endY: 0, + points: [{ x: 0, y: 0 }], + sourceSection: undefined, + targetSection: undefined, + sourceWidth: undefined, + sourceHeight: undefined, + targetWidth: undefined, + targetHeight: undefined, + }; + } + + const sourceCenter = { x: sourceNode.x, y: sourceNode.y }; + const targetCenter = { x: targetNode.x, y: targetNode.y }; + + const isSourceRound = ['circle', 'cloud', 'bang'].includes( + sourceNode.originalNode?.shape ?? '' + ); + const isTargetRound = ['circle', 'cloud', 'bang'].includes( + targetNode.originalNode?.shape ?? '' + ); + + let startPos = isSourceRound + ? computeCircleEdgeIntersection( + { + x: sourceNode.x, + y: sourceNode.y, + width: sourceNode.width ?? 100, + height: sourceNode.height ?? 100, + }, + targetCenter, + sourceCenter + ) + : intersection(sourceNode, sourceCenter, targetCenter); + + let endPos = isTargetRound + ? computeCircleEdgeIntersection( + { + x: targetNode.x, + y: targetNode.y, + width: targetNode.width ?? 100, + height: targetNode.height ?? 100, + }, + sourceCenter, + targetCenter + ) + : intersection(targetNode, targetCenter, sourceCenter); + + const midX = (startPos.x + endPos.x) / 2; + const midY = (startPos.y + endPos.y) / 2; + + const points = [startPos]; + if (sourceNode.section === 'left') { + points.push({ + x: sourceNode.x - (sourceNode.width ?? 0) / 2 - intersectionShift, + y: sourceNode.y, + }); + } else if (sourceNode.section === 'right') { + points.push({ + x: sourceNode.x + (sourceNode.width ?? 0) / 2 + intersectionShift, + y: sourceNode.y, + }); + } + if (targetNode.section === 'left') { + points.push({ + x: targetNode.x + (targetNode.width ?? 0) / 2 + intersectionShift, + y: targetNode.y, + }); + } else if (targetNode.section === 'right') { + points.push({ + x: targetNode.x - (targetNode.width ?? 0) / 2 - intersectionShift, + y: targetNode.y, + }); + } + + points.push(endPos); + + const secondPoint = points.length > 1 ? points[1] : targetCenter; + startPos = isSourceRound + ? computeCircleEdgeIntersection( + { + x: sourceNode.x, + y: sourceNode.y, + width: sourceNode.width ?? 100, + height: sourceNode.height ?? 100, + }, + secondPoint, + sourceCenter + ) + : intersection(sourceNode, secondPoint, sourceCenter); + points[0] = startPos; + + const secondLastPoint = points.length > 1 ? points[points.length - 2] : sourceCenter; + endPos = isTargetRound + ? computeCircleEdgeIntersection( + { + x: targetNode.x, + y: targetNode.y, + width: targetNode.width ?? 100, + height: targetNode.height ?? 100, + }, + secondLastPoint, + targetCenter + ) + : intersection(targetNode, secondLastPoint, targetCenter); + points[points.length - 1] = endPos; + + return { + id: edge.id, + source: edge.start ?? '', + target: edge.end ?? '', + startX: startPos.x, + startY: startPos.y, + midX, + midY, + endX: endPos.x, + endY: endPos.y, + points, + sourceSection: sourceNode?.section, + targetSection: targetNode?.section, + sourceWidth: sourceNode?.width, + sourceHeight: sourceNode?.height, + targetWidth: targetNode?.width, + targetHeight: targetNode?.height, + }; + }); +} + +/** + * Validate layout data structure + * @param data - The data to validate + * @returns True if data is valid, throws error otherwise + */ +export function validateLayoutData(data: LayoutData): boolean { + if (!data) { + throw new Error('Layout data is required'); + } + + if (!data.config) { + throw new Error('Configuration is required in layout data'); + } + + if (!Array.isArray(data.nodes)) { + throw new Error('Nodes array is required in layout data'); + } + + if (!Array.isArray(data.edges)) { + throw new Error('Edges array is required in layout data'); + } + + return true; +} diff --git a/packages/mermaid-layout-tidy-tree/src/layouts.ts b/packages/mermaid-layout-tidy-tree/src/layouts.ts new file mode 100644 index 000000000..d5eac8992 --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/src/layouts.ts @@ -0,0 +1,13 @@ +import type { LayoutLoaderDefinition } from 'mermaid'; + +const loader = async () => await import(`./render.js`); + +const tidyTreeLayout: LayoutLoaderDefinition[] = [ + { + name: 'tidy-tree', + loader, + algorithm: 'tidy-tree', + }, +]; + +export default tidyTreeLayout; diff --git a/packages/mermaid-layout-tidy-tree/src/non-layered-tidy-tree-layout.d.ts b/packages/mermaid-layout-tidy-tree/src/non-layered-tidy-tree-layout.d.ts new file mode 100644 index 000000000..248b5c05f --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/src/non-layered-tidy-tree-layout.d.ts @@ -0,0 +1,18 @@ +declare module 'non-layered-tidy-tree-layout' { + export class BoundingBox { + constructor(gap: number, bottomPadding: number); + } + + export class Layout { + constructor(boundingBox: BoundingBox); + layout(data: any): { + result: any; + boundingBox: { + left: number; + right: number; + top: number; + bottom: number; + }; + }; + } +} diff --git a/packages/mermaid-layout-tidy-tree/src/render.ts b/packages/mermaid-layout-tidy-tree/src/render.ts new file mode 100644 index 000000000..4ce5e1deb --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/src/render.ts @@ -0,0 +1,180 @@ +import type { InternalHelpers, LayoutData, RenderOptions, SVG } from 'mermaid'; +import { executeTidyTreeLayout } from './layout.js'; + +interface NodeWithPosition { + id: string; + x?: number; + y?: number; + width?: number; + height?: number; + domId?: any; + [key: string]: any; +} + +/** + * Render function for bidirectional tidy-tree layout algorithm + * + * This follows the same pattern as ELK and dagre renderers: + * 1. Insert nodes into DOM to get their actual dimensions + * 2. Run the bidirectional tidy-tree layout algorithm to calculate positions + * 3. Position the nodes and edges based on layout results + * + * The bidirectional layout creates two trees that grow horizontally in opposite + * directions from a central root node: + * - Left tree: grows horizontally to the left (children: 1st, 3rd, 5th...) + * - Right tree: grows horizontally to the right (children: 2nd, 4th, 6th...) + */ +export const render = async ( + data4Layout: LayoutData, + svg: SVG, + { + insertCluster, + insertEdge, + insertEdgeLabel, + insertMarkers, + insertNode, + log, + positionEdgeLabel, + }: InternalHelpers, + { algorithm: _algorithm }: RenderOptions +) => { + const nodeDb: Record = {}; + const clusterDb: Record = {}; + + const element = svg.select('g'); + insertMarkers(element, data4Layout.markers, data4Layout.type, data4Layout.diagramId); + + const subGraphsEl = element.insert('g').attr('class', 'subgraphs'); + const edgePaths = element.insert('g').attr('class', 'edgePaths'); + const edgeLabels = element.insert('g').attr('class', 'edgeLabels'); + const nodes = element.insert('g').attr('class', 'nodes'); + // Step 1: Insert nodes into DOM to get their actual dimensions + log.debug('Inserting nodes into DOM for dimension calculation'); + + await Promise.all( + data4Layout.nodes.map(async (node) => { + if (node.isGroup) { + const clusterNode: NodeWithPosition = { + ...node, + id: node.id, + width: node.width, + height: node.height, + }; + clusterDb[node.id] = clusterNode; + nodeDb[node.id] = clusterNode; + + await insertCluster(subGraphsEl, node); + } else { + const nodeWithPosition: NodeWithPosition = { + ...node, + id: node.id, + width: node.width, + height: node.height, + }; + nodeDb[node.id] = nodeWithPosition; + + const nodeEl = await insertNode(nodes, node, { + config: data4Layout.config, + dir: data4Layout.direction || 'TB', + }); + + const boundingBox = nodeEl.node()!.getBBox(); + nodeWithPosition.width = boundingBox.width; + nodeWithPosition.height = boundingBox.height; + nodeWithPosition.domId = nodeEl; + + log.debug(`Node ${node.id} dimensions: ${boundingBox.width}x${boundingBox.height}`); + } + }) + ); + // Step 2: Run the bidirectional tidy-tree layout algorithm + log.debug('Running bidirectional tidy-tree layout algorithm'); + + const updatedLayoutData = { + ...data4Layout, + nodes: data4Layout.nodes.map((node) => { + const nodeWithDimensions = nodeDb[node.id]; + return { + ...node, + width: nodeWithDimensions.width ?? node.width ?? 100, + height: nodeWithDimensions.height ?? node.height ?? 50, + }; + }), + }; + + const layoutResult = await executeTidyTreeLayout(updatedLayoutData); + // Step 3: Position the nodes based on bidirectional layout results + log.debug('Positioning nodes based on bidirectional layout results'); + + layoutResult.nodes.forEach((positionedNode) => { + const node = nodeDb[positionedNode.id]; + if (node?.domId) { + // Position the node at the calculated coordinates from bidirectional layout + // The layout algorithm has already calculated positions for: + // - Root node at center (0, 0) + // - Left tree nodes with negative x coordinates (growing left) + // - Right tree nodes with positive x coordinates (growing right) + node.domId.attr('transform', `translate(${positionedNode.x}, ${positionedNode.y})`); + // Store the final position + node.x = positionedNode.x; + node.y = positionedNode.y; + // Step 3: Position the nodes based on bidirectional layout results + log.debug(`Positioned node ${node.id} at (${positionedNode.x}, ${positionedNode.y})`); + } + }); + + log.debug('Inserting and positioning edges'); + + await Promise.all( + data4Layout.edges.map(async (edge) => { + await insertEdgeLabel(edgeLabels, edge); + + const startNode = nodeDb[edge.start ?? '']; + const endNode = nodeDb[edge.end ?? '']; + + if (startNode && endNode) { + const positionedEdge = layoutResult.edges.find((e) => e.id === edge.id); + + if (positionedEdge) { + log.debug('APA01 positionedEdge', positionedEdge); + const edgeWithPath = { + ...edge, + points: positionedEdge.points, + }; + const paths = insertEdge( + edgePaths, + edgeWithPath, + clusterDb, + data4Layout.type, + startNode, + endNode, + data4Layout.diagramId + ); + + positionEdgeLabel(edgeWithPath, paths); + } else { + const edgeWithPath = { + ...edge, + points: [ + { x: startNode.x ?? 0, y: startNode.y ?? 0 }, + { x: endNode.x ?? 0, y: endNode.y ?? 0 }, + ], + }; + + const paths = insertEdge( + edgePaths, + edgeWithPath, + clusterDb, + data4Layout.type, + startNode, + endNode, + data4Layout.diagramId + ); + positionEdgeLabel(edgeWithPath, paths); + } + } + }) + ); + + log.debug('Bidirectional tidy-tree rendering completed'); +}; diff --git a/packages/mermaid-layout-tidy-tree/src/types.ts b/packages/mermaid-layout-tidy-tree/src/types.ts new file mode 100644 index 000000000..2015a4909 --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/src/types.ts @@ -0,0 +1,69 @@ +import type { LayoutData } from 'mermaid'; + +export type Node = LayoutData['nodes'][number]; +export type Edge = LayoutData['edges'][number]; + +/** + * Positioned node after layout calculation + */ +export interface PositionedNode { + id: string; + x: number; + y: number; + section?: 'root' | 'left' | 'right'; + width?: number; + height?: number; + originalNode?: Node; + [key: string]: unknown; +} + +/** + * Positioned edge after layout calculation + */ +export interface PositionedEdge { + id: string; + source: string; + target: string; + startX: number; + startY: number; + midX: number; + midY: number; + endX: number; + endY: number; + sourceSection?: 'root' | 'left' | 'right'; + targetSection?: 'root' | 'left' | 'right'; + sourceWidth?: number; + sourceHeight?: number; + targetWidth?: number; + targetHeight?: number; + [key: string]: unknown; +} + +/** + * Result of layout algorithm execution + */ +export interface LayoutResult { + nodes: PositionedNode[]; + edges: PositionedEdge[]; +} + +/** + * Tidy-tree node structure compatible with non-layered-tidy-tree-layout + */ +export interface TidyTreeNode { + id: string | number; + width: number; + height: number; + x?: number; + y?: number; + children?: TidyTreeNode[]; + _originalNode?: Node; +} + +/** + * Tidy-tree layout configuration + */ +export interface TidyTreeLayoutConfig { + gap: number; + bottomPadding: number; +} diff --git a/packages/mermaid-layout-tidy-tree/tsconfig.json b/packages/mermaid-layout-tidy-tree/tsconfig.json new file mode 100644 index 000000000..8f83e2bad --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "types": ["vitest/importMeta", "vitest/globals"] + }, + "include": ["./src/**/*.ts", "./src/**/*.d.ts"], + "typeRoots": ["./src/types"] +} diff --git a/packages/mermaid/CHANGELOG.md b/packages/mermaid/CHANGELOG.md index df67f0cfd..fc2f97fdf 100644 --- a/packages/mermaid/CHANGELOG.md +++ b/packages/mermaid/CHANGELOG.md @@ -229,7 +229,6 @@ - [#5999](https://github.com/mermaid-js/mermaid/pull/5999) [`742ad7c`](https://github.com/mermaid-js/mermaid/commit/742ad7c130964df1fb5544e909d9556081285f68) Thanks [@knsv](https://github.com/knsv)! - Adding Kanban board, a new diagram type - [#5880](https://github.com/mermaid-js/mermaid/pull/5880) [`bdf145f`](https://github.com/mermaid-js/mermaid/commit/bdf145ffe362462176d9c1e68d5f3ff5c9d962b0) Thanks [@yari-dewalt](https://github.com/yari-dewalt)! - Class diagram changes: - - Updates the class diagram to the new unified way of rendering. - Includes a new "classBox" shape to be used in diagrams - Other updates such as: diff --git a/packages/mermaid/package.json b/packages/mermaid/package.json index 9e18739ed..af3185bf5 100644 --- a/packages/mermaid/package.json +++ b/packages/mermaid/package.json @@ -82,7 +82,7 @@ "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.21", - "marked": "^16.0.0", + "marked": "^15.0.7", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", @@ -123,8 +123,8 @@ "rimraf": "^6.0.1", "start-server-and-test": "^2.0.10", "type-fest": "^4.35.0", - "typedoc": "^0.27.8", - "typedoc-plugin-markdown": "^4.4.2", + "typedoc": "^0.28.9", + "typedoc-plugin-markdown": "^4.8.0", "typescript": "~5.7.3", "unist-util-flatmap": "^1.0.0", "unist-util-visit": "^5.0.0", diff --git a/packages/mermaid/scripts/docs.spec.ts b/packages/mermaid/scripts/docs.spec.ts index 68677d4c9..70923e226 100644 --- a/packages/mermaid/scripts/docs.spec.ts +++ b/packages/mermaid/scripts/docs.spec.ts @@ -171,7 +171,9 @@ This Markdown should be kept. expect(buildShapeDoc()).toMatchInlineSnapshot(` "| **Semantic Name** | **Shape Name** | **Short Name** | **Description** | **Alias Supported** | | --------------------------------- | ---------------------- | -------------- | ------------------------------ | ---------------------------------------------------------------- | + | Bang | Bang | \`bang\` | Bang | \`bang\` | | Card | Notched Rectangle | \`notch-rect\` | Represents a card | \`card\`, \`notched-rectangle\` | + | Cloud | Cloud | \`cloud\` | cloud | \`cloud\` | | Collate | Hourglass | \`hourglass\` | Represents a collate operation | \`collate\`, \`hourglass\` | | Com Link | Lightning Bolt | \`bolt\` | Communication link | \`com-link\`, \`lightning-bolt\` | | Comment | Curly Brace | \`brace\` | Adds a comment | \`brace-l\`, \`comment\` | diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index 70391f2e5..79fadd195 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -1075,6 +1075,10 @@ export interface ArchitectureDiagramConfig extends BaseDiagramConfig { export interface MindmapDiagramConfig extends BaseDiagramConfig { padding?: number; maxNodeWidth?: number; + /** + * Layout algorithm to use for positioning mindmap nodes + */ + layoutAlgorithm?: string; } /** * The object containing configurations specific for kanban diagrams diff --git a/packages/mermaid/src/diagram-api/comments.spec.ts b/packages/mermaid/src/diagram-api/comments.spec.ts index 57a7d4a34..febca83e9 100644 --- a/packages/mermaid/src/diagram-api/comments.spec.ts +++ b/packages/mermaid/src/diagram-api/comments.spec.ts @@ -1,5 +1,3 @@ -// tests to check that comments are removed - import { cleanupComments } from './comments.js'; import { describe, it, expect } from 'vitest'; @@ -10,12 +8,12 @@ describe('comments', () => { %% This is a comment %% This is another comment graph TD - A-->B + A-->B %% This is a comment `; expect(cleanupComments(text)).toMatchInlineSnapshot(` "graph TD - A-->B + A-->B " `); }); @@ -29,9 +27,9 @@ graph TD %%{ init: {'theme': 'space before init'}}%% %%{init: {'theme': 'space after ending'}}%% graph TD - A-->B + A-->B - B-->C + B-->C %% This is a comment `; expect(cleanupComments(text)).toMatchInlineSnapshot(` @@ -39,9 +37,9 @@ graph TD %%{ init: {'theme': 'space before init'}}%% %%{init: {'theme': 'space after ending'}}%% graph TD - A-->B + A-->B - B-->C + B-->C " `); }); @@ -50,14 +48,14 @@ graph TD const text = ` %% This is a comment graph TD - A-->B - %% This is a comment - C-->D + A-->B + %% This is a comment + C-->D `; expect(cleanupComments(text)).toMatchInlineSnapshot(` "graph TD - A-->B - C-->D + A-->B + C-->D " `); }); @@ -70,11 +68,11 @@ graph TD %% This is a comment graph TD - A-->B + A-->B `; expect(cleanupComments(text)).toMatchInlineSnapshot(` "graph TD - A-->B + A-->B " `); }); @@ -82,12 +80,12 @@ graph TD it('should remove comments at end of text with no newline', () => { const text = ` graph TD - A-->B + A-->B %% This is a comment`; expect(cleanupComments(text)).toMatchInlineSnapshot(` "graph TD - A-->B + A-->B " `); }); diff --git a/packages/mermaid/src/diagrams/architecture/architectureRenderer.ts b/packages/mermaid/src/diagrams/architecture/architectureRenderer.ts index b29567236..608b11816 100644 --- a/packages/mermaid/src/diagrams/architecture/architectureRenderer.ts +++ b/packages/mermaid/src/diagrams/architecture/architectureRenderer.ts @@ -1,6 +1,5 @@ -import type { Position } from 'cytoscape'; +import type { LayoutOptions, Position } from 'cytoscape'; import cytoscape from 'cytoscape'; -import type { FcoseLayoutOptions } from 'cytoscape-fcose'; import fcose from 'cytoscape-fcose'; import { select } from 'd3'; import type { DrawDefinition, SVG } from '../../diagram-api/types.js'; @@ -41,7 +40,7 @@ registerIconPacks([ icons: architectureIcons, }, ]); -cytoscape.use(fcose); +cytoscape.use(fcose as any); function addServices(services: ArchitectureService[], cy: cytoscape.Core, db: ArchitectureDB) { services.forEach((service) => { @@ -429,7 +428,7 @@ function layoutArchitecture( }, alignmentConstraint, relativePlacementConstraint, - } as FcoseLayoutOptions); + } as LayoutOptions); // Once the diagram has been generated and the service's position cords are set, adjust the XY edges to have a 90deg bend layout.one('layoutstop', () => { diff --git a/packages/mermaid/src/diagrams/mindmap/mindmapDb.getData.test.ts b/packages/mermaid/src/diagrams/mindmap/mindmapDb.getData.test.ts new file mode 100644 index 000000000..7c10c0104 --- /dev/null +++ b/packages/mermaid/src/diagrams/mindmap/mindmapDb.getData.test.ts @@ -0,0 +1,297 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { MindmapDB } from './mindmapDb.js'; +import type { MindmapLayoutNode, MindmapLayoutEdge } from './mindmapDb.js'; +import type { Edge } from '../../rendering-util/types.js'; + +// Mock the getConfig function +vi.mock('../../diagram-api/diagramAPI.js', () => ({ + getConfig: vi.fn(() => ({ + mindmap: { + layoutAlgorithm: 'cose-bilkent', + padding: 10, + maxNodeWidth: 200, + useMaxWidth: true, + }, + })), +})); + +describe('MindmapDb getData function', () => { + let db: MindmapDB; + + beforeEach(() => { + db = new MindmapDB(); + // Clear the database before each test + db.clear(); + }); + + describe('getData', () => { + it('should return empty data when no mindmap is set', () => { + const result = db.getData(); + + expect(result.nodes).toEqual([]); + expect(result.edges).toEqual([]); + expect(result.config).toBeDefined(); + expect(result.rootNode).toBeUndefined(); + }); + + it('should return structured data for simple mindmap', () => { + // Create a simple mindmap structure + db.addNode(0, 'root', 'Root Node', 0); + db.addNode(1, 'child1', 'Child 1', 0); + db.addNode(1, 'child2', 'Child 2', 0); + + const result = db.getData(); + + expect(result.nodes).toHaveLength(3); + expect(result.edges).toHaveLength(2); + expect(result.config).toBeDefined(); + expect(result.rootNode).toBeDefined(); + + // Check root node + const rootNode = (result.nodes as MindmapLayoutNode[]).find((n) => n.id === '0'); + expect(rootNode).toBeDefined(); + expect(rootNode?.label).toBe('Root Node'); + expect(rootNode?.level).toBe(0); + + // Check child nodes + const child1 = (result.nodes as MindmapLayoutNode[]).find((n) => n.id === '1'); + expect(child1).toBeDefined(); + expect(child1?.label).toBe('Child 1'); + expect(child1?.level).toBe(1); + + // Check edges + expect(result.edges).toContainEqual( + expect.objectContaining({ + start: '0', + end: '1', + depth: 0, + }) + ); + }); + + it('should return structured data for hierarchical mindmap', () => { + // Create a hierarchical mindmap structure + db.addNode(0, 'root', 'Root Node', 0); + db.addNode(1, 'child1', 'Child 1', 0); + db.addNode(2, 'grandchild1', 'Grandchild 1', 0); + db.addNode(2, 'grandchild2', 'Grandchild 2', 0); + db.addNode(1, 'child2', 'Child 2', 0); + + const result = db.getData(); + + expect(result.nodes).toHaveLength(5); + expect(result.edges).toHaveLength(4); + + // Check that all levels are represented + const levels = result.nodes.map((n) => (n as MindmapLayoutNode).level); + expect(levels).toContain(0); // root + expect(levels).toContain(1); // children + expect(levels).toContain(2); // grandchildren + + // Check edge relationships + const edgeRelations = result.edges.map( + (e) => `${(e as MindmapLayoutEdge).start}->${(e as MindmapLayoutEdge).end}` + ); + expect(edgeRelations).toContain('0->1'); // root to child1 + expect(edgeRelations).toContain('1->2'); // child1 to grandchild1 + expect(edgeRelations).toContain('1->3'); // child1 to grandchild2 + expect(edgeRelations).toContain('0->4'); // root to child2 + }); + + it('should preserve node properties in processed data', () => { + // Add a node with specific properties + db.addNode(0, 'root', 'Root Node', 2); // type 2 = rectangle + + // Set additional properties + const mindmap = db.getMindmap(); + if (mindmap) { + mindmap.width = 150; + mindmap.height = 75; + mindmap.padding = 15; + mindmap.section = 1; + mindmap.class = 'custom-class'; + mindmap.icon = 'star'; + } + + const result = db.getData(); + + expect(result.nodes).toHaveLength(1); + const node = result.nodes[0] as MindmapLayoutNode; + + expect(node.type).toBe(2); + expect(node.width).toBe(150); + expect(node.height).toBe(75); + expect(node.padding).toBe(15); + expect(node.section).toBeUndefined(); // Root node has undefined section + expect(node.cssClasses).toBe('mindmap-node section-root section--1 custom-class'); + expect(node.icon).toBe('star'); + }); + + it('should generate unique edge IDs', () => { + db.addNode(0, 'root', 'Root Node', 0); + db.addNode(1, 'child1', 'Child 1', 0); + db.addNode(1, 'child2', 'Child 2', 0); + db.addNode(1, 'child3', 'Child 3', 0); + + const result = db.getData(); + + const edgeIds = result.edges.map((e: Edge) => e.id); + const uniqueIds = new Set(edgeIds); + + expect(edgeIds).toHaveLength(3); + expect(uniqueIds.size).toBe(3); // All IDs should be unique + }); + + it('should handle nodes with missing optional properties', () => { + db.addNode(0, 'root', 'Root Node', 0); + + const result = db.getData(); + const node = result.nodes[0] as MindmapLayoutNode; + + // Should handle undefined/missing properties gracefully + expect(node.section).toBeUndefined(); // Root node has undefined section + expect(node.cssClasses).toBe('mindmap-node section-root section--1'); // Root node gets special classes + expect(node.icon).toBeUndefined(); + expect(node.x).toBeUndefined(); + expect(node.y).toBeUndefined(); + }); + + it('should assign correct section classes based on sibling position', () => { + // Create the example mindmap structure: + // A + // a0 + // aa0 + // a1 + // aaa + // a2 + db.addNode(0, 'A', 'A', 0); // Root + db.addNode(1, 'a0', 'a0', 0); // First child of root + db.addNode(2, 'aa0', 'aa0', 0); // Child of a0 + db.addNode(1, 'a1', 'a1', 0); // Second child of root + db.addNode(2, 'aaa', 'aaa', 0); // Child of a1 + db.addNode(1, 'a2', 'a2', 0); // Third child of root + + const result = db.getData(); + + // Find nodes by their labels + const nodeA = result.nodes.find((n) => n.label === 'A') as MindmapLayoutNode; + const nodeA0 = result.nodes.find((n) => n.label === 'a0') as MindmapLayoutNode; + const nodeAa0 = result.nodes.find((n) => n.label === 'aa0') as MindmapLayoutNode; + const nodeA1 = result.nodes.find((n) => n.label === 'a1') as MindmapLayoutNode; + const nodeAaa = result.nodes.find((n) => n.label === 'aaa') as MindmapLayoutNode; + const nodeA2 = result.nodes.find((n) => n.label === 'a2') as MindmapLayoutNode; + + // Check section assignments + expect(nodeA.section).toBeUndefined(); // Root has undefined section + expect(nodeA0.section).toBe(0); // First child of root + expect(nodeAa0.section).toBe(0); // Inherits from parent a0 + expect(nodeA1.section).toBe(1); // Second child of root + expect(nodeAaa.section).toBe(1); // Inherits from parent a1 + expect(nodeA2.section).toBe(2); // Third child of root + + // Check CSS classes + expect(nodeA.cssClasses).toBe('mindmap-node section-root section--1'); + expect(nodeA0.cssClasses).toBe('mindmap-node section-0'); + expect(nodeAa0.cssClasses).toBe('mindmap-node section-0'); + expect(nodeA1.cssClasses).toBe('mindmap-node section-1'); + expect(nodeAaa.cssClasses).toBe('mindmap-node section-1'); + expect(nodeA2.cssClasses).toBe('mindmap-node section-2'); + }); + + it('should preserve custom classes while adding section classes', () => { + db.addNode(0, 'root', 'Root Node', 0); + db.addNode(1, 'child', 'Child Node', 0); + + // Add custom classes to nodes + const mindmap = db.getMindmap(); + if (mindmap) { + mindmap.class = 'custom-root-class'; + if (mindmap.children?.[0]) { + mindmap.children[0].class = 'custom-child-class'; + } + } + + const result = db.getData(); + const rootNode = result.nodes.find((n) => n.label === 'Root Node') as MindmapLayoutNode; + const childNode = result.nodes.find((n) => n.label === 'Child Node') as MindmapLayoutNode; + + // Should include both section classes and custom classes + expect(rootNode.cssClasses).toBe('mindmap-node section-root section--1 custom-root-class'); + expect(childNode.cssClasses).toBe('mindmap-node section-0 custom-child-class'); + }); + + it('should not create any fake root nodes', () => { + // Create a simple mindmap + db.addNode(0, 'A', 'A', 0); + db.addNode(1, 'a0', 'a0', 0); + db.addNode(1, 'a1', 'a1', 0); + + const result = db.getData(); + + // Check that we only have the expected nodes + expect(result.nodes).toHaveLength(3); + expect(result.nodes.map((n) => n.label)).toEqual(['A', 'a0', 'a1']); + + // Check that there's no node with label "mindmap" or any other fake root + const mindmapNode = result.nodes.find((n) => n.label === 'mindmap'); + expect(mindmapNode).toBeUndefined(); + + // Verify the root node has the correct classes + const rootNode = result.nodes.find((n) => n.label === 'A') as MindmapLayoutNode; + expect(rootNode.cssClasses).toBe('mindmap-node section-root section--1'); + expect(rootNode.level).toBe(0); + }); + + it('should assign correct section classes to edges', () => { + // Create the example mindmap structure: + // A + // a0 + // aa0 + // a1 + // aaa + // a2 + db.addNode(0, 'A', 'A', 0); // Root + db.addNode(1, 'a0', 'a0', 0); // First child of root + db.addNode(2, 'aa0', 'aa0', 0); // Child of a0 + db.addNode(1, 'a1', 'a1', 0); // Second child of root + db.addNode(2, 'aaa', 'aaa', 0); // Child of a1 + db.addNode(1, 'a2', 'a2', 0); // Third child of root + + const result = db.getData(); + + // Should have 5 edges: A->a0, a0->aa0, A->a1, a1->aaa, A->a2 + expect(result.edges).toHaveLength(5); + + // Find edges by their start and end nodes + const edgeA_a0 = result.edges.find( + (e) => e.start === '0' && e.end === '1' + ) as MindmapLayoutEdge; + const edgeA0_aa0 = result.edges.find( + (e) => e.start === '1' && e.end === '2' + ) as MindmapLayoutEdge; + const edgeA_a1 = result.edges.find( + (e) => e.start === '0' && e.end === '3' + ) as MindmapLayoutEdge; + const edgeA1_aaa = result.edges.find( + (e) => e.start === '3' && e.end === '4' + ) as MindmapLayoutEdge; + const edgeA_a2 = result.edges.find( + (e) => e.start === '0' && e.end === '5' + ) as MindmapLayoutEdge; + + // Check edge classes + expect(edgeA_a0.classes).toBe('edge section-edge-0 edge-depth-1'); // A->a0: section-0, depth-1 + expect(edgeA0_aa0.classes).toBe('edge section-edge-0 edge-depth-2'); // a0->aa0: section-0, depth-2 + expect(edgeA_a1.classes).toBe('edge section-edge-1 edge-depth-1'); // A->a1: section-1, depth-1 + expect(edgeA1_aaa.classes).toBe('edge section-edge-1 edge-depth-2'); // a1->aaa: section-1, depth-2 + expect(edgeA_a2.classes).toBe('edge section-edge-2 edge-depth-1'); // A->a2: section-2, depth-1 + + // Check section assignments match the child nodes + expect(edgeA_a0.section).toBe(0); + expect(edgeA0_aa0.section).toBe(0); + expect(edgeA_a1.section).toBe(1); + expect(edgeA1_aaa.section).toBe(1); + expect(edgeA_a2.section).toBe(2); + }); + }); +}); diff --git a/packages/mermaid/src/diagrams/mindmap/mindmapDb.ts b/packages/mermaid/src/diagrams/mindmap/mindmapDb.ts index 703ba8434..aebdba71b 100644 --- a/packages/mermaid/src/diagrams/mindmap/mindmapDb.ts +++ b/packages/mermaid/src/diagrams/mindmap/mindmapDb.ts @@ -1,9 +1,26 @@ import { getConfig } from '../../diagram-api/diagramAPI.js'; +import { v4 } from 'uuid'; import type { D3Element } from '../../types.js'; import { sanitizeText } from '../../diagrams/common/common.js'; import { log } from '../../logger.js'; import type { MindmapNode } from './mindmapTypes.js'; import defaultConfig from '../../defaultConfig.js'; +import type { LayoutData, Node, Edge } from '../../rendering-util/types.js'; +import { getUserDefinedConfig } from '../../config.js'; + +// Extend Node type for mindmap-specific properties +export type MindmapLayoutNode = Node & { + level: number; + nodeId: string; + type: number; + section?: number; +}; + +// Extend Edge type for mindmap-specific properties +export type MindmapLayoutEdge = Edge & { + depth: number; + section?: number; +}; const nodeType = { DEFAULT: 0, @@ -27,7 +44,6 @@ export class MindmapDB { this.nodeType = nodeType; this.clear(); this.getType = this.getType.bind(this); - this.getMindmap = this.getMindmap.bind(this); this.getElementById = this.getElementById.bind(this); this.getParent = this.getParent.bind(this); this.getMindmap = this.getMindmap.bind(this); @@ -156,6 +172,223 @@ export class MindmapDB { } } + /** + * Assign section numbers to nodes based on their position relative to root + * @param node - The mindmap node to process + * @param sectionNumber - The section number to assign (undefined for root) + */ + public assignSections(node: MindmapNode, sectionNumber?: number): void { + // For root node, section should be undefined (not -1) + if (node.level === 0) { + node.section = undefined; + } else { + // For non-root nodes, assign the section number + node.section = sectionNumber; + } + // For root node's children, assign section numbers based on their index + // For other nodes, inherit parent's section number + if (node.children) { + for (const [index, child] of node.children.entries()) { + const childSectionNumber = node.level === 0 ? index : sectionNumber; + this.assignSections(child, childSectionNumber); + } + } + } + + /** + * Convert mindmap tree structure to flat array of nodes + * @param node - The mindmap node to process + * @param processedNodes - Array to collect processed nodes + */ + public flattenNodes(node: MindmapNode, processedNodes: MindmapLayoutNode[]): void { + // Build CSS classes for the node + const cssClasses = ['mindmap-node']; + + // Add section-specific classes + if (node.level === 0) { + // Root node gets special classes + cssClasses.push('section-root', 'section--1'); + } else if (node.section !== undefined) { + // Child nodes get section class based on their section number + cssClasses.push(`section-${node.section}`); + } + + // Add any custom classes from the node + if (node.class) { + cssClasses.push(node.class); + } + + const classes = cssClasses.join(' '); + + // Map mindmap node type to valid shape name + const getShapeFromType = (type: number) => { + switch (type) { + case nodeType.CIRCLE: + return 'mindmapCircle'; + case nodeType.RECT: + return 'rect'; + case nodeType.ROUNDED_RECT: + return 'rounded'; + case nodeType.CLOUD: + return 'cloud'; + case nodeType.BANG: + return 'bang'; + case nodeType.HEXAGON: + return 'hexagon'; + case nodeType.DEFAULT: + return 'defaultMindmapNode'; + case nodeType.NO_BORDER: + default: + return 'rect'; + } + }; + + const processedNode: MindmapLayoutNode = { + id: node.id.toString(), + domId: 'node_' + node.id.toString(), + label: node.descr, + isGroup: false, + shape: getShapeFromType(node.type), + width: node.width, + height: node.height ?? 0, + padding: node.padding, + cssClasses: classes, + cssStyles: [], + look: 'default', + icon: node.icon, + x: node.x, + y: node.y, + // Mindmap-specific properties + level: node.level, + nodeId: node.nodeId, + type: node.type, + section: node.section, + }; + + processedNodes.push(processedNode); + + // Recursively process children + if (node.children) { + for (const child of node.children) { + this.flattenNodes(child, processedNodes); + } + } + } + + /** + * Generate edges from parent-child relationships in mindmap tree + * @param node - The mindmap node to process + * @param edges - Array to collect edges + */ + public generateEdges(node: MindmapNode, edges: MindmapLayoutEdge[]): void { + if (!node.children) { + return; + } + for (const child of node.children) { + // Build CSS classes for the edge + let edgeClasses = 'edge'; + + // Add section-specific classes based on the child's section + if (child.section !== undefined) { + edgeClasses += ` section-edge-${child.section}`; + } + + // Add depth class based on the parent's level + 1 (depth of the edge) + const edgeDepth = node.level + 1; + edgeClasses += ` edge-depth-${edgeDepth}`; + + const edge: MindmapLayoutEdge = { + id: `edge_${node.id}_${child.id}`, + start: node.id.toString(), + end: child.id.toString(), + type: 'normal', + curve: 'basis', + thickness: 'normal', + look: 'default', + classes: edgeClasses, + // Store mindmap-specific data + depth: node.level, + section: child.section, + }; + + edges.push(edge); + + // Recursively process child edges + this.generateEdges(child, edges); + } + } + + /** + * Get structured data for layout algorithms + * Following the pattern established by ER diagrams + * @returns Structured data containing nodes, edges, and config + */ + public getData(): LayoutData { + const mindmapRoot = this.getMindmap(); + const config = getConfig(); + + const userDefinedConfig = getUserDefinedConfig(); + const hasUserDefinedLayout = userDefinedConfig.layout !== undefined; + + const finalConfig = config; + if (!hasUserDefinedLayout) { + finalConfig.layout = 'cose-bilkent'; + } + + if (!mindmapRoot) { + return { + nodes: [], + edges: [], + config: finalConfig, + }; + } + log.debug('getData: mindmapRoot', mindmapRoot, config); + + // Assign section numbers to all nodes based on their position relative to root + this.assignSections(mindmapRoot); + + // Convert tree structure to flat arrays + const processedNodes: MindmapLayoutNode[] = []; + const processedEdges: MindmapLayoutEdge[] = []; + + this.flattenNodes(mindmapRoot, processedNodes); + this.generateEdges(mindmapRoot, processedEdges); + + log.debug( + `getData: processed ${processedNodes.length} nodes and ${processedEdges.length} edges` + ); + + // Create shapes map for ELK compatibility + const shapes = new Map(); + for (const node of processedNodes) { + shapes.set(node.id, { + shape: node.shape, + width: node.width, + height: node.height, + padding: node.padding, + }); + } + + return { + nodes: processedNodes, + edges: processedEdges, + config: finalConfig, + // Store the root node for mindmap-specific layout algorithms + rootNode: mindmapRoot, + // Properties required by dagre layout algorithm + markers: ['point'], // Mindmaps don't use markers + direction: 'TB', // Top-to-bottom direction for mindmaps + nodeSpacing: 50, // Default spacing between nodes + rankSpacing: 50, // Default spacing between ranks + // Add shapes for ELK compatibility + shapes: Object.fromEntries(shapes), + // Additional properties that layout algorithms might expect + type: 'mindmap', + diagramId: 'mindmap-' + v4(), + }; + } + + // Expose logger to grammar public getLogger() { return log; } diff --git a/packages/mermaid/src/diagrams/mindmap/mindmapRenderer.ts b/packages/mermaid/src/diagrams/mindmap/mindmapRenderer.ts index ef9be0565..a962dc924 100644 --- a/packages/mermaid/src/diagrams/mindmap/mindmapRenderer.ts +++ b/packages/mermaid/src/diagrams/mindmap/mindmapRenderer.ts @@ -1,200 +1,83 @@ -import cytoscape from 'cytoscape'; -// @ts-expect-error No types available -import coseBilkent from 'cytoscape-cose-bilkent'; -import { select } from 'd3'; -import type { MermaidConfig } from '../../config.type.js'; -import { getConfig } from '../../diagram-api/diagramAPI.js'; import type { DrawDefinition } from '../../diagram-api/types.js'; import { log } from '../../logger.js'; -import type { D3Element } from '../../types.js'; -import { selectSvgElement } from '../../rendering-util/selectSvgElement.js'; -import { setupGraphViewbox } from '../../setupGraphViewbox.js'; -import type { FilledMindMapNode, MindmapNode } from './mindmapTypes.js'; -import { drawNode, positionNode } from './svgDraw.js'; +import { getDiagramElement } from '../../rendering-util/insertElementsForSize.js'; +import { getRegisteredLayoutAlgorithm, render } from '../../rendering-util/render.js'; +import { setupViewPortForSVG } from '../../rendering-util/setupViewPortForSVG.js'; +import type { LayoutData } from '../../rendering-util/types.js'; +import type { FilledMindMapNode } from './mindmapTypes.js'; import defaultConfig from '../../defaultConfig.js'; import type { MindmapDB } from './mindmapDb.js'; -// Inject the layout algorithm into cytoscape -cytoscape.use(coseBilkent); -async function drawNodes( - db: MindmapDB, - svg: D3Element, - mindmap: FilledMindMapNode, - section: number, - conf: MermaidConfig -) { - await drawNode(db, svg, mindmap, section, conf); - if (mindmap.children) { - await Promise.all( - mindmap.children.map((child, index) => - drawNodes(db, svg, child, section < 0 ? index : section, conf) - ) - ); - } -} - -declare module 'cytoscape' { - interface EdgeSingular { - _private: { - bodyBounds: unknown; - rscratch: { - startX: number; - startY: number; - midX: number; - midY: number; - endX: number; - endY: number; - }; - }; - } -} - -function drawEdges(edgesEl: D3Element, cy: cytoscape.Core) { - cy.edges().map((edge, id) => { - const data = edge.data(); - if (edge[0]._private.bodyBounds) { - const bounds = edge[0]._private.rscratch; - log.trace('Edge: ', id, data); - edgesEl - .insert('path') - .attr( - 'd', - `M ${bounds.startX},${bounds.startY} L ${bounds.midX},${bounds.midY} L${bounds.endX},${bounds.endY} ` - ) - .attr('class', 'edge section-edge-' + data.section + ' edge-depth-' + data.depth); +/** + * Update the layout data with actual node dimensions after drawing + */ +function _updateNodeDimensions(data4Layout: LayoutData, mindmapRoot: FilledMindMapNode) { + const updateNode = (node: FilledMindMapNode) => { + // Find the corresponding node in the layout data + const layoutNode = data4Layout.nodes.find((n) => n.id === node.id.toString()); + if (layoutNode) { + // Update with the actual dimensions calculated by drawNode + layoutNode.width = node.width; + layoutNode.height = node.height; + log.debug('Updated node dimensions:', node.id, 'width:', node.width, 'height:', node.height); } - }); -} -function addNodes(mindmap: MindmapNode, cy: cytoscape.Core, conf: MermaidConfig, level: number) { - cy.add({ - group: 'nodes', - data: { - id: mindmap.id.toString(), - labelText: mindmap.descr, - height: mindmap.height, - width: mindmap.width, - level: level, - nodeId: mindmap.id, - padding: mindmap.padding, - type: mindmap.type, - }, - position: { - x: mindmap.x!, - y: mindmap.y!, - }, - }); - if (mindmap.children) { - mindmap.children.forEach((child) => { - addNodes(child, cy, conf, level + 1); - cy.add({ - group: 'edges', - data: { - id: `${mindmap.id}_${child.id}`, - source: mindmap.id, - target: child.id, - depth: level, - section: child.section, - }, - }); - }); - } -} + // Recursively update children + node.children?.forEach(updateNode); + }; -function layoutMindmap(node: MindmapNode, conf: MermaidConfig): Promise { - return new Promise((resolve) => { - // Add temporary render element - const renderEl = select('body').append('div').attr('id', 'cy').attr('style', 'display:none'); - const cy = cytoscape({ - container: document.getElementById('cy'), // container to render in - style: [ - { - selector: 'edge', - style: { - 'curve-style': 'bezier', - }, - }, - ], - }); - // Remove element after layout - renderEl.remove(); - addNodes(node, cy, conf, 0); - - // Make cytoscape care about the dimensions of the nodes - cy.nodes().forEach(function (n) { - n.layoutDimensions = () => { - const data = n.data(); - return { w: data.width, h: data.height }; - }; - }); - - cy.layout({ - name: 'cose-bilkent', - // @ts-ignore Types for cose-bilkent are not correct? - quality: 'proof', - styleEnabled: false, - animate: false, - }).run(); - cy.ready((e) => { - log.info('Ready', e); - resolve(cy); - }); - }); -} - -function positionNodes(db: MindmapDB, cy: cytoscape.Core) { - cy.nodes().map((node, id) => { - const data = node.data(); - data.x = node.position().x; - data.y = node.position().y; - positionNode(db, data); - const el = db.getElementById(data.nodeId); - log.info('id:', id, 'Position: (', node.position().x, ', ', node.position().y, ')', data); - el.attr( - 'transform', - `translate(${node.position().x - data.width / 2}, ${node.position().y - data.height / 2})` - ); - el.attr('attr', `apa-${id})`); - }); + updateNode(mindmapRoot); } export const draw: DrawDefinition = async (text, id, _version, diagObj) => { log.debug('Rendering mindmap diagram\n' + text); + // Draw the nodes first to get their dimensions, then update the layout data const db = diagObj.db as MindmapDB; + + // The getData method provided in all supported diagrams is used to extract the data from the parsed structure + // into the Layout data format + const data4Layout = db.getData(); + + // Create the root SVG - the element is the div containing the SVG element + const svg = getDiagramElement(id, data4Layout.config.securityLevel); + + data4Layout.type = diagObj.type; + data4Layout.layoutAlgorithm = getRegisteredLayoutAlgorithm(data4Layout.config.layout, { + fallback: 'cose-bilkent', + }); + + data4Layout.diagramId = id; + const mm = db.getMindmap(); if (!mm) { return; } - const conf = getConfig(); - conf.htmlLabels = false; + data4Layout.nodes.forEach((node) => { + if (node.shape === 'rounded') { + node.radius = 15; + node.taper = 15; + node.stroke = 'none'; + node.width = 0; + node.padding = 15; + } else if (node.shape === 'circle') { + node.padding = 10; + } else if (node.shape === 'rect') { + node.width = 0; + node.padding = 10; + } + }); - const svg = selectSvgElement(id); + // Use the unified rendering system + await render(data4Layout, svg); - // Draw the graph and start with drawing the nodes without proper position - // this gives us the size of the nodes and we can set the positions later - - const edgesElem = svg.append('g'); - edgesElem.attr('class', 'mindmap-edges'); - const nodesElem = svg.append('g'); - nodesElem.attr('class', 'mindmap-nodes'); - await drawNodes(db, nodesElem, mm as FilledMindMapNode, -1, conf); - - // Next step is to layout the mindmap, giving each node a position - - const cy = await layoutMindmap(mm, conf); - - // After this we can draw, first the edges and the then nodes with the correct position - drawEdges(edgesElem, cy); - positionNodes(db, cy); - - // Setup the view box and size of the svg element - setupGraphViewbox( - undefined, + // Setup the view box and size of the svg element using config from data4Layout + setupViewPortForSVG( svg, - conf.mindmap?.padding ?? defaultConfig.mindmap.padding, - conf.mindmap?.useMaxWidth ?? defaultConfig.mindmap.useMaxWidth + data4Layout.config.mindmap?.padding ?? defaultConfig.mindmap.padding, + 'mindmapDiagram', + data4Layout.config.mindmap?.useMaxWidth ?? defaultConfig.mindmap.useMaxWidth ); }; diff --git a/packages/mermaid/src/diagrams/mindmap/styles.ts b/packages/mermaid/src/diagrams/mindmap/styles.ts index fffa6e4d9..8372bddf1 100644 --- a/packages/mermaid/src/diagrams/mindmap/styles.ts +++ b/packages/mermaid/src/diagrams/mindmap/styles.ts @@ -64,6 +64,12 @@ const getStyles: DiagramStylesProvider = (options) => .section-root text { fill: ${options.gitBranchLabel0}; } + .section-root span { + color: ${options.gitBranchLabel0}; + } + .section-2 span { + color: ${options.gitBranchLabel0}; + } .icon-container { height:100%; display: flex; diff --git a/packages/mermaid/src/diagrams/sequence/sequenceDiagram.spec.js b/packages/mermaid/src/diagrams/sequence/sequenceDiagram.spec.js index c09a92737..5f4e06dcd 100644 --- a/packages/mermaid/src/diagrams/sequence/sequenceDiagram.spec.js +++ b/packages/mermaid/src/diagrams/sequence/sequenceDiagram.spec.js @@ -1622,7 +1622,7 @@ link a: Tests @ https://tests.contoso.com/?svc=alice@contoso.com it('should handle box without description', async () => { const diagram = await Diagram.fromText(` sequenceDiagram - box Aqua + box aqua participant a as Alice participant b as Bob end @@ -1638,7 +1638,7 @@ link a: Tests @ https://tests.contoso.com/?svc=alice@contoso.com const boxes = diagram.db.getBoxes(); expect(boxes[0].name).toBeFalsy(); expect(boxes[0].actorKeys).toEqual(['a', 'b']); - expect(boxes[0].fill).toEqual('Aqua'); + expect(boxes[0].fill).toEqual('aqua'); }); it('should handle simple actor creation', async () => { diff --git a/packages/mermaid/src/docs/.vitepress/config.ts b/packages/mermaid/src/docs/.vitepress/config.ts index 1c41e7cba..066fde693 100644 --- a/packages/mermaid/src/docs/.vitepress/config.ts +++ b/packages/mermaid/src/docs/.vitepress/config.ts @@ -203,6 +203,7 @@ function sidebarConfig() { { text: 'Accessibility', link: '/config/accessibility' }, { text: 'Mermaid CLI', link: '/config/mermaidCLI' }, { text: 'FAQ', link: '/config/faq' }, + { text: 'Layouts', link: '/config/layouts' }, ], }, ]; diff --git a/packages/mermaid/src/docs/config/layouts.md b/packages/mermaid/src/docs/config/layouts.md new file mode 100644 index 000000000..56f5072f6 --- /dev/null +++ b/packages/mermaid/src/docs/config/layouts.md @@ -0,0 +1,24 @@ +# Layouts + +This page lists the available layout algorithms supported in Mermaid diagrams. + +## Supported Layouts + +- **elk**: [ELK (Eclipse Layout Kernel)](https://www.eclipse.org/elk/) +- **tidy-tree**: Tidy tree layout for hierarchical diagrams [Tidy Tree Configuration](/config/tidy-tree) +- **cose-bilkent**: Cose Bilkent layout for force-directed graphs +- **dagre**: Dagre layout for layered graphs + +## How to Use + +You can specify the layout in your diagram's YAML config or initialization options. For example: + +```mermaid +--- +config: + layout: elk +--- +graph TD; + A-->B; + B-->C; +``` diff --git a/packages/mermaid/src/docs/config/tidy-tree.md b/packages/mermaid/src/docs/config/tidy-tree.md new file mode 100644 index 000000000..f98d36379 --- /dev/null +++ b/packages/mermaid/src/docs/config/tidy-tree.md @@ -0,0 +1,49 @@ +# Tidy-tree Layout + +The **tidy-tree** layout arranges nodes in a hierarchical, tree-like structure. It is especially useful for diagrams where parent-child relationships are important, such as mindmaps. + +## Features + +- Organizes nodes in a tidy, non-overlapping tree +- Ideal for mindmaps and hierarchical data +- Automatically adjusts spacing for readability + +## Example Usage + +```mermaid-example +--- +config: + layout: tidy-tree +--- +mindmap +root((mindmap is a long thing)) + A + B + C + D +``` + +```mermaid-example +--- +config: + layout: tidy-tree +--- +mindmap +root((mindmap)) + Origins + Long history + ::icon(fa fa-book) + Popularisation + British popular psychology author Tony Buzan + Research + On effectiveness
and features + On Automatic creation + Uses + Creative techniques + Strategic planning + Argument mapping +``` + +## Note + +- Currently, tidy-tree is primarily supported for mindmap diagrams. diff --git a/packages/mermaid/src/docs/syntax/mindmap.md b/packages/mermaid/src/docs/syntax/mindmap.md index 3dfbed2f6..41d736798 100644 --- a/packages/mermaid/src/docs/syntax/mindmap.md +++ b/packages/mermaid/src/docs/syntax/mindmap.md @@ -209,3 +209,22 @@ You can also refer the [implementation in the live editor](https://github.com/me cspell:locale en,en-gb cspell:ignore Buzan ---> + +## Layouts + +Mermaid also supports a Tidy Tree layout for mindmaps. + +``` +--- +config: + layout: tidy-tree +--- +mindmap +root((mindmap is a long thing)) + A + B + C + D +``` + +Instructions to add and register tidy-tree layout are present in [Tidy Tree Configuration](/config/tidy-tree) diff --git a/packages/mermaid/src/docs/syntax/xyChart.md b/packages/mermaid/src/docs/syntax/xyChart.md index 4154fb2f0..cfff201d3 100644 --- a/packages/mermaid/src/docs/syntax/xyChart.md +++ b/packages/mermaid/src/docs/syntax/xyChart.md @@ -126,7 +126,7 @@ xychart ## Chart Theme Variables -Themes for xychart resides inside xychart attribute so to set the variables use this syntax: +Themes for xychart reside inside the `xychart` attribute, allowing customization through the following syntax: ```yaml --- @@ -151,6 +151,31 @@ config: | yAxisLineColor | Color of the y-axis line | | plotColorPalette | String of colors separated by comma e.g. "#f3456, #43445" | +### Setting Colors for Lines and Bars + +To set the color for lines and bars, use the `plotColorPalette` parameter. Colors in the palette will correspond sequentially to the elements in your chart (e.g., first bar/line will use the first color specified in the palette). + +```mermaid-example +--- +config: + themeVariables: + xyChart: + plotColorPalette: '#000000, #0000FF, #00FF00, #FF0000' +--- +xychart +title "Different Colors in xyChart" +x-axis "categoriesX" ["Category 1", "Category 2", "Category 3", "Category 4"] +y-axis "valuesY" 0 --> 50 +%% Black line +line [10,20,30,40] +%% Blue bar +bar [20,30,25,35] +%% Green bar +bar [15,25,20,30] +%% Red line +line [5,15,25,35] +``` + ## Example on config and theme ```mermaid-example diff --git a/packages/mermaid/src/preprocess.ts b/packages/mermaid/src/preprocess.ts index a62326070..2334ff0b1 100644 --- a/packages/mermaid/src/preprocess.ts +++ b/packages/mermaid/src/preprocess.ts @@ -26,6 +26,7 @@ const processFrontmatter = (code: string) => { } config.gantt.displayMode = displayMode; } + return { title, config, text }; }; diff --git a/packages/mermaid/src/rendering-util/createGraph.ts b/packages/mermaid/src/rendering-util/createGraph.ts new file mode 100644 index 000000000..b08a3aae0 --- /dev/null +++ b/packages/mermaid/src/rendering-util/createGraph.ts @@ -0,0 +1,148 @@ +import { insertNode } from './rendering-elements/nodes.js'; +import type { LayoutData, NonClusterNode } from './types.ts'; +import type { Selection } from 'd3'; +import { getConfig } from '../diagram-api/diagramAPI.js'; +import * as graphlib from 'dagre-d3-es/src/graphlib/index.js'; + +// Update type: +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 Graphology 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>; +}> { + // Create a directed, multi graph. + 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 clusters = element.insert('g').attr('class', 'clusters'); + const edgePaths = element.insert('g').attr('class', 'edges edgePath'); + const edgeLabels = element.insert('g').attr('class', 'edgeLabels'); + const nodesGroup = element.insert('g').attr('class', 'nodes'); + const rootGroups = element.insert('g').attr('class', 'root'); + + const nodeElements = new Map>(); + + // Insert nodes into the DOM and add them to the graph. + await Promise.all( + data4Layout.nodes.map(async (node) => { + 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 labelNodeId = `edge-label-${edge.start}-${edge.end}-${edge.id}`; + const labelNode = { + id: labelNodeId, + label: edge.label, + edgeStart: edge.start, + edgeEnd: edge.end, + shape: 'labelRect', + width: 0, // Will be updated after insertion + height: 0, // Will be updated after insertion + isEdgeLabel: true, + isDummy: true, + isGroup: false, + parentId: edge.parentId, + ...(edge.dir ? { dir: edge.dir } : {}), + } as NonClusterNode; + + // Insert the label node into the DOM + const labelNodeEl = await insertNode(nodesGroup, labelNode, { config, dir: edge.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/cose-bilkent/cytoscape-setup.test.ts b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/cytoscape-setup.test.ts new file mode 100644 index 000000000..707b031f4 --- /dev/null +++ b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/cytoscape-setup.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + addNodes, + addEdges, + extractPositionedNodes, + extractPositionedEdges, +} from './cytoscape-setup.js'; +import type { Node, Edge } from '../../types.js'; + +// Mock cytoscape +const mockCy = { + add: vi.fn(), + nodes: vi.fn(), + edges: vi.fn(), +}; + +vi.mock('cytoscape', () => { + const mockCytoscape = vi.fn(() => mockCy) as any; + mockCytoscape.use = vi.fn(); + return { + default: mockCytoscape, + }; +}); + +describe('Cytoscape Setup', () => { + let mockNodes: Node[]; + let mockEdges: Edge[]; + + beforeEach(() => { + vi.clearAllMocks(); + + mockNodes = [ + { + id: '1', + label: 'Root', + isGroup: false, + shape: 'rect', + width: 100, + height: 50, + padding: 10, + x: 100, + y: 100, + cssClasses: '', + cssStyles: [], + look: 'default', + }, + { + id: '2', + label: 'Child 1', + isGroup: false, + shape: 'rect', + width: 80, + height: 40, + padding: 10, + x: 150, + y: 150, + cssClasses: '', + cssStyles: [], + look: 'default', + }, + ]; + + mockEdges = [ + { + id: '1_2', + start: '1', + end: '2', + type: 'edge', + classes: '', + style: [], + animate: false, + arrowTypeEnd: 'arrow_point', + arrowTypeStart: 'none', + }, + ]; + }); + + describe('addNodes', () => { + it('should add nodes to cytoscape', () => { + addNodes([mockNodes[0]], mockCy as unknown as any); + + expect(mockCy.add).toHaveBeenCalledWith({ + group: 'nodes', + data: { + id: '1', + labelText: 'Root', + height: 50, + width: 100, + padding: 10, + isGroup: false, + shape: 'rect', + cssClasses: '', + cssStyles: [], + look: 'default', + }, + position: { + x: 100, + y: 100, + }, + }); + }); + + it('should add multiple nodes to cytoscape', () => { + addNodes(mockNodes, mockCy as unknown as any); + + expect(mockCy.add).toHaveBeenCalledTimes(2); + + expect(mockCy.add).toHaveBeenCalledWith({ + group: 'nodes', + data: { + id: '1', + labelText: 'Root', + height: 50, + width: 100, + padding: 10, + isGroup: false, + shape: 'rect', + cssClasses: '', + cssStyles: [], + look: 'default', + }, + position: { + x: 100, + y: 100, + }, + }); + + expect(mockCy.add).toHaveBeenCalledWith({ + group: 'nodes', + data: { + id: '2', + labelText: 'Child 1', + height: 40, + width: 80, + padding: 10, + isGroup: false, + shape: 'rect', + cssClasses: '', + cssStyles: [], + look: 'default', + }, + position: { + x: 150, + y: 150, + }, + }); + }); + }); + + describe('addEdges', () => { + it('should add edges to cytoscape', () => { + addEdges(mockEdges, mockCy as unknown as any); + + expect(mockCy.add).toHaveBeenCalledWith({ + group: 'edges', + data: { + id: '1_2', + source: '1', + target: '2', + type: 'edge', + classes: '', + style: [], + animate: false, + arrowTypeEnd: 'arrow_point', + arrowTypeStart: 'none', + }, + }); + }); + }); + + describe('extractPositionedNodes', () => { + it('should extract positioned nodes from cytoscape', () => { + const mockCytoscapeNodes = [ + { + data: () => ({ + id: '1', + labelText: 'Root', + width: 100, + height: 50, + padding: 10, + isGroup: false, + shape: 'rect', + }), + position: () => ({ x: 100, y: 100 }), + }, + { + data: () => ({ + id: '2', + labelText: 'Child 1', + width: 80, + height: 40, + padding: 10, + isGroup: false, + shape: 'rect', + }), + position: () => ({ x: 150, y: 150 }), + }, + ]; + + mockCy.nodes.mockReturnValue({ + map: (fn: unknown) => mockCytoscapeNodes.map(fn as any), + }); + + const result = extractPositionedNodes(mockCy as unknown as any); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + id: '1', + x: 100, + y: 100, + labelText: 'Root', + width: 100, + height: 50, + padding: 10, + isGroup: false, + shape: 'rect', + }); + }); + }); + + describe('extractPositionedEdges', () => { + it('should extract positioned edges from cytoscape', () => { + const mockCytoscapeEdges = [ + { + data: () => ({ + id: '1_2', + source: '1', + target: '2', + type: 'edge', + }), + _private: { + rscratch: { + startX: 100, + startY: 100, + midX: 125, + midY: 125, + endX: 150, + endY: 150, + }, + }, + }, + ]; + + mockCy.edges.mockReturnValue({ + map: (fn: unknown) => mockCytoscapeEdges.map(fn as any), + }); + + const result = extractPositionedEdges(mockCy as unknown as any); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: '1_2', + source: '1', + target: '2', + type: 'edge', + startX: 100, + startY: 100, + midX: 125, + midY: 125, + endX: 150, + endY: 150, + }); + }); + }); +}); diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/cytoscape-setup.ts b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/cytoscape-setup.ts new file mode 100644 index 000000000..8fb9b2599 --- /dev/null +++ b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/cytoscape-setup.ts @@ -0,0 +1,207 @@ +import cytoscape from 'cytoscape'; +import coseBilkent from 'cytoscape-cose-bilkent'; +import { select } from 'd3'; +import { log } from '../../../logger.js'; +import type { LayoutData, Node, Edge } from '../../types.js'; +import type { CytoscapeLayoutConfig, PositionedNode, PositionedEdge } from './types.js'; + +// Inject the layout algorithm into cytoscape +cytoscape.use(coseBilkent); + +/** + * Declare module augmentation for cytoscape edge types + */ +declare module 'cytoscape' { + interface EdgeSingular { + _private: { + bodyBounds: unknown; + rscratch: { + startX: number; + startY: number; + midX: number; + midY: number; + endX: number; + endY: number; + }; + }; + } +} + +/** + * Add nodes to cytoscape instance from provided node array + * This function processes only the nodes provided in the data structure + * @param nodes - Array of nodes to add + * @param cy - The cytoscape instance + */ +export function addNodes(nodes: Node[], cy: cytoscape.Core): void { + nodes.forEach((node) => { + const nodeData: Record = { + id: node.id, + labelText: node.label, + height: node.height, + width: node.width, + padding: node.padding ?? 0, + }; + + // Add any additional properties from the node + Object.keys(node).forEach((key) => { + if (!['id', 'label', 'height', 'width', 'padding', 'x', 'y'].includes(key)) { + nodeData[key] = (node as unknown as Record)[key]; + } + }); + + cy.add({ + group: 'nodes', + data: nodeData, + position: { + x: node.x ?? 0, + y: node.y ?? 0, + }, + }); + }); +} + +/** + * Add edges to cytoscape instance from provided edge array + * This function processes only the edges provided in the data structure + * @param edges - Array of edges to add + * @param cy - The cytoscape instance + */ +export function addEdges(edges: Edge[], cy: cytoscape.Core): void { + edges.forEach((edge) => { + const edgeData: Record = { + id: edge.id, + source: edge.start, + target: edge.end, + }; + + // Add any additional properties from the edge + Object.keys(edge).forEach((key) => { + if (!['id', 'start', 'end'].includes(key)) { + edgeData[key] = (edge as unknown as Record)[key]; + } + }); + + cy.add({ + group: 'edges', + data: edgeData, + }); + }); +} + +/** + * Create and configure cytoscape instance + * @param data - Layout data containing nodes and edges + * @returns Promise resolving to configured cytoscape instance + */ +export function createCytoscapeInstance(data: LayoutData): Promise { + return new Promise((resolve) => { + // Add temporary render element + const renderEl = select('body').append('div').attr('id', 'cy').attr('style', 'display:none'); + + const cy = cytoscape({ + container: document.getElementById('cy'), // container to render in + style: [ + { + selector: 'edge', + style: { + 'curve-style': 'bezier', + }, + }, + ], + }); + + // Remove element after layout + renderEl.remove(); + + // Add all nodes and edges to cytoscape using the generic functions + addNodes(data.nodes, cy); + addEdges(data.edges, cy); + + // Make cytoscape care about the dimensions of the nodes + cy.nodes().forEach(function (n) { + n.layoutDimensions = () => { + const nodeData = n.data(); + return { w: nodeData.width, h: nodeData.height }; + }; + }); + + // Configure and run the cose-bilkent layout + const layoutConfig: CytoscapeLayoutConfig = { + name: 'cose-bilkent', + // @ts-ignore Types for cose-bilkent are not correct? + quality: 'proof', + styleEnabled: false, + animate: false, + }; + + cy.layout(layoutConfig).run(); + + cy.ready((e) => { + log.info('Cytoscape ready', e); + resolve(cy); + }); + }); +} + +/** + * Extract positioned nodes from cytoscape instance + * @param cy - The cytoscape instance after layout + * @returns Array of positioned nodes + */ +export function extractPositionedNodes(cy: cytoscape.Core): PositionedNode[] { + return cy.nodes().map((node) => { + const data = node.data(); + const position = node.position(); + + // Create a positioned node with all original data plus position + const positionedNode: PositionedNode = { + id: data.id, + x: position.x, + y: position.y, + }; + + // Add all other properties from the original data + Object.keys(data).forEach((key) => { + if (key !== 'id') { + positionedNode[key] = data[key]; + } + }); + + return positionedNode; + }); +} + +/** + * Extract positioned edges from cytoscape instance + * @param cy - The cytoscape instance after layout + * @returns Array of positioned edges + */ +export function extractPositionedEdges(cy: cytoscape.Core): PositionedEdge[] { + return cy.edges().map((edge) => { + const data = edge.data(); + const rscratch = edge._private.rscratch; + + // Create a positioned edge with all original data plus position + const positionedEdge: PositionedEdge = { + id: data.id, + source: data.source, + target: data.target, + startX: rscratch.startX, + startY: rscratch.startY, + midX: rscratch.midX, + midY: rscratch.midY, + endX: rscratch.endX, + endY: rscratch.endY, + }; + + // Add all other properties from the original data + Object.keys(data).forEach((key) => { + if (!['id', 'source', 'target'].includes(key)) { + positionedEdge[key] = data[key]; + } + }); + + return positionedEdge; + }); +} diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/index.ts b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/index.ts new file mode 100644 index 000000000..9e12d38a7 --- /dev/null +++ b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/index.ts @@ -0,0 +1,25 @@ +import { render as renderWithCoseBilkent } from './render.js'; + +/** + * Cose-Bilkent Layout Algorithm for Generic Diagrams + * + * This module provides a layout algorithm implementation using Cytoscape + * with the cose-bilkent algorithm for positioning nodes and edges. + * + * The algorithm follows the unified rendering pattern and can be used + * by any diagram type that provides compatible LayoutData. + */ + +/** + * Render function for the cose-bilkent layout algorithm + * + * This function follows the unified rendering pattern used by all layout algorithms. + * It takes LayoutData, inserts nodes into DOM, runs the cose-bilkent layout algorithm, + * and renders the positioned elements to the SVG. + * + * @param layoutData - Layout data containing nodes, edges, and configuration + * @param svg - SVG element to render to + * @param helpers - Internal helper functions for rendering + * @param options - Rendering options + */ +export const render = renderWithCoseBilkent; diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/layout.test.ts b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/layout.test.ts new file mode 100644 index 000000000..f5650d3e8 --- /dev/null +++ b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/layout.test.ts @@ -0,0 +1,236 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { validateLayoutData, executeCoseBilkentLayout } from './layout.js'; +import type { LayoutResult } from './types.js'; +import type { MindmapNode } from '../../../diagrams/mindmap/mindmapTypes.js'; +import type { MermaidConfig } from '../../../config.type.js'; +import type { LayoutData } from '../../types.js'; + +// Mock cytoscape and cytoscape-cose-bilkent before importing the modules + +vi.mock('cytoscape', () => { + const mockCy = { + add: vi.fn(), + nodes: vi.fn(() => ({ + forEach: vi.fn(), + map: vi.fn((fn) => [ + fn({ + data: () => ({ + id: '1', + nodeId: '1', + labelText: 'Root', + level: 0, + type: 0, + width: 100, + height: 50, + padding: 10, + }), + position: () => ({ x: 100, y: 100 }), + }), + ]), + })), + edges: vi.fn(() => ({ + map: vi.fn((fn) => [ + fn({ + data: () => ({ + id: '1_2', + source: '1', + target: '2', + depth: 0, + }), + _private: { + rscratch: { + startX: 100, + startY: 100, + midX: 150, + midY: 150, + endX: 200, + endY: 200, + }, + }, + }), + ]), + })), + layout: vi.fn(() => ({ + run: vi.fn(), + })), + ready: vi.fn((callback) => callback({})), + }; + + const mockCytoscape = vi.fn(() => mockCy); + (mockCytoscape as any).use = vi.fn(); + + return { + default: mockCytoscape, + }; +}); + +describe('Cose-Bilkent Layout Algorithm', () => { + let mockConfig: MermaidConfig; + let mockRootNode: MindmapNode; + let mockLayoutData: LayoutData; + + beforeEach(() => { + mockConfig = { + mindmap: { + layoutAlgorithm: 'cose-bilkent', + padding: 10, + maxNodeWidth: 200, + useMaxWidth: true, + }, + } as MermaidConfig; + + mockRootNode = { + id: 1, + nodeId: '1', + level: 0, + descr: 'Root', + type: 0, + width: 100, + height: 50, + padding: 10, + x: 0, + y: 0, + children: [ + { + id: 2, + nodeId: '2', + level: 1, + descr: 'Child 1', + type: 0, + width: 80, + height: 40, + padding: 10, + x: 0, + y: 0, + }, + ], + } as MindmapNode; + + mockLayoutData = { + nodes: [ + { + id: '1', + nodeId: '1', + level: 0, + descr: 'Root', + type: 0, + width: 100, + height: 50, + padding: 10, + isGroup: false, + }, + { + id: '2', + nodeId: '2', + level: 1, + descr: 'Child 1', + type: 0, + width: 80, + height: 40, + padding: 10, + isGroup: false, + }, + ], + edges: [ + { + id: '1_2', + source: '1', + target: '2', + depth: 0, + }, + ], + config: mockConfig, + rootNode: mockRootNode, + }; + }); + + describe('validateLayoutData', () => { + it('should validate correct layout data', () => { + expect(() => validateLayoutData(mockLayoutData)).not.toThrow(); + }); + + it('should throw error for missing data', () => { + expect(() => validateLayoutData(null as any)).toThrow('Layout data is required'); + }); + + it('should throw error for missing root node', () => { + const invalidData = { ...mockLayoutData, rootNode: null as any }; + expect(() => validateLayoutData(invalidData)).toThrow('Root node is required'); + }); + + it('should throw error for missing config', () => { + const invalidData = { ...mockLayoutData, config: null as any }; + expect(() => validateLayoutData(invalidData)).toThrow('Configuration is required'); + }); + + it('should throw error for invalid nodes array', () => { + const invalidData = { ...mockLayoutData, nodes: null as any }; + expect(() => validateLayoutData(invalidData)).toThrow('No nodes found in layout data'); + }); + + it('should throw error for invalid edges array', () => { + const invalidData = { ...mockLayoutData, edges: null as any }; + expect(() => validateLayoutData(invalidData)).toThrow('Edges array is required'); + }); + }); + + describe('layout function', () => { + it('should execute layout algorithm successfully', async () => { + const result: LayoutResult = await executeCoseBilkentLayout(mockLayoutData, mockConfig); + + expect(result).toBeDefined(); + expect(result.nodes).toBeDefined(); + expect(result.edges).toBeDefined(); + expect(Array.isArray(result.nodes)).toBe(true); + expect(Array.isArray(result.edges)).toBe(true); + }); + + it('should return positioned nodes with coordinates', async () => { + const result: LayoutResult = await executeCoseBilkentLayout(mockLayoutData, mockConfig); + + expect(result.nodes.length).toBeGreaterThan(0); + result.nodes.forEach((node) => { + expect(node.x).toBeDefined(); + expect(node.y).toBeDefined(); + expect(typeof node.x).toBe('number'); + expect(typeof node.y).toBe('number'); + }); + }); + + it('should return positioned edges with coordinates', async () => { + const result: LayoutResult = await executeCoseBilkentLayout(mockLayoutData, mockConfig); + + expect(result.edges.length).toBeGreaterThan(0); + result.edges.forEach((edge) => { + expect(edge.startX).toBeDefined(); + expect(edge.startY).toBeDefined(); + expect(edge.midX).toBeDefined(); + expect(edge.midY).toBeDefined(); + expect(edge.endX).toBeDefined(); + expect(edge.endY).toBeDefined(); + }); + }); + + it('should handle empty mindmap data gracefully', async () => { + const emptyData: LayoutData = { + nodes: [], + edges: [], + config: mockConfig, + rootNode: mockRootNode, + }; + + const result: LayoutResult = await executeCoseBilkentLayout(emptyData, mockConfig); + expect(result).toBeDefined(); + expect(result.nodes).toBeDefined(); + expect(result.edges).toBeDefined(); + expect(Array.isArray(result.nodes)).toBe(true); + expect(Array.isArray(result.edges)).toBe(true); + }); + + it('should throw error for invalid data', async () => { + const invalidData = { ...mockLayoutData, rootNode: null as any }; + + await expect(executeCoseBilkentLayout(invalidData, mockConfig)).rejects.toThrow(); + }); + }); +}); diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/layout.ts b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/layout.ts new file mode 100644 index 000000000..433723259 --- /dev/null +++ b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/layout.ts @@ -0,0 +1,77 @@ +import type { MermaidConfig } from '../../../config.type.js'; +import { log } from '../../../logger.js'; +import type { LayoutData } from '../../types.js'; +import type { LayoutResult } from './types.js'; +import { + createCytoscapeInstance, + extractPositionedNodes, + extractPositionedEdges, +} from './cytoscape-setup.js'; + +/** + * Execute the cose-bilkent layout algorithm on generic layout data + * + * This function takes layout data and uses Cytoscape with the cose-bilkent + * algorithm to calculate optimal node positions and edge paths. + * + * @param data - The layout data containing nodes, edges, and configuration + * @param config - Mermaid configuration object + * @returns Promise resolving to layout result with positioned nodes and edges + */ +export async function executeCoseBilkentLayout( + data: LayoutData, + _config: MermaidConfig +): Promise { + log.debug('Starting cose-bilkent layout algorithm'); + + try { + // Validate layout data structure + validateLayoutData(data); + + // Create and configure cytoscape instance + const cy = await createCytoscapeInstance(data); + + // Extract positioned nodes and edges after layout + const positionedNodes = extractPositionedNodes(cy); + const positionedEdges = extractPositionedEdges(cy); + + log.debug(`Layout completed: ${positionedNodes.length} nodes, ${positionedEdges.length} edges`); + + return { + nodes: positionedNodes, + edges: positionedEdges, + }; + } catch (error) { + log.error('Error in cose-bilkent layout algorithm:', error); + throw error; + } +} + +/** + * Validate layout data structure + * @param data - The data to validate + * @returns True if data is valid, throws error otherwise + */ +export function validateLayoutData(data: LayoutData): boolean { + if (!data) { + throw new Error('Layout data is required'); + } + + if (!data.config) { + throw new Error('Configuration is required in layout data'); + } + + if (!data.rootNode) { + throw new Error('Root node is required'); + } + + if (!data.nodes || !Array.isArray(data.nodes)) { + throw new Error('No nodes found in layout data'); + } + + if (!Array.isArray(data.edges)) { + throw new Error('Edges array is required in layout data'); + } + + return true; +} diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/render.ts b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/render.ts new file mode 100644 index 000000000..2dbbf5d7e --- /dev/null +++ b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/render.ts @@ -0,0 +1,197 @@ +import type { InternalHelpers, LayoutData, RenderOptions, SVG, SVGGroup } from 'mermaid'; +import { executeCoseBilkentLayout } from './layout.js'; +import type { D3Selection } from '../../../types.js'; + +type Node = Record; + +interface NodeWithPosition extends Node { + x?: number; + y?: number; + domId?: string | SVGGroup | D3Selection; + width?: number; + height?: number; + id?: string; +} + +/** + * Render function for cose-bilkent layout algorithm + * + * This follows the same pattern as ELK and dagre renderers: + * 1. Insert nodes into DOM to get their actual dimensions + * 2. Run the layout algorithm to calculate positions + * 3. Position the nodes and edges based on layout results + */ +export const render = async ( + data4Layout: LayoutData, + svg: SVG, + { + insertCluster, + insertEdge, + insertEdgeLabel, + insertMarkers, + insertNode, + log, + positionEdgeLabel, + }: InternalHelpers, + { algorithm: _algorithm }: RenderOptions +) => { + const nodeDb: Record = {}; + const clusterDb: Record = {}; + + // Insert markers for edges + const element = svg.select('g'); + insertMarkers(element, data4Layout.markers, data4Layout.type, data4Layout.diagramId); + + // Create container groups + const subGraphsEl = element.insert('g').attr('class', 'subgraphs'); + const edgePaths = element.insert('g').attr('class', 'edgePaths'); + const edgeLabels = element.insert('g').attr('class', 'edgeLabels'); + const nodes = element.insert('g').attr('class', 'nodes'); + + // Step 1: Insert nodes into DOM to get their actual dimensions + log.debug('Inserting nodes into DOM for dimension calculation'); + + await Promise.all( + data4Layout.nodes.map(async (node) => { + if (node.isGroup) { + // Handle subgraphs/clusters + const clusterNode: NodeWithPosition = { ...node }; + clusterDb[node.id] = clusterNode; + nodeDb[node.id] = clusterNode; + + // Insert cluster to get dimensions + await insertCluster(subGraphsEl, node); + } else { + // Handle regular nodes + const nodeWithPosition: NodeWithPosition = { ...node }; + nodeDb[node.id] = nodeWithPosition; + + // Insert node to get actual dimensions + const nodeEl = await insertNode(nodes, node, { + config: data4Layout.config, + dir: data4Layout.direction || 'TB', + }); + + // Get the actual bounding box after insertion + const boundingBox = nodeEl.node()!.getBBox(); + nodeWithPosition.width = boundingBox.width; + nodeWithPosition.height = boundingBox.height; + nodeWithPosition.domId = nodeEl; + + log.debug(`Node ${node.id} dimensions: ${boundingBox.width}x${boundingBox.height}`); + } + }) + ); + + // Step 2: Run the cose-bilkent layout algorithm + log.debug('Running cose-bilkent layout algorithm'); + + // Update the layout data with actual dimensions + const updatedLayoutData = { + ...data4Layout, + nodes: data4Layout.nodes.map((node) => { + const nodeWithDimensions = nodeDb[node.id]; + return { + ...node, + width: nodeWithDimensions.width, + height: nodeWithDimensions.height, + }; + }), + }; + + const layoutResult = await executeCoseBilkentLayout(updatedLayoutData, data4Layout.config); + + // Step 3: Position the nodes based on layout results + log.debug('Positioning nodes based on layout results'); + + layoutResult.nodes.forEach((positionedNode) => { + const node = nodeDb[positionedNode.id]; + if (node?.domId) { + // Position the node at the calculated coordinates + // The positionedNode.x/y represents the center of the node, so use directly + (node.domId as D3Selection).attr( + 'transform', + `translate(${positionedNode.x}, ${positionedNode.y})` + ); + + // Store the final position + node.x = positionedNode.x; + node.y = positionedNode.y; + + log.debug(`Positioned node ${node.id} at center (${positionedNode.x}, ${positionedNode.y})`); + } + }); + + layoutResult.edges.forEach((positionedEdge) => { + const edge = data4Layout.edges.find((e) => e.id === positionedEdge.id); + if (edge) { + // Update the edge data with positioned coordinates + edge.points = [ + { x: positionedEdge.startX, y: positionedEdge.startY }, + { x: positionedEdge.midX, y: positionedEdge.midY }, + { x: positionedEdge.endX, y: positionedEdge.endY }, + ]; + } + }); + + // Step 4: Insert and position edges + log.debug('Inserting and positioning edges'); + + await Promise.all( + data4Layout.edges.map(async (edge) => { + // Insert edge label first + const _edgeLabel = await insertEdgeLabel(edgeLabels, edge); + + // Get start and end nodes + const startNode = nodeDb[edge.start ?? '']; + const endNode = nodeDb[edge.end ?? '']; + + if (startNode && endNode) { + // Find the positioned edge data + const positionedEdge = layoutResult.edges.find((e) => e.id === edge.id); + + if (positionedEdge) { + log.debug('APA01 positionedEdge', positionedEdge); + // Create edge path with positioned coordinates + const edgeWithPath = { ...edge }; + + // Insert the edge path + const paths = insertEdge( + edgePaths, + edgeWithPath, + clusterDb, + data4Layout.type, + startNode, + endNode, + data4Layout.diagramId + ); + + // Position the edge label + positionEdgeLabel(edgeWithPath, paths); + } else { + // Fallback: create a simple straight line between nodes + const edgeWithPath = { + ...edge, + points: [ + { x: startNode.x || 0, y: startNode.y || 0 }, + { x: endNode.x || 0, y: endNode.y || 0 }, + ], + }; + + const paths = insertEdge( + edgePaths, + edgeWithPath, + clusterDb, + data4Layout.type, + startNode, + endNode, + data4Layout.diagramId + ); + positionEdgeLabel(edgeWithPath, paths); + } + } + }) + ); + + log.debug('Cose-bilkent rendering completed'); +}; diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/types.ts b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/types.ts new file mode 100644 index 000000000..fade24682 --- /dev/null +++ b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/types.ts @@ -0,0 +1,43 @@ +/** + * Positioned node after layout calculation + */ +export interface PositionedNode { + id: string; + x: number; + y: number; + [key: string]: unknown; // Allow additional properties +} + +/** + * Positioned edge after layout calculation + */ +export interface PositionedEdge { + id: string; + source: string; + target: string; + startX: number; + startY: number; + midX: number; + midY: number; + endX: number; + endY: number; + [key: string]: unknown; // Allow additional properties +} + +/** + * Result of layout algorithm execution + */ +export interface LayoutResult { + nodes: PositionedNode[]; + edges: PositionedEdge[]; +} + +/** + * Cytoscape layout configuration + */ +export interface CytoscapeLayoutConfig { + name: 'cose-bilkent'; + quality: 'proof'; + styleEnabled: boolean; + animate: boolean; +} diff --git a/packages/mermaid/src/rendering-util/render.ts b/packages/mermaid/src/rendering-util/render.ts index b975e7bf9..ff07510b3 100644 --- a/packages/mermaid/src/rendering-util/render.ts +++ b/packages/mermaid/src/rendering-util/render.ts @@ -39,6 +39,14 @@ const registerDefaultLayoutLoaders = () => { name: 'dagre', loader: async () => await import('./layout-algorithms/dagre/index.js'), }, + ...(includeLargeFeatures + ? [ + { + name: 'cose-bilkent', + loader: async () => await import('./layout-algorithms/cose-bilkent/index.js'), + }, + ] + : []), ]); }; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/edges.js b/packages/mermaid/src/rendering-util/rendering-elements/edges.js index db48e313c..3292b3811 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/edges.js +++ b/packages/mermaid/src/rendering-util/rendering-elements/edges.js @@ -438,7 +438,6 @@ const fixCorners = function (lineData) { } return newLineData; }; - export const insertEdge = function (elem, edge, clusterDb, diagramType, startNode, endNode, id) { const { handDrawnSeed } = getConfig(); let points = edge.points; @@ -622,9 +621,9 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod // lineData.forEach((point) => { // elem // .append('circle') - // .style('stroke', 'blue') - // .style('fill', 'blue') - // .attr('r', 3) + // .style('stroke', 'red') + // .style('fill', 'red') + // .attr('r', 1) // .attr('cx', point.x) // .attr('cy', point.y); // }); diff --git a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.js b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.js index bd3eb497f..6d476fac9 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.js +++ b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.js @@ -2,64 +2,63 @@ * Returns the point at which two lines, p and q, intersect or returns undefined if they do not intersect. */ function intersectLine(p1, p2, q1, q2) { - // Algorithm from J. Avro, (ed.) Graphics Gems, No 2, Morgan Kaufmann, 1994, - // p7 and p473. + { + // Algorithm from J. Avro, (ed.) Graphics Gems, No 2, Morgan Kaufmann, 1994, + // p7 and p473. - var a1, a2, b1, b2, c1, c2; - var r1, r2, r3, r4; - var denom, offset, num; - var x, y; + // Compute a1, b1, c1, where line joining points 1 and 2 is F(x,y) = a1 x + + // b1 y + c1 = 0. + const a1 = p2.y - p1.y; + const b1 = p1.x - p2.x; + const c1 = p2.x * p1.y - p1.x * p2.y; - // Compute a1, b1, c1, where line joining points 1 and 2 is F(x,y) = a1 x + - // b1 y + c1 = 0. - a1 = p2.y - p1.y; - b1 = p1.x - p2.x; - c1 = p2.x * p1.y - p1.x * p2.y; + // Compute r3 and r4. + const r3 = a1 * q1.x + b1 * q1.y + c1; + const r4 = a1 * q2.x + b1 * q2.y + c1; - // Compute r3 and r4. - r3 = a1 * q1.x + b1 * q1.y + c1; - r4 = a1 * q2.x + b1 * q2.y + c1; + const epsilon = 1e-6; - // Check signs of r3 and r4. If both point 3 and point 4 lie on - // same side of line 1, the line segments do not intersect. - if (r3 !== 0 && r4 !== 0 && sameSign(r3, r4)) { - return /*DON'T_INTERSECT*/; + // Check signs of r3 and r4. If both point 3 and point 4 lie on + // same side of line 1, the line segments do not intersect. + if (r3 !== 0 && r4 !== 0 && sameSign(r3, r4)) { + return /*DON'T_INTERSECT*/; + } + + // Compute a2, b2, c2 where line joining points 3 and 4 is G(x,y) = a2 x + b2 y + c2 = 0 + const a2 = q2.y - q1.y; + const b2 = q1.x - q2.x; + const c2 = q2.x * q1.y - q1.x * q2.y; + + // Compute r1 and r2 + const r1 = a2 * p1.x + b2 * p1.y + c2; + const r2 = a2 * p2.x + b2 * p2.y + c2; + + // Check signs of r1 and r2. If both point 1 and point 2 lie + // on same side of second line segment, the line segments do + // not intersect. + if (Math.abs(r1) < epsilon && Math.abs(r2) < epsilon && sameSign(r1, r2)) { + return /*DON'T_INTERSECT*/; + } + + // Line segments intersect: compute intersection point. + const denom = a1 * b2 - a2 * b1; + if (denom === 0) { + return /*COLLINEAR*/; + } + + const offset = Math.abs(denom / 2); + + // The denom/2 is to get rounding instead of truncating. It + // is added or subtracted to the numerator, depending upon the + // sign of the numerator. + let num = b1 * c2 - b2 * c1; + const x = num < 0 ? (num - offset) / denom : (num + offset) / denom; + + num = a2 * c1 - a1 * c2; + const y = num < 0 ? (num - offset) / denom : (num + offset) / denom; + + return { x: x, y: y }; } - - // Compute a2, b2, c2 where line joining points 3 and 4 is G(x,y) = a2 x + b2 y + c2 = 0 - a2 = q2.y - q1.y; - b2 = q1.x - q2.x; - c2 = q2.x * q1.y - q1.x * q2.y; - - // Compute r1 and r2 - r1 = a2 * p1.x + b2 * p1.y + c2; - r2 = a2 * p2.x + b2 * p2.y + c2; - - // Check signs of r1 and r2. If both point 1 and point 2 lie - // on same side of second line segment, the line segments do - // not intersect. - if (r1 !== 0 && r2 !== 0 && sameSign(r1, r2)) { - return /*DON'T_INTERSECT*/; - } - - // Line segments intersect: compute intersection point. - denom = a1 * b2 - a2 * b1; - if (denom === 0) { - return /*COLLINEAR*/; - } - - offset = Math.abs(denom / 2); - - // The denom/2 is to get rounding instead of truncating. It - // is added or subtracted to the numerator, depending upon the - // sign of the numerator. - num = b1 * c2 - b2 * c1; - x = num < 0 ? (num - offset) / denom : (num + offset) / denom; - - num = a2 * c1 - a1 * c2; - y = num < 0 ? (num - offset) / denom : (num + offset) / denom; - - return { x: x, y: y }; } function sameSign(r1, r2) { diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts index 829f89a8f..2509dead4 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts @@ -61,6 +61,10 @@ import { erBox } from './shapes/erBox.js'; import { classBox } from './shapes/classBox.js'; import { requirementBox } from './shapes/requirementBox.js'; import { kanbanItem } from './shapes/kanbanItem.js'; +import { bang } from './shapes/bang.js'; +import { cloud } from './shapes/cloud.js'; +import { defaultMindmapNode } from './shapes/defaultMindmapNode.js'; +import { mindmapCircle } from './shapes/mindmapCircle.js'; type ShapeHandler = ( parent: D3Selection, @@ -135,6 +139,22 @@ export const shapesDefs = [ aliases: ['circ'], handler: circle, }, + { + semanticName: 'Bang', + name: 'Bang', + shortName: 'bang', + description: 'Bang', + aliases: ['bang'], + handler: bang, + }, + { + semanticName: 'Cloud', + name: 'Cloud', + shortName: 'cloud', + description: 'cloud', + aliases: ['cloud'], + handler: cloud, + }, { semanticName: 'Decision', name: 'Diamond', @@ -476,6 +496,9 @@ const generateShapeMap = () => { // Kanban diagram kanbanItem, + //Mindmap diagram + mindmapCircle, + defaultMindmapNode, // class diagram classBox, diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/bang.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/bang.ts new file mode 100644 index 000000000..bfc8896a5 --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/bang.ts @@ -0,0 +1,81 @@ +import { log } from '../../../logger.js'; +import { labelHelper, updateNodeBounds, getNodeClasses } from './util.js'; +import intersect from '../intersect/index.js'; +import type { Node } from '../../types.js'; +import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js'; +import rough from 'roughjs'; +import type { D3Selection } from '../../../types.js'; +import { handleUndefinedAttr } from '../../../utils.js'; +import type { Bounds, Point } from '../../../types.js'; + +export async function bang(parent: D3Selection, node: Node) { + const { labelStyles, nodeStyles } = styles2String(node); + node.labelStyle = labelStyles; + const { shapeSvg, bbox, halfPadding, label } = await labelHelper( + parent, + node, + getNodeClasses(node) + ); + + const w = bbox.width + 10 * halfPadding; + const h = bbox.height + 8 * halfPadding; + const r = 0.15 * w; + const { cssStyles } = node; + + const minWidth = bbox.width + 20; + const minHeight = bbox.height + 20; + const effectiveWidth = Math.max(w, minWidth); + const effectiveHeight = Math.max(h, minHeight); + + label.attr('transform', `translate(${-bbox.width / 2}, ${-bbox.height / 2})`); + + let bangElem; + const path = `M0 0 + a${r},${r} 1 0,0 ${effectiveWidth * 0.25},${-1 * effectiveHeight * 0.1} + a${r},${r} 1 0,0 ${effectiveWidth * 0.25},${0} + a${r},${r} 1 0,0 ${effectiveWidth * 0.25},${0} + a${r},${r} 1 0,0 ${effectiveWidth * 0.25},${effectiveHeight * 0.1} + + a${r},${r} 1 0,0 ${effectiveWidth * 0.15},${effectiveHeight * 0.33} + a${r * 0.8},${r * 0.8} 1 0,0 0,${effectiveHeight * 0.34} + a${r},${r} 1 0,0 ${-1 * effectiveWidth * 0.15},${effectiveHeight * 0.33} + + a${r},${r} 1 0,0 ${-1 * effectiveWidth * 0.25},${effectiveHeight * 0.15} + a${r},${r} 1 0,0 ${-1 * effectiveWidth * 0.25},0 + a${r},${r} 1 0,0 ${-1 * effectiveWidth * 0.25},0 + a${r},${r} 1 0,0 ${-1 * effectiveWidth * 0.25},${-1 * effectiveHeight * 0.15} + + a${r},${r} 1 0,0 ${-1 * effectiveWidth * 0.1},${-1 * effectiveHeight * 0.33} + a${r * 0.8},${r * 0.8} 1 0,0 0,${-1 * effectiveHeight * 0.34} + a${r},${r} 1 0,0 ${effectiveWidth * 0.1},${-1 * effectiveHeight * 0.33} + H0 V0 Z`; + + if (node.look === 'handDrawn') { + // @ts-expect-error -- Passing a D3.Selection seems to work for some reason + const rc = rough.svg(shapeSvg); + const options = userNodeOverrides(node, {}); + const roughNode = rc.path(path, options); + bangElem = shapeSvg.insert(() => roughNode, ':first-child'); + bangElem.attr('class', 'basic label-container').attr('style', handleUndefinedAttr(cssStyles)); + } else { + bangElem = shapeSvg + .insert('path', ':first-child') + .attr('class', 'basic label-container') + .attr('style', nodeStyles) + .attr('d', path); + } + + // Translate the path (center the shape) + bangElem.attr('transform', `translate(${-effectiveWidth / 2}, ${-effectiveHeight / 2})`); + + updateNodeBounds(node, bangElem); + node.calcIntersect = function (bounds: Bounds, point: Point) { + return intersect.rect(bounds, point); + }; + node.intersect = function (point) { + log.info('Bang intersect', node, point); + return intersect.rect(node, point); + }; + + return shapeSvg; +} diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/circle.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/circle.ts index 6b3be6765..57f72fd2d 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/circle.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/circle.ts @@ -1,18 +1,22 @@ -import { log } from '../../../logger.js'; -import { labelHelper, updateNodeBounds, getNodeClasses } from './util.js'; -import intersect from '../intersect/index.js'; -import type { Node } from '../../types.js'; -import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js'; import rough from 'roughjs'; -import type { D3Selection } from '../../../types.js'; +import { log } from '../../../logger.js'; +import type { Bounds, D3Selection, Point } from '../../../types.js'; import { handleUndefinedAttr } from '../../../utils.js'; +import type { MindmapOptions, Node, ShapeRenderOptions } from '../../types.js'; +import intersect from '../intersect/index.js'; +import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js'; +import { getNodeClasses, labelHelper, updateNodeBounds } from './util.js'; -export async function circle(parent: D3Selection, node: Node) { +export async function circle( + parent: D3Selection, + node: Node, + options?: MindmapOptions | ShapeRenderOptions +) { const { labelStyles, nodeStyles } = styles2String(node); node.labelStyle = labelStyles; const { shapeSvg, bbox, halfPadding } = await labelHelper(parent, node, getNodeClasses(node)); - - const radius = bbox.width / 2 + halfPadding; + const padding = options?.padding ?? halfPadding; + const radius = bbox.width / 2 + padding; let circleElem; const { cssStyles } = node; @@ -35,7 +39,10 @@ export async function circle(parent: D3Selection(parent: D3Selection, node: Node) { + const { labelStyles, nodeStyles } = styles2String(node); + node.labelStyle = labelStyles; + + const { shapeSvg, bbox, halfPadding, label } = await labelHelper( + parent, + node, + getNodeClasses(node) + ); + + const w = bbox.width + 2 * halfPadding; + const h = bbox.height + 2 * halfPadding; + + // Cloud radii + const r1 = 0.15 * w; + const r2 = 0.25 * w; + const r3 = 0.35 * w; + const r4 = 0.2 * w; + + const { cssStyles } = node; + let cloudElem; + + // Cloud path + const path = `M0 0 + a${r1},${r1} 0 0,1 ${w * 0.25},${-1 * w * 0.1} + a${r3},${r3} 1 0,1 ${w * 0.4},${-1 * w * 0.1} + a${r2},${r2} 1 0,1 ${w * 0.35},${w * 0.2} + + a${r1},${r1} 1 0,1 ${w * 0.15},${h * 0.35} + a${r4},${r4} 1 0,1 ${-1 * w * 0.15},${h * 0.65} + + a${r2},${r1} 1 0,1 ${-1 * w * 0.25},${w * 0.15} + a${r3},${r3} 1 0,1 ${-1 * w * 0.5},0 + a${r1},${r1} 1 0,1 ${-1 * w * 0.25},${-1 * w * 0.15} + + a${r1},${r1} 1 0,1 ${-1 * w * 0.1},${-1 * h * 0.35} + a${r4},${r4} 1 0,1 ${w * 0.1},${-1 * h * 0.65} + H0 V0 Z`; + + if (node.look === 'handDrawn') { + // @ts-expect-error -- Passing a D3.Selection seems to work for some reason + const rc = rough.svg(shapeSvg); + const options = userNodeOverrides(node, {}); + const roughNode = rc.path(path, options); + cloudElem = shapeSvg.insert(() => roughNode, ':first-child'); + cloudElem.attr('class', 'basic label-container').attr('style', handleUndefinedAttr(cssStyles)); + } else { + cloudElem = shapeSvg + .insert('path', ':first-child') + .attr('class', 'basic label-container') + .attr('style', nodeStyles) + .attr('d', path); + } + + label.attr('transform', `translate(${-bbox.width / 2}, ${-bbox.height / 2})`); + + // Center the shape + cloudElem.attr('transform', `translate(${-w / 2}, ${-h / 2})`); + + updateNodeBounds(node, cloudElem); + + node.calcIntersect = function (bounds: Bounds, point: Point) { + return intersect.rect(bounds, point); + }; + node.intersect = function (point) { + log.info('Cloud intersect', node, point); + return intersect.rect(node, point); + }; + + return shapeSvg; +} diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/defaultMindmapNode.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/defaultMindmapNode.ts new file mode 100644 index 000000000..f30c80844 --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/defaultMindmapNode.ts @@ -0,0 +1,64 @@ +import type { Bounds, D3Selection, Point } from '../../../types.js'; +import type { Node } from '../../types.js'; +import intersect from '../intersect/index.js'; +import { styles2String } from './handDrawnShapeStyles.js'; +import { getNodeClasses, labelHelper, updateNodeBounds } from './util.js'; + +export async function defaultMindmapNode( + parent: D3Selection, + node: Node +) { + const { labelStyles, nodeStyles } = styles2String(node); + node.labelStyle = labelStyles; + + const { shapeSvg, bbox, halfPadding, label } = await labelHelper( + parent, + node, + getNodeClasses(node) + ); + + const w = bbox.width + 8 * halfPadding; + const h = bbox.height + 2 * halfPadding; + const rd = 5; + + const rectPath = ` + M${-w / 2} ${h / 2 - rd} + v${-h + 2 * rd} + q0,-${rd} ${rd},-${rd} + h${w - 2 * rd} + q${rd},0 ${rd},${rd} + v${h - 2 * rd} + q0,${rd} -${rd},${rd} + h${-w + 2 * rd} + q-${rd},0 -${rd},-${rd} + Z + `; + + const bg = shapeSvg + .append('path') + .attr('id', 'node-' + node.id) + .attr('class', 'node-bkg node-' + node.type) + .attr('style', nodeStyles) + .attr('d', rectPath); + + shapeSvg + .append('line') + .attr('class', 'node-line-') + .attr('x1', -w / 2) + .attr('y1', h / 2) + .attr('x2', w / 2) + .attr('y2', h / 2); + + label.attr('transform', `translate(${-bbox.width / 2}, ${-bbox.height / 2})`); + shapeSvg.append(() => label.node()); + + updateNodeBounds(node, bg); + node.calcIntersect = function (bounds: Bounds, point: Point) { + return intersect.rect(bounds, point); + }; + node.intersect = function (point) { + return intersect.rect(node, point); + }; + + return shapeSvg; +} diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/drawRect.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/drawRect.ts index 707aed2c7..8f70f82fc 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/drawRect.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/drawRect.ts @@ -6,6 +6,7 @@ import { userNodeOverrides, styles2String } from './handDrawnShapeStyles.js'; import rough from 'roughjs'; import type { D3Selection } from '../../../types.js'; import { handleUndefinedAttr } from '../../../utils.js'; +import type { Bounds, Point } from '../../../types.js'; export async function drawRect( parent: D3Selection, @@ -62,6 +63,10 @@ export async function drawRect( updateNodeBounds(node, rect); + node.calcIntersect = function (bounds: Bounds, point: Point) { + return intersect.rect(bounds, point); + }; + node.intersect = function (point) { return intersect.rect(node, point); }; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/mindmapCircle.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/mindmapCircle.ts new file mode 100644 index 000000000..5b9dab3fd --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/mindmapCircle.ts @@ -0,0 +1,13 @@ +import { circle } from './circle.js'; +import type { Node, MindmapOptions } from '../../types.js'; +import type { D3Selection } from '../../../types.js'; + +export async function mindmapCircle( + parent: D3Selection, + node: Node +) { + const options = { + padding: node.padding ?? 0, + } as MindmapOptions; + return circle(parent, node, options); +} diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts index 24c811b85..87adc4814 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts @@ -1,4 +1,3 @@ -import { log } from '../../../logger.js'; import { labelHelper, updateNodeBounds, getNodeClasses } from './util.js'; import intersect from '../intersect/index.js'; import type { Node } from '../../types.js'; @@ -6,6 +5,7 @@ import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js'; import rough from 'roughjs'; import { insertPolygonShape } from './insertPolygonShape.js'; import type { D3Selection } from '../../../types.js'; +import type { Bounds, Point } from '../../../types.js'; export const createDecisionBoxPathD = (x: number, y: number, size: number): string => { return [ @@ -61,17 +61,26 @@ export async function question(parent: D3Selection } updateNodeBounds(node, polygon); + node.calcIntersect = function (bounds: Bounds, point: Point) { + const s = bounds.width; + + // Define polygon points + const points = [ + { x: s / 2, y: 0 }, + { x: s, y: -s / 2 }, + { x: s / 2, y: -s }, + { x: 0, y: -s / 2 }, + ]; + + // Calculate the intersection point + const res = intersect.polygon(bounds, points, point); + + return { x: res.x - 0.5, y: res.y - 0.5 }; // Adjusted result + }; node.intersect = function (point) { - log.debug( - 'APA12 Intersect called SPLIT\npoint:', - point, - '\nnode:\n', - node, - '\nres:', - intersect.polygon(node, points, point) - ); - return intersect.polygon(node, points, point); + // @ts-ignore TODO fix this (KNSV) + return this.calcIntersect(node as Bounds, point); }; return shapeSvg; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/roundedRect.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/roundedRect.ts index 40d71429c..2b8f03d92 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/roundedRect.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/roundedRect.ts @@ -98,18 +98,19 @@ export async function roundedRect( const w = (node?.width ? node?.width : bbox.width) + labelPaddingX * 2; const h = (node?.height ? node?.height : bbox.height) + labelPaddingY * 2; - const radius = 5; - const taper = 5; // Taper width for the rounded corners + const radius = node.radius || 5; + const taper = node.taper || 5; // Taper width for the rounded corners const { cssStyles } = node; // @ts-expect-error -- Passing a D3.Selection seems to work for some reason const rc = rough.svg(shapeSvg); const options = userNodeOverrides(node, {}); - + if (node.stroke) { + options.stroke = node.stroke; + } if (node.look !== 'handDrawn') { options.roughness = 0; options.fillStyle = 'solid'; } - const points = [ // Top edge (left to right) { x: -w / 2 + taper, y: -h / 2 }, // Top-left corner start (1) diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/squareRect.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/squareRect.ts index af72a798f..0e5ec730e 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/squareRect.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/squareRect.ts @@ -7,7 +7,7 @@ export async function squareRect(parent: D3Selecti rx: 0, ry: 0, classes: '', - labelPaddingX: (node?.padding || 0) * 2, + labelPaddingX: node.labelPaddingX ?? (node?.padding || 0) * 2, labelPaddingY: (node?.padding || 0) * 1, } as RectOptions; return drawRect(parent, node, options); diff --git a/packages/mermaid/src/rendering-util/types.ts b/packages/mermaid/src/rendering-util/types.ts index b11d2f314..c8439b534 100644 --- a/packages/mermaid/src/rendering-util/types.ts +++ b/packages/mermaid/src/rendering-util/types.ts @@ -2,6 +2,7 @@ export type MarkdownWordType = 'normal' | 'strong' | 'em'; import type { MermaidConfig } from '../config.type.js'; import type { ClusterShapeID } from './rendering-elements/clusters.js'; import type { ShapeID } from './rendering-elements/shapes.js'; +import type { Bounds, Point } from '../types.js'; export interface MarkdownWord { content: string; type: MarkdownWordType; @@ -38,11 +39,12 @@ interface BaseNode { linkTarget?: string; tooltip?: string; padding?: number; //REMOVE?, use from LayoutData.config - Keep, this could be shape specific - isGroup: boolean; + isGroup?: boolean; width?: number; height?: number; // Specific properties for State Diagram nodes TODO remove and use generic properties intersect?: (point: any) => any; + calcIntersect?: (bounds: Bounds, point: Point) => any; // Non-generic properties rx?: number; // Used for rounded corners in Rect, Ellipse, etc.Maybe it to specialized RectNode, EllipseNode, etc. @@ -58,6 +60,8 @@ interface BaseNode { borderStyle?: string; borderWidth?: number; labelTextColor?: string; + labelPaddingX?: number; + labelPaddingY?: number; // Flowchart specific properties x?: number; @@ -72,16 +76,25 @@ interface BaseNode { defaultWidth?: number; imageAspectRatio?: number; constraint?: 'on' | 'off'; + children?: NodeChildren; + nodeId?: string; + level?: number; + descr?: string; + type?: number; + radius?: number; + taper?: number; + stroke?: string; } /** * Group/cluster nodes, e.g. nodes that contain other nodes. */ +export type NodeChildren = Node[]; + export interface ClusterNode extends BaseNode { shape?: ClusterShapeID; isGroup: true; } - export interface NonClusterNode extends BaseNode { shape?: ShapeID; isGroup: false; @@ -113,7 +126,7 @@ export interface Edge { start?: string; stroke?: string; text?: string; - type: string; + type?: string; // Class Diagram specific properties startLabelRight?: string; endLabelLeft?: string; @@ -126,6 +139,12 @@ export interface Edge { thickness?: 'normal' | 'thick' | 'invisible' | 'dotted'; look?: string; isUserDefinedId?: boolean; + points?: Point[]; + parentId?: string; + dir?: string; + source?: string; + target?: string; + depth?: number; } export interface RectOptions { @@ -136,6 +155,10 @@ export interface RectOptions { classes: string; } +export interface MindmapOptions { + padding: number; +} + // Extending the Node interface for specific types if needed export type ClassDiagramNode = Node & { memberData: any; // Specific property for class diagram nodes @@ -171,6 +194,7 @@ export interface ShapeRenderOptions { config: MermaidConfig; /** Some shapes render differently if a diagram has a direction `LR` */ dir?: Node['dir']; + padding?: number; } export type KanbanNode = Node & { diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index 0ff385c61..4b75c9704 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -977,6 +977,7 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file) - useMaxWidth - padding - maxNodeWidth + - layoutAlgorithm properties: padding: type: number @@ -984,6 +985,10 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file) maxNodeWidth: type: number default: 200 + layoutAlgorithm: + description: Layout algorithm to use for positioning mindmap nodes + type: string + default: 'cose-bilkent' KanbanDiagramConfig: title: Kanban Diagram Config diff --git a/packages/mermaid/src/types.ts b/packages/mermaid/src/types.ts index 477fb17b1..727b6bb3a 100644 --- a/packages/mermaid/src/types.ts +++ b/packages/mermaid/src/types.ts @@ -48,6 +48,10 @@ export interface Point { x: number; y: number; } +export interface Bounds extends Point { + width: number; + height: number; +} export interface TextDimensionConfig { fontSize?: number; diff --git a/packages/mermaid/src/types/cytoscape-cose-bilkent.d.ts b/packages/mermaid/src/types/cytoscape-cose-bilkent.d.ts new file mode 100644 index 000000000..6e2930a47 --- /dev/null +++ b/packages/mermaid/src/types/cytoscape-cose-bilkent.d.ts @@ -0,0 +1,4 @@ +declare module 'cytoscape-cose-bilkent' { + const coseBilkent: any; + export default coseBilkent; +} diff --git a/packages/mermaid/src/utils/lineWithOffset.ts b/packages/mermaid/src/utils/lineWithOffset.ts index 800a5ffaf..057944325 100644 --- a/packages/mermaid/src/utils/lineWithOffset.ts +++ b/packages/mermaid/src/utils/lineWithOffset.ts @@ -3,7 +3,7 @@ import type { EdgeData, Point } from '../types.js'; // We need to draw the lines a bit shorter to avoid drawing // under any transparent markers. // The offsets are calculated from the markers' dimensions. -const markerOffsets = { +export const markerOffsets = { aggregation: 18, extension: 18, composition: 18, @@ -104,7 +104,6 @@ export const getLineFunctionsWithOffset = ( adjustment *= DIRECTION === 'right' ? -1 : 1; offset += adjustment; } - return pointTransformer(d).x + offset; }, y: function ( diff --git a/packages/parser/README.md b/packages/parser/README.md index 0a1ef04ed..07ef5a5b4 100644 --- a/packages/parser/README.md +++ b/packages/parser/README.md @@ -59,5 +59,4 @@ ValueConverter -->> Package: Return AST - To insert or modify attributes that can't be parsed. - When to override `ValueConverter`? - - To modify the returned value from the parser. diff --git a/packages/tiny/CHANGELOG.md b/packages/tiny/CHANGELOG.md index df67f0cfd..fc2f97fdf 100644 --- a/packages/tiny/CHANGELOG.md +++ b/packages/tiny/CHANGELOG.md @@ -229,7 +229,6 @@ - [#5999](https://github.com/mermaid-js/mermaid/pull/5999) [`742ad7c`](https://github.com/mermaid-js/mermaid/commit/742ad7c130964df1fb5544e909d9556081285f68) Thanks [@knsv](https://github.com/knsv)! - Adding Kanban board, a new diagram type - [#5880](https://github.com/mermaid-js/mermaid/pull/5880) [`bdf145f`](https://github.com/mermaid-js/mermaid/commit/bdf145ffe362462176d9c1e68d5f3ff5c9d962b0) Thanks [@yari-dewalt](https://github.com/yari-dewalt)! - Class diagram changes: - - Updates the class diagram to the new unified way of rendering. - Includes a new "classBox" shape to be used in diagrams - Other updates such as: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1db33181..ab2670281 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,8 +269,8 @@ importers: specifier: ^4.17.21 version: 4.17.21 marked: - specifier: ^16.0.0 - version: 16.0.0 + specifier: ^15.0.7 + version: 15.0.12 roughjs: specifier: ^4.6.6 version: 4.6.6(patch_hash=3543d47108cb41b68ec6a671c0e1f9d0cfe2ce524fea5b0992511ae84c3c6b64) @@ -387,11 +387,11 @@ importers: specifier: ^4.35.0 version: 4.35.0 typedoc: - specifier: ^0.27.8 - version: 0.27.8(typescript@5.7.3) + specifier: ^0.28.9 + version: 0.28.11(typescript@5.7.3) typedoc-plugin-markdown: - specifier: ^4.4.2 - version: 4.4.2(typedoc@0.27.8(typescript@5.7.3)) + specifier: ^4.8.0 + version: 4.8.1(typedoc@0.28.11(typescript@5.7.3)) typescript: specifier: ~5.7.3 version: 5.7.3 @@ -446,6 +446,22 @@ importers: specifier: workspace:^ version: link:../mermaid + packages/mermaid-layout-tidy-tree: + dependencies: + d3: + specifier: ^7.9.0 + version: 7.9.0 + non-layered-tidy-tree-layout: + specifier: ^2.0.2 + version: 2.0.2 + devDependencies: + '@types/d3': + specifier: ^7.4.3 + version: 7.4.3 + mermaid: + specifier: workspace:^ + version: link:../mermaid + packages/mermaid-zenuml: dependencies: '@zenuml/core': @@ -2409,8 +2425,8 @@ packages: '@floating-ui/utils@0.2.9': resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} - '@gerrit0/mini-shiki@1.27.2': - resolution: {integrity: sha512-GeWyHz8ao2gBiUW4OJnQDxXQnFgZQwwQk05t/CVVgNBN7/rK8XZ7xY6YhLVv9tH3VppWWmr9DCl3MwemB/i+Og==} + '@gerrit0/mini-shiki@3.12.0': + resolution: {integrity: sha512-CF1vkfe2ViPtmoFEvtUWilEc4dOCiFzV8+J7/vEISSsslKQ97FjeTPNMCqUhZEiKySmKRgK3UO/CxtkyOp7DvA==} '@hapi/hoek@9.3.0': resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} @@ -3046,27 +3062,33 @@ packages: '@shikijs/engine-javascript@2.5.0': resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} - '@shikijs/engine-oniguruma@1.29.2': - resolution: {integrity: sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==} - '@shikijs/engine-oniguruma@2.5.0': resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + '@shikijs/engine-oniguruma@3.12.0': + resolution: {integrity: sha512-IfDl3oXPbJ/Jr2K8mLeQVpnF+FxjAc7ZPDkgr38uEw/Bg3u638neSrpwqOTnTHXt1aU0Fk1/J+/RBdst1kVqLg==} + '@shikijs/langs@2.5.0': resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + '@shikijs/langs@3.12.0': + resolution: {integrity: sha512-HIca0daEySJ8zuy9bdrtcBPhcYBo8wR1dyHk1vKrOuwDsITtZuQeGhEkcEfWc6IDyTcom7LRFCH6P7ljGSCEiQ==} + '@shikijs/themes@2.5.0': resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + '@shikijs/themes@3.12.0': + resolution: {integrity: sha512-/lxvQxSI5s4qZLV/AuFaA4Wt61t/0Oka/P9Lmpr1UV+HydNCczO3DMHOC/CsXCCpbv4Zq8sMD0cDa7mvaVoj0Q==} + '@shikijs/transformers@2.5.0': resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} - '@shikijs/types@1.29.2': - resolution: {integrity: sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==} - '@shikijs/types@2.5.0': resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + '@shikijs/types@3.12.0': + resolution: {integrity: sha512-jsFzm8hCeTINC3OCmTZdhR9DOl/foJWplH2Px0bTi4m8z59fnsueLsweX82oGcjRQ7mfQAluQYKGoH2VzsWY4A==} + '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -7423,9 +7445,9 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - marked@16.0.0: - resolution: {integrity: sha512-MUKMXDjsD/eptB7GPzxo4xcnLS6oo7/RHimUMHEDRhUooPwmN9BEpMl7AEOJv3bmso169wHI2wUF9VQgL7zfmA==} - engines: {node: '>= 20'} + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} hasBin: true marked@4.3.0: @@ -7849,6 +7871,9 @@ packages: resolution: {integrity: sha512-fiVbT7BqxiQqjlR9U3FDGOSERFCKoXVCdxV2FwZuNN7/cmJ42iQx35nUFOAFDcyvemu9Adp+IlsCGlKQYLmBKw==} deprecated: Package no longer supported. Contact support@npmjs.com for more info. + non-layered-tidy-tree-layout@2.0.2: + resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -9536,18 +9561,18 @@ packages: typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} - typedoc-plugin-markdown@4.4.2: - resolution: {integrity: sha512-kJVkU2Wd+AXQpyL6DlYXXRrfNrHrEIUgiABWH8Z+2Lz5Sq6an4dQ/hfvP75bbokjNDUskOdFlEEm/0fSVyC7eg==} + typedoc-plugin-markdown@4.8.1: + resolution: {integrity: sha512-ug7fc4j0SiJxSwBGLncpSo8tLvrT9VONvPUQqQDTKPxCoFQBADLli832RGPtj6sfSVJebNSrHZQRUdEryYH/7g==} engines: {node: '>= 18'} peerDependencies: - typedoc: 0.27.x + typedoc: 0.28.x - typedoc@0.27.8: - resolution: {integrity: sha512-q0/2TUunNEDmWkn23ULKGXieK8cgGuAmBUXC/HcZ/rgzMI9Yr4Nq3in1K1vT1NZ9zx6M78yTk3kmIPbwJgK5KA==} - engines: {node: '>= 18'} + typedoc@0.28.11: + resolution: {integrity: sha512-1FqgrrUYGNuE3kImAiEDgAVVVacxdO4ZVTKbiOVDGkoeSB4sNwQaDpa8mta+Lw5TEzBFmGXzsg0I1NLRIoaSFw==} + engines: {node: '>= 18', pnpm: '>= 10'} hasBin: true peerDependencies: - typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x typescript-eslint@8.38.0: resolution: {integrity: sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg==} @@ -10324,11 +10349,6 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yaml@2.7.0: - resolution: {integrity: sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==} - engines: {node: '>= 14'} - hasBin: true - yaml@2.8.0: resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} engines: {node: '>= 14.6'} @@ -13092,10 +13112,12 @@ snapshots: '@floating-ui/utils@0.2.9': {} - '@gerrit0/mini-shiki@1.27.2': + '@gerrit0/mini-shiki@3.12.0': dependencies: - '@shikijs/engine-oniguruma': 1.29.2 - '@shikijs/types': 1.29.2 + '@shikijs/engine-oniguruma': 3.12.0 + '@shikijs/langs': 3.12.0 + '@shikijs/themes': 3.12.0 + '@shikijs/types': 3.12.0 '@shikijs/vscode-textmate': 10.0.2 '@hapi/hoek@9.3.0': {} @@ -13785,35 +13807,43 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 3.1.1 - '@shikijs/engine-oniguruma@1.29.2': - dependencies: - '@shikijs/types': 1.29.2 - '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/engine-oniguruma@2.5.0': dependencies: '@shikijs/types': 2.5.0 '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/engine-oniguruma@3.12.0': + dependencies: + '@shikijs/types': 3.12.0 + '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/langs@2.5.0': dependencies: '@shikijs/types': 2.5.0 + '@shikijs/langs@3.12.0': + dependencies: + '@shikijs/types': 3.12.0 + '@shikijs/themes@2.5.0': dependencies: '@shikijs/types': 2.5.0 + '@shikijs/themes@3.12.0': + dependencies: + '@shikijs/types': 3.12.0 + '@shikijs/transformers@2.5.0': dependencies: '@shikijs/core': 2.5.0 '@shikijs/types': 2.5.0 - '@shikijs/types@1.29.2': + '@shikijs/types@2.5.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 - '@shikijs/types@2.5.0': + '@shikijs/types@3.12.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 @@ -14400,6 +14430,14 @@ snapshots: optionalDependencies: vite: 6.1.1(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0) + '@unocss/astro@66.4.2(vite@6.1.6(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0))': + dependencies: + '@unocss/core': 66.4.2 + '@unocss/reset': 66.4.2 + '@unocss/vite': 66.4.2(vite@6.1.6(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0)) + optionalDependencies: + vite: 6.1.6(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0) + '@unocss/cli@66.4.2': dependencies: '@ampproject/remapping': 2.3.0 @@ -14544,6 +14582,19 @@ snapshots: unplugin-utils: 0.2.4 vite: 6.1.1(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0) + '@unocss/vite@66.4.2(vite@6.1.6(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@unocss/config': 66.4.2 + '@unocss/core': 66.4.2 + '@unocss/inspector': 66.4.2 + chokidar: 3.6.0 + magic-string: 0.30.17 + pathe: 2.0.3 + tinyglobby: 0.2.14 + unplugin-utils: 0.2.4 + vite: 6.1.6(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0) + '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true @@ -14607,6 +14658,10 @@ snapshots: dependencies: vite-plugin-pwa: 1.0.0(vite@6.1.1(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0))(workbox-build@7.1.1(@types/babel__core@7.20.5))(workbox-window@7.3.0) + '@vite-pwa/vitepress@1.0.0(vite-plugin-pwa@1.0.0(vite@6.1.6(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0))(workbox-build@7.1.1(@types/babel__core@7.20.5))(workbox-window@7.3.0))': + dependencies: + vite-plugin-pwa: 1.0.0(vite@6.1.6(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0))(workbox-build@7.1.1(@types/babel__core@7.20.5))(workbox-window@7.3.0) + '@vitejs/plugin-vue@5.2.1(vite@5.4.19(@types/node@22.13.5)(terser@5.39.0))(vue@3.5.13(typescript@5.7.3))': dependencies: vite: 5.4.19(@types/node@22.13.5)(terser@5.39.0) @@ -14618,6 +14673,12 @@ snapshots: vite: 6.1.1(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0) vue: 3.5.13(typescript@5.7.3) + '@vitejs/plugin-vue@6.0.0(vite@6.1.6(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0))(vue@3.5.13(typescript@5.7.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-beta.19 + vite: 6.1.6(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0) + vue: 3.5.13(typescript@5.7.3) + '@vitest/coverage-v8@3.0.6(vitest@3.0.6)': dependencies: '@ampproject/remapping': 2.3.0 @@ -19115,7 +19176,7 @@ snapshots: markdown-table@3.0.4: {} - marked@16.0.0: {} + marked@15.0.12: {} marked@4.3.0: {} @@ -19673,6 +19734,8 @@ snapshots: colors: 0.5.1 underscore: 1.1.7 + non-layered-tidy-tree-layout@2.0.2: {} + normalize-path@3.0.0: {} normalize-url@6.1.0: {} @@ -21095,7 +21158,7 @@ snapshots: deep-equal: 2.2.3 dependency-tree: 11.0.1 lazy-ass: 2.0.3 - tinyglobby: 0.2.12 + tinyglobby: 0.2.14 transitivePeerDependencies: - supports-color @@ -21621,18 +21684,18 @@ snapshots: dependencies: is-typedarray: 1.0.0 - typedoc-plugin-markdown@4.4.2(typedoc@0.27.8(typescript@5.7.3)): + typedoc-plugin-markdown@4.8.1(typedoc@0.28.11(typescript@5.7.3)): dependencies: - typedoc: 0.27.8(typescript@5.7.3) + typedoc: 0.28.11(typescript@5.7.3) - typedoc@0.27.8(typescript@5.7.3): + typedoc@0.28.11(typescript@5.7.3): dependencies: - '@gerrit0/mini-shiki': 1.27.2 + '@gerrit0/mini-shiki': 3.12.0 lunr: 2.3.9 markdown-it: 14.1.0 minimatch: 9.0.5 typescript: 5.7.3 - yaml: 2.7.0 + yaml: 2.8.0 typescript-eslint@8.38.0(eslint@9.26.0(jiti@2.4.2))(typescript@5.7.3): dependencies: @@ -21780,6 +21843,33 @@ snapshots: - postcss - supports-color + unocss@66.4.2(postcss@8.5.6)(vite@6.1.6(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0)): + dependencies: + '@unocss/astro': 66.4.2(vite@6.1.6(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0)) + '@unocss/cli': 66.4.2 + '@unocss/core': 66.4.2 + '@unocss/postcss': 66.4.2(postcss@8.5.6) + '@unocss/preset-attributify': 66.4.2 + '@unocss/preset-icons': 66.4.2 + '@unocss/preset-mini': 66.4.2 + '@unocss/preset-tagify': 66.4.2 + '@unocss/preset-typography': 66.4.2 + '@unocss/preset-uno': 66.4.2 + '@unocss/preset-web-fonts': 66.4.2 + '@unocss/preset-wind': 66.4.2 + '@unocss/preset-wind3': 66.4.2 + '@unocss/preset-wind4': 66.4.2 + '@unocss/transformer-attributify-jsx': 66.4.2 + '@unocss/transformer-compile-class': 66.4.2 + '@unocss/transformer-directives': 66.4.2 + '@unocss/transformer-variant-group': 66.4.2 + '@unocss/vite': 66.4.2(vite@6.1.6(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0)) + optionalDependencies: + vite: 6.1.6(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0) + transitivePeerDependencies: + - postcss + - supports-color + unpipe@1.0.0: {} unplugin-utils@0.2.4: @@ -21934,6 +22024,17 @@ snapshots: transitivePeerDependencies: - supports-color + vite-plugin-pwa@1.0.0(vite@6.1.6(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0))(workbox-build@7.1.1(@types/babel__core@7.20.5))(workbox-window@7.3.0): + dependencies: + debug: 4.4.0 + pretty-bytes: 6.1.1 + tinyglobby: 0.2.12 + vite: 6.1.6(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.0) + workbox-build: 7.1.1(@types/babel__core@7.20.5) + workbox-window: 7.3.0 + transitivePeerDependencies: + - supports-color + vite@5.4.19(@types/node@22.13.5)(terser@5.39.0): dependencies: esbuild: 0.21.5 @@ -22604,8 +22705,6 @@ snapshots: yallist@3.1.1: {} - yaml@2.7.0: {} - yaml@2.8.0: {} yargs-parser@18.1.3: