mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-09-16 05:49:43 +02:00
Compare commits
12 Commits
fix/gantt-
...
feat/useca
Author | SHA1 | Date | |
---|---|---|---|
![]() |
ff2ddae587 | ||
![]() |
58042f1596 | ||
![]() |
181af8167b | ||
![]() |
799d2ed547 | ||
![]() |
08160a74b4 | ||
![]() |
6d221fb3ca | ||
![]() |
8b20907141 | ||
![]() |
a459c436c9 | ||
![]() |
8d4ffdf808 | ||
![]() |
32106e259c | ||
![]() |
47c0d2d040 | ||
![]() |
ac3b777bf6 |
@@ -28,6 +28,7 @@ const MERMAID_CONFIG_DIAGRAM_KEYS = [
|
||||
'packet',
|
||||
'architecture',
|
||||
'radar',
|
||||
'usecase',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'mermaid': patch
|
||||
---
|
||||
|
||||
fix: Resolve gantt chart crash due to invalid array length
|
@@ -143,6 +143,9 @@ typeof
|
||||
typestr
|
||||
unshift
|
||||
urlsafe
|
||||
usecase
|
||||
Usecase
|
||||
USECASE
|
||||
verifymethod
|
||||
VERIFYMTHD
|
||||
WARN_DOCSDIR_DOESNT_MATCH
|
||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@@ -51,5 +51,8 @@ demos/dev/**
|
||||
tsx-0/**
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# autogenereated by langium-cli
|
||||
# autogenereated by langium-cli and antlr-cli
|
||||
generated/
|
||||
|
||||
# autogenereated by antlr-cli
|
||||
.antlr/
|
||||
|
@@ -803,34 +803,4 @@ describe('Gantt diagram', () => {
|
||||
{}
|
||||
);
|
||||
});
|
||||
it('should handle numeric timestamps with dateFormat x', () => {
|
||||
imgSnapshotTest(
|
||||
`
|
||||
gantt
|
||||
title Process time profile (ms)
|
||||
dateFormat x
|
||||
axisFormat %L
|
||||
tickInterval 250millisecond
|
||||
|
||||
section Pipeline
|
||||
Parse JSON p1: 000, 120
|
||||
`,
|
||||
{}
|
||||
);
|
||||
});
|
||||
it('should handle numeric timestamps with dateFormat X', () => {
|
||||
imgSnapshotTest(
|
||||
`
|
||||
gantt
|
||||
title Process time profile (ms)
|
||||
dateFormat X
|
||||
axisFormat %L
|
||||
tickInterval 250millisecond
|
||||
|
||||
section Pipeline
|
||||
Parse JSON p1: 000, 120
|
||||
`,
|
||||
{}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
425
cypress/integration/rendering/usecase.spec.ts
Normal file
425
cypress/integration/rendering/usecase.spec.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import { imgSnapshotTest, renderGraph } from '../../helpers/util.ts';
|
||||
|
||||
describe('Usecase diagram', () => {
|
||||
it('should render a simple usecase diagram with actors and use cases', () => {
|
||||
imgSnapshotTest(
|
||||
`
|
||||
usecase
|
||||
actor User
|
||||
actor Admin
|
||||
User --> Login
|
||||
Admin --> "Manage Users"
|
||||
User --> "View Profile"
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with quoted actor names', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor "Customer Service"
|
||||
actor "System Administrator"
|
||||
"Customer Service" --> "Handle Tickets"
|
||||
"System Administrator" --> "Manage System"
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with different arrow types', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor User
|
||||
actor Admin
|
||||
User --> Login
|
||||
Admin <-- Logout
|
||||
User -- "View Data"
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with edge labels', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor Developer
|
||||
actor Manager
|
||||
Developer --important--> "Write Code"
|
||||
Manager --review--> "Code Review"
|
||||
Developer --urgent--> Manager
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with node ID syntax', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor User
|
||||
User --> a(Login)
|
||||
User --> b("View Profile")
|
||||
User --> c("Update Settings")
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with comma-separated actors', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor "Customer Service", "Technical Support", "Sales Team"
|
||||
actor SystemAdmin
|
||||
"Customer Service" --> "Handle Tickets"
|
||||
"Technical Support" --> "Resolve Issues"
|
||||
"Sales Team" --> "Process Orders"
|
||||
SystemAdmin --> "Manage System"
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with actor metadata', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor User@{ "type" : "primary", "icon" : "user" }
|
||||
actor Admin@{ "type" : "secondary", "icon" : "admin" }
|
||||
actor System@{ "type" : "hollow", "icon" : "system" }
|
||||
User --> Login
|
||||
Admin --> "Manage Users"
|
||||
System --> "Process Data"
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with system boundaries (rect type)', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor Admin, User
|
||||
systemBoundary "Authentication"
|
||||
Login
|
||||
Logout
|
||||
"Reset Password"
|
||||
end
|
||||
"Authentication"@{ type: rect }
|
||||
Admin --> Login
|
||||
User --> Login
|
||||
User --> "Reset Password"
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with system boundaries (package type)', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor Admin, User
|
||||
systemBoundary "Authentication"
|
||||
Login
|
||||
Logout
|
||||
"Reset Password"
|
||||
end
|
||||
"Authentication"@{ type: package }
|
||||
Admin --> Login
|
||||
User --> Login
|
||||
User --> "Reset Password"
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render complex usecase diagram with all features', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor "Customer Service"@{ "type" : "primary", "icon" : "user" }
|
||||
actor "System Admin"@{ "type" : "secondary", "icon" : "admin" }
|
||||
actor "Database"@{ "type" : "hollow", "icon" : "database" }
|
||||
|
||||
systemBoundary "Customer Support System"
|
||||
"Handle Tickets"
|
||||
"View Customer Info"
|
||||
end
|
||||
"Customer Support System"@{ type: package }
|
||||
|
||||
systemBoundary "Administration"
|
||||
"User Management"
|
||||
"System Config"
|
||||
end
|
||||
|
||||
"Customer Service" --priority--> "Handle Tickets"
|
||||
"Customer Service" --> "View Customer Info"
|
||||
"System Admin" --manage--> "User Management"
|
||||
"System Admin" --> "System Config"
|
||||
"Database" <-- "Handle Tickets"
|
||||
"Database" <-- "View Customer Info"
|
||||
"Database" <-- "User Management"
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with actor-to-actor relationships', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor Manager
|
||||
actor Developer
|
||||
actor Tester
|
||||
|
||||
Manager --supervises--> Developer
|
||||
Manager --coordinates--> Tester
|
||||
Developer --collaborates--> Tester
|
||||
|
||||
Developer --> "Write Code"
|
||||
Tester --> "Test Code"
|
||||
Manager --> "Review Progress"
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with mixed relationship types', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor User
|
||||
actor Admin
|
||||
|
||||
User --> "Basic Login"
|
||||
Admin --> "Advanced Login"
|
||||
User --includes--> "View Profile"
|
||||
Admin --extends--> "Manage Profiles"
|
||||
|
||||
"Basic Login" <-- "Advanced Login"
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with long labels and text wrapping', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor "Customer Service Representative"
|
||||
actor "System Administrator with Extended Privileges"
|
||||
|
||||
"Customer Service Representative" --Process--> "Handle Complex Customer Support Tickets"
|
||||
"System Administrator with Extended Privileges" --> "Manage System Configuration and User Permissions"
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with special characters in names', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor "User@Company.com"
|
||||
actor "Admin (Level-1)"
|
||||
"User@Company.com" --> a("Login & Authenticate")
|
||||
"Admin (Level-1)" --> b("Manage Users & Permissions")
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram when useMaxWidth is true (default)', () => {
|
||||
renderGraph(
|
||||
`usecase
|
||||
actor User
|
||||
actor Admin
|
||||
User --> Login
|
||||
Admin --> "Manage System"
|
||||
User --> "View Profile"
|
||||
`,
|
||||
{ usecase: { 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;$/);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render usecase diagram when useMaxWidth is false', () => {
|
||||
renderGraph(
|
||||
`usecase
|
||||
actor User
|
||||
actor Admin
|
||||
User --> Login
|
||||
Admin --> "Manage System"
|
||||
`,
|
||||
{ usecase: { useMaxWidth: false } }
|
||||
);
|
||||
cy.get('svg').should((svg) => {
|
||||
const width = parseFloat(svg.attr('width'));
|
||||
expect(width).to.be.greaterThan(200);
|
||||
expect(svg).to.not.have.attr('style');
|
||||
});
|
||||
});
|
||||
|
||||
it('should render empty usecase diagram', () => {
|
||||
imgSnapshotTest(`usecase`);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with only actors', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor User
|
||||
actor Admin
|
||||
actor Guest
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with implicit use case creation', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor User
|
||||
User --> Login
|
||||
User --> Register
|
||||
User --> "Forgot Password"
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with nested system boundaries', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor User
|
||||
actor Admin
|
||||
|
||||
systemBoundary "Main System"
|
||||
Login
|
||||
Logout
|
||||
"Create User"
|
||||
"Delete User"
|
||||
end
|
||||
|
||||
User --> Login
|
||||
User --> Logout
|
||||
Admin --> "Create User"
|
||||
Admin --> "Delete User"
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with multiple edge labels on same relationship', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor Developer
|
||||
actor Manager
|
||||
|
||||
Developer --"code review"--> Manager
|
||||
Developer --"status update"--> Manager
|
||||
Manager --"feedback"--> Developer
|
||||
Manager --"approval"--> Developer
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with various actor icon types', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor User@{ "icon": "user" }
|
||||
actor Admin@{ "icon": "admin" }
|
||||
actor Database@{ "icon": "database" }
|
||||
actor API@{ "icon": "api" }
|
||||
actor Mobile@{ "icon": "mobile" }
|
||||
actor Web@{ "icon": "web" }
|
||||
|
||||
User --> "Access System"
|
||||
Admin --> "Manage System"
|
||||
Database --> "Store Data"
|
||||
API --> "Provide Services"
|
||||
Mobile --> "Mobile Access"
|
||||
Web --> "Web Access"
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with mixed arrow directions and labels', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor User
|
||||
actor System
|
||||
actor Admin
|
||||
|
||||
User --request--> System
|
||||
System --response--> User
|
||||
System <--monitor-- Admin
|
||||
Admin --configure--> System
|
||||
User -- "direct access" -- Admin
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with boundary-less use cases', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor User
|
||||
actor Admin
|
||||
|
||||
systemBoundary "Secure Area"
|
||||
"Admin Panel"
|
||||
"User Management"
|
||||
end
|
||||
|
||||
User --> "Public Login"
|
||||
User --> "Guest Access"
|
||||
Admin --> "Public Login"
|
||||
Admin --> "Admin Panel"
|
||||
Admin --> "User Management"
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with complex metadata combinations', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor "Primary User"@{ "type": "primary", "icon": "user", "fillColor": "lightblue" }
|
||||
actor "Secondary User"@{ "type": "secondary", "icon": "client", "strokeColor": "red" }
|
||||
actor "System Service"@{ "type": "hollow", "icon": "service", "strokeWidth": "3" }
|
||||
|
||||
"Primary User" --"high priority"--> a("Critical Process")
|
||||
"Secondary User" --"low priority"--> b("Background Task")
|
||||
"System Service" --"automated"--> c("System Maintenance")
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render usecase diagram with Unicode characters', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor "用户"@{ "icon": "user" }
|
||||
actor "管理员"@{ "icon": "admin" }
|
||||
|
||||
"用户" --"登录"--> "系统访问"
|
||||
"管理员" --"管理"--> "用户管理"
|
||||
"用户" --> "数据查看"
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render large usecase diagram with many elements', () => {
|
||||
imgSnapshotTest(
|
||||
`usecase
|
||||
actor User1, User2, User3, User4
|
||||
actor Admin1, Admin2
|
||||
actor System1@{ "icon": "system" }
|
||||
actor System2@{ "icon": "database" }
|
||||
|
||||
systemBoundary "Module A"
|
||||
"Feature A1"
|
||||
"Feature A2"
|
||||
"Admin A1"
|
||||
end
|
||||
"Module A"@{ type: package }
|
||||
|
||||
systemBoundary "Module B"
|
||||
"Feature B1"
|
||||
"Feature B2"
|
||||
"Admin B1"
|
||||
end
|
||||
|
||||
User1 --> "Feature A1"
|
||||
User2 --> "Feature A2"
|
||||
Admin1 --> "Admin A1"
|
||||
User3 --> "Feature B1"
|
||||
User4 --> "Feature B2"
|
||||
Admin2 --> "Admin B1"
|
||||
|
||||
System1 <-- "Feature A1"
|
||||
System1 <-- "Feature B1"
|
||||
System2 <-- "Admin A1"
|
||||
System2 <-- "Admin B1"
|
||||
|
||||
User1 --"collaborates"--> User2
|
||||
Admin1 --"supervises"--> Admin2
|
||||
`
|
||||
);
|
||||
});
|
||||
});
|
@@ -12,4 +12,4 @@
|
||||
|
||||
> `const` **configKeys**: `Set`<`string`>
|
||||
|
||||
Defined in: [packages/mermaid/src/defaultConfig.ts:292](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L292)
|
||||
Defined in: [packages/mermaid/src/defaultConfig.ts:295](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L295)
|
||||
|
@@ -105,7 +105,7 @@ You can set this attribute to base the seed on a static string.
|
||||
|
||||
> `optional` **dompurifyConfig**: `Config`
|
||||
|
||||
Defined in: [packages/mermaid/src/config.type.ts:213](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L213)
|
||||
Defined in: [packages/mermaid/src/config.type.ts:214](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L214)
|
||||
|
||||
---
|
||||
|
||||
@@ -179,7 +179,7 @@ See <https://developer.mozilla.org/en-US/docs/Web/CSS/font-family>
|
||||
|
||||
> `optional` **fontSize**: `number`
|
||||
|
||||
Defined in: [packages/mermaid/src/config.type.ts:215](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L215)
|
||||
Defined in: [packages/mermaid/src/config.type.ts:216](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L216)
|
||||
|
||||
---
|
||||
|
||||
@@ -292,7 +292,7 @@ Defines which main look to use for the diagram.
|
||||
|
||||
> `optional` **markdownAutoWrap**: `boolean`
|
||||
|
||||
Defined in: [packages/mermaid/src/config.type.ts:216](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L216)
|
||||
Defined in: [packages/mermaid/src/config.type.ts:217](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L217)
|
||||
|
||||
---
|
||||
|
||||
@@ -424,7 +424,7 @@ Defined in: [packages/mermaid/src/config.type.ts:198](https://github.com/mermaid
|
||||
|
||||
> `optional` **suppressErrorRendering**: `boolean`
|
||||
|
||||
Defined in: [packages/mermaid/src/config.type.ts:222](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L222)
|
||||
Defined in: [packages/mermaid/src/config.type.ts:223](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L223)
|
||||
|
||||
Suppresses inserting 'Syntax error' diagram in the DOM.
|
||||
This is useful when you want to control how to handle syntax errors in your application.
|
||||
@@ -466,11 +466,19 @@ Defined in: [packages/mermaid/src/config.type.ts:196](https://github.com/mermaid
|
||||
|
||||
---
|
||||
|
||||
### usecase?
|
||||
|
||||
> `optional` **usecase**: `UsecaseDiagramConfig`
|
||||
|
||||
Defined in: [packages/mermaid/src/config.type.ts:213](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L213)
|
||||
|
||||
---
|
||||
|
||||
### wrap?
|
||||
|
||||
> `optional` **wrap**: `boolean`
|
||||
|
||||
Defined in: [packages/mermaid/src/config.type.ts:214](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L214)
|
||||
Defined in: [packages/mermaid/src/config.type.ts:215](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L215)
|
||||
|
||||
---
|
||||
|
||||
|
@@ -38,3 +38,5 @@ Each user journey is split into sections, these describe the part of the task
|
||||
the user is trying to complete.
|
||||
|
||||
Tasks syntax is `Task name: <score>: <comma separated list of actors>`
|
||||
|
||||
Score is a number between 1 and 5, inclusive.
|
||||
|
@@ -210,6 +210,7 @@ export interface MermaidConfig {
|
||||
packet?: PacketDiagramConfig;
|
||||
block?: BlockDiagramConfig;
|
||||
radar?: RadarDiagramConfig;
|
||||
usecase?: UsecaseDiagramConfig;
|
||||
dompurifyConfig?: DOMPurifyConfiguration;
|
||||
wrap?: boolean;
|
||||
fontSize?: number;
|
||||
@@ -1623,6 +1624,50 @@ export interface RadarDiagramConfig extends BaseDiagramConfig {
|
||||
*/
|
||||
curveTension?: number;
|
||||
}
|
||||
/**
|
||||
* The object containing configurations specific for usecase diagrams.
|
||||
*
|
||||
* This interface was referenced by `MermaidConfig`'s JSON-Schema
|
||||
* via the `definition` "UsecaseDiagramConfig".
|
||||
*/
|
||||
export interface UsecaseDiagramConfig extends BaseDiagramConfig {
|
||||
/**
|
||||
* Font size for actor labels
|
||||
*/
|
||||
actorFontSize?: number;
|
||||
/**
|
||||
* Font family for actor labels
|
||||
*/
|
||||
actorFontFamily?: string;
|
||||
/**
|
||||
* Font weight for actor labels
|
||||
*/
|
||||
actorFontWeight?: string;
|
||||
/**
|
||||
* Font size for usecase labels
|
||||
*/
|
||||
usecaseFontSize?: number;
|
||||
/**
|
||||
* Font family for usecase labels
|
||||
*/
|
||||
usecaseFontFamily?: string;
|
||||
/**
|
||||
* Font weight for usecase labels
|
||||
*/
|
||||
usecaseFontWeight?: string;
|
||||
/**
|
||||
* Margin around actors
|
||||
*/
|
||||
actorMargin?: number;
|
||||
/**
|
||||
* Margin around use cases
|
||||
*/
|
||||
usecaseMargin?: number;
|
||||
/**
|
||||
* Padding around the entire diagram
|
||||
*/
|
||||
diagramPadding?: number;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `MermaidConfig`'s JSON-Schema
|
||||
* via the `definition` "FontConfig".
|
||||
|
@@ -264,6 +264,9 @@ const config: RequiredDeep<MermaidConfig> = {
|
||||
radar: {
|
||||
...defaultConfigJson.radar,
|
||||
},
|
||||
usecase: {
|
||||
...defaultConfigJson.usecase,
|
||||
},
|
||||
treemap: {
|
||||
useMaxWidth: true,
|
||||
padding: 10,
|
||||
|
@@ -28,6 +28,7 @@ import architecture from '../diagrams/architecture/architectureDetector.js';
|
||||
import { registerLazyLoadedDiagrams } from './detectType.js';
|
||||
import { registerDiagram } from './diagramAPI.js';
|
||||
import { treemap } from '../diagrams/treemap/detector.js';
|
||||
import { usecase } from '../diagrams/usecase/usecaseDetector.js';
|
||||
import '../type.d.ts';
|
||||
|
||||
let hasLoadedDiagrams = false;
|
||||
@@ -101,6 +102,7 @@ export const addDiagrams = () => {
|
||||
xychart,
|
||||
block,
|
||||
radar,
|
||||
treemap
|
||||
treemap,
|
||||
usecase
|
||||
);
|
||||
};
|
||||
|
@@ -268,9 +268,7 @@ const fixTaskDates = function (startTime, endTime, dateFormat, excludes, include
|
||||
|
||||
const getStartDate = function (prevTime, dateFormat, str) {
|
||||
str = str.trim();
|
||||
if ((dateFormat.trim() === 'x' || dateFormat.trim() === 'X') && /^\d+$/.test(str)) {
|
||||
return new Date(Number(str));
|
||||
}
|
||||
|
||||
// Test for after
|
||||
const afterRePattern = /^after\s+(?<ids>[\d\w- ]+)/;
|
||||
const afterStatement = afterRePattern.exec(str);
|
||||
|
@@ -37,6 +37,7 @@ export class MindmapDB {
|
||||
private nodes: MindmapNode[] = [];
|
||||
private count = 0;
|
||||
private elements: Record<number, D3Element> = {};
|
||||
private baseLevel?: number;
|
||||
public readonly nodeType: typeof nodeType;
|
||||
|
||||
constructor() {
|
||||
@@ -54,6 +55,7 @@ export class MindmapDB {
|
||||
this.nodes = [];
|
||||
this.count = 0;
|
||||
this.elements = {};
|
||||
this.baseLevel = undefined;
|
||||
}
|
||||
|
||||
public getParent(level: number): MindmapNode | null {
|
||||
@@ -72,6 +74,17 @@ export class MindmapDB {
|
||||
public addNode(level: number, id: string, descr: string, type: number): void {
|
||||
log.info('addNode', level, id, descr, type);
|
||||
|
||||
let isRoot = false;
|
||||
|
||||
if (this.nodes.length === 0) {
|
||||
this.baseLevel = level;
|
||||
level = 0;
|
||||
isRoot = true;
|
||||
} else if (this.baseLevel !== undefined) {
|
||||
level = level - this.baseLevel;
|
||||
isRoot = false;
|
||||
}
|
||||
|
||||
const conf = getConfig();
|
||||
let padding = conf.mindmap?.padding ?? defaultConfig.mindmap.padding;
|
||||
|
||||
@@ -92,6 +105,7 @@ export class MindmapDB {
|
||||
children: [],
|
||||
width: conf.mindmap?.maxNodeWidth ?? defaultConfig.mindmap.maxNodeWidth,
|
||||
padding,
|
||||
isRoot,
|
||||
};
|
||||
|
||||
const parent = this.getParent(level);
|
||||
@@ -99,7 +113,7 @@ export class MindmapDB {
|
||||
parent.children.push(node);
|
||||
this.nodes.push(node);
|
||||
} else {
|
||||
if (this.nodes.length === 0) {
|
||||
if (isRoot) {
|
||||
this.nodes.push(node);
|
||||
} else {
|
||||
throw new Error(
|
||||
@@ -204,8 +218,7 @@ export class MindmapDB {
|
||||
// Build CSS classes for the node
|
||||
const cssClasses = ['mindmap-node'];
|
||||
|
||||
// Add section-specific classes
|
||||
if (node.level === 0) {
|
||||
if (node.isRoot === true) {
|
||||
// Root node gets special classes
|
||||
cssClasses.push('section-root', 'section--1');
|
||||
} else if (node.section !== undefined) {
|
||||
|
@@ -15,6 +15,7 @@ export interface MindmapNode {
|
||||
icon?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
isRoot?: boolean;
|
||||
}
|
||||
|
||||
export type FilledMindMapNode = RequiredDeep<MindmapNode>;
|
||||
|
40
packages/mermaid/src/diagrams/usecase/styles.ts
Normal file
40
packages/mermaid/src/diagrams/usecase/styles.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
const getStyles = (options: any) =>
|
||||
`
|
||||
.actor {
|
||||
stroke: ${options.primaryColor};
|
||||
fill: ${options.primaryColor};
|
||||
}
|
||||
|
||||
.actor-label {
|
||||
fill: ${options.primaryTextColor};
|
||||
font-family: ${options.fontFamily};
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.usecase {
|
||||
stroke: ${options.primaryColor};
|
||||
fill: ${options.primaryColor};
|
||||
}
|
||||
|
||||
.usecase-label {
|
||||
fill: ${options.primaryTextColor};
|
||||
font-family: ${options.fontFamily};
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.relationship {
|
||||
stroke: ${options.primaryColor};
|
||||
fill: ${options.primaryColor};
|
||||
}
|
||||
|
||||
.relationship-label {
|
||||
fill: ${options.primaryTextColor};
|
||||
font-family: ${options.fontFamily};
|
||||
font-size: 10px;
|
||||
font-weight: normal;
|
||||
}
|
||||
`;
|
||||
|
||||
export default getStyles;
|
112
packages/mermaid/src/diagrams/usecase/usecase.spec.ts
Normal file
112
packages/mermaid/src/diagrams/usecase/usecase.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { vi } from 'vitest';
|
||||
import { setSiteConfig } from '../../diagram-api/diagramAPI.js';
|
||||
import mermaidAPI from '../../mermaidAPI.js';
|
||||
import { Diagram } from '../../Diagram.js';
|
||||
import { addDiagrams } from '../../diagram-api/diagram-orchestration.js';
|
||||
|
||||
beforeAll(async () => {
|
||||
// Is required to load the useCase diagram
|
||||
await Diagram.fromText('usecase\n actor TestActor');
|
||||
});
|
||||
|
||||
/**
|
||||
* UseCase diagrams require a basic d3 mock for rendering
|
||||
*/
|
||||
vi.mock('d3', () => {
|
||||
const NewD3 = function (this: any) {
|
||||
function returnThis(this: any) {
|
||||
return this;
|
||||
}
|
||||
return {
|
||||
append: function () {
|
||||
return NewD3();
|
||||
},
|
||||
lower: returnThis,
|
||||
attr: returnThis,
|
||||
style: returnThis,
|
||||
text: returnThis,
|
||||
getBBox: function () {
|
||||
return {
|
||||
height: 10,
|
||||
width: 20,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
select: function () {
|
||||
return new (NewD3 as any)();
|
||||
},
|
||||
|
||||
selectAll: function () {
|
||||
return new (NewD3 as any)();
|
||||
},
|
||||
|
||||
// TODO: In d3 these are CurveFactory types, not strings
|
||||
curveBasis: 'basis',
|
||||
curveBasisClosed: 'basisClosed',
|
||||
curveBasisOpen: 'basisOpen',
|
||||
curveBumpX: 'bumpX',
|
||||
curveBumpY: 'bumpY',
|
||||
curveBundle: 'bundle',
|
||||
curveCardinalClosed: 'cardinalClosed',
|
||||
curveCardinalOpen: 'cardinalOpen',
|
||||
curveCardinal: 'cardinal',
|
||||
curveCatmullRomClosed: 'catmullRomClosed',
|
||||
curveCatmullRomOpen: 'catmullRomOpen',
|
||||
curveCatmullRom: 'catmullRom',
|
||||
curveLinear: 'linear',
|
||||
curveLinearClosed: 'linearClosed',
|
||||
curveMonotoneX: 'monotoneX',
|
||||
curveMonotoneY: 'monotoneY',
|
||||
curveNatural: 'natural',
|
||||
curveStep: 'step',
|
||||
curveStepAfter: 'stepAfter',
|
||||
curveStepBefore: 'stepBefore',
|
||||
};
|
||||
});
|
||||
// -------------------------------
|
||||
|
||||
addDiagrams();
|
||||
|
||||
/**
|
||||
* @param conf - Configuration object
|
||||
* @param key - Configuration key
|
||||
* @param value - Configuration value
|
||||
*/
|
||||
function addConf(conf: any, key: any, value: any) {
|
||||
if (value !== undefined) {
|
||||
conf[key] = value;
|
||||
}
|
||||
return conf;
|
||||
}
|
||||
|
||||
describe('UseCase diagram with ANTLR parser', () => {
|
||||
it('should parse actors and use cases correctly', async () => {
|
||||
const diagram = await Diagram.fromText(`
|
||||
usecase
|
||||
actor Developer1
|
||||
actor Developer2
|
||||
usecase "Login System"
|
||||
usecase Authentication
|
||||
Developer1 --> "Login System"
|
||||
Developer2 --> Authentication
|
||||
`);
|
||||
|
||||
expect(diagram).toBeDefined();
|
||||
expect(diagram.type).toBe('usecase');
|
||||
});
|
||||
|
||||
it('should handle simple usecase diagram', async () => {
|
||||
const diagram = await Diagram.fromText(`
|
||||
usecase
|
||||
actor User
|
||||
usecase "View Profile"
|
||||
User --> "View Profile"
|
||||
`);
|
||||
|
||||
expect(diagram).toBeDefined();
|
||||
expect(diagram.type).toBe('usecase');
|
||||
});
|
||||
});
|
175
packages/mermaid/src/diagrams/usecase/usecaseDb.ts
Normal file
175
packages/mermaid/src/diagrams/usecase/usecaseDb.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { log } from '../../logger.js';
|
||||
import {
|
||||
setAccTitle,
|
||||
getAccTitle,
|
||||
setDiagramTitle,
|
||||
getDiagramTitle,
|
||||
getAccDescription,
|
||||
setAccDescription,
|
||||
clear as commonClear,
|
||||
} from '../common/commonDb.js';
|
||||
import type {
|
||||
UsecaseFields,
|
||||
UsecaseDB,
|
||||
Actor,
|
||||
UseCase,
|
||||
SystemBoundary,
|
||||
Relationship,
|
||||
} from './usecaseTypes.js';
|
||||
import type { RequiredDeep } from 'type-fest';
|
||||
import type { UsecaseDiagramConfig } from '../../config.type.js';
|
||||
import DEFAULT_CONFIG from '../../defaultConfig.js';
|
||||
|
||||
export const DEFAULT_USECASE_CONFIG: Required<UsecaseDiagramConfig> = DEFAULT_CONFIG.usecase;
|
||||
|
||||
export const DEFAULT_USECASE_DB: RequiredDeep<UsecaseFields> = {
|
||||
actors: new Map(),
|
||||
useCases: new Map(),
|
||||
systemBoundaries: new Map(),
|
||||
relationships: [],
|
||||
config: DEFAULT_USECASE_CONFIG,
|
||||
} as const;
|
||||
|
||||
let actors = new Map<string, Actor>();
|
||||
let useCases = new Map<string, UseCase>();
|
||||
let systemBoundaries = new Map<string, SystemBoundary>();
|
||||
let relationships: Relationship[] = [];
|
||||
const config: Required<UsecaseDiagramConfig> = structuredClone(DEFAULT_USECASE_CONFIG);
|
||||
|
||||
const getConfig = (): Required<UsecaseDiagramConfig> => structuredClone(config);
|
||||
|
||||
const clear = (): void => {
|
||||
actors = new Map();
|
||||
useCases = new Map();
|
||||
systemBoundaries = new Map();
|
||||
relationships = [];
|
||||
commonClear();
|
||||
};
|
||||
|
||||
// Actor management
|
||||
const addActor = (actor: Actor): void => {
|
||||
if (!actor.id || !actor.name) {
|
||||
throw new Error(
|
||||
`Invalid actor: Actor must have both id and name. Received: ${JSON.stringify(actor)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!actors.has(actor.id)) {
|
||||
actors.set(actor.id, actor);
|
||||
log.debug(`Added actor: ${actor.id} (${actor.name})`);
|
||||
} else {
|
||||
log.debug(`Actor ${actor.id} already exists`);
|
||||
}
|
||||
};
|
||||
|
||||
const getActors = (): Map<string, Actor> => actors;
|
||||
|
||||
const getActor = (id: string): Actor | undefined => actors.get(id);
|
||||
|
||||
// UseCase management
|
||||
const addUseCase = (useCase: UseCase): void => {
|
||||
if (!useCase.id || !useCase.name) {
|
||||
throw new Error(
|
||||
`Invalid use case: Use case must have both id and name. Received: ${JSON.stringify(useCase)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!useCases.has(useCase.id)) {
|
||||
useCases.set(useCase.id, useCase);
|
||||
log.debug(`Added use case: ${useCase.id} (${useCase.name})`);
|
||||
} else {
|
||||
log.debug(`Use case ${useCase.id} already exists`);
|
||||
}
|
||||
};
|
||||
|
||||
const getUseCases = (): Map<string, UseCase> => useCases;
|
||||
|
||||
const getUseCase = (id: string): UseCase | undefined => useCases.get(id);
|
||||
|
||||
// SystemBoundary management
|
||||
const addSystemBoundary = (systemBoundary: SystemBoundary): void => {
|
||||
if (!systemBoundary.id || !systemBoundary.name) {
|
||||
throw new Error(
|
||||
`Invalid system boundary: System boundary must have both id and name. Received: ${JSON.stringify(systemBoundary)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!systemBoundaries.has(systemBoundary.id)) {
|
||||
systemBoundaries.set(systemBoundary.id, systemBoundary);
|
||||
log.debug(`Added system boundary: ${systemBoundary.name}`);
|
||||
} else {
|
||||
log.debug(`System boundary ${systemBoundary.id} already exists`);
|
||||
}
|
||||
};
|
||||
|
||||
const getSystemBoundaries = (): Map<string, SystemBoundary> => systemBoundaries;
|
||||
|
||||
const getSystemBoundary = (id: string): SystemBoundary | undefined => systemBoundaries.get(id);
|
||||
|
||||
// Relationship management
|
||||
const addRelationship = (relationship: Relationship): void => {
|
||||
// Validate relationship structure
|
||||
if (!relationship.id || !relationship.from || !relationship.to) {
|
||||
throw new Error(
|
||||
`Invalid relationship: Relationship must have id, from, and to fields. Received: ${JSON.stringify(relationship)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!relationship.type) {
|
||||
throw new Error(
|
||||
`Invalid relationship: Relationship must have a type. Received: ${JSON.stringify(relationship)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate relationship type
|
||||
const validTypes = ['association', 'include', 'extend'];
|
||||
if (!validTypes.includes(relationship.type)) {
|
||||
throw new Error(
|
||||
`Invalid relationship type: ${relationship.type}. Valid types are: ${validTypes.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate arrow type if present
|
||||
if (relationship.arrowType !== undefined) {
|
||||
const validArrowTypes = [0, 1, 2]; // SOLID_ARROW, BACK_ARROW, LINE_SOLID
|
||||
if (!validArrowTypes.includes(relationship.arrowType)) {
|
||||
throw new Error(
|
||||
`Invalid arrow type: ${relationship.arrowType}. Valid arrow types are: ${validArrowTypes.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
relationships.push(relationship);
|
||||
log.debug(
|
||||
`Added relationship: ${relationship.from} -> ${relationship.to} (${relationship.type})`
|
||||
);
|
||||
};
|
||||
|
||||
const getRelationships = (): Relationship[] => relationships;
|
||||
|
||||
export const db: UsecaseDB = {
|
||||
getConfig,
|
||||
|
||||
clear,
|
||||
setDiagramTitle,
|
||||
getDiagramTitle,
|
||||
setAccTitle,
|
||||
getAccTitle,
|
||||
setAccDescription,
|
||||
getAccDescription,
|
||||
|
||||
addActor,
|
||||
getActors,
|
||||
getActor,
|
||||
|
||||
addUseCase,
|
||||
getUseCases,
|
||||
getUseCase,
|
||||
|
||||
addSystemBoundary,
|
||||
getSystemBoundaries,
|
||||
getSystemBoundary,
|
||||
|
||||
addRelationship,
|
||||
getRelationships,
|
||||
};
|
22
packages/mermaid/src/diagrams/usecase/usecaseDetector.ts
Normal file
22
packages/mermaid/src/diagrams/usecase/usecaseDetector.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type {
|
||||
DiagramDetector,
|
||||
DiagramLoader,
|
||||
ExternalDiagramDefinition,
|
||||
} from '../../diagram-api/types.js';
|
||||
|
||||
const id = 'usecase';
|
||||
|
||||
const detector: DiagramDetector = (txt) => {
|
||||
return /^\s*usecase/.test(txt);
|
||||
};
|
||||
|
||||
const loader: DiagramLoader = async () => {
|
||||
const { diagram } = await import('./usecaseDiagram.js');
|
||||
return { id, diagram };
|
||||
};
|
||||
|
||||
export const usecase: ExternalDiagramDefinition = {
|
||||
id,
|
||||
detector,
|
||||
loader,
|
||||
};
|
12
packages/mermaid/src/diagrams/usecase/usecaseDiagram.ts
Normal file
12
packages/mermaid/src/diagrams/usecase/usecaseDiagram.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { DiagramDefinition } from '../../diagram-api/types.js';
|
||||
import { parser } from './usecaseParser.js';
|
||||
import { db } from './usecaseDb.js';
|
||||
import { renderer } from './usecaseRenderer.js';
|
||||
import styles from './styles.js';
|
||||
|
||||
export const diagram: DiagramDefinition = {
|
||||
parser,
|
||||
db,
|
||||
renderer,
|
||||
styles,
|
||||
};
|
150
packages/mermaid/src/diagrams/usecase/usecaseParser.ts
Normal file
150
packages/mermaid/src/diagrams/usecase/usecaseParser.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
// Import ANTLR parser from the parser package
|
||||
import { parse } from '@mermaid-js/parser';
|
||||
import { log } from '../../logger.js';
|
||||
import type { ParserDefinition } from '../../diagram-api/types.js';
|
||||
import { populateCommonDb } from '../common/populateCommonDb.js';
|
||||
import type {
|
||||
UsecaseDB,
|
||||
Actor,
|
||||
UseCase,
|
||||
SystemBoundary,
|
||||
Relationship,
|
||||
ArrowType,
|
||||
} from './usecaseTypes.js';
|
||||
import { db } from './usecaseDb.js';
|
||||
|
||||
// ANTLR parser result interface
|
||||
interface UsecaseParseResult {
|
||||
actors: { id: string; name: string; metadata?: Record<string, string> }[];
|
||||
useCases: { id: string; name: string; nodeId?: string; systemBoundary?: string }[];
|
||||
systemBoundaries: { id: string; name: string; useCases: string[]; type?: 'package' | 'rect' }[];
|
||||
relationships: {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
type: 'association' | 'include' | 'extend';
|
||||
arrowType: number;
|
||||
label?: string;
|
||||
}[];
|
||||
accDescr?: string;
|
||||
accTitle?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse usecase diagram using ANTLR parser
|
||||
*/
|
||||
const parseUsecaseWithAntlr = async (input: string): Promise<UsecaseParseResult> => {
|
||||
// Use the ANTLR parser from @mermaid-js/parser
|
||||
const result = (await parse('usecase', input)) as UsecaseParseResult;
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Populate the database with parsed ANTLR results
|
||||
*/
|
||||
const populateDb = (ast: UsecaseParseResult, db: UsecaseDB) => {
|
||||
// Clear existing data
|
||||
db.clear();
|
||||
|
||||
// Add actors (ANTLR result already has id, name, and metadata)
|
||||
ast.actors.forEach((actorData) => {
|
||||
const actor: Actor = {
|
||||
id: actorData.id,
|
||||
name: actorData.name,
|
||||
metadata: actorData.metadata,
|
||||
};
|
||||
db.addActor(actor);
|
||||
});
|
||||
|
||||
// Add use cases (ANTLR result already has id, name, nodeId, and systemBoundary)
|
||||
ast.useCases.forEach((useCaseData) => {
|
||||
const useCase: UseCase = {
|
||||
id: useCaseData.id,
|
||||
name: useCaseData.name,
|
||||
nodeId: useCaseData.nodeId,
|
||||
systemBoundary: useCaseData.systemBoundary,
|
||||
};
|
||||
db.addUseCase(useCase);
|
||||
});
|
||||
|
||||
// Add system boundaries
|
||||
if (ast.systemBoundaries) {
|
||||
ast.systemBoundaries.forEach((boundaryData) => {
|
||||
const systemBoundary: SystemBoundary = {
|
||||
id: boundaryData.id,
|
||||
name: boundaryData.name,
|
||||
useCases: boundaryData.useCases,
|
||||
type: boundaryData.type || 'rect', // default to 'rect' if not specified
|
||||
};
|
||||
db.addSystemBoundary(systemBoundary);
|
||||
});
|
||||
}
|
||||
|
||||
// Add relationships (ANTLR result already has proper structure)
|
||||
ast.relationships.forEach((relationshipData) => {
|
||||
const relationship: Relationship = {
|
||||
id: relationshipData.id,
|
||||
from: relationshipData.from,
|
||||
to: relationshipData.to,
|
||||
type: relationshipData.type,
|
||||
arrowType: relationshipData.arrowType as ArrowType,
|
||||
label: relationshipData.label,
|
||||
};
|
||||
db.addRelationship(relationship);
|
||||
});
|
||||
|
||||
log.debug('Populated usecase database:', {
|
||||
actors: ast.actors.length,
|
||||
useCases: ast.useCases.length,
|
||||
relationships: ast.relationships.length,
|
||||
});
|
||||
};
|
||||
|
||||
export const parser: ParserDefinition = {
|
||||
parse: async (input: string): Promise<void> => {
|
||||
log.debug('Parsing usecase diagram with ANTLR:', input);
|
||||
|
||||
try {
|
||||
// Use our ANTLR parser
|
||||
const ast: UsecaseParseResult = await parseUsecaseWithAntlr(input);
|
||||
log.debug('ANTLR parsing result:', ast);
|
||||
|
||||
// Populate common database fields
|
||||
populateCommonDb(ast as any, db);
|
||||
|
||||
// Populate the database with validation
|
||||
populateDb(ast, db);
|
||||
|
||||
log.debug('Usecase diagram parsing completed successfully');
|
||||
} catch (error) {
|
||||
log.error('Error parsing usecase diagram:', error);
|
||||
|
||||
// Check if it's a UsecaseParseError from our ANTLR parser
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'name' in error &&
|
||||
error.name === 'UsecaseParseError'
|
||||
) {
|
||||
// Re-throw the detailed error for better error reporting
|
||||
throw error;
|
||||
}
|
||||
|
||||
// For other errors, wrap them in a generic error
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
const wrappedError = new Error(`Failed to parse usecase diagram: ${errorMessage}`);
|
||||
|
||||
// Add hash property for consistency with other diagram types
|
||||
(wrappedError as any).hash = {
|
||||
text: input.split('\n')[0] || '',
|
||||
token: 'unknown',
|
||||
line: '1',
|
||||
loc: { first_line: 1, last_line: 1, first_column: 1, last_column: 1 },
|
||||
expected: ['valid usecase syntax'],
|
||||
};
|
||||
|
||||
throw wrappedError;
|
||||
}
|
||||
},
|
||||
};
|
545
packages/mermaid/src/diagrams/usecase/usecaseRenderer.ts
Normal file
545
packages/mermaid/src/diagrams/usecase/usecaseRenderer.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
import type { DrawDefinition, SVG, SVGGroup } from '../../diagram-api/types.js';
|
||||
import { log } from '../../logger.js';
|
||||
import { selectSvgElement } from '../../rendering-util/selectSvgElement.js';
|
||||
import { setupViewPortForSVG } from '../../rendering-util/setupViewPortForSVG.js';
|
||||
import { getConfig } from '../../diagram-api/diagramAPI.js';
|
||||
import {
|
||||
insertEdgeLabel,
|
||||
positionEdgeLabel,
|
||||
} from '../../rendering-util/rendering-elements/edges.js';
|
||||
import { db } from './usecaseDb.js';
|
||||
import { ARROW_TYPE } from './usecaseTypes.js';
|
||||
import type { Actor, UseCase, SystemBoundary, Relationship } from './usecaseTypes.js';
|
||||
|
||||
// Layout constants
|
||||
const ACTOR_WIDTH = 80;
|
||||
const ACTOR_HEIGHT = 100;
|
||||
const USECASE_WIDTH = 120;
|
||||
const USECASE_HEIGHT = 60;
|
||||
const SYSTEM_BOUNDARY_PADDING = 30;
|
||||
const MARGIN = 50;
|
||||
const SPACING_X = 200;
|
||||
const SPACING_Y = 150;
|
||||
|
||||
/**
|
||||
* Get actor styling based on metadata
|
||||
*/
|
||||
const getActorStyling = (metadata?: Record<string, string>) => {
|
||||
const defaults = {
|
||||
fillColor: 'none',
|
||||
strokeColor: 'black',
|
||||
strokeWidth: 2,
|
||||
type: 'solid',
|
||||
};
|
||||
|
||||
if (!metadata) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
return {
|
||||
fillColor: metadata.type === 'hollow' ? 'none' : metadata.fillColor || defaults.fillColor,
|
||||
strokeColor: metadata.strokeColor || defaults.strokeColor,
|
||||
strokeWidth: parseInt(metadata.strokeWidth || '2', 10),
|
||||
type: metadata.type || defaults.type,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw an actor (stick figure) with metadata support
|
||||
*/
|
||||
const drawActor = (group: SVGGroup, actor: Actor, x: number, y: number): void => {
|
||||
const actorGroup = group.append('g').attr('class', 'actor').attr('id', actor.id);
|
||||
const styling = getActorStyling(actor.metadata);
|
||||
|
||||
// Add metadata as data attributes for CSS styling
|
||||
if (actor.metadata) {
|
||||
Object.entries(actor.metadata).forEach(([key, value]) => {
|
||||
actorGroup.attr(`data-${key}`, value);
|
||||
});
|
||||
}
|
||||
|
||||
// Check if we should render an icon instead of stick figure
|
||||
if (actor.metadata?.icon) {
|
||||
drawActorWithIcon(actorGroup, actor, x, y, styling);
|
||||
} else {
|
||||
drawStickFigure(actorGroup, actor, x, y, styling);
|
||||
}
|
||||
|
||||
// Actor name (always rendered)
|
||||
const displayName = actor.metadata?.name ?? actor.name;
|
||||
actorGroup
|
||||
.append('text')
|
||||
.attr('x', x)
|
||||
.attr('y', y + 50)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('class', 'actor-label')
|
||||
.text(displayName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw traditional stick figure
|
||||
*/
|
||||
const drawStickFigure = (
|
||||
actorGroup: SVGGroup,
|
||||
_actor: Actor,
|
||||
x: number,
|
||||
y: number,
|
||||
styling: ReturnType<typeof getActorStyling>
|
||||
): void => {
|
||||
// Head (circle)
|
||||
actorGroup
|
||||
.append('circle')
|
||||
.attr('cx', x)
|
||||
.attr('cy', y - 30)
|
||||
.attr('r', 8)
|
||||
.attr('fill', styling.fillColor)
|
||||
.attr('stroke', styling.strokeColor)
|
||||
.attr('stroke-width', styling.strokeWidth);
|
||||
|
||||
// Body (line)
|
||||
actorGroup
|
||||
.append('line')
|
||||
.attr('x1', x)
|
||||
.attr('y1', y - 22)
|
||||
.attr('x2', x)
|
||||
.attr('y2', y + 10)
|
||||
.attr('stroke', styling.strokeColor)
|
||||
.attr('stroke-width', styling.strokeWidth);
|
||||
|
||||
// Arms (line)
|
||||
actorGroup
|
||||
.append('line')
|
||||
.attr('x1', x - 15)
|
||||
.attr('y1', y - 10)
|
||||
.attr('x2', x + 15)
|
||||
.attr('y2', y - 10)
|
||||
.attr('stroke', styling.strokeColor)
|
||||
.attr('stroke-width', styling.strokeWidth);
|
||||
|
||||
// Left leg
|
||||
actorGroup
|
||||
.append('line')
|
||||
.attr('x1', x)
|
||||
.attr('y1', y + 10)
|
||||
.attr('x2', x - 15)
|
||||
.attr('y2', y + 30)
|
||||
.attr('stroke', styling.strokeColor)
|
||||
.attr('stroke-width', styling.strokeWidth);
|
||||
|
||||
// Right leg
|
||||
actorGroup
|
||||
.append('line')
|
||||
.attr('x1', x)
|
||||
.attr('y1', y + 10)
|
||||
.attr('x2', x + 15)
|
||||
.attr('y2', y + 30)
|
||||
.attr('stroke', styling.strokeColor)
|
||||
.attr('stroke-width', styling.strokeWidth);
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw actor with icon representation
|
||||
*/
|
||||
const drawActorWithIcon = (
|
||||
actorGroup: SVGGroup,
|
||||
actor: Actor,
|
||||
x: number,
|
||||
y: number,
|
||||
styling: ReturnType<typeof getActorStyling>
|
||||
): void => {
|
||||
const iconName = actor.metadata?.icon ?? 'user';
|
||||
|
||||
// Create a rectangle background for the icon
|
||||
actorGroup
|
||||
.append('rect')
|
||||
.attr('x', x - 20)
|
||||
.attr('y', y - 35)
|
||||
.attr('width', 40)
|
||||
.attr('height', 40)
|
||||
.attr('rx', 5)
|
||||
.attr('fill', styling.fillColor === 'none' ? 'white' : styling.fillColor)
|
||||
.attr('stroke', styling.strokeColor)
|
||||
.attr('stroke-width', styling.strokeWidth);
|
||||
|
||||
// Add icon text (could be enhanced to use actual icons/SVG symbols)
|
||||
actorGroup
|
||||
.append('text')
|
||||
.attr('x', x)
|
||||
.attr('y', y - 10)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('class', 'actor-icon')
|
||||
.attr('font-size', '16px')
|
||||
.attr('font-weight', 'bold')
|
||||
.text(getIconSymbol(iconName));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get symbol representation for common icons
|
||||
*/
|
||||
const getIconSymbol = (iconName: string): string => {
|
||||
const iconMap: Record<string, string> = {
|
||||
user: '👤',
|
||||
admin: '👑',
|
||||
system: '⚙️',
|
||||
database: '🗄️',
|
||||
api: '🔌',
|
||||
service: '🔧',
|
||||
client: '💻',
|
||||
server: '🖥️',
|
||||
mobile: '📱',
|
||||
web: '🌐',
|
||||
default: '👤',
|
||||
};
|
||||
|
||||
return iconMap[iconName.toLowerCase()] || iconMap.default;
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw a use case (oval)
|
||||
*/
|
||||
const drawUseCase = (group: SVGGroup, useCase: UseCase, x: number, y: number): void => {
|
||||
const useCaseGroup = group.append('g').attr('class', 'usecase').attr('id', useCase.id);
|
||||
|
||||
// Oval
|
||||
useCaseGroup
|
||||
.append('ellipse')
|
||||
.attr('cx', x)
|
||||
.attr('cy', y)
|
||||
.attr('rx', USECASE_WIDTH / 2)
|
||||
.attr('ry', USECASE_HEIGHT / 2)
|
||||
.attr('fill', 'white')
|
||||
.attr('stroke', 'black')
|
||||
.attr('stroke-width', 2);
|
||||
|
||||
// Use case name
|
||||
useCaseGroup
|
||||
.append('text')
|
||||
.attr('x', x)
|
||||
.attr('y', y + 5)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('class', 'usecase-label')
|
||||
.text(useCase.name);
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw a system boundary (supports both 'rect' and 'package' types)
|
||||
*/
|
||||
const drawSystemBoundary = (
|
||||
group: SVGGroup,
|
||||
boundary: SystemBoundary,
|
||||
useCasePositions: Map<string, { x: number; y: number }>
|
||||
): void => {
|
||||
// Calculate boundary dimensions based on contained use cases
|
||||
const containedUseCases = boundary.useCases
|
||||
.map((ucId) => useCasePositions.get(ucId))
|
||||
.filter((pos) => pos !== undefined) as { x: number; y: number }[];
|
||||
|
||||
if (containedUseCases.length === 0) {
|
||||
return; // No use cases to bound
|
||||
}
|
||||
|
||||
// Find min/max coordinates of contained use cases
|
||||
const minX =
|
||||
Math.min(...containedUseCases.map((pos) => pos.x)) -
|
||||
USECASE_WIDTH / 2 -
|
||||
SYSTEM_BOUNDARY_PADDING;
|
||||
const maxX =
|
||||
Math.max(...containedUseCases.map((pos) => pos.x)) +
|
||||
USECASE_WIDTH / 2 +
|
||||
SYSTEM_BOUNDARY_PADDING;
|
||||
const minY =
|
||||
Math.min(...containedUseCases.map((pos) => pos.y)) -
|
||||
USECASE_HEIGHT / 2 -
|
||||
SYSTEM_BOUNDARY_PADDING;
|
||||
const maxY =
|
||||
Math.max(...containedUseCases.map((pos) => pos.y)) +
|
||||
USECASE_HEIGHT / 2 +
|
||||
SYSTEM_BOUNDARY_PADDING;
|
||||
|
||||
const boundaryType = boundary.type || 'rect'; // default to 'rect'
|
||||
const boundaryGroup = group.append('g').attr('class', 'system-boundary').attr('id', boundary.id);
|
||||
|
||||
if (boundaryType === 'package') {
|
||||
drawPackageBoundary(boundaryGroup, boundary, minX, minY, maxX, maxY);
|
||||
} else {
|
||||
drawRectBoundary(boundaryGroup, boundary, minX, minY, maxX, maxY);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw rect-type system boundary (simple dashed rectangle)
|
||||
*/
|
||||
const drawRectBoundary = (
|
||||
boundaryGroup: SVGGroup,
|
||||
boundary: SystemBoundary,
|
||||
minX: number,
|
||||
minY: number,
|
||||
maxX: number,
|
||||
maxY: number
|
||||
): void => {
|
||||
// Draw dashed rectangle
|
||||
boundaryGroup
|
||||
.append('rect')
|
||||
.attr('x', minX)
|
||||
.attr('y', minY)
|
||||
.attr('width', maxX - minX)
|
||||
.attr('height', maxY - minY)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', 'black')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('stroke-dasharray', '5,5');
|
||||
// .attr('rx', 10)
|
||||
// .attr('ry', 10);
|
||||
|
||||
// Draw boundary label at top-left
|
||||
boundaryGroup
|
||||
.append('text')
|
||||
.attr('x', minX + 10)
|
||||
.attr('y', minY + 20)
|
||||
.attr('class', 'system-boundary-label')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('font-size', '14px')
|
||||
.text(boundary.name);
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw package-type system boundary (rectangle with separate name box)
|
||||
*/
|
||||
const drawPackageBoundary = (
|
||||
boundaryGroup: SVGGroup,
|
||||
boundary: SystemBoundary,
|
||||
minX: number,
|
||||
minY: number,
|
||||
maxX: number,
|
||||
maxY: number
|
||||
): void => {
|
||||
// Calculate name box dimensions
|
||||
const nameBoxWidth = Math.max(80, boundary.name.length * 8 + 20);
|
||||
const nameBoxHeight = 25;
|
||||
|
||||
// Draw main boundary rectangle
|
||||
boundaryGroup
|
||||
.append('rect')
|
||||
.attr('x', minX)
|
||||
.attr('y', minY)
|
||||
.attr('width', maxX - minX)
|
||||
.attr('height', maxY - minY)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', 'black')
|
||||
.attr('stroke-width', 2);
|
||||
|
||||
// Draw name box (package tab)
|
||||
boundaryGroup
|
||||
.append('rect')
|
||||
.attr('x', minX)
|
||||
.attr('y', minY - nameBoxHeight)
|
||||
.attr('width', nameBoxWidth)
|
||||
.attr('height', nameBoxHeight)
|
||||
.attr('fill', 'white')
|
||||
.attr('stroke', 'black')
|
||||
.attr('stroke-width', 2);
|
||||
// .attr('rx', 5)
|
||||
// .attr('ry', 5);
|
||||
|
||||
// Draw boundary label in the name box
|
||||
boundaryGroup
|
||||
.append('text')
|
||||
.attr('x', minX + nameBoxWidth / 2)
|
||||
.attr('y', minY - nameBoxHeight / 2 + 5)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('class', 'system-boundary-label')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('font-size', '14px')
|
||||
.text(boundary.name);
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw a relationship (line with arrow)
|
||||
*/
|
||||
const drawRelationship = async (
|
||||
group: SVGGroup,
|
||||
relationship: Relationship,
|
||||
fromPos: { x: number; y: number },
|
||||
toPos: { x: number; y: number }
|
||||
): Promise<void> => {
|
||||
const relationshipGroup = group
|
||||
.append('g')
|
||||
.attr('class', 'relationship')
|
||||
.attr('id', relationship.id);
|
||||
|
||||
// Calculate arrow direction
|
||||
const dx = toPos.x - fromPos.x;
|
||||
const dy = toPos.y - fromPos.y;
|
||||
const length = Math.sqrt(dx * dx + dy * dy);
|
||||
const unitX = dx / length;
|
||||
const unitY = dy / length;
|
||||
|
||||
// Adjust start and end points to avoid overlapping with shapes
|
||||
const startX = fromPos.x + unitX * 40;
|
||||
const startY = fromPos.y + unitY * 40;
|
||||
const endX = toPos.x - unitX * 60;
|
||||
const endY = toPos.y - unitY * 30;
|
||||
|
||||
// Main line
|
||||
relationshipGroup
|
||||
.append('line')
|
||||
.attr('x1', startX)
|
||||
.attr('y1', startY)
|
||||
.attr('x2', endX)
|
||||
.attr('y2', endY)
|
||||
.attr('stroke', 'black')
|
||||
.attr('stroke-width', 2);
|
||||
|
||||
// Draw arrow based on arrow type
|
||||
const arrowSize = 10;
|
||||
|
||||
if (relationship.arrowType === ARROW_TYPE.SOLID_ARROW) {
|
||||
// Forward arrow (-->)
|
||||
const arrowX1 = endX - arrowSize * unitX - arrowSize * unitY * 0.5;
|
||||
const arrowY1 = endY - arrowSize * unitY + arrowSize * unitX * 0.5;
|
||||
const arrowX2 = endX - arrowSize * unitX + arrowSize * unitY * 0.5;
|
||||
const arrowY2 = endY - arrowSize * unitY - arrowSize * unitX * 0.5;
|
||||
|
||||
relationshipGroup
|
||||
.append('polygon')
|
||||
.attr('points', `${endX},${endY} ${arrowX1},${arrowY1} ${arrowX2},${arrowY2}`)
|
||||
.attr('fill', 'black');
|
||||
} else if (relationship.arrowType === ARROW_TYPE.BACK_ARROW) {
|
||||
// Backward arrow (<--)
|
||||
const arrowX1 = startX + arrowSize * unitX - arrowSize * unitY * 0.5;
|
||||
const arrowY1 = startY + arrowSize * unitY + arrowSize * unitX * 0.5;
|
||||
const arrowX2 = startX + arrowSize * unitX + arrowSize * unitY * 0.5;
|
||||
const arrowY2 = startY + arrowSize * unitY - arrowSize * unitX * 0.5;
|
||||
|
||||
relationshipGroup
|
||||
.append('polygon')
|
||||
.attr('points', `${startX},${startY} ${arrowX1},${arrowY1} ${arrowX2},${arrowY2}`)
|
||||
.attr('fill', 'black');
|
||||
}
|
||||
// For LINE_SOLID (--), no arrow head is drawn
|
||||
|
||||
// Enhanced edge label rendering (if present)
|
||||
if (relationship.label) {
|
||||
const midX = (startX + endX) / 2;
|
||||
const midY = (startY + endY) / 2;
|
||||
|
||||
// Create edge data structure compatible with the edge label system
|
||||
const edgeData = {
|
||||
id: relationship.id,
|
||||
label: relationship.label,
|
||||
labelStyle: 'stroke: #333; stroke-width: 1.5px; fill: none;',
|
||||
x: midX,
|
||||
y: midY,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// Use the proper edge label rendering system
|
||||
await insertEdgeLabel(relationshipGroup, edgeData);
|
||||
|
||||
// Position the edge label at the midpoint
|
||||
const points = [
|
||||
{ x: startX, y: startY },
|
||||
{ x: midX, y: midY },
|
||||
{ x: endX, y: endY },
|
||||
];
|
||||
|
||||
positionEdgeLabel(edgeData, {
|
||||
originalPath: points,
|
||||
});
|
||||
} catch (error) {
|
||||
// Fallback to simple text if edge label system fails
|
||||
log.warn(
|
||||
'Failed to render edge label with advanced system, falling back to simple text:',
|
||||
error
|
||||
);
|
||||
relationshipGroup
|
||||
.append('text')
|
||||
.attr('x', midX)
|
||||
.attr('y', midY - 5)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('class', 'relationship-label')
|
||||
.text(relationship.label);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Main draw function
|
||||
*/
|
||||
const draw: DrawDefinition = async (text, id, _version) => {
|
||||
log.debug('Rendering usecase diagram\n' + text);
|
||||
|
||||
const svg: SVG = selectSvgElement(id);
|
||||
const group: SVGGroup = svg.append('g');
|
||||
|
||||
// Get data from database
|
||||
const actors = [...db.getActors().values()];
|
||||
const useCases = [...db.getUseCases().values()];
|
||||
const systemBoundaries = [...db.getSystemBoundaries().values()];
|
||||
const relationships = db.getRelationships();
|
||||
|
||||
log.debug('Rendering data:', {
|
||||
actors: actors.length,
|
||||
useCases: useCases.length,
|
||||
systemBoundaries: systemBoundaries.length,
|
||||
relationships: relationships.length,
|
||||
});
|
||||
|
||||
// Calculate layout
|
||||
const actorPositions = new Map<string, { x: number; y: number }>();
|
||||
const useCasePositions = new Map<string, { x: number; y: number }>();
|
||||
|
||||
// Position actors on the left
|
||||
actors.forEach((actor, index) => {
|
||||
const x = MARGIN + ACTOR_WIDTH / 2;
|
||||
const y = MARGIN + ACTOR_HEIGHT / 2 + index * SPACING_Y;
|
||||
actorPositions.set(actor.id, { x, y });
|
||||
});
|
||||
|
||||
// Position use cases on the right
|
||||
useCases.forEach((useCase, index) => {
|
||||
const x = MARGIN + SPACING_X + USECASE_WIDTH / 2;
|
||||
const y = MARGIN + USECASE_HEIGHT / 2 + index * SPACING_Y;
|
||||
useCasePositions.set(useCase.id, { x, y });
|
||||
});
|
||||
|
||||
// Draw actors
|
||||
actors.forEach((actor) => {
|
||||
const pos = actorPositions.get(actor.id)!;
|
||||
drawActor(group, actor, pos.x, pos.y);
|
||||
});
|
||||
|
||||
// Draw use cases
|
||||
useCases.forEach((useCase) => {
|
||||
const pos = useCasePositions.get(useCase.id)!;
|
||||
drawUseCase(group, useCase, pos.x, pos.y);
|
||||
});
|
||||
|
||||
// Draw system boundaries (after use cases, before relationships)
|
||||
systemBoundaries.forEach((boundary) => {
|
||||
drawSystemBoundary(group, boundary, useCasePositions);
|
||||
});
|
||||
|
||||
// Draw relationships (async to handle edge labels)
|
||||
const relationshipPromises = relationships.map(async (relationship) => {
|
||||
const fromPos =
|
||||
actorPositions.get(relationship.from) ?? useCasePositions.get(relationship.from);
|
||||
const toPos = actorPositions.get(relationship.to) ?? useCasePositions.get(relationship.to);
|
||||
|
||||
if (fromPos && toPos) {
|
||||
await drawRelationship(group, relationship, fromPos, toPos);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all relationships to be drawn
|
||||
await Promise.all(relationshipPromises);
|
||||
|
||||
// Setup viewBox and SVG dimensions properly
|
||||
const { usecase: conf } = getConfig();
|
||||
const padding = 8; // Standard padding used by other diagrams
|
||||
setupViewPortForSVG(svg, padding, 'usecaseDiagram', conf?.useMaxWidth ?? true);
|
||||
};
|
||||
|
||||
export const renderer = { draw };
|
40
packages/mermaid/src/diagrams/usecase/usecaseStyles.ts
Normal file
40
packages/mermaid/src/diagrams/usecase/usecaseStyles.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
const getStyles = (options: any) =>
|
||||
`
|
||||
.actor {
|
||||
stroke: ${options.primaryColor};
|
||||
fill: ${options.primaryColor};
|
||||
}
|
||||
|
||||
.actor-label {
|
||||
fill: ${options.primaryTextColor};
|
||||
font-family: ${options.fontFamily};
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.usecase {
|
||||
stroke: ${options.primaryColor};
|
||||
fill: ${options.primaryColor};
|
||||
}
|
||||
|
||||
.usecase-label {
|
||||
fill: ${options.primaryTextColor};
|
||||
font-family: ${options.fontFamily};
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.relationship {
|
||||
stroke: ${options.primaryColor};
|
||||
fill: ${options.primaryColor};
|
||||
}
|
||||
|
||||
.relationship-label {
|
||||
fill: ${options.primaryTextColor};
|
||||
font-family: ${options.fontFamily};
|
||||
font-size: 10px;
|
||||
font-weight: normal;
|
||||
}
|
||||
`;
|
||||
|
||||
export default getStyles;
|
80
packages/mermaid/src/diagrams/usecase/usecaseTypes.ts
Normal file
80
packages/mermaid/src/diagrams/usecase/usecaseTypes.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { DiagramDB } from '../../diagram-api/types.js';
|
||||
import type { UsecaseDiagramConfig } from '../../config.type.js';
|
||||
|
||||
export type ActorMetadata = Record<string, string>;
|
||||
|
||||
export interface Actor {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
metadata?: ActorMetadata;
|
||||
}
|
||||
|
||||
export interface UseCase {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
nodeId?: string; // Optional node ID (e.g., 'a' in 'a(Go through code)')
|
||||
systemBoundary?: string; // Optional reference to system boundary
|
||||
}
|
||||
|
||||
export type SystemBoundaryType = 'package' | 'rect';
|
||||
|
||||
export interface SystemBoundary {
|
||||
id: string;
|
||||
name: string;
|
||||
useCases: string[]; // Array of use case IDs within this boundary
|
||||
type?: SystemBoundaryType; // Type of boundary rendering (default: 'rect')
|
||||
}
|
||||
|
||||
// Arrow types for usecase diagrams (matching parser types)
|
||||
export const ARROW_TYPE = {
|
||||
SOLID_ARROW: 0, // -->
|
||||
BACK_ARROW: 1, // <--
|
||||
LINE_SOLID: 2, // --
|
||||
} as const;
|
||||
|
||||
export type ArrowType = (typeof ARROW_TYPE)[keyof typeof ARROW_TYPE];
|
||||
|
||||
export interface Relationship {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
type: 'association' | 'include' | 'extend';
|
||||
arrowType: ArrowType;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface UsecaseFields {
|
||||
actors: Map<string, Actor>;
|
||||
useCases: Map<string, UseCase>;
|
||||
systemBoundaries: Map<string, SystemBoundary>;
|
||||
relationships: Relationship[];
|
||||
config: Required<UsecaseDiagramConfig>;
|
||||
}
|
||||
|
||||
export interface UsecaseDB extends DiagramDB {
|
||||
getConfig: () => Required<UsecaseDiagramConfig>;
|
||||
|
||||
// Actor management
|
||||
addActor: (actor: Actor) => void;
|
||||
getActors: () => Map<string, Actor>;
|
||||
getActor: (id: string) => Actor | undefined;
|
||||
|
||||
// UseCase management
|
||||
addUseCase: (useCase: UseCase) => void;
|
||||
getUseCases: () => Map<string, UseCase>;
|
||||
getUseCase: (id: string) => UseCase | undefined;
|
||||
|
||||
// SystemBoundary management
|
||||
addSystemBoundary: (systemBoundary: SystemBoundary) => void;
|
||||
getSystemBoundaries: () => Map<string, SystemBoundary>;
|
||||
getSystemBoundary: (id: string) => SystemBoundary | undefined;
|
||||
|
||||
// Relationship management
|
||||
addRelationship: (relationship: Relationship) => void;
|
||||
getRelationships: () => Relationship[];
|
||||
|
||||
// Utility methods
|
||||
clear: () => void;
|
||||
}
|
@@ -1,7 +1,13 @@
|
||||
import mermaid, { type MermaidConfig } from 'mermaid';
|
||||
import zenuml from '../../../../../mermaid-zenuml/dist/mermaid-zenuml.core.mjs';
|
||||
import tidyTreeLayout from '../../../../../mermaid-layout-tidy-tree/dist/mermaid-layout-tidy-tree.core.mjs';
|
||||
import layouts from '../../../../../mermaid-layout-elk/dist/mermaid-layout-elk.core.mjs';
|
||||
|
||||
const init = mermaid.registerExternalDiagrams([zenuml]);
|
||||
const init = Promise.all([
|
||||
mermaid.registerExternalDiagrams([zenuml]),
|
||||
mermaid.registerLayoutLoaders(layouts),
|
||||
mermaid.registerLayoutLoaders(tidyTreeLayout),
|
||||
]);
|
||||
mermaid.registerIconPacks([
|
||||
{
|
||||
name: 'logos',
|
||||
|
@@ -33,7 +33,7 @@
|
||||
"pathe": "^2.0.3",
|
||||
"unocss": "^66.4.2",
|
||||
"unplugin-vue-components": "^28.4.0",
|
||||
"vite": "^6.1.1",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
"vitepress": "1.6.3",
|
||||
"workbox-window": "^7.3.0"
|
||||
|
@@ -20,3 +20,5 @@ Each user journey is split into sections, these describe the part of the task
|
||||
the user is trying to complete.
|
||||
|
||||
Tasks syntax is `Task name: <score>: <comma separated list of actors>`
|
||||
|
||||
Score is a number between 1 and 5, inclusive.
|
||||
|
@@ -13,6 +13,10 @@ const virtualModuleId = 'virtual:mermaid-config';
|
||||
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
// Vite v7 changes the default target and drops old browser support
|
||||
target: 'modules',
|
||||
},
|
||||
optimizeDeps: {
|
||||
// vitepress is aliased with replacement `join(DIST_CLIENT_PATH, '/index')`
|
||||
// This needs to be excluded from optimization
|
||||
|
@@ -56,6 +56,7 @@ required:
|
||||
- block
|
||||
- look
|
||||
- radar
|
||||
- usecase
|
||||
properties:
|
||||
theme:
|
||||
description: |
|
||||
@@ -310,6 +311,8 @@ properties:
|
||||
$ref: '#/$defs/BlockDiagramConfig'
|
||||
radar:
|
||||
$ref: '#/$defs/RadarDiagramConfig'
|
||||
usecase:
|
||||
$ref: '#/$defs/UsecaseDiagramConfig'
|
||||
dompurifyConfig:
|
||||
title: DOM Purify Configuration
|
||||
description: Configuration options to pass to the `dompurify` library.
|
||||
@@ -2329,6 +2332,57 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file)
|
||||
maximum: 1
|
||||
default: 0.17
|
||||
|
||||
UsecaseDiagramConfig:
|
||||
title: Usecase Diagram Config
|
||||
allOf: [{ $ref: '#/$defs/BaseDiagramConfig' }]
|
||||
description: The object containing configurations specific for usecase diagrams.
|
||||
type: object
|
||||
unevaluatedProperties: false
|
||||
required:
|
||||
- useMaxWidth
|
||||
properties:
|
||||
actorFontSize:
|
||||
description: Font size for actor labels
|
||||
type: number
|
||||
minimum: 1
|
||||
default: 14
|
||||
actorFontFamily:
|
||||
description: Font family for actor labels
|
||||
type: string
|
||||
default: '"Open Sans", sans-serif'
|
||||
actorFontWeight:
|
||||
description: Font weight for actor labels
|
||||
type: string
|
||||
default: 'normal'
|
||||
usecaseFontSize:
|
||||
description: Font size for usecase labels
|
||||
type: number
|
||||
minimum: 1
|
||||
default: 12
|
||||
usecaseFontFamily:
|
||||
description: Font family for usecase labels
|
||||
type: string
|
||||
default: '"Open Sans", sans-serif'
|
||||
usecaseFontWeight:
|
||||
description: Font weight for usecase labels
|
||||
type: string
|
||||
default: 'normal'
|
||||
actorMargin:
|
||||
description: Margin around actors
|
||||
type: number
|
||||
minimum: 0
|
||||
default: 50
|
||||
usecaseMargin:
|
||||
description: Margin around use cases
|
||||
type: number
|
||||
minimum: 0
|
||||
default: 50
|
||||
diagramPadding:
|
||||
description: Padding around the entire diagram
|
||||
type: number
|
||||
minimum: 0
|
||||
default: 20
|
||||
|
||||
FontCalculator:
|
||||
title: Font Calculator
|
||||
description: |
|
||||
|
@@ -19,7 +19,8 @@
|
||||
"scripts": {
|
||||
"clean": "rimraf dist src/language/generated",
|
||||
"langium:generate": "langium generate",
|
||||
"langium:watch": "langium generate --watch"
|
||||
"langium:watch": "langium generate --watch",
|
||||
"antlr:generate": "cd src/language/usecase && npx antlr-ng -Dlanguage=TypeScript --generate-visitor --generate-listener -o generated Usecase.g4"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -33,9 +34,11 @@
|
||||
"ast"
|
||||
],
|
||||
"dependencies": {
|
||||
"antlr4ng": "^3.0.7",
|
||||
"langium": "3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"antlr-ng": "^1.0.10",
|
||||
"chevrotain": "^11.0.3"
|
||||
},
|
||||
"files": [
|
||||
|
@@ -45,3 +45,4 @@ export * from './pie/index.js';
|
||||
export * from './architecture/index.js';
|
||||
export * from './radar/index.js';
|
||||
export * from './treemap/index.js';
|
||||
export * from './usecase/index.js';
|
||||
|
170
packages/parser/src/language/usecase/Usecase.g4
Normal file
170
packages/parser/src/language/usecase/Usecase.g4
Normal file
@@ -0,0 +1,170 @@
|
||||
grammar Usecase;
|
||||
|
||||
// Parser rules
|
||||
usecaseDiagram
|
||||
: 'usecase' NEWLINE* statement* EOF
|
||||
;
|
||||
|
||||
statement
|
||||
: actorStatement
|
||||
| relationshipStatement
|
||||
| systemBoundaryStatement
|
||||
| systemBoundaryTypeStatement
|
||||
| NEWLINE
|
||||
;
|
||||
|
||||
actorStatement
|
||||
: 'actor' actorList NEWLINE*
|
||||
;
|
||||
|
||||
actorList
|
||||
: actorName (',' actorName)*
|
||||
;
|
||||
|
||||
actorName
|
||||
: (IDENTIFIER | STRING) metadata?
|
||||
;
|
||||
|
||||
metadata
|
||||
: '@' '{' metadataContent '}'
|
||||
;
|
||||
|
||||
metadataContent
|
||||
: metadataProperty (',' metadataProperty)*
|
||||
;
|
||||
|
||||
metadataProperty
|
||||
: STRING ':' STRING
|
||||
;
|
||||
|
||||
|
||||
|
||||
relationshipStatement
|
||||
: entityName arrow entityName NEWLINE*
|
||||
| actorDeclaration arrow entityName NEWLINE*
|
||||
;
|
||||
|
||||
systemBoundaryStatement
|
||||
: 'systemBoundary' systemBoundaryName NEWLINE* systemBoundaryContent* 'end' NEWLINE*
|
||||
;
|
||||
|
||||
systemBoundaryName
|
||||
: IDENTIFIER
|
||||
| STRING
|
||||
;
|
||||
|
||||
systemBoundaryContent
|
||||
: usecaseInBoundary NEWLINE*
|
||||
| NEWLINE
|
||||
;
|
||||
|
||||
usecaseInBoundary
|
||||
: IDENTIFIER
|
||||
| STRING
|
||||
;
|
||||
|
||||
systemBoundaryTypeStatement
|
||||
: systemBoundaryName '@' '{' systemBoundaryTypeContent '}' NEWLINE*
|
||||
;
|
||||
|
||||
systemBoundaryTypeContent
|
||||
: systemBoundaryTypeProperty (',' systemBoundaryTypeProperty)*
|
||||
;
|
||||
|
||||
systemBoundaryTypeProperty
|
||||
: 'type' ':' systemBoundaryType
|
||||
;
|
||||
|
||||
systemBoundaryType
|
||||
: 'package'
|
||||
| 'rect'
|
||||
;
|
||||
|
||||
entityName
|
||||
: IDENTIFIER
|
||||
| STRING
|
||||
| nodeIdWithLabel
|
||||
;
|
||||
|
||||
actorDeclaration
|
||||
: 'actor' actorName
|
||||
;
|
||||
|
||||
nodeIdWithLabel
|
||||
: IDENTIFIER '(' nodeLabel ')'
|
||||
;
|
||||
|
||||
nodeLabel
|
||||
: IDENTIFIER
|
||||
| STRING
|
||||
| nodeLabel IDENTIFIER
|
||||
| nodeLabel STRING
|
||||
;
|
||||
|
||||
arrow
|
||||
: SOLID_ARROW
|
||||
| BACK_ARROW
|
||||
| LINE_SOLID
|
||||
| labeledArrow
|
||||
;
|
||||
|
||||
labeledArrow
|
||||
: LINE_SOLID edgeLabel SOLID_ARROW
|
||||
| BACK_ARROW edgeLabel LINE_SOLID
|
||||
| LINE_SOLID edgeLabel LINE_SOLID
|
||||
;
|
||||
|
||||
edgeLabel
|
||||
: IDENTIFIER
|
||||
| STRING
|
||||
;
|
||||
|
||||
// Lexer rules
|
||||
SOLID_ARROW
|
||||
: '-->'
|
||||
;
|
||||
|
||||
BACK_ARROW
|
||||
: '<--'
|
||||
;
|
||||
|
||||
LINE_SOLID
|
||||
: '--'
|
||||
;
|
||||
|
||||
COMMA
|
||||
: ','
|
||||
;
|
||||
|
||||
AT
|
||||
: '@'
|
||||
;
|
||||
|
||||
LBRACE
|
||||
: '{'
|
||||
;
|
||||
|
||||
RBRACE
|
||||
: '}'
|
||||
;
|
||||
|
||||
COLON
|
||||
: ':'
|
||||
;
|
||||
|
||||
IDENTIFIER
|
||||
: [a-zA-Z_][a-zA-Z0-9_]*
|
||||
;
|
||||
|
||||
STRING
|
||||
: '"' (~["\r\n])* '"'
|
||||
| '\'' (~['\r\n])* '\''
|
||||
;
|
||||
|
||||
NEWLINE
|
||||
: [\r\n]+
|
||||
;
|
||||
|
||||
WS
|
||||
: [ \t]+ -> skip
|
||||
;
|
4
packages/parser/src/language/usecase/index.ts
Normal file
4
packages/parser/src/language/usecase/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './module.js';
|
||||
export * from './types.js';
|
||||
export * from './parser.js';
|
||||
export * from './visitor.js';
|
50
packages/parser/src/language/usecase/module.ts
Normal file
50
packages/parser/src/language/usecase/module.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* ANTLR UseCase Module
|
||||
*
|
||||
* This module provides dependency injection and service creation
|
||||
* for the ANTLR-based UseCase parser, following the Langium pattern.
|
||||
*/
|
||||
|
||||
import type { AntlrUsecaseServices } from './types.js';
|
||||
import { UsecaseAntlrParser } from './parser.js';
|
||||
import { UsecaseAntlrVisitor } from './visitor.js';
|
||||
|
||||
/**
|
||||
* ANTLR UseCase Module for dependency injection
|
||||
*/
|
||||
export const AntlrUsecaseModule = {
|
||||
parser: () => new UsecaseAntlrParser(),
|
||||
visitor: () => new UsecaseAntlrVisitor(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the full set of ANTLR UseCase services
|
||||
*
|
||||
* This follows the Langium pattern but for ANTLR services
|
||||
*
|
||||
* @returns An object with ANTLR UseCase services
|
||||
*/
|
||||
export function createAntlrUsecaseServices(): AntlrUsecaseServices {
|
||||
const parser = new UsecaseAntlrParser();
|
||||
const visitor = new UsecaseAntlrVisitor();
|
||||
|
||||
return {
|
||||
parser,
|
||||
visitor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance of ANTLR UseCase services
|
||||
*/
|
||||
let antlrUsecaseServices: AntlrUsecaseServices | undefined;
|
||||
|
||||
/**
|
||||
* Get or create the singleton ANTLR UseCase services
|
||||
*/
|
||||
export function getAntlrUsecaseServices(): AntlrUsecaseServices {
|
||||
if (!antlrUsecaseServices) {
|
||||
antlrUsecaseServices = createAntlrUsecaseServices();
|
||||
}
|
||||
return antlrUsecaseServices;
|
||||
}
|
194
packages/parser/src/language/usecase/parser.ts
Normal file
194
packages/parser/src/language/usecase/parser.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* True ANTLR Parser Implementation for UseCase Diagrams
|
||||
*
|
||||
* This parser uses the actual ANTLR-generated files from Usecase.g4
|
||||
* and implements the visitor pattern to build the AST.
|
||||
*/
|
||||
|
||||
import { CharStream, CommonTokenStream, BaseErrorListener } from 'antlr4ng';
|
||||
import type { RecognitionException, Recognizer } from 'antlr4ng';
|
||||
import { UsecaseLexer } from './generated/UsecaseLexer.js';
|
||||
import { UsecaseParser } from './generated/UsecaseParser.js';
|
||||
import { UsecaseAntlrVisitor } from './visitor.js';
|
||||
import type { AntlrUsecaseParser, UsecaseParseResult } from './types.js';
|
||||
|
||||
/**
|
||||
* Custom error listener for ANTLR parser to capture syntax errors
|
||||
*/
|
||||
class UsecaseErrorListener extends BaseErrorListener {
|
||||
private errors: string[] = [];
|
||||
|
||||
syntaxError(
|
||||
_recognizer: Recognizer<any>,
|
||||
_offendingSymbol: any,
|
||||
line: number,
|
||||
charPositionInLine: number,
|
||||
message: string,
|
||||
_e: RecognitionException | null
|
||||
): void {
|
||||
const errorMsg = `Syntax error at line ${line}:${charPositionInLine} - ${message}`;
|
||||
this.errors.push(errorMsg);
|
||||
}
|
||||
|
||||
reportAmbiguity(): void {
|
||||
// Optional: handle ambiguity reports
|
||||
}
|
||||
|
||||
reportAttemptingFullContext(): void {
|
||||
// Optional: handle full context attempts
|
||||
}
|
||||
|
||||
reportContextSensitivity(): void {
|
||||
// Optional: handle context sensitivity reports
|
||||
}
|
||||
|
||||
getErrors(): string[] {
|
||||
return this.errors;
|
||||
}
|
||||
|
||||
hasErrors(): boolean {
|
||||
return this.errors.length > 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.errors = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error class for usecase parsing errors
|
||||
*/
|
||||
export class UsecaseParseError extends Error {
|
||||
public line?: number;
|
||||
public column?: number;
|
||||
public token?: string;
|
||||
public expected?: string[];
|
||||
public hash?: Record<string, any>;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
details?: {
|
||||
line?: number;
|
||||
column?: number;
|
||||
token?: string;
|
||||
expected?: string[];
|
||||
}
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'UsecaseParseError';
|
||||
this.line = details?.line;
|
||||
this.column = details?.column;
|
||||
this.token = details?.token;
|
||||
this.expected = details?.expected;
|
||||
|
||||
// Create hash object similar to other diagram types
|
||||
this.hash = {
|
||||
text: details?.token ?? '',
|
||||
token: details?.token ?? '',
|
||||
line: details?.line?.toString() ?? '1',
|
||||
loc: {
|
||||
first_line: details?.line ?? 1,
|
||||
last_line: details?.line ?? 1,
|
||||
first_column: details?.column ?? 1,
|
||||
last_column: (details?.column ?? 1) + (details?.token?.length ?? 0),
|
||||
},
|
||||
expected: details?.expected ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ANTLR-based UseCase parser implementation
|
||||
*/
|
||||
export class UsecaseAntlrParser implements AntlrUsecaseParser {
|
||||
private visitor: UsecaseAntlrVisitor;
|
||||
private errorListener: UsecaseErrorListener;
|
||||
|
||||
constructor() {
|
||||
this.visitor = new UsecaseAntlrVisitor();
|
||||
this.errorListener = new UsecaseErrorListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse UseCase diagram input using true ANTLR parsing
|
||||
*
|
||||
* @param input - The UseCase diagram text to parse
|
||||
* @returns Parsed result with actors, use cases, and relationships
|
||||
* @throws UsecaseParseError when syntax errors are encountered
|
||||
*/
|
||||
parse(input: string): UsecaseParseResult {
|
||||
// Clear previous errors
|
||||
this.errorListener.clear();
|
||||
|
||||
try {
|
||||
// Step 1: Create ANTLR input stream
|
||||
const chars = CharStream.fromString(input);
|
||||
|
||||
// Step 2: Create lexer from generated ANTLR lexer
|
||||
const lexer = new UsecaseLexer(chars);
|
||||
|
||||
// Add error listener to lexer
|
||||
lexer.removeErrorListeners();
|
||||
lexer.addErrorListener(this.errorListener);
|
||||
|
||||
// Step 3: Create token stream
|
||||
const tokens = new CommonTokenStream(lexer);
|
||||
|
||||
// Step 4: Create parser from generated ANTLR parser
|
||||
const parser = new UsecaseParser(tokens);
|
||||
|
||||
// Add error listener to parser
|
||||
parser.removeErrorListeners();
|
||||
parser.addErrorListener(this.errorListener);
|
||||
|
||||
// Step 5: Parse using the grammar rule: usecaseDiagram
|
||||
const tree = parser.usecaseDiagram();
|
||||
|
||||
// Check for syntax errors before proceeding
|
||||
if (this.errorListener.hasErrors()) {
|
||||
const errors = this.errorListener.getErrors();
|
||||
throw new UsecaseParseError(`Syntax error in usecase diagram: ${errors.join('; ')}`, {
|
||||
token: 'unknown',
|
||||
expected: ['valid usecase syntax'],
|
||||
});
|
||||
}
|
||||
|
||||
// Step 6: Visit the parse tree using our visitor
|
||||
this.visitor.visitUsecaseDiagram!(tree);
|
||||
|
||||
// Step 7: Get the parse result
|
||||
return this.visitor.getParseResult();
|
||||
} catch (error) {
|
||||
if (error instanceof UsecaseParseError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Handle other types of errors
|
||||
throw new UsecaseParseError(
|
||||
`Failed to parse usecase diagram: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
{
|
||||
token: 'unknown',
|
||||
expected: ['valid usecase syntax'],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a new ANTLR UseCase parser
|
||||
*/
|
||||
export function createUsecaseAntlrParser(): AntlrUsecaseParser {
|
||||
return new UsecaseAntlrParser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function for parsing UseCase diagrams
|
||||
*
|
||||
* @param input - The UseCase diagram text to parse
|
||||
* @returns Parsed result with actors, use cases, and relationships
|
||||
*/
|
||||
export function parseUsecaseWithAntlr(input: string): UsecaseParseResult {
|
||||
const parser = createUsecaseAntlrParser();
|
||||
return parser.parse(input);
|
||||
}
|
70
packages/parser/src/language/usecase/types.ts
Normal file
70
packages/parser/src/language/usecase/types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Type definitions for ANTLR UseCase parser
|
||||
*/
|
||||
|
||||
// Arrow types for usecase diagrams (similar to sequence diagram LINETYPE)
|
||||
export const ARROW_TYPE = {
|
||||
SOLID_ARROW: 0, // -->
|
||||
BACK_ARROW: 1, // <--
|
||||
LINE_SOLID: 2, // --
|
||||
} as const;
|
||||
|
||||
export type ArrowType = (typeof ARROW_TYPE)[keyof typeof ARROW_TYPE];
|
||||
|
||||
export type ActorMetadata = Record<string, string>;
|
||||
|
||||
export interface Actor {
|
||||
id: string;
|
||||
name: string;
|
||||
metadata?: ActorMetadata;
|
||||
}
|
||||
|
||||
export interface UseCase {
|
||||
id: string;
|
||||
name: string;
|
||||
nodeId?: string; // Optional node ID (e.g., 'a' in 'a(Go through code)')
|
||||
systemBoundary?: string; // Optional reference to system boundary
|
||||
}
|
||||
|
||||
export type SystemBoundaryType = 'package' | 'rect';
|
||||
|
||||
export interface SystemBoundary {
|
||||
id: string;
|
||||
name: string;
|
||||
useCases: string[]; // Array of use case IDs within this boundary
|
||||
type?: SystemBoundaryType; // Type of boundary rendering (default: 'rect')
|
||||
}
|
||||
|
||||
export interface Relationship {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
type: 'association' | 'include' | 'extend';
|
||||
arrowType: ArrowType;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface UsecaseParseResult {
|
||||
actors: Actor[];
|
||||
useCases: UseCase[];
|
||||
systemBoundaries: SystemBoundary[];
|
||||
relationships: Relationship[];
|
||||
accDescr?: string;
|
||||
accTitle?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ANTLR Parser Services interface
|
||||
*/
|
||||
export interface AntlrUsecaseServices {
|
||||
parser: AntlrUsecaseParser;
|
||||
visitor: any; // UsecaseAntlrVisitor - using any to avoid circular dependency
|
||||
}
|
||||
|
||||
/**
|
||||
* ANTLR Parser interface
|
||||
*/
|
||||
export interface AntlrUsecaseParser {
|
||||
parse(input: string): UsecaseParseResult;
|
||||
}
|
605
packages/parser/src/language/usecase/visitor.ts
Normal file
605
packages/parser/src/language/usecase/visitor.ts
Normal file
@@ -0,0 +1,605 @@
|
||||
/**
|
||||
* ANTLR Visitor Implementation for UseCase Diagrams
|
||||
*
|
||||
* This visitor traverses the ANTLR parse tree and builds the AST
|
||||
* according to the grammar rules defined in Usecase.g4
|
||||
*/
|
||||
|
||||
import { UsecaseVisitor } from './generated/UsecaseVisitor.js';
|
||||
import type {
|
||||
UsecaseDiagramContext,
|
||||
StatementContext,
|
||||
ActorStatementContext,
|
||||
ActorListContext,
|
||||
RelationshipStatementContext,
|
||||
SystemBoundaryStatementContext,
|
||||
SystemBoundaryTypeStatementContext,
|
||||
SystemBoundaryNameContext,
|
||||
SystemBoundaryTypeContentContext,
|
||||
SystemBoundaryTypePropertyContext,
|
||||
SystemBoundaryTypeContext,
|
||||
UsecaseInBoundaryContext,
|
||||
ActorNameContext,
|
||||
ActorDeclarationContext,
|
||||
NodeIdWithLabelContext,
|
||||
NodeLabelContext,
|
||||
MetadataContext,
|
||||
MetadataContentContext,
|
||||
MetadataPropertyContext,
|
||||
EntityNameContext,
|
||||
ArrowContext,
|
||||
LabeledArrowContext,
|
||||
EdgeLabelContext,
|
||||
} from './generated/UsecaseParser.js';
|
||||
import { ARROW_TYPE } from './types.js';
|
||||
import type {
|
||||
Actor,
|
||||
UseCase,
|
||||
SystemBoundary,
|
||||
Relationship,
|
||||
UsecaseParseResult,
|
||||
ArrowType,
|
||||
} from './types.js';
|
||||
|
||||
export class UsecaseAntlrVisitor extends UsecaseVisitor<void> {
|
||||
private actors: Actor[] = [];
|
||||
private useCases: UseCase[] = [];
|
||||
private systemBoundaries: SystemBoundary[] = [];
|
||||
private relationships: Relationship[] = [];
|
||||
private relationshipCounter = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Assign visitor functions as properties
|
||||
this.visitUsecaseDiagram = this.visitUsecaseDiagramImpl.bind(this);
|
||||
this.visitStatement = this.visitStatementImpl.bind(this);
|
||||
this.visitActorStatement = this.visitActorStatementImpl.bind(this);
|
||||
this.visitRelationshipStatement = this.visitRelationshipStatementImpl.bind(this);
|
||||
this.visitSystemBoundaryStatement = this.visitSystemBoundaryStatementImpl.bind(this);
|
||||
this.visitSystemBoundaryTypeStatement = this.visitSystemBoundaryTypeStatementImpl.bind(this);
|
||||
this.visitActorName = this.visitActorNameImpl.bind(this);
|
||||
this.visitArrow = this.visitArrowImpl.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit the root usecaseDiagram rule
|
||||
* Grammar: usecaseDiagram : 'usecase' statement* EOF ;
|
||||
*/
|
||||
visitUsecaseDiagramImpl(ctx: UsecaseDiagramContext): void {
|
||||
// Reset state
|
||||
this.actors = [];
|
||||
this.useCases = [];
|
||||
this.relationships = [];
|
||||
this.relationshipCounter = 0;
|
||||
|
||||
// Visit all statement children
|
||||
if (ctx.statement) {
|
||||
const statements = Array.isArray(ctx.statement()) ? ctx.statement() : [ctx.statement()];
|
||||
for (const statementCtx of statements) {
|
||||
if (Array.isArray(statementCtx)) {
|
||||
for (const stmt of statementCtx) {
|
||||
this.visitStatementImpl(stmt);
|
||||
}
|
||||
} else {
|
||||
this.visitStatementImpl(statementCtx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit statement rule
|
||||
* Grammar: statement : actorStatement | relationshipStatement | systemBoundaryStatement | systemBoundaryTypeStatement | NEWLINE ;
|
||||
*/
|
||||
private visitStatementImpl(ctx: StatementContext): void {
|
||||
if (ctx.actorStatement?.()) {
|
||||
this.visitActorStatementImpl(ctx.actorStatement()!);
|
||||
} else if (ctx.relationshipStatement?.()) {
|
||||
this.visitRelationshipStatementImpl(ctx.relationshipStatement()!);
|
||||
} else if (ctx.systemBoundaryStatement?.()) {
|
||||
this.visitSystemBoundaryStatementImpl(ctx.systemBoundaryStatement()!);
|
||||
} else if (ctx.systemBoundaryTypeStatement?.()) {
|
||||
this.visitSystemBoundaryTypeStatementImpl(ctx.systemBoundaryTypeStatement()!);
|
||||
}
|
||||
// NEWLINE is ignored
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit actorStatement rule
|
||||
* Grammar: actorStatement : 'actor' actorList NEWLINE* ;
|
||||
*/
|
||||
visitActorStatementImpl(ctx: ActorStatementContext): void {
|
||||
if (ctx.actorList?.()) {
|
||||
this.visitActorListImpl(ctx.actorList());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit actorList rule
|
||||
* Grammar: actorList : actorName (',' actorName)* ;
|
||||
*/
|
||||
visitActorListImpl(ctx: ActorListContext): void {
|
||||
// Get all actorName contexts from the list
|
||||
const actorNameContexts = ctx.actorName();
|
||||
|
||||
for (const actorNameCtx of actorNameContexts) {
|
||||
const actorResult = this.visitActorNameImpl(actorNameCtx);
|
||||
this.actors.push({
|
||||
id: actorResult.name,
|
||||
name: actorResult.name,
|
||||
metadata: actorResult.metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit relationshipStatement rule
|
||||
* Grammar: relationshipStatement : entityName arrow entityName NEWLINE* | actorDeclaration arrow entityName NEWLINE* ;
|
||||
*/
|
||||
visitRelationshipStatementImpl(ctx: RelationshipStatementContext): void {
|
||||
let from = '';
|
||||
let to = '';
|
||||
|
||||
// Handle different relationship patterns
|
||||
if (ctx.actorDeclaration?.()) {
|
||||
// Pattern: actor ActorName --> entityName
|
||||
from = this.visitActorDeclarationImpl(ctx.actorDeclaration()!);
|
||||
to = this.visitEntityNameImpl(ctx.entityName(0)!);
|
||||
} else if (ctx.entityName && ctx.entityName().length >= 2) {
|
||||
// Pattern: entityName --> entityName
|
||||
from = this.visitEntityNameImpl(ctx.entityName(0)!);
|
||||
to = this.visitEntityNameImpl(ctx.entityName(1)!);
|
||||
}
|
||||
|
||||
// Get arrow information (type and optional label)
|
||||
const arrowInfo = this.visitArrowImpl(ctx.arrow());
|
||||
|
||||
// Auto-create use cases for entities that are not actors
|
||||
this.ensureUseCaseExists(from);
|
||||
this.ensureUseCaseExists(to);
|
||||
|
||||
const relationship: Relationship = {
|
||||
id: `rel_${this.relationshipCounter++}`,
|
||||
from,
|
||||
to,
|
||||
type: 'association',
|
||||
arrowType: arrowInfo.arrowType,
|
||||
};
|
||||
|
||||
// Add label if present
|
||||
if (arrowInfo.label) {
|
||||
relationship.label = arrowInfo.label;
|
||||
}
|
||||
|
||||
this.relationships.push(relationship);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a use case exists for the given entity name if it's not an actor
|
||||
*/
|
||||
private ensureUseCaseExists(entityName: string): void {
|
||||
// Check if it's already an actor
|
||||
const isActor = this.actors.some((actor) => actor.id === entityName);
|
||||
|
||||
// If it's not an actor, create it as a use case (if not already exists)
|
||||
if (!isActor) {
|
||||
const existingUseCase = this.useCases.some((useCase) => useCase.id === entityName);
|
||||
if (!existingUseCase) {
|
||||
this.useCases.push({
|
||||
id: entityName,
|
||||
name: entityName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit systemBoundaryStatement rule
|
||||
* Grammar: systemBoundaryStatement : 'systemBoundary' systemBoundaryName NEWLINE* systemBoundaryContent* 'end' NEWLINE* ;
|
||||
*/
|
||||
visitSystemBoundaryStatementImpl(ctx: SystemBoundaryStatementContext): void {
|
||||
let boundaryName = '';
|
||||
|
||||
// Get the system boundary name
|
||||
if (ctx.systemBoundaryName?.()) {
|
||||
boundaryName = this.visitSystemBoundaryNameImpl(ctx.systemBoundaryName());
|
||||
}
|
||||
|
||||
// Collect use cases within this boundary
|
||||
const useCasesInBoundary: string[] = [];
|
||||
|
||||
if (ctx.systemBoundaryContent?.()?.length > 0) {
|
||||
for (const contentCtx of ctx.systemBoundaryContent()) {
|
||||
const usecaseInBoundary = contentCtx.usecaseInBoundary?.();
|
||||
if (usecaseInBoundary) {
|
||||
const useCaseName = this.visitUsecaseInBoundaryImpl(usecaseInBoundary);
|
||||
useCasesInBoundary.push(useCaseName);
|
||||
|
||||
// Create the use case and mark it as being in this boundary
|
||||
const existingUseCase = this.useCases.find((uc) => uc.id === useCaseName);
|
||||
if (existingUseCase) {
|
||||
existingUseCase.systemBoundary = boundaryName;
|
||||
} else {
|
||||
this.useCases.push({
|
||||
id: useCaseName,
|
||||
name: useCaseName,
|
||||
systemBoundary: boundaryName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the system boundary with default type
|
||||
this.systemBoundaries.push({
|
||||
id: boundaryName,
|
||||
name: boundaryName,
|
||||
useCases: useCasesInBoundary,
|
||||
type: 'rect', // default type
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit systemBoundaryName rule
|
||||
* Grammar: systemBoundaryName : IDENTIFIER | STRING ;
|
||||
*/
|
||||
private visitSystemBoundaryNameImpl(ctx: SystemBoundaryNameContext): string {
|
||||
const identifier = ctx.IDENTIFIER?.();
|
||||
if (identifier) {
|
||||
return identifier.getText();
|
||||
}
|
||||
|
||||
const string = ctx.STRING?.();
|
||||
if (string) {
|
||||
const text = string.getText();
|
||||
// Remove quotes from string
|
||||
return text.slice(1, -1);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit usecaseInBoundary rule
|
||||
* Grammar: usecaseInBoundary : IDENTIFIER | STRING ;
|
||||
*/
|
||||
private visitUsecaseInBoundaryImpl(ctx: UsecaseInBoundaryContext): string {
|
||||
const identifier = ctx.IDENTIFIER?.();
|
||||
if (identifier) {
|
||||
return identifier.getText();
|
||||
}
|
||||
|
||||
const string = ctx.STRING?.();
|
||||
if (string) {
|
||||
const text = string.getText();
|
||||
// Remove quotes from string
|
||||
return text.slice(1, -1);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit systemBoundaryTypeStatement rule
|
||||
* Grammar: systemBoundaryTypeStatement : systemBoundaryName '\@' '\{' systemBoundaryTypeContent '\}' NEWLINE* ;
|
||||
*/
|
||||
visitSystemBoundaryTypeStatementImpl(ctx: SystemBoundaryTypeStatementContext): void {
|
||||
let boundaryName = '';
|
||||
|
||||
// Get the system boundary name
|
||||
const systemBoundaryName = ctx.systemBoundaryName?.();
|
||||
if (systemBoundaryName) {
|
||||
boundaryName = this.visitSystemBoundaryNameImpl(systemBoundaryName);
|
||||
}
|
||||
|
||||
// Get the type configuration
|
||||
let boundaryType: 'package' | 'rect' = 'rect'; // default
|
||||
const systemBoundaryTypeContent = ctx.systemBoundaryTypeContent?.();
|
||||
if (systemBoundaryTypeContent) {
|
||||
boundaryType = this.visitSystemBoundaryTypeContentImpl(systemBoundaryTypeContent);
|
||||
}
|
||||
|
||||
// Find the existing system boundary and update its type
|
||||
const existingBoundary = this.systemBoundaries.find((b) => b.id === boundaryName);
|
||||
if (existingBoundary) {
|
||||
existingBoundary.type = boundaryType;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit systemBoundaryTypeContent rule
|
||||
* Grammar: systemBoundaryTypeContent : systemBoundaryTypeProperty (',' systemBoundaryTypeProperty)* ;
|
||||
*/
|
||||
private visitSystemBoundaryTypeContentImpl(
|
||||
ctx: SystemBoundaryTypeContentContext
|
||||
): 'package' | 'rect' {
|
||||
// Get all type properties
|
||||
const typeProperties = ctx.systemBoundaryTypeProperty();
|
||||
|
||||
for (const propCtx of typeProperties) {
|
||||
const type = this.visitSystemBoundaryTypePropertyImpl(propCtx);
|
||||
if (type) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
return 'rect'; // default
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit systemBoundaryTypeProperty rule
|
||||
* Grammar: systemBoundaryTypeProperty : 'type' ':' systemBoundaryType ;
|
||||
*/
|
||||
private visitSystemBoundaryTypePropertyImpl(
|
||||
ctx: SystemBoundaryTypePropertyContext
|
||||
): 'package' | 'rect' | null {
|
||||
const systemBoundaryType = ctx.systemBoundaryType?.();
|
||||
if (systemBoundaryType) {
|
||||
return this.visitSystemBoundaryTypeImpl(systemBoundaryType);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit systemBoundaryType rule
|
||||
* Grammar: systemBoundaryType : 'package' | 'rect' ;
|
||||
*/
|
||||
private visitSystemBoundaryTypeImpl(ctx: SystemBoundaryTypeContext): 'package' | 'rect' {
|
||||
const text = ctx.getText();
|
||||
if (text === 'package') {
|
||||
return 'package';
|
||||
} else if (text === 'rect') {
|
||||
return 'rect';
|
||||
}
|
||||
return 'rect'; // default
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit actorName rule
|
||||
* Grammar: actorName : (IDENTIFIER | STRING) metadata? ;
|
||||
*/
|
||||
private visitActorNameImpl(ctx: ActorNameContext): {
|
||||
name: string;
|
||||
metadata?: Record<string, string>;
|
||||
} {
|
||||
let name = '';
|
||||
|
||||
if (ctx.IDENTIFIER?.()) {
|
||||
name = ctx.IDENTIFIER()!.getText();
|
||||
} else if (ctx.STRING?.()) {
|
||||
const text = ctx.STRING()!.getText();
|
||||
// Remove quotes from string
|
||||
name = text.slice(1, -1);
|
||||
}
|
||||
|
||||
let metadata = undefined;
|
||||
if (ctx.metadata?.()) {
|
||||
metadata = this.visitMetadataImpl(ctx.metadata()!);
|
||||
}
|
||||
|
||||
return { name, metadata };
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit metadata rule
|
||||
* Grammar: metadata : '\@' '\{' metadataContent '\}' ;
|
||||
*/
|
||||
private visitMetadataImpl(ctx: MetadataContext): Record<string, string> {
|
||||
const metadataContent = ctx.metadataContent?.();
|
||||
if (metadataContent) {
|
||||
return this.visitMetadataContentImpl(metadataContent);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit metadataContent rule
|
||||
* Grammar: metadataContent : metadataProperty (',' metadataProperty)* ;
|
||||
*/
|
||||
private visitMetadataContentImpl(ctx: MetadataContentContext): Record<string, string> {
|
||||
const metadata: Record<string, string> = {};
|
||||
const properties = ctx.metadataProperty();
|
||||
|
||||
for (const property of properties) {
|
||||
const { key, value } = this.visitMetadataPropertyImpl(property);
|
||||
metadata[key] = value;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit metadataProperty rule
|
||||
* Grammar: metadataProperty : STRING ':' STRING ;
|
||||
*/
|
||||
private visitMetadataPropertyImpl(ctx: MetadataPropertyContext): { key: string; value: string } {
|
||||
const strings = ctx.STRING();
|
||||
if (strings.length >= 2) {
|
||||
const key = strings[0].getText().slice(1, -1); // Remove quotes
|
||||
const value = strings[1].getText().slice(1, -1); // Remove quotes
|
||||
return { key, value };
|
||||
}
|
||||
return { key: '', value: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit entityName rule
|
||||
* Grammar: entityName : IDENTIFIER | STRING | nodeIdWithLabel ;
|
||||
*/
|
||||
private visitEntityNameImpl(ctx: EntityNameContext): string {
|
||||
const identifier = ctx.IDENTIFIER?.();
|
||||
if (identifier) {
|
||||
return identifier.getText();
|
||||
}
|
||||
|
||||
const string = ctx.STRING?.();
|
||||
if (string) {
|
||||
const text = string.getText();
|
||||
// Remove quotes from string
|
||||
return text.slice(1, -1);
|
||||
}
|
||||
|
||||
const nodeIdWithLabel = ctx.nodeIdWithLabel?.();
|
||||
if (nodeIdWithLabel) {
|
||||
return this.visitNodeIdWithLabelImpl(nodeIdWithLabel);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit actorDeclaration rule
|
||||
* Grammar: actorDeclaration : 'actor' actorName ;
|
||||
*/
|
||||
private visitActorDeclarationImpl(ctx: ActorDeclarationContext): string {
|
||||
const actorName = ctx.actorName?.();
|
||||
if (actorName) {
|
||||
const actorResult = this.visitActorNameImpl(actorName);
|
||||
|
||||
// Add the actor if it doesn't already exist
|
||||
const existingActor = this.actors.find((actor) => actor.id === actorResult.name);
|
||||
if (!existingActor) {
|
||||
this.actors.push({
|
||||
id: actorResult.name,
|
||||
name: actorResult.name,
|
||||
metadata: actorResult.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
return actorResult.name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit nodeIdWithLabel rule
|
||||
* Grammar: nodeIdWithLabel : IDENTIFIER '(' nodeLabel ')' ;
|
||||
*/
|
||||
private visitNodeIdWithLabelImpl(ctx: NodeIdWithLabelContext): string {
|
||||
let nodeId = '';
|
||||
let nodeLabel = '';
|
||||
|
||||
const identifier = ctx.IDENTIFIER?.();
|
||||
if (identifier) {
|
||||
nodeId = identifier.getText();
|
||||
}
|
||||
|
||||
const nodeLabelCtx = ctx.nodeLabel?.();
|
||||
if (nodeLabelCtx) {
|
||||
nodeLabel = this.visitNodeLabelImpl(nodeLabelCtx);
|
||||
}
|
||||
|
||||
// Create or update the use case with nodeId and label
|
||||
const existingUseCase = this.useCases.find((uc) => uc.id === nodeLabel || uc.nodeId === nodeId);
|
||||
if (existingUseCase) {
|
||||
// Update existing use case with nodeId if not already set
|
||||
existingUseCase.nodeId ??= nodeId;
|
||||
} else {
|
||||
// Create new use case with nodeId and label
|
||||
this.useCases.push({
|
||||
id: nodeLabel,
|
||||
name: nodeLabel,
|
||||
nodeId: nodeId,
|
||||
});
|
||||
}
|
||||
|
||||
return nodeLabel; // Return the label as the entity name for relationships
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit nodeLabel rule
|
||||
* Grammar: nodeLabel : IDENTIFIER | STRING | nodeLabel IDENTIFIER | nodeLabel STRING ;
|
||||
*/
|
||||
private visitNodeLabelImpl(ctx: NodeLabelContext): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Handle recursive nodeLabel structure
|
||||
const nodeLabel = ctx.nodeLabel?.();
|
||||
if (nodeLabel) {
|
||||
parts.push(this.visitNodeLabelImpl(nodeLabel));
|
||||
}
|
||||
|
||||
const identifier = ctx.IDENTIFIER?.();
|
||||
if (identifier) {
|
||||
parts.push(identifier.getText());
|
||||
} else {
|
||||
const string = ctx.STRING?.();
|
||||
if (string) {
|
||||
const text = string.getText();
|
||||
// Remove quotes from string
|
||||
parts.push(text.slice(1, -1));
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit arrow rule
|
||||
* Grammar: arrow : SOLID_ARROW | BACK_ARROW | LINE_SOLID | labeledArrow ;
|
||||
*/
|
||||
private visitArrowImpl(ctx: ArrowContext): { arrowType: ArrowType; label?: string } {
|
||||
// Check if this is a labeled arrow
|
||||
if (ctx.labeledArrow()) {
|
||||
return this.visitLabeledArrowImpl(ctx.labeledArrow()!);
|
||||
}
|
||||
|
||||
// Regular arrow without label - determine type from token
|
||||
if (ctx.SOLID_ARROW()) {
|
||||
return { arrowType: ARROW_TYPE.SOLID_ARROW };
|
||||
} else if (ctx.BACK_ARROW()) {
|
||||
return { arrowType: ARROW_TYPE.BACK_ARROW };
|
||||
} else if (ctx.LINE_SOLID()) {
|
||||
return { arrowType: ARROW_TYPE.LINE_SOLID };
|
||||
}
|
||||
|
||||
// Fallback (should not happen with proper grammar)
|
||||
return { arrowType: ARROW_TYPE.SOLID_ARROW };
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit labeled arrow rule
|
||||
* Grammar: labeledArrow : LINE_SOLID edgeLabel SOLID_ARROW | BACK_ARROW edgeLabel LINE_SOLID | LINE_SOLID edgeLabel LINE_SOLID ;
|
||||
*/
|
||||
private visitLabeledArrowImpl(ctx: LabeledArrowContext): { arrowType: ArrowType; label: string } {
|
||||
const label = this.visitEdgeLabelImpl(ctx.edgeLabel());
|
||||
|
||||
// Determine arrow type based on the tokens present
|
||||
if (ctx.SOLID_ARROW()) {
|
||||
return { arrowType: ARROW_TYPE.SOLID_ARROW, label };
|
||||
} else if (ctx.BACK_ARROW()) {
|
||||
return { arrowType: ARROW_TYPE.BACK_ARROW, label };
|
||||
} else {
|
||||
return { arrowType: ARROW_TYPE.LINE_SOLID, label };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit edge label rule
|
||||
* Grammar: edgeLabel : IDENTIFIER | STRING ;
|
||||
*/
|
||||
private visitEdgeLabelImpl(ctx: EdgeLabelContext): string {
|
||||
const text = ctx.getText();
|
||||
// Remove quotes if it's a string
|
||||
if (
|
||||
(text.startsWith('"') && text.endsWith('"')) ||
|
||||
(text.startsWith("'") && text.endsWith("'"))
|
||||
) {
|
||||
return text.slice(1, -1);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parse result after visiting the diagram
|
||||
*/
|
||||
getParseResult(): UsecaseParseResult {
|
||||
return {
|
||||
actors: this.actors,
|
||||
useCases: this.useCases,
|
||||
systemBoundaries: this.systemBoundaries,
|
||||
relationships: this.relationships,
|
||||
};
|
||||
}
|
||||
}
|
@@ -1,8 +1,10 @@
|
||||
import type { LangiumParser, ParseResult } from 'langium';
|
||||
|
||||
import type { Info, Packet, Pie, Architecture, GitGraph, Radar, Treemap } from './index.js';
|
||||
import type { UsecaseParseResult } from './language/usecase/types.js';
|
||||
|
||||
export type DiagramAST = Info | Packet | Pie | Architecture | GitGraph | Radar;
|
||||
export type DiagramAST = Info | Packet | Pie | Architecture | GitGraph | Radar | UsecaseParseResult;
|
||||
export type LangiumDiagramAST = Info | Packet | Pie | Architecture | GitGraph | Radar;
|
||||
|
||||
const parsers: Record<string, LangiumParser> = {};
|
||||
const initializers = {
|
||||
@@ -41,6 +43,9 @@ const initializers = {
|
||||
const parser = createTreemapServices().Treemap.parser.LangiumParser;
|
||||
parsers.treemap = parser;
|
||||
},
|
||||
usecase: () => {
|
||||
// ANTLR-based parser - no Langium parser needed
|
||||
},
|
||||
} as const;
|
||||
|
||||
export async function parse(diagramType: 'info', text: string): Promise<Info>;
|
||||
@@ -50,7 +55,12 @@ export async function parse(diagramType: 'architecture', text: string): Promise<
|
||||
export async function parse(diagramType: 'gitGraph', text: string): Promise<GitGraph>;
|
||||
export async function parse(diagramType: 'radar', text: string): Promise<Radar>;
|
||||
export async function parse(diagramType: 'treemap', text: string): Promise<Treemap>;
|
||||
export async function parse(diagramType: 'usecase', text: string): Promise<UsecaseParseResult>;
|
||||
|
||||
export async function parse<T extends LangiumDiagramAST>(
|
||||
diagramType: Exclude<keyof typeof initializers, 'usecase'>,
|
||||
text: string
|
||||
): Promise<T>;
|
||||
export async function parse<T extends DiagramAST>(
|
||||
diagramType: keyof typeof initializers,
|
||||
text: string
|
||||
@@ -59,11 +69,19 @@ export async function parse<T extends DiagramAST>(
|
||||
if (!initializer) {
|
||||
throw new Error(`Unknown diagram type: ${diagramType}`);
|
||||
}
|
||||
|
||||
// Handle ANTLR-based parsers separately
|
||||
if (diagramType === 'usecase') {
|
||||
const { parseUsecaseWithAntlr } = await import('./language/usecase/index.js');
|
||||
return parseUsecaseWithAntlr(text) as T;
|
||||
}
|
||||
|
||||
if (!parsers[diagramType]) {
|
||||
await initializer();
|
||||
}
|
||||
const parser: LangiumParser = parsers[diagramType];
|
||||
const result: ParseResult<T> = parser.parse<T>(text);
|
||||
const result: ParseResult<T extends LangiumDiagramAST ? T : never> =
|
||||
parser.parse<T extends LangiumDiagramAST ? T : never>(text);
|
||||
if (result.lexerErrors.length > 0 || result.parserErrors.length > 0) {
|
||||
throw new MermaidParseError(result);
|
||||
}
|
||||
|
1612
packages/parser/tests/usecase.test.ts
Normal file
1612
packages/parser/tests/usecase.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1965
pnpm-lock.yaml
generated
1965
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user