diff --git a/.changeset/hungry-baths-glow.md b/.changeset/hungry-baths-glow.md new file mode 100644 index 000000000..b3084bcab --- /dev/null +++ b/.changeset/hungry-baths-glow.md @@ -0,0 +1,5 @@ +--- +'mermaid': minor +--- + +feat: Added support for new participant types (`actor`, `boundary`, `control`, `entity`, `database`, `collections`, `queue`) in `sequenceDiagram`. diff --git a/cypress/integration/rendering/sequencediagram-v2.spec.js b/cypress/integration/rendering/sequencediagram-v2.spec.js new file mode 100644 index 000000000..f1c2aafbd --- /dev/null +++ b/cypress/integration/rendering/sequencediagram-v2.spec.js @@ -0,0 +1,659 @@ +import { imgSnapshotTest, renderGraph } from '../../helpers/util.ts'; + +const looks = ['classic']; +const participantTypes = [ + { type: 'participant', display: 'participant' }, + { type: 'actor', display: 'actor' }, + { type: 'boundary', display: 'boundary' }, + { type: 'control', display: 'control' }, + { type: 'entity', display: 'entity' }, + { type: 'database', display: 'database' }, + { type: 'collections', display: 'collections' }, + { type: 'queue', display: 'queue' }, +]; + +const restrictedTypes = ['boundary', 'control', 'entity', 'database', 'collections', 'queue']; + +const interactionTypes = ['->>', '-->>', '->', '-->', '-x', '--x', '->>+', '-->>+']; + +const notePositions = ['left of', 'right of', 'over']; + +function getParticipantLine(name, type, alias) { + if (restrictedTypes.includes(type)) { + return ` participant ${name}@{ "type" : "${type}" }\n`; + } else if (alias) { + return ` participant ${name}@{ "type" : "${type}" } \n`; + } else { + return ` participant ${name}@{ "type" : "${type}" }\n`; + } +} + +looks.forEach((look) => { + describe(`Sequence Diagram Tests - ${look} look`, () => { + it('should render all participant types', () => { + let diagramCode = `sequenceDiagram\n`; + participantTypes.forEach((pt, index) => { + const name = `${pt.display}${index}`; + diagramCode += getParticipantLine(name, pt.type); + }); + for (let i = 0; i < participantTypes.length - 1; i++) { + diagramCode += ` ${participantTypes[i].display}${i} ->> ${participantTypes[i + 1].display}${i + 1}: Message ${i}\n`; + } + imgSnapshotTest(diagramCode, { look, sequence: { diagramMarginX: 50, diagramMarginY: 10 } }); + }); + + it('should render all interaction types', () => { + let diagramCode = `sequenceDiagram\n`; + diagramCode += getParticipantLine('A', 'actor'); + diagramCode += getParticipantLine('B', 'boundary'); + interactionTypes.forEach((interaction, index) => { + diagramCode += ` A ${interaction} B: ${interaction} message ${index}\n`; + }); + imgSnapshotTest(diagramCode, { look }); + }); + + it('should render participant creation and destruction', () => { + let diagramCode = `sequenceDiagram\n`; + participantTypes.forEach((pt, index) => { + const name = `${pt.display}${index}`; + diagramCode += getParticipantLine('A', pt.type); + diagramCode += getParticipantLine('B', pt.type); + diagramCode += ` create participant ${name}@{ "type" : "${pt.type}" }\n`; + diagramCode += ` A ->> ${name}: Hello ${pt.display}\n`; + if (index % 2 === 0) { + diagramCode += ` destroy ${name}\n`; + } + }); + imgSnapshotTest(diagramCode, { look }); + }); + + it('should render notes in all positions', () => { + let diagramCode = `sequenceDiagram\n`; + diagramCode += getParticipantLine('A', 'actor'); + diagramCode += getParticipantLine('B', 'boundary'); + notePositions.forEach((position, index) => { + diagramCode += ` Note ${position} A: Note ${position} ${index}\n`; + }); + diagramCode += ` A ->> B: Message with notes\n`; + imgSnapshotTest(diagramCode, { look }); + }); + + it('should render parallel interactions', () => { + let diagramCode = `sequenceDiagram\n`; + participantTypes.slice(0, 4).forEach((pt, index) => { + diagramCode += getParticipantLine(`${pt.display}${index}`, pt.type); + }); + diagramCode += ` par Parallel actions\n`; + for (let i = 0; i < 3; i += 2) { + diagramCode += ` ${participantTypes[i].display}${i} ->> ${participantTypes[i + 1].display}${i + 1}: Message ${i}\n`; + if (i < participantTypes.length - 2) { + diagramCode += ` and\n`; + } + } + diagramCode += ` end\n`; + imgSnapshotTest(diagramCode, { look }); + }); + + it('should render alternative flows', () => { + let diagramCode = `sequenceDiagram\n`; + diagramCode += getParticipantLine('A', 'actor'); + diagramCode += getParticipantLine('B', 'boundary'); + diagramCode += ` alt Successful case\n`; + diagramCode += ` A ->> B: Request\n`; + diagramCode += ` B -->> A: Success\n`; + diagramCode += ` else Failure case\n`; + diagramCode += ` A ->> B: Request\n`; + diagramCode += ` B --x A: Failure\n`; + diagramCode += ` end\n`; + imgSnapshotTest(diagramCode, { look }); + }); + + it('should render loops', () => { + let diagramCode = `sequenceDiagram\n`; + participantTypes.slice(0, 3).forEach((pt, index) => { + diagramCode += getParticipantLine(`${pt.display}${index}`, pt.type); + }); + diagramCode += ` loop For each participant\n`; + for (let i = 0; i < 3; i++) { + diagramCode += ` ${participantTypes[0].display}0 ->> ${participantTypes[1].display}1: Message ${i}\n`; + } + diagramCode += ` end\n`; + imgSnapshotTest(diagramCode, { look }); + }); + + it('should render boxes around groups', () => { + let diagramCode = `sequenceDiagram\n`; + diagramCode += ` box Group 1\n`; + participantTypes.slice(0, 3).forEach((pt, index) => { + diagramCode += ` ${getParticipantLine(`${pt.display}${index}`, pt.type)}`; + }); + diagramCode += ` end\n`; + diagramCode += ` box rgb(200,220,255) Group 2\n`; + participantTypes.slice(3, 6).forEach((pt, index) => { + diagramCode += ` ${getParticipantLine(`${pt.display}${index}`, pt.type)}`; + }); + diagramCode += ` end\n`; + diagramCode += ` ${participantTypes[0].display}0 ->> ${participantTypes[3].display}0: Cross-group message\n`; + imgSnapshotTest(diagramCode, { look }); + }); + + it('should render with different font settings', () => { + let diagramCode = `sequenceDiagram\n`; + participantTypes.slice(0, 3).forEach((pt, index) => { + diagramCode += getParticipantLine(`${pt.display}${index}`, pt.type); + }); + diagramCode += ` ${participantTypes[0].display}0 ->> ${participantTypes[1].display}1: Regular message\n`; + diagramCode += ` Note right of ${participantTypes[1].display}1: Regular note\n`; + imgSnapshotTest(diagramCode, { + look, + sequence: { + actorFontFamily: 'courier', + actorFontSize: 14, + messageFontFamily: 'Arial', + messageFontSize: 12, + noteFontFamily: 'times', + noteFontSize: 16, + noteAlign: 'left', + }, + }); + }); + }); +}); + +// Additional tests for specific combinations +describe('Sequence Diagram Special Cases', () => { + it('should render complex sequence with all features', () => { + const diagramCode = ` + sequenceDiagram + box rgb(200,220,255) Authentication + actor User + participant LoginUI@{ "type": "boundary" } + participant AuthService@{ "type": "control" } + participant UserDB@{ "type": "database" } + end + + box rgb(200,255,220) Order Processing + participant Order@{ "type": "entity" } + participant OrderQueue@{ "type": "queue" } + participant AuditLogs@{ "type": "collections" } + end + + User ->> LoginUI: Enter credentials + LoginUI ->> AuthService: Validate + AuthService ->> UserDB: Query user + UserDB -->> AuthService: User data + alt Valid credentials + AuthService -->> LoginUI: Success + LoginUI -->> User: Welcome + + par Place order + User ->> Order: New order + Order ->> OrderQueue: Process + and + Order ->> AuditLogs: Record + end + + loop Until confirmed + OrderQueue ->> Order: Update status + Order -->> User: Notification + end + else Invalid credentials + AuthService --x LoginUI: Failure + LoginUI --x User: Retry + end + `; + imgSnapshotTest(diagramCode, {}); + }); + + it('should render with wrapped messages and notes', () => { + const diagramCode = ` + sequenceDiagram + participant A + participant B + + A ->> B: This is a very long message that should wrap properly in the diagram rendering + Note over A,B: This is a very long note that should also wrap properly when rendered in the diagram + + par Wrapped parallel + A ->> B: Parallel message 1
with explicit line break + and + B ->> A: Parallel message 2
with explicit line break + end + + loop Wrapped loop + Note right of B: This is a long note
in a loop + A ->> B: Message in loop + end + `; + imgSnapshotTest(diagramCode, { sequence: { wrap: true } }); + }); + describe('Sequence Diagram Rendering with Different Participant Types', () => { + it('should render a sequence diagram with various participant types', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant User@{ "type": "actor" } + participant AuthService@{ "type": "control" } + participant UI@{ "type": "boundary" } + participant OrderController@{ "type": "control" } + participant Product@{ "type": "entity" } + participant MongoDB@{ "type": "database" } + participant Products@{ "type": "collections" } + participant OrderQueue@{ "type": "queue" } + User ->> UI: Login request + UI ->> AuthService: Validate credentials + AuthService -->> UI: Authentication token + UI ->> OrderController: Place order + OrderController ->> Product: Check availability + Product -->> OrderController: Available + OrderController ->> MongoDB: Save order + MongoDB -->> OrderController: Order saved + OrderController ->> OrderQueue: Process payment + OrderQueue -->> User: Order confirmation + ` + ); + }); + + it('should render participant creation and destruction with different types', () => { + imgSnapshotTest(` + sequenceDiagram + participant Alice@{ "type" : "boundary" } + Alice->>Bob: Hello Bob, how are you ? + Bob->>Alice: Fine, thank you. And you? + create participant Carl@{ "type" : "control" } + Alice->>Carl: Hi Carl! + create actor D as Donald + Carl->>D: Hi! + destroy Carl + Alice-xCarl: We are too many + destroy Bob + Bob->>Alice: I agree + `); + }); + + it('should handle complex interactions between different participant types', () => { + imgSnapshotTest( + ` + sequenceDiagram + box rgb(200,220,255) Authentication + participant User@{ "type": "actor" } + participant LoginUI@{ "type": "boundary" } + participant AuthService@{ "type": "control" } + participant UserDB@{ "type": "database" } + end + + box rgb(200,255,220) Order Processing + participant Order@{ "type": "entity" } + participant OrderQueue@{ "type": "queue" } + participant AuditLogs@{ "type": "collections" } + end + + User ->> LoginUI: Enter credentials + LoginUI ->> AuthService: Validate + AuthService ->> UserDB: Query user + UserDB -->> AuthService: User data + + alt Valid credentials + AuthService -->> LoginUI: Success + LoginUI -->> User: Welcome + + par Place order + User ->> Order: New order + Order ->> OrderQueue: Process + and + Order ->> AuditLogs: Record + end + + loop Until confirmed + OrderQueue ->> Order: Update status + Order -->> User: Notification + end + else Invalid credentials + AuthService --x LoginUI: Failure + LoginUI --x User: Retry + end + `, + { sequence: { useMaxWidth: false } } + ); + }); + + it('should render parallel processes with different participant types', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Customer@{ "type": "actor" } + participant Frontend@{ "type": "participant" } + participant PaymentService@{ "type": "boundary" } + participant InventoryManager@{ "type": "control" } + participant Order@{ "type": "entity" } + participant OrdersDB@{ "type": "database" } + participant NotificationQueue@{ "type": "queue" } + + Customer ->> Frontend: Place order + Frontend ->> Order: Create order + par Parallel Processing + Order ->> PaymentService: Process payment + and + Order ->> InventoryManager: Reserve items + end + PaymentService -->> Order: Payment confirmed + InventoryManager -->> Order: Items reserved + Order ->> OrdersDB: Save finalized order + OrdersDB -->> Order: Order saved + Order ->> NotificationQueue: Send confirmation + NotificationQueue -->> Customer: Order confirmation + ` + ); + }); + }); + it('should render different participant types with notes and loops', () => { + imgSnapshotTest( + ` + sequenceDiagram + actor Admin + participant Dashboard + participant AuthService@{ "type" : "boundary" } + participant UserManager@{ "type" : "control" } + participant UserProfile@{ "type" : "entity" } + participant UserDB@{ "type" : "database" } + participant Logs@{ "type" : "database" } + + Admin ->> Dashboard: Open user management + loop Authentication check + Dashboard ->> AuthService: Verify admin rights + AuthService ->> Dashboard: Access granted + end + Dashboard ->> UserManager: List users + UserManager ->> UserDB: Query users + UserDB ->> UserManager: Return user data + Note right of UserDB: Encrypted data
requires decryption + UserManager ->> UserProfile: Format profiles + UserProfile ->> UserManager: Formatted data + UserManager ->> Dashboard: Display users + Dashboard ->> Logs: Record access + Logs ->> Admin: Audit trail + ` + ); + }); + + it('should render different participant types with alternative flows', () => { + imgSnapshotTest( + ` + sequenceDiagram + actor Client + participant MobileApp + participant CloudService@{ "type" : "boundary" } + participant DataProcessor@{ "type" : "control" } + participant Transaction@{ "type" : "entity" } + participant TransactionsDB@{ "type" : "database" } + participant EventBus@{ "type" : "queue" } + + Client ->> MobileApp: Initiate transaction + MobileApp ->> CloudService: Authenticate + alt Authentication successful + CloudService -->> MobileApp: Auth token + MobileApp ->> DataProcessor: Process data + DataProcessor ->> Transaction: Create transaction + Transaction ->> TransactionsDB: Save record + TransactionsDB -->> Transaction: Confirmation + Transaction ->> EventBus: Publish event + EventBus -->> Client: Notification + else Authentication failed + CloudService -->> MobileApp: Error + MobileApp -->> Client: Show error + end + ` + ); + }); + + it('should render different participant types with wrapping text', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant B@{ "type" : "boundary" } + participant C@{ "type" : "control" } + participant E@{ "type" : "entity" } + participant DB@{ "type" : "database" } + participant COL@{ "type" : "collections" } + participant Q@{ "type" : "queue" } + + FE ->> B: Another long message
with explicit
line breaks + B -->> FE: Response message that is also quite long and needs to wrap + FE ->> C: Process data + C ->> E: Validate + E -->> C: Validation result + C ->> DB: Save + DB -->> C: Save result + C ->> COL: Log + COL -->> Q: Forward + Q -->> LongNameUser: Final response with confirmation of all actions taken + `, + { sequence: { wrap: true } } + ); + }); + + describe('Sequence Diagram - New Participant Types with Long Notes and Messages', () => { + it('should render long notes left of boundary', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice@{ "type" : "boundary" } + actor Bob + Alice->>Bob: Hola + Note left of Alice: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + Bob->>Alice: I'm short though + `, + {} + ); + }); + + it('should render wrapped long notes left of control', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice@{ "type" : "control" } + actor Bob + Alice->>Bob: Hola + Note left of Alice:wrap: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + Bob->>Alice: I'm short though + `, + {} + ); + }); + + it('should render long notes right of entity', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice@{ "type" : "entity" } + actor Bob + Alice->>Bob: Hola + Note right of Alice: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + Bob->>Alice: I'm short though + `, + {} + ); + }); + + it('should render wrapped long notes right of database', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice@{ "type" : "database" } + actor Bob + Alice->>Bob: Hola + Note right of Alice:wrap: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + Bob->>Alice: I'm short though + `, + {} + ); + }); + + it('should render long notes over collections', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice@{ "type" : "collections" } + actor Bob + Alice->>Bob: Hola + Note over Alice: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + Bob->>Alice: I'm short though + `, + {} + ); + }); + + it('should render wrapped long notes over queue', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice@{ "type" : "queue" } + actor Bob + Alice->>Bob: Hola + Note over Alice:wrap: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + Bob->>Alice: I'm short though + `, + {} + ); + }); + + it('should render notes over actor and boundary', () => { + imgSnapshotTest( + ` + sequenceDiagram + actor Alice + participant Charlie@{ "type" : "boundary" } + note over Alice: Some note + note over Charlie: Other note + `, + {} + ); + }); + + it('should render long messages from database to collections', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice@{ "type" : "database" } + participant Bob@{ "type" : "collections" } + Alice->>Bob: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + Bob->>Alice: I'm short though + `, + {} + ); + }); + + it('should render wrapped long messages from control to entity', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice@{ "type" : "control" } + participant Bob@{ "type" : "entity" } + Alice->>Bob:wrap: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + Bob->>Alice: I'm short though + `, + {} + ); + }); + + it('should render long messages from queue to boundary', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice@{ "type" : "queue" } + participant Bob@{ "type" : "boundary" } + Alice->>Bob: I'm short + Bob->>Alice: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + `, + {} + ); + }); + + it('should render wrapped long messages from actor to database', () => { + imgSnapshotTest( + ` + sequenceDiagram + actor Alice + participant Bob@{ "type" : "database" } + Alice->>Bob: I'm short + Bob->>Alice:wrap: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + `, + {} + ); + }); + }); + + describe('svg size', () => { + it('should render a sequence diagram when useMaxWidth is true (default)', () => { + renderGraph( + ` + sequenceDiagram + actor Alice + participant Bob@{ "type" : "boundary" } + participant John@{ "type" : "control" } + Alice ->> Bob: Hello Bob, how are you? + Bob-->>John: How about you John? + Bob--x Alice: I am good thanks! + Bob-x John: I am good thanks! + Note right of John: Bob thinks a long
long time, so long
that the text does
not fit on a row. + Bob-->Alice: Checking with John... + alt either this + Alice->>John: Yes + else or this + Alice->>John: No + else or this will happen + Alice->John: Maybe + end + par this happens in parallel + Alice -->> Bob: Parallel message 1 + and + Alice -->> John: Parallel message 2 + end + `, + { sequence: { useMaxWidth: true } } + ); + cy.get('svg').should((svg) => { + expect(svg).to.have.attr('width', '100%'); + const style = svg.attr('style'); + expect(style).to.match(/^max-width: [\d.]+px;$/); + const maxWidthValue = parseFloat(style.match(/[\d.]+/g).join('')); + expect(maxWidthValue).to.be.within(820 * 0.95, 820 * 1.05); + }); + }); + + it('should render a sequence diagram when useMaxWidth is false', () => { + renderGraph( + ` + sequenceDiagram + actor Alice + participant Bob@{ "type" : "boundary" } + participant John@{ "type" : "control" } + Alice ->> Bob: Hello Bob, how are you? + Bob-->>John: How about you John? + Bob--x Alice: I am good thanks! + Bob-x John: I am good thanks! + Note right of John: Bob thinks a long
long time, so long
that the text does
not fit on a row. + Bob-->Alice: Checking with John... + alt either this + Alice->>John: Yes + else or this + Alice->>John: No + else or this will happen + Alice->John: Maybe + end + par this happens in parallel + Alice -->> Bob: Parallel message 1 + and + Alice -->> John: Parallel message 2 + end + `, + { sequence: { useMaxWidth: false } } + ); + cy.get('svg').should((svg) => { + const width = parseFloat(svg.attr('width')); + expect(width).to.be.within(820 * 0.95, 820 * 1.05); + expect(svg).to.not.have.attr('style'); + }); + }); + }); +}); diff --git a/docs/config/setup/mermaid/interfaces/ParseOptions.md b/docs/config/setup/mermaid/interfaces/ParseOptions.md index ea96f2706..e068a91fb 100644 --- a/docs/config/setup/mermaid/interfaces/ParseOptions.md +++ b/docs/config/setup/mermaid/interfaces/ParseOptions.md @@ -10,7 +10,7 @@ # Interface: ParseOptions -Defined in: [packages/mermaid/src/types.ts:72](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L72) +Defined in: [packages/mermaid/src/types.ts:84](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L84) ## Properties @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/types.ts:72](https://github.com/mermaid-js/mer > `optional` **suppressErrors**: `boolean` -Defined in: [packages/mermaid/src/types.ts:77](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L77) +Defined in: [packages/mermaid/src/types.ts:89](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L89) If `true`, parse will return `false` instead of throwing error when the diagram is invalid. The `parseError` function will not be called. diff --git a/docs/config/setup/mermaid/interfaces/ParseResult.md b/docs/config/setup/mermaid/interfaces/ParseResult.md index 7a5990610..1651a6fa9 100644 --- a/docs/config/setup/mermaid/interfaces/ParseResult.md +++ b/docs/config/setup/mermaid/interfaces/ParseResult.md @@ -10,7 +10,7 @@ # Interface: ParseResult -Defined in: [packages/mermaid/src/types.ts:80](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L80) +Defined in: [packages/mermaid/src/types.ts:92](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L92) ## Properties @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/types.ts:80](https://github.com/mermaid-js/mer > **config**: [`MermaidConfig`](MermaidConfig.md) -Defined in: [packages/mermaid/src/types.ts:88](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L88) +Defined in: [packages/mermaid/src/types.ts:100](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L100) The config passed as YAML frontmatter or directives @@ -28,6 +28,6 @@ The config passed as YAML frontmatter or directives > **diagramType**: `string` -Defined in: [packages/mermaid/src/types.ts:84](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L84) +Defined in: [packages/mermaid/src/types.ts:96](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L96) The diagram type, e.g. 'flowchart', 'sequence', etc. diff --git a/docs/config/setup/mermaid/interfaces/RenderResult.md b/docs/config/setup/mermaid/interfaces/RenderResult.md index fc5fac4f5..c0e5496b8 100644 --- a/docs/config/setup/mermaid/interfaces/RenderResult.md +++ b/docs/config/setup/mermaid/interfaces/RenderResult.md @@ -10,7 +10,7 @@ # Interface: RenderResult -Defined in: [packages/mermaid/src/types.ts:98](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L98) +Defined in: [packages/mermaid/src/types.ts:110](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L110) ## Properties @@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/types.ts:98](https://github.com/mermaid-js/mer > `optional` **bindFunctions**: (`element`) => `void` -Defined in: [packages/mermaid/src/types.ts:116](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L116) +Defined in: [packages/mermaid/src/types.ts:128](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L128) Bind function to be called after the svg has been inserted into the DOM. This is necessary for adding event listeners to the elements in the svg. @@ -45,7 +45,7 @@ bindFunctions?.(div); // To call bindFunctions only if it's present. > **diagramType**: `string` -Defined in: [packages/mermaid/src/types.ts:106](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L106) +Defined in: [packages/mermaid/src/types.ts:118](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L118) The diagram type, e.g. 'flowchart', 'sequence', etc. @@ -55,6 +55,6 @@ The diagram type, e.g. 'flowchart', 'sequence', etc. > **svg**: `string` -Defined in: [packages/mermaid/src/types.ts:102](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L102) +Defined in: [packages/mermaid/src/types.ts:114](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/types.ts#L114) The svg code for the rendered graph. diff --git a/docs/syntax/sequenceDiagram.md b/docs/syntax/sequenceDiagram.md index 0b5b038e2..e473cc00f 100644 --- a/docs/syntax/sequenceDiagram.md +++ b/docs/syntax/sequenceDiagram.md @@ -74,6 +74,126 @@ sequenceDiagram Bob->>Alice: Hi Alice ``` +### Boundary + +If you want to use the boundary symbol for a participant, use the JSON configuration syntax as shown below. + +```mermaid-example +sequenceDiagram + participant Alice@{ "type" : "boundary" } + participant Bob + Alice->>Bob: Request from boundary + Bob->>Alice: Response to boundary +``` + +```mermaid +sequenceDiagram + participant Alice@{ "type" : "boundary" } + participant Bob + Alice->>Bob: Request from boundary + Bob->>Alice: Response to boundary +``` + +### Control + +If you want to use the control symbol for a participant, use the JSON configuration syntax as shown below. + +```mermaid-example +sequenceDiagram + participant Alice@{ "type" : "control" } + participant Bob + Alice->>Bob: Control request + Bob->>Alice: Control response +``` + +```mermaid +sequenceDiagram + participant Alice@{ "type" : "control" } + participant Bob + Alice->>Bob: Control request + Bob->>Alice: Control response +``` + +### Entity + +If you want to use the entity symbol for a participant, use the JSON configuration syntax as shown below. + +```mermaid-example +sequenceDiagram + participant Alice@{ "type" : "entity" } + participant Bob + Alice->>Bob: Entity request + Bob->>Alice: Entity response +``` + +```mermaid +sequenceDiagram + participant Alice@{ "type" : "entity" } + participant Bob + Alice->>Bob: Entity request + Bob->>Alice: Entity response +``` + +### Database + +If you want to use the database symbol for a participant, use the JSON configuration syntax as shown below. + +```mermaid-example +sequenceDiagram + participant Alice@{ "type" : "database" } + participant Bob + Alice->>Bob: DB query + Bob->>Alice: DB result +``` + +```mermaid +sequenceDiagram + participant Alice@{ "type" : "database" } + participant Bob + Alice->>Bob: DB query + Bob->>Alice: DB result +``` + +### Collections + +If you want to use the collections symbol for a participant, use the JSON configuration syntax as shown below. + +```mermaid-example +sequenceDiagram + participant Alice@{ "type" : "collections" } + participant Bob + Alice->>Bob: Collections request + Bob->>Alice: Collections response +``` + +```mermaid +sequenceDiagram + participant Alice@{ "type" : "collections" } + participant Bob + Alice->>Bob: Collections request + Bob->>Alice: Collections response +``` + +### Queue + +If you want to use the queue symbol for a participant, use the JSON configuration syntax as shown below. + +```mermaid-example +sequenceDiagram + participant Alice@{ "type" : "queue" } + participant Bob + Alice->>Bob: Queue message + Bob->>Alice: Queue response +``` + +```mermaid +sequenceDiagram + participant Alice@{ "type" : "queue" } + participant Bob + Alice->>Bob: Queue message + Bob->>Alice: Queue response +``` + ### Aliases The actor can have a convenient identifier and a descriptive label. diff --git a/packages/mermaid/src/diagrams/sequence/parser/sequenceDiagram.jison b/packages/mermaid/src/diagrams/sequence/parser/sequenceDiagram.jison index 9defa64e8..f1364895b 100644 --- a/packages/mermaid/src/diagrams/sequence/parser/sequenceDiagram.jison +++ b/packages/mermaid/src/diagrams/sequence/parser/sequenceDiagram.jison @@ -14,7 +14,7 @@ // Special states for recognizing aliases // A special state for grabbing text up to the first comment/newline -%x ID ALIAS LINE +%x ID ALIAS LINE CONFIG CONFIG_DATA %x acc_title %x acc_descr @@ -28,6 +28,11 @@ \%%(?!\{)[^\n]* /* skip comments */ [^\}]\%\%[^\n]* /* skip comments */ [0-9]+(?=[ \n]+) return 'NUM'; +\@\{ { this.begin('CONFIG'); return 'CONFIG_START'; } +[^\}]+ { return 'CONFIG_CONTENT'; } +\} { this.popState(); this.popState(); return 'CONFIG_END'; } +[^\<->\->:\n,;@\s]+(?=\@\{) { yytext = yytext.trim(); return 'ACTOR'; } +[^\<->\->:\n,;@]+?([\-]*[^\<->\->:\n,;@]+?)*?(?=((?!\n)\s)+"as"(?!\n)\s|[#\n;]|$) { yytext = yytext.trim(); this.begin('ALIAS'); return 'ACTOR'; } "box" { this.begin('LINE'); return 'box'; } "participant" { this.begin('ID'); return 'participant'; } "actor" { this.begin('ID'); return 'participant_actor'; } @@ -257,6 +262,8 @@ participant_statement | 'participant_actor' actor 'AS' restOfLine 'NEWLINE' {$2.draw='actor'; $2.type='addParticipant';$2.description=yy.parseMessage($4); $$=$2;} | 'participant_actor' actor 'NEWLINE' {$2.draw='actor'; $2.type='addParticipant'; $$=$2;} | 'destroy' actor 'NEWLINE' {$2.type='destroyParticipant'; $$=$2;} + | 'participant' actor_with_config 'NEWLINE' {$2.draw='participant'; $2.type='addParticipant'; $$=$2;} + ; note_statement @@ -341,6 +348,23 @@ signal { $$ = [$1,$3,{type: 'addMessage', from:$1.actor, to:$3.actor, signalType:$2, msg:$4}]} ; +actor_with_config + : ACTOR config_object + { + $$ = { + type: 'addParticipant', + actor: $1, + config: $2 + }; + } + ; + +config_object + : CONFIG_START CONFIG_CONTENT CONFIG_END + { + $$ = $2.trim(); + } + ; // actor // : actor_participant // | actor_actor diff --git a/packages/mermaid/src/diagrams/sequence/sequenceDb.ts b/packages/mermaid/src/diagrams/sequence/sequenceDb.ts index 0d411a7f5..d4758f39e 100644 --- a/packages/mermaid/src/diagrams/sequence/sequenceDb.ts +++ b/packages/mermaid/src/diagrams/sequence/sequenceDb.ts @@ -1,4 +1,5 @@ import { getConfig } from '../../diagram-api/diagramAPI.js'; +import * as yaml from 'js-yaml'; import type { DiagramDB } from '../../diagram-api/types.js'; import { log } from '../../logger.js'; import { ImperativeState } from '../../utils/imperativeState.js'; @@ -13,6 +14,7 @@ import { setDiagramTitle, } from '../common/commonDb.js'; import type { Actor, AddMessageParams, Box, Message, Note } from './types.js'; +import type { ParticipantMetaData } from '../../types.js'; interface SequenceState { prevActor?: string; @@ -99,6 +101,17 @@ const PLACEMENT = { OVER: 2, } as const; +export const PARTICIPANT_TYPE = { + ACTOR: 'actor', + BOUNDARY: 'boundary', + COLLECTIONS: 'collections', + CONTROL: 'control', + DATABASE: 'database', + ENTITY: 'entity', + PARTICIPANT: 'participant', + QUEUE: 'queue', +} as const; + export class SequenceDB implements DiagramDB { private readonly state = new ImperativeState(() => ({ prevActor: undefined, @@ -143,9 +156,22 @@ export class SequenceDB implements DiagramDB { id: string, name: string, description: { text: string; wrap?: boolean | null; type: string }, - type: string + type: string, + metadata?: any ) { let assignedBox = this.state.records.currentBox; + let doc; + if (metadata !== undefined) { + let yamlData; + // detect if shapeData contains a newline character + if (!metadata.includes('\n')) { + yamlData = '{\n' + metadata + '\n}'; + } else { + yamlData = metadata + '\n'; + } + doc = yaml.load(yamlData, { schema: yaml.JSON_SCHEMA }) as ParticipantMetaData; + } + type = doc?.type ?? type; const old = this.state.records.actors.get(id); if (old) { // If already set and trying to set to a new one throw error @@ -544,7 +570,7 @@ export class SequenceDB implements DiagramDB { }); break; case 'addParticipant': - this.addActor(param.actor, param.actor, param.description, param.draw); + this.addActor(param.actor, param.actor, param.description, param.draw, param.config); break; case 'createParticipant': if (this.state.records.actors.has(param.actor)) { @@ -553,7 +579,7 @@ export class SequenceDB implements DiagramDB { ); } this.state.records.lastCreated = param.actor; - this.addActor(param.actor, param.actor, param.description, param.draw); + this.addActor(param.actor, param.actor, param.description, param.draw, param.config); this.state.records.createdActors.set(param.actor, this.state.records.messages.length); break; case 'destroyParticipant': diff --git a/packages/mermaid/src/diagrams/sequence/sequenceDiagram.spec.js b/packages/mermaid/src/diagrams/sequence/sequenceDiagram.spec.js index 69c1b1d1d..68c1c7a29 100644 --- a/packages/mermaid/src/diagrams/sequence/sequenceDiagram.spec.js +++ b/packages/mermaid/src/diagrams/sequence/sequenceDiagram.spec.js @@ -2094,4 +2094,272 @@ Bob->>Alice:Got it! const messages = diagram.db.getMessages(); }); + describe('when parsing extended participant syntax', () => { + it('should parse participants with different quote styles and whitespace', async () => { + const diagram = await Diagram.fromText(` + sequenceDiagram + participant Alice@{ "type" : "database" } + participant Bob@{ "type" : "database" } + participant Carl@{ type: "database" } + participant David@{ "type" : 'database' } + participant Eve@{ type: 'database' } + participant Favela@{ "type" : "database" } + Bob->>+Alice: Hi Alice + Alice->>+Bob: Hi Bob + `); + + const actors = diagram.db.getActors(); + + expect(actors.get('Alice').type).toBe('database'); + expect(actors.get('Alice').description).toBe('Alice'); + + expect(actors.get('Bob').type).toBe('database'); + expect(actors.get('Bob').description).toBe('Bob'); + + expect(actors.get('Carl').type).toBe('database'); + expect(actors.get('Carl').description).toBe('Carl'); + + expect(actors.get('David').type).toBe('database'); + expect(actors.get('David').description).toBe('David'); + + expect(actors.get('Eve').type).toBe('database'); + expect(actors.get('Eve').description).toBe('Eve'); + + expect(actors.get('Favela').type).toBe('database'); + expect(actors.get('Favela').description).toBe('Favela'); + + // Verify messages were parsed correctly + const messages = diagram.db.getMessages(); + expect(messages.length).toBe(4); // 2 messages + 2 activation messages + expect(messages[0].from).toBe('Bob'); + expect(messages[0].to).toBe('Alice'); + expect(messages[0].message).toBe('Hi Alice'); + expect(messages[2].from).toBe('Alice'); // Second message (index 2 due to activation) + expect(messages[2].to).toBe('Bob'); + expect(messages[2].message).toBe('Hi Bob'); + }); + + it('should parse mixed participant types with extended syntax', async () => { + const diagram = await Diagram.fromText(` + sequenceDiagram + participant lead + participant dsa@{ "type" : "queue" } + API->>+Database: getUserb + Database-->>-API: userb + dsa --> Database: hello +`); + + // Verify actors were created + const actors = diagram.db.getActors(); + + expect(actors.get('lead').type).toBe('participant'); + expect(actors.get('lead').description).toBe('lead'); + + // Participant with extended syntax + expect(actors.get('dsa').type).toBe('queue'); + expect(actors.get('dsa').description).toBe('dsa'); + + // Implicitly created actors (from messages) + expect(actors.get('API').type).toBe('participant'); + expect(actors.get('API').description).toBe('API'); + + expect(actors.get('Database').type).toBe('participant'); + expect(actors.get('Database').description).toBe('Database'); + + // Verify messages were parsed correctly + const messages = diagram.db.getMessages(); + expect(messages.length).toBe(5); // 3 messages + 2 activation messages + + // First message with activation + expect(messages[0].from).toBe('API'); + expect(messages[0].to).toBe('Database'); + expect(messages[0].message).toBe('getUserb'); + expect(messages[0].activate).toBe(true); + + // Second message with deactivation + expect(messages[2].from).toBe('Database'); + expect(messages[2].to).toBe('API'); + expect(messages[2].message).toBe('userb'); + + // Third message + expect(messages[4].from).toBe('dsa'); + expect(messages[4].to).toBe('Database'); + expect(messages[4].message).toBe('hello'); + }); + + it('should fail for malformed JSON in participant definition', async () => { + const invalidDiagram = ` + sequenceDiagram + participant D@{ "type: "entity" } + participant E@{ "type": "dat + abase } + `; + + let error = false; + try { + await mermaidAPI.parse(invalidDiagram); + } catch (e) { + error = true; + } + expect(error).toBe(true); + }); + + it('should fail for missing colon separator', async () => { + const invalidDiagram = ` + sequenceDiagram + participant C@{ "type" "control" } + C ->> C: action + `; + + let error = false; + try { + await mermaidAPI.parse(invalidDiagram); + } catch (e) { + error = true; + } + expect(error).toBe(true); + }); + + it('should fail for missing closing brace', async () => { + const invalidDiagram = ` + sequenceDiagram + participant E@{ "type": "entity" + E ->> E: process + `; + + let error = false; + try { + await mermaidAPI.parse(invalidDiagram); + } catch (e) { + error = true; + } + expect(error).toBe(true); + }); + }); + describe('participant type parsing', () => { + it('should parse boundary participant', async () => { + const diagram = await Diagram.fromText(` + sequenceDiagram + participant boundary@{ "type" : "boundary" } + boundary->boundary: test + `); + const actors = diagram.db.getActors(); + expect(actors.get('boundary').type).toBe('boundary'); + expect(actors.get('boundary').description).toBe('boundary'); + }); + + it('should parse control participant', async () => { + const diagram = await Diagram.fromText(` + sequenceDiagram + participant C@{ "type" : "control" } + C->C: test + `); + const actors = diagram.db.getActors(); + expect(actors.get('C').type).toBe('control'); + expect(actors.get('C').description).toBe('C'); + }); + + it('should parse entity participant', async () => { + const diagram = await Diagram.fromText(` + sequenceDiagram + participant E@{ "type" : "entity" } + E->E: test + `); + const actors = diagram.db.getActors(); + expect(actors.get('E').type).toBe('entity'); + expect(actors.get('E').description).toBe('E'); + }); + + it('should parse database participant', async () => { + const diagram = await Diagram.fromText(` + sequenceDiagram + participant D@{ "type" : "database" } + D->D: test + `); + const actors = diagram.db.getActors(); + expect(actors.get('D').type).toBe('database'); + expect(actors.get('D').description).toBe('D'); + }); + + it('should parse collections participant', async () => { + const diagram = await Diagram.fromText(` + sequenceDiagram + participant L@{ "type" : "collections" } + L->L: test + `); + const actors = diagram.db.getActors(); + expect(actors.get('L').type).toBe('collections'); + expect(actors.get('L').description).toBe('L'); + }); + + it('should parse queue participant', async () => { + const diagram = await Diagram.fromText(` + sequenceDiagram + participant Q@{ "type" : "queue" } + Q->Q: test + `); + const actors = diagram.db.getActors(); + expect(actors.get('Q').type).toBe('queue'); + expect(actors.get('Q').description).toBe('Q'); + }); + }); + + describe('participant type parsing', () => { + it('should parse actor participant', async () => { + const diagram = await Diagram.fromText(` + sequenceDiagram + participant A@{ "type" : "queue" } + A->A: test + `); + const actors = diagram.db.getActors(); + expect(actors.get('A').type).toBe('queue'); + expect(actors.get('A').description).toBe('A'); + }); + + it('should parse participant participant', async () => { + const diagram = await Diagram.fromText(` + sequenceDiagram + participant P@{ "type" : "database" } + P->P: test + `); + const actors = diagram.db.getActors(); + expect(actors.get('P').type).toBe('database'); + expect(actors.get('P').description).toBe('P'); + }); + + it('should parse boundary using actor keyword', async () => { + const diagram = await Diagram.fromText(` + sequenceDiagram + participant Alice@{ "type" : "collections" } + participant Bob@{ "type" : "control" } + Alice->>Bob: Hello Bob, how are you? + `); + const actors = diagram.db.getActors(); + expect(actors.get('Alice').type).toBe('collections'); + expect(actors.get('Bob').type).toBe('control'); + expect(actors.get('Bob').description).toBe('Bob'); + }); + + it('should parse control using participant keyword', async () => { + const diagram = await Diagram.fromText(` + sequenceDiagram + participant C@{ "type" : "control" } + C->C: test + `); + const actors = diagram.db.getActors(); + expect(actors.get('C').type).toBe('control'); + expect(actors.get('C').description).toBe('C'); + }); + + it('should parse entity using actor keyword', async () => { + const diagram = await Diagram.fromText(` + sequenceDiagram + participant E@{ "type" : "entity" } + E->E: test + `); + const actors = diagram.db.getActors(); + expect(actors.get('E').type).toBe('entity'); + expect(actors.get('E').description).toBe('E'); + }); + }); }); diff --git a/packages/mermaid/src/diagrams/sequence/sequenceRenderer.ts b/packages/mermaid/src/diagrams/sequence/sequenceRenderer.ts index c344968a1..86d57757a 100644 --- a/packages/mermaid/src/diagrams/sequence/sequenceRenderer.ts +++ b/packages/mermaid/src/diagrams/sequence/sequenceRenderer.ts @@ -10,6 +10,7 @@ import assignWithDepth from '../../assignWithDepth.js'; import utils from '../../utils.js'; import { configureSvgSize } from '../../setupGraphViewbox.js'; import type { Diagram } from '../../Diagram.js'; +import { PARTICIPANT_TYPE } from './sequenceDb.js'; let conf = {}; @@ -854,11 +855,19 @@ function adjustCreatedDestroyedData( msgModel.startx = msgModel.startx - adjustment; } } + const actorArray = [ + PARTICIPANT_TYPE.ACTOR, + PARTICIPANT_TYPE.CONTROL, + PARTICIPANT_TYPE.ENTITY, + PARTICIPANT_TYPE.DATABASE, + ]; // if it is a create message if (createdActors.get(msg.to) == index) { const actor = actors.get(msg.to); - const adjustment = actor.type == 'actor' ? ACTOR_TYPE_WIDTH / 2 + 3 : actor.width / 2 + 3; + const adjustment = actorArray.includes(actor.type) + ? ACTOR_TYPE_WIDTH / 2 + 3 + : actor.width / 2 + 3; receiverAdjustment(actor, adjustment); actor.starty = lineStartY - actor.height / 2; bounds.bumpVerticalPos(actor.height / 2); @@ -867,7 +876,7 @@ function adjustCreatedDestroyedData( else if (destroyedActors.get(msg.from) == index) { const actor = actors.get(msg.from); if (conf.mirrorActors) { - const adjustment = actor.type == 'actor' ? ACTOR_TYPE_WIDTH / 2 : actor.width / 2; + const adjustment = actorArray.includes(actor.type) ? ACTOR_TYPE_WIDTH / 2 : actor.width / 2; senderAdjustment(actor, adjustment); } actor.stopy = lineStartY - actor.height / 2; @@ -877,7 +886,9 @@ function adjustCreatedDestroyedData( else if (destroyedActors.get(msg.to) == index) { const actor = actors.get(msg.to); if (conf.mirrorActors) { - const adjustment = actor.type == 'actor' ? ACTOR_TYPE_WIDTH / 2 + 3 : actor.width / 2 + 3; + const adjustment = actorArray.includes(actor.type) + ? ACTOR_TYPE_WIDTH / 2 + 3 + : actor.width / 2 + 3; receiverAdjustment(actor, adjustment); } actor.stopy = lineStartY - actor.height / 2; @@ -1226,10 +1237,11 @@ export const draw = async function (_text: string, id: string, _version: string, for (const box of bounds.models.boxes) { box.height = bounds.getVerticalPos() - box.y; bounds.insert(box.x, box.y, box.x + box.width, box.height); - box.startx = box.x; - box.starty = box.y; - box.stopx = box.startx + box.width; - box.stopy = box.starty + box.height; + const boxPadding = conf.boxMargin * 2; + box.startx = box.x - boxPadding; + box.starty = box.y - boxPadding * 0.25; + box.stopx = box.startx + box.width + 2 * boxPadding; + box.stopy = box.starty + box.height + boxPadding * 0.75; box.stroke = 'rgb(0,0,0, 0.5)'; svgDraw.drawBox(diagram, box, conf); } @@ -1494,6 +1506,9 @@ async function calculateActorMargins( return (total += actors.get(aKey).width + (actors.get(aKey).margin || 0)); }, 0); + const standardBoxPadding = conf.boxMargin * 8; + totalWidth += standardBoxPadding; + totalWidth -= 2 * conf.boxTextMargin; if (box.wrap) { box.name = utils.wrapLabel(box.name, totalWidth - 2 * conf.wrapPadding, textFont); diff --git a/packages/mermaid/src/diagrams/sequence/styles.js b/packages/mermaid/src/diagrams/sequence/styles.js index 5c36b4ed1..3cee9d3dc 100644 --- a/packages/mermaid/src/diagrams/sequence/styles.js +++ b/packages/mermaid/src/diagrams/sequence/styles.js @@ -12,6 +12,11 @@ const getStyles = (options) => .actor-line { stroke: ${options.actorLineColor}; } + + .innerArc { + stroke-width: 1.5; + stroke-dasharray: none; + } .messageLine0 { stroke-width: 1.5; @@ -115,6 +120,7 @@ const getStyles = (options) => fill: ${options.actorBkg}; stroke-width: 2px; } + `; export default getStyles; diff --git a/packages/mermaid/src/diagrams/sequence/svgDraw.js b/packages/mermaid/src/diagrams/sequence/svgDraw.js index 9a7c1bf54..7db661930 100644 --- a/packages/mermaid/src/diagrams/sequence/svgDraw.js +++ b/packages/mermaid/src/diagrams/sequence/svgDraw.js @@ -415,6 +415,600 @@ const drawActorTypeParticipant = function (elem, actor, conf, isFooter) { return height; }; +/** + * Draws an actor in the diagram with the attached line + * + * @param {any} elem - The diagram we'll draw to. + * @param {any} actor - The actor to draw. + * @param {any} conf - DrawText implementation discriminator object + * @param {boolean} isFooter - If the actor is the footer one + */ +const drawActorTypeCollections = function (elem, actor, conf, isFooter) { + const actorY = isFooter ? actor.stopy : actor.starty; + const center = actor.x + actor.width / 2; + const centerY = actorY + actor.height; + + const boxplusLineGroup = elem.append('g').lower(); + var g = boxplusLineGroup; + + if (!isFooter) { + actorCnt++; + if (Object.keys(actor.links || {}).length && !conf.forceMenus) { + g.attr('onclick', popupMenuToggle(`actor${actorCnt}_popup`)).attr('cursor', 'pointer'); + } + g.append('line') + .attr('id', 'actor' + actorCnt) + .attr('x1', center) + .attr('y1', centerY) + .attr('x2', center) + .attr('y2', 2000) + .attr('class', 'actor-line 200') + .attr('stroke-width', '0.5px') + .attr('stroke', '#999') + .attr('name', actor.name); + + g = boxplusLineGroup.append('g'); + actor.actorCnt = actorCnt; + + if (actor.links != null) { + g.attr('id', 'root-' + actorCnt); + } + } + + const rect = svgDrawCommon.getNoteRect(); + var cssclass = 'actor'; + if (actor.properties?.class) { + cssclass = actor.properties.class; + } else { + rect.fill = '#eaeaea'; + } + if (isFooter) { + cssclass += ` ${BOTTOM_ACTOR_CLASS}`; + } else { + cssclass += ` ${TOP_ACTOR_CLASS}`; + } + rect.x = actor.x; + rect.y = actorY; + rect.width = actor.width; + rect.height = actor.height; + rect.class = cssclass; + rect.name = actor.name; + + // DRAW STACKED RECTANGLES + const offset = 6; + const shadowRect = { + ...rect, + x: rect.x + (isFooter ? -offset : -offset), + y: rect.y + (isFooter ? +offset : +offset), + class: 'actor', + }; + const rectElem = drawRect(g, rect); // draw main rectangle on top + drawRect(g, shadowRect); + actor.rectData = rect; + + if (actor.properties?.icon) { + const iconSrc = actor.properties.icon.trim(); + if (iconSrc.charAt(0) === '@') { + svgDrawCommon.drawEmbeddedImage(g, rect.x + rect.width - 20, rect.y + 10, iconSrc.substr(1)); + } else { + svgDrawCommon.drawImage(g, rect.x + rect.width - 20, rect.y + 10, iconSrc); + } + } + + _drawTextCandidateFunc(conf, hasKatex(actor.description))( + actor.description, + g, + rect.x - offset, + rect.y + offset, + rect.width, + rect.height, + { class: `actor ${ACTOR_BOX_CLASS}` }, + conf + ); + + let height = actor.height; + if (rectElem.node) { + const bounds = rectElem.node().getBBox(); + actor.height = bounds.height; + height = bounds.height; + } + + return height; +}; + +const drawActorTypeQueue = function (elem, actor, conf, isFooter) { + const actorY = isFooter ? actor.stopy : actor.starty; + const center = actor.x + actor.width / 2; + const centerY = actorY + actor.height; + + const boxplusLineGroup = elem.append('g').lower(); + let g = boxplusLineGroup; + + if (!isFooter) { + actorCnt++; + if (Object.keys(actor.links || {}).length && !conf.forceMenus) { + g.attr('onclick', popupMenuToggle(`actor${actorCnt}_popup`)).attr('cursor', 'pointer'); + } + g.append('line') + .attr('id', 'actor' + actorCnt) + .attr('x1', center) + .attr('y1', centerY) + .attr('x2', center) + .attr('y2', 2000) + .attr('class', 'actor-line 200') + .attr('stroke-width', '0.5px') + .attr('stroke', '#999') + .attr('name', actor.name); + + g = boxplusLineGroup.append('g'); + actor.actorCnt = actorCnt; + + if (actor.links != null) { + g.attr('id', 'root-' + actorCnt); + } + } + + const rect = svgDrawCommon.getNoteRect(); + let cssclass = 'actor'; + if (actor.properties?.class) { + cssclass = actor.properties.class; + } else { + rect.fill = '#eaeaea'; + } + + if (isFooter) { + cssclass += ` ${BOTTOM_ACTOR_CLASS}`; + } else { + cssclass += ` ${TOP_ACTOR_CLASS}`; + } + + rect.x = actor.x; + rect.y = actorY; + rect.width = actor.width; + rect.height = actor.height; + rect.class = cssclass; + rect.name = actor.name; + + // Cylinder dimensions + const ry = rect.height / 2; + const rx = ry / (2.5 + rect.height / 50); + + // Cylinder base group + const cylinderGroup = g.append('g'); + const cylinderArc = g.append('g'); + + // Main cylinder body + cylinderGroup + .append('path') + .attr( + 'd', + `M ${rect.x},${rect.y + ry} + a ${rx},${ry} 0 0 0 0,${rect.height} + h ${rect.width - 2 * rx} + a ${rx},${ry} 0 0 0 0,-${rect.height} + Z + ` + ) + .attr('class', cssclass); + cylinderArc + .append('path') + .attr( + 'd', + `M ${rect.x},${rect.y + ry} + a ${rx},${ry} 0 0 0 0,${rect.height}` + ) + .attr('stroke', '#666') + .attr('stroke-width', '1px') + .attr('class', cssclass); + + cylinderGroup.attr('transform', `translate(${rx}, ${-(rect.height / 2)})`); + cylinderArc.attr('transform', `translate(${rect.width - rx}, ${-rect.height / 2})`); + + actor.rectData = rect; + + if (actor.properties?.icon) { + const iconSrc = actor.properties.icon.trim(); + const iconX = rect.x + rect.width - 20; + const iconY = rect.y + 10; + if (iconSrc.charAt(0) === '@') { + svgDrawCommon.drawEmbeddedImage(g, iconX, iconY, iconSrc.substr(1)); + } else { + svgDrawCommon.drawImage(g, iconX, iconY, iconSrc); + } + } + + _drawTextCandidateFunc(conf, hasKatex(actor.description))( + actor.description, + g, + rect.x, + rect.y, + rect.width, + rect.height, + { class: `actor ${ACTOR_BOX_CLASS}` }, + conf + ); + + let height = actor.height; + const lastPath = cylinderGroup.select('path:last-child'); + if (lastPath.node()) { + const bounds = lastPath.node().getBBox(); + actor.height = bounds.height; + height = bounds.height; + } + + return height; +}; + +const drawActorTypeControl = function (elem, actor, conf, isFooter) { + const actorY = isFooter ? actor.stopy : actor.starty; + const center = actor.x + actor.width / 2; + const centerY = actorY + 75; + + const line = elem.append('g').lower(); + + if (!isFooter) { + actorCnt++; + line + .append('line') + .attr('id', 'actor' + actorCnt) + .attr('x1', center) + .attr('y1', centerY) + .attr('x2', center) + .attr('y2', 2000) + .attr('class', 'actor-line 200') + .attr('stroke-width', '0.5px') + .attr('stroke', '#999') + .attr('name', actor.name); + + actor.actorCnt = actorCnt; + } + const actElem = elem.append('g'); + let cssClass = ACTOR_MAN_FIGURE_CLASS; + if (isFooter) { + cssClass += ` ${BOTTOM_ACTOR_CLASS}`; + } else { + cssClass += ` ${TOP_ACTOR_CLASS}`; + } + actElem.attr('class', cssClass); + actElem.attr('name', actor.name); + + const rect = svgDrawCommon.getNoteRect(); + rect.x = actor.x; + rect.y = actorY; + rect.fill = '#eaeaea'; + rect.width = actor.width; + rect.height = actor.height; + rect.class = 'actor'; + + const cx = actor.x + actor.width / 2; + const cy = actorY + 30; + const r = 18; + + actElem + .append('defs') + .append('marker') + .attr('id', 'filled-head-control') + .attr('refX', 11) + .attr('refY', 5.8) + .attr('markerWidth', 20) + .attr('markerHeight', 28) + .attr('orient', '172.5') + .append('path') + .attr('d', 'M 14.4 5.6 L 7.2 10.4 L 8.8 5.6 L 7.2 0.8 Z'); + + // Draw the base circle + actElem + .append('circle') + .attr('cx', cx) + .attr('cy', cy) + .attr('r', r) + .attr('fill', '#eaeaf7') + .attr('stroke', '#666') + .attr('stroke-width', 1.2); + + // Draw looping arrow as arc path + actElem + .append('line') + .attr('marker-end', 'url(#filled-head-control)') + .attr('transform', `translate(${cx}, ${cy - r})`); + + const bounds = actElem.node().getBBox(); + actor.height = bounds.height + 2 * (conf?.sequence?.labelBoxHeight ?? 0); + + _drawTextCandidateFunc(conf, hasKatex(actor.description))( + actor.description, + actElem, + rect.x, + rect.y + r + (isFooter ? 5 : 10), + rect.width, + rect.height, + { class: `actor ${ACTOR_MAN_FIGURE_CLASS}` }, + conf + ); + + return actor.height; +}; + +const drawActorTypeEntity = function (elem, actor, conf, isFooter) { + const actorY = isFooter ? actor.stopy : actor.starty; + const center = actor.x + actor.width / 2; + const centerY = actorY + 75; + + const line = elem.append('g').lower(); + + const actElem = elem.append('g'); + let cssClass = ACTOR_MAN_FIGURE_CLASS; + if (isFooter) { + cssClass += ` ${BOTTOM_ACTOR_CLASS}`; + } else { + cssClass += ` ${TOP_ACTOR_CLASS}`; + } + actElem.attr('class', cssClass); + actElem.attr('name', actor.name); + + const rect = svgDrawCommon.getNoteRect(); + rect.x = actor.x; + rect.y = actorY; + rect.fill = '#eaeaea'; + rect.width = actor.width; + rect.height = actor.height; + rect.class = 'actor'; + + const cx = actor.x + actor.width / 2; + const cy = actorY + (!isFooter ? 25 : 10); + const r = 18; + + actElem + .append('circle') + .attr('cx', cx) + .attr('cy', cy) + .attr('r', r) + .attr('width', actor.width) + .attr('height', actor.height); + + actElem + .append('line') + .attr('x1', cx - r) + .attr('x2', cx + r) + .attr('y1', cy + r) + .attr('y2', cy + r) + .attr('stroke', '#333') + .attr('stroke-width', 2); + + const bounds = actElem.node().getBBox(); + actor.height = bounds.height + (conf?.sequence?.labelBoxHeight ?? 0); + + if (!isFooter) { + actorCnt++; + line + .append('line') + .attr('id', 'actor' + actorCnt) + .attr('x1', center) + .attr('y1', centerY) + .attr('x2', center) + .attr('y2', 2000) + .attr('class', 'actor-line 200') + .attr('stroke-width', '0.5px') + .attr('stroke', '#999') + .attr('name', actor.name); + + actor.actorCnt = actorCnt; + } + + _drawTextCandidateFunc(conf, hasKatex(actor.description))( + actor.description, + actElem, + rect.x, + rect.y + (!isFooter ? (cy + r - actorY) / 2 : (cy - actorY + r - 5) / 2), + rect.width, + rect.height, + { class: `actor ${ACTOR_MAN_FIGURE_CLASS}` }, + conf + ); + + if (!isFooter) { + actElem.attr('transform', `translate(${0}, ${r / 2})`); + } else { + actElem.attr('transform', `translate(${0}, ${r / 2})`); + } + + return actor.height; +}; + +const drawActorTypeDatabase = function (elem, actor, conf, isFooter) { + const actorY = isFooter ? actor.stopy : actor.starty; + const center = actor.x + actor.width / 2; + const centerY = actorY + actor.height + 2 * conf.boxTextMargin; + + const boxplusLineGroup = elem.append('g').lower(); + let g = boxplusLineGroup; + + if (!isFooter) { + actorCnt++; + if (Object.keys(actor.links || {}).length && !conf.forceMenus) { + g.attr('onclick', popupMenuToggle(`actor${actorCnt}_popup`)).attr('cursor', 'pointer'); + } + g.append('line') + .attr('id', 'actor' + actorCnt) + .attr('x1', center) + .attr('y1', centerY) + .attr('x2', center) + .attr('y2', 2000) + .attr('class', 'actor-line 200') + .attr('stroke-width', '0.5px') + .attr('stroke', '#999') + .attr('name', actor.name); + + g = boxplusLineGroup.append('g'); + actor.actorCnt = actorCnt; + + if (actor.links != null) { + g.attr('id', 'root-' + actorCnt); + } + } + + const rect = svgDrawCommon.getNoteRect(); + + let cssclass = 'actor'; + if (actor.properties?.class) { + cssclass = actor.properties.class; + } else { + rect.fill = '#eaeaea'; + } + + if (isFooter) { + cssclass += ` ${BOTTOM_ACTOR_CLASS}`; + } else { + cssclass += ` ${TOP_ACTOR_CLASS}`; + } + + rect.x = actor.x; + rect.y = actorY; + rect.width = actor.width; + rect.height = actor.height; + rect.class = cssclass; + rect.name = actor.name; + + // Cylinder dimensions + rect.x = actor.x; + rect.y = actorY; + const w = rect.width / 4; + const h = rect.width / 4; + const rx = w / 2; + const ry = rx / (2.5 + w / 50); + + // Cylinder base group + const cylinderGroup = g.append('g'); + + const d = ` + M ${rect.x},${rect.y + ry} + a ${rx},${ry} 0 0 0 ${w},0 + a ${rx},${ry} 0 0 0 -${w},0 + l 0,${h - 2 * ry} + a ${rx},${ry} 0 0 0 ${w},0 + l 0,-${h - 2 * ry} +`; + // Draw the main cylinder body + cylinderGroup + .append('path') + .attr('d', d) + .attr('fill', '#eaeaea') + .attr('stroke', '#000') + .attr('stroke-width', 1) + .attr('class', cssclass); + + if (!isFooter) { + cylinderGroup.attr('transform', `translate(${w * 1.5}, ${(rect.height + ry) / 4})`); + } else { + cylinderGroup.attr('transform', `translate(${w * 1.5}, ${rect.height / 4 - 2 * ry})`); + } + actor.rectData = rect; + _drawTextCandidateFunc(conf, hasKatex(actor.description))( + actor.description, + g, + rect.x, + rect.y + (!isFooter ? (rect.height + ry) / 2 : (rect.height + h) / 4), + rect.width, + rect.height, + { class: `actor ${ACTOR_BOX_CLASS}` }, + conf + ); + + const lastPath = cylinderGroup.select('path:last-child'); + if (lastPath.node()) { + const bounds = lastPath.node().getBBox(); + actor.height = bounds.height + (conf.sequence.labelBoxHeight ?? 0); + } + + return actor.height; +}; + +const drawActorTypeBoundary = function (elem, actor, conf, isFooter) { + const actorY = isFooter ? actor.stopy : actor.starty; + const center = actor.x + actor.width / 2; + const centerY = actorY + 80; + const radius = 30; + const line = elem.append('g').lower(); + + if (!isFooter) { + actorCnt++; + line + .append('line') + .attr('id', 'actor' + actorCnt) + .attr('x1', center) + .attr('y1', centerY) + .attr('x2', center) + .attr('y2', 2000) + .attr('class', 'actor-line 200') + .attr('stroke-width', '0.5px') + .attr('stroke', '#999') + .attr('name', actor.name); + + actor.actorCnt = actorCnt; + } + const actElem = elem.append('g'); + let cssClass = ACTOR_MAN_FIGURE_CLASS; + if (isFooter) { + cssClass += ` ${BOTTOM_ACTOR_CLASS}`; + } else { + cssClass += ` ${TOP_ACTOR_CLASS}`; + } + actElem.attr('class', cssClass); + actElem.attr('name', actor.name); + + const rect = svgDrawCommon.getNoteRect(); + rect.x = actor.x; + rect.y = actorY; + rect.fill = '#eaeaea'; + rect.width = actor.width; + rect.height = actor.height; + rect.class = 'actor'; + + actElem + .append('line') + .attr('id', 'actor-man-torso' + actorCnt) + .attr('x1', actor.x + actor.width / 2 - radius * 2.5) + .attr('y1', actorY + 10) + .attr('x2', actor.x + actor.width / 2 - 15) + .attr('y2', actorY + 10); + + actElem + .append('line') + .attr('id', 'actor-man-arms' + actorCnt) + .attr('x1', actor.x + actor.width / 2 - radius * 2.5) + .attr('y1', actorY + 0) // starting Y + .attr('x2', actor.x + actor.width / 2 - radius * 2.5) + .attr('y2', actorY + 20); // ending Y (26px long, adjust as needed) + + actElem + .append('circle') + .attr('cx', actor.x + actor.width / 2) + .attr('cy', actorY + 10) + .attr('r', radius); + + const bounds = actElem.node().getBBox(); + actor.height = bounds.height + (conf.sequence.labelBoxHeight ?? 0); + + _drawTextCandidateFunc(conf, hasKatex(actor.description))( + actor.description, + actElem, + rect.x, + rect.y + (!isFooter ? radius / 2 + 3 : radius / 2 - 4), + rect.width, + rect.height, + { class: `actor ${ACTOR_MAN_FIGURE_CLASS}` }, + conf + ); + + if (!isFooter) { + actElem.attr('transform', `translate(0,${radius / 2 + 7})`); + } else { + actElem.attr('transform', `translate(0,${radius / 2 + 7})`); + } + + return actor.height; +}; + const drawActorTypeActor = function (elem, actor, conf, isFooter) { const actorY = isFooter ? actor.stopy : actor.starty; const center = actor.x + actor.width / 2; @@ -516,6 +1110,18 @@ export const drawActor = async function (elem, actor, conf, isFooter) { return await drawActorTypeActor(elem, actor, conf, isFooter); case 'participant': return await drawActorTypeParticipant(elem, actor, conf, isFooter); + case 'boundary': + return await drawActorTypeBoundary(elem, actor, conf, isFooter); + case 'control': + return await drawActorTypeControl(elem, actor, conf, isFooter); + case 'entity': + return await drawActorTypeEntity(elem, actor, conf, isFooter); + case 'database': + return await drawActorTypeDatabase(elem, actor, conf, isFooter); + case 'collections': + return await drawActorTypeCollections(elem, actor, conf, isFooter); + case 'queue': + return await drawActorTypeQueue(elem, actor, conf, isFooter); } }; diff --git a/packages/mermaid/src/docs/syntax/sequenceDiagram.md b/packages/mermaid/src/docs/syntax/sequenceDiagram.md index c0e927dae..6d10c6f97 100644 --- a/packages/mermaid/src/docs/syntax/sequenceDiagram.md +++ b/packages/mermaid/src/docs/syntax/sequenceDiagram.md @@ -46,6 +46,78 @@ sequenceDiagram Bob->>Alice: Hi Alice ``` +### Boundary + +If you want to use the boundary symbol for a participant, use the JSON configuration syntax as shown below. + +```mermaid-example +sequenceDiagram + participant Alice@{ "type" : "boundary" } + participant Bob + Alice->>Bob: Request from boundary + Bob->>Alice: Response to boundary +``` + +### Control + +If you want to use the control symbol for a participant, use the JSON configuration syntax as shown below. + +```mermaid-example +sequenceDiagram + participant Alice@{ "type" : "control" } + participant Bob + Alice->>Bob: Control request + Bob->>Alice: Control response +``` + +### Entity + +If you want to use the entity symbol for a participant, use the JSON configuration syntax as shown below. + +```mermaid-example +sequenceDiagram + participant Alice@{ "type" : "entity" } + participant Bob + Alice->>Bob: Entity request + Bob->>Alice: Entity response +``` + +### Database + +If you want to use the database symbol for a participant, use the JSON configuration syntax as shown below. + +```mermaid-example +sequenceDiagram + participant Alice@{ "type" : "database" } + participant Bob + Alice->>Bob: DB query + Bob->>Alice: DB result +``` + +### Collections + +If you want to use the collections symbol for a participant, use the JSON configuration syntax as shown below. + +```mermaid-example +sequenceDiagram + participant Alice@{ "type" : "collections" } + participant Bob + Alice->>Bob: Collections request + Bob->>Alice: Collections response +``` + +### Queue + +If you want to use the queue symbol for a participant, use the JSON configuration syntax as shown below. + +```mermaid-example +sequenceDiagram + participant Alice@{ "type" : "queue" } + participant Bob + Alice->>Bob: Queue message + Bob->>Alice: Queue response +``` + ### Aliases The actor can have a convenient identifier and a descriptive label. diff --git a/packages/mermaid/src/types.ts b/packages/mermaid/src/types.ts index d1394e71b..477fb17b1 100644 --- a/packages/mermaid/src/types.ts +++ b/packages/mermaid/src/types.ts @@ -13,6 +13,18 @@ export interface NodeMetaData { ticket?: string; } +export interface ParticipantMetaData { + type?: + | 'actor' + | 'participant' + | 'boundary' + | 'control' + | 'entity' + | 'database' + | 'collections' + | 'queue'; +} + export interface EdgeMetaData { animation?: 'fast' | 'slow'; animate?: boolean;