From 4fae63b62c0192fb65f18eeb5f519caa2081a6b5 Mon Sep 17 00:00:00 2001 From: Chui Tey Date: Fri, 20 Dec 2024 00:46:14 +1000 Subject: [PATCH] Use case with actors and system boundaries --- demos/usecase.html | 117 ++++++++++++++++++ packages/mermaid/src/config.type.ts | 6 +- .../mermaid/src/diagrams/usecase/styles.ts | 28 +++++ .../mermaid/src/diagrams/usecase/usecaseDB.ts | 117 +++++++++++++----- .../src/diagrams/usecase/usecaseDiagram.ts | 2 + .../src/diagrams/usecase/usecaseRenderer.ts | 44 +++++-- .../rendering-elements/shapes.ts | 8 ++ .../rendering-elements/shapes/actor.ts | 43 +++++++ 8 files changed, 322 insertions(+), 43 deletions(-) create mode 100644 demos/usecase.html create mode 100644 packages/mermaid/src/diagrams/usecase/styles.ts create mode 100644 packages/mermaid/src/rendering-util/rendering-elements/shapes/actor.ts diff --git a/demos/usecase.html b/demos/usecase.html new file mode 100644 index 0000000000..3768dfeff3 --- /dev/null +++ b/demos/usecase.html @@ -0,0 +1,117 @@ + + + + + + Use case tests + + + + +

Basic Use Case

+
+    usecase-beta
+      User -> (Open Bank Account)
+      User --> (Get Loan)
+      (Get Loan) --> (Credit Check)
+    
+
+ +

Use Case with Title and Aliases

+
+      usecase-beta
+          title  Simple use case
+          systemboundary
+            title Acme System
+            (Start)
+            (Use) as (Use the application)
+            (Another use case)
+          end
+          User -> (Start)
+          User --> (Use)
+          (Use) --> (Another use case)
+    
+
+ +

Complex Use Case

+
+      usecase-beta
+      title Student Management System Use Cases
+
+      actor Student
+      actor Admin
+      service Authentication
+      service Grades
+      service Courses
+
+      systemboundary
+        title Student Management System
+        (Login) {
+          - Authenticate
+        }
+        (Submit Assignment) {
+          - Upload Assignment
+        }
+        (View Grades)
+        (Manage Users) {
+          - Add User
+          - Edit User
+          - Delete User
+        }
+        (Manage Courses) {
+          - Add Course
+          - Edit Course
+          - Delete Course
+        }
+        (Generate Reports) {
+          - Generate User Report
+          - Generate Course Report
+        }
+        (View Grades) {
+          - View User Grades
+          - View Course Grades
+        }
+      end
+
+      Student -> (Login) -> Authentication
+      Student -> (Submit Assignment) -> Courses
+      Student -> (View Grades) -> Grades
+      Admin -> (Login) -> Authentication
+      Admin -> (Manage Users) -> Courses
+      Admin -> (Manage Courses) -> Courses
+      Admin -> (Generate Reports) -> Courses, Grades
+      Admin -> (View Grades) -> Grades
+    
+ + + + diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index df52d662fd..b6a4e3b8db 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -1488,7 +1488,11 @@ export interface SankeyDiagramConfig extends BaseDiagramConfig { * This interface was referenced by `MermaidConfig`'s JSON-Schema * via the `definition` "UsecaseDiagramConfig". */ -export interface UsecaseDiagramConfig extends BaseDiagramConfig {} +export interface UsecaseDiagramConfig extends BaseDiagramConfig { + diagramMarginX?: number; + diagramMarginY?: number; +} + /** * The object containing configurations specific for packet diagrams. * diff --git a/packages/mermaid/src/diagrams/usecase/styles.ts b/packages/mermaid/src/diagrams/usecase/styles.ts new file mode 100644 index 0000000000..9d18014e89 --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/styles.ts @@ -0,0 +1,28 @@ +export interface UsecaseStyleOptions { + clusterBkg: string; + clusterBorder: string; + lineColor: string; + mainBkg: string; +} + +const getStyles = (options: UsecaseStyleOptions) => + ` + .actor { + stroke: ${options.lineColor}; + background: white; + } + .node { + fill: ${options.mainBkg}; + } + .cluster rect { + fill: ${options.clusterBkg}; + stroke: ${options.clusterBorder}; + stroke-width: 1px; + } + .flowchart-link { + stroke: ${options.lineColor}; + fill: none; + } +`; + +export default getStyles; diff --git a/packages/mermaid/src/diagrams/usecase/usecaseDB.ts b/packages/mermaid/src/diagrams/usecase/usecaseDB.ts index 60172e8e2b..91a2419713 100644 --- a/packages/mermaid/src/diagrams/usecase/usecaseDB.ts +++ b/packages/mermaid/src/diagrams/usecase/usecaseDB.ts @@ -1,5 +1,6 @@ +import type { BaseDiagramConfig, MermaidConfig } from '../../config.type.js'; import { getConfig } from '../../diagram-api/diagramAPI.js'; -import type { LayoutData, MermaidConfig } from '../../mermaid.js'; +import type { LayoutData } from '../../mermaid.js'; import type { Edge, Node } from '../../rendering-util/types.js'; import common from '../common/common.js'; import { @@ -44,47 +45,98 @@ export class UsecaseDB { this.links.push(new UsecaseLink(sourceNode, targetNode, arrow)); } - addSystemBoundary(elements: string[], title?: string) { - this.systemBoundaries.push({ elements, title }); + addSystemBoundary(useCases: string[], title?: string) { + if (title) { + title = common.sanitizeText(title.trim(), getConfig()); + if (title.startsWith('title')) { + title = title.slice(5).trim(); + } + } + this.systemBoundaries.push({ id: 'boundary-' + this.systemBoundaries.length, useCases, title }); + } + + getActors() { + return this.links.map((link) => link.source.id).filter((source) => !source.startsWith('(')); } getConfig() { - return getConfig().usecase!; + return getConfig() as BaseDiagramConfig; } getData(): LayoutData { const edges: Edge[] = this.links.map((link) => ({ - id: `${link.source.ID}-${link.target.ID}`, - type: 'normal', + id: `${link.source.id}-${link.target.id}`, + classes: 'edge-thickness-normal edge-pattern-solid flowchart-link', + start: link.source.id, + end: link.target.id, + arrowTypeStart: 'none', + arrowTypeEnd: 'arrow_point', + label: '', + minlen: 1, + pattern: 'normal', + thickness: 'normal', + type: 'arrow_point', })); + const baseNode = { + shape: 'squareRect', + cssClasses: 'default', + padding: 15, + look: 'classic', + isGroup: false, + styles: [], + }; + + const parentLookup = new Map( + this.getSystemBoundaries().flatMap((boundary) => + boundary.useCases.map((useCase) => [useCase, boundary.id]) + ) + ); + const nodes: Node[] = [ - ...this.nodes.map((node) => ({ - id: node.ID, - type: 'normal', - label: this.aliases.get(node.ID) ?? node.ID, - isGroup: false, - })), - ...this.systemBoundaries.map((boundary) => ({ - id: boundary.title ?? 'System Boundary', - type: 'normal', - label: boundary.title ?? 'System Boundary', - isGroup: true, - children: boundary.elements.map((element) => ({ - id: element, - type: 'normal', - label: this.aliases.get(element) ?? element, - isGroup: false, - })), - })), + ...this.nodes.map( + (node) => + ({ + ...baseNode, + id: node.id, + label: this.aliases.get(node.id) ?? node.id, + parentId: parentLookup.get(node.id), + }) as Node + ), + ...this.getSystemBoundaries().map( + (boundary) => + ({ + ...baseNode, + id: boundary.id, + type: 'normal', + label: boundary.title ?? 'System Boundary', + shape: 'rect', + isGroup: true, + styles: [], + }) as Node + ), ]; - const config = this.getConfig() as MermaidConfig; + nodes + .filter((node) => node.label?.startsWith('(') && node.label.endsWith(')')) + .forEach((node) => { + node.label = node.label!.slice(1, -1); + node.rx = 50; + node.ry = 50; + }); + + // @ts-ignore TODO fix types + const config: MermaidConfig = this.getConfig(); return { nodes, edges, config, + markers: ['point', 'circle', 'cross'], + other: {}, + direction: 'LR', + rankSpacing: 50, + type: 'usecase', }; } @@ -93,10 +145,7 @@ export class UsecaseDB { } getSystemBoundaries() { - return this.systemBoundaries.map((boundary) => ({ - useCases: boundary.elements, - title: boundary.title, - })); + return this.systemBoundaries; } getAccDescription = getAccDescription; @@ -119,7 +168,8 @@ export class UsecaseDB { export class UsecaseSystemBoundary { constructor( - public elements: string[], + public id: string, + public useCases: string[], public title?: string ) {} } @@ -133,7 +183,7 @@ export class UsecaseLink { } export class UsecaseNode { - constructor(public ID: string) {} + constructor(public id: string) {} } // Export an instance of the class @@ -142,11 +192,16 @@ export default { clear: db.clear.bind(db), addRelationship: db.addRelationship.bind(db), addAlias: db.addAlias.bind(db), + getAccDescription, + getAccTitle, + getActors: db.getActors.bind(db), getConfig: db.getConfig.bind(db), getData: db.getData.bind(db), getRelationships: db.getRelationships.bind(db), getDiagramTitle: db.getDiagramTitle.bind(db), getSystemBoundaries: db.getSystemBoundaries.bind(db), + setAccDescription, + setAccTitle, setDiagramTitle: db.setDiagramTitle.bind(db), addSystemBoundary: db.addSystemBoundary.bind(db), }; diff --git a/packages/mermaid/src/diagrams/usecase/usecaseDiagram.ts b/packages/mermaid/src/diagrams/usecase/usecaseDiagram.ts index b8e0c35c35..92509a4f90 100644 --- a/packages/mermaid/src/diagrams/usecase/usecaseDiagram.ts +++ b/packages/mermaid/src/diagrams/usecase/usecaseDiagram.ts @@ -2,6 +2,7 @@ import type { DiagramDefinition } from '../../diagram-api/types.js'; // @ts-ignore: jison doesn't export types import parser from './parser/usecase.jison'; import db from './usecaseDB.js'; +import styles from './styles.js'; import renderer from './usecaseRenderer.js'; import { prepareTextForParsing } from './usecaseUtils.js'; @@ -10,6 +11,7 @@ parser.parse = (text: string) => originalParse(prepareTextForParsing(text)); export const diagram: DiagramDefinition = { parser, + styles, db, renderer, }; diff --git a/packages/mermaid/src/diagrams/usecase/usecaseRenderer.ts b/packages/mermaid/src/diagrams/usecase/usecaseRenderer.ts index 2857bb6c49..59de2bf441 100644 --- a/packages/mermaid/src/diagrams/usecase/usecaseRenderer.ts +++ b/packages/mermaid/src/diagrams/usecase/usecaseRenderer.ts @@ -1,9 +1,12 @@ import type { Diagram } from '../../Diagram.js'; import { getConfig } from '../../diagram-api/diagramAPI.js'; import type { UsecaseDB } from './usecaseDB.js'; -import * as svgDrawCommon from '../../diagrams/common/svgDrawCommon.js'; -import { getRegisteredLayoutAlgorithm, render } from '../../rendering-util/render.js'; +import { render } from '../../rendering-util/render.js'; import { selectSvgElement } from '../../rendering-util/selectSvgElement.js'; +import { setupViewPortForSVG } from '../../rendering-util/setupViewPortForSVG.js'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { setLogLevel } from '../../logger.js'; +import type { LayoutData } from '../../mermaid.js'; /** * Draws Use Case diagram. @@ -19,28 +22,47 @@ export const draw = async function ( _version: string, diagObj: Diagram ): Promise { - // Get Usecase config - const { usecase: conf } = getConfig(); + // setLogLevel('debug'); const db = diagObj.db as UsecaseDB; const svg = selectSvgElement(id); - if (conf?.useMaxWidth) { + const { usecase: conf } = getConfig(); + if (conf!.useMaxWidth) { const svg = selectSvgElement(id); svg.attr('width', '100%'); } - const title = db.getDiagramTitle(); - svg.append('g').attr('class', 'title').append('text'); + const data4Layout = db.getData(); + data4Layout.layoutAlgorithm = 'dagre'; + + preprocess(db, data4Layout); - svgDrawCommon.drawText(svg, { x: 0, y: 100, text: title, textMargin: 5, anchor: 'left' }); + await render(data4Layout, svg); - const layout = db.getData(); - layout.layoutAlgorithm = getRegisteredLayoutAlgorithm('dagre'); + const title = db.getDiagramTitle(); + if (title) { + svg.append('text').attr('text-anchor', 'middle').attr('x', '50%').attr('y', '1em').text(title); + + const extraVertForTitle = 40; + svg.select('g').attr('transform', `translate(0, ${extraVertForTitle})`); + } - await render(layout, svg); + const padding = 8; + setupViewPortForSVG(svg, padding, '', conf?.useMaxWidth ?? true); }; +function preprocess(db: UsecaseDB, data4Layout: LayoutData) { + // assign actor icons + const actors = db.getActors(); + data4Layout.nodes + .filter((node) => actors.includes(node.id)) + .forEach((node) => { + node.shape = 'actor'; + node.cssClasses = 'actor'; + }); +} + export default { draw, }; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts index dbfc93677f..7e6f01a9c5 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts @@ -1,6 +1,7 @@ import type { Entries } from 'type-fest'; import type { D3Selection, MaybePromise } from '../../types.js'; import type { Node, ShapeRenderOptions } from '../types.js'; +import { actor } from './shapes/actor.js'; import { anchor } from './shapes/anchor.js'; import { bowTieRect } from './shapes/bowTieRect.js'; import { card } from './shapes/card.js'; @@ -83,6 +84,13 @@ export interface ShapeDefinition { } export const shapesDefs = [ + { + semanticName: 'Actor', + name: 'Actor', + shortName: 'actor', + description: 'Actor used in Use Cases', + handler: actor, + }, { semanticName: 'Process', name: 'Rectangle', diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/actor.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/actor.ts new file mode 100644 index 0000000000..33449a945b --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/actor.ts @@ -0,0 +1,43 @@ +import { getNodeClasses, labelHelper } from './util.js'; +import type { Node } from '../../types.js'; +import { styles2String } from './handDrawnShapeStyles.js'; +import type { D3Selection } from '../../../types.js'; +import type { Selection } from 'd3-selection'; + +export async function actor( + parent: D3Selection, + node: Node +): Promise> { + const { labelStyles } = styles2String(node); + node.labelStyle = labelStyles; + const { shapeSvg: labelSvg } = await labelHelper(parent, node, getNodeClasses(node)); + const classes = getNodeClasses(node); + let cssClasses = classes; + if (!classes) { + cssClasses = 'actor'; + } + const _ = labelSvg + .insert('path') + .attr('class', cssClasses) + .attr('transform', 'scale(0.5),translate(-50,-220)') + .attr( + 'd', + ` M 50,30 + m -20,0 + a 20,20 0 1,0 40,0 + a 20,20 0 1,0 -40,0 + M 50,50 + L 50,120 + M 50,70 + L 20,100 + M 50,70 + L 80,100 + M 50,120 + L 30,170 + M 50,120 + L 70,170` + ) + .attr('id', node.domId ?? node.id); + + return labelSvg; +}