diff --git a/.build/jsonSchema.ts b/.build/jsonSchema.ts index 50b9ff097b..7a700c1e28 100644 --- a/.build/jsonSchema.ts +++ b/.build/jsonSchema.ts @@ -19,6 +19,7 @@ const MERMAID_CONFIG_DIAGRAM_KEYS = [ 'xyChart', 'requirement', 'mindmap', + 'kanban', 'timeline', 'gitGraph', 'c4', diff --git a/.changeset/kind-drinks-invent.md b/.changeset/kind-drinks-invent.md new file mode 100644 index 0000000000..244be2bf63 --- /dev/null +++ b/.changeset/kind-drinks-invent.md @@ -0,0 +1,5 @@ +--- +'mermaid': minor +--- + +Adding Kanban board, a new diagram type diff --git a/.cspell/mermaid-terms.txt b/.cspell/mermaid-terms.txt index 8551bd1962..cb6db41dec 100644 --- a/.cspell/mermaid-terms.txt +++ b/.cspell/mermaid-terms.txt @@ -12,6 +12,7 @@ gantt gitgraph gzipped handDrawn +kanban knsv Knut marginx diff --git a/cypress/integration/rendering/classDiagram-elk-v3.spec.js b/cypress/integration/rendering/classDiagram-elk-v3.spec.js new file mode 100644 index 0000000000..ee6ca0b2b4 --- /dev/null +++ b/cypress/integration/rendering/classDiagram-elk-v3.spec.js @@ -0,0 +1,1037 @@ +import { imgSnapshotTest } from '../../helpers/util.ts'; +describe('Class diagram V3 ELK', () => { + it('ELK-0: should render a simple class diagram', () => { + imgSnapshotTest( + ` + classDiagram + + classA -- classB : Inheritance + classA -- classC : link + classC -- classD : link + classB -- classD + + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-1: should render a simple class diagram', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 *-- Class04 + Class05 o-- Class06 + Class07 .. Class08 + Class09 --> C2 : Where am i? + Class09 --* C3 + Class09 --|> Class07 + Class12 <|.. Class08 + Class11 ..>Class12 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-1.1: should render a simple class diagram without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 *-- Class04 + Class05 o-- Class06 + Class07 .. Class08 + Class09 --> C2 : Where am i? + Class09 --* C3 + Class09 --|> Class07 + Class12 <|.. Class08 + Class11 ..>Class12 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: false, layout: 'elk' } + ); + }); + + it('ELK-2: should render a simple class diagrams with cardinality', () => { + imgSnapshotTest( + ` + classDiagram + Class01 "1" <|--|> "*" AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 "1" <--> "*" C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-2.1: should render a simple class diagrams with cardinality without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + Class01 "1" <|--|> "*" AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 "1" <--> "*" C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: false, layout: 'elk' } + ); + }); + + it('ELK-2.2 should render a simple class diagram with different visibilities', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class01 : -privateMethod() + Class01 : +publicMethod() + Class01 : #protectedMethod() + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + it('ELK-2.3 should render a simple class diagram with different visibilities without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class01 : -privateMethod() + Class01 : +publicMethod() + Class01 : #protectedMethod() + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + `, + { logLevel: 1, htmlLabels: false, layout: 'elk' } + ); + }); + + it('ELK-3: should render multiple class diagrams', () => { + imgSnapshotTest( + [ + ` + classDiagram + Class01 "1" <|--|> "*" AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 "1" <--> "*" C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + ` + classDiagram + Class01 "1" <|--|> "*" AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 "1" <--> "*" C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + ], + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-4: should render a simple class diagram with comments', () => { + imgSnapshotTest( + ` + classDiagram + %% this is a comment + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 *-- Class04 + Class05 o-- Class06 + Class07 .. Class08 + Class09 --> C2 : Where am i? + Class09 --* C3 + Class09 --|> Class07 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-5: should render a simple class diagram with abstract method', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + Class01 : someMethod()* + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-5.1: should render a simple class diagram with abstract method without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + Class01 : someMethod()* + `, + { logLevel: 1, htmlLabels: false, layout: 'elk' } + ); + }); + + it('ELK-6: should render a simple class diagram with static method', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + Class01 : someMethod()$ + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-6.1: should render a simple class diagram with static method without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + Class01 : someMethod()$ + `, + { logLevel: 1, htmlLabels: false, layout: 'elk' } + ); + }); + + it('ELK-7: should render a simple class diagram with Generic class', () => { + imgSnapshotTest( + ` + classDiagram + class Class01~T~ + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-7.1: should render a simple class diagram with Generic class without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class01~T~ + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: false, layout: 'elk' } + ); + }); + + it('ELK-8: should render a simple class diagram with Generic class and relations', () => { + imgSnapshotTest( + ` + classDiagram + Class01~T~ <|-- AveryLongClass : Cool + Class03~T~ *-- Class04~T~ + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-9: should render a simple class diagram with clickable link', () => { + imgSnapshotTest( + ` + classDiagram + Class01~T~ <|-- AveryLongClass : Cool + Class03~T~ *-- Class04~T~ + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + link Class01 "google.com" "A Tooltip" + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-10: should render a simple class diagram with clickable callback', () => { + imgSnapshotTest( + ` + classDiagram + Class01~T~ <|-- AveryLongClass : Cool + Class03~T~ *-- Class04~T~ + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + callback Class01 "functionCall" "A Tooltip" + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-11: should render a simple class diagram with return type on method', () => { + imgSnapshotTest( + ` + classDiagram + class Class10~T~ { + int[] id + test(int[] ids) bool + testArray() bool[] + } + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-11.1: should render a simple class diagram with return type on method without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class10~T~ { + int[] id + test(int[] ids) bool + testArray() bool[] + } + `, + { logLevel: 1, htmlLabels: false, layout: 'elk' } + ); + }); + + it('ELK-12: should render a simple class diagram with generic types', () => { + imgSnapshotTest( + ` + classDiagram + class Class10~T~ { + int[] id + List~int~ ids + test(List~int~ ids) List~bool~ + testArray() bool[] + } + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-12.1: should render a simple class diagram with generic types without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class10~T~ { + int[] id + List~int~ ids + test(List~int~ ids) List~bool~ + testArray() bool[] + } + `, + { logLevel: 1, htmlLabels: false, layout: 'elk' } + ); + }); + + it('ELK-13: should render a simple class diagram with css classes applied', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 { + int[] id + List~int~ ids + test(List~int~ ids) List~bool~ + testArray() bool[] + } + + cssClass "Class10" exClass2 + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-14: should render a simple class diagram with css classes applied directly', () => { + imgSnapshotTest( + ` + classDiagram + class Class10:::exClass2 { + int[] id + List~int~ ids + test(List~int~ ids) List~bool~ + testArray() bool[] + } + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-15: should render a simple class diagram with css classes applied two multiple classes', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + class Class20 + + cssClass "Class10, class20" exClass2 + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-16a: should render a simple class diagram with static field', () => { + imgSnapshotTest( + ` + classDiagram + class Foo { + +String bar$ + } + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-16b: should handle the direction statement with TB', () => { + imgSnapshotTest( + ` + classDiagram + direction TB + class Student { + -idCard : IdCard + } + class IdCard{ + -id : int + -name : string + } + class Bike{ + -id : int + -name : string + } + Student "1" --o "1" IdCard : carries + Student "1" --o "1" Bike : rides + + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + it('ELK-17a: should handle the direction statement with BT', () => { + imgSnapshotTest( + ` + classDiagram + direction BT + class Student { + -idCard : IdCard + } + class IdCard{ + -id : int + -name : string + } + class Bike{ + -id : int + -name : string + } + Student "1" --o "1" IdCard : carries + Student "1" --o "1" Bike : rides + + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + it('ELK-17b: should handle the direction statement with RL', () => { + imgSnapshotTest( + ` + classDiagram + direction RL + class Student { + -idCard : IdCard + } + class IdCard{ + -id : int + -name : string + } + class Bike{ + -id : int + -name : string + } + Student "1" --o "1" IdCard : carries + Student "1" --o "1" Bike : rides + + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-18a: should handle the direction statement with LR', () => { + imgSnapshotTest( + ` + classDiagram + direction LR + class Student { + -idCard : IdCard + } + class IdCard{ + -id : int + -name : string + } + class Bike{ + -id : int + -name : string + } + Student "1" --o "1" IdCard : carries + Student "1" --o "1" Bike : rides + + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-18b: should render a simple class diagram with notes', () => { + imgSnapshotTest( + ` + classDiagram + note "I love this diagram!\nDo you love it?" + class Class10 { + int id + size() + } + note for Class10 "Cool class\nI said it's very cool class!" + + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK-1433: should render a simple class with a title', () => { + imgSnapshotTest( + `--- +title: simple class diagram +--- +classDiagram +class Class10 +`, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK: should render a class with text label', () => { + imgSnapshotTest( + `classDiagram + class C1["Class 1 with text label"] + C1 --> C2`, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK: should render two classes with text labels', () => { + imgSnapshotTest( + `classDiagram + class C1["Class 1 with text label"] + class C2["Class 2 with chars @?"] + C1 --> C2`, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + it('ELK: should render a class with a text label, members and annotation', () => { + imgSnapshotTest( + `classDiagram + class C1["Class 1 with text label"] { + <<interface>> + +member1 + } + C1 --> C2`, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + it('ELK: should render multiple classes with same text labels', () => { + imgSnapshotTest( + `classDiagram + class C1["Class with text label"] + class C2["Class with text label"] + class C3["Class with text label"] + C1 --> C2 + C3 ..> C2 + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + it('ELK: should render classes with different text labels', () => { + imgSnapshotTest( + `classDiagram + class C1["OneWord"] + class C2["With, Comma"] + class C3["With (Brackets)"] + class C4["With [Brackets]"] + class C5["With {Brackets}"] + class C7["With 1 number"] + class C8["With . period..."] + class C9["With - dash"] + class C10["With _ underscore"] + class C11["With ' single quote"] + class C12["With ~!@#$%^&*()_+=-/?"] + class C13["With Città foreign language"] + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + + it('ELK: should render classLabel if class has already been defined earlier', () => { + imgSnapshotTest( + `classDiagram + Animal <|-- Duck + class Duck["Duck with text label"] + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + it('ELK: should add classes namespaces', function () { + imgSnapshotTest( + ` + classDiagram + namespace Namespace1 { + class C1 + class C2 + } + C1 --> C2 + class C3 + class C4 + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + it('ELK: should render a simple class diagram with no members', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + it('ELK: should render a simple class diagram with no members if hideEmptyMembersBox is enabled', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + `, + { logLevel: 1, class: { htmlLabels: true, hideEmptyMembersBox: true }, layout: 'elk' } + ); + }); + it('ELK: should render a simple class diagram with no attributes, only methods', () => { + imgSnapshotTest( + ` + classDiagram + class Duck { + +swim() + +quack() + } + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + it('ELK: should render a simple class diagram with no methods, only attributes', () => { + imgSnapshotTest( + ` + classDiagram + class Duck { + +String beakColor + +int age + +float weight + } + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + it('ELK: should render a simple class diagram with style definition', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + style Class10 fill:#f9f,stroke:#333,stroke-width:4px + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + it('ELK: should render a simple class diagram with style definition without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + style Class10 fill:#f9f,stroke:#333,stroke-width:4px + `, + { logLevel: 1, htmlLabels: false, layout: 'elk' } + ); + }); + it('ELK: should render a simple class diagram with classDef definitions', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + classDef pink fill:#f9f + classDef bold stroke:#333,stroke-width:6px,color:#fff + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + it('ELK: should render a simple class diagram with classDefs being applied', () => { + imgSnapshotTest( + ` + classDiagram + class Class10:::pink + cssClass "Class10" bold + classDef pink fill:#f9f + classDef bold stroke:#333,stroke-width:6px,color:#fff + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + it('ELK: should render a simple class diagram with classDefs being applied without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class10:::pink + cssClass "Class10" bold + classDef pink fill:#f9f + classDef bold stroke:#333,stroke-width:6px,color:#fff + `, + { logLevel: 1, htmlLabels: false, layout: 'elk' } + ); + }); + it('ELK: should render a simple class diagram with markdown styling', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 { + +attribute *italic** + ~attribute **bold*** + _italicmethod_() + __boldmethod__() + _+_swim_()a_ + __+quack() test__ + } + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + it('ELK: should render a simple class diagram with markdown styling without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 { + +attribute *italic** + ~attribute **bold*** + _italicmethod_() + __boldmethod__() + _+_swim_()a_ + __+quack() test__ + } + `, + { logLevel: 1, htmlLabels: false, layout: 'elk' } + ); + }); + it('ELK: should render a simple class diagram with the handDrawn look', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn', layout: 'elk' } + ); + }); + it('ELK: should render a simple class diagram with styles and the handDrawn look', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + style Class10 fill:#f9f,stroke:#333,stroke-width:4px,color:white + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn', layout: 'elk' } + ); + }); + it('ELK: should render a simple class diagram with styles and the handDrawn look without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + style Class10 fill:#f9f,stroke:#333,stroke-width:4px,color:white + `, + { logLevel: 1, htmlLabels: false, look: 'handDrawn', layout: 'elk' } + ); + }); + it('ELK: should render a full class diagram with the handDrawn look', () => { + imgSnapshotTest( + ` + classDiagram + note "I love this diagram!\nDo you love it?" + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class12 <|.. Class08 + Class11 ..>Class12 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + note for Class10 "Cool class\nI said it's very cool class!" + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn', layout: 'elk' } + ); + }); + it('ELK: should render a simple class diagram with a custom theme', () => { + imgSnapshotTest( + ` + %%{ + init: { + 'theme': 'base', + 'themeVariables': { + 'primaryColor': '#BB2528', + 'primaryTextColor': '#fff', + 'primaryBorderColor': '#7C0000', + 'lineColor': '#F83d29', + 'secondaryColor': '#006100', + 'tertiaryColor': '#fff' + } + } + }%% + classDiagram + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 *-- Class04 + Class05 o-- Class06 + Class07 .. Class08 + Class09 --> C2 : Where am i? + Class09 --* C3 + Class09 --|> Class07 + Class12 <|.. Class08 + Class11 ..>Class12 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); + it('ELK: should render a simple class diagram with a custom theme and the handDrawn look', () => { + imgSnapshotTest( + ` + %%{ + init: { + 'theme': 'base', + 'themeVariables': { + 'primaryColor': '#BB2528', + 'primaryTextColor': '#fff', + 'primaryBorderColor': '#7C0000', + 'lineColor': '#F83d29', + 'secondaryColor': '#006100', + 'tertiaryColor': '#fff' + } + } + }%% + classDiagram + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 *-- Class04 + Class05 o-- Class06 + Class07 .. Class08 + Class09 --> C2 : Where am i? + Class09 --* C3 + Class09 --|> Class07 + Class12 <|.. Class08 + Class11 ..>Class12 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn', layout: 'elk' } + ); + }); + it('ELK: should render a full class diagram using elk', () => { + imgSnapshotTest( + ` + classDiagram + note "I love this diagram!\nDo you love it?" + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class12 <|.. Class08 + Class11 ..>Class12 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + note for Class10 "Cool class\nI said it's very cool class!" + `, + { logLevel: 1, htmlLabels: true, layout: 'elk' } + ); + }); +}); diff --git a/cypress/integration/rendering/classDiagram-handDrawn-v3.spec.js b/cypress/integration/rendering/classDiagram-handDrawn-v3.spec.js new file mode 100644 index 0000000000..32a82c0897 --- /dev/null +++ b/cypress/integration/rendering/classDiagram-handDrawn-v3.spec.js @@ -0,0 +1,1041 @@ +import { imgSnapshotTest } from '../../helpers/util.ts'; +describe('Class diagram V3 HD', () => { + it('HD-0: should render a simple class diagram', () => { + imgSnapshotTest( + ` + classDiagram + + classA -- classB : Inheritance + classA -- classC : link + classC -- classD : link + classB -- classD + + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-1: should render a simple class diagram', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 *-- Class04 + Class05 o-- Class06 + Class07 .. Class08 + Class09 --> C2 : Where am i? + Class09 --* C3 + Class09 --|> Class07 + Class12 <|.. Class08 + Class11 ..>Class12 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-1.1: should render a simple class diagram without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 *-- Class04 + Class05 o-- Class06 + Class07 .. Class08 + Class09 --> C2 : Where am i? + Class09 --* C3 + Class09 --|> Class07 + Class12 <|.. Class08 + Class11 ..>Class12 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: false, look: 'handDrawn' } + ); + }); + + it('HD-2: should render a simple class diagrams with cardinality', () => { + imgSnapshotTest( + ` + classDiagram + Class01 "1" <|--|> "*" AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 "1" <--> "*" C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-2.1: should render a simple class diagrams with cardinality without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + Class01 "1" <|--|> "*" AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 "1" <--> "*" C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: false, look: 'handDrawn' } + ); + }); + + it('HD-2.2 should render a simple class diagram with different visibilities', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class01 : -privateMethod() + Class01 : +publicMethod() + Class01 : #protectedMethod() + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD-2.3 should render a simple class diagram with different visibilities without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class01 : -privateMethod() + Class01 : +publicMethod() + Class01 : #protectedMethod() + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + `, + { logLevel: 1, htmlLabels: false, look: 'handDrawn' } + ); + }); + + it('HD-3: should render multiple class diagrams', () => { + imgSnapshotTest( + [ + ` + classDiagram + Class01 "1" <|--|> "*" AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 "1" <--> "*" C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + ` + classDiagram + Class01 "1" <|--|> "*" AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 "1" <--> "*" C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + ], + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-4: should render a simple class diagram with comments', () => { + imgSnapshotTest( + ` + classDiagram + %% this is a comment + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 *-- Class04 + Class05 o-- Class06 + Class07 .. Class08 + Class09 --> C2 : Where am i? + Class09 --* C3 + Class09 --|> Class07 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-5: should render a simple class diagram with abstract method', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + Class01 : someMethod()* + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-5.1: should render a simple class diagram with abstract method without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + Class01 : someMethod()* + `, + { logLevel: 1, htmlLabels: false, look: 'handDrawn' } + ); + }); + + it('HD-6: should render a simple class diagram with static method', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + Class01 : someMethod()$ + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-6.1: should render a simple class diagram with static method without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + Class01 : someMethod()$ + `, + { logLevel: 1, htmlLabels: false, look: 'handDrawn' } + ); + }); + + it('HD-7: should render a simple class diagram with Generic class', () => { + imgSnapshotTest( + ` + classDiagram + class Class01~T~ + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-7.1: should render a simple class diagram with Generic class without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class01~T~ + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: false, look: 'handDrawn' } + ); + }); + + it('HD-8: should render a simple class diagram with Generic class and relations', () => { + imgSnapshotTest( + ` + classDiagram + Class01~T~ <|-- AveryLongClass : Cool + Class03~T~ *-- Class04~T~ + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-9: should render a simple class diagram with clickable link', () => { + imgSnapshotTest( + ` + classDiagram + Class01~T~ <|-- AveryLongClass : Cool + Class03~T~ *-- Class04~T~ + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + link Class01 "google.com" "A Tooltip" + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-10: should render a simple class diagram with clickable callback', () => { + imgSnapshotTest( + ` + classDiagram + Class01~T~ <|-- AveryLongClass : Cool + Class03~T~ *-- Class04~T~ + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + callback Class01 "functionCall" "A Tooltip" + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-11: should render a simple class diagram with return type on method', () => { + imgSnapshotTest( + ` + classDiagram + class Class10~T~ { + int[] id + test(int[] ids) bool + testArray() bool[] + } + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-11.1: should render a simple class diagram with return type on method without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class10~T~ { + int[] id + test(int[] ids) bool + testArray() bool[] + } + `, + { logLevel: 1, htmlLabels: false, look: 'handDrawn' } + ); + }); + + it('HD-12: should render a simple class diagram with generic types', () => { + imgSnapshotTest( + ` + classDiagram + class Class10~T~ { + int[] id + List~int~ ids + test(List~int~ ids) List~bool~ + testArray() bool[] + } + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-12.1: should render a simple class diagram with generic types without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class10~T~ { + int[] id + List~int~ ids + test(List~int~ ids) List~bool~ + testArray() bool[] + } + `, + { logLevel: 1, htmlLabels: false, look: 'handDrawn' } + ); + }); + + it('HD-13: should render a simple class diagram with css classes applied', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 { + int[] id + List~int~ ids + test(List~int~ ids) List~bool~ + testArray() bool[] + } + + cssClass "Class10" exClass2 + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-14: should render a simple class diagram with css classes applied directly', () => { + imgSnapshotTest( + ` + classDiagram + class Class10:::exClass2 { + int[] id + List~int~ ids + test(List~int~ ids) List~bool~ + testArray() bool[] + } + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-15: should render a simple class diagram with css classes applied two multiple classes', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + class Class20 + + cssClass "Class10, class20" exClass2 + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-16a: should render a simple class diagram with static field', () => { + imgSnapshotTest( + ` + classDiagram + class Foo { + +String bar$ + } + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-16b: should handle the direction statement with TB', () => { + imgSnapshotTest( + ` + classDiagram + direction TB + class Student { + -idCard : IdCard + } + class IdCard{ + -id : int + -name : string + } + class Bike{ + -id : int + -name : string + } + Student "1" --o "1" IdCard : carries + Student "1" --o "1" Bike : rides + + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD-17a: should handle the direction statement with BT', () => { + imgSnapshotTest( + ` + classDiagram + direction BT + class Student { + -idCard : IdCard + } + class IdCard{ + -id : int + -name : string + } + class Bike{ + -id : int + -name : string + } + Student "1" --o "1" IdCard : carries + Student "1" --o "1" Bike : rides + + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD-17b: should handle the direction statement with RL', () => { + imgSnapshotTest( + ` + classDiagram + direction RL + class Student { + -idCard : IdCard + } + class IdCard{ + -id : int + -name : string + } + class Bike{ + -id : int + -name : string + } + Student "1" --o "1" IdCard : carries + Student "1" --o "1" Bike : rides + + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-18a: should handle the direction statement with LR', () => { + imgSnapshotTest( + ` + classDiagram + direction LR + class Student { + -idCard : IdCard + } + class IdCard{ + -id : int + -name : string + } + class Bike{ + -id : int + -name : string + } + Student "1" --o "1" IdCard : carries + Student "1" --o "1" Bike : rides + + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-18b: should render a simple class diagram with notes', () => { + imgSnapshotTest( + ` + classDiagram + note "I love this diagram!\nDo you love it?" + class Class10 { + int id + size() + } + note for Class10 "Cool class\nI said it's very cool class!" + + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD-1433: should render a simple class with a title', () => { + imgSnapshotTest( + `--- +title: simple class diagram +--- +classDiagram +class Class10 +`, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD: should render a class with text label', () => { + imgSnapshotTest( + `classDiagram + class C1["Class 1 with text label"] + C1 --> C2`, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD: should render two classes with text labels', () => { + imgSnapshotTest( + `classDiagram + class C1["Class 1 with text label"] + class C2["Class 2 with chars @?"] + C1 --> C2`, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD: should render a class with a text label, members and annotation', () => { + imgSnapshotTest( + `classDiagram + class C1["Class 1 with text label"] { + <<interface>> + +member1 + } + C1 --> C2`, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD: should render multiple classes with same text labels', () => { + imgSnapshotTest( + `classDiagram +class C1["Class with text label"] +class C2["Class with text label"] +class C3["Class with text label"] +C1 --> C2 +C3 ..> C2 + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD: should render classes with different text labels', () => { + imgSnapshotTest( + `classDiagram +class C1["OneWord"] +class C2["With, Comma"] +class C3["With (Brackets)"] +class C4["With [Brackets]"] +class C5["With {Brackets}"] +class C7["With 1 number"] +class C8["With . period..."] +class C9["With - dash"] +class C10["With _ underscore"] +class C11["With ' single quote"] +class C12["With ~!@#$%^&*()_+=-/?"] +class C13["With Città foreign language"] + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + + it('HD: should render classLabel if class has already been defined earlier', () => { + imgSnapshotTest( + `classDiagram + Animal <|-- Duck + class Duck["Duck with text label"] +`, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD: should add classes namespaces', function () { + imgSnapshotTest( + ` + classDiagram + namespace Namespace1 { + class C1 + class C2 + } + C1 --> C2 + class C3 + class C4 + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD: should render a simple class diagram with no members', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD: should render a simple class diagram with no members if hideEmptyMembersBox is enabled', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + `, + { logLevel: 1, class: { htmlLabels: true, hideEmptyMembersBox: true }, look: 'handDrawn' } + ); + }); + it('HD: should render a simple class diagram with no attributes, only methods', () => { + imgSnapshotTest( + ` + classDiagram + class Duck { + +swim() + +quack() + } + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD: should render a simple class diagram with no methods, only attributes', () => { + imgSnapshotTest( + ` + classDiagram + class Duck { + +String beakColor + +int age + +float weight + } + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD: should render a simple class diagram with style definition', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + style Class10 fill:#f9f,stroke:#333,stroke-width:4px + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD: should render a simple class diagram with style definition without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + style Class10 fill:#f9f,stroke:#333,stroke-width:4px + `, + { logLevel: 1, htmlLabels: false, look: 'handDrawn' } + ); + }); + it('HD: should render a simple class diagram with classDef definitions', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + classDef pink fill:#f9f + classDef bold stroke:#333,stroke-width:6px,color:#fff + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD: should render a simple class diagram with classDefs being applied', () => { + imgSnapshotTest( + ` + classDiagram + class Class10:::pink + cssClass "Class10" bold + classDef pink fill:#f9f + classDef bold stroke:#333,stroke-width:6px,color:#fff + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD: should render a simple class diagram with classDefs being applied without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class10:::pink + cssClass "Class10" bold + classDef pink fill:#f9f + classDef bold stroke:#333,stroke-width:6px,color:#fff + `, + { logLevel: 1, htmlLabels: false, look: 'handDrawn' } + ); + }); + it('HD: should render a simple class diagram with markdown styling', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 { + +attribute *italic** + ~attribute **bold*** + _italicmethod_() + __boldmethod__() + _+_swim_()a_ + __+quack() test__ + } + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD: should render a simple class diagram with markdown styling without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 { + +attribute *italic** + ~attribute **bold*** + _italicmethod_() + __boldmethod__() + _+_swim_()a_ + __+quack() test__ + } + `, + { logLevel: 1, htmlLabels: false, look: 'handDrawn' } + ); + }); + it('HD: should render a simple class diagram with the handDrawn look', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD: should render a simple class diagram with styles and the handDrawn look', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + style Class10 fill:#f9f,stroke:#333,stroke-width:4px,color:white + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD: should render a simple class diagram with styles and the handDrawn look without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + style Class10 fill:#f9f,stroke:#333,stroke-width:4px,color:white + `, + { logLevel: 1, htmlLabels: false, look: 'handDrawn' } + ); + }); + it('HD: should render a full class diagram with the handDrawn look', () => { + imgSnapshotTest( + ` + classDiagram + note "I love this diagram!\nDo you love it?" + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class12 <|.. Class08 + Class11 ..>Class12 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + note for Class10 "Cool class\nI said it's very cool class!" + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD: should render a simple class diagram with a custom theme', () => { + imgSnapshotTest( + ` + %%{ + init: { + 'theme': 'base', + 'themeVariables': { + 'primaryColor': '#BB2528', + 'primaryTextColor': '#fff', + 'primaryBorderColor': '#7C0000', + 'lineColor': '#F83d29', + 'secondaryColor': '#006100', + 'tertiaryColor': '#fff' + } + } + }%% + classDiagram + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 *-- Class04 + Class05 o-- Class06 + Class07 .. Class08 + Class09 --> C2 : Where am i? + Class09 --* C3 + Class09 --|> Class07 + Class12 <|.. Class08 + Class11 ..>Class12 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD: should render a simple class diagram with a custom theme and the handDrawn look', () => { + imgSnapshotTest( + ` + %%{ + init: { + 'theme': 'base', + 'themeVariables': { + 'primaryColor': '#BB2528', + 'primaryTextColor': '#fff', + 'primaryBorderColor': '#7C0000', + 'lineColor': '#F83d29', + 'secondaryColor': '#006100', + 'tertiaryColor': '#fff' + } + } + }%% + classDiagram + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 *-- Class04 + Class05 o-- Class06 + Class07 .. Class08 + Class09 --> C2 : Where am i? + Class09 --* C3 + Class09 --|> Class07 + Class12 <|.. Class08 + Class11 ..>Class12 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('HD: should render a full class diagram using elk', () => { + imgSnapshotTest( + ` +--- + config: + layout: elk +--- + classDiagram + note "I love this diagram!\nDo you love it?" + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class12 <|.. Class08 + Class11 ..>Class12 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + note for Class10 "Cool class\nI said it's very cool class!" + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); +}); diff --git a/cypress/integration/rendering/classDiagram-v3.spec.js b/cypress/integration/rendering/classDiagram-v3.spec.js new file mode 100644 index 0000000000..626d6fcea4 --- /dev/null +++ b/cypress/integration/rendering/classDiagram-v3.spec.js @@ -0,0 +1,1031 @@ +import { imgSnapshotTest } from '../../helpers/util.ts'; +describe('Class diagram V3', () => { + it('0: should render a simple class diagram', () => { + imgSnapshotTest( + ` + classDiagram + + classA -- classB : Inheritance + classA -- classC : link + classC -- classD : link + classB -- classD + + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('1: should render a simple class diagram', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 *-- Class04 + Class05 o-- Class06 + Class07 .. Class08 + Class09 --> C2 : Where am i? + Class09 --* C3 + Class09 --|> Class07 + Class12 <|.. Class08 + Class11 ..>Class12 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('1.1: should render a simple class diagram without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 *-- Class04 + Class05 o-- Class06 + Class07 .. Class08 + Class09 --> C2 : Where am i? + Class09 --* C3 + Class09 --|> Class07 + Class12 <|.. Class08 + Class11 ..>Class12 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: false } + ); + }); + + it('2: should render a simple class diagrams with cardinality', () => { + imgSnapshotTest( + ` + classDiagram + Class01 "1" <|--|> "*" AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 "1" <--> "*" C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('2.1: should render a simple class diagrams with cardinality without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + Class01 "1" <|--|> "*" AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 "1" <--> "*" C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: false } + ); + }); + + it('2.2 should render a simple class diagram with different visibilities', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class01 : -privateMethod() + Class01 : +publicMethod() + Class01 : #protectedMethod() + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + `, + { logLevel: 1, htmlLabels: true } + ); + }); + it('2.3 should render a simple class diagram with different visibilities without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class01 : -privateMethod() + Class01 : +publicMethod() + Class01 : #protectedMethod() + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + `, + { logLevel: 1, htmlLabels: false } + ); + }); + + it('3: should render multiple class diagrams', () => { + imgSnapshotTest( + [ + ` + classDiagram + Class01 "1" <|--|> "*" AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 "1" <--> "*" C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + ` + classDiagram + Class01 "1" <|--|> "*" AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 "1" <--> "*" C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + ], + { logLevel: 1, htmlLabels: true } + ); + }); + + it('4: should render a simple class diagram with comments', () => { + imgSnapshotTest( + ` + classDiagram + %% this is a comment + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 *-- Class04 + Class05 o-- Class06 + Class07 .. Class08 + Class09 --> C2 : Where am i? + Class09 --* C3 + Class09 --|> Class07 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('5: should render a simple class diagram with abstract method', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + Class01 : someMethod()* + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('5.1: should render a simple class diagram with abstract method without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + Class01 : someMethod()* + `, + { logLevel: 1, htmlLabels: false } + ); + }); + + it('6: should render a simple class diagram with static method', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + Class01 : someMethod()$ + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('6.1: should render a simple class diagram with static method without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + Class01 <|-- AveryLongClass : Cool + Class01 : someMethod()$ + `, + { logLevel: 1, htmlLabels: false } + ); + }); + + it('7: should render a simple class diagram with Generic class', () => { + imgSnapshotTest( + ` + classDiagram + class Class01~T~ + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('7.1: should render a simple class diagram with Generic class without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class01~T~ + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: false } + ); + }); + + it('8: should render a simple class diagram with Generic class and relations', () => { + imgSnapshotTest( + ` + classDiagram + Class01~T~ <|-- AveryLongClass : Cool + Class03~T~ *-- Class04~T~ + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('9: should render a simple class diagram with clickable link', () => { + imgSnapshotTest( + ` + classDiagram + Class01~T~ <|-- AveryLongClass : Cool + Class03~T~ *-- Class04~T~ + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + link Class01 "google.com" "A Tooltip" + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('10: should render a simple class diagram with clickable callback', () => { + imgSnapshotTest( + ` + classDiagram + Class01~T~ <|-- AveryLongClass : Cool + Class03~T~ *-- Class04~T~ + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 <--> C2: Cool label + class Class10~T~ { + <<service>> + int id + test() + } + callback Class01 "functionCall" "A Tooltip" + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('11: should render a simple class diagram with return type on method', () => { + imgSnapshotTest( + ` + classDiagram + class Class10~T~ { + int[] id + test(int[] ids) bool + testArray() bool[] + } + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('11.1: should render a simple class diagram with return type on method without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class10~T~ { + int[] id + test(int[] ids) bool + testArray() bool[] + } + `, + { logLevel: 1, htmlLabels: false } + ); + }); + + it('12: should render a simple class diagram with generic types', () => { + imgSnapshotTest( + ` + classDiagram + class Class10~T~ { + int[] id + List~int~ ids + test(List~int~ ids) List~bool~ + testArray() bool[] + } + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('12.1: should render a simple class diagram with generic types without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class10~T~ { + int[] id + List~int~ ids + test(List~int~ ids) List~bool~ + testArray() bool[] + } + `, + { logLevel: 1, htmlLabels: false } + ); + }); + + it('13: should render a simple class diagram with css classes applied', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 { + int[] id + List~int~ ids + test(List~int~ ids) List~bool~ + testArray() bool[] + } + + cssClass "Class10" exClass2 + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('14: should render a simple class diagram with css classes applied directly', () => { + imgSnapshotTest( + ` + classDiagram + class Class10:::exClass2 { + int[] id + List~int~ ids + test(List~int~ ids) List~bool~ + testArray() bool[] + } + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('15: should render a simple class diagram with css classes applied two multiple classes', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + class Class20 + + cssClass "Class10, class20" exClass2 + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('16a: should render a simple class diagram with static field', () => { + imgSnapshotTest( + ` + classDiagram + class Foo { + +String bar$ + } + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('16b: should handle the direction statement with TB', () => { + imgSnapshotTest( + ` + classDiagram + direction TB + class Student { + -idCard : IdCard + } + class IdCard{ + -id : int + -name : string + } + class Bike{ + -id : int + -name : string + } + Student "1" --o "1" IdCard : carries + Student "1" --o "1" Bike : rides + + `, + { logLevel: 1, htmlLabels: true } + ); + }); + it('17a: should handle the direction statement with BT', () => { + imgSnapshotTest( + ` + classDiagram + direction BT + class Student { + -idCard : IdCard + } + class IdCard{ + -id : int + -name : string + } + class Bike{ + -id : int + -name : string + } + Student "1" --o "1" IdCard : carries + Student "1" --o "1" Bike : rides + + `, + { logLevel: 1, htmlLabels: true } + ); + }); + it('17b: should handle the direction statement with RL', () => { + imgSnapshotTest( + ` + classDiagram + direction RL + class Student { + -idCard : IdCard + } + class IdCard{ + -id : int + -name : string + } + class Bike{ + -id : int + -name : string + } + Student "1" --o "1" IdCard : carries + Student "1" --o "1" Bike : rides + + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('18a: should handle the direction statement with LR', () => { + imgSnapshotTest( + ` + classDiagram + direction LR + class Student { + -idCard : IdCard + } + class IdCard{ + -id : int + -name : string + } + class Bike{ + -id : int + -name : string + } + Student "1" --o "1" IdCard : carries + Student "1" --o "1" Bike : rides + + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('18b: should render a simple class diagram with notes', () => { + imgSnapshotTest( + ` + classDiagram + note "I love this diagram!\nDo you love it?" + class Class10 { + int id + size() + } + note for Class10 "Cool class\nI said it's very cool class!" + + `, + { logLevel: 1, htmlLabels: true } + ); + }); + + it('1433: should render a simple class with a title', () => { + imgSnapshotTest( + `--- +title: simple class diagram +--- +classDiagram +class Class10 +` + ); + }); + + it('should render a class with text label', () => { + imgSnapshotTest( + `classDiagram + class C1["Class 1 with text label"] + C1 --> C2` + ); + }); + + it('should render two classes with text labels', () => { + imgSnapshotTest( + `classDiagram + class C1["Class 1 with text label"] + class C2["Class 2 with chars @?"] + C1 --> C2` + ); + }); + it('should render a class with a text label, members and annotation', () => { + imgSnapshotTest( + `classDiagram + class C1["Class 1 with text label"] { + <<interface>> + +member1 + } + C1 --> C2` + ); + }); + it('should render multiple classes with same text labels', () => { + imgSnapshotTest( + `classDiagram +class C1["Class with text label"] +class C2["Class with text label"] +class C3["Class with text label"] +C1 --> C2 +C3 ..> C2 + ` + ); + }); + it('should render classes with different text labels', () => { + imgSnapshotTest( + `classDiagram +class C1["OneWord"] +class C2["With, Comma"] +class C3["With (Brackets)"] +class C4["With [Brackets]"] +class C5["With {Brackets}"] +class C7["With 1 number"] +class C8["With . period..."] +class C9["With - dash"] +class C10["With _ underscore"] +class C11["With ' single quote"] +class C12["With ~!@#$%^&*()_+=-/?"] +class C13["With Città foreign language"] + ` + ); + }); + + it('should render classLabel if class has already been defined earlier', () => { + imgSnapshotTest( + `classDiagram + Animal <|-- Duck + class Duck["Duck with text label"] +` + ); + }); + it('should add classes namespaces', function () { + imgSnapshotTest( + ` + classDiagram + namespace Namespace1 { + class C1 + class C2 + } + C1 --> C2 + class C3 + class C4 + ` + ); + }); + it('should render a simple class diagram with no members', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + `, + { logLevel: 1, htmlLabels: true } + ); + }); + it('should render a simple class diagram with no members if hideEmptyMembersBox is enabled', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + `, + { logLevel: 1, class: { htmlLabels: true, hideEmptyMembersBox: true } } + ); + }); + it('should render a simple class diagram with no attributes, only methods', () => { + imgSnapshotTest( + ` + classDiagram + class Duck { + +swim() + +quack() + } + ` + ); + }); + it('should render a simple class diagram with no methods, only attributes', () => { + imgSnapshotTest( + ` + classDiagram + class Duck { + +String beakColor + +int age + +float weight + } + ` + ); + }); + it('should render a simple class diagram with style definition', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + style Class10 fill:#f9f,stroke:#333,stroke-width:4px + `, + { logLevel: 1, htmlLabels: true } + ); + }); + it('should render a simple class diagram with style definition without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + style Class10 fill:#f9f,stroke:#333,stroke-width:4px + `, + { logLevel: 1, htmlLabels: false } + ); + }); + it('should render a simple class diagram with classDef definitions', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + classDef pink fill:#f9f + classDef bold stroke:#333,stroke-width:6px,color:#fff + `, + { logLevel: 1, htmlLabels: true } + ); + }); + it('should render a simple class diagram with classDefs being applied', () => { + imgSnapshotTest( + ` + classDiagram + class Class10:::pink + cssClass "Class10" bold + classDef pink fill:#f9f + classDef bold stroke:#333,stroke-width:6px,color:#fff + `, + { logLevel: 1, htmlLabels: true } + ); + }); + it('should render a simple class diagram with classDefs being applied without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class10:::pink + cssClass "Class10" bold + classDef pink fill:#f9f + classDef bold stroke:#333,stroke-width:6px,color:#fff + `, + { logLevel: 1, htmlLabels: false } + ); + }); + it('should render a simple class diagram with markdown styling', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 { + +attribute *italic** + ~attribute **bold*** + _italicmethod_() + __boldmethod__() + _+_swim_()a_ + __+quack() test__ + } + `, + { logLevel: 1, htmlLabels: true } + ); + }); + it('should render a simple class diagram with markdown styling without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 { + +attribute *italic** + ~attribute **bold*** + _italicmethod_() + __boldmethod__() + _+_swim_()a_ + __+quack() test__ + } + `, + { logLevel: 1, htmlLabels: false } + ); + }); + it('should render a simple class diagram with the handDrawn look', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('should render a simple class diagram with styles and the handDrawn look', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + style Class10 fill:#f9f,stroke:#333,stroke-width:4px,color:white + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('should render a simple class diagram with styles and the handDrawn look without htmlLabels', () => { + imgSnapshotTest( + ` + classDiagram + class Class10 + style Class10 fill:#f9f,stroke:#333,stroke-width:4px,color:white + `, + { logLevel: 1, htmlLabels: false, look: 'handDrawn' } + ); + }); + it('should render a full class diagram with the handDrawn look', () => { + imgSnapshotTest( + ` + classDiagram + note "I love this diagram!\nDo you love it?" + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class12 <|.. Class08 + Class11 ..>Class12 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + note for Class10 "Cool class\nI said it's very cool class!" + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('should render a simple class diagram with a custom theme', () => { + imgSnapshotTest( + ` + %%{ + init: { + 'theme': 'base', + 'themeVariables': { + 'primaryColor': '#BB2528', + 'primaryTextColor': '#fff', + 'primaryBorderColor': '#7C0000', + 'lineColor': '#F83d29', + 'secondaryColor': '#006100', + 'tertiaryColor': '#fff' + } + } + }%% + classDiagram + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 *-- Class04 + Class05 o-- Class06 + Class07 .. Class08 + Class09 --> C2 : Where am i? + Class09 --* C3 + Class09 --|> Class07 + Class12 <|.. Class08 + Class11 ..>Class12 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true } + ); + }); + it('should render a simple class diagram with a custom theme and the handDrawn look', () => { + imgSnapshotTest( + ` + %%{ + init: { + 'theme': 'base', + 'themeVariables': { + 'primaryColor': '#BB2528', + 'primaryTextColor': '#fff', + 'primaryBorderColor': '#7C0000', + 'lineColor': '#F83d29', + 'secondaryColor': '#006100', + 'tertiaryColor': '#fff' + } + } + }%% + classDiagram + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 *-- Class04 + Class05 o-- Class06 + Class07 .. Class08 + Class09 --> C2 : Where am i? + Class09 --* C3 + Class09 --|> Class07 + Class12 <|.. Class08 + Class11 ..>Class12 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + { logLevel: 1, htmlLabels: true, look: 'handDrawn' } + ); + }); + it('should render a full class diagram using elk', () => { + imgSnapshotTest( + ` +--- + config: + layout: elk +--- + classDiagram + note "I love this diagram!\nDo you love it?" + Class01 <|-- AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class12 <|.. Class08 + Class11 ..>Class12 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class01 : -int privateChimp + Class01 : +int publicGorilla + Class01 : #int protectedMarmoset + Class08 <--> C2: Cool label + class Class10 { + <<service>> + int id + test() + } + note for Class10 "Cool class\nI said it's very cool class!" + `, + { logLevel: 1, htmlLabels: true } + ); + }); +}); diff --git a/cypress/integration/rendering/kanban.spec.ts b/cypress/integration/rendering/kanban.spec.ts new file mode 100644 index 0000000000..6293776d6f --- /dev/null +++ b/cypress/integration/rendering/kanban.spec.ts @@ -0,0 +1,136 @@ +import { imgSnapshotTest } from '../../helpers/util.ts'; + +describe('Kanban diagram', () => { + it('1: should render a kanban with a single section', () => { + imgSnapshotTest( + `kanban + id1[Todo] + docs[Create Documentation] + docs[Create Blog about the new diagram] + `, + {} + ); + }); + it('2: should render a kanban with multiple sections', () => { + imgSnapshotTest( + `kanban + id1[Todo] + docs[Create Documentation] + id2 + docs[Create Blog about the new diagram] + `, + {} + ); + }); + it('3: should render a kanban with a single wrapping node', () => { + imgSnapshotTest( + `kanban + id1[Todo] + id2[Title of diagram is more than 100 chars when user duplicates diagram with 100 char, wrapping] + `, + {} + ); + }); + it('4: should handle the height of a section with a wrapping node at the end', () => { + imgSnapshotTest( + `kanban + id1[Todo] + id2[One line] + id3[Title of diagram is more than 100 chars when user duplicates diagram with 100 char, wrapping] + `, + {} + ); + }); + it('5: should handle the height of a section with a wrapping node at the top', () => { + imgSnapshotTest( + `kanban + id1[Todo] + id2[Title of diagram is more than 100 chars when user duplicates diagram with 100 char, wrapping] + id3[One line] + `, + {} + ); + }); + it('6: should handle the height of a section with a wrapping node in the middle', () => { + imgSnapshotTest( + `kanban + id1[Todo] + id2[One line] + id3[Title of diagram is more than 100 chars when user duplicates diagram with 100 char, wrapping] + id4[One line] + `, + {} + ); + }); + it('6: should handle assigments', () => { + imgSnapshotTest( + `kanban + id1[Todo] + docs[Create Documentation] + id2[In progress] + docs[Create Blog about the new diagram]@{ assigned: 'knsv' } + `, + {} + ); + }); + it('7: should handle prioritization', () => { + imgSnapshotTest( + `kanban + id2[In progress] + vh[Very High]@{ priority: 'Very High' } + h[High]@{ priority: 'High' } + m[Default priority] + l[Low]@{ priority: 'Low' } + vl[Very Low]@{ priority: 'Very Low' } + `, + {} + ); + }); + it('7: should handle external tickets', () => { + imgSnapshotTest( + `kanban + id1[Todo] + docs[Create Documentation] + id2[In progress] + docs[Create Blog about the new diagram]@{ ticket: MC-2037 } + `, + {} + ); + }); + it('8: should handle assignments, prioritization and tickets ids in the same item', () => { + imgSnapshotTest( + `kanban + id2[In progress] + docs[Create Blog about the new diagram]@{ priority: 'Very Low', ticket: MC-2037, assigned: 'knsv' } + `, + {} + ); + }); + it('10: Full example', () => { + imgSnapshotTest( + `--- +config: + kanban: + ticketBaseUrl: 'https://abc123.atlassian.net/browse/#TICKET#' +--- +kanban + id1[Todo] + docs[Create Documentation] + docs[Create Blog about the new diagram] + id7[In progress] + id6[Create renderer so that it works in all cases. We also add som extra text here for testing purposes. And some more just for the extra flare.] + id8[Design grammar]@{ assigned: 'knsv' } + id9[Ready for deploy] + id10[Ready for test] + id11[Done] + id5[define getData] + id2[Title of diagram is more than 100 chars when user duplicates diagram with 100 char]@{ ticket: MC-2036, priority: 'Very High'} + id3[Update DB function]@{ ticket: MC-2037, assigned: knsv, priority: 'High' } + id4[Create parsing tests]@{ ticket: MC-2038, assigned: 'K.Sveidqvist', priority: 'High' } + id66[last item]@{ priority: 'Very Low', assigned: 'knsv' } + id12[Can't reproduce] + `, + {} + ); + }); +}); diff --git a/cypress/platform/knsv2.html b/cypress/platform/knsv2.html index d93881018e..1de0712838 100644 --- a/cypress/platform/knsv2.html +++ b/cypress/platform/knsv2.html @@ -34,6 +34,7 @@ /* background: rgb(221, 208, 208); */ /* background: #333; */ font-family: 'Arial'; + /* color: white; */ /* font-size: 18px !important; */ } @@ -83,349 +84,36 @@ -
-
----
-  title: hello2
-  config:
-    look: handDrawn
-    layout: elk
-    elk:
-        
-        
----
-stateDiagram-v2
-    direction LR
-    accTitle: An idealized Open Source supply-chain graph
-
-    %%
-    state "🟦 Importer" as author_importer
-    state "🟥 Supplier, Owner" as author_owner
-    state "🟨🟥 Maintainer, Author\n🟨 Custodian" as author
-    state "🟩 Distributor" as repository_distributor
-    state "🟦 Importer" as language_importer
-    state "🟦🟨 Packager" as language_packager
-    state "🟦🟨 OSS Steward" as language_steward
-    state "🟨 Curator" as language_curator
-    state "🟩 Distributor" as language_distributor
-    state "🟦 Contributor" as contributor
-    state "🟦 Importer" as package_importer
-    state "🟨 Patcher" as package_patcher
-    state "🟨🟦 Builder\n🟨🟦 Packager\n🟨🟦 Containerizer" as package_packager
-    state "🟨 Curator" as package_curator
-    state "🟩 Distributor" as package_distributor
-    state "🟦 Importer" as integrator_importer
-    state "🟥 Supplier, Manufacturer, Owner" as integrator_owner
-    state "🟦🟨🟥 Integrator, Developer" as integrator_developer
-    state "🟩🟨 SBOM Redactor\n🟩 Publisher" as integrator_publisher
-    state "🟦🟨 Builder" as integrator_builder
-    state "🟨 Deployer" as deployer
-    state "🟦 Vuln. Checker" as integrator_checker
-    state "🟩🟨 SBOM Redactor" as redactor
-    state "🟦 Consumer\n🟦  User" as consumer
-    state "🟦 Auditor" as auditor_internal
-    state "🟦 Auditor" as auditor_external
-
-    %%
-    classDef createsSBOM stroke:red,stroke-width:3px;
-    classDef updatesSBOM stroke:yellow,stroke-width:3px;
-    classDef assemblesSBOM stroke:yellow,stroke-width:3px;
-    classDef distributesSBOM stroke:green,stroke-width:3px;
-    classDef verifiesSBOM stroke:#07f,stroke-width:3px;
-
-    %%
-    class author_importer verifiesSBOM
-    class author_owner createsSBOM
-    class manufacturer_owner createsSBOM
-    class author assemblesSBOM
-    class package_importer verifiesSBOM
-    class package_patcher updatesSBOM
-    class package_packager assemblesSBOM
-    class package_curator distributesSBOM
-    class package_distributor distributesSBOM
-    class language_importer verifiesSBOM
-    class language_packager assemblesSBOM
-    class language_steward updatesSBOM
-    class language_curator distributesSBOM
-    class language_distributor distributesSBOM
-    class repository_distributor distributesSBOM
-    class integrator_importer verifiesSBOM
-    class integrator_owner createsSBOM
-    class integrator_developer assemblesSBOM
-    class integrator_publisher distributesSBOM
-    class integrator_builder assemblesSBOM
-    class integrator_checker verifiesSBOM
-    class deployer assemblesSBOM
-    class redactor distributesSBOM
-    class auditor_internal verifiesSBOM
-    class auditor_external verifiesSBOM
-
-    state "Maintainer Environment" as environment_maintainer {
-        [*] --> author_importer
-        [*] --> author
-        author_importer --> author
-        author_owner --> author
-        author       --> language_packager
-    }
-
-    [*] --> environment_maintainer
-
-    state "Language Ecosystem" as ecosystem_lang {
-        [*] --> language_importer
-        [*] --> language_steward
-        [*] --> language_curator
-        [*] --> language_distributor
-        language_importer --> language_distributor
-        language_importer --> language_curator
-        language_steward --> language_curator
-        language_curator --> language_distributor
-    }
-
-    language_packager --> ecosystem_lang
-    ecosystem_lang    --> ecosystem_lang
-
-    state "Public Collaboration Ecosystem" as ecosystem_repo {
-        [*] --> repository_distributor
-    }
-
-    author         --> ecosystem_repo
-    ecosystem_repo --> author
-
-    repository_distributor --> contributor
-    contributor            --> repository_distributor
-
-    state "Package Ecosystem" as ecosystem_package {
-        [*] --> package_importer
-        [*] --> package_packager
-        [*] --> package_patcher
-        package_importer --> package_patcher
-        package_importer --> package_packager
-        package_patcher  --> package_packager
-        package_packager --> package_curator
-        package_packager --> package_distributor
-        package_curator  --> package_distributor
-    }
-
-    repository_distributor --> ecosystem_package
-    language_distributor   --> ecosystem_package
-    ecosystem_package      --> ecosystem_package
-
-    state "Integrator Environment" as environment_integrator {
-        [*] --> integrator_developer
-        [*] --> integrator_importer
-        integrator_importer  --> integrator_developer
-        integrator_owner     --> integrator_developer
-        integrator_builder   --> integrator_publisher
-        integrator_developer --> integrator_checker
-        integrator_checker   --> integrator_developer
-        auditor_internal     --> integrator_developer
-        integrator_developer --> integrator_builder
-        integrator_developer --> auditor_internal
-    }
-
-    repository_distributor --> environment_integrator
-    language_distributor   --> environment_integrator
-    package_distributor    --> environment_integrator
-
-    state "Production Environment" as environment_prod {
-        [*] --> deployer
-        deployer --> redactor
-    }
-
-    integrator_publisher --> [*]
-    integrator_developer --> environment_prod
-    integrator_builder   --> environment_prod
-    integrator_publisher --> environment_prod
-
-    deployer --> auditor_external
-    deployer --> consumer
-    redactor --> consumer
-
-
-
-
- -
----
-config:
-  look: neo
----
-flowchart RL
-    subgraph "   "
-        A5@{ shape: manual-file, label: "a label"}
-        B5@{ shape: manual-input, label: "a label" }
-        C5@{ shape: mul-doc, label: "a label" }
-        D5@{ shape: mul-proc, label: "a label" }
-        E5@{ shape: paper-tape, label: "a label" }
-        B3@{ shape: das, label: "a label" }
-        C3@{ shape: disk, label: "a label" }
-        D4@{ shape: lin-doc, label: "a label" }
-        E4@{ shape: loop-limit, label: "a label" }
-    end
-    subgraph "   "
-        B6@{ shape: summary, label: "a label" }
-        C6@{ shape: tag-we-rect, label: "a label" }
-        D6@{ shape: tag-rect, label: "a label" }
-        A2@{ shape: fork}
-        B2@{ shape: hourglass }
-        C2@{ shape: comment, label: "I am a comment" }
-        D2@{ shape: bolt }
-        D3@{ shape: disp, label: "a label" }
-        C4@{ shape: junction, label: "a label" }
-        A4@{ shape: extract, label: "a label"}
-        B52[a fr]@{ shape: fr }
-    end
-    subgraph " "
-        A1@{ shape: text, label: This is a textblock}
-        B1@{ shape: card, label: "a label" }
-        C1@{ shape: lined-proc, label: "a label" }
-        D1@{ shape: start, label: "a label" }
-        E1@{ shape: stop, label: "a label" }
-        E2@{ shape: doc, label: "a label" }
-        A6@{ shape: stored-data, label: "a label"}
-        A3@{ shape: delay, label: "a label" }
-        E3@{ shape: div-proc, label: "a label" }
-        B4[a label]@{ shape: win-pane }
-    end
-      
-
----
-  title: hello2
-  config:
-    look: handDrawn
-    elk:
-      
----
-%%{init: {"flowchart": {"defaultRenderer": "elk"}} }%%
-flowchart TD
-
-    A([Start]) -->|go to booking page| B("select
-    ISBS booking no")
-    A --> QQ{cancel booking}
-    A --> RR{no show}
-    A --> SS{change booking}
-    B -->C(wmpay_request_payment.request_type= 'partial',
- wmpay_request_payment.status= 'paid',
- pos_booking.booking_status= ‘partial’ and 'full_deposit')
- style C text-align:left
-    C -->D{manage booking}
-
-    D -->|cancel|E[ระบบแสดงช่องให้กรอกเหตุผล]
-    E -->F{กดปุ่ม 'cancel' หรือไม่}
-    F -->|Yes|G[ระบบบันทึกค่าใหม่
-    และไม่สามารถแก้ไขข้อมูลได้]
-    F -->|No|H[กดปุ่ม 'close']
-    H -->|ระบบไม่เปลี่ยนแปลงข้อมูล|Z
-    G -->|ระบบส่งข้อมูล|I[(POS_database)]
-    I -->|pos_booking.booking_status='cancel'|Z([End])
-
-
-    D -->|no show|J[ระบบแสดงช่องให้กรอกเหตุผล]
-    J -->K{กดปุ่ม 'noshow' หรือไม่}
-    K -->|Yes|L[ระบบสร้างใบเสร็จอัตโนมัติ
-    Product_id: 439,
-    ItemName: no show]
-     style L text-align:left
-
-     K -->|No|O[กดปุ่ม 'close']
-     O -->|ระบบไม่เปลี่ยนแปลงข้อมูล|Z
-    L -->M[ระบบบันทึกค่าใหม่]
-    M -->|ระบบส่งข้อมูล|N[(POS_database)]
-    N -->|pos_booking.booking_status=‘noshow’|Z
-
-
-
-
-
----
-  title: hello2
-  config:
-    look: handDrawn
-    layout: dagre
-    elk:
-        nodePlacementStrategy: BRANDES_KOEPF
----
-flowchart
-  A --> A
-  subgraph A
-    B --> B
-    subgraph B
-      C
-    end
-  end
-
-
-
-
----
-config:
-  look: handdrawn
-  flowchart:
-    htmlLabels: true
----
-flowchart
-      A[I am a long text, where do I go??? handdrawn - true]
-
-
-
-
----
-config:
-  flowchart:
-    htmlLabels: false
----
-flowchart
-      A[I am a long text, where do I go??? classic - false]
-
-
----
-config:
-  flowchart:
-    htmlLabels: true
----
-flowchart
-      A[I am a long text, where do I go??? classic - true]
-
-
-
-flowchart LR
-    id1(Start)-->id2(Stop)
-    style id1 fill:#f9f,stroke:#333,stroke-width:4px
-    style id2 fill:#bbf,stroke:#f66,stroke-width:2px,color:#fff,stroke-dasharray: 5 5
-
-
-    
- -
-      flowchart LR
-    A:::foo & B:::bar --> C:::foobar
-    classDef foo stroke:#f00
-    classDef bar stroke:#0f0
-    classDef ash color:red
-    class C ash
-    style C stroke:#00f, fill:black
-
-    
-
-      stateDiagram
-    A:::foo
-    B:::bar --> C:::foobar
-    classDef foo stroke:#f00
-    classDef bar stroke:#0f0
-    style C stroke:#00f, fill:black, color:white
-
+kanban
+  id2[In progress]
+    docs[Create Blog about the new diagram]@{ priority: 'Very Low', ticket: MC-2037, assigned: 'knsv' }
     
-
-flowchart TB
-  A@{
-    label: "aksljhf kasjdh"
-  }
+---
+config:
+  kanban:
+    ticketBaseUrl: 'https://mermaidchart.atlassian.net/browse/#TICKET#'
+    # sectionWidth: 300
+---
+kanban
+  Todo
+    [Create Documentation]
+    docs[Create Blog about the new diagram]
+  id7[In progress]
+    id6[Create renderer so that it works in all cases. We also add som extra text here for testing purposes. And some more just for the extra flare.]
+  id9[Ready for deploy]
+    id8[Design grammar]@{ assigned: 'knsv' }
+  id10[Ready for test]
+    id4[Create parsing tests]@{ ticket: MC-2038, assigned: 'K.Sveidqvist', priority: 'High' }
+    id66[last item]@{ priority: 'Very Low', assigned: 'knsv' }
+  id11[Done]
+    id5[define getData]
+    id2[Title of diagram is more than 100 chars when user duplicates diagram with 100 char]@{ ticket: MC-2036, priority: 'Very High'}
+    id3[Update DB function]@{ ticket: MC-2037, assigned: knsv, priority: 'High' }
+
+  id12[Can't reproduce]
+    id3[Weird flickering in Firefox]
     
+ + + diff --git a/docs/config/setup/interfaces/mermaid.LayoutData.md b/docs/config/setup/interfaces/mermaid.LayoutData.md index c5f3c3cab9..1570b2b2a9 100644 --- a/docs/config/setup/interfaces/mermaid.LayoutData.md +++ b/docs/config/setup/interfaces/mermaid.LayoutData.md @@ -20,7 +20,7 @@ #### Defined in -[packages/mermaid/src/rendering-util/types.ts:126](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L126) +[packages/mermaid/src/rendering-util/types.ts:128](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L128) --- @@ -30,7 +30,7 @@ #### Defined in -[packages/mermaid/src/rendering-util/types.ts:125](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L125) +[packages/mermaid/src/rendering-util/types.ts:127](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L127) --- @@ -40,4 +40,4 @@ #### Defined in -[packages/mermaid/src/rendering-util/types.ts:124](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L124) +[packages/mermaid/src/rendering-util/types.ts:126](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L126) diff --git a/docs/config/setup/interfaces/mermaid.MermaidConfig.md b/docs/config/setup/interfaces/mermaid.MermaidConfig.md index ad078653a6..14c348145d 100644 --- a/docs/config/setup/interfaces/mermaid.MermaidConfig.md +++ b/docs/config/setup/interfaces/mermaid.MermaidConfig.md @@ -49,7 +49,7 @@ This matters if you are using base tag settings. #### Defined in -[packages/mermaid/src/config.type.ts:200](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L200) +[packages/mermaid/src/config.type.ts:201](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L201) --- @@ -59,7 +59,7 @@ This matters if you are using base tag settings. #### Defined in -[packages/mermaid/src/config.type.ts:197](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L197) +[packages/mermaid/src/config.type.ts:198](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L198) --- @@ -121,7 +121,7 @@ should not change unless content is changed. #### Defined in -[packages/mermaid/src/config.type.ts:201](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L201) +[packages/mermaid/src/config.type.ts:202](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L202) --- @@ -183,7 +183,7 @@ See #### Defined in -[packages/mermaid/src/config.type.ts:203](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L203) +[packages/mermaid/src/config.type.ts:204](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L204) --- @@ -217,7 +217,7 @@ If set to true, ignores legacyMathML. #### Defined in -[packages/mermaid/src/config.type.ts:196](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L196) +[packages/mermaid/src/config.type.ts:197](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L197) --- @@ -253,6 +253,16 @@ Defines the seed to be used when using handDrawn look. This is important for the --- +### kanban + +• `Optional` **kanban**: `KanbanDiagramConfig` + +#### Defined in + +[packages/mermaid/src/config.type.ts:196](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L196) + +--- + ### layout • `Optional` **layout**: `string` @@ -310,7 +320,7 @@ Defines which main look to use for the diagram. #### Defined in -[packages/mermaid/src/config.type.ts:204](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L204) +[packages/mermaid/src/config.type.ts:205](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L205) --- @@ -354,7 +364,7 @@ The maximum allowed size of the users text diagram #### Defined in -[packages/mermaid/src/config.type.ts:199](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L199) +[packages/mermaid/src/config.type.ts:200](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L200) --- @@ -394,7 +404,7 @@ The maximum allowed size of the users text diagram #### Defined in -[packages/mermaid/src/config.type.ts:198](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L198) +[packages/mermaid/src/config.type.ts:199](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L199) --- @@ -465,7 +475,7 @@ This is useful when you want to control how to handle syntax errors in your appl #### Defined in -[packages/mermaid/src/config.type.ts:210](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L210) +[packages/mermaid/src/config.type.ts:211](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L211) --- @@ -518,7 +528,7 @@ You may also use `themeCSS` to override this value. #### Defined in -[packages/mermaid/src/config.type.ts:202](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L202) +[packages/mermaid/src/config.type.ts:203](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L203) --- diff --git a/docs/config/setup/interfaces/mermaid.ParseOptions.md b/docs/config/setup/interfaces/mermaid.ParseOptions.md index 52b4af49ee..717e355657 100644 --- a/docs/config/setup/interfaces/mermaid.ParseOptions.md +++ b/docs/config/setup/interfaces/mermaid.ParseOptions.md @@ -19,4 +19,4 @@ The `parseError` function will not be called. #### Defined in -[packages/mermaid/src/types.ts:56](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L56) +[packages/mermaid/src/types.ts:59](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L59) diff --git a/docs/config/setup/interfaces/mermaid.ParseResult.md b/docs/config/setup/interfaces/mermaid.ParseResult.md index ef371d094c..9f90b6dd4d 100644 --- a/docs/config/setup/interfaces/mermaid.ParseResult.md +++ b/docs/config/setup/interfaces/mermaid.ParseResult.md @@ -18,7 +18,7 @@ The config passed as YAML frontmatter or directives #### Defined in -[packages/mermaid/src/types.ts:67](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L67) +[packages/mermaid/src/types.ts:70](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L70) --- @@ -30,4 +30,4 @@ The diagram type, e.g. 'flowchart', 'sequence', etc. #### Defined in -[packages/mermaid/src/types.ts:63](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L63) +[packages/mermaid/src/types.ts:66](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L66) diff --git a/docs/config/setup/modules/defaultConfig.md b/docs/config/setup/modules/defaultConfig.md index 68486467c2..b4cf55dd1d 100644 --- a/docs/config/setup/modules/defaultConfig.md +++ b/docs/config/setup/modules/defaultConfig.md @@ -14,7 +14,7 @@ #### Defined in -[packages/mermaid/src/defaultConfig.ts:267](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L267) +[packages/mermaid/src/defaultConfig.ts:270](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L270) --- diff --git a/docs/syntax/classDiagram.md b/docs/syntax/classDiagram.md index ed15922f13..746d0eba63 100644 --- a/docs/syntax/classDiagram.md +++ b/docs/syntax/classDiagram.md @@ -427,6 +427,51 @@ And `Link` can be one of: | -- | Solid | | .. | Dashed | +### Lollipop Interfaces + +Classes can also be given a special relation type that defines a lollipop interface on the class. A lollipop interface is defined using the following syntax: + +- `bar ()-- foo` +- `foo --() bar` + +The interface (bar) with the lollipop connects to the class (foo). + +Note: Each interface that is defined is unique and is meant to not be shared between classes / have multiple edges connecting to it. + +```mermaid-example +classDiagram + bar ()-- foo +``` + +```mermaid +classDiagram + bar ()-- foo +``` + +```mermaid-example +classDiagram + class Class01 { + int amount + draw() + } + Class01 --() bar + Class02 --() bar + + foo ()-- Class01 +``` + +```mermaid +classDiagram + class Class01 { + int amount + draw() + } + Class01 --() bar + Class02 --() bar + + foo ()-- Class01 +``` + ## Define Namespace A namespace groups classes. @@ -776,10 +821,12 @@ Beginner's tip—a full example using interactive links in an HTML page: ## Styling -### Styling a node (v10.7.0+) +### Styling a node It is possible to apply specific styles such as a thicker border or a different background color to an individual node using the `style` keyword. +Note that notes and namespaces cannot be styled individually but do support themes. + ```mermaid-example classDiagram class Animal @@ -799,172 +846,147 @@ classDiagram #### Classes More convenient than defining the style every time is to define a class of styles and attach this class to the nodes that -should have a different look. This is done by predefining classes in css styles that can be applied from the graph definition using the `cssClass` statement or the `:::` short hand. +should have a different look. -```html - +A class definition looks like the example below: + +``` +classDef className fill:#f9f,stroke:#333,stroke-width:4px; +``` + +Also, it is possible to define style to multiple classes in one statement: + +``` +classDef firstClassName,secondClassName font-size:12pt; ``` -Then attaching that class to a specific node: +Attachment of a class to a node is done as per below: ``` - cssClass "nodeId1" styleClass; +cssClass "nodeId1" className; ``` It is also possible to attach a class to a list of nodes in one statement: ``` - cssClass "nodeId1,nodeId2" styleClass; +cssClass "nodeId1,nodeId2" className; ``` A shorter form of adding a class is to attach the classname to the node using the `:::` operator: ```mermaid-example classDiagram - class Animal:::styleClass + class Animal:::someclass + classDef someclass fill:#f96 ``` ```mermaid classDiagram - class Animal:::styleClass + class Animal:::someclass + classDef someclass fill:#f96 ``` Or: ```mermaid-example classDiagram - class Animal:::styleClass { + class Animal:::someclass { -int sizeInFeet -canEat() } + classDef someclass fill:#f96 ``` ```mermaid classDiagram - class Animal:::styleClass { + class Animal:::someclass { -int sizeInFeet -canEat() } + classDef someclass fill:#f96 ``` -?> cssClasses cannot be added using this shorthand method at the same time as a relation statement. - -?> Due to limitations with existing markup for class diagrams, it is not currently possible to define css classes within the diagram itself. **_Coming soon!_** - -### Default Styles - -The main styling of the class diagram is done with a preset number of css classes. During rendering these classes are extracted from the file located at src/themes/class.scss. The classes used here are described below: +### Default class -| Class | Description | -| ------------------ | ----------------------------------------------------------------- | -| g.classGroup text | Styles for general class text | -| classGroup .title | Styles for general class title | -| g.classGroup rect | Styles for class diagram rectangle | -| g.classGroup line | Styles for class diagram line | -| .classLabel .box | Styles for class label box | -| .classLabel .label | Styles for class label text | -| composition | Styles for composition arrow head and arrow line | -| aggregation | Styles for aggregation arrow head and arrow line(dashed or solid) | -| dependency | Styles for dependency arrow head and arrow line | +If a class is named default it will be applied to all nodes. Specific styles and classes should be defined afterwards to override the applied default styling. -#### Sample stylesheet - -```scss -body { - background: white; -} +``` +classDef default fill:#f9f,stroke:#333,stroke-width:4px; +``` -g.classGroup text { - fill: $nodeBorder; - stroke: none; - font-family: 'trebuchet ms', verdana, arial; - font-family: var(--mermaid-font-family); - font-size: 10px; +```mermaid-example +classDiagram + class Animal:::pink + class Mineral - .title { - font-weight: bolder; - } -} + classDef default fill:#f96,color:red + classDef pink color:#f9f +``` -g.classGroup rect { - fill: $nodeBkg; - stroke: $nodeBorder; -} +```mermaid +classDiagram + class Animal:::pink + class Mineral -g.classGroup line { - stroke: $nodeBorder; - stroke-width: 1; -} + classDef default fill:#f96,color:red + classDef pink color:#f9f +``` -.classLabel .box { - stroke: none; - stroke-width: 0; - fill: $nodeBkg; - opacity: 0.5; -} +### CSS Classes -.classLabel .label { - fill: $nodeBorder; - font-size: 10px; -} +It is also possible to predefine classes in CSS styles that can be applied from the graph definition as in the example +below: -.relation { - stroke: $nodeBorder; - stroke-width: 1; - fill: none; -} +**Example style** -@mixin composition { - fill: $nodeBorder; - stroke: $nodeBorder; - stroke-width: 1; -} +```html + +``` -#compositionStart { - @include composition; -} +**Example definition** -#compositionEnd { - @include composition; -} +```mermaid-example +classDiagram + class Animal:::styleClass +``` -@mixin aggregation { - fill: $nodeBkg; - stroke: $nodeBorder; - stroke-width: 1; -} +```mermaid +classDiagram + class Animal:::styleClass +``` -#aggregationStart { - @include aggregation; -} +> cssClasses cannot be added using this shorthand method at the same time as a relation statement. -#aggregationEnd { - @include aggregation; -} +## Configuration -#dependencyStart { - @include composition; -} +### Members Box -#dependencyEnd { - @include composition; -} +It is possible to hide the empty members box of a class node. -#extensionStart { - @include composition; -} +This is done by changing the **hideEmptyMembersBox** value of the class diagram configuration. For more information on how to edit the Mermaid configuration see the [configuration page.](https://mermaid.js.org/config/configuration.html) -#extensionEnd { - @include composition; -} +```mermaid-example +--- + config: + class: + hideEmptyMembersBox: true +--- +classDiagram + class Duck ``` -## Configuration - -`Coming soon!` +```mermaid +--- + config: + class: + hideEmptyMembersBox: true +--- +classDiagram + class Duck +``` diff --git a/docs/syntax/flowchart.md b/docs/syntax/flowchart.md index 3837e77de6..97b9110d1d 100644 --- a/docs/syntax/flowchart.md +++ b/docs/syntax/flowchart.md @@ -319,6 +319,7 @@ Below is a comprehensive list of the newly introduced shapes and their correspon | **Semantic Name** | **Shape Name** | **Short Name** | **Description** | **Alias Supported** | | --------------------------------- | ---------------------- | -------------- | ------------------------------ | ---------------------------------------------------------------- | | Card | Notched Rectangle | `notch-rect` | Represents a card | `card`, `notched-rectangle` | +| Class Box | Class Box | `classBox` | Class Box | `class-box` | | 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/kanban.md b/docs/syntax/kanban.md new file mode 100644 index 0000000000..e29057266f --- /dev/null +++ b/docs/syntax/kanban.md @@ -0,0 +1,161 @@ +> **Warning** +> +> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT. +> +> ## Please edit the corresponding file in [/packages/mermaid/src/docs/syntax/kanban.md](../../packages/mermaid/src/docs/syntax/kanban.md). + +# Mermaid Kanban Diagram Documentation + +Mermaid’s Kanban diagram allows you to create visual representations of tasks moving through different stages of a workflow. This guide explains how to use the Kanban diagram syntax, based on the provided example. + +## Overview + +A Kanban diagram in Mermaid starts with the kanban keyword, followed by the definition of columns (stages) and tasks within those columns. + +```mermaid-example +kanban + column1[Column Title] + task1[Task Description] +``` + +```mermaid +kanban + column1[Column Title] + task1[Task Description] +``` + +## Defining Columns + +Columns represent the different stages in your workflow, such as “Todo,” “In Progress,” “Done,” etc. Each column is defined using a unique identifier and a title enclosed in square brackets. + +**Syntax:** + +``` +columnId[Column Title] +``` + +- columnId: A unique identifier for the column. +- \[Column Title]: The title displayed on the column header. + +Like this `id1[Todo]` + +## Adding Tasks to Columns + +Tasks are listed under their respective columns with an indentation. Each task also has a unique identifier and a description enclosed in square brackets. + +**Syntax:** + +``` +taskId[Task Description] +``` + +``` +• taskId: A unique identifier for the task. +• [Task Description]: The description of the task. +``` + +**Example:** + +``` +docs[Create Documentation] +``` + +## Adding Metadata to Tasks + +You can include additional metadata for each task using the @{ ... } syntax. Metadata can contain key-value pairs like assigned, ticket, priority, etc. This will be rendered added to the rendering of the node. + +## Supported Metadata Keys + +``` +• assigned: Specifies who is responsible for the task. +• ticket: Links the task to a ticket or issue number. +• priority: Indicates the urgency of the task. Allowed values: 'Very High', 'High', 'Low' and 'Very Low' +``` + +```mermaid-example +kanban +todo[Todo] + id3[Update Database Function]@{ ticket: MC-2037, assigned: 'knsv', priority: 'High' } +``` + +```mermaid +kanban +todo[Todo] + id3[Update Database Function]@{ ticket: MC-2037, assigned: 'knsv', priority: 'High' } +``` + +## Configuration Options + +You can customize the Kanban diagram using a configuration block at the beginning of your markdown file. This is useful for setting global settings like a base URL for tickets. Currently there is one configuration option for kanban diagrams tacketBaseUrl. This can be set as in the the following example: + +```yaml +--- +config: + kanban: + ticketBaseUrl: 'https://yourproject.atlassian.net/browse/#TICKET#' +--- +``` + +When the kanban item has an assigned ticket number the ticket number in the diagram will have a link to an external system where the ticket is defined. The `ticketBaseUrl` sets the base URL to the external system and #TICKET# is replaced with the ticket value from task metadata to create a valid link. + +## Full Example + +Below is the full Kanban diagram based on the provided example: + +```mermaid-example +--- +config: + kanban: + ticketBaseUrl: 'https://mermaidchart.atlassian.net/browse/#TICKET#' +--- +kanban + Todo + [Create Documentation] + docs[Create Blog about the new diagram] + [In progress] + id6[Create renderer so that it works in all cases. We also add som extra text here for testing purposes. And some more just for the extra flare.] + id9[Ready for deploy] + id8[Design grammar]@{ assigned: 'knsv' } + id10[Ready for test] + id4[Create parsing tests]@{ ticket: MC-2038, assigned: 'K.Sveidqvist', priority: 'High' } + id66[last item]@{ priority: 'Very Low', assigned: 'knsv' } + id11[Done] + id5[define getData] + id2[Title of diagram is more than 100 chars when user duplicates diagram with 100 char]@{ ticket: MC-2036, priority: 'Very High'} + id3[Update DB function]@{ ticket: MC-2037, assigned: knsv, priority: 'High' } + + id12[Can't reproduce] + id3[Weird flickering in Firefox] +``` + +```mermaid +--- +config: + kanban: + ticketBaseUrl: 'https://mermaidchart.atlassian.net/browse/#TICKET#' +--- +kanban + Todo + [Create Documentation] + docs[Create Blog about the new diagram] + [In progress] + id6[Create renderer so that it works in all cases. We also add som extra text here for testing purposes. And some more just for the extra flare.] + id9[Ready for deploy] + id8[Design grammar]@{ assigned: 'knsv' } + id10[Ready for test] + id4[Create parsing tests]@{ ticket: MC-2038, assigned: 'K.Sveidqvist', priority: 'High' } + id66[last item]@{ priority: 'Very Low', assigned: 'knsv' } + id11[Done] + id5[define getData] + id2[Title of diagram is more than 100 chars when user duplicates diagram with 100 char]@{ ticket: MC-2036, priority: 'Very High'} + id3[Update DB function]@{ ticket: MC-2037, assigned: knsv, priority: 'High' } + + id12[Can't reproduce] + id3[Weird flickering in Firefox] +``` + +In conclusion, creating a Kanban diagram in Mermaid is a straightforward process that effectively visualizes your workflow. Start by using the kanban keyword to initiate the diagram. Define your columns with unique identifiers and titles to represent different stages of your project. Under each column, list your tasks—also with unique identifiers—and provide detailed descriptions as needed. Remember that proper indentation is crucial; tasks must be indented under their parent columns to maintain the correct structure. + +You can enhance your diagram by adding optional metadata to tasks using the @{ ... } syntax, which allows you to include additional context such as assignee, ticket numbers, and priority levels. For further customization, utilize the configuration block at the top of your file to set global options like ticketBaseUrl for linking tickets directly from your diagram. + +By adhering to these guidelines—ensuring unique identifiers, proper indentation, and utilizing metadata and configuration options—you can create a comprehensive and customized Kanban board that effectively maps out your project’s workflow using Mermaid. diff --git a/packages/mermaid-layout-elk/src/render.ts b/packages/mermaid-layout-elk/src/render.ts index 49a5863c66..60cdff8d62 100644 --- a/packages/mermaid-layout-elk/src/render.ts +++ b/packages/mermaid-layout-elk/src/render.ts @@ -149,6 +149,7 @@ export const render = async ( const clusterNode = JSON.parse(JSON.stringify(node)); clusterNode.x = node.offset.posX + node.width / 2; clusterNode.y = node.offset.posY + node.height / 2; + clusterNode.width = Math.max(clusterNode.width, node.labelData.width); await insertCluster(subgraphEl, clusterNode); log.debug('Id (UIO)= ', node.id, node.width, node.shape, node.labels); @@ -275,6 +276,8 @@ export const render = async ( interpolate: undefined; style: undefined; labelType: any; + startLabelRight?: string; + endLabelLeft?: string; }) { // Identify Link const linkIdBase = edge.id; // 'L-' + edge.start + '-' + edge.end; @@ -328,6 +331,9 @@ export const render = async ( let style = ''; let labelStyle = ''; + edgeData.startLabelRight = edge.startLabelRight; + edgeData.endLabelLeft = edge.endLabelLeft; + switch (edge.stroke) { case 'normal': style = 'fill:none;'; diff --git a/packages/mermaid/scripts/docs.spec.ts b/packages/mermaid/scripts/docs.spec.ts index 68677d4c9c..4ed61e9ffe 100644 --- a/packages/mermaid/scripts/docs.spec.ts +++ b/packages/mermaid/scripts/docs.spec.ts @@ -172,6 +172,7 @@ This Markdown should be kept. "| **Semantic Name** | **Shape Name** | **Short Name** | **Description** | **Alias Supported** | | --------------------------------- | ---------------------- | -------------- | ------------------------------ | ---------------------------------------------------------------- | | Card | Notched Rectangle | \`notch-rect\` | Represents a card | \`card\`, \`notched-rectangle\` | + | Class Box | Class Box | \`classBox\` | Class Box | \`class-box\` | | 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 035a158e0d..86281cd522 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -193,6 +193,7 @@ export interface MermaidConfig { requirement?: RequirementDiagramConfig; architecture?: ArchitectureDiagramConfig; mindmap?: MindmapDiagramConfig; + kanban?: KanbanDiagramConfig; gitGraph?: GitGraphDiagramConfig; c4?: C4DiagramConfig; sankey?: SankeyDiagramConfig; @@ -716,6 +717,7 @@ export interface ClassDiagramConfig extends BaseDiagramConfig { */ diagramPadding?: number; htmlLabels?: boolean; + hideEmptyMembersBox?: boolean; } /** * The object containing configurations specific for entity relationship diagrams @@ -1023,6 +1025,17 @@ export interface MindmapDiagramConfig extends BaseDiagramConfig { padding?: number; maxNodeWidth?: number; } +/** + * The object containing configurations specific for kanban diagrams + * + * This interface was referenced by `MermaidConfig`'s JSON-Schema + * via the `definition` "KanbanDiagramConfig". + */ +export interface KanbanDiagramConfig extends BaseDiagramConfig { + padding?: number; + sectionWidth?: number; + ticketBaseUrl?: string; +} /** * This interface was referenced by `MermaidConfig`'s JSON-Schema * via the `definition` "GitGraphDiagramConfig". diff --git a/packages/mermaid/src/defaultConfig.ts b/packages/mermaid/src/defaultConfig.ts index feae37f52d..a3dab2ddbb 100644 --- a/packages/mermaid/src/defaultConfig.ts +++ b/packages/mermaid/src/defaultConfig.ts @@ -53,6 +53,9 @@ const config: RequiredDeep = { }; }, }, + class: { + hideEmptyMembersBox: false, + }, gantt: { ...defaultConfigJson.gantt, tickInterval: undefined, diff --git a/packages/mermaid/src/diagram-api/diagram-orchestration.ts b/packages/mermaid/src/diagram-api/diagram-orchestration.ts index d68a1c4982..5b8cfc3fe9 100644 --- a/packages/mermaid/src/diagram-api/diagram-orchestration.ts +++ b/packages/mermaid/src/diagram-api/diagram-orchestration.ts @@ -19,6 +19,7 @@ import errorDiagram from '../diagrams/error/errorDiagram.js'; import flowchartElk from '../diagrams/flowchart/elk/detector.js'; import timeline from '../diagrams/timeline/detector.js'; import mindmap from '../diagrams/mindmap/detector.js'; +import kanban from '../diagrams/kanban/detector.js'; import sankey from '../diagrams/sankey/sankeyDetector.js'; import { packet } from '../diagrams/packet/detector.js'; import block from '../diagrams/block/blockDetector.js'; @@ -70,6 +71,7 @@ export const addDiagrams = () => { // Ordering of detectors is important. The first one to return true will be used. registerLazyLoadedDiagrams( c4, + kanban, classDiagramV2, classDiagram, er, diff --git a/packages/mermaid/src/diagrams/class/classDb.ts b/packages/mermaid/src/diagrams/class/classDb.ts index 1fec5c2dc4..5699437367 100644 --- a/packages/mermaid/src/diagrams/class/classDb.ts +++ b/packages/mermaid/src/diagrams/class/classDb.ts @@ -1,9 +1,8 @@ -import type { Selection } from 'd3'; -import { select } from 'd3'; +import { select, type Selection } from 'd3'; import { log } from '../../logger.js'; import { getConfig } from '../../diagram-api/diagramAPI.js'; import common from '../common/common.js'; -import utils from '../../utils.js'; +import utils, { getEdgeId } from '../../utils.js'; import { setAccTitle, getAccTitle, @@ -21,13 +20,18 @@ import type { ClassMap, NamespaceMap, NamespaceNode, + StyleClass, + Interface, } from './classTypes.js'; +import type { Node, Edge } from '../../rendering-util/types.js'; const MERMAID_DOM_ID_PREFIX = 'classId-'; let relations: ClassRelation[] = []; let classes = new Map(); +const styleClasses = new Map(); let notes: ClassNote[] = []; +let interfaces: Interface[] = []; let classCounter = 0; let namespaces = new Map(); let namespaceCounter = 0; @@ -58,6 +62,8 @@ export const setClassLabel = function (_id: string, label: string) { const { className } = splitClassNameAndType(id); classes.get(className)!.label = label; + classes.get(className)!.text = + `${label}${classes.get(className)!.type ? `<${classes.get(className)!.type}>` : ''}`; }; /** @@ -80,7 +86,9 @@ export const addClass = function (_id: string) { id: name, type: type, label: name, - cssClasses: [], + text: `${name}${type ? `<${type}>` : ''}`, + shape: 'classBox', + cssClasses: 'default', methods: [], members: [], annotations: [], @@ -91,6 +99,16 @@ export const addClass = function (_id: string) { classCounter++; }; +const addInterface = function (label: string, classId: string) { + const classInterface: Interface = { + id: `interface${interfaces.length}`, + label, + classId, + }; + + interfaces.push(classInterface); +}; + /** * Function to lookup domId from id in the graph definition. * @@ -109,6 +127,7 @@ export const clear = function () { relations = []; classes = new Map(); notes = []; + interfaces = []; functions = []; functions.push(setupToolTips); namespaces = new Map(); @@ -133,19 +152,50 @@ export const getNotes = function () { return notes; }; -export const addRelation = function (relation: ClassRelation) { - log.debug('Adding relation: ' + JSON.stringify(relation)); - addClass(relation.id1); - addClass(relation.id2); +export const addRelation = function (classRelation: ClassRelation) { + log.debug('Adding relation: ' + JSON.stringify(classRelation)); + // Due to relationType cannot just check if it is equal to 'none' or it complains, can fix this later + const invalidTypes = [ + relationType.LOLLIPOP, + relationType.AGGREGATION, + relationType.COMPOSITION, + relationType.DEPENDENCY, + relationType.EXTENSION, + ]; + + if ( + classRelation.relation.type1 === relationType.LOLLIPOP && + !invalidTypes.includes(classRelation.relation.type2) + ) { + addClass(classRelation.id2); + addInterface(classRelation.id1, classRelation.id2); + classRelation.id1 = `interface${interfaces.length - 1}`; + } else if ( + classRelation.relation.type2 === relationType.LOLLIPOP && + !invalidTypes.includes(classRelation.relation.type1) + ) { + addClass(classRelation.id1); + addInterface(classRelation.id2, classRelation.id1); + classRelation.id2 = `interface${interfaces.length - 1}`; + } else { + addClass(classRelation.id1); + addClass(classRelation.id2); + } - relation.id1 = splitClassNameAndType(relation.id1).className; - relation.id2 = splitClassNameAndType(relation.id2).className; + classRelation.id1 = splitClassNameAndType(classRelation.id1).className; + classRelation.id2 = splitClassNameAndType(classRelation.id2).className; - relation.relationTitle1 = common.sanitizeText(relation.relationTitle1.trim(), getConfig()); + classRelation.relationTitle1 = common.sanitizeText( + classRelation.relationTitle1.trim(), + getConfig() + ); - relation.relationTitle2 = common.sanitizeText(relation.relationTitle2.trim(), getConfig()); + classRelation.relationTitle2 = common.sanitizeText( + classRelation.relationTitle2.trim(), + getConfig() + ); - relations.push(relation); + relations.push(classRelation); }; /** @@ -229,11 +279,37 @@ export const setCssClass = function (ids: string, className: string) { } const classNode = classes.get(id); if (classNode) { - classNode.cssClasses.push(className); + classNode.cssClasses += ' ' + className; } }); }; +export const defineClass = function (ids: string[], style: string[]) { + for (const id of ids) { + let styleClass = styleClasses.get(id); + if (styleClass === undefined) { + styleClass = { id, styles: [], textStyles: [] }; + styleClasses.set(id, styleClass); + } + + if (style) { + style.forEach(function (s) { + if (/color/.exec(s)) { + const newStyle = s.replace('fill', 'bgFill'); // .replace('color', 'fill'); + styleClass.textStyles.push(newStyle); + } + styleClass.styles.push(s); + }); + } + + classes.forEach((value) => { + if (value.cssClasses.includes(id)) { + value.styles.push(...style.flatMap((s) => s.split(','))); + } + }); + } +}; + /** * Called by parser when a tooltip is found, e.g. a clickable element. * @@ -472,6 +548,152 @@ export const setCssStyle = function (id: string, styles: string[]) { } }; +/** + * Gets the arrow marker for a type index + * + * @param type - The type to look for + * @returns The arrow marker + */ +function getArrowMarker(type: number) { + let marker; + switch (type) { + case 0: + marker = 'aggregation'; + break; + case 1: + marker = 'extension'; + break; + case 2: + marker = 'composition'; + break; + case 3: + marker = 'dependency'; + break; + case 4: + marker = 'lollipop'; + break; + default: + marker = 'none'; + } + return marker; +} + +export const getData = () => { + const nodes: Node[] = []; + const edges: Edge[] = []; + const config = getConfig(); + + for (const namespaceKey of namespaces.keys()) { + const namespace = namespaces.get(namespaceKey); + if (namespace) { + const node: Node = { + id: namespace.id, + label: namespace.id, + isGroup: true, + padding: config.class!.padding ?? 16, + // parent node must be one of [rect, roundedWithTitle, noteGroup, divider] + shape: 'rect', + cssStyles: ['fill: none', 'stroke: black'], + look: config.look, + }; + nodes.push(node); + } + } + + for (const classKey of classes.keys()) { + const classNode = classes.get(classKey); + if (classNode) { + const node = classNode as unknown as Node; + node.parentId = classNode.parent; + node.look = config.look; + nodes.push(node); + } + } + + let cnt = 0; + for (const note of notes) { + cnt++; + const noteNode: Node = { + id: note.id, + label: note.text, + isGroup: false, + shape: 'note', + padding: config.class!.padding ?? 6, + cssStyles: [ + 'text-align: left', + 'white-space: nowrap', + `fill: ${config.themeVariables.noteBkgColor}`, + `stroke: ${config.themeVariables.noteBorderColor}`, + ], + look: config.look, + }; + nodes.push(noteNode); + + const noteClassId = classes.get(note.class)?.id ?? ''; + + if (noteClassId) { + const edge: Edge = { + id: `edgeNote${cnt}`, + start: note.id, + end: noteClassId, + type: 'normal', + thickness: 'normal', + classes: 'relation', + arrowTypeStart: 'none', + arrowTypeEnd: 'none', + arrowheadStyle: '', + labelStyle: [''], + style: ['fill: none'], + pattern: 'dotted', + look: config.look, + }; + edges.push(edge); + } + } + + for (const _interface of interfaces) { + const interfaceNode: Node = { + id: _interface.id, + label: _interface.label, + isGroup: false, + shape: 'rect', + cssStyles: ['opacity: 0;'], + look: config.look, + }; + nodes.push(interfaceNode); + } + + cnt = 0; + for (const classRelation of relations) { + cnt++; + const edge: Edge = { + id: getEdgeId(classRelation.id1, classRelation.id2, { + prefix: 'id', + counter: cnt, + }), + start: classRelation.id1, + end: classRelation.id2, + type: 'normal', + label: classRelation.title, + labelpos: 'c', + thickness: 'normal', + classes: 'relation', + arrowTypeStart: getArrowMarker(classRelation.relation.type1), + arrowTypeEnd: getArrowMarker(classRelation.relation.type2), + startLabelRight: classRelation.relationTitle1 === 'none' ? '' : classRelation.relationTitle1, + endLabelLeft: classRelation.relationTitle2 === 'none' ? '' : classRelation.relationTitle2, + arrowheadStyle: '', + labelStyle: ['display: inline-block'], + style: classRelation.style || '', + pattern: classRelation.relation.lineType == 1 ? 'dashed' : 'solid', + look: config.look, + }; + edges.push(edge); + } + + return { nodes, edges, other: {}, config, direction: getDirection() }; +}; + export default { setAccTitle, getAccTitle, @@ -497,6 +719,7 @@ export default { relationType, setClickEvent, setCssClass, + defineClass, setLink, getTooltip, setTooltip, @@ -509,4 +732,5 @@ export default { getNamespace, getNamespaces, setCssStyle, + getData, }; diff --git a/packages/mermaid/src/diagrams/class/classDiagram-styles.spec.js b/packages/mermaid/src/diagrams/class/classDiagram-styles.spec.js index 9571884014..18bdaade59 100644 --- a/packages/mermaid/src/diagrams/class/classDiagram-styles.spec.js +++ b/packages/mermaid/src/diagrams/class/classDiagram-styles.spec.js @@ -13,7 +13,7 @@ describe('class diagram, ', function () { parser.parse(str); - expect(parser.yy.getClass('Class01').cssClasses[0]).toBe('exClass'); + expect(parser.yy.getClass('Class01').cssClasses).toBe('default exClass'); }); it('should be possible to apply a css class to a class directly with struct', function () { @@ -28,7 +28,7 @@ describe('class diagram, ', function () { parser.parse(str); const testClass = parser.yy.getClass('Class1'); - expect(testClass.cssClasses[0]).toBe('exClass'); + expect(testClass.cssClasses).toBe('default exClass'); }); it('should be possible to apply a css class to a class with relations', function () { @@ -36,7 +36,7 @@ describe('class diagram, ', function () { parser.parse(str); - expect(parser.yy.getClass('Class01').cssClasses[0]).toBe('exClass'); + expect(parser.yy.getClass('Class01').cssClasses).toBe('default exClass'); }); it('should be possible to apply a cssClass to a class', function () { @@ -44,7 +44,7 @@ describe('class diagram, ', function () { parser.parse(str); - expect(parser.yy.getClass('Class01').cssClasses[0]).toBe('exClass'); + expect(parser.yy.getClass('Class01').cssClasses).toBe('default exClass'); }); it('should be possible to apply a cssClass to a comma separated list of classes', function () { @@ -53,8 +53,8 @@ describe('class diagram, ', function () { parser.parse(str); - expect(parser.yy.getClass('Class01').cssClasses[0]).toBe('exClass'); - expect(parser.yy.getClass('Class02').cssClasses[0]).toBe('exClass'); + expect(parser.yy.getClass('Class01').cssClasses).toBe('default exClass'); + expect(parser.yy.getClass('Class02').cssClasses).toBe('default exClass'); }); it('should be possible to apply a style to an individual node', function () { const str = @@ -69,5 +69,47 @@ describe('class diagram, ', function () { expect(styleElements[1]).toBe('stroke:#333'); expect(styleElements[2]).toBe('stroke-width:4px'); }); + it('should be possible to define and assign a class inside the diagram', function () { + const str = + 'classDiagram\n' + 'class Class01\n cssClass "Class01" pink\n classDef pink fill:#f9f'; + + parser.parse(str); + + expect(parser.yy.getClass('Class01').cssClasses).toBe('default pink'); + }); + it('should be possible to define and assign a class using shorthand inside the diagram', function () { + const str = 'classDiagram\n' + 'class Class01:::pink\n classDef pink fill:#f9f'; + + parser.parse(str); + + expect(parser.yy.getClass('Class01').cssClasses).toBe('default pink'); + }); + it('should properly assign styles from a class defined inside the diagram', function () { + const str = + 'classDiagram\n' + + 'class Class01:::pink\n classDef pink fill:#f9f,stroke:#333,stroke-width:6px'; + + parser.parse(str); + + expect(parser.yy.getClass('Class01').styles).toStrictEqual([ + 'fill:#f9f', + 'stroke:#333', + 'stroke-width:6px', + ]); + }); + it('should properly assign multiple classes and styles from classes defined inside the diagram', function () { + const str = + 'classDiagram\n' + + 'class Class01:::pink\n cssClass "Class01" bold\n classDef pink fill:#f9f\n classDef bold stroke:#333,stroke-width:6px'; + + parser.parse(str); + + expect(parser.yy.getClass('Class01').styles).toStrictEqual([ + 'fill:#f9f', + 'stroke:#333', + 'stroke-width:6px', + ]); + expect(parser.yy.getClass('Class01').cssClasses).toBe('default pink bold'); + }); }); }); diff --git a/packages/mermaid/src/diagrams/class/classDiagram-v2.ts b/packages/mermaid/src/diagrams/class/classDiagram-v2.ts index ec5398d29d..6a3747e418 100644 --- a/packages/mermaid/src/diagrams/class/classDiagram-v2.ts +++ b/packages/mermaid/src/diagrams/class/classDiagram-v2.ts @@ -3,7 +3,7 @@ import type { DiagramDefinition } from '../../diagram-api/types.js'; import parser from './parser/classDiagram.jison'; import db from './classDb.js'; import styles from './styles.js'; -import renderer from './classRenderer-v2.js'; +import renderer from './classRenderer-v3-unified.js'; export const diagram: DiagramDefinition = { parser, diff --git a/packages/mermaid/src/diagrams/class/classDiagram.spec.ts b/packages/mermaid/src/diagrams/class/classDiagram.spec.ts index 41cec8820c..40027f27ec 100644 --- a/packages/mermaid/src/diagrams/class/classDiagram.spec.ts +++ b/packages/mermaid/src/diagrams/class/classDiagram.spec.ts @@ -246,7 +246,7 @@ describe('given a basic class diagram, ', function () { const c1 = classDb.getClass('C1'); expect(c1.label).toBe('Class 1 with text label'); - expect(c1.cssClasses[0]).toBe('styleClass'); + expect(c1.cssClasses).toBe('default styleClass'); }); it('should parse a class with text label and css class', () => { @@ -261,7 +261,7 @@ describe('given a basic class diagram, ', function () { const c1 = classDb.getClass('C1'); expect(c1.label).toBe('Class 1 with text label'); expect(c1.members[0].getDisplayDetails().displayText).toBe('int member1'); - expect(c1.cssClasses[0]).toBe('styleClass'); + expect(c1.cssClasses).toBe('default styleClass'); }); it('should parse two classes with text labels and css classes', () => { @@ -276,11 +276,11 @@ describe('given a basic class diagram, ', function () { const c1 = classDb.getClass('C1'); expect(c1.label).toBe('Class 1 with text label'); - expect(c1.cssClasses[0]).toBe('styleClass'); + expect(c1.cssClasses).toBe('default styleClass'); const c2 = classDb.getClass('C2'); expect(c2.label).toBe('Long long long long long long long long long long label'); - expect(c2.cssClasses[0]).toBe('styleClass'); + expect(c2.cssClasses).toBe('default styleClass'); }); it('should parse two classes with text labels and css class shorthands', () => { @@ -293,11 +293,11 @@ describe('given a basic class diagram, ', function () { const c1 = classDb.getClass('C1'); expect(c1.label).toBe('Class 1 with text label'); - expect(c1.cssClasses[0]).toBe('styleClass1'); + expect(c1.cssClasses).toBe('default styleClass1'); const c2 = classDb.getClass('C2'); expect(c2.label).toBe('Class 2 !@#$%^&*() label'); - expect(c2.cssClasses[0]).toBe('styleClass2'); + expect(c2.cssClasses).toBe('default styleClass2'); }); it('should parse multiple classes with same text labels', () => { @@ -494,10 +494,32 @@ class C13["With Città foreign language"] ], methods: [], annotations: [], - cssClasses: [], + cssClasses: 'default', }); - expect(classDb.getClasses().size).toBe(3); + expect(classDb.getClasses().get('Student')).toMatchInlineSnapshot(` + { + "annotations": [], + "cssClasses": "default", + "domId": "classId-Student-141", + "id": "Student", + "label": "Student", + "members": [ + ClassMember { + "classifier": "", + "id": "idCard : IdCard", + "memberType": "attribute", + "text": "\\-idCard : IdCard", + "visibility": "-", + }, + ], + "methods": [], + "shape": "classBox", + "styles": [], + "text": "Student", + "type": "", + } + `); expect(classDb.getRelations().length).toBe(2); expect(classDb.getRelations()).toMatchInlineSnapshot(` [ @@ -738,7 +760,7 @@ foo() const actual = parser.yy.getClass('Class1'); expect(actual.link).toBe('google.com'); - expect(actual.cssClasses[0]).toBe('clickable'); + expect(actual.cssClasses).toBe('default clickable'); }); it('should handle href link with tooltip', function () { @@ -754,7 +776,7 @@ foo() const actual = parser.yy.getClass('Class1'); expect(actual.link).toBe('google.com'); expect(actual.tooltip).toBe('A Tooltip'); - expect(actual.cssClasses[0]).toBe('clickable'); + expect(actual.cssClasses).toBe('default clickable'); }); it('should handle href link with tooltip and target', function () { @@ -773,7 +795,7 @@ foo() const actual = parser.yy.getClass('Class1'); expect(actual.link).toBe('google.com'); expect(actual.tooltip).toBe('A tooltip'); - expect(actual.cssClasses[0]).toBe('clickable'); + expect(actual.cssClasses).toBe('default clickable'); }); it('should handle function call', function () { @@ -1468,8 +1490,7 @@ describe('given a class diagram with relationships, ', function () { const testClass = parser.yy.getClass('Class1'); expect(testClass.link).toBe('google.com'); - expect(testClass.cssClasses.length).toBe(1); - expect(testClass.cssClasses[0]).toBe('clickable'); + expect(testClass.cssClasses).toBe('default clickable'); }); it('should associate click and href link and css appropriately', function () { @@ -1482,8 +1503,7 @@ describe('given a class diagram with relationships, ', function () { const testClass = parser.yy.getClass('Class1'); expect(testClass.link).toBe('google.com'); - expect(testClass.cssClasses.length).toBe(1); - expect(testClass.cssClasses[0]).toBe('clickable'); + expect(testClass.cssClasses).toBe('default clickable'); }); it('should associate link with tooltip', function () { @@ -1497,8 +1517,7 @@ describe('given a class diagram with relationships, ', function () { const testClass = parser.yy.getClass('Class1'); expect(testClass.link).toBe('google.com'); expect(testClass.tooltip).toBe('A tooltip'); - expect(testClass.cssClasses.length).toBe(1); - expect(testClass.cssClasses[0]).toBe('clickable'); + expect(testClass.cssClasses).toBe('default clickable'); }); it('should associate click and href link with tooltip', function () { @@ -1512,8 +1531,7 @@ describe('given a class diagram with relationships, ', function () { const testClass = parser.yy.getClass('Class1'); expect(testClass.link).toBe('google.com'); expect(testClass.tooltip).toBe('A tooltip'); - expect(testClass.cssClasses.length).toBe(1); - expect(testClass.cssClasses[0]).toBe('clickable'); + expect(testClass.cssClasses).toBe('default clickable'); }); it('should associate click and href link with tooltip and target appropriately', function () { @@ -1770,8 +1788,7 @@ C1 --> C2 const c1 = classDb.getClass('C1'); expect(c1.label).toBe('Class 1 with text label'); - expect(c1.cssClasses.length).toBe(1); - expect(c1.cssClasses[0]).toBe('styleClass'); + expect(c1.cssClasses).toBe('default styleClass'); const member = c1.members[0]; expect(member.getDisplayDetails().displayText).toBe('+member1'); }); @@ -1787,8 +1804,7 @@ cssClass "C1" styleClass const c1 = classDb.getClass('C1'); expect(c1.label).toBe('Class 1 with text label'); - expect(c1.cssClasses.length).toBe(1); - expect(c1.cssClasses[0]).toBe('styleClass'); + expect(c1.cssClasses).toBe('default styleClass'); const member = c1.members[0]; expect(member.getDisplayDetails().displayText).toBe('+member1'); }); @@ -1805,13 +1821,11 @@ cssClass "C1,C2" styleClass const c1 = classDb.getClass('C1'); expect(c1.label).toBe('Class 1 with text label'); - expect(c1.cssClasses.length).toBe(1); - expect(c1.cssClasses[0]).toBe('styleClass'); + expect(c1.cssClasses).toBe('default styleClass'); const c2 = classDb.getClass('C2'); expect(c2.label).toBe('Long long long long long long long long long long label'); - expect(c2.cssClasses.length).toBe(1); - expect(c2.cssClasses[0]).toBe('styleClass'); + expect(c2.cssClasses).toBe('default styleClass'); }); it('should parse two classes with text labels and css class shorthands', () => { @@ -1825,13 +1839,11 @@ C1 --> C2 const c1 = classDb.getClass('C1'); expect(c1.label).toBe('Class 1 with text label'); - expect(c1.cssClasses.length).toBe(1); - expect(c1.cssClasses[0]).toBe('styleClass1'); + expect(c1.cssClasses).toBe('default styleClass1'); const c2 = classDb.getClass('C2'); expect(c2.label).toBe('Class 2 !@#$%^&*() label'); - expect(c2.cssClasses.length).toBe(1); - expect(c2.cssClasses[0]).toBe('styleClass2'); + expect(c2.cssClasses).toBe('default styleClass2'); }); it('should parse multiple classes with same text labels', () => { diff --git a/packages/mermaid/src/diagrams/class/classDiagram.ts b/packages/mermaid/src/diagrams/class/classDiagram.ts index 7f027c186e..6a3747e418 100644 --- a/packages/mermaid/src/diagrams/class/classDiagram.ts +++ b/packages/mermaid/src/diagrams/class/classDiagram.ts @@ -3,7 +3,7 @@ import type { DiagramDefinition } from '../../diagram-api/types.js'; import parser from './parser/classDiagram.jison'; import db from './classDb.js'; import styles from './styles.js'; -import renderer from './classRenderer.js'; +import renderer from './classRenderer-v3-unified.js'; export const diagram: DiagramDefinition = { parser, diff --git a/packages/mermaid/src/diagrams/class/classRenderer-v3-unified.ts b/packages/mermaid/src/diagrams/class/classRenderer-v3-unified.ts new file mode 100644 index 0000000000..670f93f165 --- /dev/null +++ b/packages/mermaid/src/diagrams/class/classRenderer-v3-unified.ts @@ -0,0 +1,79 @@ +import { getConfig } from '../../diagram-api/diagramAPI.js'; +import type { DiagramStyleClassDef } from '../../diagram-api/types.js'; +import { log } from '../../logger.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 utils from '../../utils.js'; + +/** + * Get the direction from the statement items. + * Look through all of the documents (docs) in the parsedItems + * Because is a _document_ direction, the default direction is not necessarily the same as the overall default _diagram_ direction. + * @param parsedItem - the parsed statement item to look through + * @param defaultDir - the direction to use if none is found + * @returns The direction to use + */ +export const getDir = (parsedItem: any, defaultDir = 'TB') => { + if (!parsedItem.doc) { + return defaultDir; + } + + let dir = defaultDir; + + for (const parsedItemDoc of parsedItem.doc) { + if (parsedItemDoc.stmt === 'dir') { + dir = parsedItemDoc.value; + } + } + + return dir; +}; + +export const getClasses = function ( + text: string, + diagramObj: any +): Map { + return diagramObj.db.getClasses(); +}; + +export const draw = async function (text: string, id: string, _version: string, diag: any) { + log.info('REF0:'); + log.info('Drawing class diagram (v3)', id); + const { securityLevel, state: conf, layout } = getConfig(); + // Extracting the data from the parsed structure into a more usable form + // Not related to the refactoring, but this is the first step in the rendering process + // diag.db.extract(diag.db.getRootDocV2()); + + // 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 = diag.db.getData() as LayoutData; + + // Create the root SVG - the element is the div containing the SVG element + const svg = getDiagramElement(id, securityLevel); + + data4Layout.type = diag.type; + data4Layout.layoutAlgorithm = getRegisteredLayoutAlgorithm(layout); + + data4Layout.nodeSpacing = conf?.nodeSpacing || 50; + data4Layout.rankSpacing = conf?.rankSpacing || 50; + data4Layout.markers = ['aggregation', 'extension', 'composition', 'dependency', 'lollipop']; + data4Layout.diagramId = id; + await render(data4Layout, svg); + const padding = 8; + utils.insertTitle( + svg, + 'classDiagramTitleText', + conf?.titleTopMargin ?? 25, + diag.db.getDiagramTitle() + ); + + setupViewPortForSVG(svg, padding, 'classDiagram', conf?.useMaxWidth ?? true); +}; + +export default { + getClasses, + draw, + getDir, +}; diff --git a/packages/mermaid/src/diagrams/class/classTypes.ts b/packages/mermaid/src/diagrams/class/classTypes.ts index f1955a2246..9d0d47569c 100644 --- a/packages/mermaid/src/diagrams/class/classTypes.ts +++ b/packages/mermaid/src/diagrams/class/classTypes.ts @@ -5,7 +5,9 @@ export interface ClassNode { id: string; type: string; label: string; - cssClasses: string[]; + shape: string; + text: string; + cssClasses: string; methods: ClassMember[]; members: ClassMember[]; annotations: string[]; @@ -16,6 +18,7 @@ export interface ClassNode { linkTarget?: string; haveCallback?: boolean; tooltip?: string; + look?: string; } export type Visibility = '#' | '+' | '~' | '-' | ''; @@ -30,6 +33,7 @@ export class ClassMember { cssStyle!: string; memberType!: 'method' | 'attribute'; visibility!: Visibility; + text: string; /** * denote if static or to determine which css class to apply to the node * @defaultValue '' @@ -50,6 +54,7 @@ export class ClassMember { this.memberType = memberType; this.visibility = ''; this.classifier = ''; + this.text = ''; const sanitizedInput = sanitizeText(input, getConfig()); this.parseMember(sanitizedInput); } @@ -85,7 +90,7 @@ export class ClassMember { this.visibility = detectedVisibility as Visibility; } - this.id = match[2].trim(); + this.id = match[2]; this.parameters = match[3] ? match[3].trim() : ''; potentialClassifier = match[4] ? match[4].trim() : ''; this.returnType = match[5] ? match[5].trim() : ''; @@ -118,6 +123,14 @@ export class ClassMember { } this.classifier = potentialClassifier; + // Preserve one space only + this.id = this.id.startsWith(' ') ? ' ' + this.id.trim() : this.id.trim(); + + const combinedText = `${this.visibility ? '\\' + this.visibility : ''}${parseGenericTypes(this.id)}${this.memberType === 'method' ? `(${parseGenericTypes(this.parameters)})${this.returnType ? ' : ' + parseGenericTypes(this.returnType) : ''}` : ''}`; + this.text = combinedText.replaceAll('<', '<').replaceAll('>', '>'); + if (this.text.startsWith('\\<')) { + this.text = this.text.replace('\\<', '~'); + } } parseClassifier() { @@ -154,6 +167,12 @@ export interface ClassRelation { }; } +export interface Interface { + id: string; + label: string; + classId: string; +} + export interface NamespaceNode { id: string; domId: string; @@ -161,5 +180,11 @@ export interface NamespaceNode { children: NamespaceMap; } +export interface StyleClass { + id: string; + styles: string[]; + textStyles: string[]; +} + export type ClassMap = Map; export type NamespaceMap = Map; diff --git a/packages/mermaid/src/diagrams/class/parser/classDiagram.jison b/packages/mermaid/src/diagrams/class/parser/classDiagram.jison index 5fcea2da3c..83d9bd48ed 100644 --- a/packages/mermaid/src/diagrams/class/parser/classDiagram.jison +++ b/packages/mermaid/src/diagrams/class/parser/classDiagram.jison @@ -61,6 +61,7 @@ Function arguments are optional: 'call ()' simply executes 'callb [^"]* return "STR"; <*>["] this.begin("string"); "style" return 'STYLE'; +"classDef" return 'CLASSDEF'; "namespace" { this.begin('namespace'); return 'NAMESPACE'; } \s*(\r?\n)+ { this.popState(); return 'NEWLINE'; } @@ -265,6 +266,7 @@ statement | styleStatement | cssClassStatement | noteStatement + | classDefStatement | direction | acc_title acc_title_value { $$=$2.trim();yy.setAccTitle($$); } | acc_descr acc_descr_value { $$=$2.trim();yy.setAccDescription($$); } @@ -326,6 +328,15 @@ noteStatement | NOTE noteText { yy.addNote($2); } ; +classDefStatement + : CLASSDEF classList stylesOpt {$$ = $CLASSDEF;yy.defineClass($classList,$stylesOpt);} + ; + +classList + : ALPHA { $$ = [$ALPHA]; } + | classList COMMA ALPHA = { $$ = $classList.concat([$ALPHA]); } + ; + direction : direction_tb { yy.setDirection('TB');} diff --git a/packages/mermaid/src/diagrams/class/shapeUtil.ts b/packages/mermaid/src/diagrams/class/shapeUtil.ts new file mode 100644 index 0000000000..94c8f817ae --- /dev/null +++ b/packages/mermaid/src/diagrams/class/shapeUtil.ts @@ -0,0 +1,223 @@ +import { select } from 'd3'; +import { getConfig } from '../../config.js'; +import { getNodeClasses } from '../../rendering-util/rendering-elements/shapes/util.js'; +import { calculateTextWidth, decodeEntities } from '../../utils.js'; +import type { ClassMember, ClassNode } from './classTypes.js'; +import { sanitizeText } from '../../diagram-api/diagramAPI.js'; +import { createText } from '../../rendering-util/createText.js'; +import { evaluate, hasKatex } from '../common/common.js'; +import type { Node } from '../../rendering-util/types.js'; +import type { MermaidConfig } from '../../config.type.js'; +import type { D3Selection } from '../../types.js'; + +// Creates the shapeSvg and inserts text +export async function textHelper( + parent: D3Selection, + node: any, + config: MermaidConfig, + useHtmlLabels: boolean, + GAP = config.class!.padding ?? 12 +) { + const TEXT_PADDING = !useHtmlLabels ? 3 : 0; + const shapeSvg = parent + // @ts-ignore: Ignore error for using .insert on SVGAElement + .insert('g') + .attr('class', getNodeClasses(node)) + .attr('id', node.domId || node.id); + + let annotationGroup = null; + let labelGroup = null; + let membersGroup = null; + let methodsGroup = null; + + let annotationGroupHeight = 0; + let labelGroupHeight = 0; + let membersGroupHeight = 0; + + annotationGroup = shapeSvg.insert('g').attr('class', 'annotation-group text'); + if (node.annotations.length > 0) { + const annotation = node.annotations[0]; + await addText(annotationGroup, { text: `«${annotation}»` } as unknown as ClassMember, 0); + + const annotationGroupBBox = annotationGroup.node()!.getBBox(); + annotationGroupHeight = annotationGroupBBox.height; + } + + labelGroup = shapeSvg.insert('g').attr('class', 'label-group text'); + await addText(labelGroup, node, 0, ['font-weight: bolder']); + const labelGroupBBox = labelGroup.node()!.getBBox(); + labelGroupHeight = labelGroupBBox.height; + + membersGroup = shapeSvg.insert('g').attr('class', 'members-group text'); + let yOffset = 0; + for (const member of node.members) { + const height = await addText(membersGroup, member, yOffset, [member.parseClassifier()]); + yOffset += height + TEXT_PADDING; + } + membersGroupHeight = membersGroup.node()!.getBBox().height; + if (membersGroupHeight <= 0) { + membersGroupHeight = GAP / 2; + } + + methodsGroup = shapeSvg.insert('g').attr('class', 'methods-group text'); + let methodsYOffset = 0; + for (const method of node.methods) { + const height = await addText(methodsGroup, method, methodsYOffset, [method.parseClassifier()]); + methodsYOffset += height + TEXT_PADDING; + } + + let bbox = shapeSvg.node()!.getBBox(); + + // Center annotation + if (annotationGroup !== null) { + const annotationGroupBBox = annotationGroup.node()!.getBBox(); + annotationGroup.attr('transform', `translate(${-annotationGroupBBox.width / 2})`); + } + + // Adjust label + labelGroup.attr('transform', `translate(${-labelGroupBBox.width / 2}, ${annotationGroupHeight})`); + + bbox = shapeSvg.node()!.getBBox(); + + membersGroup.attr( + 'transform', + `translate(${0}, ${annotationGroupHeight + labelGroupHeight + GAP * 2})` + ); + bbox = shapeSvg.node()!.getBBox(); + methodsGroup.attr( + 'transform', + `translate(${0}, ${annotationGroupHeight + labelGroupHeight + (membersGroupHeight ? membersGroupHeight + GAP * 4 : GAP * 2)})` + ); + + bbox = shapeSvg.node()!.getBBox(); + + return { shapeSvg, bbox }; +} + +// Modified version of labelHelper() to help create and place text for classes +async function addText( + parentGroup: D3Selection, + node: Node | ClassNode | ClassMember, + yOffset: number, + styles: string[] = [] +) { + const textEl = parentGroup.insert('g').attr('class', 'label').attr('style', styles.join('; ')); + const config = getConfig(); + let useHtmlLabels = + 'useHtmlLabels' in node ? node.useHtmlLabels : (evaluate(config.htmlLabels) ?? true); + + let textContent = ''; + // Support regular node type (.label) and classNodes (.text) + if ('text' in node) { + textContent = node.text; + } else { + textContent = node.label!; + } + + // createText() will cause unwanted behavior because of classDiagram syntax so workarounds are needed + + if (!useHtmlLabels && textContent.startsWith('\\')) { + textContent = textContent.substring(1); + } + + if (hasKatex(textContent)) { + useHtmlLabels = true; + } + + const text = await createText( + textEl, + sanitizeText(decodeEntities(textContent)), + { + width: calculateTextWidth(textContent, config) + 50, // Add room for error when splitting text into multiple lines + classes: 'markdown-node-label', + useHtmlLabels, + }, + config + ); + let bbox; + let numberOfLines = 1; + + if (!useHtmlLabels) { + // Undo font-weight normal + if (styles.includes('font-weight: bolder')) { + select(text).selectAll('tspan').attr('font-weight', ''); + } + + numberOfLines = text.children.length; + + const textChild = text.children[0]; + if (text.textContent === '' || text.textContent.includes('>')) { + textChild.textContent = + textContent[0] + + textContent.substring(1).replaceAll('>', '>').replaceAll('<', '<').trim(); + + // Text was improperly removed due to spaces (preserve one space if present) + const preserveSpace = textContent[1] === ' '; + if (preserveSpace) { + textChild.textContent = textChild.textContent[0] + ' ' + textChild.textContent.substring(1); + } + } + + // To support empty boxes + if (textChild.textContent === 'undefined') { + textChild.textContent = ''; + } + + // Get the bounding box after the text update + bbox = text.getBBox(); + } else { + const div = text.children[0]; + const dv = select(text); + + numberOfLines = div.innerHTML.split('
').length; + // Katex math support + if (div.innerHTML.includes('')) { + numberOfLines += div.innerHTML.split('').length - 1; + } + + // Support images + const images = div.getElementsByTagName('img'); + if (images) { + const noImgText = textContent.replace(/]*>/g, '').trim() === ''; + await Promise.all( + [...images].map( + (img) => + new Promise((res) => { + function setupImage() { + img.style.display = 'flex'; + img.style.flexDirection = 'column'; + + if (noImgText) { + // default size if no text + const bodyFontSize = + config.fontSize?.toString() ?? window.getComputedStyle(document.body).fontSize; + const enlargingFactor = 5; + const width = parseInt(bodyFontSize, 10) * enlargingFactor + 'px'; + img.style.minWidth = width; + img.style.maxWidth = width; + } else { + img.style.width = '100%'; + } + res(img); + } + setTimeout(() => { + if (img.complete) { + setupImage(); + } + }); + img.addEventListener('error', setupImage); + img.addEventListener('load', setupImage); + }) + ) + ); + } + + bbox = div.getBoundingClientRect(); + dv.attr('width', bbox.width); + dv.attr('height', bbox.height); + } + + // Center text and offset by yOffset + textEl.attr('transform', 'translate(0,' + (-bbox.height / (2 * numberOfLines) + yOffset) + ')'); + return bbox.height; +} diff --git a/packages/mermaid/src/diagrams/class/styles.js b/packages/mermaid/src/diagrams/class/styles.js index 9bad27f386..4a888a2658 100644 --- a/packages/mermaid/src/diagrams/class/styles.js +++ b/packages/mermaid/src/diagrams/class/styles.js @@ -20,6 +20,10 @@ const getStyles = (options) => .label text { fill: ${options.classText}; } + +.labelBkg { + background: ${options.mainBkg}; +} .edgeLabel .label span { background: ${options.mainBkg}; } diff --git a/packages/mermaid/src/diagrams/kanban/detector.ts b/packages/mermaid/src/diagrams/kanban/detector.ts new file mode 100644 index 0000000000..3c07ca4dff --- /dev/null +++ b/packages/mermaid/src/diagrams/kanban/detector.ts @@ -0,0 +1,23 @@ +import type { + DiagramDetector, + DiagramLoader, + ExternalDiagramDefinition, +} from '../../diagram-api/types.js'; +const id = 'kanban'; + +const detector: DiagramDetector = (txt) => { + return /^\s*kanban/.test(txt); +}; + +const loader: DiagramLoader = async () => { + const { diagram } = await import('./kanban-definition.js'); + return { id, diagram }; +}; + +const plugin: ExternalDiagramDefinition = { + id, + detector, + loader, +}; + +export default plugin; diff --git a/packages/mermaid/src/diagrams/kanban/kanban-definition.ts b/packages/mermaid/src/diagrams/kanban/kanban-definition.ts new file mode 100644 index 0000000000..fbaed30c0f --- /dev/null +++ b/packages/mermaid/src/diagrams/kanban/kanban-definition.ts @@ -0,0 +1,13 @@ +// @ts-ignore: JISON doesn't support types +import parser from './parser/kanban.jison'; +import db from './kanbanDb.js'; +import renderer from './kanbanRenderer.js'; +import styles from './styles.js'; +import type { DiagramDefinition } from '../../diagram-api/types.js'; + +export const diagram: DiagramDefinition = { + db, + renderer, + parser, + styles, +}; diff --git a/packages/mermaid/src/diagrams/kanban/kanban.spec.ts b/packages/mermaid/src/diagrams/kanban/kanban.spec.ts new file mode 100644 index 0000000000..58fdab0e61 --- /dev/null +++ b/packages/mermaid/src/diagrams/kanban/kanban.spec.ts @@ -0,0 +1,494 @@ +// @ts-expect-error No types available for JISON +import { parser as kanban } from './parser/kanban.jison'; +import kanbanDB from './kanbanDb.js'; +import type { KanbanNode } from '../../rendering-util/types.js'; +// Todo fix utils functions for tests +import { setLogLevel } from '../../diagram-api/diagramAPI.js'; + +describe('when parsing a kanban ', function () { + beforeEach(function () { + kanban.yy = kanbanDB; + kanban.yy.clear(); + setLogLevel('trace'); + }); + describe('hiearchy', function () { + it('KNBN-1 should handle a simple root definition abc122', function () { + const str = `kanban + root`; + + kanban.parse(str); + const sections = kanban.yy.getSections(); + expect(sections.length).toEqual(1); + expect(sections[0].label).toEqual('root'); + }); + it('KNBN-2 should handle a hierachial kanban definition', function () { + const str = `kanban + root + child1 + child2 + `; + + kanban.parse(str); + + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections.length).toEqual(1); + expect(sections[0].label).toEqual('root'); + expect(children.length).toEqual(2); + expect(children[0].label).toEqual('child1'); + expect(children[1].label).toEqual('child2'); + }); + + /** CATCH case when a lower level comes later, should throw + * a + * b + * c + */ + + it('3 should handle a simple root definition with a shape and without an id abc123', function () { + const str = `kanban + (root)`; + + kanban.parse(str); + const sections = kanban.yy.getSections(); + expect(sections[0].label).toEqual('root'); + }); + + it('KNBN-4 should not dsitinguis between deeper hierachial levels in thr kanban definition', function () { + const str = `kanban + root + child1 + leaf1 + child2`; + + // less picky is better + // expect(() => kanban.parse(str)).toThrow( + // 'There can be only one root. No parent could be found for ("fakeRoot")' + // ); + + kanban.parse(str); + + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections.length).toBe(1); + expect(children.length).toBe(3); + }); + it('5 Multiple sections are ok', function () { + const str = `kanban + section1 + section2`; + kanban.parse(str); + + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections.length).toBe(2); + expect(sections[0].label).toBe('section1'); + expect(sections[1].label).toBe('section2'); + + // expect(() => kanban.parse(str)).toThrow( + // 'There can be only one root. No parent could be found for ("fakeRoot")' + // ); + }); + it('KNBN-6 real root in wrong place', function () { + const str = `kanban + root + fakeRoot + realRootWrongPlace`; + expect(() => kanban.parse(str)).toThrow( + 'Items without section detected, found section ("fakeRoot")' + ); + }); + }); + describe('nodes', function () { + it('KNBN-7 should handle an id and type for a node definition', function () { + const str = `kanban + root[The root] + `; + + kanban.parse(str); + + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections[0].id).toEqual('root'); + expect(sections[0].label).toEqual('The root'); + }); + it('KNBN-8 should handle an id and type for a node definition', function () { + const str = `kanban + root + theId(child1)`; + + kanban.parse(str); + + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections[0].label).toEqual('root'); + expect(children.length).toEqual(1); + const child = children[0]; + expect(child.label).toEqual('child1'); + expect(child.id).toEqual('theId'); + }); + it('KNBN-9 should handle an id and type for a node definition', function () { + const str = `kanban +root + theId(child1)`; + + kanban.parse(str); + + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections[0].label).toEqual('root'); + expect(children.length).toEqual(1); + const child = children[0]; + expect(child.label).toEqual('child1'); + expect(child.id).toEqual('theId'); + }); + }); + describe('decorations', function () { + it('KNBN-13 should be possible to set an icon for the node', function () { + const str = `kanban + root[The root] + ::icon(bomb) + `; + // ::class1 class2 + + kanban.parse(str); + + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections[0].id).toEqual('root'); + expect(sections[0].label).toEqual('The root'); + + expect(sections[0].icon).toEqual('bomb'); + }); + it('KNBN-14 should be possible to set classes for the node', function () { + const str = `kanban + root[The root] + :::m-4 p-8 + `; + // ::class1 class2 + + kanban.parse(str); + + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections[0].id).toEqual('root'); + expect(sections[0].label).toEqual('The root'); + expect(sections[0].cssClasses).toEqual('m-4 p-8'); + }); + it('KNBN-15 should be possible to set both classes and icon for the node', function () { + const str = `kanban + root[The root] + :::m-4 p-8 + ::icon(bomb) + `; + // ::class1 class2 + + kanban.parse(str); + + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections[0].id).toEqual('root'); + expect(sections[0].label).toEqual('The root'); + expect(sections[0].cssClasses).toEqual('m-4 p-8'); + expect(sections[0].icon).toEqual('bomb'); + }); + it('KNBN-16 should be possible to set both classes and icon for the node', function () { + const str = `kanban + root[The root] + ::icon(bomb) + :::m-4 p-8 + `; + // ::class1 class2 + + kanban.parse(str); + + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections[0].id).toEqual('root'); + expect(sections[0].label).toEqual('The root'); + // expect(sections[0].type).toEqual('rect'); + expect(sections[0].cssClasses).toEqual('m-4 p-8'); + expect(sections[0].icon).toEqual('bomb'); + }); + }); + describe('descriptions', function () { + it('KNBN-17 should be possible to use node syntax in the descriptions', function () { + const str = `kanban + root["String containing []"] +`; + kanban.parse(str); + + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections[0].id).toEqual('root'); + expect(sections[0].label).toEqual('String containing []'); + }); + it('KNBN-18 should be possible to use node syntax in the descriptions in children', function () { + const str = `kanban + root["String containing []"] + child1["String containing ()"] +`; + kanban.parse(str); + + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections[0].id).toEqual('root'); + expect(sections[0].label).toEqual('String containing []'); + expect(children.length).toEqual(1); + expect(children[0].label).toEqual('String containing ()'); + }); + it('KNBN-19 should be possible to have a child after a class assignment', function () { + const str = `kanban + root(Root) + Child(Child) + :::hot + a(a) + b[New Stuff]`; + kanban.parse(str); + + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections[0].id).toEqual('root'); + expect(sections[0].label).toEqual('Root'); + expect(children.length).toEqual(3); + + const item1 = children[0]; + const item2 = children[1]; + const item3 = children[2]; + expect(item1.id).toEqual('Child'); + expect(item2.id).toEqual('a'); + expect(item3.id).toEqual('b'); + }); + }); + it('KNBN-20 should be possible to have meaningless empty rows in a kanban abc124', function () { + const str = `kanban + root(Root) + Child(Child) + a(a) + + b[New Stuff]`; + kanban.parse(str); + + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections[0].id).toEqual('root'); + expect(sections[0].label).toEqual('Root'); + expect(children.length).toEqual(3); + + const item1 = children[0]; + const item2 = children[1]; + const item3 = children[2]; + expect(item1.id).toEqual('Child'); + expect(item2.id).toEqual('a'); + expect(item3.id).toEqual('b'); + }); + it('KNBN-21 should be possible to have comments in a kanban', function () { + const str = `kanban + root(Root) + Child(Child) + a(a) + + %% This is a comment + b[New Stuff]`; + kanban.parse(str); + + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections[0].id).toEqual('root'); + expect(sections[0].label).toEqual('Root'); + + const child = children[0]; + expect(child.id).toEqual('Child'); + expect(children[1].id).toEqual('a'); + expect(children[2].id).toEqual('b'); + expect(children.length).toEqual(3); + }); + + it('KNBN-22 should be possible to have comments at the end of a line', function () { + const str = `kanban + root(Root) + Child(Child) + a(a) %% This is a comment + b[New Stuff]`; + kanban.parse(str); + + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections[0].id).toEqual('root'); + expect(sections[0].label).toEqual('Root'); + expect(children.length).toEqual(3); + + const child1 = children[0]; + expect(child1.id).toEqual('Child'); + const child2 = children[1]; + expect(child2.id).toEqual('a'); + const child3 = children[2]; + expect(child3.id).toEqual('b'); + }); + it('KNBN-23 Rows with only spaces should not interfere', function () { + const str = 'kanban\nroot\n A\n \n\n B'; + kanban.parse(str); + + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections[0].id).toEqual('root'); + expect(children.length).toEqual(2); + + const child = children[0]; + expect(child.id).toEqual('A'); + const child2 = children[1]; + expect(child2.id).toEqual('B'); + }); + it('KNBN-24 Handle rows above the kanban declarations', function () { + const str = '\n \nkanban\nroot\n A\n \n\n B'; + kanban.parse(str); + + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections[0].id).toEqual('root'); + expect(children.length).toEqual(2); + + const child = children[0]; + expect(child.id).toEqual('A'); + const child2 = children[1]; + expect(child2.id).toEqual('B'); + }); + it('KNBN-25 Handle rows above the kanban declarations, no space', function () { + const str = '\n\n\nkanban\nroot\n A\n \n\n B'; + kanban.parse(str); + const data = kanban.yy.getData(); + const sections = kanban.yy.getSections(); + const children = data.nodes.filter((n: KanbanNode) => n.parentId === sections[0].id); + + expect(sections[0].id).toEqual('root'); + expect(children.length).toEqual(2); + + const child = children[0]; + expect(child.id).toEqual('A'); + const child2 = children[1]; + expect(child2.id).toEqual('B'); + }); +}); +describe('item data data', function () { + beforeEach(function () { + kanban.yy = kanbanDB; + kanban.yy.clear(); + setLogLevel('trace'); + }); + it('KNBN-30 should be possible to set the priority', function () { + let str = `kanban + root + `; + str = `kanban + root@{ priority: high } + `; + kanban.parse(str); + const sections = kanban.yy.getSections(); + expect(sections[0].id).toEqual('root'); + expect(sections[0].priority).toEqual('high'); + }); + it('KNBN-31 should be possible to set the assignment', function () { + const str = `kanban + root@{ assigned: knsv } + `; + kanban.parse(str); + const sections = kanban.yy.getSections(); + expect(sections[0].id).toEqual('root'); + expect(sections[0].assigned).toEqual('knsv'); + }); + it('KNBN-32 should be possible to set the icon', function () { + const str = `kanban + root@{ icon: star } + `; + kanban.parse(str); + const sections = kanban.yy.getSections(); + expect(sections[0].id).toEqual('root'); + expect(sections[0].icon).toEqual('star'); + }); + it('KNBN-33 should be possible to set the icon', function () { + const str = `kanban + root@{ icon: star } + `; + kanban.parse(str); + const sections = kanban.yy.getSections(); + expect(sections[0].id).toEqual('root'); + expect(sections[0].icon).toEqual('star'); + }); + it('KNBN-34 should be possible to set the metadata using multiple lines', function () { + const str = `kanban + root@{ + icon: star + assigned: knsv + } + `; + kanban.parse(str); + const sections = kanban.yy.getSections(); + expect(sections[0].id).toEqual('root'); + expect(sections[0].icon).toEqual('star'); + expect(sections[0].assigned).toEqual('knsv'); + }); + it('KNBN-35 should be possible to set the metadata using one line', function () { + const str = `kanban + root@{ icon: star, assigned: knsv } + `; + kanban.parse(str); + const sections = kanban.yy.getSections(); + expect(sections[0].id).toEqual('root'); + expect(sections[0].icon).toEqual('star'); + expect(sections[0].assigned).toEqual('knsv'); + }); + it('KNBN-36 should be possible to set the label using the new syntax', function () { + const str = `kanban + root@{ icon: star, label: 'fix things' } + `; + kanban.parse(str); + const sections = kanban.yy.getSections(); + expect(sections[0].label).toEqual('fix things'); + }); + it('KNBN-37 should be possible to set the external id', function () { + const str = `kanban + root@{ ticket: MC-1234 } + `; + kanban.parse(str); + const sections = kanban.yy.getSections(); + const data = kanban.yy.getData(); + expect(sections[0].id).toEqual('root'); + expect(sections[0].ticket).toEqual('MC-1234'); + }); +}); diff --git a/packages/mermaid/src/diagrams/kanban/kanbanDb.ts b/packages/mermaid/src/diagrams/kanban/kanbanDb.ts new file mode 100644 index 0000000000..e6f57ef79a --- /dev/null +++ b/packages/mermaid/src/diagrams/kanban/kanbanDb.ts @@ -0,0 +1,251 @@ +import { getConfig } from '../../diagram-api/diagramAPI.js'; +import type { D3Element } from '../../types.js'; +import { sanitizeText } from '../../diagrams/common/common.js'; +import { log } from '../../logger.js'; +import type { Edge, KanbanNode } from '../../rendering-util/types.js'; +import defaultConfig from '../../defaultConfig.js'; +import type { NodeMetaData } from '../../types.js'; +import * as yaml from 'js-yaml'; + +let nodes: KanbanNode[] = []; +let sections: KanbanNode[] = []; +let cnt = 0; +let elements: Record = {}; + +const clear = () => { + nodes = []; + sections = []; + cnt = 0; + elements = {}; +}; +/* + * if your level is the section level return null - then you do not belong to a level + * otherwise return the current section + */ +const getSection = (level: number) => { + if (nodes.length === 0) { + // console.log('No nodes'); + return null; + } + const sectionLevel = nodes[0].level; + let lastSection = null; + for (let i = nodes.length - 1; i >= 0; i--) { + if (nodes[i].level === sectionLevel && !lastSection) { + lastSection = nodes[i]; + // console.log('lastSection found', lastSection); + } + // console.log('HERE', nodes[i].id, level, nodes[i].level, sectionLevel); + if (nodes[i].level < sectionLevel) { + throw new Error('Items without section detected, found section ("' + nodes[i].label + '")'); + } + } + if (level === lastSection?.level) { + return null; + } + + // No found + return lastSection; +}; + +const getSections = function () { + return sections; +}; + +const getData = function () { + const edges = [] as Edge[]; + const _nodes: KanbanNode[] = []; + + const sections = getSections(); + const conf = getConfig(); + + for (const section of sections) { + const node = { + id: section.id, + label: sanitizeText(section.label ?? '', conf), + isGroup: true, + ticket: section.ticket, + shape: 'kanbanSection', + level: section.level, + look: conf.look, + } satisfies KanbanNode; + _nodes.push(node); + const children = nodes.filter((n) => n.parentId === section.id); + + for (const item of children) { + const childNode = { + id: item.id, + parentId: section.id, + label: sanitizeText(item.label ?? '', conf), + isGroup: false, + ticket: item?.ticket, + priority: item?.priority, + assigned: item?.assigned, + icon: item?.icon, + shape: 'kanbanItem', + level: item.level, + rx: 5, + cssStyles: ['text-align: left'], + } satisfies KanbanNode; + _nodes.push(childNode); + } + } + + return { nodes: _nodes, edges, other: {}, config: getConfig() }; +}; + +const addNode = (level: number, id: string, descr: string, type: number, shapeData: string) => { + const conf = getConfig(); + let padding: number = conf.mindmap?.padding ?? defaultConfig.mindmap.padding; + switch (type) { + case nodeType.ROUNDED_RECT: + case nodeType.RECT: + case nodeType.HEXAGON: + padding *= 2; + } + + const node: KanbanNode = { + id: sanitizeText(id, conf) || 'kbn' + cnt++, + level, + label: sanitizeText(descr, conf), + width: conf.mindmap?.maxNodeWidth ?? defaultConfig.mindmap.maxNodeWidth, + padding, + isGroup: false, + } satisfies KanbanNode; + + if (shapeData !== undefined) { + let yamlData; + // detect if shapeData contains a newline character + // console.log('shapeData', shapeData); + if (!shapeData.includes('\n')) { + // console.log('yamlData shapeData has no new lines', shapeData); + yamlData = '{\n' + shapeData + '\n}'; + } else { + // console.log('yamlData shapeData has new lines', shapeData); + yamlData = shapeData + '\n'; + } + const doc = yaml.load(yamlData, { schema: yaml.JSON_SCHEMA }) as NodeMetaData; + // console.log('yamlData', doc); + if (doc.shape && (doc.shape !== doc.shape.toLowerCase() || doc.shape.includes('_'))) { + throw new Error(`No such shape: ${doc.shape}. Shape names should be lowercase.`); + } + + if (doc?.shape) { + node.shape = doc?.shape; + } + if (doc?.label) { + node.label = doc?.label; + } + if (doc?.icon) { + node.icon = doc?.icon; + } + if (doc?.assigned) { + node.assigned = doc?.assigned; + } + if (doc?.ticket) { + node.ticket = doc?.ticket; + } + + if (doc?.priority) { + node.priority = doc?.priority; + } + } + + const section = getSection(level); + if (section) { + // @ts-ignore false positive for section.id + node.parentId = section.id || 'kbn' + cnt++; + } else { + sections.push(node); + } + nodes.push(node); +}; + +const nodeType = { + DEFAULT: 0, + NO_BORDER: 0, + ROUNDED_RECT: 1, + RECT: 2, + CIRCLE: 3, + CLOUD: 4, + BANG: 5, + HEXAGON: 6, +}; + +const getType = (startStr: string, endStr: string): number => { + log.debug('In get type', startStr, endStr); + switch (startStr) { + case '[': + return nodeType.RECT; + case '(': + return endStr === ')' ? nodeType.ROUNDED_RECT : nodeType.CLOUD; + case '((': + return nodeType.CIRCLE; + case ')': + return nodeType.CLOUD; + case '))': + return nodeType.BANG; + case '{{': + return nodeType.HEXAGON; + default: + return nodeType.DEFAULT; + } +}; + +const setElementForId = (id: number, element: D3Element) => { + elements[id] = element; +}; + +const decorateNode = (decoration?: { class?: string; icon?: string }) => { + if (!decoration) { + return; + } + const config = getConfig(); + const node = nodes[nodes.length - 1]; + if (decoration.icon) { + node.icon = sanitizeText(decoration.icon, config); + } + if (decoration.class) { + node.cssClasses = sanitizeText(decoration.class, config); + } +}; + +const type2Str = (type: number) => { + switch (type) { + case nodeType.DEFAULT: + return 'no-border'; + case nodeType.RECT: + return 'rect'; + case nodeType.ROUNDED_RECT: + return 'rounded-rect'; + case nodeType.CIRCLE: + return 'circle'; + case nodeType.CLOUD: + return 'cloud'; + case nodeType.BANG: + return 'bang'; + case nodeType.HEXAGON: + return 'hexgon'; // cspell: disable-line + default: + return 'no-border'; + } +}; + +// Expose logger to grammar +const getLogger = () => log; +const getElementById = (id: number) => elements[id]; + +const db = { + clear, + addNode, + getSections, + getData, + nodeType, + getType, + setElementForId, + decorateNode, + type2Str, + getLogger, + getElementById, +} as const; + +export default db; diff --git a/packages/mermaid/src/diagrams/kanban/kanbanRenderer.ts b/packages/mermaid/src/diagrams/kanban/kanbanRenderer.ts new file mode 100644 index 0000000000..9bd3e07317 --- /dev/null +++ b/packages/mermaid/src/diagrams/kanban/kanbanRenderer.ts @@ -0,0 +1,87 @@ +import { getConfig } from '../../diagram-api/diagramAPI.js'; +import type { DrawDefinition } from '../../diagram-api/types.js'; +import { log } from '../../logger.js'; +import { selectSvgElement } from '../../rendering-util/selectSvgElement.js'; +import { setupGraphViewbox } from '../../setupGraphViewbox.js'; +import type { KanbanDB } from './kanbanTypes.js'; +import defaultConfig from '../../defaultConfig.js'; +import { insertCluster } from '../../rendering-util/rendering-elements/clusters.js'; +import { insertNode, positionNode } from '../../rendering-util/rendering-elements/nodes.js'; + +export const draw: DrawDefinition = async (text, id, _version, diagObj) => { + log.debug('Rendering kanban diagram\n' + text); + + const db = diagObj.db as KanbanDB; + const data4Layout = db.getData(); + + const conf = getConfig(); + conf.htmlLabels = false; + + const svg = selectSvgElement(id); + + // 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 sectionsElem = svg.append('g'); + sectionsElem.attr('class', 'sections'); + const nodesElem = svg.append('g'); + nodesElem.attr('class', 'items'); + const sections = data4Layout.nodes.filter((node) => node.isGroup); + let cnt = 0; + // TODO set padding + const padding = 10; + + const sectionObjects = []; + let maxLabelHeight = 25; + for (const section of sections) { + const WIDTH = conf?.kanban?.sectionWidth || 200; + // const top = (-WIDTH * 3) / 2 + 25; + // let y = top; + cnt = cnt + 1; + section.x = WIDTH * cnt + ((cnt - 1) * padding) / 2; + section.width = WIDTH; + section.y = 0; + section.height = WIDTH * 3; + section.rx = 5; + section.ry = 5; + + // Todo, use theme variable THEME_COLOR_LIMIT instead of 10 + section.cssClasses = section.cssClasses + ' section-' + cnt; + const sectionObj = await insertCluster(sectionsElem, section); + maxLabelHeight = Math.max(maxLabelHeight, sectionObj?.labelBBox?.height); + sectionObjects.push(sectionObj); + } + let i = 0; + for (const section of sections) { + const sectionObj = sectionObjects[i]; + i = i + 1; + const WIDTH = conf?.kanban?.sectionWidth || 200; + const top = (-WIDTH * 3) / 2 + maxLabelHeight; + let y = top; + const sectionItems = data4Layout.nodes.filter((node) => node.parentId === section.id); + for (const item of sectionItems) { + item.x = section.x; + item.width = WIDTH - 1.5 * padding; + const nodeEl = await insertNode(nodesElem, item, { config: conf }); + const bbox = nodeEl.node().getBBox(); + item.y = y + bbox.height / 2; + await positionNode(item); + y = item.y + bbox.height / 2 + padding / 2; + } + const rect = sectionObj.cluster.select('rect'); + const height = Math.max(y - top + 3 * padding, 50) + (maxLabelHeight - 25); + rect.attr('height', height); + } + + // Setup the view box and size of the svg element + setupGraphViewbox( + undefined, + svg, + conf.mindmap?.padding ?? defaultConfig.kanban.padding, + conf.mindmap?.useMaxWidth ?? defaultConfig.kanban.useMaxWidth + ); +}; + +export default { + draw, +}; diff --git a/packages/mermaid/src/diagrams/kanban/kanbanTypes.ts b/packages/mermaid/src/diagrams/kanban/kanbanTypes.ts new file mode 100644 index 0000000000..efcb61f98e --- /dev/null +++ b/packages/mermaid/src/diagrams/kanban/kanbanTypes.ts @@ -0,0 +1,3 @@ +import type kanbanDb from './kanbanDb.js'; + +export type KanbanDB = typeof kanbanDb; diff --git a/packages/mermaid/src/diagrams/kanban/parser/kanban.jison b/packages/mermaid/src/diagrams/kanban/parser/kanban.jison new file mode 100644 index 0000000000..6fb31bf0bb --- /dev/null +++ b/packages/mermaid/src/diagrams/kanban/parser/kanban.jison @@ -0,0 +1,166 @@ +/** mermaid + * https://knsv.github.io/mermaid + * (c) 2015 Knut Sveidqvist + * MIT license. + */ +%lex + +%options case-insensitive + +%{ + // Pre-lexer code can go here +%} +%x NODE +%x NSTR +%x NSTR2 +%x ICON +%x CLASS +%x shapeData +%x shapeDataStr +%x shapeDataEndBracket + +%% + +\@\{ { + // console.log('=> shapeData', yytext); + this.pushState("shapeData"); yytext=""; return 'SHAPE_DATA' } +["] { + // console.log('=> shapeDataStr', yytext); + this.pushState("shapeDataStr"); + return 'SHAPE_DATA'; + } +["] { + // console.log('shapeData <==', yytext); + this.popState(); return 'SHAPE_DATA'} +[^\"]+ { + // console.log('shapeData', yytext); + const re = /\n\s*/g; + yytext = yytext.replace(re,"
"); + return 'SHAPE_DATA'} +[^}^"]+ { + // console.log('shapeData', yytext); + return 'SHAPE_DATA'; + } +"}" { + // console.log('<== root', yytext) + this.popState(); + } +\s*\%\%.* {yy.getLogger().trace('Found comment',yytext); return 'SPACELINE';} +// \%\%[^\n]*\n /* skip comments */ +"kanban" {return 'KANBAN';} +":::" { this.begin('CLASS'); } +.+ { this.popState();return 'CLASS'; } +\n { this.popState();} +// [\s]*"::icon(" { this.begin('ICON'); } +"::icon(" { yy.getLogger().trace('Begin icon');this.begin('ICON'); } +[\s]+[\n] {yy.getLogger().trace('SPACELINE');return 'SPACELINE' /* skip all whitespace */ ;} +[\n]+ return 'NL'; +[^\)]+ { return 'ICON'; } +\) {yy.getLogger().trace('end icon');this.popState();} +"-)" { yy.getLogger().trace('Exploding node'); this.begin('NODE');return 'NODE_DSTART'; } +"(-" { yy.getLogger().trace('Cloud'); this.begin('NODE');return 'NODE_DSTART'; } +"))" { yy.getLogger().trace('Explosion Bang'); this.begin('NODE');return 'NODE_DSTART'; } +")" { yy.getLogger().trace('Cloud Bang'); this.begin('NODE');return 'NODE_DSTART'; } +"((" { this.begin('NODE');return 'NODE_DSTART'; } +"{{" { this.begin('NODE');return 'NODE_DSTART'; } +"(" { this.begin('NODE');return 'NODE_DSTART'; } +"[" { this.begin('NODE');return 'NODE_DSTART'; } +[\s]+ return 'SPACELIST' /* skip all whitespace */ ; +// !(-\() return 'NODE_ID'; +[^\(\[\n\)\{\}@]+ {return 'NODE_ID';} +<> return 'EOF'; +["][`] { this.begin("NSTR2");} +[^`"]+ { return "NODE_DESCR";} +[`]["] { this.popState();} +["] { yy.getLogger().trace('Starting NSTR');this.begin("NSTR");} +[^"]+ { yy.getLogger().trace('description:', yytext); return "NODE_DESCR";} +["] {this.popState();} +[\)]\) {this.popState();yy.getLogger().trace('node end ))');return "NODE_DEND";} +[\)] {this.popState();yy.getLogger().trace('node end )');return "NODE_DEND";} +[\]] {this.popState();yy.getLogger().trace('node end ...',yytext);return "NODE_DEND";} +"}}" {this.popState();yy.getLogger().trace('node end ((');return "NODE_DEND";} +"(-" {this.popState();yy.getLogger().trace('node end (-');return "NODE_DEND";} +"-)" {this.popState();yy.getLogger().trace('node end (-');return "NODE_DEND";} +"((" {this.popState();yy.getLogger().trace('node end ((');return "NODE_DEND";} +"(" {this.popState();yy.getLogger().trace('node end ((');return "NODE_DEND";} +[^\)\]\(\}]+ { yy.getLogger().trace('Long description:', yytext); return 'NODE_DESCR';} +.+(?!\(\() { yy.getLogger().trace('Long description:', yytext); return 'NODE_DESCR';} +// [\[] return 'NODE_START'; +// .+ return 'TXT' ; + +/lex + +%start start + +%% /* language grammar */ + +start +// %{ : info document 'EOF' { return yy; } } + : mindMap + | spaceLines mindMap + ; + +spaceLines + : SPACELINE + | spaceLines SPACELINE + | spaceLines NL + ; + +mindMap + : KANBAN document { return yy; } + | KANBAN NL document { return yy; } + ; + +stop + : NL {yy.getLogger().trace('Stop NL ');} + | EOF {yy.getLogger().trace('Stop EOF ');} + | SPACELINE + | stop NL {yy.getLogger().trace('Stop NL2 ');} + | stop EOF {yy.getLogger().trace('Stop EOF2 ');} + ; +document + : document statement stop + | statement stop + ; + +statement + : SPACELIST node shapeData { yy.getLogger().info('Node: ',$2.id);yy.addNode($1.length, $2.id, $2.descr, $2.type, $3); } + | SPACELIST node { yy.getLogger().info('Node: ',$2.id);yy.addNode($1.length, $2.id, $2.descr, $2.type); } + | SPACELIST ICON { yy.getLogger().trace('Icon: ',$2);yy.decorateNode({icon: $2}); } + | SPACELIST CLASS { yy.decorateNode({class: $2}); } + | SPACELINE { yy.getLogger().trace('SPACELIST');} + | node shapeData { yy.getLogger().trace('Node: ',$1.id);yy.addNode(0, $1.id, $1.descr, $1.type, $2); } + | node { yy.getLogger().trace('Node: ',$1.id);yy.addNode(0, $1.id, $1.descr, $1.type); } + | ICON { yy.decorateNode({icon: $1}); } + | CLASS { yy.decorateNode({class: $1}); } + | SPACELIST + ; + + + +node + :nodeWithId + |nodeWithoutId + ; + +nodeWithoutId + : NODE_DSTART NODE_DESCR NODE_DEND + { yy.getLogger().trace("node found ..", $1); $$ = { id: $2, descr: $2, type: yy.getType($1, $3) }; } + ; + +nodeWithId + : NODE_ID { $$ = { id: $1, descr: $1, type: 0 }; } + | NODE_ID NODE_DSTART NODE_DESCR NODE_DEND + { yy.getLogger().trace("node found ..", $1); $$ = { id: $1, descr: $3, type: yy.getType($2, $4) }; } + ; + +shapeData: + shapeData SHAPE_DATA + { $$ = $1 + $2; } + | SHAPE_DATA + { $$ = $1; } + ; + + + +%% diff --git a/packages/mermaid/src/diagrams/kanban/samples.md b/packages/mermaid/src/diagrams/kanban/samples.md new file mode 100644 index 0000000000..a75cfeeeff --- /dev/null +++ b/packages/mermaid/src/diagrams/kanban/samples.md @@ -0,0 +1,105 @@ +```mermaid +kanban + New + Sometimes wrong Shape type is highlighted + In progress + + +``` + +```mermaid +kanban + Todo + Create JISON + Update DB function + Create parsing tests + define getData + Create renderer + In progress + Design grammar + +``` + +Adding ID + +```mermaid +kanban + id1[Todo] + id2[Create JISON] + id3[Update DB function] + id4[Create parsing tests] + id5[define getData] + id6[Create renderer] + id7[In progress] + id8[Design grammar] + +``` + +Background color for section + +```mermaid +kanban + id1[Todo] + id2[Create JISON] + id3[Update DB function] + id4[Create parsing tests] + id5[define getData] + id6[Create renderer] + id7[In progress] + id8[Design grammar] + + style n2 stroke:#AA00FF,fill:#E1BEE7 +``` + +Background color for section + +```mermaid +kanban + id1[Todo] + id2[Create JISON] + id3[Update DB function] + id4[Create parsing tests] + id5[define getData] + id6[Create renderer] + id7[In progress] + id8[Design grammar] + + id2@{ + assigned: knsv + icon: heart + priority: high + descr: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + } + style n1 stroke:#AA00FF,fill:#E1BEE7 +``` + +Background color for section + +```mermaid +--- +config: + kanban: + showIds: true + fields: [[title],[description][id, assigned]] +--- +kanban + id1[Todo] + id2[Create JISON] + id3[Update DB function] + id4[Create parsing tests] + id5[define getData] + id6[Create renderer] + id7[In progress] + id8[Design grammar] + + id2@{ + assigned: knsv + icon: heart + priority: high + descr: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + } + style n1 stroke:#AA00FF,fill:#E1BEE7 +``` + +priority - dedicated +link - dedicated diff --git a/packages/mermaid/src/diagrams/kanban/styles.ts b/packages/mermaid/src/diagrams/kanban/styles.ts new file mode 100644 index 0000000000..8b40224b24 --- /dev/null +++ b/packages/mermaid/src/diagrams/kanban/styles.ts @@ -0,0 +1,109 @@ +// @ts-expect-error Incorrect khroma types +import { darken, lighten, isDark } from 'khroma'; +import type { DiagramStylesProvider } from '../../diagram-api/types.js'; + +const genSections: DiagramStylesProvider = (options) => { + let sections = ''; + + for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) { + options['lineColor' + i] = options['lineColor' + i] || options['cScaleInv' + i]; + if (isDark(options['lineColor' + i])) { + options['lineColor' + i] = lighten(options['lineColor' + i], 20); + } else { + options['lineColor' + i] = darken(options['lineColor' + i], 20); + } + } + + const adjuster = (color: string, level: number) => + options.darkMode ? darken(color, level) : lighten(color, level); + + for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) { + const sw = '' + (17 - 3 * i); + sections += ` + .section-${i - 1} rect, .section-${i - 1} path, .section-${i - 1} circle, .section-${ + i - 1 + } polygon, .section-${i - 1} path { + fill: ${adjuster(options['cScale' + i], 10)}; + stroke: ${adjuster(options['cScale' + i], 10)}; + + } + .section-${i - 1} text { + fill: ${options['cScaleLabel' + i]}; + } + .node-icon-${i - 1} { + font-size: 40px; + color: ${options['cScaleLabel' + i]}; + } + .section-edge-${i - 1}{ + stroke: ${options['cScale' + i]}; + } + .edge-depth-${i - 1}{ + stroke-width: ${sw}; + } + .section-${i - 1} line { + stroke: ${options['cScaleInv' + i]} ; + stroke-width: 3; + } + + .disabled, .disabled circle, .disabled text { + fill: lightgray; + } + .disabled text { + fill: #efefef; + } + + .node rect, + .node circle, + .node ellipse, + .node polygon, + .node path { + fill: ${options.background}; + stroke: ${options.nodeBorder}; + stroke-width: 1px; + } + + .kanban-ticket-link { + fill: ${options.background}; + stroke: ${options.nodeBorder}; + text-decoration: underline; + } + `; + } + return sections; +}; + +// TODO: These options seem incorrect. +const getStyles: DiagramStylesProvider = (options) => + ` + .edge { + stroke-width: 3; + } + ${genSections(options)} + .section-root rect, .section-root path, .section-root circle, .section-root polygon { + fill: ${options.git0}; + } + .section-root text { + fill: ${options.gitBranchLabel0}; + } + .icon-container { + height:100%; + display: flex; + justify-content: center; + align-items: center; + } + .edge { + fill: none; + } + .cluster-label, .label { + color: ${options.textColor}; + fill: ${options.textColor}; + } + .kanban-label { + dy: 1em; + alignment-baseline: middle; + text-anchor: middle; + dominant-baseline: middle; + text-align: center; + } +`; +export default getStyles; diff --git a/packages/mermaid/src/docs/.vitepress/config.ts b/packages/mermaid/src/docs/.vitepress/config.ts index 4dec8231f7..8a0225c56b 100644 --- a/packages/mermaid/src/docs/.vitepress/config.ts +++ b/packages/mermaid/src/docs/.vitepress/config.ts @@ -157,6 +157,7 @@ function sidebarSyntax() { { text: 'XY Chart 🔥', link: '/syntax/xyChart' }, { text: 'Block Diagram 🔥', link: '/syntax/block' }, { text: 'Packet 🔥', link: '/syntax/packet' }, + { text: 'Kanban 🔥', link: '/syntax/kanban' }, { text: 'Architecture 🔥', link: '/syntax/architecture' }, { text: 'Other Examples', link: '/syntax/examples' }, ], diff --git a/packages/mermaid/src/docs/syntax/classDiagram.md b/packages/mermaid/src/docs/syntax/classDiagram.md index 029d11b540..552670d3f8 100644 --- a/packages/mermaid/src/docs/syntax/classDiagram.md +++ b/packages/mermaid/src/docs/syntax/classDiagram.md @@ -277,6 +277,34 @@ And `Link` can be one of: | -- | Solid | | .. | Dashed | +### Lollipop Interfaces + +Classes can also be given a special relation type that defines a lollipop interface on the class. A lollipop interface is defined using the following syntax: + +- `bar ()-- foo` +- `foo --() bar` + +The interface (bar) with the lollipop connects to the class (foo). + +Note: Each interface that is defined is unique and is meant to not be shared between classes / have multiple edges connecting to it. + +```mermaid-example +classDiagram + bar ()-- foo +``` + +```mermaid-example +classDiagram + class Class01 { + int amount + draw() + } + Class01 --() bar + Class02 --() bar + + foo ()-- Class01 +``` + ## Define Namespace A namespace groups classes. @@ -518,10 +546,12 @@ Beginner's tip—a full example using interactive links in an HTML page: ## Styling -### Styling a node (v10.7.0+) +### Styling a node It is possible to apply specific styles such as a thicker border or a different background color to an individual node using the `style` keyword. +Note that notes and namespaces cannot be styled individually but do support themes. + ```mermaid-example classDiagram class Animal @@ -533,159 +563,108 @@ classDiagram #### Classes More convenient than defining the style every time is to define a class of styles and attach this class to the nodes that -should have a different look. This is done by predefining classes in css styles that can be applied from the graph definition using the `cssClass` statement or the `:::` short hand. +should have a different look. -```html - +A class definition looks like the example below: + +``` +classDef className fill:#f9f,stroke:#333,stroke-width:4px; +``` + +Also, it is possible to define style to multiple classes in one statement: + +``` +classDef firstClassName,secondClassName font-size:12pt; ``` -Then attaching that class to a specific node: +Attachment of a class to a node is done as per below: ``` - cssClass "nodeId1" styleClass; +cssClass "nodeId1" className; ``` It is also possible to attach a class to a list of nodes in one statement: ``` - cssClass "nodeId1,nodeId2" styleClass; +cssClass "nodeId1,nodeId2" className; ``` A shorter form of adding a class is to attach the classname to the node using the `:::` operator: ```mermaid-example classDiagram - class Animal:::styleClass + class Animal:::someclass + classDef someclass fill:#f96 ``` Or: ```mermaid-example classDiagram - class Animal:::styleClass { + class Animal:::someclass { -int sizeInFeet -canEat() } + classDef someclass fill:#f96 ``` -?> cssClasses cannot be added using this shorthand method at the same time as a relation statement. +### Default class -?> Due to limitations with existing markup for class diagrams, it is not currently possible to define css classes within the diagram itself. **_Coming soon!_** +If a class is named default it will be applied to all nodes. Specific styles and classes should be defined afterwards to override the applied default styling. -### Default Styles +``` +classDef default fill:#f9f,stroke:#333,stroke-width:4px; +``` -The main styling of the class diagram is done with a preset number of css classes. During rendering these classes are extracted from the file located at src/themes/class.scss. The classes used here are described below: +```mermaid-example +classDiagram + class Animal:::pink + class Mineral -| Class | Description | -| ------------------ | ----------------------------------------------------------------- | -| g.classGroup text | Styles for general class text | -| classGroup .title | Styles for general class title | -| g.classGroup rect | Styles for class diagram rectangle | -| g.classGroup line | Styles for class diagram line | -| .classLabel .box | Styles for class label box | -| .classLabel .label | Styles for class label text | -| composition | Styles for composition arrow head and arrow line | -| aggregation | Styles for aggregation arrow head and arrow line(dashed or solid) | -| dependency | Styles for dependency arrow head and arrow line | + classDef default fill:#f96,color:red + classDef pink color:#f9f +``` -#### Sample stylesheet +### CSS Classes -```scss -body { - background: white; -} +It is also possible to predefine classes in CSS styles that can be applied from the graph definition as in the example +below: -g.classGroup text { - fill: $nodeBorder; - stroke: none; - font-family: 'trebuchet ms', verdana, arial; - font-family: var(--mermaid-font-family); - font-size: 10px; +**Example style** - .title { - font-weight: bolder; +```html + +``` -#compositionEnd { - @include composition; -} +**Example definition** -@mixin aggregation { - fill: $nodeBkg; - stroke: $nodeBorder; - stroke-width: 1; -} +```mermaid-example +classDiagram + class Animal:::styleClass +``` -#aggregationStart { - @include aggregation; -} +> cssClasses cannot be added using this shorthand method at the same time as a relation statement. -#aggregationEnd { - @include aggregation; -} +## Configuration -#dependencyStart { - @include composition; -} +### Members Box -#dependencyEnd { - @include composition; -} +It is possible to hide the empty members box of a class node. -#extensionStart { - @include composition; -} +This is done by changing the **hideEmptyMembersBox** value of the class diagram configuration. For more information on how to edit the Mermaid configuration see the [configuration page.](https://mermaid.js.org/config/configuration.html) -#extensionEnd { - @include composition; -} +```mermaid-example +--- + config: + class: + hideEmptyMembersBox: true +--- +classDiagram + class Duck ``` - -## Configuration - -`Coming soon!` diff --git a/packages/mermaid/src/docs/syntax/kanban.md b/packages/mermaid/src/docs/syntax/kanban.md new file mode 100644 index 0000000000..4ef98fbacd --- /dev/null +++ b/packages/mermaid/src/docs/syntax/kanban.md @@ -0,0 +1,113 @@ +# Mermaid Kanban Diagram Documentation + +Mermaid’s Kanban diagram allows you to create visual representations of tasks moving through different stages of a workflow. This guide explains how to use the Kanban diagram syntax, based on the provided example. + +## Overview + +A Kanban diagram in Mermaid starts with the kanban keyword, followed by the definition of columns (stages) and tasks within those columns. + +```mermaid-example +kanban + column1[Column Title] + task1[Task Description] +``` + +## Defining Columns + +Columns represent the different stages in your workflow, such as “Todo,” “In Progress,” “Done,” etc. Each column is defined using a unique identifier and a title enclosed in square brackets. + +**Syntax:** + +``` +columnId[Column Title] +``` + +- columnId: A unique identifier for the column. +- [Column Title]: The title displayed on the column header. + +Like this `id1[Todo]` + +## Adding Tasks to Columns + +Tasks are listed under their respective columns with an indentation. Each task also has a unique identifier and a description enclosed in square brackets. + +**Syntax:** + +``` +taskId[Task Description] +``` + + • taskId: A unique identifier for the task. + • [Task Description]: The description of the task. + +**Example:** + +``` +docs[Create Documentation] +``` + +## Adding Metadata to Tasks + +You can include additional metadata for each task using the @{ ... } syntax. Metadata can contain key-value pairs like assigned, ticket, priority, etc. This will be rendered added to the rendering of the node. + +## Supported Metadata Keys + + • assigned: Specifies who is responsible for the task. + • ticket: Links the task to a ticket or issue number. + • priority: Indicates the urgency of the task. Allowed values: 'Very High', 'High', 'Low' and 'Very Low' + +```mermaid-example +kanban +todo[Todo] + id3[Update Database Function]@{ ticket: MC-2037, assigned: 'knsv', priority: 'High' } +``` + +## Configuration Options + +You can customize the Kanban diagram using a configuration block at the beginning of your markdown file. This is useful for setting global settings like a base URL for tickets. Currently there is one configuration option for kanban diagrams tacketBaseUrl. This can be set as in the the following example: + +```yaml +--- +config: + kanban: + ticketBaseUrl: 'https://yourproject.atlassian.net/browse/#TICKET#' +--- +``` + +When the kanban item has an assigned ticket number the ticket number in the diagram will have a link to an external system where the ticket is defined. The `ticketBaseUrl` sets the base URL to the external system and #TICKET# is replaced with the ticket value from task metadata to create a valid link. + +## Full Example + +Below is the full Kanban diagram based on the provided example: + +```mermaid-example +--- +config: + kanban: + ticketBaseUrl: 'https://mermaidchart.atlassian.net/browse/#TICKET#' +--- +kanban + Todo + [Create Documentation] + docs[Create Blog about the new diagram] + [In progress] + id6[Create renderer so that it works in all cases. We also add som extra text here for testing purposes. And some more just for the extra flare.] + id9[Ready for deploy] + id8[Design grammar]@{ assigned: 'knsv' } + id10[Ready for test] + id4[Create parsing tests]@{ ticket: MC-2038, assigned: 'K.Sveidqvist', priority: 'High' } + id66[last item]@{ priority: 'Very Low', assigned: 'knsv' } + id11[Done] + id5[define getData] + id2[Title of diagram is more than 100 chars when user duplicates diagram with 100 char]@{ ticket: MC-2036, priority: 'Very High'} + id3[Update DB function]@{ ticket: MC-2037, assigned: knsv, priority: 'High' } + + id12[Can't reproduce] + id3[Weird flickering in Firefox] +``` + +In conclusion, creating a Kanban diagram in Mermaid is a straightforward process that effectively visualizes your workflow. Start by using the kanban keyword to initiate the diagram. Define your columns with unique identifiers and titles to represent different stages of your project. Under each column, list your tasks—also with unique identifiers—and provide detailed descriptions as needed. Remember that proper indentation is crucial; tasks must be indented under their parent columns to maintain the correct structure. + +You can enhance your diagram by adding optional metadata to tasks using the @{ ... } syntax, which allows you to include additional context such as assignee, ticket numbers, and priority levels. For further customization, utilize the configuration block at the top of your file to set global options like ticketBaseUrl for linking tickets directly from your diagram. + +By adhering to these guidelines—ensuring unique identifiers, proper indentation, and utilizing metadata and configuration options—you can create a comprehensive and customized Kanban board that effectively maps out your project’s workflow using Mermaid. diff --git a/packages/mermaid/src/mermaidAPI.spec.ts b/packages/mermaid/src/mermaidAPI.spec.ts index f51bc0795a..5bd1b1dfcf 100644 --- a/packages/mermaid/src/mermaidAPI.spec.ts +++ b/packages/mermaid/src/mermaidAPI.spec.ts @@ -779,7 +779,7 @@ graph TD;A--x|text including URL space|B;`) // We have to have both the specific textDiagramType and the expected type name because the expected type may be slightly different than was is put in the diagram text (ex: in -v2 diagrams) const diagramTypesAndExpectations = [ { textDiagramType: 'C4Context', expectedType: 'c4' }, - { textDiagramType: 'classDiagram', expectedType: 'classDiagram' }, + { textDiagramType: 'classDiagram', expectedType: 'class' }, { textDiagramType: 'classDiagram-v2', expectedType: 'classDiagram' }, { textDiagramType: 'erDiagram', expectedType: 'er' }, { textDiagramType: 'graph', expectedType: 'flowchart-v2' }, diff --git a/packages/mermaid/src/rendering-util/handle-markdown-text.ts b/packages/mermaid/src/rendering-util/handle-markdown-text.ts index 4b6a04428d..1bff5a9776 100644 --- a/packages/mermaid/src/rendering-util/handle-markdown-text.ts +++ b/packages/mermaid/src/rendering-util/handle-markdown-text.ts @@ -85,6 +85,8 @@ export function markdownToHTML(markdown: string, { markdownAutoWrap }: MermaidCo return ''; } else if (node.type === 'html') { return `${node.text}`; + } else if (node.type === 'escape') { + return node.text; } return `Unsupported markdown: ${node.type}`; } diff --git a/packages/mermaid/src/rendering-util/rendering-elements/clusters.js b/packages/mermaid/src/rendering-util/rendering-elements/clusters.js index a62a920136..3bd9c9dc77 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/clusters.js +++ b/packages/mermaid/src/rendering-util/rendering-elements/clusters.js @@ -280,6 +280,117 @@ const roundedWithTitle = async (parent, node) => { return { cluster: shapeSvg, labelBBox: bbox }; }; +const kanbanSection = async (parent, node) => { + log.info('Creating subgraph rect for ', node.id, node); + const siteConfig = getConfig(); + const { themeVariables, handDrawnSeed } = siteConfig; + const { clusterBkg, clusterBorder } = themeVariables; + + const { labelStyles, nodeStyles, borderStyles, backgroundStyles } = styles2String(node); + + // Add outer g element + const shapeSvg = parent + .insert('g') + .attr('class', 'cluster ' + node.cssClasses) + .attr('id', node.id) + .attr('data-look', node.look); + + const useHtmlLabels = evaluate(siteConfig.flowchart.htmlLabels); + + // Create the label and insert it after the rect + const labelEl = shapeSvg.insert('g').attr('class', 'cluster-label '); + + const text = await createText(labelEl, node.label, { + style: node.labelStyle, + useHtmlLabels, + isNode: true, + width: node.width, + }); + + // Get the size of the label + let bbox = text.getBBox(); + + if (evaluate(siteConfig.flowchart.htmlLabels)) { + const div = text.children[0]; + const dv = select(text); + bbox = div.getBoundingClientRect(); + dv.attr('width', bbox.width); + dv.attr('height', bbox.height); + } + + const width = node.width <= bbox.width + node.padding ? bbox.width + node.padding : node.width; + if (node.width <= bbox.width + node.padding) { + node.diff = (width - node.width) / 2 - node.padding; + } else { + node.diff = -node.padding; + } + + const height = node.height; + const x = node.x - width / 2; + const y = node.y - height / 2; + + log.trace('Data ', node, JSON.stringify(node)); + let rect; + if (node.look === 'handDrawn') { + // @ts-ignore TODO: Fix rough typings + const rc = rough.svg(shapeSvg); + const options = userNodeOverrides(node, { + roughness: 0.7, + fill: clusterBkg, + // fill: 'red', + stroke: clusterBorder, + fillWeight: 4, + seed: handDrawnSeed, + }); + const roughNode = rc.path(createRoundedRectPathD(x, y, width, height, node.rx), options); + rect = shapeSvg.insert(() => { + log.debug('Rough node insert CXC', roughNode); + return roughNode; + }, ':first-child'); + // Should we affect the options instead of doing this? + rect.select('path:nth-child(2)').attr('style', borderStyles.join(';')); + rect.select('path').attr('style', backgroundStyles.join(';').replace('fill', 'stroke')); + } else { + // add the rect + rect = shapeSvg.insert('rect', ':first-child'); + // center the rect around its coordinate + rect + .attr('style', nodeStyles) + .attr('rx', node.rx) + .attr('ry', node.ry) + .attr('x', x) + .attr('y', y) + .attr('width', width) + .attr('height', height); + } + const { subGraphTitleTopMargin } = getSubGraphTitleMargins(siteConfig); + labelEl.attr( + 'transform', + // This puts the label on top of the box instead of inside it + `translate(${node.x - bbox.width / 2}, ${node.y - node.height / 2 + subGraphTitleTopMargin})` + ); + + if (labelStyles) { + const span = labelEl.select('span'); + if (span) { + span.attr('style', labelStyles); + } + } + // Center the label + + const rectBox = rect.node().getBBox(); + node.offsetX = 0; + node.width = rectBox.width; + node.height = rectBox.height; + // Used by layout engine to position subgraph in parent + node.offsetY = bbox.height - node.padding / 2; + + node.intersect = function (point) { + return intersectRect(node, point); + }; + + return { cluster: shapeSvg, labelBBox: bbox }; +}; const divider = (parent, node) => { const siteConfig = getConfig(); @@ -355,6 +466,7 @@ const shapes = { roundedWithTitle, noteGroup, divider, + kanbanSection, }; let clusterElems = new Map(); diff --git a/packages/mermaid/src/rendering-util/rendering-elements/edges.js b/packages/mermaid/src/rendering-util/rendering-elements/edges.js index 41368ee1e2..a6a7a55f77 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/edges.js +++ b/packages/mermaid/src/rendering-util/rendering-elements/edges.js @@ -463,15 +463,6 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod let lineData = points.filter((p) => !Number.isNaN(p.y)); lineData = fixCorners(lineData); - let lastPoint = lineData[lineData.length - 1]; - if (lineData.length > 1) { - lastPoint = lineData[lineData.length - 1]; - const secondLastPoint = lineData[lineData.length - 2]; - const diffX = (lastPoint.x - secondLastPoint.x) / 2; - const diffY = (lastPoint.y - secondLastPoint.y) / 2; - const midPoint = { x: secondLastPoint.x + diffX, y: secondLastPoint.y + diffY }; - lineData.splice(-1, 0, midPoint); - } let curve = curveBasis; if (edge.curve) { curve = edge.curve; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts index 162619d997..4f6459d852 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts @@ -57,6 +57,8 @@ import { triangle } from './shapes/triangle.js'; import { waveEdgedRectangle } from './shapes/waveEdgedRectangle.js'; import { waveRectangle } from './shapes/waveRectangle.js'; import { windowPane } from './shapes/windowPane.js'; +import { classBox } from './shapes/classBox.js'; +import { kanbanItem } from './shapes/kanbanItem.js'; type ShapeHandler = ( parent: D3Selection, @@ -447,6 +449,14 @@ export const shapesDefs = [ aliases: ['lined-document'], handler: linedWaveEdgedRect, }, + { + semanticName: 'Class Box', + name: 'Class Box', + shortName: 'classBox', + description: 'Class Box', + aliases: ['class-box'], + handler: classBox, + }, ] as const satisfies ShapeDefinition[]; const generateShapeMap = () => { @@ -467,7 +477,7 @@ const generateShapeMap = () => { icon, iconRounded, imageSquare, - + kanbanItem, anchor, } as const; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/classBox.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/classBox.ts new file mode 100644 index 0000000000..e35ee94abb --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/classBox.ts @@ -0,0 +1,207 @@ +import { updateNodeBounds } from './util.js'; +import { getConfig } from '../../../diagram-api/diagramAPI.js'; +import { select } from 'd3'; +import type { Node } from '../../types.js'; +import type { ClassNode } from '../../../diagrams/class/classTypes.js'; +import rough from 'roughjs'; +import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js'; +import intersect from '../intersect/index.js'; +import { textHelper } from '../../../diagrams/class/shapeUtil.js'; +import { evaluate } from '../../../diagrams/common/common.js'; +import type { D3Selection } from '../../../types.js'; + +export async function classBox(parent: D3Selection, node: Node) { + const config = getConfig(); + const PADDING = config.class!.padding ?? 12; + const GAP = PADDING; + const useHtmlLabels = node.useHtmlLabels ?? evaluate(config.htmlLabels) ?? true; + // Treat node as classNode + const classNode = node as unknown as ClassNode; + classNode.annotations = classNode.annotations ?? []; + classNode.members = classNode.members ?? []; + classNode.methods = classNode.methods ?? []; + + const { shapeSvg, bbox } = await textHelper(parent, node, config, useHtmlLabels, GAP); + + const { labelStyles, nodeStyles } = styles2String(node); + node.labelStyle = labelStyles; + + node.cssStyles = classNode.styles || ''; + + const styles = classNode.styles?.join(';') || nodeStyles || ''; + + if (!node.cssStyles) { + node.cssStyles = styles.replaceAll('!important', '').split(';'); + } + + const renderExtraBox = + classNode.members.length === 0 && + classNode.methods.length === 0 && + !config.class?.hideEmptyMembersBox; + + // Setup roughjs + // @ts-ignore TODO: Fix rough typings + const rc = rough.svg(shapeSvg); + const options = userNodeOverrides(node, {}); + + if (node.look !== 'handDrawn') { + options.roughness = 0; + options.fillStyle = 'solid'; + } + + const w = bbox.width; + let h = bbox.height; + if (classNode.members.length === 0 && classNode.methods.length === 0) { + h += GAP; + } else if (classNode.members.length > 0 && classNode.methods.length === 0) { + h += GAP * 2; + } + const x = -w / 2; + const y = -h / 2; + + // Create and center rectangle + const roughRect = rc.rectangle( + x - PADDING, + y - + PADDING - + (renderExtraBox + ? PADDING + : classNode.members.length === 0 && classNode.methods.length === 0 + ? -PADDING / 2 + : 0), + w + 2 * PADDING, + h + + 2 * PADDING + + (renderExtraBox + ? PADDING * 2 + : classNode.members.length === 0 && classNode.methods.length === 0 + ? -PADDING + : 0), + options + ); + + const rect = shapeSvg.insert(() => roughRect, ':first-child'); + rect.attr('class', 'basic label-container'); + const rectBBox = rect.node()!.getBBox(); + + // Rect is centered so now adjust labels. + // TODO: Fix types + shapeSvg.selectAll('.text').each((_: any, i: number, nodes: any) => { + const text = select(nodes[i]); + // Get the current transform attribute + const transform = text.attr('transform'); + // Initialize variables for the translation values + let translateY = 0; + // Check if the transform attribute exists + if (transform) { + const regex = RegExp(/translate\(([^,]+),([^)]+)\)/); + const translate = regex.exec(transform); + if (translate) { + translateY = parseFloat(translate[2]); + } + } + // Add to the y value + let newTranslateY = + translateY + + y + + PADDING - + (renderExtraBox + ? PADDING + : classNode.members.length === 0 && classNode.methods.length === 0 + ? -PADDING / 2 + : 0); + if (!useHtmlLabels) { + // Fix so non html labels are better centered. + // BBox of text seems to be slightly different when calculated so we offset + newTranslateY -= 4; + } + let newTranslateX = x; + if ( + text.attr('class').includes('label-group') || + text.attr('class').includes('annotation-group') + ) { + newTranslateX = -text.node()?.getBBox().width / 2 || 0; + shapeSvg.selectAll('text').each(function (_: any, i: number, nodes: any) { + if (window.getComputedStyle(nodes[i]).textAnchor === 'middle') { + newTranslateX = 0; + } + }); + } + // Set the updated transform attribute + text.attr('transform', `translate(${newTranslateX}, ${newTranslateY})`); + }); + + // Render divider lines. + const annotationGroupHeight = + (shapeSvg.select('.annotation-group').node() as SVGGraphicsElement).getBBox().height - + (renderExtraBox ? PADDING / 2 : 0) || 0; + const labelGroupHeight = + (shapeSvg.select('.label-group').node() as SVGGraphicsElement).getBBox().height - + (renderExtraBox ? PADDING / 2 : 0) || 0; + const membersGroupHeight = + (shapeSvg.select('.members-group').node() as SVGGraphicsElement).getBBox().height - + (renderExtraBox ? PADDING / 2 : 0) || 0; + // First line (under label) + if (classNode.members.length > 0 || classNode.methods.length > 0 || renderExtraBox) { + const roughLine = rc.line( + rectBBox.x, + annotationGroupHeight + labelGroupHeight + y + PADDING, + rectBBox.x + rectBBox.width, + annotationGroupHeight + labelGroupHeight + y + PADDING, + options + ); + const line = shapeSvg.insert(() => roughLine); + line.attr('class', 'divider').attr('style', styles); + } + + // Second line (under members) + if (renderExtraBox || classNode.members.length > 0 || classNode.methods.length > 0) { + const roughLine = rc.line( + rectBBox.x, + annotationGroupHeight + labelGroupHeight + membersGroupHeight + y + GAP * 2 + PADDING, + rectBBox.x + rectBBox.width, + annotationGroupHeight + labelGroupHeight + membersGroupHeight + y + PADDING + GAP * 2, + options + ); + const line = shapeSvg.insert(() => roughLine); + line.attr('class', 'divider').attr('style', styles); + } + + /// Apply styles /// + if (classNode.look !== 'handDrawn') { + shapeSvg.selectAll('path').attr('style', styles); + } + // Apply other styles like stroke-width and stroke-dasharray to border (not background of shape) + rect.select(':nth-child(2)').attr('style', styles); + // Divider lines + shapeSvg.selectAll('.divider').select('path').attr('style', styles); + // Text elements + if (node.labelStyle) { + shapeSvg.selectAll('span').attr('style', node.labelStyle); + } else { + shapeSvg.selectAll('span').attr('style', styles); + } + // SVG text uses fill not color + if (!useHtmlLabels) { + // We just want to apply color to the text + const colorRegex = RegExp(/color\s*:\s*([^;]*)/); + const match = colorRegex.exec(styles); + if (match) { + const colorStyle = match[0].replace('color', 'fill'); + shapeSvg.selectAll('tspan').attr('style', colorStyle); + } else if (labelStyles) { + const match = colorRegex.exec(labelStyles); + if (match) { + const colorStyle = match[0].replace('color', 'fill'); + shapeSvg.selectAll('tspan').attr('style', colorStyle); + } + } + } + + updateNodeBounds(node, rect); + node.intersect = function (point) { + return intersect.rect(node, point); + }; + + return shapeSvg; +} diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/kanbanItem.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/kanbanItem.ts new file mode 100644 index 0000000000..61dc3f85da --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/kanbanItem.ts @@ -0,0 +1,148 @@ +import { labelHelper, insertLabel, updateNodeBounds, getNodeClasses } from './util.js'; +import intersect from '../intersect/index.js'; +import type { SVG } from '../../../diagram-api/types.js'; +import type { Node, KanbanNode, ShapeRenderOptions } from '../../types.js'; +import { createRoundedRectPathD } from './roundedRectPath.js'; +import { userNodeOverrides, styles2String } from './handDrawnShapeStyles.js'; +import rough from 'roughjs'; + +const colorFromPriority = (priority: KanbanNode['priority']) => { + switch (priority) { + case 'Very High': + return 'red'; + case 'High': + return 'orange'; + case 'Low': + return 'blue'; + case 'Very Low': + return 'lightblue'; + } +}; +export const kanbanItem = async (parent: SVG, node: Node, { config }: ShapeRenderOptions) => { + const unknownNode = node as unknown; + const kanbanNode = unknownNode as KanbanNode; + const { labelStyles, nodeStyles } = styles2String(kanbanNode); + kanbanNode.labelStyle = labelStyles; + + const labelPaddingX = 10; + const orgWidth = kanbanNode.width; + kanbanNode.width = (kanbanNode.width ?? 200) - 10; + + const { + shapeSvg, + bbox, + label: labelElTitle, + } = await labelHelper(parent, kanbanNode, getNodeClasses(kanbanNode)); + const padding = kanbanNode.padding || 10; + + let ticketUrl = ''; + let link; + + if (kanbanNode.ticket && config?.kanban?.ticketBaseUrl) { + ticketUrl = config?.kanban?.ticketBaseUrl.replace('#TICKET#', kanbanNode.ticket); + link = shapeSvg + .insert('svg:a', ':first-child') + .attr('class', 'kanban-ticket-link') + .attr('xlink:href', ticketUrl) + .attr('target', '_blank'); + } + + const options = { + useHtmlLabels: kanbanNode.useHtmlLabels, + labelStyle: kanbanNode.labelStyle, + width: kanbanNode.width, + icon: kanbanNode.icon, + img: kanbanNode.img, + padding: kanbanNode.padding, + centerLabel: false, + }; + const { label: labelEl, bbox: bbox2 } = await insertLabel( + link ? link : shapeSvg, + kanbanNode.ticket || '', + options + ); + const { label: labelElAssigned, bbox: bboxAssigned } = await insertLabel( + shapeSvg, + kanbanNode.assigned || '', + options + ); + kanbanNode.width = orgWidth; + const labelPaddingY = 10; + const totalWidth = kanbanNode?.width || 0; + const heightAdj = Math.max(bbox2.height, bboxAssigned.height) / 2; + const totalHeight = + Math.max(bbox.height + labelPaddingY * 2, kanbanNode?.height || 0) + heightAdj; + const x = -totalWidth / 2; + const y = -totalHeight / 2; + labelElTitle.attr( + 'transform', + 'translate(' + (padding - totalWidth / 2) + ', ' + (-heightAdj - bbox.height / 2) + ')' + ); + labelEl.attr( + 'transform', + 'translate(' + (padding - totalWidth / 2) + ', ' + (-heightAdj + bbox.height / 2) + ')' + ); + labelElAssigned.attr( + 'transform', + 'translate(' + + (padding + totalWidth / 2 - bboxAssigned.width - 2 * labelPaddingX) + + ', ' + + (-heightAdj + bbox.height / 2) + + ')' + ); + + let rect; + + const { rx, ry } = kanbanNode; + const { cssStyles } = kanbanNode; + + if (kanbanNode.look === 'handDrawn') { + // @ts-ignore TODO: Fix rough typings + const rc = rough.svg(shapeSvg); + const options = userNodeOverrides(kanbanNode, {}); + + const roughNode = + rx || ry + ? rc.path(createRoundedRectPathD(x, y, totalWidth, totalHeight, rx || 0), options) + : rc.rectangle(x, y, totalWidth, totalHeight, options); + + rect = shapeSvg.insert(() => roughNode, ':first-child'); + rect.attr('class', 'basic label-container').attr('style', cssStyles); + } else { + rect = shapeSvg.insert('rect', ':first-child'); + + rect + .attr('class', 'basic label-container __APA__') + .attr('style', nodeStyles) + .attr('rx', rx) + .attr('ry', ry) + .attr('x', x) + .attr('y', y) + .attr('width', totalWidth) + .attr('height', totalHeight); + if (kanbanNode.priority) { + const line = shapeSvg.append('line', ':first-child'); + const lineX = x + 2; + + const y1 = y + Math.floor((rx ?? 0) / 2); + const y2 = y + totalHeight - Math.floor((rx ?? 0) / 2); + line + .attr('x1', lineX) + .attr('y1', y1) + .attr('x2', lineX) + .attr('y2', y2) + + .attr('stroke-width', '4') + .attr('stroke', colorFromPriority(kanbanNode.priority)); + } + } + + updateNodeBounds(kanbanNode, rect); + kanbanNode.height = totalHeight; + + kanbanNode.intersect = function (point) { + return intersect.rect(kanbanNode, point); + }; + + return shapeSvg; +}; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/util.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/util.ts index 883129e9a8..6a9db8c222 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/util.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/util.ts @@ -10,7 +10,8 @@ import type { D3Selection, Point } from '../../../types.js'; export const labelHelper = async ( parent: D3Selection, node: Node, - _classes?: string + _classes?: string, + _shapeSvg?: D3Selection ) => { let cssClasses; const useHtmlLabels = node.useHtmlLabels || evaluate(getConfig()?.flowchart?.htmlLabels); @@ -21,10 +22,12 @@ export const labelHelper = async ( } // Add outer g element - const shapeSvg = parent - .insert('g') - .attr('class', cssClasses) - .attr('id', node.domId || node.id); + const shapeSvg = _shapeSvg + ? _shapeSvg + : parent + .insert('g') + .attr('class', cssClasses) + .attr('id', node.domId || node.id); // Create the label and insert it after the rect const labelEl = shapeSvg @@ -116,7 +119,56 @@ export const labelHelper = async ( labelEl.insert('rect', ':first-child'); return { shapeSvg, bbox, halfPadding, label: labelEl }; }; +export const insertLabel = async ( + parent: D3Selection, + label: string, + options: { + labelStyle?: string | undefined; + icon?: boolean | undefined; + img?: string | undefined; + useHtmlLabels?: boolean | undefined; + padding: number; + width?: number | undefined; + centerLabel?: boolean | undefined; + addSvgBackground?: boolean | undefined; + } +) => { + const useHtmlLabels = options.useHtmlLabels || evaluate(getConfig()?.flowchart?.htmlLabels); + + // Create the label and insert it after the rect + const labelEl = parent.insert('g').attr('class', 'label').attr('style', options.labelStyle); + + const text = await createText(labelEl, sanitizeText(decodeEntities(label), getConfig()), { + useHtmlLabels, + width: options.width || getConfig()?.flowchart?.wrappingWidth, + style: options.labelStyle, + addSvgBackground: !!options.icon || !!options.img, + }); + // Get the size of the label + let bbox = text.getBBox(); + const halfPadding = options.padding / 2; + + if (evaluate(getConfig()?.flowchart?.htmlLabels)) { + const div = text.children[0]; + const dv = select(text); + bbox = div.getBoundingClientRect(); + dv.attr('width', bbox.width); + dv.attr('height', bbox.height); + } + + // Center the label + if (useHtmlLabels) { + labelEl.attr('transform', 'translate(' + -bbox.width / 2 + ', ' + -bbox.height / 2 + ')'); + } else { + labelEl.attr('transform', 'translate(' + 0 + ', ' + -bbox.height / 2 + ')'); + } + if (options.centerLabel) { + labelEl.attr('transform', 'translate(' + -bbox.width / 2 + ', ' + -bbox.height / 2 + ')'); + } + labelEl.insert('rect', ':first-child'); + return { shapeSvg: parent, bbox, halfPadding, label: labelEl }; +}; export const updateNodeBounds = ( node: Node, // D3Selection is for the roughjs case, D3Selection is for the non-roughjs case diff --git a/packages/mermaid/src/rendering-util/types.ts b/packages/mermaid/src/rendering-util/types.ts index 6f16571690..e49218f711 100644 --- a/packages/mermaid/src/rendering-util/types.ts +++ b/packages/mermaid/src/rendering-util/types.ts @@ -96,6 +96,9 @@ export interface Edge { stroke?: string; text?: string; type: string; + // Class Diagram specific properties + startLabelRight?: string; + endLabelLeft?: string; // Rendering specific properties curve?: string; labelpos?: string; @@ -150,3 +153,12 @@ export interface ShapeRenderOptions { /** Some shapes render differently if a diagram has a direction `LR` */ dir?: Node['dir']; } + +export interface KanbanNode extends Node { + // Kanban specif data + priority?: 'Very High' | 'High' | 'Medium' | 'Low' | 'Very Low'; + ticket?: string; + assigned?: string; + icon?: string; + level: number; +} diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index a7b3549ebf..e1014e889f 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -48,6 +48,7 @@ required: - requirement - architecture - mindmap + - kanban - gitGraph - c4 - sankey @@ -279,6 +280,8 @@ properties: $ref: '#/$defs/ArchitectureDiagramConfig' mindmap: $ref: '#/$defs/MindmapDiagramConfig' + kanban: + $ref: '#/$defs/KanbanDiagramConfig' gitGraph: $ref: '#/$defs/GitGraphDiagramConfig' c4: @@ -964,6 +967,23 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file) type: number default: 200 + KanbanDiagramConfig: + title: Kanban Diagram Config + allOf: [{ $ref: '#/$defs/BaseDiagramConfig' }] + description: The object containing configurations specific for kanban diagrams + type: object + unevaluatedProperties: false + properties: + padding: + type: number + default: 8 + sectionWidth: + type: number + default: 200 + ticketBaseUrl: + type: string + default: '' + PieDiagramConfig: title: Pie Diagram Config allOf: [{ $ref: '#/$defs/BaseDiagramConfig' }] @@ -1428,6 +1448,9 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file) htmlLabels: type: boolean default: false + hideEmptyMembersBox: + type: boolean + default: false JourneyDiagramConfig: title: Journey Diagram Config diff --git a/packages/mermaid/src/types.ts b/packages/mermaid/src/types.ts index 8912317728..5587ca3f4d 100644 --- a/packages/mermaid/src/types.ts +++ b/packages/mermaid/src/types.ts @@ -8,6 +8,9 @@ export interface NodeMetaData { w?: string; h?: string; constraint?: 'on' | 'off'; + priority: 'Very High' | 'High' | 'Medium' | 'Low' | 'Very Low'; + assigned?: string; + ticket?: string; } import type { MermaidConfig } from './config.type.js'; diff --git a/packages/mermaid/src/utils.ts b/packages/mermaid/src/utils.ts index f523197ae9..c1d6748344 100644 --- a/packages/mermaid/src/utils.ts +++ b/packages/mermaid/src/utils.ts @@ -824,6 +824,7 @@ export const insertTitle = ( parent .append('text') .text(title) + .attr('text-anchor', 'middle') .attr('x', bounds.x + bounds.width / 2) .attr('y', -titleTopMargin) .attr('class', cssClass); diff --git a/packages/mermaid/src/utils/lineWithOffset.ts b/packages/mermaid/src/utils/lineWithOffset.ts index 8e7c544246..800a5ffaf7 100644 --- a/packages/mermaid/src/utils/lineWithOffset.ts +++ b/packages/mermaid/src/utils/lineWithOffset.ts @@ -52,18 +52,15 @@ export const getLineFunctionsWithOffset = ( data: (Point | [number, number])[] ) { let offset = 0; + const DIRECTION = + pointTransformer(data[0]).x < pointTransformer(data[data.length - 1]).x ? 'left' : 'right'; if (i === 0 && Object.hasOwn(markerOffsets, edge.arrowTypeStart)) { - // Handle first point - // Calculate the angle and delta between the first two points const { angle, deltaX } = calculateDeltaAndAngle(data[0], data[1]); - // Calculate the offset based on the angle and the marker's dimensions offset = markerOffsets[edge.arrowTypeStart as keyof typeof markerOffsets] * Math.cos(angle) * (deltaX >= 0 ? 1 : -1); } else if (i === data.length - 1 && Object.hasOwn(markerOffsets, edge.arrowTypeEnd)) { - // Handle last point - // Calculate the angle and delta between the last two points const { angle, deltaX } = calculateDeltaAndAngle( data[data.length - 1], data[data.length - 2] @@ -73,6 +70,41 @@ export const getLineFunctionsWithOffset = ( Math.cos(angle) * (deltaX >= 0 ? 1 : -1); } + + const differenceToEnd = Math.abs( + pointTransformer(d).x - pointTransformer(data[data.length - 1]).x + ); + const differenceInYEnd = Math.abs( + pointTransformer(d).y - pointTransformer(data[data.length - 1]).y + ); + const differenceToStart = Math.abs(pointTransformer(d).x - pointTransformer(data[0]).x); + const differenceInYStart = Math.abs(pointTransformer(d).y - pointTransformer(data[0]).y); + const startMarkerHeight = markerOffsets[edge.arrowTypeStart as keyof typeof markerOffsets]; + const endMarkerHeight = markerOffsets[edge.arrowTypeEnd as keyof typeof markerOffsets]; + const extraRoom = 1; + + // Adjust the offset if the difference is smaller than the marker height + if ( + differenceToEnd < endMarkerHeight && + differenceToEnd > 0 && + differenceInYEnd < endMarkerHeight + ) { + let adjustment = endMarkerHeight + extraRoom - differenceToEnd; + adjustment *= DIRECTION === 'right' ? -1 : 1; + // Adjust the offset by the amount needed to fit the marker + offset -= adjustment; + } + + if ( + differenceToStart < startMarkerHeight && + differenceToStart > 0 && + differenceInYStart < startMarkerHeight + ) { + let adjustment = startMarkerHeight + extraRoom - differenceToStart; + adjustment *= DIRECTION === 'right' ? -1 : 1; + offset += adjustment; + } + return pointTransformer(d).x + offset; }, y: function ( @@ -81,8 +113,9 @@ export const getLineFunctionsWithOffset = ( i: number, data: (Point | [number, number])[] ) { - // Same handling as X above let offset = 0; + const DIRECTION = + pointTransformer(data[0]).y < pointTransformer(data[data.length - 1]).y ? 'down' : 'up'; if (i === 0 && Object.hasOwn(markerOffsets, edge.arrowTypeStart)) { const { angle, deltaY } = calculateDeltaAndAngle(data[0], data[1]); offset = @@ -99,6 +132,40 @@ export const getLineFunctionsWithOffset = ( Math.abs(Math.sin(angle)) * (deltaY >= 0 ? 1 : -1); } + + const differenceToEnd = Math.abs( + pointTransformer(d).y - pointTransformer(data[data.length - 1]).y + ); + const differenceInXEnd = Math.abs( + pointTransformer(d).x - pointTransformer(data[data.length - 1]).x + ); + const differenceToStart = Math.abs(pointTransformer(d).y - pointTransformer(data[0]).y); + const differenceInXStart = Math.abs(pointTransformer(d).x - pointTransformer(data[0]).x); + const startMarkerHeight = markerOffsets[edge.arrowTypeStart as keyof typeof markerOffsets]; + const endMarkerHeight = markerOffsets[edge.arrowTypeEnd as keyof typeof markerOffsets]; + const extraRoom = 1; + + // Adjust the offset if the difference is smaller than the marker height + if ( + differenceToEnd < endMarkerHeight && + differenceToEnd > 0 && + differenceInXEnd < endMarkerHeight + ) { + let adjustment = endMarkerHeight + extraRoom - differenceToEnd; + adjustment *= DIRECTION === 'up' ? -1 : 1; + // Adjust the offset by the amount needed to fit the marker + offset -= adjustment; + } + + if ( + differenceToStart < startMarkerHeight && + differenceToStart > 0 && + differenceInXStart < startMarkerHeight + ) { + let adjustment = startMarkerHeight + extraRoom - differenceToStart; + adjustment *= DIRECTION === 'up' ? -1 : 1; + offset += adjustment; + } return pointTransformer(d).y + offset; }, }; diff --git a/packages/mermaid/tsconfig.json b/packages/mermaid/tsconfig.json index 66c6600f63..447a5bb0da 100644 --- a/packages/mermaid/tsconfig.json +++ b/packages/mermaid/tsconfig.json @@ -9,6 +9,7 @@ "./src/**/*.ts", "./package.json", "src/diagrams/gantt/ganttDb.js", - "src/diagrams/git/gitGraphRenderer.js" + "src/diagrams/git/gitGraphRenderer.js", + "src/diagrams/class/classRenderer.js" ] }