Compare commits

..

76 Commits

Author SHA1 Message Date
omkarht
dc9bfa712d fix(parser): improve error messages for missing 'usecase' in ANTLR parser
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-17 12:32:55 +05:30
omkarht
1adc145b18 Merge branch 'develop' into feat/usecase-diagram-implementation 2025-09-17 12:12:10 +05:30
omkarht
5362d3f5ba feat: add use case diagram examples and update parser tests for error handling
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-17 12:11:36 +05:30
Shubham P
16569b295b Merge pull request #6942 from mermaid-js/renovate/peter-evans-create-pull-request-digest
chore(deps): update peter-evans/create-pull-request digest to 915d841
2025-09-16 17:28:34 +00:00
renovate[bot]
11a35c11ee chore(deps): update peter-evans/create-pull-request digest to 915d841 2025-09-16 16:02:14 +00:00
Shubham P
216be22801 Merge pull request #6945 from mermaid-js/fix/gantt-crash-obsidian-browser
6920: Fix Gantt chart crashes in Obsidian and browser
2025-09-16 15:49:49 +00:00
omkarht
781554bf70 refactor: simplify use case diagram tests for clarity and consistency
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-16 18:13:05 +05:30
omkarht
1c60d125fd fix: update checkCircle script to exclude generated files
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-16 17:15:48 +05:30
omkarht
313da2b0df feat: implement ANTLR generation functionality with CLI support
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-16 17:04:49 +05:30
omkarht
c9b9f4425b chore: updated pnpm-lock.yaml file
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-16 13:52:57 +05:30
omkarht
5c468c0bf0 Merge branch 'develop' into feat/usecase-diagram-implementation 2025-09-16 13:30:46 +05:30
Sidharth Vinod
994f7df29a Merge pull request #6949 from tklever/examples-license
docs: add MIT license to `examples` metadata
2025-09-16 12:40:40 +05:30
Tim Klever
dc11b8645c docs: add MIT license to examples metadata
Adding the MIT license documentation so the published package metadata accurately reflects the license. The MIT license is currently included in the package distribution https://www.npmjs.com/package/@mermaid-js/examples?activeTab=code, but not documented in the metadata. This causes npmjs.com to display the license as "none"
2025-09-15 17:35:44 -07:00
omkarht
ff2ddae587 Merge branch 'develop' into feat/usecase-diagram-implementation 2025-09-15 20:54:37 +05:30
omkarht
58042f1596 feat: Adding support for the new Usecase diagram type
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-15 20:52:53 +05:30
Shubham P
181af8167b Merge pull request #6934 from fulldecent/patch-2
Specify score range for task syntax
2025-09-15 09:18:39 +00:00
Shubham P
799d2ed547 Merge branch 'develop' into patch-2 2025-09-15 14:38:57 +05:30
Shubham P
08160a74b4 Merge pull request #6931 from mermaid-js/renovate/npm-vite-vulnerability
chore(deps): update dependency vite to v7.0.7 [security]
2025-09-15 08:22:27 +00:00
Knut Sveidqvist
6d221fb3ca Merge pull request #6944 from mermaid-js/docs/fix-mindmap-rendering
6932:Fix mindmap rendering in docs and apply tidytree layout
2025-09-15 08:06:41 +00:00
Shubham P
8b20907141 Merge branch 'develop' into renovate/npm-vite-vulnerability 2025-09-15 13:13:40 +05:30
darshanr0107
d318f1a13c chore: add changeset
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-15 12:42:25 +05:30
darshanr0107
525a7de8ae fix:gant chart crashing in browser
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-15 12:42:24 +05:30
darshanr0107
a459c436c9 docs: fix rendering and ensure tidytree layout is applied
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-15 12:00:46 +05:30
Sidharth Vinod
1c2a0020bd Merge pull request #6939 from aloisklink/build/fix-netlify-mermaid-live-preview
build: change mermaid live preview to use pnpm
2025-09-12 21:18:29 +05:30
Alois Klink
141c6b3808 build: change mermaid live preview to use pnpm
Right now, the netlify build seems to be failing since we're ignoring
the `mermaid-live-editor` lockfile.

This is causing errors with broken dependencies.

Switching to `pnpm`, which the `mermaid-live-editor` uses, fixes these
issues!
2025-09-13 00:35:29 +09:00
renovate[bot]
8d4ffdf808 chore(deps): update dependency vite to v7.0.7 [security] 2025-09-12 20:14:12 +09:00
Alois Klink
32106e259c build(docs): set build.target = 'modules'
Explicility set the `build.target` to `modules`, as Vite v7 changes this
and drops support for older browsers.

We probably should do this eventually too, but maybe as part of a
Mermaid v12 release.
2025-09-12 20:14:12 +09:00
Shubham P
b36edd557e Merge pull request #6921 from quilicicf/feat/6627_add_ids_in_architecture_diagrams
feat(architecture): Add ids in generated SVG
2025-09-11 05:10:13 +00:00
Shubham P
5e3b5e8f36 Merge branch 'develop' into feat/6627_add_ids_in_architecture_diagrams 2025-09-11 10:22:18 +05:30
Shubham P
764b315dc1 Updated changeset 2025-09-11 10:22:04 +05:30
autofix-ci[bot]
47c0d2d040 [autofix.ci] apply automated fixes 2025-09-10 18:00:00 +00:00
William Entriken
ac3b777bf6 Specify score range for task syntax 2025-09-10 13:54:31 -04:00
Ashish Jain
166782cd38 Merge pull request #6854 from mermaid-js/mindmaps-and-elk-updates
Update elk layout to handle start/stop of edges properly for all shapes
2025-09-10 15:26:22 +00:00
darshanr0107
b37eb6d0d1 fix: arrow head color not matching arrow color
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-10 20:30:31 +05:30
Knut Sveidqvist
f759f5dcf7 Merge branch 'develop' into mindmaps-and-elk-updates 2025-09-10 15:59:19 +02:00
Knut Sveidqvist
80bcefe321 Merge branch 'mindmaps-and-elk-updates' of github.com:mermaid-js/mermaid into mindmaps-and-elk-updates 2025-09-10 15:59:08 +02:00
Knut Sveidqvist
70cbbe69d8 Handing edges for edges leaving subgraphs 2025-09-10 15:58:20 +02:00
Knut Sveidqvist
baf4093e8d Merge pull request #6826 from mermaid-js/6784-edge-label-color-mismatch
6784: Fix edge ID styling mismatch with linkStyle color
2025-09-10 13:41:44 +00:00
darshanr0107
fd185f7694 chore: fix failing test
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-10 18:41:23 +05:30
Shubham P
027d7b6368 Merge pull request #6926 from saurabhg772244/Added-missing-types-in-diagramDB
chore: Added missing types in diagramDB
2025-09-10 12:59:42 +00:00
Knut Sveidqvist
7986b66a88 Fix for edge calculation to subgraphs 2025-09-10 14:39:08 +02:00
darshanr0107
edb0edc451 chore: fix failing tests
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-10 17:21:58 +05:30
Knut Sveidqvist
b511a2e9be Merge branch 'develop' into mindmaps-and-elk-updates 2025-09-10 10:42:24 +02:00
autofix-ci[bot]
b80ea26a2b [autofix.ci] apply automated fixes 2025-09-08 08:35:29 +00:00
saurabhg772244
f88986a87d Updated DiagramDB interface to use DiagramOrientation for setDirection method 2025-09-08 13:59:52 +05:30
saurabhg772244
e16f0848ab Added missing types in diagramDB 2025-09-08 12:33:37 +05:30
quilicicf
2812a0d12a feat(architecture): Add ids in generated SVG 2025-09-06 14:27:28 +02:00
Knut Sveidqvist
25fa26d915 fix(layout-elk): prevent NaN paths from duplicate points 2025-09-05 16:24:32 +02:00
Knut Sveidqvist
62915183b1 Merge branch 'mindmaps-and-elk-updates' of github.com:mermaid-js/mermaid into mindmaps-and-elk-updates 2025-09-05 15:51:40 +02:00
Knut Sveidqvist
6874ab3fb6 Adjusted elk-config 2025-09-05 15:50:58 +02:00
darshanr0107
040af4f545 fix: failing unit test
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-05 19:16:43 +05:30
Knut Sveidqvist
65ca3eabfd Some cleanup 2025-09-05 15:21:45 +02:00
Knut Sveidqvist
8b9bbad842 Fix for render issue to and from subgraphs 2025-09-05 14:48:15 +02:00
darshanr0107
d2773db7dc fix: review comments and unit tests
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-05 16:43:29 +05:30
darshanr0107
0dd46a3543 fix: resolve TypeScript errors in mermaid-layout-elk
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-04 14:57:24 +05:30
darshanr0107
f81e63663c fix: pnpm lock issue
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-04 14:25:18 +05:30
darshanr0107
7109e3a17f fix: pnpm lock fil issue
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-04 14:19:18 +05:30
darshanr0107
e0bd51941e Revert "fix: revert pnpm-lock file"
This reverts commit 38f4e67ca7.
2025-09-04 14:14:01 +05:30
darshanr0107
38f4e67ca7 fix: revert pnpm-lock file
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-04 14:04:42 +05:30
darshanr0107
681d829227 fix: pnpm lock issues
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-04 14:00:38 +05:30
darshanr0107
164e44c3d9 Merge branch 'develop' of https://github.com/mermaid-js/mermaid into mindmaps-and-elk-updates 2025-09-04 13:46:33 +05:30
darshanr0107
2e1d156d66 Merge branch 'develop' into 6784-edge-label-color-mismatch 2025-09-02 17:21:02 +05:30
darshanr0107
e863ad1547 chore: revert unintended pnpm-lock.yaml changes
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-09-02 17:09:13 +05:30
darshanr0107
7091792694 fix: build issues
Some optional description over here if you need to add more info

on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-08-21 14:15:50 +05:30
darshanr0107
efd94b705d fix: build issues
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-08-21 14:11:48 +05:30
darshanr0107
9ec989e633 Merge branch 'develop' of https://github.com/mermaid-js/mermaid into 6784-edge-label-color-mismatch 2025-08-21 12:06:04 +05:30
Knut Sveidqvist
a716a525c3 Merge remote-tracking branch 'origin/develop' into mindmaps-and-elk-updates 2025-08-14 13:53:33 +02:00
Knut Sveidqvist
11abfc9ae5 Refactor code structure for improved readability and maintainability 2025-08-12 16:08:19 +02:00
darshanr0107
227cef05b3 fix: the breaking argos
Some optional description over here if you need to add more info

on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-08-12 18:55:11 +05:30
Knut Sveidqvist
81b0ffb92a Merge branch '6088-fix-for-diamond-intersections' into mindmaps-and-elk-updates 2025-08-12 11:11:51 +02:00
darshanr0107
1d3681053b added changeset
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-08-06 12:34:41 +05:30
darshanr0107
93df13898f fix: ensure class def color is applied to edge label
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-08-06 12:21:34 +05:30
Knut Sveidqvist
8314554eb5 Merge branch 'test-merge' into 6088-fix-for-diamond-intersections 2025-06-25 13:00:03 +02:00
Knut Sveidqvist
b7c03dc27e Some cleanup 2025-06-25 12:58:54 +02:00
Knut Sveidqvist
c7f2f609a9 Intersections ok 2025-06-24 20:30:50 +02:00
Knut Sveidqvist
4c3de3a1ec Merge remote-tracking branch 'origin/develop' into test-merge 2025-06-24 10:58:28 +02:00
78 changed files with 7303 additions and 1752 deletions

92
.build/antlr-cli.ts Normal file
View File

@@ -0,0 +1,92 @@
/* eslint-disable no-console */
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { resolve, dirname } from 'node:path';
import { readFile, mkdir, access } from 'node:fs/promises';
import { existsSync } from 'node:fs';
const execAsync = promisify(exec);
interface AntlrGrammarConfig {
id: string;
grammar: string;
outputDir: string;
language: string;
generateVisitor?: boolean;
generateListener?: boolean;
}
interface AntlrConfig {
projectName: string;
grammars: AntlrGrammarConfig[];
mode: string;
}
export async function generateFromConfig(configFile: string): Promise<void> {
const configPath = resolve(configFile);
if (!existsSync(configPath)) {
throw new Error(`ANTLR config file not found: ${configPath}`);
}
const configContent = await readFile(configPath, 'utf-8');
const config: AntlrConfig = JSON.parse(configContent);
const configDir = dirname(configPath);
for (const grammarConfig of config.grammars) {
await generateGrammar(grammarConfig, configDir);
}
}
async function generateGrammar(grammarConfig: AntlrGrammarConfig, baseDir: string): Promise<void> {
const grammarFile = resolve(baseDir, grammarConfig.grammar);
const outputDir = resolve(baseDir, grammarConfig.outputDir);
// Check if grammar file exists
try {
await access(grammarFile);
} catch {
throw new Error(`Grammar file not found: ${grammarFile}`);
}
// Ensure output directory exists
await mkdir(outputDir, { recursive: true });
// Build ANTLR command arguments
// eslint-disable-next-line @cspell/spellchecker
const args = ['antlr-ng', `-Dlanguage=${grammarConfig.language}`];
if (grammarConfig.generateVisitor) {
args.push('--generate-visitor');
}
if (grammarConfig.generateListener) {
args.push('--generate-listener');
}
args.push('-o', `"${outputDir}"`, `"${grammarFile}"`);
const command = `npx ${args.join(' ')}`;
try {
await execAsync(command);
console.log(`Generated ANTLR files for ${grammarConfig.id}`);
} catch (error) {
console.error(`Failed to generate ANTLR files for ${grammarConfig.id}:`);
throw error;
}
}
// CLI interface
if (import.meta.url === `file://${process.argv[1]}`) {
const configFile = process.argv[2] || './packages/parser/antlr-config.json';
try {
await generateFromConfig(configFile);
console.log('ANTLR generation completed successfully!');
} catch (error) {
console.error('ANTLR generation failed:', error.message);
}
}

5
.build/generateAntlr.ts Normal file
View File

@@ -0,0 +1,5 @@
import { generateFromConfig } from './antlr-cli.js';
export async function generateAntlr() {
await generateFromConfig('./packages/parser/antlr-config.json');
}

View File

@@ -28,6 +28,7 @@ const MERMAID_CONFIG_DIAGRAM_KEYS = [
'packet',
'architecture',
'radar',
'usecase',
] as const;
/**

View File

@@ -0,0 +1,5 @@
---
'mermaid': minor
---
Add IDs in architecture diagrams

View File

@@ -0,0 +1,5 @@
---
'mermaid': patch
---
fix: Ensure edge label color is applied when using classDef with edge IDs

View File

@@ -0,0 +1,5 @@
---
'mermaid': patch
---
fix: Resolve gantt chart crash due to invalid array length

View File

@@ -1,5 +0,0 @@
---
'mermaid': patch
---
fix: Added support for styling class diagram elements based on stereotype annotations

View File

@@ -0,0 +1,5 @@
---
'mermaid': minor
---
feat: Add IDs in architecture diagrams

View File

@@ -143,6 +143,9 @@ typeof
typestr
unshift
urlsafe
usecase
Usecase
USECASE
verifymethod
VERIFYMTHD
WARN_DOCSDIR_DOESNT_MATCH

View File

@@ -8,6 +8,7 @@ compositTitleSize
cose
curv
doublecircle
elem
elems
gantt
gitgraph

View File

@@ -4,6 +4,7 @@ import { packageOptions } from '../.build/common.js';
import { generateLangium } from '../.build/generateLangium.js';
import type { MermaidBuildOptions } from './util.js';
import { defaultOptions, getBuildConfig } from './util.js';
import { generateAntlr } from '../.build/generateAntlr.js';
const shouldVisualize = process.argv.includes('--visualize');
@@ -95,6 +96,7 @@ const buildTinyMermaid = async () => {
const main = async () => {
await generateLangium();
await generateAntlr();
await mkdir('stats', { recursive: true });
const packageNames = Object.keys(packageOptions) as (keyof typeof packageOptions)[];
// it should build `parser` before `mermaid` because it's a dependency

View File

@@ -58,7 +58,7 @@ jobs:
echo "EOF" >> $GITHUB_OUTPUT
- name: Commit and create pull request
uses: peter-evans/create-pull-request@18e469570b1cf0dfc11d60ec121099f8ff3e617a
uses: peter-evans/create-pull-request@915d841dae6a4f191bb78faf61a257411d7be4d2
with:
add-paths: |
cypress/timings.json

5
.gitignore vendored
View File

@@ -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/

View File

@@ -10,6 +10,7 @@ import type { TemplateType } from 'rollup-plugin-visualizer/dist/plugin/template
import istanbul from 'vite-plugin-istanbul';
import { packageOptions } from '../.build/common.js';
import { generateLangium } from '../.build/generateLangium.js';
import { generateAntlr } from '../.build/generateAntlr.js';
const visualize = process.argv.includes('--visualize');
const watch = process.argv.includes('--watch');
@@ -123,6 +124,7 @@ const main = async () => {
};
await generateLangium();
await generateAntlr();
if (watch) {
await build(getBuildConfig({ minify: false, watch, core: false, entryName: 'parser' }));

View File

@@ -98,12 +98,12 @@ describe('Configuration', () => {
it('should handle arrowMarkerAbsolute set to true', () => {
renderGraph(
`flowchart TD
A[Christmas] -->|Get money| B(Go shopping)
B --> C{Let me think}
C -->|One| D[Laptop]
C -->|Two| E[iPhone]
C -->|Three| F[fa:fa-car Car]
`,
A[Christmas] -->|Get money| B(Go shopping)
B --> C{Let me think}
C -->|One| D[Laptop]
C -->|Two| E[iPhone]
C -->|Three| F[fa:fa-car Car]
`,
{
arrowMarkerAbsolute: true,
}
@@ -113,8 +113,7 @@ describe('Configuration', () => {
cy.get('path')
.first()
.should('have.attr', 'marker-end')
.should('exist')
.and('include', 'url(http\\:\\/\\/localhost');
.and('include', 'url(http://localhost');
});
});
it('should not taint the initial configuration when using multiple directives', () => {

View File

@@ -1028,88 +1028,4 @@ class C13["With Città foreign language"]
{ logLevel: 1, htmlLabels: true }
);
});
it('should render a full class diagram using interface annotation', () => {
imgSnapshotTest(
`
classDiagram
Class01 <|-- AveryLongClass : Cool
&lt;&lt;interface&gt;&gt; Class01
Class03 "0" *-- "0..n" Class04
Class05 "1" o-- "many" Class06
Class07 .. Class08
Class09 "many" --> "1" C2 : Where am i?
Class09 "0" --* "1..n" C3
Class09 --|> Class07
Class07 : equals()
Class07 : Object[] elementData
Class01 : #size()
Class01 : -int chimp
Class01 : +int gorilla
Class08 <--> C2: Cool label
class Class10 {
&lt;&lt;service&gt;&gt;
int id
size()
}
`,
{ logLevel: 1, htmlLabels: true }
);
});
it('should render a full class diagram using abstract annotation', () => {
imgSnapshotTest(
`
classDiagram
Class01 <|-- AveryLongClass : Cool
&lt;&lt;abstract&gt;&gt; Class01
Class03 "0" *-- "0..n" Class04
Class05 "1" o-- "many" Class06
Class07 .. Class08
Class09 "many" --> "1" C2 : Where am i?
Class09 "0" --* "1..n" C3
Class09 --|> Class07
Class07 : equals()
Class07 : Object[] elementData
Class01 : #size()
Class01 : -int chimp
Class01 : +int gorilla
Class08 <--> C2: Cool label
class Class10 {
&lt;&lt;service&gt;&gt;
int id
size()
}
`,
{ logLevel: 1, htmlLabels: true }
);
});
it('should render a full class diagram using enumeration annotation', () => {
imgSnapshotTest(
`
classDiagram
Class01 <|-- AveryLongClass : Cool
&lt;&lt;enumeration&gt;&gt; Class01
Class03 "0" *-- "0..n" Class04
Class05 "1" o-- "many" Class06
Class07 .. Class08
Class09 "many" --> "1" C2 : Where am i?
Class09 "0" --* "1..n" C3
Class09 --|> Class07
Class07 : equals()
Class07 : Object[] elementData
Class01 : #size()
Class01 : -int chimp
Class01 : +int gorilla
Class08 <--> C2: Cool label
class Class10 {
&lt;&lt;service&gt;&gt;
int id
size()
}
`,
{ logLevel: 1, htmlLabels: true }
);
});
});

View File

@@ -109,7 +109,7 @@ describe('Flowchart ELK', () => {
const style = svg.attr('style');
expect(style).to.match(/^max-width: [\d.]+px;$/);
const maxWidthValue = parseFloat(style.match(/[\d.]+/g).join(''));
verifyNumber(maxWidthValue, 380);
verifyNumber(maxWidthValue, 380, 15);
});
});
it('8-elk: should render a flowchart when useMaxWidth is false', () => {
@@ -128,7 +128,7 @@ describe('Flowchart ELK', () => {
const width = parseFloat(svg.attr('width'));
// use within because the absolute value can be slightly different depending on the environment ±5%
// expect(height).to.be.within(446 * 0.95, 446 * 1.05);
verifyNumber(width, 380);
verifyNumber(width, 380, 15);
expect(svg).to.not.have.attr('style');
});
});

View File

@@ -1186,4 +1186,17 @@ end
imgSnapshotTest(graph, { htmlLabels: false });
});
});
it('V2 - 17: should apply class def colour to edge label', () => {
imgSnapshotTest(
` graph LR
id1(Start) link@-- "Label" -->id2(Stop)
style id1 fill:#f9f,stroke:#333,stroke-width:4px
class id2 myClass
classDef myClass fill:#bbf,stroke:#f66,stroke-width:2px,color:white,stroke-dasharray: 5 5
class link myClass
`
);
});
});

View File

@@ -803,4 +803,34 @@ 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
`,
{}
);
});
});

View 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
`
);
});
});

View File

@@ -32,26 +32,8 @@
href="https://fonts.googleapis.com/css2?family=Kalam:wght@300;400;700&family=Rubik+Mono+One&display=swap"
rel="stylesheet"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Recursive:wght@300..1000&display=swap"
rel="stylesheet"
/>
<style>
.recursive-mermaid {
font-family: 'Recursive', sans-serif;
font-optical-sizing: auto;
font-weight: 500;
font-style: normal;
font-variation-settings:
'slnt' 0,
'CASL' 0,
'CRSV' 0.5,
'MONO' 0;
}
body {
/* background: rgb(221, 208, 208); */
/* background: #333; */
@@ -63,9 +45,7 @@
h1 {
color: grey;
}
.mermaid {
border: 1px solid red;
}
.mermaid2 {
display: none;
}
@@ -103,11 +83,6 @@
width: 100%;
}
.class2 {
fill: red;
fill-opacity: 1;
}
/* tspan {
font-size: 6px !important;
} */
@@ -130,76 +105,194 @@
</head>
<body>
<pre id="diagram4" class="mermaid2">
---
config:
layout: tidy-tree
---
mindmap
root((mindmap))
Origins
Long history
::icon(fa fa-book)
Popularisation
British popular psychology author Tony Buzan
Research
On effectiveness<br/>and features
On Automatic creation
Uses
Creative techniques
Strategic planning
Argument mapping
Tools
Pen and paper
Mermaid
</pre>
<pre id="diagram4" class="mermaid">
---
config:
layout: tidy-tree
layout: elk
---
mindmap
root((mindmap is a long thing))
A
B
C
D
</pre
flowchart-elk TB
c1-->a2
subgraph one
a1-->a2
end
subgraph two
b1-->b2
end
subgraph three
c1-->c2
end
one --> two
three --> two
two --> c2
</pre
>
<pre id="diagram4" class="mermaid">
---
config:
layout: tidy-tree
layout: elk
---
mindmap
root((mindmap))
A
B
</pre
flowchart TB
process_C
subgraph container_Alpha
subgraph process_B
pppB
end
subgraph process_A
pppA
end
process_B-->|via_AWSBatch|container_Beta
process_A-->|messages|container_Beta
end
</pre
>
<pre id="diagram4" class="mermaid">
---
config:
layout: tidy-tree
layout: elk
---
mindmap
root((mindmap))
A
a
apa[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
apa2[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
b
c
d
B
apa3[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
D
apa5[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
apa4[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
flowchart TB
subgraph container_Beta
process_C
end
subgraph container_Alpha
subgraph process_B
pppB
end
subgraph process_A
pppA
end
process_B-->|via_AWSBatch|container_Beta
process_A-->|messages|container_Beta
end
</pre>
</pre
>
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
---
flowchart TB
subgraph container_Beta
process_C
end
process_B-->|via_AWSBatch|container_Beta
</pre
>
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
---
classDiagram
note "I love this diagram!\nDo you love it?"
Class01 <|-- AveryLongClass : Cool
&lt;&lt;interface&gt;&gt; Class01
Class03 "1" *-- "*" Class04
Class05 "1" o-- "many" Class06
Class07 "1" .. "*" Class08
Class09 "1" --> "*" C2 : Where am i?
Class09 "*" --* "*" C3
Class09 "1" --|> "1" Class07
Class12 <|.. Class08
Class11 ..>Class12
Class07 : equals()
Class07 : Object[] elementData
Class01 : size()
Class01 : int chimp
Class01 : int gorilla
Class01 : -int privateChimp
Class01 : +int publicGorilla
Class01 : #int protectedMarmoset
Class08 <--> C2: Cool label
class Class10 {
&lt;&lt;service&gt;&gt;
int id
test()
}
note for Class10 "Cool class\nI said it's very cool class!"
</pre
>
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
---
requirementDiagram
requirement test_req {
id: 1
text: the test text.
risk: high
verifymethod: test
}
element test_entity {
type: simulation
}
test_entity - satisfies -> test_req
</pre
>
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
---
flowchart-elk TB
internet
nat
router
compute1
subgraph project
router
nat
subgraph subnet1
compute1
end
end
%% router --> subnet1
subnet1 --> nat
%% nat --> internet
</pre
>
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
---
flowchart-elk TB
internet
nat
router
lb1
lb2
compute1
compute2
subgraph project
router
nat
subgraph subnet1
compute1
lb1
end
subgraph subnet2
compute2
lb2
end
end
internet --> router
router --> subnet1 & subnet2
subnet1 & subnet2 --> nat --> internet
</pre
>
<pre id="diagram4" class="mermaid">
---
config:
@@ -227,91 +320,35 @@ treemap
"Leaf 2.2": 25
"Leaf 2.3": 12
classDef class1 fill:red,color:blue,stroke:#FFD600;
</pre>
<pre id="diagram5" class="mermaid">
---
config:
layout: elk
flowchart:
curve: rounded
---
flowchart LR
I["fa:fa-code Text"] -- Mermaid js --> D["Use<br/>the<br/>editor!"]
I --> D & D
D@{ shape: question}
I@{ shape: question}
</pre
>
<pre id="diagram4" class="mermaid2">
---
config:
treemap:
valueFormat: '$0,0'
---
treemap
"Budget"
"Operations"
"Salaries": 7000
"Equipment": 2000
"Supplies": 1000
"Marketing"
"Advertising": 4000
"Events": 1000
</pre
>
</pre>
<pre id="diagram4" class="mermaid">
treemap
title Accessible Treemap Title
"Category A"
"Item A1": 10
"Item A2": 20
"Category B"
"Item B1": 15
"Item B2": 25
</pre>
<pre id="diagram4" class="mermaid2">
---
config:
layout: tidy-tree
---
mindmap
root((mindmap))
a
apa[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
apa2[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
b
apa3[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
apa4[I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on. I am a long long multline string passing several levels of text. Lorum ipsum and so on]
</pre>
<pre id="diagram4" class="mermaid2">
---
config:
layout: tidy-tree
---
flowchart TB
A --> n0["1"]
A --> n1["2"]
A --> n2["3"]
A --> n3["4"] --> Q & R & S & T
</pre>
<pre id="diagram4" class="mermaid2">
---
config:
layout: elk
---
flowchart TB
A --> n0["1"]
A --> n1["2"]
A --> n2["3"]
A --> n3["4"] --> Q & R & S & T
</pre>
<pre id="diagram4" class="mermaid2">
---
config:
layout: dagre
---
mindmap
root((mindmap is a long thing))
Origins
Long history
::icon(fa fa-book)
Popularisation
British popular psychology author Tony Buzan
Research
On effectiveness&lt;br/>and features
On effectiveness<br/>and features
On Automatic creation
Uses
Creative techniques
@@ -320,128 +357,112 @@ treemap
Tools
Pen and paper
Mermaid
</pre>
<pre id="diagram4" class="mermaid2">
---
config:
layout: cose-bilkent
---
mindmap
root((mindmap))
Origins
Long history
::icon(fa fa-book)
Popularisation
British popular psychology author Tony Buzan
Research
On effectiveness&lt;br/>and features
On Automatic creation
Uses
Creative techniques
Strategic planning
Argument mapping
Tools
Pen and paper
Mermaid
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
---
mindmap
root((mindmap))
Origins
Long history
::icon(fa fa-book)
Popularisation
British popular psychology author Tony Buzan
Research
On effectiveness&lt;br/>and features
On Automatic creation
Uses
Creative techniques
Strategic planning
Argument mapping
Tools
Pen and paper
Mermaid
</pre>
<pre id="diagram4" class="mermaid2">
---
config:
layout: cose-bilkent
flowchart:
curve: linear
---
flowchart LR
root{mindmap} --- Origins --- Europe
Origins --> Asia
root --- Background --- Rich
Background --- Poor
subgraph apa
Background
Poor
end
A[A] --> B[B]
A[A] --- B([C])
A@{ shape: diamond}
%%B@{ shape: diamond}
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
flowchart:
curve: linear
---
flowchart LR
root{mindmap} --- Origins --- Europe
Origins --> Asia
root --- Background --- Rich
Background --- Poor
A[A] -- Mermaid js --> B[B]
A[A] -- Mermaid js --- B[B]
A@{ shape: diamond}
B@{ shape: diamond}
</pre>
<pre id="diagram4" class="mermaid2">
flowchart
D(("for D"))
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
flowchart:
curve: rounded
---
flowchart LR
A e1@==> B
e1@{ animate: true}
D["Use the editor"] -- Mermaid js --> I["fa:fa-code Text"]
I --> D & D
D@{ shape: question}
I@{ shape: question}
</pre>
<pre id="diagram4" class="mermaid2">
flowchart LR
A e1@--> B
classDef animate stroke-width:2,stroke-dasharray:10\,8,stroke-dashoffset:-180,animation: edge-animation-frame 6s linear infinite, stroke-linecap: round
class e1 animate
</pre>
<h2>infinite</h2>
<pre id="diagram4" class="mermaid2">
flowchart LR
A e1@--> B
classDef animate stroke-dasharray: 9\,5,stroke-dashoffset: 900,animation: dash 25s linear infinite;
class e1 animate
</pre>
<h2>Mermaid - edge-animation-slow</h2>
<pre id="diagram4" class="mermaid2">
flowchart LR
A e1@--> B
e1@{ animation: fast}
</pre>
<h2>Mermaid - edge-animation-fast</h2>
<pre id="diagram4" class="mermaid2">
flowchart LR
A e1@--> B
classDef animate stroke-dasharray: 1000,stroke-dashoffset: 1000,animation: dash 10s linear;
class e1 edge-animation-fast
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
flowchart:
curve: rounded
elk:
nodePlacementStrategy: NETWORK_SIMPLEX
---
flowchart LR
D["Use the editor"] -- Mermaid js --> I["fa:fa-code Text"]
D --> I & I
a["a"]
D@{ shape: trap-b}
I@{ shape: lean-l}
</pre>
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
<pre id="diagram4" class="mermaid2">
---
flowchart LR
%% subgraph s1["Untitled subgraph"]
C["Evaluate"]
%% end
info </pre
>
<pre id="diagram4" class="mermaid2">
B --> C
</pre>
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
flowchart:
//curve: linear
---
flowchart LR
%% A ==> B
%% A2 --> B2
A{A} --> B((Bo boo)) & B & B & B
</pre>
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
theme: default
look: classic
---
flowchart LR
subgraph s1["APA"]
D{"Use the editor"}
end
subgraph S2["S2"]
s1
I>"fa:fa-code Text"]
E["E"]
end
D -- Mermaid js --> I
D --> I & E
E --> I
</pre>
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
@@ -466,7 +487,7 @@ config:
end
end
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
@@ -479,45 +500,7 @@ config:
D-->I
D-->I
</pre>
<pre id="diagram4" class="mermaid2">
---
config:
layout: elk
---
flowchart LR
a
subgraph s0["APA"]
subgraph s8["APA"]
subgraph s1["APA"]
D{"X"}
E[Q]
end
subgraph s3["BAPA"]
F[Q]
I
end
D --> I
D --> I
D --> I
I{"X"}
end
end
</pre>
<pre id="diagram4" class="mermaid2">
---
config:
layout: elk
---
flowchart LR
a
D{"Use the editor"}
D -- Mermaid js --> I{"fa:fa-code Text"}
D-->I
D-->I
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
@@ -556,7 +539,7 @@ flowchart LR
n8@{ shape: rect}
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
@@ -572,7 +555,7 @@ flowchart LR
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
@@ -581,7 +564,7 @@ flowchart LR
A{A} --> B & C
</pre
>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
@@ -593,7 +576,7 @@ flowchart LR
end
</pre
>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
---
config:
layout: elk
@@ -611,7 +594,7 @@ flowchart LR
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
---
config:
kanban:
@@ -630,81 +613,81 @@ kanban
task3[💻 Develop login feature]@{ ticket: 103 }
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
flowchart LR
nA[Default] --> A@{ icon: 'fa:bell', form: 'rounded' }
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
flowchart LR
nA[Style] --> A@{ icon: 'fa:bell', form: 'rounded' }
style A fill:#f9f,stroke:#333,stroke-width:4px
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
flowchart LR
nA[Class] --> A@{ icon: 'fa:bell', form: 'rounded' }
A:::AClass
classDef AClass fill:#f9f,stroke:#333,stroke-width:4px
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
flowchart LR
nA[Class] --> A@{ icon: 'logos:aws', form: 'rounded' }
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
flowchart LR
nA[Default] --> A@{ icon: 'fa:bell', form: 'square' }
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
flowchart LR
nA[Style] --> A@{ icon: 'fa:bell', form: 'square' }
style A fill:#f9f,stroke:#333,stroke-width:4px
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
flowchart LR
nA[Class] --> A@{ icon: 'fa:bell', form: 'square' }
A:::AClass
classDef AClass fill:#f9f,stroke:#333,stroke-width:4px
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
flowchart LR
nA[Class] --> A@{ icon: 'logos:aws', form: 'square' }
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
flowchart LR
nA[Default] --> A@{ icon: 'fa:bell', form: 'circle' }
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
flowchart LR
nA[Style] --> A@{ icon: 'fa:bell', form: 'circle' }
style A fill:#f9f,stroke:#333,stroke-width:4px
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
flowchart LR
nA[Class] --> A@{ icon: 'fa:bell', form: 'circle' }
A:::AClass
classDef AClass fill:#f9f,stroke:#333,stroke-width:4px
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
flowchart LR
nA[Class] --> A@{ icon: 'logos:aws', form: 'circle' }
A:::AClass
classDef AClass fill:#f9f,stroke:#333,stroke-width:4px
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
flowchart LR
nA[Style] --> A@{ icon: 'logos:aws', form: 'circle' }
style A fill:#f9f,stroke:#333,stroke-width:4px
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
kanban
id2[In progress]
docs[Create Blog about the new diagram]@{ priority: 'Very Low', ticket: MC-2037, assigned: 'knsv' }
</pre>
<pre id="diagram4" class="mermaid2">
<pre id="diagram4" class="mermaid">
---
config:
kanban:
@@ -768,18 +751,22 @@ kanban
alert('It worked');
}
await mermaid.initialize({
// theme: 'forest',
// theme: 'base',
// theme: 'default',
// theme: 'forest',
// handDrawnSeed: 12,
// look: 'handDrawn',
// 'elk.nodePlacement.strategy': 'NETWORK_SIMPLEX',
// layout: 'dagre',
// layout: 'elk',
layout: 'elk',
// layout: 'fixed',
// htmlLabels: false,
flowchart: { titleTopMargin: 10 },
fontFamily: "'Recursive', sans-serif",
// fontFamily: 'Caveat',
// fontFamily: 'Kalam',
// fontFamily: 'courier',
fontFamily: 'arial',
sequence: {
actorFontFamily: 'courier',
noteFontFamily: 'courier',

View File

@@ -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)

View File

@@ -10,7 +10,7 @@
# Interface: ExternalDiagramDefinition
Defined in: [packages/mermaid/src/diagram-api/types.ts:94](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L94)
Defined in: [packages/mermaid/src/diagram-api/types.ts:96](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L96)
## Properties
@@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/diagram-api/types.ts:94](https://github.com/me
> **detector**: `DiagramDetector`
Defined in: [packages/mermaid/src/diagram-api/types.ts:96](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L96)
Defined in: [packages/mermaid/src/diagram-api/types.ts:98](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L98)
---
@@ -26,7 +26,7 @@ Defined in: [packages/mermaid/src/diagram-api/types.ts:96](https://github.com/me
> **id**: `string`
Defined in: [packages/mermaid/src/diagram-api/types.ts:95](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L95)
Defined in: [packages/mermaid/src/diagram-api/types.ts:97](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L97)
---
@@ -34,4 +34,4 @@ Defined in: [packages/mermaid/src/diagram-api/types.ts:95](https://github.com/me
> **loader**: `DiagramLoader`
Defined in: [packages/mermaid/src/diagram-api/types.ts:97](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L97)
Defined in: [packages/mermaid/src/diagram-api/types.ts:99](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L99)

View File

@@ -10,7 +10,7 @@
# Interface: LayoutLoaderDefinition
Defined in: [packages/mermaid/src/rendering-util/render.ts:21](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L21)
Defined in: [packages/mermaid/src/rendering-util/render.ts:24](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L24)
## Properties
@@ -18,7 +18,7 @@ Defined in: [packages/mermaid/src/rendering-util/render.ts:21](https://github.co
> `optional` **algorithm**: `string`
Defined in: [packages/mermaid/src/rendering-util/render.ts:24](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L24)
Defined in: [packages/mermaid/src/rendering-util/render.ts:27](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L27)
---
@@ -26,7 +26,7 @@ Defined in: [packages/mermaid/src/rendering-util/render.ts:24](https://github.co
> **loader**: `LayoutLoader`
Defined in: [packages/mermaid/src/rendering-util/render.ts:23](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L23)
Defined in: [packages/mermaid/src/rendering-util/render.ts:26](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L26)
---
@@ -34,4 +34,4 @@ Defined in: [packages/mermaid/src/rendering-util/render.ts:23](https://github.co
> **name**: `string`
Defined in: [packages/mermaid/src/rendering-util/render.ts:22](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L22)
Defined in: [packages/mermaid/src/rendering-util/render.ts:25](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L25)

View File

@@ -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)
---

View File

@@ -10,7 +10,7 @@
# Interface: RenderOptions
Defined in: [packages/mermaid/src/rendering-util/render.ts:7](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L7)
Defined in: [packages/mermaid/src/rendering-util/render.ts:10](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L10)
## Properties
@@ -18,4 +18,4 @@ Defined in: [packages/mermaid/src/rendering-util/render.ts:7](https://github.com
> `optional` **algorithm**: `string`
Defined in: [packages/mermaid/src/rendering-util/render.ts:8](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L8)
Defined in: [packages/mermaid/src/rendering-util/render.ts:11](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L11)

View File

@@ -12,4 +12,4 @@
> **SVG** = `d3.Selection`<`SVGSVGElement`, `unknown`, `Element` | `null`, `unknown`>
Defined in: [packages/mermaid/src/diagram-api/types.ts:126](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L126)
Defined in: [packages/mermaid/src/diagram-api/types.ts:128](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L128)

View File

@@ -12,4 +12,4 @@
> **SVGGroup** = `d3.Selection`<`SVGGElement`, `unknown`, `Element` | `null`, `unknown`>
Defined in: [packages/mermaid/src/diagram-api/types.ts:128](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L128)
Defined in: [packages/mermaid/src/diagram-api/types.ts:130](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/diagram-api/types.ts#L130)

View File

@@ -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.

View File

@@ -3,6 +3,7 @@
"version": "1.0.0",
"description": "Mermaid examples package",
"author": "Sidharth Vinod",
"license": "MIT",
"type": "module",
"module": "./dist/mermaid-examples.core.mjs",
"types": "./dist/mermaid.d.ts",

View File

@@ -0,0 +1,51 @@
import type { DiagramMetadata } from '../types.js';
export default {
id: 'usecase',
name: 'Use Case Diagram',
description: 'Visualize system functionality and user interactions',
examples: [
{
title: 'Basic Use Case',
isDefault: true,
code: `usecase
actor User
actor Admin
User --> (Login)
User --> (View Profile)
Admin --> (Manage Users)
Admin --> (View Reports)`,
},
{
title: 'System Boundary',
code: `usecase
actor Customer
actor Support
SystemBoundary@{ type: rect } "E-commerce System" {
Customer --> (Browse Products)
Customer --> (Place Order)
Customer --> (Track Order)
}
SystemBoundary@{ type: package } "Admin Panel" {
Support --> (Process Orders)
Support --> (Handle Returns)
}`,
},
{
title: 'Actor Relationships',
code: `usecase
actor Developer1
actor Developer2
actor Manager
Developer1 --> (Write Code)
Developer2 --> (Review Code)
Manager --> (Approve Release)
Developer1 --> Developer2
Manager --> Developer1`,
},
],
} satisfies DiagramMetadata;

View File

@@ -21,6 +21,7 @@ import quadrantChart from './examples/quadrant-chart.js';
import packetDiagram from './examples/packet.js';
import blockDiagram from './examples/block.js';
import treemapDiagram from './examples/treemap.js';
import usecaseDiagram from './examples/usecase.js';
export const diagramData: DiagramMetadata[] = [
flowChart,
@@ -45,4 +46,5 @@ export const diagramData: DiagramMetadata[] = [
packetDiagram,
blockDiagram,
treemapDiagram,
usecaseDiagram,
];

View File

@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import {
intersection,
ensureTrulyOutside,
makeInsidePoint,
tryNodeIntersect,
replaceEndpoint,
type RectLike,
type P,
} from '../geometry.js';
const approx = (a: number, b: number, eps = 1e-6) => Math.abs(a - b) < eps;
describe('geometry helpers', () => {
it('intersection: vertical approach hits bottom border', () => {
const rect: RectLike = { x: 0, y: 0, width: 100, height: 50 };
const h = rect.height / 2; // 25
const outside: P = { x: 0, y: 100 };
const inside: P = { x: 0, y: 0 };
const res = intersection(rect, outside, inside);
expect(approx(res.x, 0)).toBe(true);
expect(approx(res.y, h)).toBe(true);
});
it('ensureTrulyOutside nudges near-boundary point outward', () => {
const rect: RectLike = { x: 0, y: 0, width: 100, height: 50 };
// near bottom boundary (y ~ h)
const near: P = { x: 0, y: rect.height / 2 - 0.2 };
const out = ensureTrulyOutside(rect, near, 10);
expect(out.y).toBeGreaterThan(rect.height / 2);
});
it('makeInsidePoint keeps x for vertical and y from center', () => {
const rect: RectLike = { x: 10, y: 5, width: 100, height: 50 };
const outside: P = { x: 10, y: 40 };
const center: P = { x: 99, y: -123 }; // center y should be used
const inside = makeInsidePoint(rect, outside, center);
expect(inside.x).toBe(outside.x);
expect(inside.y).toBe(center.y);
});
it('tryNodeIntersect returns null for wrong-side intersections', () => {
const rect: RectLike = { x: 0, y: 0, width: 100, height: 50 };
const outside: P = { x: -50, y: 0 };
const node = { intersect: () => ({ x: 10, y: 0 }) } as any; // right side of center
const res = tryNodeIntersect(node, rect, outside);
expect(res).toBeNull();
});
it('replaceEndpoint dedup removes end/start appropriately', () => {
const pts: P[] = [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
];
// remove duplicate end
replaceEndpoint(pts, 'end', { x: 1, y: 1 });
expect(pts.length).toBe(1);
const pts2: P[] = [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
];
// remove duplicate start
replaceEndpoint(pts2, 'start', { x: 0, y: 0 });
expect(pts2.length).toBe(1);
});
});

View File

@@ -0,0 +1,209 @@
/* Geometry utilities extracted from render.ts for reuse and testing */
export interface P {
x: number;
y: number;
}
export interface RectLike {
x: number; // center x
y: number; // center y
width: number;
height: number;
padding?: number;
}
export interface NodeLike {
intersect?: (p: P) => P | null;
}
export const EPS = 1;
export const PUSH_OUT = 10;
export const onBorder = (bounds: RectLike, p: P, tol = 0.5): boolean => {
const halfW = bounds.width / 2;
const halfH = bounds.height / 2;
const left = bounds.x - halfW;
const right = bounds.x + halfW;
const top = bounds.y - halfH;
const bottom = bounds.y + halfH;
const onLeft = Math.abs(p.x - left) <= tol && p.y >= top - tol && p.y <= bottom + tol;
const onRight = Math.abs(p.x - right) <= tol && p.y >= top - tol && p.y <= bottom + tol;
const onTop = Math.abs(p.y - top) <= tol && p.x >= left - tol && p.x <= right + tol;
const onBottom = Math.abs(p.y - bottom) <= tol && p.x >= left - tol && p.x <= right + tol;
return onLeft || onRight || onTop || onBottom;
};
/**
* Compute intersection between a rectangle (center x/y, width/height) and the line
* segment from insidePoint -\> outsidePoint. Returns the point on the rectangle border.
*
* This version avoids snapping to outsidePoint when certain variables evaluate to 0
* (previously caused vertical top/bottom cases to miss the border). It only enforces
* axis-constant behavior for purely vertical/horizontal approaches.
*/
export const intersection = (node: RectLike, outsidePoint: P, insidePoint: P): P => {
const x = node.x;
const y = node.y;
const dx = Math.abs(x - insidePoint.x);
const w = node.width / 2;
let r = insidePoint.x < outsidePoint.x ? w - dx : w + dx;
const h = node.height / 2;
const Q = Math.abs(outsidePoint.y - insidePoint.y);
const R = Math.abs(outsidePoint.x - insidePoint.x);
if (Math.abs(y - outsidePoint.y) * w > Math.abs(x - outsidePoint.x) * h) {
// Intersection is top or bottom of rect.
const q = insidePoint.y < outsidePoint.y ? outsidePoint.y - h - y : y - h - outsidePoint.y;
r = (R * q) / Q;
const res = {
x: insidePoint.x < outsidePoint.x ? insidePoint.x + r : insidePoint.x - R + r,
y: insidePoint.y < outsidePoint.y ? insidePoint.y + Q - q : insidePoint.y - Q + q,
};
// Keep axis-constant special-cases only
if (R === 0) {
res.x = outsidePoint.x;
}
if (Q === 0) {
res.y = outsidePoint.y;
}
return res;
} else {
// Intersection on sides of rect
if (insidePoint.x < outsidePoint.x) {
r = outsidePoint.x - w - x;
} else {
r = x - w - outsidePoint.x;
}
const q = (Q * r) / R;
let _x = insidePoint.x < outsidePoint.x ? insidePoint.x + R - r : insidePoint.x - R + r;
let _y = insidePoint.y < outsidePoint.y ? insidePoint.y + q : insidePoint.y - q;
// Only handle axis-constant cases
if (R === 0) {
_x = outsidePoint.x;
}
if (Q === 0) {
_y = outsidePoint.y;
}
return { x: _x, y: _y };
}
};
export const outsideNode = (node: RectLike, point: P): boolean => {
const x = node.x;
const y = node.y;
const dx = Math.abs(point.x - x);
const dy = Math.abs(point.y - y);
const w = node.width / 2;
const h = node.height / 2;
return dx >= w || dy >= h;
};
export const ensureTrulyOutside = (bounds: RectLike, p: P, push = PUSH_OUT): P => {
const dx = Math.abs(p.x - bounds.x);
const dy = Math.abs(p.y - bounds.y);
const w = bounds.width / 2;
const h = bounds.height / 2;
if (Math.abs(dx - w) < EPS || Math.abs(dy - h) < EPS) {
const dirX = p.x - bounds.x;
const dirY = p.y - bounds.y;
const len = Math.sqrt(dirX * dirX + dirY * dirY);
if (len > 0) {
return {
x: bounds.x + (dirX / len) * (len + push),
y: bounds.y + (dirY / len) * (len + push),
};
}
}
return p;
};
export const makeInsidePoint = (bounds: RectLike, outside: P, center: P): P => {
const isVertical = Math.abs(outside.x - bounds.x) < EPS;
const isHorizontal = Math.abs(outside.y - bounds.y) < EPS;
return {
x: isVertical
? outside.x
: outside.x < bounds.x
? bounds.x - bounds.width / 4
: bounds.x + bounds.width / 4,
y: isHorizontal ? outside.y : center.y,
};
};
export const tryNodeIntersect = (node: NodeLike, bounds: RectLike, outside: P): P | null => {
if (!node?.intersect) {
return null;
}
const res = node.intersect(outside);
if (!res) {
return null;
}
const wrongSide =
(outside.x < bounds.x && res.x > bounds.x) || (outside.x > bounds.x && res.x < bounds.x);
if (wrongSide) {
return null;
}
const dist = Math.hypot(outside.x - res.x, outside.y - res.y);
if (dist <= EPS) {
return null;
}
return res;
};
export const fallbackIntersection = (bounds: RectLike, outside: P, center: P): P => {
const inside = makeInsidePoint(bounds, outside, center);
return intersection(bounds, outside, inside);
};
export const computeNodeIntersection = (
node: NodeLike,
bounds: RectLike,
outside: P,
center: P
): P => {
const outside2 = ensureTrulyOutside(bounds, outside);
return tryNodeIntersect(node, bounds, outside2) ?? fallbackIntersection(bounds, outside2, center);
};
export const replaceEndpoint = (
points: P[],
which: 'start' | 'end',
value: P | null | undefined,
tol = 0.1
) => {
if (!value || points.length === 0) {
return;
}
if (which === 'start') {
if (
points.length > 0 &&
Math.abs(points[0].x - value.x) < tol &&
Math.abs(points[0].y - value.y) < tol
) {
// duplicate start remove it
points.shift();
} else {
points[0] = value;
}
} else {
const last = points.length - 1;
if (
points.length > 0 &&
Math.abs(points[last].x - value.x) < tol &&
Math.abs(points[last].y - value.y) < tol
) {
// duplicate end remove it
points.pop();
} else {
points[last] = value;
}
}
};

View File

@@ -1,11 +1,26 @@
import type { InternalHelpers, LayoutData, RenderOptions, SVG, SVGGroup } from 'mermaid';
// @ts-ignore TODO: Investigate D3 issue
import { curveLinear } from 'd3';
import ELK from 'elkjs/lib/elk.bundled.js';
import type { InternalHelpers, LayoutData, RenderOptions, SVG, SVGGroup } from 'mermaid';
import { type TreeData, findCommonAncestor } from './find-common-ancestor.js';
import {
type P,
type RectLike,
outsideNode,
computeNodeIntersection,
replaceEndpoint,
onBorder,
} from './geometry.js';
type Node = LayoutData['nodes'][number];
// Used to calculate distances in order to avoid floating number rounding issues when comparing floating numbers
const epsilon = 0.0001;
// Minimal structural type to avoid depending on d3 Selection typings
interface D3Selection<T extends Element> {
node(): T | null;
attr(name: string, value: string): D3Selection<T>;
}
interface LabelData {
width: number;
height: number;
@@ -16,18 +31,9 @@ interface LabelData {
interface NodeWithVertex extends Omit<Node, 'domId'> {
children?: LayoutData['nodes'];
labelData?: LabelData;
domId?: Node['domId'] | SVGGroup | d3.Selection<SVGAElement, unknown, Element | null, unknown>;
}
interface Point {
x: number;
y: number;
}
function distance(p1?: Point, p2?: Point): number {
if (!p1 || !p2) {
return 0;
}
return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
domId?: D3Selection<SVGAElement | SVGGElement>;
}
export const render = async (
data4Layout: LayoutData,
svg: SVG,
@@ -61,39 +67,26 @@ export const render = async (
// Add the element to the DOM
if (!node.isGroup) {
// Create a clean node object for ELK with only the properties it expects
const child: NodeWithVertex = {
id: node.id,
width: node.width,
height: node.height,
// Store the original node data for later use
label: node.label,
isGroup: node.isGroup,
shape: node.shape,
padding: node.padding,
cssClasses: node.cssClasses,
cssStyles: node.cssStyles,
look: node.look,
// Include parentId for subgraph processing
parentId: node.parentId,
};
const child = node as NodeWithVertex;
graph.children.push(child);
nodeDb[node.id] = child;
nodeDb[node.id] = node;
const childNodeEl = await insertNode(nodeEl, node, { config, dir: node.dir });
const boundingBox = childNodeEl.node()!.getBBox();
// Store the domId separately for rendering, not in the ELK graph
child.domId = childNodeEl;
child.calcIntersect = node.calcIntersect;
child.width = boundingBox.width;
child.height = boundingBox.height;
} else {
// A subgraph
const child: NodeWithVertex & { children: NodeWithVertex[] } = {
...node,
domId: undefined,
children: [],
};
// Let elk render with the copy
graph.children.push(child);
// Save the original containing the intersection function
nodeDb[node.id] = child;
await addVertices(nodeEl, nodeArr, child, node.id);
@@ -168,7 +161,7 @@ export const render = async (
height: node.height,
};
if (node.isGroup) {
log.debug('id abc88 subgraph = ', node.id, node.x, node.y, node.labelData);
log.debug('Id abc88 subgraph = ', node.id, node.x, node.y, node.labelData);
const subgraphEl = subgraphsEl.insert('g').attr('class', 'subgraph');
// TODO use faster way of cloning
const clusterNode = JSON.parse(JSON.stringify(node));
@@ -177,10 +170,10 @@ export const render = async (
clusterNode.width = Math.max(clusterNode.width, node.labelData.width);
await insertCluster(subgraphEl, clusterNode);
log.debug('id (UIO)= ', node.id, node.width, node.shape, node.labels);
log.debug('Id (UIO)= ', node.id, node.width, node.shape, node.labels);
} else {
log.info(
'id NODE = ',
'Id NODE = ',
node.id,
node.x,
node.y,
@@ -222,25 +215,19 @@ export const render = async (
});
});
subgraphs.forEach(function (subgraph: { id: string | number }) {
const data: any = { id: subgraph.id };
if (parentLookupDb.parentById[subgraph.id] !== undefined) {
data.parent = parentLookupDb.parentById[subgraph.id];
}
});
return parentLookupDb;
};
const getEdgeStartEndPoint = (edge: any) => {
const source: any = edge.start;
const target: any = edge.end;
// edge.start and edge.end are IDs (string/number) in our layout data
const sourceId: string | number = edge.start;
const targetId: string | number = edge.end;
// Save the original source and target
const sourceId = source;
const targetId = target;
const source = sourceId;
const target = targetId;
const startNode = nodeDb[edge.start.id];
const endNode = nodeDb[edge.end.id];
const startNode = nodeDb[sourceId];
const endNode = nodeDb[targetId];
if (!startNode || !endNode) {
return { source, target };
@@ -263,6 +250,112 @@ export const render = async (
/**
* Add edges to graph based on parsed graph definition
*/
// Edge helper maps and utilities (de-duplicated)
const ARROW_MAP: Record<string, [string, string]> = {
arrow_open: ['arrow_open', 'arrow_open'],
arrow_cross: ['arrow_open', 'arrow_cross'],
double_arrow_cross: ['arrow_cross', 'arrow_cross'],
arrow_point: ['arrow_open', 'arrow_point'],
double_arrow_point: ['arrow_point', 'arrow_point'],
arrow_circle: ['arrow_open', 'arrow_circle'],
double_arrow_circle: ['arrow_circle', 'arrow_circle'],
};
const computeStroke = (
stroke: string | undefined,
defaultStyle?: string,
defaultLabelStyle?: string
) => {
// Defaults correspond to 'normal'
let thickness = 'normal';
let pattern = 'solid';
let style = '';
let labelStyle = '';
if (stroke === 'dotted') {
pattern = 'dotted';
style = 'fill:none;stroke-width:2px;stroke-dasharray:3;';
} else if (stroke === 'thick') {
thickness = 'thick';
style = 'stroke-width: 3.5px;fill:none;';
} else {
// normal
style = defaultStyle ?? 'fill:none;';
if (defaultLabelStyle !== undefined) {
labelStyle = defaultLabelStyle;
}
}
return { thickness, pattern, style, labelStyle };
};
const getCurve = (edgeInterpolate: any, edgesDefaultInterpolate: any, confCurve: any) => {
if (edgeInterpolate !== undefined) {
return interpolateToCurve(edgeInterpolate, curveLinear);
}
if (edgesDefaultInterpolate !== undefined) {
return interpolateToCurve(edgesDefaultInterpolate, curveLinear);
}
// @ts-ignore TODO: fix this
return interpolateToCurve(confCurve, curveLinear);
};
const buildEdgeData = (
edge: any,
defaults: {
defaultStyle?: string;
defaultLabelStyle?: string;
defaultInterpolate?: any;
confCurve: any;
},
common: any
) => {
const edgeData: any = { style: '', labelStyle: '' };
edgeData.minlen = edge.length || 1;
// maintain legacy behavior
edge.text = edge.label;
// Arrowhead fill vs none
edgeData.arrowhead = edge.type === 'arrow_open' ? 'none' : 'normal';
// Arrow types
const arrowMap = ARROW_MAP[edge.type] ?? ARROW_MAP.arrow_open;
edgeData.arrowTypeStart = arrowMap[0];
edgeData.arrowTypeEnd = arrowMap[1];
// Optional edge label positioning flags
edgeData.startLabelRight = edge.startLabelRight;
edgeData.endLabelLeft = edge.endLabelLeft;
// Stroke
const strokeRes = computeStroke(edge.stroke, defaults.defaultStyle, defaults.defaultLabelStyle);
edgeData.thickness = strokeRes.thickness;
edgeData.pattern = strokeRes.pattern;
edgeData.style = (edgeData.style || '') + (strokeRes.style || '');
edgeData.labelStyle = (edgeData.labelStyle || '') + (strokeRes.labelStyle || '');
// Curve
// @ts-ignore - defaults.confCurve is present at runtime but missing in type
edgeData.curve = getCurve(edge.interpolate, defaults.defaultInterpolate, defaults.confCurve);
// Arrowhead style + labelpos when we have label text
const hasText = (edge?.text ?? '') !== '';
if (hasText) {
edgeData.arrowheadStyle = 'fill: #333';
edgeData.labelpos = 'c';
} else if (edge.style !== undefined) {
edgeData.arrowheadStyle = 'fill: #333';
}
edgeData.labelType = edge.labelType;
edgeData.label = (edge?.text ?? '').replace(common.lineBreakRegex, '\n');
if (edge.style === undefined) {
edgeData.style = edgeData.style ?? 'stroke: #333; stroke-width: 1.5px;fill:none;';
}
edgeData.labelStyle = edgeData.labelStyle.replace('color:', 'fill:');
return edgeData;
};
const addEdges = async function (
dataForLayout: { edges: any; direction?: string },
graph: {
@@ -284,7 +377,6 @@ export const render = async (
const edges = dataForLayout.edges;
const labelsEl = svg.insert('g').attr('class', 'edgeLabels');
const linkIdCnt: any = {};
const dir = dataForLayout.direction || 'DOWN';
let defaultStyle: string | undefined;
let defaultLabelStyle: string | undefined;
@@ -314,105 +406,24 @@ export const render = async (
linkIdCnt[linkIdBase]++;
log.info('abc78 new entry', linkIdBase, linkIdCnt[linkIdBase]);
}
const linkId = linkIdBase + '_' + linkIdCnt[linkIdBase];
const linkId = linkIdBase; // + '_' + linkIdCnt[linkIdBase];
edge.id = linkId;
log.info('abc78 new link id to be used is', linkIdBase, linkId, linkIdCnt[linkIdBase]);
const linkNameStart = 'LS_' + edge.start;
const linkNameEnd = 'LE_' + edge.end;
const edgeData: any = { style: '', labelStyle: '' };
edgeData.minlen = edge.length || 1;
edge.text = edge.label;
// Set link type for rendering
if (edge.type === 'arrow_open') {
edgeData.arrowhead = 'none';
} else {
edgeData.arrowhead = 'normal';
}
// Check of arrow types, placed here in order not to break old rendering
edgeData.arrowTypeStart = 'arrow_open';
edgeData.arrowTypeEnd = 'arrow_open';
/* eslint-disable no-fallthrough */
switch (edge.type) {
case 'double_arrow_cross':
edgeData.arrowTypeStart = 'arrow_cross';
case 'arrow_cross':
edgeData.arrowTypeEnd = 'arrow_cross';
break;
case 'double_arrow_point':
edgeData.arrowTypeStart = 'arrow_point';
case 'arrow_point':
edgeData.arrowTypeEnd = 'arrow_point';
break;
case 'double_arrow_circle':
edgeData.arrowTypeStart = 'arrow_circle';
case 'arrow_circle':
edgeData.arrowTypeEnd = 'arrow_circle';
break;
}
let style = '';
let labelStyle = '';
edgeData.startLabelRight = edge.startLabelRight;
edgeData.endLabelLeft = edge.endLabelLeft;
switch (edge.stroke) {
case 'normal':
style = 'fill:none;';
if (defaultStyle !== undefined) {
style = defaultStyle;
}
if (defaultLabelStyle !== undefined) {
labelStyle = defaultLabelStyle;
}
edgeData.thickness = 'normal';
edgeData.pattern = 'solid';
break;
case 'dotted':
edgeData.thickness = 'normal';
edgeData.pattern = 'dotted';
edgeData.style = 'fill:none;stroke-width:2px;stroke-dasharray:3;';
break;
case 'thick':
edgeData.thickness = 'thick';
edgeData.pattern = 'solid';
edgeData.style = 'stroke-width: 3.5px;fill:none;';
break;
}
edgeData.style = edgeData.style += style;
edgeData.labelStyle = edgeData.labelStyle += labelStyle;
const conf = getConfig();
if (edge.interpolate !== undefined) {
edgeData.curve = interpolateToCurve(edge.interpolate, curveLinear);
} else if (edges.defaultInterpolate !== undefined) {
edgeData.curve = interpolateToCurve(edges.defaultInterpolate, curveLinear);
} else {
// @ts-ignore TODO: fix this
edgeData.curve = interpolateToCurve(conf.curve, curveLinear);
}
if (edge.text === undefined) {
if (edge.style !== undefined) {
edgeData.arrowheadStyle = 'fill: #333';
}
} else {
edgeData.arrowheadStyle = 'fill: #333';
edgeData.labelpos = 'c';
}
edgeData.labelType = edge.labelType;
edgeData.label = (edge?.text || '').replace(common.lineBreakRegex, '\n');
if (edge.style === undefined) {
edgeData.style = edgeData.style || 'stroke: #333; stroke-width: 1.5px;fill:none;';
}
edgeData.labelStyle = edgeData.labelStyle.replace('color:', 'fill:');
const edgeData = buildEdgeData(
edge,
{
defaultStyle,
defaultLabelStyle,
defaultInterpolate: edges.defaultInterpolate,
// @ts-ignore - conf.curve exists at runtime but is missing from typing
confCurve: conf.curve,
},
common
);
edgeData.id = linkId;
edgeData.classes = 'flowchart-link ' + linkNameStart + ' ' + linkNameEnd;
@@ -421,13 +432,11 @@ export const render = async (
// calculate start and end points of the edge, note that the source and target
// can be modified for shapes that have ports
// @ts-ignore TODO: fix this
const { source, target, sourceId, targetId } = getEdgeStartEndPoint(edge, dir);
const { source, target, sourceId, targetId } = getEdgeStartEndPoint(edge);
log.debug('abc78 source and target', source, target);
// Add the edge to the graph
graph.edges.push({
// @ts-ignore TODO: fix this
id: 'e' + edge.start + edge.end,
...edge,
sources: [source],
targets: [target],
@@ -461,6 +470,7 @@ export const render = async (
case 'RL':
return 'LEFT';
case 'TB':
case 'TD': // TD is an alias for TB in Mermaid
return 'DOWN';
case 'BT':
return 'UP';
@@ -484,6 +494,203 @@ export const render = async (
}
}
// Node bounds helpers (global)
const getEffectiveGroupWidth = (node: any): number => {
const labelW = node?.labels?.[0]?.width ?? 0;
const padding = node?.padding ?? 0;
return Math.max(node.width ?? 0, labelW + padding);
};
const boundsFor = (node: any): RectLike => {
const width = node?.isGroup ? getEffectiveGroupWidth(node) : node.width;
return {
x: node.offset.posX + node.width / 2,
y: node.offset.posY + node.height / 2,
width,
height: node.height,
padding: node.padding,
};
};
// Helper utilities for endpoint handling around cutter2
type Side = 'start' | 'end';
const approxEq = (a: number, b: number, eps = 1e-6) => Math.abs(a - b) < eps;
const isCenterApprox = (pt: P, node: { x: number; y: number }) =>
approxEq(pt.x, node.x) && approxEq(pt.y, node.y);
const getCandidateBorderPoint = (
points: P[],
node: any,
side: Side
): { candidate: P; centerApprox: boolean } => {
if (!points?.length) {
return { candidate: { x: node.x, y: node.y } as P, centerApprox: true };
}
if (side === 'start') {
const first = points[0];
const centerApprox = isCenterApprox(first, node);
const candidate = centerApprox && points.length > 1 ? points[1] : first;
return { candidate, centerApprox };
} else {
const last = points[points.length - 1];
const centerApprox = isCenterApprox(last, node);
const candidate = centerApprox && points.length > 1 ? points[points.length - 2] : last;
return { candidate, centerApprox };
}
};
const dropAutoCenterPoint = (points: P[], side: Side, doDrop: boolean) => {
if (!doDrop) {
return;
}
if (side === 'start') {
if (points.length > 0) {
points.shift();
}
} else {
if (points.length > 0) {
points.pop();
}
}
};
const applyStartIntersectionIfNeeded = (points: P[], startNode: any, startBounds: RectLike) => {
let firstOutsideStartIndex = -1;
for (const [i, p] of points.entries()) {
if (outsideNode(startBounds, p)) {
firstOutsideStartIndex = i;
break;
}
}
if (firstOutsideStartIndex !== -1) {
const outsidePointForStart = points[firstOutsideStartIndex];
const startCenter = points[0];
const startIntersection = computeNodeIntersection(
startNode,
startBounds,
outsidePointForStart,
startCenter
);
replaceEndpoint(points, 'start', startIntersection);
log.debug('UIO cutter2: start-only intersection applied', { startIntersection });
}
};
const applyEndIntersectionIfNeeded = (points: P[], endNode: any, endBounds: RectLike) => {
let outsideIndexForEnd = -1;
for (let i = points.length - 1; i >= 0; i--) {
if (outsideNode(endBounds, points[i])) {
outsideIndexForEnd = i;
break;
}
}
if (outsideIndexForEnd !== -1) {
const outsidePointForEnd = points[outsideIndexForEnd];
const endCenter = points[points.length - 1];
const endIntersection = computeNodeIntersection(
endNode,
endBounds,
outsidePointForEnd,
endCenter
);
replaceEndpoint(points, 'end', endIntersection);
log.debug('UIO cutter2: end-only intersection applied', { endIntersection });
}
};
const cutter2 = (startNode: any, endNode: any, _points: any[]) => {
const startBounds = boundsFor(startNode);
const endBounds = boundsFor(endNode);
if (_points.length === 0) {
return [];
}
// Copy the original points array
const points: P[] = [..._points] as P[];
// The first point is the center of sNode, the last point is the center of eNode
const startCenter = points[0];
const endCenter = points[points.length - 1];
// Minimal, structured logging for diagnostics
log.debug('PPP cutter2: bounds', { startBounds, endBounds });
log.debug('PPP cutter2: original points', _points);
let firstOutsideStartIndex = -1;
// Single iteration through the array
for (const [i, point] of points.entries()) {
if (firstOutsideStartIndex === -1 && outsideNode(startBounds, point)) {
firstOutsideStartIndex = i;
}
if (outsideNode(endBounds, point)) {
// keep scanning; we'll also scan from the end for the last outside point
}
}
// Calculate intersection with start node if we found a point outside it
if (firstOutsideStartIndex !== -1) {
const outsidePointForStart = points[firstOutsideStartIndex];
const startIntersection = computeNodeIntersection(
startNode,
startBounds,
outsidePointForStart,
startCenter
);
log.debug('UIO cutter2: start intersection', startIntersection);
replaceEndpoint(points, 'start', startIntersection);
}
// Calculate intersection with end node
let outsidePointForEnd = null;
let outsideIndexForEnd = -1;
for (let i = points.length - 1; i >= 0; i--) {
if (outsideNode(endBounds, points[i])) {
outsidePointForEnd = points[i];
outsideIndexForEnd = i;
break;
}
}
if (!outsidePointForEnd && points.length > 1) {
outsidePointForEnd = points[points.length - 2];
outsideIndexForEnd = points.length - 2;
}
if (outsidePointForEnd) {
const endIntersection = computeNodeIntersection(
endNode,
endBounds,
outsidePointForEnd,
endCenter
);
log.debug('UIO cutter2: end intersection', { endIntersection, outsideIndexForEnd });
replaceEndpoint(points, 'end', endIntersection);
}
// Final cleanup: Check if the last point is too close to the previous point
if (points.length > 1) {
const lastPoint = points[points.length - 1];
const secondLastPoint = points[points.length - 2];
const distance = Math.sqrt(
(lastPoint.x - secondLastPoint.x) ** 2 + (lastPoint.y - secondLastPoint.y) ** 2
);
if (distance < 2) {
log.debug('UIO cutter2: trimming tail point (too close)', {
distance,
lastPoint,
secondLastPoint,
});
points.pop();
}
}
log.debug('UIO cutter2: final points', points);
return points;
};
// @ts-ignore - ELK is not typed
const elk = new ELK();
const element = svg.select('g');
@@ -495,17 +702,19 @@ export const render = async (
id: 'root',
layoutOptions: {
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
'elk.layered.crossingMinimization.forceNodeModelOrder':
data4Layout.config.elk?.forceNodeModelOrder,
'elk.layered.considerModelOrder.strategy': data4Layout.config.elk?.considerModelOrder,
'elk.algorithm': algorithm,
'nodePlacement.strategy': data4Layout.config.elk?.nodePlacementStrategy,
'elk.layered.mergeEdges': data4Layout.config.elk?.mergeEdges,
'elk.direction': 'DOWN',
'spacing.baseValue': 35,
'spacing.baseValue': 40,
'elk.layered.crossingMinimization.forceNodeModelOrder':
data4Layout.config.elk?.forceNodeModelOrder,
'elk.layered.considerModelOrder.strategy': data4Layout.config.elk?.considerModelOrder,
'elk.layered.unnecessaryBendpoints': true,
'elk.layered.cycleBreaking.strategy': data4Layout.config.elk?.cycleBreakingStrategy,
// 'elk.layered.cycleBreaking.strategy': 'GREEDY_MODEL_ORDER',
// 'elk.layered.cycleBreaking.strategy': 'MODEL_ORDER',
// 'spacing.nodeNode': 20,
// 'spacing.nodeNodeBetweenLayers': 25,
// 'spacing.edgeNode': 20,
@@ -513,22 +722,28 @@ export const render = async (
// 'spacing.edgeEdge': 10,
// 'spacing.edgeEdgeBetweenLayers': 20,
// 'spacing.nodeSelfLoop': 20,
// Tweaking options
// 'nodePlacement.favorStraightEdges': true,
// 'elk.layered.nodePlacement.favorStraightEdges': true,
// 'nodePlacement.feedbackEdges': true,
// 'elk.layered.wrapping.multiEdge.improveCuts': true,
// 'elk.layered.wrapping.multiEdge.improveWrappedEdges': true,
'elk.layered.wrapping.multiEdge.improveCuts': true,
'elk.layered.wrapping.multiEdge.improveWrappedEdges': true,
// 'elk.layered.wrapping.strategy': 'MULTI_EDGE',
// 'elk.layered.edgeRouting.selfLoopDistribution': 'EQUALLY',
// 'elk.layered.mergeHierarchyEdges': true,
// 'elk.layered.wrapping.strategy': 'SINGLE_EDGE',
'elk.layered.edgeRouting.selfLoopDistribution': 'EQUALLY',
'elk.layered.mergeHierarchyEdges': true,
// 'elk.layered.feedbackEdges': true,
// 'elk.layered.crossingMinimization.semiInteractive': true,
// 'elk.layered.edgeRouting.splines.sloppy.layerSpacingFactor': 1,
// 'elk.layered.edgeRouting.polyline.slopedEdgeZoneWidth': 4.0,
// 'elk.layered.wrapping.validify.strategy': 'LOOK_BACK',
// 'elk.insideSelfLoops.activate': true,
// 'elk.separateConnectedComponents': true,
// 'elk.alg.layered.options.EdgeStraighteningStrategy': 'NONE',
// 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', // NODES_AND_EDGES
// 'elk.layered.considerModelOrder.strategy': 'EDGES', // NODES_AND_EDGES
// 'elk.layered.wrapping.cutting.strategy': 'ARD', // NODES_AND_EDGES
},
children: [],
@@ -538,7 +753,7 @@ export const render = async (
log.info('Drawing flowchart using v4 renderer', elk);
// Set the direction of the graph based on the parsed information
const dir = data4Layout.direction || 'DOWN';
const dir = data4Layout.direction ?? 'DOWN';
elkGraph.layoutOptions['elk.direction'] = dir2ElkDirection(dir);
// Create the lookup db for the subgraphs and their children to used when creating
@@ -569,15 +784,16 @@ export const render = async (
// Subgraph
if (parentLookupDb.childrenById[node.id] !== undefined) {
// Set label and adjust node width separately (avoid side effects in labels array)
node.labels = [
{
text: node.label,
width: node?.labelData?.width || 50,
height: node?.labelData?.height || 50,
width: node?.labelData?.width ?? 50,
height: node?.labelData?.height ?? 50,
},
(node.width = node.width + 2 * node.padding),
log.debug('UIO node label', node?.labelData?.width, node.padding),
];
node.width = node.width + 2 * node.padding;
log.debug('UIO node label', node?.labelData?.width, node.padding);
node.layoutOptions = {
'spacing.baseValue': 30,
'nodeLabels.placement': '[H_CENTER V_TOP, INSIDE]',
@@ -641,7 +857,7 @@ export const render = async (
try {
g = await elk.layout(elkGraph);
log.debug('APA01 after - success');
log.debug('APA01 layout result:', JSON.stringify(g, null, 2));
log.info('APA01 layout result:', JSON.stringify(g, null, 2));
} catch (error) {
log.error('APA01 ELK layout error:', error);
throw error;
@@ -702,10 +918,10 @@ export const render = async (
// sw = Math.max(bbox.width, startNode.width, startNode.labels[0].width);
sw = Math.max(startNode.width, startNode.labels[0].width + startNode.padding);
// sw = startNode.width;
log.debug(
log.info(
'UIO width',
startNode.id,
startNode.with,
startNode.width,
'bbox.width=',
bbox.width,
'lw=',
@@ -725,7 +941,7 @@ export const render = async (
log.debug(
'UIO width',
startNode.id,
startNode.with,
startNode.width,
bbox.width,
'EW = ',
ew,
@@ -733,38 +949,109 @@ export const render = async (
startNode.innerHTML
);
}
startNode.x = startNode.offset.posX + startNode.width / 2;
startNode.y = startNode.offset.posY + startNode.height / 2;
endNode.x = endNode.offset.posX + endNode.width / 2;
endNode.y = endNode.offset.posY + endNode.height / 2;
if (startNode.calcIntersect) {
const intersection = startNode.calcIntersect(
{
x: startNode.offset.posX + startNode.width / 2,
y: startNode.offset.posY + startNode.height / 2,
width: startNode.width,
height: startNode.height,
},
edge.points[0]
);
// Only add center points for non-subgraph nodes or when the edge path doesn't already end near the target
const shouldAddStartCenter = startNode.shape !== 'rect33';
const shouldAddEndCenter = endNode.shape !== 'rect33';
if (distance(intersection, edge.points[0]) > epsilon) {
edge.points.unshift(intersection);
}
}
if (endNode.calcIntersect) {
const intersection = endNode.calcIntersect(
{
x: endNode.offset.posX + endNode.width / 2,
y: endNode.offset.posY + endNode.height / 2,
width: endNode.width,
height: endNode.height,
},
edge.points[edge.points.length - 1]
);
if (distance(intersection, edge.points[edge.points.length - 1]) > epsilon) {
edge.points.push(intersection);
}
if (shouldAddStartCenter) {
edge.points.unshift({
x: startNode.x,
y: startNode.y,
});
}
if (shouldAddEndCenter) {
edge.points.push({
x: endNode.x,
y: endNode.y,
});
}
// Debug and sanitize points around cutter2
const prevPoints = Array.isArray(edge.points) ? [...edge.points] : [];
const endBounds = boundsFor(endNode);
log.debug(
'PPP cutter2: Points before cutter2:',
JSON.stringify(edge.points),
'endBounds:',
endBounds,
onBorder(endBounds, edge.points[edge.points.length - 1])
);
// Block for reducing variable scope and guardrails for the cutter function
{
const startBounds = boundsFor(startNode);
const endBounds = boundsFor(endNode);
const startIsGroup = !!startNode?.isGroup;
const endIsGroup = !!endNode?.isGroup;
const { candidate: startCandidate, centerApprox: startCenterApprox } =
getCandidateBorderPoint(prevPoints as P[], startNode, 'start');
const { candidate: endCandidate, centerApprox: endCenterApprox } =
getCandidateBorderPoint(prevPoints as P[], endNode, 'end');
const skipStart = startIsGroup && onBorder(startBounds, startCandidate);
const skipEnd = endIsGroup && onBorder(endBounds, endCandidate);
dropAutoCenterPoint(prevPoints as P[], 'start', skipStart && startCenterApprox);
dropAutoCenterPoint(prevPoints as P[], 'end', skipEnd && endCenterApprox);
if (skipStart || skipEnd) {
if (!skipStart) {
applyStartIntersectionIfNeeded(prevPoints as P[], startNode, startBounds);
}
if (!skipEnd) {
applyEndIntersectionIfNeeded(prevPoints as P[], endNode, endBounds);
}
log.debug('PPP cutter2: skipping cutter2 due to on-border group endpoint(s)', {
skipStart,
skipEnd,
startCenterApprox,
endCenterApprox,
startCandidate,
endCandidate,
});
edge.points = prevPoints;
} else {
edge.points = cutter2(startNode, endNode, prevPoints);
}
}
log.debug('PPP cutter2: Points after cutter2:', JSON.stringify(edge.points));
const hasNaN = (pts: { x: number; y: number }[]) =>
pts?.some((p) => !Number.isFinite(p?.x) || !Number.isFinite(p?.y));
if (!Array.isArray(edge.points) || edge.points.length < 2 || hasNaN(edge.points)) {
log.warn(
'POI cutter2: Invalid points from cutter2, falling back to prevPoints',
edge.points
);
// Fallback to previous points and strip any invalid ones just in case
const cleaned = prevPoints.filter((p) => Number.isFinite(p?.x) && Number.isFinite(p?.y));
edge.points = cleaned.length >= 2 ? cleaned : prevPoints;
}
log.debug('UIO cutter2: Points after cutter2 (sanitized):', edge.points);
// Remove consecutive duplicate points to avoid zero-length segments in path builders
const deduped = edge.points.filter(
(p: { x: number; y: number }, i: number, arr: { x: number; y: number }[]) => {
if (i === 0) {
return true;
}
const prev = arr[i - 1];
return Math.abs(p.x - prev.x) > 1e-6 || Math.abs(p.y - prev.y) > 1e-6;
}
);
if (deduped.length !== edge.points.length) {
log.debug('UIO cutter2: removed consecutive duplicate points', {
before: edge.points,
after: deduped,
});
}
edge.points = deduped;
const paths = insertEdge(
edgesEl,
edge,
@@ -772,8 +1059,10 @@ export const render = async (
data4Layout.type,
startNode,
endNode,
data4Layout.diagramId
data4Layout.diagramId,
true
);
log.info('APA12 edge points after insert', JSON.stringify(edge.points));
edge.x = edge.labels[0].x + offset.x + edge.labels[0].width / 2;
edge.y = edge.labels[0].y + offset.y + edge.labels[0].height / 2;

View File

@@ -47,7 +47,7 @@
"docs:verify-version": "tsx scripts/update-release-version.mts --verify",
"types:build-config": "tsx scripts/create-types-from-json-schema.mts",
"types:verify-config": "tsx scripts/create-types-from-json-schema.mts --verify",
"checkCircle": "npx madge --circular ./src",
"checkCircle": "npx madge --circular ./src --exclude '.*generated.*'",
"prepublishOnly": "pnpm docs:verify-version"
},
"repository": {

View File

@@ -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".

View File

@@ -264,6 +264,9 @@ const config: RequiredDeep<MermaidConfig> = {
radar: {
...defaultConfigJson.radar,
},
usecase: {
...defaultConfigJson.usecase,
},
treemap: {
useMaxWidth: true,
padding: 10,

View File

@@ -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
);
};

View File

@@ -3,6 +3,7 @@ import type * as d3 from 'd3';
import type { SetOptional, SetRequired } from 'type-fest';
import type { Diagram } from '../Diagram.js';
import type { BaseDiagramConfig, MermaidConfig } from '../config.type.js';
import type { DiagramOrientation } from '../diagrams/git/gitGraphTypes.js';
export interface DiagramMetadata {
title?: string;
@@ -35,7 +36,8 @@ export interface DiagramDB {
getAccTitle?: () => string;
setAccDescription?: (description: string) => void;
getAccDescription?: () => string;
getDirection?: () => string | undefined;
setDirection?: (dir: DiagramOrientation) => void;
setDisplayMode?: (title: string) => void;
bindFunctions?: (element: Element) => void;
}

View File

@@ -0,0 +1,48 @@
import { describe } from 'vitest';
import { draw } from './architectureRenderer.js';
import { Diagram } from '../../Diagram.js';
import { addDetector } from '../../diagram-api/detectType.js';
import architectureDetector from './architectureDetector.js';
import { ensureNodeFromSelector, jsdomIt } from '../../tests/util.js';
const { id, detector, loader } = architectureDetector;
addDetector(id, detector, loader); // Add architecture schemas to Mermaid
describe('architecture diagram SVGs', () => {
jsdomIt('should add ids', async () => {
const svgNode = await drawDiagram(`
architecture-beta
group api(cloud)[API]
service db(database)[Database] in api
service disk1(disk)[Storage] in api
service disk2(disk)[Storage] in api
service server(server)[Server] in api
db:L -- R:server
disk1:T -- B:server
disk2:T -- B:db
`);
const nodesForGroup = svgNode.querySelectorAll(`#group-api`);
expect(nodesForGroup.length).toBe(1);
const serviceIds = [...svgNode.querySelectorAll(`[id^=service-]`)].map(({ id }) => id).sort();
expect(serviceIds).toStrictEqual([
'service-db',
'service-disk1',
'service-disk2',
'service-server',
]);
const edgeIds = [...svgNode.querySelectorAll(`.edge[id^=L_]`)].map(({ id }) => id).sort();
expect(edgeIds).toStrictEqual(['L_db_server_0', 'L_disk1_server_0', 'L_disk2_db_0']);
});
});
async function drawDiagram(diagramText: string): Promise<Element> {
const diagram = await Diagram.fromText(diagramText, {});
await draw('NOT_USED', 'svg', '1.0.0', diagram);
return ensureNodeFromSelector('#svg');
}

View File

@@ -20,6 +20,7 @@ import {
type ArchitectureJunction,
type ArchitectureService,
} from './architectureTypes.js';
import { getEdgeId } from '../../utils.js';
export const drawEdges = async function (
edgesEl: D3Element,
@@ -91,7 +92,8 @@ export const drawEdges = async function (
g.insert('path')
.attr('d', `M ${startX},${startY} L ${midX},${midY} L${endX},${endY} `)
.attr('class', 'edge');
.attr('class', 'edge')
.attr('id', getEdgeId(source, target, { prefix: 'L' }));
if (sourceArrow) {
const xShift = isArchitectureDirectionX(sourceDir)
@@ -206,8 +208,9 @@ export const drawGroups = async function (
if (data.type === 'group') {
const { h, w, x1, y1 } = node.boundingBox();
groupsEl
.append('rect')
const groupsNode = groupsEl.append('rect');
groupsNode
.attr('id', `group-${data.id}`)
.attr('x', x1 + halfIconSize)
.attr('y', y1 + halfIconSize)
.attr('width', w)
@@ -262,6 +265,7 @@ export const drawGroups = async function (
')'
);
}
db.setElementForId(data.id, groupsNode);
}
})
);
@@ -342,9 +346,9 @@ export const drawServices = async function (
);
}
serviceElem.attr('class', 'architecture-service');
serviceElem.attr('id', `service-${service.id}`).attr('class', 'architecture-service');
const { width, height } = serviceElem._groups[0][0].getBBox();
const { width, height } = serviceElem.node().getBBox();
service.width = width;
service.height = height;
db.setElementForId(service.id, serviceElem);

View File

@@ -36,57 +36,15 @@ export async function textHelper<T extends SVGGraphicsElement>(
annotationGroup = shapeSvg.insert('g').attr('class', 'annotation-group text');
if (node.annotations.length > 0) {
const annotation = node.annotations[0].toLowerCase();
let isSupported = false;
switch (annotation) {
case 'interface':
case 'abstract':
case 'enumeration':
isSupported = true;
break;
}
if (!isSupported) {
await addText(
annotationGroup,
{ text: `«${node.annotations[0]}»` } as unknown as ClassMember,
0,
[]
);
annotationGroup.style('opacity', '1');
}
const annotation = node.annotations[0];
await addText(annotationGroup, { text: `«${annotation}»` } as unknown as ClassMember, 0);
const annotationGroupBBox = annotationGroup.node()!.getBBox();
annotationGroupHeight = annotationGroupBBox.height;
}
labelGroup = shapeSvg.insert('g').attr('class', 'label-group text');
// Determine styling based on annotations
let labelStyles = [''];
let labelClass = '';
if (node.annotations && node.annotations.length > 0) {
const annotation = node.annotations[0].toLowerCase();
switch (annotation) {
case 'abstract':
labelClass = 'abstract';
labelStyles = [];
break;
case 'enumeration':
labelClass = 'enumeration';
labelStyles = [];
break;
case 'interface':
labelClass = 'interface';
labelStyles = [];
break;
default:
labelClass = '';
labelStyles = [];
break;
}
}
// Apply the CSS class to the label group
labelGroup.attr('class', `label-group text classTitle ${labelClass}`);
await addText(labelGroup, node, 0, labelStyles);
await addText(labelGroup, node, 0, ['font-weight: bolder']);
const labelGroupBBox = labelGroup.node()!.getBBox();
labelGroupHeight = labelGroupBBox.height;
@@ -113,7 +71,7 @@ export async function textHelper<T extends SVGGraphicsElement>(
// Center annotation
if (annotationGroup !== null) {
const annotationGroupBBox = annotationGroup.node()!.getBBox();
annotationGroup.attr('transform', `translate(${-annotationGroupBBox.width / 2}, 0)`);
annotationGroup.attr('transform', `translate(${-annotationGroupBBox.width / 2})`);
}
// Adjust label

View File

@@ -31,7 +31,7 @@ const getStyles = (options) =>
}
.classTitle {
font-weight: normal;
font-weight: bolder;
}
.node rect,
.node circle,
@@ -148,20 +148,6 @@ g.classGroup line {
stroke: ${options.lineColor} !important;
stroke-width: 1;
}
.classTitle.abstract {
font-style: italic;
font-weight: normal;
}
.classTitle.enumeration {
text-decoration: underline;
font-weight: normal;
}
.classTitle.interface {
font-weight: bold;
}
.edgeTerminals {
font-size: 11px;

View File

@@ -268,7 +268,9 @@ 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);

View File

@@ -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) {

View File

@@ -15,6 +15,7 @@ export interface MindmapNode {
icon?: string;
x?: number;
y?: number;
isRoot?: boolean;
}
export type FilledMindMapNode = RequiredDeep<MindmapNode>;

View 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;

View File

@@ -0,0 +1,98 @@
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
Developer1 --> a("Login System")
Developer2 --> b(Authentication)`
);
expect(diagram).toBeDefined();
expect(diagram.type).toBe('usecase');
});
});

View 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,
};

View 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,
};

View 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,
};

View 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;
}
},
};

View 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 };

View 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;

View 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;
}

View File

@@ -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',

View File

@@ -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"

View File

@@ -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.

View File

@@ -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

View File

@@ -4,6 +4,9 @@ import { internalHelpers } from '../internals.js';
import { log } from '../logger.js';
import type { LayoutData } from './types.js';
// console.log('MUST be removed, this only for keeping dev server working');
// import tmp from './layout-algorithms/dagre/index.js';
export interface RenderOptions {
algorithm?: string;
}

View File

@@ -1,9 +1,13 @@
import { getConfig } from '../../diagram-api/diagramAPI.js';
import { evaluate, getUrl } from '../../diagrams/common/common.js';
import { evaluate } from '../../diagrams/common/common.js';
import { log } from '../../logger.js';
import { createText } from '../createText.js';
import utils from '../../utils.js';
import { getLineFunctionsWithOffset } from '../../utils/lineWithOffset.js';
import {
getLineFunctionsWithOffset,
markerOffsets,
markerOffsets2,
} from '../../utils/lineWithOffset.js';
import { getSubGraphTitleMargins } from '../../utils/subGraphTitleMargins.js';
import {
@@ -25,10 +29,10 @@ import {
import rough from 'roughjs';
import createLabel from './createLabel.js';
import { addEdgeMarkers } from './edgeMarker.ts';
import { isLabelStyle } from './shapes/handDrawnShapeStyles.js';
import { isLabelStyle, styles2String } from './shapes/handDrawnShapeStyles.js';
const edgeLabels = new Map();
const terminalLabels = new Map();
export const edgeLabels = new Map();
export const terminalLabels = new Map();
export const clear = () => {
edgeLabels.clear();
@@ -43,8 +47,10 @@ export const getLabelStyles = (styleArray) => {
export const insertEdgeLabel = async (elem, edge) => {
let useHtmlLabels = evaluate(getConfig().flowchart.htmlLabels);
const { labelStyles } = styles2String(edge);
edge.labelStyle = labelStyles;
const labelElement = await createText(elem, edge.label, {
style: getLabelStyles(edge.labelStyle),
style: edge.labelStyle,
useHtmlLabels,
addSvgBackground: true,
isNode: false,
@@ -55,7 +61,7 @@ export const insertEdgeLabel = async (elem, edge) => {
const edgeLabel = elem.insert('g').attr('class', 'edgeLabel');
// Create inner g, label, this will be positioned now for centering the text
const label = edgeLabel.insert('g').attr('class', 'label');
const label = edgeLabel.insert('g').attr('class', 'label').attr('data-id', edge.id);
label.node().appendChild(labelElement);
// Center the label
@@ -438,7 +444,33 @@ const fixCorners = function (lineData) {
}
return newLineData;
};
export const insertEdge = function (elem, edge, clusterDb, diagramType, startNode, endNode, id) {
const generateDashArray = (len, oValueS, oValueE) => {
const middleLength = len - oValueS - oValueE;
const dashLength = 2; // Length of each dash
const gapLength = 2; // Length of each gap
const dashGapPairLength = dashLength + gapLength;
// Calculate number of complete dash-gap pairs that can fit
const numberOfPairs = Math.floor(middleLength / dashGapPairLength);
// Generate the middle pattern array
const middlePattern = Array(numberOfPairs).fill(`${dashLength} ${gapLength}`).join(' ');
// Combine all parts
const dashArray = `0 ${oValueS} ${middlePattern} ${oValueE}`;
return dashArray;
};
export const insertEdge = function (
elem,
edge,
clusterDb,
diagramType,
startNode,
endNode,
id,
skipIntersect = false
) {
const { handDrawnSeed } = getConfig();
let points = edge.points;
let pointsHasChanged = false;
@@ -452,11 +484,12 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod
edgeClassStyles.push(edge.cssCompiledStyles[key]);
}
if (head.intersect && tail.intersect) {
log.debug('UIO intersect check', edge.points, head.x, tail.x);
if (head.intersect && tail.intersect && !skipIntersect) {
points = points.slice(1, edge.points.length - 1);
points.unshift(tail.intersect(points[0]));
log.debug(
'Last point APA12',
'Last point UIO',
edge.start,
'-->',
edge.end,
@@ -466,6 +499,7 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod
);
points.push(head.intersect(points[points.length - 1]));
}
const pointsStr = btoa(JSON.stringify(points));
if (edge.toCluster) {
log.info('to cluster abc88', clusterDb.get(edge.toCluster));
points = cutPathAtIntersect(edge.points, clusterDb.get(edge.toCluster).node);
@@ -529,6 +563,10 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod
curve = curveBasis;
}
// if (edge.curve) {
// curve = edge.curve;
// }
const { x, y } = getLineFunctionsWithOffset(edge);
const lineFunction = line().x(x).y(y).curve(curve);
@@ -560,10 +598,14 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod
strokeClasses += ' edge-pattern-solid';
}
let svgPath;
let linePath = lineFunction(lineData);
const edgeStyles = Array.isArray(edge.style) ? edge.style : edge.style ? [edge.style] : [];
let linePath =
edge.curve === 'rounded'
? generateRoundedPath(applyMarkerOffsetsToPoints(lineData, edge), 5)
: lineFunction(lineData);
const edgeStyles = Array.isArray(edge.style) ? edge.style : [edge.style];
let strokeColor = edgeStyles.find((style) => style?.startsWith('stroke:'));
let animatedEdge = false;
if (edge.look === 'handDrawn') {
const rc = rough.svg(elem);
Object.assign([], lineData);
@@ -594,7 +636,10 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod
animationClass = ' edge-animation-' + edge.animation;
}
const pathStyle = stylesFromClasses ? stylesFromClasses + ';' + styles + ';' : styles;
const pathStyle =
(stylesFromClasses ? stylesFromClasses + ';' + styles + ';' : styles) +
';' +
(edgeStyles ? edgeStyles.reduce((acc, style) => acc + ';' + style, '') : '');
svgPath = elem
.append('path')
.attr('d', linePath)
@@ -604,11 +649,39 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod
' ' + strokeClasses + (edge.classes ? ' ' + edge.classes : '') + (animationClass ?? '')
)
.attr('style', pathStyle);
//eslint-disable-next-line @typescript-eslint/prefer-regexp-exec
strokeColor = pathStyle.match(/stroke:([^;]+)/)?.[1];
// Possible fix to remove eslint-disable-next-line
//strokeColor = /stroke:([^;]+)/.exec(pathStyle)?.[1];
animatedEdge =
edge.animate === true || !!edge.animation || stylesFromClasses.includes('animation');
const pathNode = svgPath.node();
const len = typeof pathNode.getTotalLength === 'function' ? pathNode.getTotalLength() : 0;
const oValueS = markerOffsets2[edge.arrowTypeStart] || 0;
const oValueE = markerOffsets2[edge.arrowTypeEnd] || 0;
if (edge.look === 'neo' && !animatedEdge) {
const dashArray =
edge.pattern === 'dotted' || edge.pattern === 'dashed'
? generateDashArray(len, oValueS, oValueE)
: `0 ${oValueS} ${len - oValueS - oValueE} ${oValueE}`;
// No offset needed because we already start with a zero-length dash that effectively sets us up for a gap at the start.
const mOffset = `stroke-dasharray: ${dashArray}; stroke-dashoffset: 0;`;
svgPath.attr('style', mOffset + svgPath.attr('style'));
}
}
// DEBUG code, DO NOT REMOVE
// adds a red circle at each edge coordinate
// MC Special
svgPath.attr('data-edge', true);
svgPath.attr('data-et', 'edge');
svgPath.attr('data-id', edge.id);
svgPath.attr('data-points', pointsStr);
// DEBUG code, adds a red circle at each edge coordinate
// cornerPoints.forEach((point) => {
// elem
// .append('circle')
@@ -618,19 +691,27 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod
// .attr('cx', point.x)
// .attr('cy', point.y);
// });
// lineData.forEach((point) => {
// elem
// .append('circle')
// .style('stroke', 'red')
// .style('fill', 'red')
// .attr('r', 1)
// .attr('cx', point.x)
// .attr('cy', point.y);
// });
if (edge.showPoints) {
lineData.forEach((point) => {
elem
.append('circle')
.style('stroke', 'red')
.style('fill', 'red')
.attr('r', 1)
.attr('cx', point.x)
.attr('cy', point.y);
});
}
let url = '';
if (getConfig().flowchart.arrowMarkerAbsolute || getConfig().state.arrowMarkerAbsolute) {
url = getUrl(true);
url =
window.location.protocol +
'//' +
window.location.host +
window.location.pathname +
window.location.search;
url = url.replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
log.info('arrowTypeStart', edge.arrowTypeStart);
log.info('arrowTypeEnd', edge.arrowTypeEnd);
@@ -649,3 +730,134 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod
paths.originalPath = edge.points;
return paths;
};
/**
* Generates SVG path data with rounded corners from an array of points.
* @param {Array} points - Array of points in the format [{x: Number, y: Number}, ...]
* @param {Number} radius - The radius of the rounded corners
* @returns {String} - SVG path data string
*/
function generateRoundedPath(points, radius) {
if (points.length < 2) {
return '';
}
let path = '';
const size = points.length;
const epsilon = 1e-5;
for (let i = 0; i < size; i++) {
const currPoint = points[i];
const prevPoint = points[i - 1];
const nextPoint = points[i + 1];
if (i === 0) {
// Move to the first point
path += `M${currPoint.x},${currPoint.y}`;
} else if (i === size - 1) {
// Last point, draw a straight line to the final point
path += `L${currPoint.x},${currPoint.y}`;
} else {
// Calculate vectors for incoming and outgoing segments
const dx1 = currPoint.x - prevPoint.x;
const dy1 = currPoint.y - prevPoint.y;
const dx2 = nextPoint.x - currPoint.x;
const dy2 = nextPoint.y - currPoint.y;
const len1 = Math.hypot(dx1, dy1);
const len2 = Math.hypot(dx2, dy2);
// Prevent division by zero
if (len1 < epsilon || len2 < epsilon) {
path += `L${currPoint.x},${currPoint.y}`;
continue;
}
// Normalize the vectors
const nx1 = dx1 / len1;
const ny1 = dy1 / len1;
const nx2 = dx2 / len2;
const ny2 = dy2 / len2;
// Calculate the angle between the vectors
const dot = nx1 * nx2 + ny1 * ny2;
// Clamp the dot product to avoid numerical issues with acos
const clampedDot = Math.max(-1, Math.min(1, dot));
const angle = Math.acos(clampedDot);
// Skip rounding if the angle is too small or too close to 180 degrees
if (angle < epsilon || Math.abs(Math.PI - angle) < epsilon) {
path += `L${currPoint.x},${currPoint.y}`;
continue;
}
// Calculate the distance to offset the control point
const cutLen = Math.min(radius / Math.sin(angle / 2), len1 / 2, len2 / 2);
// Calculate the start and end points of the curve
const startX = currPoint.x - nx1 * cutLen;
const startY = currPoint.y - ny1 * cutLen;
const endX = currPoint.x + nx2 * cutLen;
const endY = currPoint.y + ny2 * cutLen;
// Draw the line to the start of the curve
path += `L${startX},${startY}`;
// Draw the quadratic Bezier curve
path += `Q${currPoint.x},${currPoint.y} ${endX},${endY}`;
}
}
return path;
}
// Helper function to calculate delta and angle between two points
function calculateDeltaAndAngle(point1, point2) {
if (!point1 || !point2) {
return { angle: 0, deltaX: 0, deltaY: 0 };
}
const deltaX = point2.x - point1.x;
const deltaY = point2.y - point1.y;
const angle = Math.atan2(deltaY, deltaX);
return { angle, deltaX, deltaY };
}
// Function to adjust the first and last points of the points array
function applyMarkerOffsetsToPoints(points, edge) {
// Copy the points array to avoid mutating the original data
const newPoints = points.map((point) => ({ ...point }));
// Handle the first point (start of the edge)
if (points.length >= 2 && markerOffsets[edge.arrowTypeStart]) {
const offsetValue = markerOffsets[edge.arrowTypeStart];
const point1 = points[0];
const point2 = points[1];
const { angle } = calculateDeltaAndAngle(point1, point2);
const offsetX = offsetValue * Math.cos(angle);
const offsetY = offsetValue * Math.sin(angle);
newPoints[0].x = point1.x + offsetX;
newPoints[0].y = point1.y + offsetY;
}
// Handle the last point (end of the edge)
const n = points.length;
if (n >= 2 && markerOffsets[edge.arrowTypeEnd]) {
const offsetValue = markerOffsets[edge.arrowTypeEnd];
const point1 = points[n - 1];
const point2 = points[n - 2];
const { angle } = calculateDeltaAndAngle(point2, point1);
const offsetX = offsetValue * Math.cos(angle);
const offsetY = offsetValue * Math.sin(angle);
newPoints[n - 1].x = point1.x - offsetX;
newPoints[n - 1].y = point1.y - offsetY;
}
return newPoints;
}

View File

@@ -20,7 +20,11 @@ export const compileStyles = (node: Node) => {
// the array is the styles of node from the classes it is using
// node.cssStyles is an array of styles directly set on the node
// concat the arrays and remove duplicates such that the values from node.cssStyles are used if there are duplicates
const stylesMap = styles2Map([...(node.cssCompiledStyles || []), ...(node.cssStyles || [])]);
const stylesMap = styles2Map([
...(node.cssCompiledStyles || []),
...(node.cssStyles || []),
...(node.labelStyle || []),
]);
return { stylesMap, stylesArray: [...stylesMap] };
};

View File

@@ -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: |

View File

@@ -4,12 +4,22 @@ import type { EdgeData, Point } from '../types.js';
// under any transparent markers.
// The offsets are calculated from the markers' dimensions.
export const markerOffsets = {
aggregation: 18,
extension: 18,
composition: 18,
aggregation: 17.25,
extension: 17.25,
composition: 17.25,
dependency: 6,
lollipop: 13.5,
arrow_point: 4,
//arrow_cross: 24,
} as const;
// We need to draw the lines a bit shorter to avoid drawing
// under any transparent markers.
// The offsets are calculated from the markers' dimensions.
export const markerOffsets2 = {
arrow_point: 9,
arrow_cross: 12.5,
arrow_circle: 12.5,
} as const;
/**
@@ -104,6 +114,7 @@ export const getLineFunctionsWithOffset = (
adjustment *= DIRECTION === 'right' ? -1 : 1;
offset += adjustment;
}
return pointTransformer(d).x + offset;
},
y: function (

View File

@@ -0,0 +1,14 @@
{
"projectName": "Mermaid",
"grammars": [
{
"id": "usecase",
"grammar": "src/language/usecase/Usecase.g4",
"outputDir": "src/language/usecase/generated",
"language": "TypeScript",
"generateVisitor": true,
"generateListener": true
}
],
"mode": "production"
}

View File

@@ -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": "tsx ../../.build/antlr-cli.ts antlr-config.json"
},
"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": [

View File

@@ -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';

View 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
;

View File

@@ -0,0 +1,4 @@
export * from './module.js';
export * from './types.js';
export * from './parser.js';
export * from './visitor.js';

View 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;
}

View 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);
}

View 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;
}

View 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,
};
}
}

View File

@@ -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);
}

File diff suppressed because it is too large Load Diff

2183
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,6 @@ pushd packages/mermaid
# Append commit hash to version
jq ".version = .version + \"+${COMMIT_REF:0:7}\"" package.json > package.tmp.json
mv package.tmp.json package.json
yarn link
popd
pnpm run -r clean
@@ -26,13 +25,14 @@ cd mermaid-live-editor
git clean -xdf
rm -rf docs/
# We have to use npm instead of yarn because it causes trouble in netlify
# Tells PNPM that mermaid-live-editor is not part of this workspace
touch pnpm-workspace.yaml
# Install dependencies
yarn install
pnpm install --frozen-lockfile
# Link local mermaid to live editor
yarn link mermaid
pnpm link ../packages/mermaid
# Force Build the site
yarn run build
pnpm run build