diff --git a/.build/common.ts b/.build/common.ts index efd0e3a85..2497d443f 100644 --- a/.build/common.ts +++ b/.build/common.ts @@ -33,6 +33,11 @@ export const packageOptions = { packageName: 'mermaid-layout-elk', file: 'layouts.ts', }, + 'mermaid-layout-tidy-tree': { + name: 'mermaid-layout-tidy-tree', + packageName: 'mermaid-layout-tidy-tree', + file: 'index.ts', + }, examples: { name: 'mermaid-examples', packageName: 'examples', diff --git a/.changeset/brave-baths-behave.md b/.changeset/brave-baths-behave.md new file mode 100644 index 000000000..b688a1faf --- /dev/null +++ b/.changeset/brave-baths-behave.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix: Prevent HTML tags from being escaped in sandbox label rendering diff --git a/.changeset/brave-memes-flash.md b/.changeset/brave-memes-flash.md new file mode 100644 index 000000000..720cd7202 --- /dev/null +++ b/.changeset/brave-memes-flash.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix: Support edge animation in hand drawn look diff --git a/.changeset/busy-mirrors-try.md b/.changeset/busy-mirrors-try.md new file mode 100644 index 000000000..7e5d3b632 --- /dev/null +++ b/.changeset/busy-mirrors-try.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix: Resolved parsing error where direction TD was not recognized within subgraphs diff --git a/.changeset/chilly-words-march.md b/.changeset/chilly-words-march.md new file mode 100644 index 000000000..54c0b4ebf --- /dev/null +++ b/.changeset/chilly-words-march.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix: Correct viewBox casing and make SVGs responsive diff --git a/.changeset/curly-apes-prove.md b/.changeset/curly-apes-prove.md new file mode 100644 index 000000000..2acf3d1a3 --- /dev/null +++ b/.changeset/curly-apes-prove.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix: Improve participant parsing and prevent recursive loops on invalid syntax diff --git a/.changeset/loud-results-melt.md b/.changeset/loud-results-melt.md new file mode 100644 index 000000000..7005750c6 --- /dev/null +++ b/.changeset/loud-results-melt.md @@ -0,0 +1,5 @@ +--- +'mermaid': minor +--- + +feat: Add half-arrowheads (solid & stick) and central connection support diff --git a/.changeset/short-seals-sort.md b/.changeset/short-seals-sort.md new file mode 100644 index 000000000..db8309c7f --- /dev/null +++ b/.changeset/short-seals-sort.md @@ -0,0 +1,5 @@ +--- +'mermaid': minor +--- + +feat: allow to put notes in namespaces on classDiagram diff --git a/.changeset/slow-lemons-know.md b/.changeset/slow-lemons-know.md new file mode 100644 index 000000000..49eb48543 --- /dev/null +++ b/.changeset/slow-lemons-know.md @@ -0,0 +1,5 @@ +--- +'@mermaid': patch +--- + +fix: Mindmap breaking in ELK layout diff --git a/.changeset/sweet-games-build.md b/.changeset/sweet-games-build.md new file mode 100644 index 000000000..a71e3de25 --- /dev/null +++ b/.changeset/sweet-games-build.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix(er-diagram): prevent syntax error when using 'u', numbers, and decimals in node names diff --git a/.changeset/ten-plums-bet.md b/.changeset/ten-plums-bet.md new file mode 100644 index 000000000..f00a41090 --- /dev/null +++ b/.changeset/ten-plums-bet.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix: Support ComponentQueue_Ext to prevent parsing error diff --git a/.cspell/code-terms.txt b/.cspell/code-terms.txt index 8b549f888..5f72ea221 100644 --- a/.cspell/code-terms.txt +++ b/.cspell/code-terms.txt @@ -1,3 +1,5 @@ +!viewbox +# It should be viewBox # This file contains coding related terms ALPHANUM antiscript diff --git a/.cspell/libraries.txt b/.cspell/libraries.txt index feee10fd1..4fceed5bb 100644 --- a/.cspell/libraries.txt +++ b/.cspell/libraries.txt @@ -64,6 +64,7 @@ rscratch shiki Slidev sparkline +speccharts sphinxcontrib ssim stylis diff --git a/.cspell/mermaid-terms.txt b/.cspell/mermaid-terms.txt index b0cfa0a1d..45152a0ce 100644 --- a/.cspell/mermaid-terms.txt +++ b/.cspell/mermaid-terms.txt @@ -5,8 +5,10 @@ bmatrix braintree catmull compositTitleSize +cose curv doublecircle +elem elems gantt gitgraph diff --git a/.cspell/misc-terms.txt b/.cspell/misc-terms.txt index 1820e3c86..2906a02fa 100644 --- a/.cspell/misc-terms.txt +++ b/.cspell/misc-terms.txt @@ -1,4 +1,5 @@ BRANDES +Buzan circo handDrawn KOEPF diff --git a/.esbuild/util.ts b/.esbuild/util.ts index 3a0ec6b41..a3e2ffe55 100644 --- a/.esbuild/util.ts +++ b/.esbuild/util.ts @@ -71,6 +71,9 @@ export const getBuildConfig = (options: MermaidBuildOptions): BuildOptions => { const external: string[] = ['require', 'fs', 'path']; const outFileName = getFileName(name, options); + const { dependencies, version } = JSON.parse( + readFileSync(resolve(__dirname, `../packages/${packageName}/package.json`), 'utf-8') + ); const output: BuildOptions = buildOptions({ ...rest, absWorkingDir: resolve(__dirname, `../packages/${packageName}`), @@ -82,15 +85,13 @@ export const getBuildConfig = (options: MermaidBuildOptions): BuildOptions => { chunkNames: `chunks/${outFileName}/[name]-[hash]`, define: { // This needs to be stringified for esbuild - includeLargeFeatures: `${includeLargeFeatures}`, + 'injected.includeLargeFeatures': `${includeLargeFeatures}`, + 'injected.version': `'${version}'`, 'import.meta.vitest': 'undefined', }, }); if (core) { - const { dependencies } = JSON.parse( - readFileSync(resolve(__dirname, `../packages/${packageName}/package.json`), 'utf-8') - ); // Core build is used to generate file without bundled dependencies. // This is used by downstream projects to bundle dependencies themselves. // Ignore dependencies and any dependencies of dependencies diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a6400a86a..64de2eb66 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -26,8 +26,8 @@ jobs: strategy: fail-fast: false matrix: - language: ['javascript'] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + language: ['javascript', 'actions'] + # CodeQL supports [ 'actions', 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: @@ -36,7 +36,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10 + uses: github/codeql-action/init@5378192d256ef1302a6980fffe5ca04426d43091 # v3.28.21 with: config-file: ./.github/codeql/codeql-config.yml languages: ${{ matrix.language }} @@ -48,7 +48,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10 + uses: github/codeql-action/autobuild@5378192d256ef1302a6980fffe5ca04426d43091 # v3.28.21 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -62,4 +62,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10 + uses: github/codeql-action/analyze@5378192d256ef1302a6980fffe5ca04426d43091 # v3.28.21 diff --git a/.github/workflows/e2e-applitools.yml b/.github/workflows/e2e-applitools.yml index dd97b49e1..853818425 100644 --- a/.github/workflows/e2e-applitools.yml +++ b/.github/workflows/e2e-applitools.yml @@ -23,9 +23,6 @@ env: jobs: e2e-applitools: runs-on: ubuntu-latest - container: - image: cypress/browsers:node-20.11.0-chrome-121.0.6167.85-1-ff-120.0-edge-121.0.2277.83-1 - options: --user 1001 steps: - if: ${{ ! env.USE_APPLI }} name: Warn if not using Applitools @@ -56,7 +53,7 @@ jobs: args: -X POST "$APPLITOOLS_SERVER_URL/api/externals/github/push?apiKey=$APPLITOOLS_API_KEY&CommitSha=$GITHUB_SHA&BranchName=${APPLITOOLS_BRANCH}$&ParentBranchName=$APPLITOOLS_PARENT_BRANCH" - name: Cypress run - uses: cypress-io/github-action@18a6541367f4580a515371905f499a27a44e8dbe # v6.7.12 + uses: cypress-io/github-action@108b8684ae52e735ff7891524cbffbcd4be5b19f # v6.7.16 id: cypress with: start: pnpm run dev diff --git a/.github/workflows/e2e-timings.yml b/.github/workflows/e2e-timings.yml index 2bbfa8412..21f6b4049 100644 --- a/.github/workflows/e2e-timings.yml +++ b/.github/workflows/e2e-timings.yml @@ -27,12 +27,12 @@ jobs: with: node-version-file: '.node-version' - name: Install dependencies - uses: cypress-io/github-action@18a6541367f4580a515371905f499a27a44e8dbe # v6.7.12 + uses: cypress-io/github-action@108b8684ae52e735ff7891524cbffbcd4be5b19f # v6.7.16 with: runTests: false - name: Cypress run - uses: cypress-io/github-action@18a6541367f4580a515371905f499a27a44e8dbe # v6.7.12 + uses: cypress-io/github-action@108b8684ae52e735ff7891524cbffbcd4be5b19f # v6.7.16 id: cypress with: install: false @@ -58,7 +58,7 @@ jobs: echo "EOF" >> $GITHUB_OUTPUT - name: Commit and create pull request - uses: peter-evans/create-pull-request@cb4d3bfce175d44325c6b7697f81e0afe8a79bdf + uses: peter-evans/create-pull-request@0edc001d28a2959cd7a6b505629f1d82f0a6e67d with: add-paths: | cypress/timings.json diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 56883b987..8fbf6d6f6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -45,7 +45,7 @@ jobs: node-version-file: '.node-version' - name: Cache snapshots id: cache-snapshot - uses: actions/cache@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.1 + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ./cypress/snapshots key: ${{ runner.os }}-snapshots-${{ env.targetHash }} @@ -59,7 +59,7 @@ jobs: - name: Install dependencies if: ${{ steps.cache-snapshot.outputs.cache-hit != 'true' }} - uses: cypress-io/github-action@18a6541367f4580a515371905f499a27a44e8dbe # v6.7.12 + uses: cypress-io/github-action@108b8684ae52e735ff7891524cbffbcd4be5b19f # v6.7.16 with: # just perform install runTests: false @@ -95,13 +95,13 @@ jobs: # These cached snapshots are downloaded, providing the reference snapshots. - name: Cache snapshots id: cache-snapshot - uses: actions/cache/restore@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.1 + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ./cypress/snapshots key: ${{ runner.os }}-snapshots-${{ env.targetHash }} - name: Install dependencies - uses: cypress-io/github-action@18a6541367f4580a515371905f499a27a44e8dbe # v6.7.12 + uses: cypress-io/github-action@108b8684ae52e735ff7891524cbffbcd4be5b19f # v6.7.16 with: runTests: false @@ -117,7 +117,7 @@ jobs: # Install NPM dependencies, cache them correctly # and run all Cypress tests - name: Cypress run - uses: cypress-io/github-action@18a6541367f4580a515371905f499a27a44e8dbe # v6.7.12 + uses: cypress-io/github-action@108b8684ae52e735ff7891524cbffbcd4be5b19f # v6.7.16 id: cypress with: install: false diff --git a/.github/workflows/link-checker.yml b/.github/workflows/link-checker.yml index f855ed23b..ce43c2ed7 100644 --- a/.github/workflows/link-checker.yml +++ b/.github/workflows/link-checker.yml @@ -32,7 +32,7 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Restore lychee cache - uses: actions/cache@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.1 + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: .lycheecache key: cache-lychee-${{ github.sha }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 649c40034..ece84ac20 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,11 +36,10 @@ jobs: - name: Create Release Pull Request or Publish to npm id: changesets - uses: changesets/action@c8bada60c408975afd1a20b3db81d6eee6789308 # v1.4.9 + uses: changesets/action@06245a4e0a36c064a573d4150030f5ec548e4fcc # v1.4.10 with: version: pnpm changeset:version publish: pnpm changeset:publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: true diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 4901b3781..3177ca15a 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -20,18 +20,18 @@ jobs: with: persist-credentials: false - name: Run analysis - uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif publish_results: true - name: Upload artifact - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: SARIF file path: results.sarif retention-days: 5 - name: Upload to code-scanning - uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10 + uses: github/codeql-action/upload-sarif@5378192d256ef1302a6980fffe5ca04426d43091 # v3.28.21 with: sarif_file: results.sarif diff --git a/.github/workflows/update-browserlist.yml b/.github/workflows/update-browserlist.yml index 94de12ad3..54ef39b11 100644 --- a/.github/workflows/update-browserlist.yml +++ b/.github/workflows/update-browserlist.yml @@ -19,7 +19,7 @@ jobs: message: 'chore: update browsers list' push: false - name: Create Pull Request - uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f # v7.0.6 + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 with: branch: update-browserslist title: Update Browserslist diff --git a/.github/workflows/validate-lockfile.yml b/.github/workflows/validate-lockfile.yml index 6eb0a63ca..dcfb255b6 100644 --- a/.github/workflows/validate-lockfile.yml +++ b/.github/workflows/validate-lockfile.yml @@ -1,7 +1,7 @@ name: Validate pnpm-lock.yaml on: - pull_request: + pull_request_target: paths: - 'pnpm-lock.yaml' - '**/package.json' @@ -15,13 +15,8 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + ref: ${{ github.event.pull_request.head.sha }} + repository: ${{ github.event.pull_request.head.repo.full_name }} - name: Validate pnpm-lock.yaml entries id: validate # give this step an ID so we can reference its outputs @@ -35,7 +30,7 @@ jobs: # 2) No unwanted vitepress paths if grep -qF 'packages/mermaid/src/vitepress' pnpm-lock.yaml; then - issues+=("• Disallowed path 'packages/mermaid/src/vitepress' present. Run `rm -rf packages/mermaid/src/vitepress && pnpm install` to regenerate.") + issues+=("• Disallowed path 'packages/mermaid/src/vitepress' present. Run \`rm -rf packages/mermaid/src/vitepress && pnpm install\` to regenerate.") fi # 3) Lockfile only changes when package.json changes @@ -55,16 +50,41 @@ jobs: exit 1 fi + - name: Find existing lockfile validation comment + if: always() + uses: peter-evans/find-comment@v3 + id: find-comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: 'Lockfile Validation Failed' + - name: Comment on PR if validation failed if: failure() - uses: peter-evans/create-or-update-comment@v4 + uses: peter-evans/create-or-update-comment@v5 with: token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.find-comment.outputs.comment-id }} + edit-mode: replace body: | + ❌ **Lockfile Validation Failed** + The following issue(s) were detected: ${{ steps.validate.outputs.errors }} Please address these and push an update. _Posted automatically by GitHub Actions_ + + - name: Delete comment if validation passed + if: success() && steps.find-comment.outputs.comment-id != '' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: ${{ steps.find-comment.outputs.comment-id }}, + }); diff --git a/.gitignore b/.gitignore index 7448f2a81..7eb55d5cb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ coverage/ .idea/ .pnpm-store/ +.instructions/ dist v8-compile-cache-0 diff --git a/.vite/build.ts b/.vite/build.ts index 480dd6b30..d59f0fac3 100644 --- a/.vite/build.ts +++ b/.vite/build.ts @@ -78,6 +78,8 @@ export const getBuildConfig = ({ minify, core, watch, entryName }: BuildOptions) }, define: { 'import.meta.vitest': 'undefined', + 'injected.includeLargeFeatures': 'true', + 'injected.version': `'0.0.0'`, }, resolve: { extensions: [], @@ -94,10 +96,6 @@ export const getBuildConfig = ({ minify, core, watch, entryName }: BuildOptions) }), ...visualizerOptions(packageName, core), ], - define: { - // Needs to be string - includeLargeFeatures: 'true', - }, }; if (watch && config.build) { diff --git a/Dockerfile b/Dockerfile index 533604407..d7e6c8f84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ USER 0:0 RUN corepack enable \ && corepack enable pnpm -RUN apk add --no-cache git~=2.43.4 \ +RUN apk add --no-cache git~=2.43 \ && git config --add --system safe.directory /mermaid ENV NODE_OPTIONS="--max_old_space_size=8192" diff --git a/cypress/helpers/util.ts b/cypress/helpers/util.ts index ab4bbef64..51268c2a9 100644 --- a/cypress/helpers/util.ts +++ b/cypress/helpers/util.ts @@ -6,6 +6,7 @@ interface CypressConfig { listUrl?: boolean; listId?: string; name?: string; + screenshot?: boolean; } type CypressMermaidConfig = MermaidConfig & CypressConfig; @@ -90,20 +91,33 @@ export const renderGraph = ( export const openURLAndVerifyRendering = ( url: string, - options: CypressMermaidConfig, + { screenshot = true, ...options }: CypressMermaidConfig, validation?: any ): void => { const name: string = (options.name ?? cy.state('runnable').fullTitle()).replace(/\s+/g, '-'); cy.visit(url); cy.window().should('have.property', 'rendered', true); - cy.get('svg').should('be.visible'); - if (validation) { - cy.get('svg').should(validation); + // Handle sandbox mode where SVG is inside an iframe + if (options.securityLevel === 'sandbox') { + cy.get('iframe').should('be.visible'); + if (validation) { + cy.get('iframe').should(validation); + } + } else { + cy.get('svg').should('be.visible'); + // cspell:ignore viewbox + cy.get('svg').should('not.have.attr', 'viewbox'); + + if (validation) { + cy.get('svg').should(validation); + } } - verifyScreenshot(name); + if (screenshot) { + verifyScreenshot(name); + } }; export const verifyScreenshot = (name: string): void => { diff --git a/cypress/integration/other/configuration.spec.js b/cypress/integration/other/configuration.spec.js index b48a197a4..a699e03a7 100644 --- a/cypress/integration/other/configuration.spec.js +++ b/cypress/integration/other/configuration.spec.js @@ -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', () => { diff --git a/cypress/integration/rendering/c4.spec.js b/cypress/integration/rendering/c4.spec.js index 00e71adec..92b834d41 100644 --- a/cypress/integration/rendering/c4.spec.js +++ b/cypress/integration/rendering/c4.spec.js @@ -114,4 +114,28 @@ describe('C4 diagram', () => { {} ); }); + it('C4.6 should render C4Context diagram with ComponentQueue_Ext', () => { + imgSnapshotTest( + ` + C4Context + title System Context diagram with ComponentQueue_Ext + + Enterprise_Boundary(b0, "BankBoundary0") { + Person(customerA, "Banking Customer A", "A customer of the bank, with personal bank accounts.") + + System(SystemAA, "Internet Banking System", "Allows customers to view information about their bank accounts, and make payments.") + + Enterprise_Boundary(b1, "BankBoundary") { + ComponentQueue_Ext(msgQueue, "Message Queue", "RabbitMQ", "External message queue system for processing banking transactions") + System_Ext(SystemC, "E-mail system", "The internal Microsoft Exchange e-mail system.") + } + } + + BiRel(customerA, SystemAA, "Uses") + Rel(SystemAA, msgQueue, "Sends messages to") + Rel(SystemAA, SystemC, "Sends e-mails", "SMTP") + `, + {} + ); + }); }); diff --git a/cypress/integration/rendering/classDiagram-v2.spec.js b/cypress/integration/rendering/classDiagram-v2.spec.js index 0c5dbc04b..f54768d9a 100644 --- a/cypress/integration/rendering/classDiagram-v2.spec.js +++ b/cypress/integration/rendering/classDiagram-v2.spec.js @@ -562,6 +562,20 @@ class C13["With Città foreign language"] ` ); }); + it('should add notes in namespaces', function () { + imgSnapshotTest( + ` + classDiagram + note "This is a outer note" + note for C1 "This is a outer note for C1" + namespace Namespace1 { + note "This is a inner note" + note for C1 "This is a inner note for C1" + class C1 + } + ` + ); + }); it('should render a simple class diagram with no members', () => { imgSnapshotTest( ` diff --git a/cypress/integration/rendering/classDiagram-v3.spec.js b/cypress/integration/rendering/classDiagram-v3.spec.js index 626d6fcea..7e8d2ff0a 100644 --- a/cypress/integration/rendering/classDiagram-v3.spec.js +++ b/cypress/integration/rendering/classDiagram-v3.spec.js @@ -709,6 +709,20 @@ class C13["With Città foreign language"] ` ); }); + it('should add notes in namespaces', function () { + imgSnapshotTest( + ` + classDiagram + note "This is a outer note" + note for C1 "This is a outer note for C1" + namespace Namespace1 { + note "This is a inner note" + note for C1 "This is a inner note for C1" + class C1 + } + ` + ); + }); it('should render a simple class diagram with no members', () => { imgSnapshotTest( ` diff --git a/cypress/integration/rendering/classDiagram.spec.js b/cypress/integration/rendering/classDiagram.spec.js index bd2a96b34..6cea402f8 100644 --- a/cypress/integration/rendering/classDiagram.spec.js +++ b/cypress/integration/rendering/classDiagram.spec.js @@ -524,5 +524,18 @@ describe('Class diagram', () => { `, {} ); + it('should handle an empty class body with empty braces', () => { + imgSnapshotTest( + ` classDiagram + class FooBase~T~ {} + class Bar { + +Zip + +Zap() + } + FooBase <|-- Ba + `, + { flowchart: { defaultRenderer: 'elk' } } + ); + }); }); }); diff --git a/cypress/integration/rendering/erDiagram.spec.js b/cypress/integration/rendering/erDiagram.spec.js index 7d59a5793..fc2ae9919 100644 --- a/cypress/integration/rendering/erDiagram.spec.js +++ b/cypress/integration/rendering/erDiagram.spec.js @@ -381,4 +381,92 @@ ORDER ||--|{ LINE-ITEM : contains ); }); }); + + describe('Special characters and numbers syntax', () => { + it('should render ER diagram with numeric entity names', () => { + imgSnapshotTest( + ` + erDiagram + 1 ||--|| ORDER : places + ORDER ||--|{ 2 : contains + 2 ||--o{ 3.5 : references + `, + { logLevel: 1 } + ); + }); + + it('should render ER diagram with "u" character in entity names and cardinality', () => { + imgSnapshotTest( + ` + erDiagram + CUSTOMER ||--|| u : has + u ||--|| ORDER : places + PROJECT u--o{ TEAM_MEMBER : "parent" + `, + { logLevel: 1 } + ); + }); + + it('should render ER diagram with decimal numbers in relationships', () => { + imgSnapshotTest( + ` + erDiagram + 2.5 ||--|| 1.5 : has + CUSTOMER ||--o{ 3.14 : references + 1.0 ||--|{ ORDER : contains + `, + { logLevel: 1 } + ); + }); + + it('should render ER diagram with numeric entity names and attributes', () => { + imgSnapshotTest( + ` + erDiagram + 1 { + string name + int value + } + 1 ||--|| ORDER : places + ORDER { + float price + string description + } + `, + { logLevel: 1 } + ); + }); + + it('should render complex ER diagram with mixed special entity names', () => { + imgSnapshotTest( + ` + erDiagram + CUSTOMER ||--o{ 1 : places + 1 ||--|{ u : contains + 1.5 + u ||--|| 2.5 : processes + 2.5 { + string id + float value + } + u { + varchar(50) name + int count + } + `, + { logLevel: 1 } + ); + }); + it('should render ER diagram with standalone numeric entities', () => { + imgSnapshotTest( + `erDiagram + PRODUCT ||--o{ ORDER-ITEM : has + 1.5 + u + 1 + `, + { logLevel: 1 } + ); + }); + }); }); diff --git a/cypress/integration/rendering/flowchart-elk.spec.js b/cypress/integration/rendering/flowchart-elk.spec.js index 312e1d5b4..fac4f3b4b 100644 --- a/cypress/integration/rendering/flowchart-elk.spec.js +++ b/cypress/integration/rendering/flowchart-elk.spec.js @@ -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'); }); }); diff --git a/cypress/integration/rendering/flowchart-handDrawn.spec.js b/cypress/integration/rendering/flowchart-handDrawn.spec.js index 49c55c628..d3ca1d1f1 100644 --- a/cypress/integration/rendering/flowchart-handDrawn.spec.js +++ b/cypress/integration/rendering/flowchart-handDrawn.spec.js @@ -1029,4 +1029,19 @@ graph TD } ); }); + + it('FDH49: should add edge animation', () => { + renderGraph( + ` + flowchart TD + A(["Start"]) L_A_B_0@--> B{"Decision"} + B --> C["Option A"] & D["Option B"] + style C stroke-width:4px,stroke-dasharray: 5 + L_A_B_0@{ animation: slow } + L_B_D_0@{ animation: fast }`, + { look: 'handDrawn', screenshot: false } + ); + cy.get('path#L_A_B_0').should('have.class', 'edge-animation-slow'); + cy.get('path#L_B_D_0').should('have.class', 'edge-animation-fast'); + }); }); diff --git a/cypress/integration/rendering/flowchart-v2.spec.js b/cypress/integration/rendering/flowchart-v2.spec.js index 8c6cde57a..cd3676fbf 100644 --- a/cypress/integration/rendering/flowchart-v2.spec.js +++ b/cypress/integration/rendering/flowchart-v2.spec.js @@ -79,6 +79,18 @@ describe('Flowchart v2', () => { { htmlLabels: true, flowchart: { htmlLabels: true }, securityLevel: 'loose' } ); }); + it('6a: should render complex HTML in labels with sandbox security', () => { + imgSnapshotTest( + `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] + `, + { securityLevel: 'sandbox', flowchart: { htmlLabels: true } } + ); + }); it('7: should render a flowchart when useMaxWidth is true (default)', () => { renderGraph( `flowchart TD @@ -1186,4 +1198,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 +` + ); + }); }); diff --git a/cypress/integration/rendering/flowchart.spec.js b/cypress/integration/rendering/flowchart.spec.js index 40713ac4e..5e1984377 100644 --- a/cypress/integration/rendering/flowchart.spec.js +++ b/cypress/integration/rendering/flowchart.spec.js @@ -774,6 +774,21 @@ describe('Graph', () => { expect(svg).to.not.have.attr('style'); }); }); + it('40: should add edge animation', () => { + renderGraph( + ` + flowchart TD + A(["Start"]) L_A_B_0@--> B{"Decision"} + B --> C["Option A"] & D["Option B"] + style C stroke-width:4px,stroke-dasharray: 5 + L_A_B_0@{ animation: slow } + L_B_D_0@{ animation: fast }`, + { screenshot: false } + ); + // Verify animation classes are applied to both edges + cy.get('path#L_A_B_0').should('have.class', 'edge-animation-slow'); + cy.get('path#L_B_D_0').should('have.class', 'edge-animation-fast'); + }); it('58: handle styling with style expressions', () => { imgSnapshotTest( ` @@ -973,4 +988,19 @@ graph TD } ); }); + + it('70: should render a subgraph with direction TD', () => { + imgSnapshotTest( + ` + flowchart LR + subgraph A + direction TD + a --> b + end + `, + { + fontFamily: 'courier', + } + ); + }); }); diff --git a/cypress/integration/rendering/gantt.spec.js b/cypress/integration/rendering/gantt.spec.js index 32dbcb4d9..72cb6ea29 100644 --- a/cypress/integration/rendering/gantt.spec.js +++ b/cypress/integration/rendering/gantt.spec.js @@ -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 + `, + {} + ); + }); }); diff --git a/cypress/integration/rendering/mindmap-tidy-tree.spec.js b/cypress/integration/rendering/mindmap-tidy-tree.spec.js new file mode 100644 index 000000000..e111c281a --- /dev/null +++ b/cypress/integration/rendering/mindmap-tidy-tree.spec.js @@ -0,0 +1,79 @@ +import { imgSnapshotTest } from '../../helpers/util.ts'; + +describe('Mindmap Tidy Tree', () => { + it('1-tidy-tree: should render a simple mindmap without children', () => { + imgSnapshotTest( + ` --- + config: + layout: tidy-tree + --- + mindmap + root((mindmap)) + A + B + ` + ); + }); + it('2-tidy-tree: should render a simple mindmap', () => { + imgSnapshotTest( + ` --- + config: + layout: tidy-tree + --- + mindmap + root((mindmap is a long thing)) + A + B + C + D + ` + ); + }); + it('3-tidy-tree: should render a mindmap with different shapes', () => { + imgSnapshotTest( + ` --- + 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 + id)I am a cloud( + id))I am a bang(( + Tools + ` + ); + }); + it('4-tidy-tree: should render a mindmap with children', () => { + imgSnapshotTest( + ` --- + config: + layout: tidy-tree + --- + mindmap + ((This is a mindmap)) + child1 + grandchild 1 + grandchild 2 + child2 + grandchild 3 + grandchild 4 + child3 + grandchild 5 + grandchild 6 + ` + ); + }); +}); diff --git a/cypress/integration/rendering/mindmap.spec.ts b/cypress/integration/rendering/mindmap.spec.ts index d76e58c56..e0409ed46 100644 --- a/cypress/integration/rendering/mindmap.spec.ts +++ b/cypress/integration/rendering/mindmap.spec.ts @@ -159,12 +159,10 @@ root }); it('square shape', () => { imgSnapshotTest( - ` -mindmap + `mindmap root[ The root - ] - `, + ]`, {}, undefined, shouldHaveRoot @@ -172,12 +170,10 @@ mindmap }); it('rounded rect shape', () => { imgSnapshotTest( - ` -mindmap + `mindmap root(( The root - )) - `, + ))`, {}, undefined, shouldHaveRoot @@ -185,12 +181,10 @@ mindmap }); it('circle shape', () => { imgSnapshotTest( - ` -mindmap + `mindmap root( The root - ) - `, + )`, {}, undefined, shouldHaveRoot @@ -198,10 +192,8 @@ mindmap }); it('default shape', () => { imgSnapshotTest( - ` -mindmap - The root - `, + `mindmap + The root`, {}, undefined, shouldHaveRoot @@ -209,12 +201,10 @@ mindmap }); it('adding children', () => { imgSnapshotTest( - ` -mindmap + `mindmap The root child1 - child2 - `, + child2`, {}, undefined, shouldHaveRoot @@ -222,13 +212,11 @@ mindmap }); it('adding grand children', () => { imgSnapshotTest( - ` -mindmap + `mindmap The root child1 child2 - child3 - `, + child3`, {}, undefined, shouldHaveRoot @@ -240,25 +228,21 @@ mindmap `mindmap id1[\`**Start** with a second line 😎\`] - id2[\`The dog in **the** hog... a *very long text* about it -Word!\`] -` + id2[\`The dog in **the** hog... a *very long text* about it Word!\`]` ); }); }); describe('Include char sequence "graph" in text (#6795)', () => { it('has a label with char sequence "graph"', () => { imgSnapshotTest( - ` - mindmap + ` mindmap root Photograph Waterfall Landscape Geography Mountains - Rocks - `, + Rocks`, { flowchart: { defaultRenderer: 'elk' } } ); }); diff --git a/cypress/integration/rendering/sequencediagram-v2.spec.js b/cypress/integration/rendering/sequencediagram-v2.spec.js new file mode 100644 index 000000000..42db4001d --- /dev/null +++ b/cypress/integration/rendering/sequencediagram-v2.spec.js @@ -0,0 +1,780 @@ +import { imgSnapshotTest, renderGraph } from '../../helpers/util.ts'; + +const looks = ['classic']; +const participantTypes = [ + { type: 'participant', display: 'participant' }, + { type: 'actor', display: 'actor' }, + { type: 'boundary', display: 'boundary' }, + { type: 'control', display: 'control' }, + { type: 'entity', display: 'entity' }, + { type: 'database', display: 'database' }, + { type: 'collections', display: 'collections' }, + { type: 'queue', display: 'queue' }, +]; + +const restrictedTypes = ['boundary', 'control', 'entity', 'database', 'collections', 'queue']; + +const interactionTypes = ['->>', '-->>', '->', '-->', '-x', '--x', '->>+', '-->>+']; + +const notePositions = ['left of', 'right of', 'over']; + +function getParticipantLine(name, type, alias) { + if (restrictedTypes.includes(type)) { + return ` participant ${name}@{ "type" : "${type}" }\n`; + } else if (alias) { + return ` participant ${name}@{ "type" : "${type}" } \n`; + } else { + return ` participant ${name}@{ "type" : "${type}" }\n`; + } +} + +looks.forEach((look) => { + describe(`Sequence Diagram Tests - ${look} look`, () => { + it('should render all participant types', () => { + let diagramCode = `sequenceDiagram\n`; + participantTypes.forEach((pt, index) => { + const name = `${pt.display}${index}`; + diagramCode += getParticipantLine(name, pt.type); + }); + for (let i = 0; i < participantTypes.length - 1; i++) { + diagramCode += ` ${participantTypes[i].display}${i} ->> ${participantTypes[i + 1].display}${i + 1}: Message ${i}\n`; + } + imgSnapshotTest(diagramCode, { look, sequence: { diagramMarginX: 50, diagramMarginY: 10 } }); + }); + + it('should render all interaction types', () => { + let diagramCode = `sequenceDiagram\n`; + diagramCode += getParticipantLine('A', 'actor'); + diagramCode += getParticipantLine('B', 'boundary'); + interactionTypes.forEach((interaction, index) => { + diagramCode += ` A ${interaction} B: ${interaction} message ${index}\n`; + }); + imgSnapshotTest(diagramCode, { look }); + }); + + it('should render participant creation and destruction', () => { + let diagramCode = `sequenceDiagram\n`; + participantTypes.forEach((pt, index) => { + const name = `${pt.display}${index}`; + diagramCode += getParticipantLine('A', pt.type); + diagramCode += getParticipantLine('B', pt.type); + diagramCode += ` create participant ${name}@{ "type" : "${pt.type}" }\n`; + diagramCode += ` A ->> ${name}: Hello ${pt.display}\n`; + if (index % 2 === 0) { + diagramCode += ` destroy ${name}\n`; + } + }); + imgSnapshotTest(diagramCode, { look }); + }); + + it('should render notes in all positions', () => { + let diagramCode = `sequenceDiagram\n`; + diagramCode += getParticipantLine('A', 'actor'); + diagramCode += getParticipantLine('B', 'boundary'); + notePositions.forEach((position, index) => { + diagramCode += ` Note ${position} A: Note ${position} ${index}\n`; + }); + diagramCode += ` A ->> B: Message with notes\n`; + imgSnapshotTest(diagramCode, { look }); + }); + + it('should render parallel interactions', () => { + let diagramCode = `sequenceDiagram\n`; + participantTypes.slice(0, 4).forEach((pt, index) => { + diagramCode += getParticipantLine(`${pt.display}${index}`, pt.type); + }); + diagramCode += ` par Parallel actions\n`; + for (let i = 0; i < 3; i += 2) { + diagramCode += ` ${participantTypes[i].display}${i} ->> ${participantTypes[i + 1].display}${i + 1}: Message ${i}\n`; + if (i < participantTypes.length - 2) { + diagramCode += ` and\n`; + } + } + diagramCode += ` end\n`; + imgSnapshotTest(diagramCode, { look }); + }); + + it('should render alternative flows', () => { + let diagramCode = `sequenceDiagram\n`; + diagramCode += getParticipantLine('A', 'actor'); + diagramCode += getParticipantLine('B', 'boundary'); + diagramCode += ` alt Successful case\n`; + diagramCode += ` A ->> B: Request\n`; + diagramCode += ` B -->> A: Success\n`; + diagramCode += ` else Failure case\n`; + diagramCode += ` A ->> B: Request\n`; + diagramCode += ` B --x A: Failure\n`; + diagramCode += ` end\n`; + imgSnapshotTest(diagramCode, { look }); + }); + + it('should render loops', () => { + let diagramCode = `sequenceDiagram\n`; + participantTypes.slice(0, 3).forEach((pt, index) => { + diagramCode += getParticipantLine(`${pt.display}${index}`, pt.type); + }); + diagramCode += ` loop For each participant\n`; + for (let i = 0; i < 3; i++) { + diagramCode += ` ${participantTypes[0].display}0 ->> ${participantTypes[1].display}1: Message ${i}\n`; + } + diagramCode += ` end\n`; + imgSnapshotTest(diagramCode, { look }); + }); + + it('should render boxes around groups', () => { + let diagramCode = `sequenceDiagram\n`; + diagramCode += ` box Group 1\n`; + participantTypes.slice(0, 3).forEach((pt, index) => { + diagramCode += ` ${getParticipantLine(`${pt.display}${index}`, pt.type)}`; + }); + diagramCode += ` end\n`; + diagramCode += ` box rgb(200,220,255) Group 2\n`; + participantTypes.slice(3, 6).forEach((pt, index) => { + diagramCode += ` ${getParticipantLine(`${pt.display}${index}`, pt.type)}`; + }); + diagramCode += ` end\n`; + diagramCode += ` ${participantTypes[0].display}0 ->> ${participantTypes[3].display}0: Cross-group message\n`; + imgSnapshotTest(diagramCode, { look }); + }); + + it('should render with different font settings', () => { + let diagramCode = `sequenceDiagram\n`; + participantTypes.slice(0, 3).forEach((pt, index) => { + diagramCode += getParticipantLine(`${pt.display}${index}`, pt.type); + }); + diagramCode += ` ${participantTypes[0].display}0 ->> ${participantTypes[1].display}1: Regular message\n`; + diagramCode += ` Note right of ${participantTypes[1].display}1: Regular note\n`; + imgSnapshotTest(diagramCode, { + look, + sequence: { + actorFontFamily: 'courier', + actorFontSize: 14, + messageFontFamily: 'Arial', + messageFontSize: 12, + noteFontFamily: 'times', + noteFontSize: 16, + noteAlign: 'left', + }, + }); + }); + }); +}); + +// Additional tests for specific combinations +describe('Sequence Diagram Special Cases', () => { + it('should render complex sequence with all features', () => { + const diagramCode = ` + sequenceDiagram + box rgb(200,220,255) Authentication + actor User + participant LoginUI@{ "type": "boundary" } + participant AuthService@{ "type": "control" } + participant UserDB@{ "type": "database" } + end + + box rgb(200,255,220) Order Processing + participant Order@{ "type": "entity" } + participant OrderQueue@{ "type": "queue" } + participant AuditLogs@{ "type": "collections" } + end + + User ->> LoginUI: Enter credentials + LoginUI ->> AuthService: Validate + AuthService ->> UserDB: Query user + UserDB -->> AuthService: User data + alt Valid credentials + AuthService -->> LoginUI: Success + LoginUI -->> User: Welcome + + par Place order + User ->> Order: New order + Order ->> OrderQueue: Process + and + Order ->> AuditLogs: Record + end + + loop Until confirmed + OrderQueue ->> Order: Update status + Order -->> User: Notification + end + else Invalid credentials + AuthService --x LoginUI: Failure + LoginUI --x User: Retry + end + `; + imgSnapshotTest(diagramCode, {}); + }); + + it('should render with wrapped messages and notes', () => { + const diagramCode = ` + sequenceDiagram + participant A + participant B + + A ->> B: This is a very long message that should wrap properly in the diagram rendering + Note over A,B: This is a very long note that should also wrap properly when rendered in the diagram + + par Wrapped parallel + A ->> B: Parallel message 1
with explicit line break + and + B ->> A: Parallel message 2
with explicit line break + end + + loop Wrapped loop + Note right of B: This is a long note
in a loop + A ->> B: Message in loop + end + `; + imgSnapshotTest(diagramCode, { sequence: { wrap: true } }); + }); + describe('Sequence Diagram Rendering with Different Participant Types', () => { + it('should render a sequence diagram with various participant types', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant User@{ "type": "actor" } + participant AuthService@{ "type": "control" } + participant UI@{ "type": "boundary" } + participant OrderController@{ "type": "control" } + participant Product@{ "type": "entity" } + participant MongoDB@{ "type": "database" } + participant Products@{ "type": "collections" } + participant OrderQueue@{ "type": "queue" } + User ->> UI: Login request + UI ->> AuthService: Validate credentials + AuthService -->> UI: Authentication token + UI ->> OrderController: Place order + OrderController ->> Product: Check availability + Product -->> OrderController: Available + OrderController ->> MongoDB: Save order + MongoDB -->> OrderController: Order saved + OrderController ->> OrderQueue: Process payment + OrderQueue -->> User: Order confirmation + ` + ); + }); + + it('should render participant creation and destruction with different types', () => { + imgSnapshotTest(` + sequenceDiagram + participant Alice@{ "type" : "boundary" } + Alice->>Bob: Hello Bob, how are you ? + Bob->>Alice: Fine, thank you. And you? + create participant Carl@{ "type" : "control" } + Alice->>Carl: Hi Carl! + create actor D as Donald + Carl->>D: Hi! + destroy Carl + Alice-xCarl: We are too many + destroy Bob + Bob->>Alice: I agree + `); + }); + + it('should handle complex interactions between different participant types', () => { + imgSnapshotTest( + ` + sequenceDiagram + box rgb(200,220,255) Authentication + participant User@{ "type": "actor" } + participant LoginUI@{ "type": "boundary" } + participant AuthService@{ "type": "control" } + participant UserDB@{ "type": "database" } + end + + box rgb(200,255,220) Order Processing + participant Order@{ "type": "entity" } + participant OrderQueue@{ "type": "queue" } + participant AuditLogs@{ "type": "collections" } + end + + User ->> LoginUI: Enter credentials + LoginUI ->> AuthService: Validate + AuthService ->> UserDB: Query user + UserDB -->> AuthService: User data + + alt Valid credentials + AuthService -->> LoginUI: Success + LoginUI -->> User: Welcome + + par Place order + User ->> Order: New order + Order ->> OrderQueue: Process + and + Order ->> AuditLogs: Record + end + + loop Until confirmed + OrderQueue ->> Order: Update status + Order -->> User: Notification + end + else Invalid credentials + AuthService --x LoginUI: Failure + LoginUI --x User: Retry + end + `, + { sequence: { useMaxWidth: false } } + ); + }); + + it('should render parallel processes with different participant types', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Customer@{ "type": "actor" } + participant Frontend@{ "type": "participant" } + participant PaymentService@{ "type": "boundary" } + participant InventoryManager@{ "type": "control" } + participant Order@{ "type": "entity" } + participant OrdersDB@{ "type": "database" } + participant NotificationQueue@{ "type": "queue" } + + Customer ->> Frontend: Place order + Frontend ->> Order: Create order + par Parallel Processing + Order ->> PaymentService: Process payment + and + Order ->> InventoryManager: Reserve items + end + PaymentService -->> Order: Payment confirmed + InventoryManager -->> Order: Items reserved + Order ->> OrdersDB: Save finalized order + OrdersDB -->> Order: Order saved + Order ->> NotificationQueue: Send confirmation + NotificationQueue -->> Customer: Order confirmation + ` + ); + }); + }); + it('should render different participant types with notes and loops', () => { + imgSnapshotTest( + ` + sequenceDiagram + actor Admin + participant Dashboard + participant AuthService@{ "type" : "boundary" } + participant UserManager@{ "type" : "control" } + participant UserProfile@{ "type" : "entity" } + participant UserDB@{ "type" : "database" } + participant Logs@{ "type" : "database" } + + Admin ->> Dashboard: Open user management + loop Authentication check + Dashboard ->> AuthService: Verify admin rights + AuthService ->> Dashboard: Access granted + end + Dashboard ->> UserManager: List users + UserManager ->> UserDB: Query users + UserDB ->> UserManager: Return user data + Note right of UserDB: Encrypted data
requires decryption + UserManager ->> UserProfile: Format profiles + UserProfile ->> UserManager: Formatted data + UserManager ->> Dashboard: Display users + Dashboard ->> Logs: Record access + Logs ->> Admin: Audit trail + ` + ); + }); + + it('should render different participant types with alternative flows', () => { + imgSnapshotTest( + ` + sequenceDiagram + actor Client + participant MobileApp + participant CloudService@{ "type" : "boundary" } + participant DataProcessor@{ "type" : "control" } + participant Transaction@{ "type" : "entity" } + participant TransactionsDB@{ "type" : "database" } + participant EventBus@{ "type" : "queue" } + + Client ->> MobileApp: Initiate transaction + MobileApp ->> CloudService: Authenticate + alt Authentication successful + CloudService -->> MobileApp: Auth token + MobileApp ->> DataProcessor: Process data + DataProcessor ->> Transaction: Create transaction + Transaction ->> TransactionsDB: Save record + TransactionsDB -->> Transaction: Confirmation + Transaction ->> EventBus: Publish event + EventBus -->> Client: Notification + else Authentication failed + CloudService -->> MobileApp: Error + MobileApp -->> Client: Show error + end + ` + ); + }); + + it('should render different participant types with wrapping text', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant B@{ "type" : "boundary" } + participant C@{ "type" : "control" } + participant E@{ "type" : "entity" } + participant DB@{ "type" : "database" } + participant COL@{ "type" : "collections" } + participant Q@{ "type" : "queue" } + + FE ->> B: Another long message
with explicit
line breaks + B -->> FE: Response message that is also quite long and needs to wrap + FE ->> C: Process data + C ->> E: Validate + E -->> C: Validation result + C ->> DB: Save + DB -->> C: Save result + C ->> COL: Log + COL -->> Q: Forward + Q -->> LongNameUser: Final response with confirmation of all actions taken + `, + { sequence: { wrap: true } } + ); + }); + + describe('Sequence Diagram - New Participant Types with Long Notes and Messages', () => { + it('should render long notes left of boundary', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice@{ "type" : "boundary" } + actor Bob + Alice->>Bob: Hola + Note left of Alice: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + Bob->>Alice: I'm short though + `, + {} + ); + }); + + it('should render wrapped long notes left of control', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice@{ "type" : "control" } + actor Bob + Alice->>Bob: Hola + Note left of Alice:wrap: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + Bob->>Alice: I'm short though + `, + {} + ); + }); + + it('should render long notes right of entity', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice@{ "type" : "entity" } + actor Bob + Alice->>Bob: Hola + Note right of Alice: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + Bob->>Alice: I'm short though + `, + {} + ); + }); + + it('should render wrapped long notes right of database', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice@{ "type" : "database" } + actor Bob + Alice->>Bob: Hola + Note right of Alice:wrap: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + Bob->>Alice: I'm short though + `, + {} + ); + }); + + it('should render long notes over collections', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice@{ "type" : "collections" } + actor Bob + Alice->>Bob: Hola + Note over Alice: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + Bob->>Alice: I'm short though + `, + {} + ); + }); + + it('should render wrapped long notes over queue', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice@{ "type" : "queue" } + actor Bob + Alice->>Bob: Hola + Note over Alice:wrap: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + Bob->>Alice: I'm short though + `, + {} + ); + }); + + it('should render notes over actor and boundary', () => { + imgSnapshotTest( + ` + sequenceDiagram + actor Alice + participant Charlie@{ "type" : "boundary" } + note over Alice: Some note + note over Charlie: Other note + `, + {} + ); + }); + + it('should render long messages from database to collections', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice@{ "type" : "database" } + participant Bob@{ "type" : "collections" } + Alice->>Bob: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + Bob->>Alice: I'm short though + `, + {} + ); + }); + + it('should render wrapped long messages from control to entity', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice@{ "type" : "control" } + participant Bob@{ "type" : "entity" } + Alice->>Bob:wrap: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + Bob->>Alice: I'm short though + `, + {} + ); + }); + + it('should render long messages from queue to boundary', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice@{ "type" : "queue" } + participant Bob@{ "type" : "boundary" } + Alice->>Bob: I'm short + Bob->>Alice: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + `, + {} + ); + }); + + it('should render wrapped long messages from actor to database', () => { + imgSnapshotTest( + ` + sequenceDiagram + actor Alice + participant Bob@{ "type" : "database" } + Alice->>Bob: I'm short + Bob->>Alice:wrap: Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be + `, + {} + ); + }); + }); + + describe('svg size', () => { + it('should render a sequence diagram when useMaxWidth is true (default)', () => { + renderGraph( + ` + sequenceDiagram + actor Alice + participant Bob@{ "type" : "boundary" } + participant John@{ "type" : "control" } + Alice ->> Bob: Hello Bob, how are you? + Bob-->>John: How about you John? + Bob--x Alice: I am good thanks! + Bob-x John: I am good thanks! + Note right of John: Bob thinks a long
long time, so long
that the text does
not fit on a row. + Bob-->Alice: Checking with John... + alt either this + Alice->>John: Yes + else or this + Alice->>John: No + else or this will happen + Alice->John: Maybe + end + par this happens in parallel + Alice -->> Bob: Parallel message 1 + and + Alice -->> John: Parallel message 2 + end + `, + { sequence: { useMaxWidth: true } } + ); + cy.get('svg').should((svg) => { + expect(svg).to.have.attr('width', '100%'); + const style = svg.attr('style'); + expect(style).to.match(/^max-width: [\d.]+px;$/); + const maxWidthValue = parseFloat(style.match(/[\d.]+/g).join('')); + expect(maxWidthValue).to.be.within(820 * 0.95, 820 * 1.05); + }); + }); + + it('should render a sequence diagram when useMaxWidth is false', () => { + renderGraph( + ` + sequenceDiagram + actor Alice + participant Bob@{ "type" : "boundary" } + participant John@{ "type" : "control" } + Alice ->> Bob: Hello Bob, how are you? + Bob-->>John: How about you John? + Bob--x Alice: I am good thanks! + Bob-x John: I am good thanks! + Note right of John: Bob thinks a long
long time, so long
that the text does
not fit on a row. + Bob-->Alice: Checking with John... + alt either this + Alice->>John: Yes + else or this + Alice->>John: No + else or this will happen + Alice->John: Maybe + end + par this happens in parallel + Alice -->> Bob: Parallel message 1 + and + Alice -->> John: Parallel message 2 + end + `, + { sequence: { useMaxWidth: false } } + ); + cy.get('svg').should((svg) => { + const width = parseFloat(svg.attr('width')); + expect(width).to.be.within(820 * 0.95, 820 * 1.05); + expect(svg).to.not.have.attr('style'); + }); + }); + + describe('Central Connection Rendering Tests', () => { + it('should render central connection circles on actor vertical lines', () => { + imgSnapshotTest( + `sequenceDiagram + participant Alice + participant Bob + participant Charlie + Alice ()->>() Bob: Central connection + Bob ()-->> Charlie: Reverse central connection + Charlie ()<<-->>() Alice: Dual central connection`, + { look: 'classic', sequence: { diagramMarginX: 50, diagramMarginY: 10 } } + ); + }); + + it('should render central connections with different arrow types', () => { + imgSnapshotTest( + `sequenceDiagram + participant Alice + participant Bob + Alice ()->>() Bob: Solid open arrow + Alice ()-->>() Bob: Dotted open arrow + Alice ()-x() Bob: Solid cross + Alice ()--x() Bob: Dotted cross + Alice ()->() Bob: Solid arrow`, + { look: 'classic', sequence: { diagramMarginX: 50, diagramMarginY: 10 } } + ); + }); + + it('should render central connections with bidirectional arrows', () => { + imgSnapshotTest( + `sequenceDiagram + participant Alice + participant Bob + Alice ()<<->>() Bob: Bidirectional solid + Alice ()<<-->>() Bob: Bidirectional dotted`, + { look: 'classic', sequence: { diagramMarginX: 50, diagramMarginY: 10 } } + ); + }); + + it('should render central connections with activations', () => { + imgSnapshotTest( + `sequenceDiagram + participant Alice + participant Bob + participant Charlie + Alice ()->>() Bob: Activate Bob + activate Bob + Bob ()-->> Charlie: Message to Charlie + Bob ()->>() Alice: Response to Alice + deactivate Bob`, + { look: 'classic', sequence: { diagramMarginX: 50, diagramMarginY: 10 } } + ); + }); + + it('should render central connections mixed with normal messages', () => { + imgSnapshotTest( + `sequenceDiagram + participant Alice + participant Bob + participant Charlie + Alice ->> Bob: Normal message + Bob ()->>() Charlie: Central connection + Charlie -->> Alice: Normal dotted message + Alice ()<<-->>() Bob: Dual central connection + Bob -x Charlie: Normal cross message`, + { look: 'classic', sequence: { diagramMarginX: 50, diagramMarginY: 10 } } + ); + }); + + it('should render central connections with notes', () => { + imgSnapshotTest( + `sequenceDiagram + participant Alice + participant Bob + participant Charlie + Alice ()->>() Bob: Central connection + Note over Alice,Bob: Central connection note + Bob ()-->> Charlie: Reverse central connection + Note right of Charlie: Response note + Charlie ()<<-->>() Alice: Dual central connection`, + { look: 'classic', sequence: { diagramMarginX: 50, diagramMarginY: 10 } } + ); + }); + + it('should render central connections with loops and alternatives', () => { + imgSnapshotTest( + `sequenceDiagram + participant Alice + participant Bob + participant Charlie + loop Every minute + Alice ()->>() Bob: Central heartbeat + Bob ()-->> Charlie: Forward heartbeat + end + alt Success + Charlie ()<<-->>() Alice: Success response + else Failure + Charlie ()-x() Alice: Failure response + end`, + { look: 'classic', sequence: { diagramMarginX: 50, diagramMarginY: 10 } } + ); + }); + + it('should render central connections with different participant types', () => { + imgSnapshotTest( + `sequenceDiagram + participant Alice + actor Bob + participant Charlie@{"type":"boundary"} + participant David@{"type":"control"} + participant Eve@{"type":"entity"} + Alice ()->>() Bob: To actor + Bob ()-->> Charlie: To boundary + Charlie ()->>() David: To control + David ()<<-->>() Eve: To entity + Eve ()-x() Alice: Back to participant`, + { look: 'classic', sequence: { diagramMarginX: 50, diagramMarginY: 10 } } + ); + }); + }); + }); +}); diff --git a/cypress/integration/rendering/sequencediagram.spec.js b/cypress/integration/rendering/sequencediagram.spec.js index f18e99abf..0ec913a8c 100644 --- a/cypress/integration/rendering/sequencediagram.spec.js +++ b/cypress/integration/rendering/sequencediagram.spec.js @@ -893,6 +893,17 @@ describe('Sequence diagram', () => { } ); }); + + it('should handle bidirectional arrows with autonumber', () => { + imgSnapshotTest(` + sequenceDiagram + autonumber + participant A + participant B + A<<->>B: This is a bidirectional message + A->B: This is a normal message`); + }); + it('should support actor links and properties when not mirrored EXPERIMENTAL: USE WITH CAUTION', () => { //Be aware that the syntax for "properties" is likely to be changed. imgSnapshotTest( @@ -1042,4 +1053,167 @@ describe('Sequence diagram', () => { ]); }); }); + describe('render new arrow type', () => { + it('should render Solid half arrow top', () => { + imgSnapshotTest( + ` + sequenceDiagram + Alice -|\\ John: Hello John, how are you? + Alice-|\\ John: Hi Alice, I can hear you! + Alice -|\\ John: Test + ` + ); + }); + it('should render Solid half arrow bottom', () => { + imgSnapshotTest( + ` + sequenceDiagram + Alice-|/John: Hello John, how are you? + Alice-|/John: Hi Alice, I can hear you! + Alice-|/John: Test + ` + ); + }); + + it('should render Stick half arrow top ', () => { + imgSnapshotTest( + ` + sequenceDiagram + Alice-\\\\John: Hello John, how are you? + Alice-\\\\John: Hi Alice, I can hear you! + Alice-\\\\John: Test + ` + ); + }); + it('should render Stick half arrow bottom ', () => { + imgSnapshotTest( + ` + sequenceDiagram + Alice-//John: Hello John, how are you? + Alice-//John: Hi Alice, I can hear you! + Alice-//John: Test + ` + ); + }); + it('should render Solid half arrow top reverse ', () => { + imgSnapshotTest( + ` + sequenceDiagram + Alice/|-John: Hello Alice, how are you? + Alice/|-John: Hi Alice, I can hear you! + Alice/|-John: Test + + ` + ); + }); + + it('should render Solid half arrow bottom reverse ', () => { + imgSnapshotTest( + `sequenceDiagram + Alice \\|- John: Hello Alice, how are you? + Alice \\|- John: Hi Alice, I can hear you! + Alice \\|- John: Test` + ); + }); + + it('should render Stick half arrow top reverse ', () => { + imgSnapshotTest( + ` + sequenceDiagram + Alice //-John: Hello Alice, how are you? + Alice //-John: Hi Alice, I can hear you! + Alice //-John: Test` + ); + }); + + it('should render Stick half arrow bottom reverse ', () => { + imgSnapshotTest( + ` + sequenceDiagram + Alice \\\\-John: Hello Alice, how are you? + Alice \\\\-John: Hi Alice, I can hear you! + Alice \\\\-John: Test` + ); + }); + + it('should render Solid half arrow top dotted', () => { + imgSnapshotTest( + ` + sequenceDiagram + Alice --|\\John: Hello John, how are you? + Alice --|\\John: Hi Alice, I can hear you! + Alice --|\\John: Test` + ); + }); + + it('should render Solid half arrow bottom dotted', () => { + imgSnapshotTest( + ` + sequenceDiagram + Alice --|/John: Hello John, how are you? + Alice --|/John: Hi Alice, I can hear you! + Alice --|/John: Test` + ); + }); + + it('should render Stick half arrow top dotted', () => { + imgSnapshotTest( + ` + sequenceDiagram + Alice--\\\\John: Hello John, how are you? + Alice--\\\\John: Hi Alice, I can hear you! + Alice--\\\\John: Test` + ); + }); + + it('should render Stick half arrow bottom dotted', () => { + imgSnapshotTest( + ` + sequenceDiagram + Alice--//John: Hello John, how are you? + Alice--//John: Hi Alice, I can hear you! + Alice--//John: Test` + ); + }); + + it('should render Solid half arrow top reverse dotted', () => { + imgSnapshotTest( + ` + sequenceDiagram + Alice/|--John: Hello Alice, how are you? + Alice/|--John: Hi Alice, I can hear you! + Alice/|--John: Test` + ); + }); + + it('should render Solid half arrow bottom reverse dotted', () => { + imgSnapshotTest( + ` + sequenceDiagram + Alice\\|--John: Hello Alice, how are you? + Alice\\|--John: Hi Alice, I can hear you! + Alice\\|--John: Test` + ); + }); + + it('should render Stick half arrow top reverse dotted ', () => { + imgSnapshotTest( + ` + sequenceDiagram + Alice//--John: Hello Alice, how are you? + Alice//--John: Hi Alice, I can hear you! + Alice//--John: Test` + ); + }); + + it('should render Stick half arrow bottom reverse dotted ', () => { + imgSnapshotTest( + ` + sequenceDiagram + Alice\\\\--John: Hello Alice, how are you? + Alice\\\\--John: Hi Alice, I can hear you! + Alice\\\\--John: Test` + ); + }); + }); }); diff --git a/cypress/platform/knsv2.html b/cypress/platform/knsv2.html index eb5528844..90f40003a 100644 --- a/cypress/platform/knsv2.html +++ b/cypress/platform/knsv2.html @@ -32,26 +32,8 @@ href="https://fonts.googleapis.com/css2?family=Kalam:wght@300;400;700&family=Rubik+Mono+One&display=swap" rel="stylesheet" /> - - - + + + +
+ ---
+      config:
+        layout: tidy-tree
+      ---
+      mindmap
+      root((mindmap))
+        A
+        B
+    
+
+ ---
+      config:
+        layout: dagre
+      ---
+      mindmap
+      root((mindmap))
+        A
+        B
+    
+
+ ---
+      config:
+        layout: elk
+      ---
+      mindmap
+      root((mindmap))
+        A
+        B
+    
+
+ ---
+      config:
+        layout: cose-bilkent
+      ---
+      mindmap
+      root((mindmap))
+        A
+        B
+    
+
+    ---
+      config:
+        layout: tidy-tree
+      ---
+      mindmap
+      root((mindmap is a long thing))
+        A
+        B
+        C
+        D
+    
+
+    ---
+      config:
+        layout: dagre
+      ---
+      mindmap
+      root((mindmap is a long thing))
+        A
+        B
+        C
+        D
+    
+
+    ---
+      config:
+        layout: elk
+      ---
+      mindmap
+      root((mindmap is a long thing))
+        A
+        B
+        C
+        D
+    
+
+    ---
+      config:
+        layout: cose-bilkent
+      ---
+      mindmap
+      root((mindmap is a long thing))
+        A
+        B
+        C
+        D
+    
+ +
+    ---
+      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
+              id)I am a cloud(
+                  id))I am a bang((
+                    Tools
+    
+
+    ---
+      config:
+        layout: dagre
+      ---
+      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
+              id)I am a cloud(
+                  id))I am a bang((
+                    Tools
+    
+
+    ---
+      config:
+        layout: elk
+      ---
+      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
+              id)I am a cloud(
+                  id))I am a bang((
+                    Tools
+    
+
+    ---
+      config:
+        layout: cose-bilkent
+      ---
+      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
+              id)I am a cloud(
+                  id))I am a bang((
+                    Tools
+    
+
+      ---
+      config:
+        layout: tidy-tree
+      ---
+      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]
+
+    
+
+      ---
+      config:
+        layout: dagre
+      ---
+      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]
+
+    
+
+      ---
+      config:
+        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]
+
+    
+
+      ---
+      config:
+        layout: cose-bilkent
+      ---
+      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]
+
+    
+ +
+ ---
+      config:
+        layout: tidy-tree
+      ---
+      mindmap
+      ((This is a mindmap))
+        child1
+         grandchild 1
+         grandchild 2
+        child2
+         grandchild 3
+         grandchild 4
+        child3
+         grandchild 5
+         grandchild 6
+      
+    
+ +
+ ---
+      config:
+        layout: dagre
+      ---
+      mindmap
+      ((This is a mindmap))
+        child1
+         grandchild 1
+         grandchild 2
+        child2
+         grandchild 3
+         grandchild 4
+        child3
+         grandchild 5
+         grandchild 6
+      
+    
+ +
+ ---
+      config:
+        layout: elk
+      ---
+      mindmap
+      ((This is a mindmap))
+        child1
+         grandchild 1
+         grandchild 2
+        child2
+         grandchild 3
+         grandchild 4
+        child3
+         grandchild 5
+         grandchild 6
+      
+    
+ +
+ ---
+      config:
+        layout: cose-bilkent
+      ---
+      mindmap
+      ((This is a mindmap))
+        child1
+         grandchild 1
+         grandchild 2
+        child2
+         grandchild 3
+         grandchild 4
+        child3
+         grandchild 5
+         grandchild 6
+      
+    
+ +
+ + + diff --git a/cypress/platform/viewer.js b/cypress/platform/viewer.js index 7ff95e163..de7dcafe8 100644 --- a/cypress/platform/viewer.js +++ b/cypress/platform/viewer.js @@ -1,5 +1,6 @@ import externalExample from './mermaid-example-diagram.esm.mjs'; import layouts from './mermaid-layout-elk.esm.mjs'; +import tidyTree from './mermaid-layout-tidy-tree.esm.mjs'; import zenUml from './mermaid-zenuml.esm.mjs'; import mermaid from './mermaid.esm.mjs'; @@ -65,6 +66,7 @@ const contentLoaded = async function () { await mermaid.registerExternalDiagrams([externalExample, zenUml]); mermaid.registerLayoutLoaders(layouts); + mermaid.registerLayoutLoaders(tidyTree); mermaid.initialize(graphObj.mermaid); /** * CC-BY-4.0 diff --git a/cypress/platform/yari.html b/cypress/platform/yari.html index 390218344..893d4c7b1 100644 --- a/cypress/platform/yari.html +++ b/cypress/platform/yari.html @@ -603,6 +603,10 @@
+          ---
+            config:
+              theme: dark
+          ---
           classDiagram
             test ()--() test2
         
diff --git a/cypress/timings.json b/cypress/timings.json index 86d5b5222..6635346b7 100644 --- a/cypress/timings.json +++ b/cypress/timings.json @@ -2,219 +2,227 @@ "durations": [ { "spec": "cypress/integration/other/configuration.spec.js", - "duration": 6297 + "duration": 6099 }, { "spec": "cypress/integration/other/external-diagrams.spec.js", - "duration": 2187 + "duration": 2236 }, { "spec": "cypress/integration/other/ghsa.spec.js", - "duration": 3509 + "duration": 3405 }, { "spec": "cypress/integration/other/iife.spec.js", - "duration": 2218 + "duration": 2176 }, { "spec": "cypress/integration/other/interaction.spec.js", - "duration": 12104 + "duration": 12300 }, { "spec": "cypress/integration/other/rerender.spec.js", - "duration": 2151 + "duration": 2089 }, { "spec": "cypress/integration/other/xss.spec.js", - "duration": 33064 + "duration": 32033 }, { "spec": "cypress/integration/rendering/appli.spec.js", - "duration": 3488 + "duration": 3672 }, { "spec": "cypress/integration/rendering/architecture.spec.ts", - "duration": 106 + "duration": 103 }, { "spec": "cypress/integration/rendering/block.spec.js", - "duration": 18317 + "duration": 18135 }, { "spec": "cypress/integration/rendering/c4.spec.js", - "duration": 5592 + "duration": 5661 }, { "spec": "cypress/integration/rendering/classDiagram-elk-v3.spec.js", - "duration": 39358 + "duration": 41456 }, { "spec": "cypress/integration/rendering/classDiagram-handDrawn-v3.spec.js", - "duration": 37160 + "duration": 38910 }, { "spec": "cypress/integration/rendering/classDiagram-v2.spec.js", - "duration": 23660 + "duration": 24120 }, { "spec": "cypress/integration/rendering/classDiagram-v3.spec.js", - "duration": 36866 + "duration": 38454 }, { "spec": "cypress/integration/rendering/classDiagram.spec.js", - "duration": 17334 + "duration": 17099 }, { "spec": "cypress/integration/rendering/conf-and-directives.spec.js", - "duration": 9871 + "duration": 9844 }, { "spec": "cypress/integration/rendering/current.spec.js", - "duration": 2833 + "duration": 2951 }, { "spec": "cypress/integration/rendering/erDiagram-unified.spec.js", - "duration": 85321 + "duration": 90081 }, { "spec": "cypress/integration/rendering/erDiagram.spec.js", - "duration": 15673 + "duration": 19496 }, { "spec": "cypress/integration/rendering/errorDiagram.spec.js", - "duration": 3724 + "duration": 3829 }, { "spec": "cypress/integration/rendering/flowchart-elk.spec.js", - "duration": 41178 + "duration": 42517 }, { "spec": "cypress/integration/rendering/flowchart-handDrawn.spec.js", - "duration": 29966 + "duration": 31541 }, { "spec": "cypress/integration/rendering/flowchart-icon.spec.js", - "duration": 7689 + "duration": 7749 }, { "spec": "cypress/integration/rendering/flowchart-shape-alias.spec.ts", - "duration": 24709 + "duration": 25230 }, { "spec": "cypress/integration/rendering/flowchart-v2.spec.js", - "duration": 45565 + "duration": 49359 }, { "spec": "cypress/integration/rendering/flowchart.spec.js", - "duration": 31144 + "duration": 33028 }, { "spec": "cypress/integration/rendering/gantt.spec.js", - "duration": 20808 + "duration": 22271 }, { "spec": "cypress/integration/rendering/gitGraph.spec.js", - "duration": 49985 + "duration": 51837 }, { "spec": "cypress/integration/rendering/iconShape.spec.ts", - "duration": 273272 + "duration": 285060 }, { "spec": "cypress/integration/rendering/imageShape.spec.ts", - "duration": 55880 + "duration": 59517 }, { "spec": "cypress/integration/rendering/info.spec.ts", - "duration": 3271 + "duration": 3501 }, { "spec": "cypress/integration/rendering/journey.spec.js", - "duration": 7293 + "duration": 7405 }, { "spec": "cypress/integration/rendering/kanban.spec.ts", - "duration": 7861 + "duration": 7975 }, { "spec": "cypress/integration/rendering/katex.spec.js", - "duration": 3922 + "duration": 4312 }, { "spec": "cypress/integration/rendering/marker_unique_id.spec.js", - "duration": 2726 + "duration": 2630 + }, + { + "spec": "cypress/integration/rendering/mindmap-tidy-tree.spec.js", + "duration": 4541 }, { "spec": "cypress/integration/rendering/mindmap.spec.ts", - "duration": 11670 + "duration": 12134 }, { "spec": "cypress/integration/rendering/newShapes.spec.ts", - "duration": 146020 + "duration": 151160 }, { "spec": "cypress/integration/rendering/oldShapes.spec.ts", - "duration": 114244 + "duration": 118044 }, { "spec": "cypress/integration/rendering/packet.spec.ts", - "duration": 5036 + "duration": 5166 }, { "spec": "cypress/integration/rendering/pie.spec.ts", - "duration": 6545 + "duration": 7074 }, { "spec": "cypress/integration/rendering/quadrantChart.spec.js", - "duration": 9097 + "duration": 9518 }, { "spec": "cypress/integration/rendering/radar.spec.js", - "duration": 5676 + "duration": 5846 }, { "spec": "cypress/integration/rendering/requirement.spec.js", - "duration": 2795 + "duration": 3089 }, { "spec": "cypress/integration/rendering/requirementDiagram-unified.spec.js", - "duration": 51660 + "duration": 55361 }, { "spec": "cypress/integration/rendering/sankey.spec.ts", - "duration": 6957 + "duration": 7236 + }, + { + "spec": "cypress/integration/rendering/sequencediagram-v2.spec.js", + "duration": 26057 }, { "spec": "cypress/integration/rendering/sequencediagram.spec.js", - "duration": 36026 + "duration": 48401 }, { "spec": "cypress/integration/rendering/stateDiagram-v2.spec.js", - "duration": 29551 + "duration": 30364 }, { "spec": "cypress/integration/rendering/stateDiagram.spec.js", - "duration": 17364 + "duration": 16862 }, { "spec": "cypress/integration/rendering/theme.spec.js", - "duration": 30209 + "duration": 30553 }, { "spec": "cypress/integration/rendering/timeline.spec.ts", - "duration": 8699 + "duration": 8962 }, { "spec": "cypress/integration/rendering/treemap.spec.ts", - "duration": 12168 + "duration": 12486 }, { "spec": "cypress/integration/rendering/xyChart.spec.js", - "duration": 21453 + "duration": 21718 }, { "spec": "cypress/integration/rendering/zenuml.spec.js", - "duration": 3577 + "duration": 3882 } ] } diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index b01ce9bac..1c0202c62 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "es2020", "lib": ["es2020", "dom"], - "types": ["cypress", "node", "@argos-ci/cypress/dist/support.d.ts"], + "types": ["cypress", "node", "@argos-ci/cypress/support"], "allowImportingTsExtensions": true, "noEmit": true }, diff --git a/demos/classchart.html b/demos/classchart.html index 10d8e6b70..7f285533a 100644 --- a/demos/classchart.html +++ b/demos/classchart.html @@ -184,6 +184,7 @@ } Admin --> Report : generates +
     classDiagram
       namespace Company.Project.Module {
@@ -240,6 +241,20 @@
       Bike --> Square : "Logo Shape"
 
     
+
+
+      classDiagram
+        note "This is a outer note"
+        note for Class1 "This is a outer note for Class1"
+        namespace ns {
+          note "This is a inner note"
+          note for Class1 "This is a inner note for Class1"
+          class Class1
+          class Class2
+        }
+    
+
+ +``` + +## Tidy Tree Layout Overview + +tidy-tree: The bidirectional tidy tree layout + +The bidirectional tidy tree layout algorithm creates two separate trees that grow horizontally in opposite directions from a central root node: +Left tree: grows horizontally to the left (children alternate: 1st, 3rd, 5th...) +Right tree: grows horizontally to the right (children alternate: 2nd, 4th, 6th...) + +This creates a balanced, symmetric layout that is ideal for mindmaps, organizational charts, and other tree-based diagrams. + +Layout Structure: +[Child 3] ← [Child 1] ← [Root] → [Child 2] → [Child 4] +↓ ↓ ↓ ↓ +[GrandChild] [GrandChild] [GrandChild] [GrandChild] diff --git a/packages/mermaid-layout-tidy-tree/package.json b/packages/mermaid-layout-tidy-tree/package.json new file mode 100644 index 000000000..2abaaa211 --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/package.json @@ -0,0 +1,48 @@ +{ + "name": "@mermaid-js/layout-tidy-tree", + "version": "0.2.0", + "description": "Tidy-tree layout engine for mermaid", + "module": "dist/mermaid-layout-tidy-tree.core.mjs", + "types": "dist/layouts.d.ts", + "type": "module", + "exports": { + ".": { + "import": "./dist/mermaid-layout-tidy-tree.core.mjs", + "types": "./dist/layouts.d.ts" + }, + "./": "./" + }, + "keywords": [ + "diagram", + "markdown", + "tidy-tree", + "mermaid", + "layout" + ], + "scripts": { + "clean": "rimraf dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/mermaid-js/mermaid" + }, + "contributors": [ + "Knut Sveidqvist", + "Sidharth Vinod" + ], + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "non-layered-tidy-tree-layout": "^2.0.2" + }, + "devDependencies": { + "@types/d3": "^7.4.3", + "mermaid": "workspace:^" + }, + "peerDependencies": { + "mermaid": "^11.0.2" + }, + "files": [ + "dist" + ] +} diff --git a/packages/mermaid-layout-tidy-tree/src/index.ts b/packages/mermaid-layout-tidy-tree/src/index.ts new file mode 100644 index 000000000..2be1b59e6 --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/src/index.ts @@ -0,0 +1,50 @@ +/** + * Bidirectional Tidy-Tree Layout Algorithm for Generic Diagrams + * + * This module provides a layout algorithm implementation using the + * non-layered-tidy-tree-layout algorithm for positioning nodes and edges + * in tree structures with a bidirectional approach. + * + * The algorithm creates two separate trees that grow horizontally in opposite + * directions from a central root node: + * - Left tree: grows horizontally to the left (children alternate: 1st, 3rd, 5th...) + * - Right tree: grows horizontally to the right (children alternate: 2nd, 4th, 6th...) + * + * This creates a balanced, symmetric layout that is ideal for mindmaps, + * organizational charts, and other tree-based diagrams. + * + * The algorithm follows the unified rendering pattern and can be used + * by any diagram type that provides compatible LayoutData. + */ + +/** + * Render function for the bidirectional tidy-tree layout algorithm + * + * This function follows the unified rendering pattern used by all layout algorithms. + * It takes LayoutData, inserts nodes into DOM, runs the bidirectional tidy-tree layout algorithm, + * and renders the positioned elements to the SVG. + * + * Features: + * - Alternates root children between left and right trees + * - Left tree grows horizontally to the left (rotated 90° counterclockwise) + * - Right tree grows horizontally to the right (rotated 90° clockwise) + * - Uses tidy-tree algorithm for optimal spacing within each tree + * - Creates symmetric, balanced layouts + * - Maintains proper edge connections between all nodes + * + * Layout Structure: + * ``` + * [Child 3] ← [Child 1] ← [Root] → [Child 2] → [Child 4] + * ↓ ↓ ↓ ↓ + * [GrandChild] [GrandChild] [GrandChild] [GrandChild] + * ``` + * + * @param layoutData - Layout data containing nodes, edges, and configuration + * @param svg - SVG element to render to + * @param helpers - Internal helper functions for rendering + * @param options - Rendering options + */ +export { default } from './layouts.js'; +export * from './types.js'; +export * from './layout.js'; +export { render } from './render.js'; diff --git a/packages/mermaid-layout-tidy-tree/src/layout.test.ts b/packages/mermaid-layout-tidy-tree/src/layout.test.ts new file mode 100644 index 000000000..2b3b79b37 --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/src/layout.test.ts @@ -0,0 +1,409 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { executeTidyTreeLayout, validateLayoutData } from './layout.js'; +import type { LayoutResult } from './types.js'; +import type { LayoutData, MermaidConfig } from 'mermaid'; + +// Mock non-layered-tidy-tree-layout +vi.mock('non-layered-tidy-tree-layout', () => ({ + BoundingBox: vi.fn().mockImplementation(() => ({})), + Layout: vi.fn().mockImplementation(() => ({ + layout: vi.fn().mockImplementation((treeData) => { + const result = { ...treeData }; + + if (result.id?.toString().startsWith('virtual-root')) { + result.x = 0; + result.y = 0; + } else { + result.x = 100; + result.y = 50; + } + + if (result.children) { + result.children.forEach((child: any, index: number) => { + child.x = 50 + index * 100; + child.y = 100; + + if (child.children) { + child.children.forEach((grandchild: any, gIndex: number) => { + grandchild.x = 25 + gIndex * 50; + grandchild.y = 200; + }); + } + }); + } + + return { + result, + boundingBox: { + left: 0, + right: 200, + top: 0, + bottom: 250, + }, + }; + }), + })), +})); + +describe('Tidy-Tree Layout Algorithm', () => { + let mockConfig: MermaidConfig; + let mockLayoutData: LayoutData; + + beforeEach(() => { + mockConfig = { + theme: 'default', + } as MermaidConfig; + + mockLayoutData = { + nodes: [ + { + id: 'root', + label: 'Root', + isGroup: false, + shape: 'rect', + width: 100, + height: 50, + padding: 10, + x: 0, + y: 0, + cssClasses: '', + cssStyles: [], + look: 'default', + }, + { + id: 'child1', + label: 'Child 1', + isGroup: false, + shape: 'rect', + width: 80, + height: 40, + padding: 10, + x: 0, + y: 0, + cssClasses: '', + cssStyles: [], + look: 'default', + }, + { + id: 'child2', + label: 'Child 2', + isGroup: false, + shape: 'rect', + width: 80, + height: 40, + padding: 10, + x: 0, + y: 0, + cssClasses: '', + cssStyles: [], + look: 'default', + }, + { + id: 'child3', + label: 'Child 3', + isGroup: false, + shape: 'rect', + width: 80, + height: 40, + padding: 10, + x: 0, + y: 0, + cssClasses: '', + cssStyles: [], + look: 'default', + }, + { + id: 'child4', + label: 'Child 4', + isGroup: false, + shape: 'rect', + width: 80, + height: 40, + padding: 10, + x: 0, + y: 0, + cssClasses: '', + cssStyles: [], + look: 'default', + }, + ], + edges: [ + { + id: 'root_child1', + start: 'root', + end: 'child1', + type: 'edge', + classes: '', + style: [], + animate: false, + arrowTypeEnd: 'arrow_point', + arrowTypeStart: 'none', + }, + { + id: 'root_child2', + start: 'root', + end: 'child2', + type: 'edge', + classes: '', + style: [], + animate: false, + arrowTypeEnd: 'arrow_point', + arrowTypeStart: 'none', + }, + { + id: 'root_child3', + start: 'root', + end: 'child3', + type: 'edge', + classes: '', + style: [], + animate: false, + arrowTypeEnd: 'arrow_point', + arrowTypeStart: 'none', + }, + { + id: 'root_child4', + start: 'root', + end: 'child4', + type: 'edge', + classes: '', + style: [], + animate: false, + arrowTypeEnd: 'arrow_point', + arrowTypeStart: 'none', + }, + ], + config: mockConfig, + direction: 'TB', + type: 'test', + diagramId: 'test-diagram', + markers: [], + }; + }); + + describe('validateLayoutData', () => { + it('should validate correct layout data', () => { + expect(() => validateLayoutData(mockLayoutData)).not.toThrow(); + }); + + it('should throw error for missing data', () => { + expect(() => validateLayoutData(null as any)).toThrow('Layout data is required'); + }); + + it('should throw error for missing config', () => { + const invalidData = { ...mockLayoutData, config: null as any }; + expect(() => validateLayoutData(invalidData)).toThrow('Configuration is required'); + }); + + it('should throw error for invalid nodes array', () => { + const invalidData = { ...mockLayoutData, nodes: null as any }; + expect(() => validateLayoutData(invalidData)).toThrow('Nodes array is required'); + }); + + it('should throw error for invalid edges array', () => { + const invalidData = { ...mockLayoutData, edges: null as any }; + expect(() => validateLayoutData(invalidData)).toThrow('Edges array is required'); + }); + }); + + describe('executeTidyTreeLayout function', () => { + it('should execute layout algorithm successfully', async () => { + const result: LayoutResult = await executeTidyTreeLayout(mockLayoutData); + + expect(result).toBeDefined(); + expect(result.nodes).toBeDefined(); + expect(result.edges).toBeDefined(); + expect(Array.isArray(result.nodes)).toBe(true); + expect(Array.isArray(result.edges)).toBe(true); + }); + + it('should return positioned nodes with coordinates', async () => { + const result: LayoutResult = await executeTidyTreeLayout(mockLayoutData); + + expect(result.nodes.length).toBeGreaterThan(0); + result.nodes.forEach((node) => { + expect(node.x).toBeDefined(); + expect(node.y).toBeDefined(); + expect(typeof node.x).toBe('number'); + expect(typeof node.y).toBe('number'); + }); + }); + + it('should return positioned edges with coordinates', async () => { + const result: LayoutResult = await executeTidyTreeLayout(mockLayoutData); + + expect(result.edges.length).toBeGreaterThan(0); + result.edges.forEach((edge) => { + expect(edge.startX).toBeDefined(); + expect(edge.startY).toBeDefined(); + expect(edge.midX).toBeDefined(); + expect(edge.midY).toBeDefined(); + expect(edge.endX).toBeDefined(); + expect(edge.endY).toBeDefined(); + }); + }); + + it('should handle empty layout data gracefully', async () => { + const emptyData: LayoutData = { + ...mockLayoutData, + nodes: [], + edges: [], + }; + + await expect(executeTidyTreeLayout(emptyData)).rejects.toThrow( + 'No nodes found in layout data' + ); + }); + + it('should throw error for missing nodes', async () => { + const invalidData = { ...mockLayoutData, nodes: [] }; + + await expect(executeTidyTreeLayout(invalidData)).rejects.toThrow( + 'No nodes found in layout data' + ); + }); + + it('should handle empty edges (single node tree)', async () => { + const singleNodeData = { + ...mockLayoutData, + edges: [], + nodes: [mockLayoutData.nodes[0]], + }; + + const result = await executeTidyTreeLayout(singleNodeData); + expect(result).toBeDefined(); + expect(result.nodes).toHaveLength(1); + expect(result.edges).toHaveLength(0); + }); + + it('should create bidirectional dual-tree layout with alternating left/right children', async () => { + const result = await executeTidyTreeLayout(mockLayoutData); + + expect(result).toBeDefined(); + expect(result.nodes).toHaveLength(5); + + const rootNode = result.nodes.find((node) => node.id === 'root'); + expect(rootNode).toBeDefined(); + expect(rootNode!.x).toBe(0); + expect(rootNode!.y).toBe(20); + + const child1 = result.nodes.find((node) => node.id === 'child1'); + const child2 = result.nodes.find((node) => node.id === 'child2'); + const child3 = result.nodes.find((node) => node.id === 'child3'); + const child4 = result.nodes.find((node) => node.id === 'child4'); + + expect(child1).toBeDefined(); + expect(child2).toBeDefined(); + expect(child3).toBeDefined(); + expect(child4).toBeDefined(); + + expect(child1!.x).toBeLessThan(rootNode!.x); + expect(child2!.x).toBeGreaterThan(rootNode!.x); + expect(child3!.x).toBeLessThan(rootNode!.x); + expect(child4!.x).toBeGreaterThan(rootNode!.x); + + expect(child1!.x).toBeLessThan(-100); + expect(child3!.x).toBeLessThan(-100); + + expect(child2!.x).toBeGreaterThan(100); + expect(child4!.x).toBeGreaterThan(100); + }); + + it('should correctly transpose coordinates to prevent high nodes from covering nodes above them', async () => { + const testData = { + ...mockLayoutData, + nodes: [ + { + id: 'root', + label: 'Root', + isGroup: false, + shape: 'rect' as const, + width: 100, + height: 50, + padding: 10, + x: 0, + y: 0, + cssClasses: '', + cssStyles: [], + look: 'default', + }, + { + id: 'tall-child', + label: 'Tall Child', + isGroup: false, + shape: 'rect' as const, + width: 80, + height: 120, + padding: 10, + x: 0, + y: 0, + cssClasses: '', + cssStyles: [], + look: 'default', + }, + { + id: 'short-child', + label: 'Short Child', + isGroup: false, + shape: 'rect' as const, + width: 80, + height: 30, + padding: 10, + x: 0, + y: 0, + cssClasses: '', + cssStyles: [], + look: 'default', + }, + ], + edges: [ + { + id: 'root_tall', + start: 'root', + end: 'tall-child', + type: 'edge', + classes: '', + style: [], + animate: false, + arrowTypeEnd: 'arrow_point', + arrowTypeStart: 'none', + }, + { + id: 'root_short', + start: 'root', + end: 'short-child', + type: 'edge', + classes: '', + style: [], + animate: false, + arrowTypeEnd: 'arrow_point', + arrowTypeStart: 'none', + }, + ], + }; + + const result = await executeTidyTreeLayout(testData); + + expect(result).toBeDefined(); + expect(result.nodes).toHaveLength(3); + + const rootNode = result.nodes.find((node) => node.id === 'root'); + const tallChild = result.nodes.find((node) => node.id === 'tall-child'); + const shortChild = result.nodes.find((node) => node.id === 'short-child'); + + expect(rootNode).toBeDefined(); + expect(tallChild).toBeDefined(); + expect(shortChild).toBeDefined(); + + expect(tallChild!.x).not.toBe(shortChild!.x); + + expect(tallChild!.width).toBe(80); + expect(tallChild!.height).toBe(120); + expect(shortChild!.width).toBe(80); + expect(shortChild!.height).toBe(30); + + const yDifference = Math.abs(tallChild!.y - shortChild!.y); + expect(yDifference).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/packages/mermaid-layout-tidy-tree/src/layout.ts b/packages/mermaid-layout-tidy-tree/src/layout.ts new file mode 100644 index 000000000..6cc06a9ab --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/src/layout.ts @@ -0,0 +1,629 @@ +import type { LayoutData } from 'mermaid'; +import type { Bounds, Point } from 'mermaid/src/types.js'; +import { BoundingBox, Layout } from 'non-layered-tidy-tree-layout'; +import type { + Edge, + LayoutResult, + Node, + PositionedEdge, + PositionedNode, + TidyTreeNode, +} from './types.js'; + +/** + * Execute the tidy-tree layout algorithm on generic layout data + * + * This function takes layout data and uses the non-layered-tidy-tree-layout + * algorithm to calculate optimal node positions for tree structures. + * + * @param data - The layout data containing nodes, edges, and configuration + * @param config - Mermaid configuration object + * @returns Promise resolving to layout result with positioned nodes and edges + */ +export function executeTidyTreeLayout(data: LayoutData): Promise { + let intersectionShift = 50; + + return new Promise((resolve, reject) => { + try { + if (!data.nodes || !Array.isArray(data.nodes) || data.nodes.length === 0) { + throw new Error('No nodes found in layout data'); + } + + if (!data.edges || !Array.isArray(data.edges)) { + data.edges = []; + } + + const { leftTree, rightTree, rootNode } = convertToDualTreeFormat(data); + + const gap = 20; + const bottomPadding = 40; + intersectionShift = 30; + + const bb = new BoundingBox(gap, bottomPadding); + const layout = new Layout(bb); + + let leftResult = null; + let rightResult = null; + + if (leftTree) { + const leftLayoutResult = layout.layout(leftTree); + leftResult = leftLayoutResult.result; + } + + if (rightTree) { + const rightLayoutResult = layout.layout(rightTree); + rightResult = rightLayoutResult.result; + } + + const positionedNodes = combineAndPositionTrees(rootNode, leftResult, rightResult); + const positionedEdges = calculateEdgePositions( + data.edges, + positionedNodes, + intersectionShift + ); + resolve({ + nodes: positionedNodes, + edges: positionedEdges, + }); + } catch (error) { + reject(error); + } + }); +} + +/** + * Convert LayoutData to dual-tree format (left and right trees) + * + * This function builds two separate tree structures from the nodes and edges, + * alternating children between left and right trees. + */ +function convertToDualTreeFormat(data: LayoutData): { + leftTree: TidyTreeNode | null; + rightTree: TidyTreeNode | null; + rootNode: TidyTreeNode; +} { + const { nodes, edges } = data; + + const nodeMap = new Map(); + nodes.forEach((node) => nodeMap.set(node.id, node)); + + const children = new Map(); + const parents = new Map(); + + edges.forEach((edge) => { + const parentId = edge.start; + const childId = edge.end; + + if (parentId && childId) { + if (!children.has(parentId)) { + children.set(parentId, []); + } + children.get(parentId)!.push(childId); + parents.set(childId, parentId); + } + }); + + const rootNodeData = nodes.find((node) => !parents.has(node.id)); + if (!rootNodeData && nodes.length === 0) { + throw new Error('No nodes available to create tree'); + } + + const actualRoot = rootNodeData ?? nodes[0]; + + const rootNode: TidyTreeNode = { + id: actualRoot.id, + width: actualRoot.width ?? 100, + height: actualRoot.height ?? 50, + _originalNode: actualRoot, + }; + + const rootChildren = children.get(actualRoot.id) ?? []; + const leftChildren: string[] = []; + const rightChildren: string[] = []; + + rootChildren.forEach((childId, index) => { + if (index % 2 === 0) { + leftChildren.push(childId); + } else { + rightChildren.push(childId); + } + }); + + const leftTree = leftChildren.length > 0 ? buildSubTree(leftChildren, children, nodeMap) : null; + + const rightTree = + rightChildren.length > 0 ? buildSubTree(rightChildren, children, nodeMap) : null; + + return { leftTree, rightTree, rootNode }; +} + +/** + * Build a subtree from a list of root children + * For horizontal trees, we need to transpose width/height since the tree will be rotated 90° + */ +function buildSubTree( + rootChildren: string[], + children: Map, + nodeMap: Map +): TidyTreeNode { + const virtualRoot: TidyTreeNode = { + id: `virtual-root-${Math.random()}`, + width: 1, + height: 1, + children: rootChildren + .map((childId) => nodeMap.get(childId)) + .filter((child): child is Node => child !== undefined) + .map((child) => convertNodeToTidyTreeTransposed(child, children, nodeMap)), + }; + + return virtualRoot; +} + +/** + * Recursively convert a node and its children to tidy-tree format + * This version transposes width/height for horizontal tree layout + */ +function convertNodeToTidyTreeTransposed( + node: Node, + children: Map, + nodeMap: Map +): TidyTreeNode { + const childIds = children.get(node.id) ?? []; + const childNodes = childIds + .map((childId) => nodeMap.get(childId)) + .filter((child): child is Node => child !== undefined) + .map((child) => convertNodeToTidyTreeTransposed(child, children, nodeMap)); + + return { + id: node.id, + width: node.height ?? 50, + height: node.width ?? 100, + children: childNodes.length > 0 ? childNodes : undefined, + _originalNode: node, + }; +} +/** + * Combine and position the left and right trees around the root node + * Creates a bidirectional layout where left tree grows left and right tree grows right + */ +function combineAndPositionTrees( + rootNode: TidyTreeNode, + leftResult: TidyTreeNode | null, + rightResult: TidyTreeNode | null +): PositionedNode[] { + const positionedNodes: PositionedNode[] = []; + + const rootX = 0; + const rootY = 0; + + const treeSpacing = rootNode.width / 2 + 30; + const leftTreeNodes: PositionedNode[] = []; + const rightTreeNodes: PositionedNode[] = []; + + if (leftResult?.children) { + positionLeftTreeBidirectional(leftResult.children, leftTreeNodes, rootX - treeSpacing, rootY); + } + + if (rightResult?.children) { + positionRightTreeBidirectional( + rightResult.children, + rightTreeNodes, + rootX + treeSpacing, + rootY + ); + } + + let leftTreeCenterY = 0; + let rightTreeCenterY = 0; + + if (leftTreeNodes.length > 0) { + const leftTreeXPositions = [...new Set(leftTreeNodes.map((node) => node.x))].sort( + (a, b) => b - a + ); + const firstLevelLeftX = leftTreeXPositions[0]; + const firstLevelLeftNodes = leftTreeNodes.filter((node) => node.x === firstLevelLeftX); + + if (firstLevelLeftNodes.length > 0) { + const leftMinY = Math.min( + ...firstLevelLeftNodes.map((node) => node.y - (node.height ?? 50) / 2) + ); + const leftMaxY = Math.max( + ...firstLevelLeftNodes.map((node) => node.y + (node.height ?? 50) / 2) + ); + leftTreeCenterY = (leftMinY + leftMaxY) / 2; + } + } + + if (rightTreeNodes.length > 0) { + const rightTreeXPositions = [...new Set(rightTreeNodes.map((node) => node.x))].sort( + (a, b) => a - b + ); + const firstLevelRightX = rightTreeXPositions[0]; + const firstLevelRightNodes = rightTreeNodes.filter((node) => node.x === firstLevelRightX); + + if (firstLevelRightNodes.length > 0) { + const rightMinY = Math.min( + ...firstLevelRightNodes.map((node) => node.y - (node.height ?? 50) / 2) + ); + const rightMaxY = Math.max( + ...firstLevelRightNodes.map((node) => node.y + (node.height ?? 50) / 2) + ); + rightTreeCenterY = (rightMinY + rightMaxY) / 2; + } + } + + const leftTreeOffset = -leftTreeCenterY; + const rightTreeOffset = -rightTreeCenterY; + + positionedNodes.push({ + id: String(rootNode.id), + x: rootX, + y: rootY + 20, + section: 'root', + width: rootNode._originalNode?.width ?? rootNode.width, + height: rootNode._originalNode?.height ?? rootNode.height, + originalNode: rootNode._originalNode, + }); + + const leftTreeNodesWithOffset = leftTreeNodes.map((node) => ({ + id: node.id, + x: node.x - (node.width ?? 0) / 2, + y: node.y + leftTreeOffset + (node.height ?? 0) / 2, + section: 'left' as const, + width: node.width, + height: node.height, + originalNode: node.originalNode, + })); + + const rightTreeNodesWithOffset = rightTreeNodes.map((node) => ({ + id: node.id, + x: node.x + (node.width ?? 0) / 2, + y: node.y + rightTreeOffset + (node.height ?? 0) / 2, + section: 'right' as const, + width: node.width, + height: node.height, + originalNode: node.originalNode, + })); + + positionedNodes.push(...leftTreeNodesWithOffset); + positionedNodes.push(...rightTreeNodesWithOffset); + + return positionedNodes; +} + +/** + * Position nodes from the left tree in a bidirectional layout (grows to the left) + * Rotates the tree 90 degrees counterclockwise so it grows horizontally to the left + */ +function positionLeftTreeBidirectional( + nodes: TidyTreeNode[], + positionedNodes: PositionedNode[], + offsetX: number, + offsetY: number +): void { + nodes.forEach((node) => { + const distanceFromRoot = node.y ?? 0; + const verticalPosition = node.x ?? 0; + + const originalWidth = node._originalNode?.width ?? 100; + const originalHeight = node._originalNode?.height ?? 50; + + const adjustedY = offsetY + verticalPosition; + + positionedNodes.push({ + id: String(node.id), + x: offsetX - distanceFromRoot, + y: adjustedY, + width: originalWidth, + height: originalHeight, + originalNode: node._originalNode, + }); + + if (node.children) { + positionLeftTreeBidirectional(node.children, positionedNodes, offsetX, offsetY); + } + }); +} + +/** + * Position nodes from the right tree in a bidirectional layout (grows to the right) + * Rotates the tree 90 degrees clockwise so it grows horizontally to the right + */ +function positionRightTreeBidirectional( + nodes: TidyTreeNode[], + positionedNodes: PositionedNode[], + offsetX: number, + offsetY: number +): void { + nodes.forEach((node) => { + const distanceFromRoot = node.y ?? 0; + const verticalPosition = node.x ?? 0; + + const originalWidth = node._originalNode?.width ?? 100; + const originalHeight = node._originalNode?.height ?? 50; + + const adjustedY = offsetY + verticalPosition; + + positionedNodes.push({ + id: String(node.id), + x: offsetX + distanceFromRoot, + y: adjustedY, + width: originalWidth, + height: originalHeight, + originalNode: node._originalNode, + }); + + if (node.children) { + positionRightTreeBidirectional(node.children, positionedNodes, offsetX, offsetY); + } + }); +} + +/** + * Calculate the intersection point of a line with a circle + * @param circle - Circle coordinates and radius + * @param lineStart - Starting point of the line + * @param lineEnd - Ending point of the line + * @returns The intersection point + */ +function computeCircleEdgeIntersection(circle: Bounds, lineStart: Point, lineEnd: Point): Point { + const radius = Math.min(circle.width, circle.height) / 2; + + const dx = lineEnd.x - lineStart.x; + const dy = lineEnd.y - lineStart.y; + const length = Math.sqrt(dx * dx + dy * dy); + + if (length === 0) { + return lineStart; + } + + const nx = dx / length; + const ny = dy / length; + + return { + x: circle.x - nx * radius, + y: circle.y - ny * radius, + }; +} + +function intersection(node: PositionedNode, outsidePoint: Point, insidePoint: Point): Point { + const x = node.x; + const y = node.y; + + if (!node.width || !node.height) { + return { x: outsidePoint.x, y: outsidePoint.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, + }; + + if (r === 0) { + res.x = outsidePoint.x; + res.y = outsidePoint.y; + } + if (R === 0) { + res.x = outsidePoint.x; + } + if (Q === 0) { + res.y = outsidePoint.y; + } + + return res; + } else { + 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; + + if (r === 0) { + _x = outsidePoint.x; + _y = outsidePoint.y; + } + if (R === 0) { + _x = outsidePoint.x; + } + if (Q === 0) { + _y = outsidePoint.y; + } + + return { x: _x, y: _y }; + } +} + +/** + * Calculate edge positions based on positioned nodes + * Now includes tree membership and node dimensions for precise edge calculations + * Edges now stop at shape boundaries instead of extending to centers + */ +function calculateEdgePositions( + edges: Edge[], + positionedNodes: PositionedNode[], + intersectionShift: number +): PositionedEdge[] { + const nodeInfo = new Map(); + positionedNodes.forEach((node) => { + nodeInfo.set(node.id, node); + }); + + return edges.map((edge) => { + const sourceNode = nodeInfo.get(edge.start ?? ''); + const targetNode = nodeInfo.get(edge.end ?? ''); + + if (!sourceNode || !targetNode) { + return { + id: edge.id, + source: edge.start ?? '', + target: edge.end ?? '', + startX: 0, + startY: 0, + midX: 0, + midY: 0, + endX: 0, + endY: 0, + points: [{ x: 0, y: 0 }], + sourceSection: undefined, + targetSection: undefined, + sourceWidth: undefined, + sourceHeight: undefined, + targetWidth: undefined, + targetHeight: undefined, + }; + } + + const sourceCenter = { x: sourceNode.x, y: sourceNode.y }; + const targetCenter = { x: targetNode.x, y: targetNode.y }; + + const isSourceRound = ['circle', 'cloud', 'bang'].includes( + sourceNode.originalNode?.shape ?? '' + ); + const isTargetRound = ['circle', 'cloud', 'bang'].includes( + targetNode.originalNode?.shape ?? '' + ); + + let startPos = isSourceRound + ? computeCircleEdgeIntersection( + { + x: sourceNode.x, + y: sourceNode.y, + width: sourceNode.width ?? 100, + height: sourceNode.height ?? 100, + }, + targetCenter, + sourceCenter + ) + : intersection(sourceNode, sourceCenter, targetCenter); + + let endPos = isTargetRound + ? computeCircleEdgeIntersection( + { + x: targetNode.x, + y: targetNode.y, + width: targetNode.width ?? 100, + height: targetNode.height ?? 100, + }, + sourceCenter, + targetCenter + ) + : intersection(targetNode, targetCenter, sourceCenter); + + const midX = (startPos.x + endPos.x) / 2; + const midY = (startPos.y + endPos.y) / 2; + + const points = [startPos]; + if (sourceNode.section === 'left') { + points.push({ + x: sourceNode.x - (sourceNode.width ?? 0) / 2 - intersectionShift, + y: sourceNode.y, + }); + } else if (sourceNode.section === 'right') { + points.push({ + x: sourceNode.x + (sourceNode.width ?? 0) / 2 + intersectionShift, + y: sourceNode.y, + }); + } + if (targetNode.section === 'left') { + points.push({ + x: targetNode.x + (targetNode.width ?? 0) / 2 + intersectionShift, + y: targetNode.y, + }); + } else if (targetNode.section === 'right') { + points.push({ + x: targetNode.x - (targetNode.width ?? 0) / 2 - intersectionShift, + y: targetNode.y, + }); + } + + points.push(endPos); + + const secondPoint = points.length > 1 ? points[1] : targetCenter; + startPos = isSourceRound + ? computeCircleEdgeIntersection( + { + x: sourceNode.x, + y: sourceNode.y, + width: sourceNode.width ?? 100, + height: sourceNode.height ?? 100, + }, + secondPoint, + sourceCenter + ) + : intersection(sourceNode, secondPoint, sourceCenter); + points[0] = startPos; + + const secondLastPoint = points.length > 1 ? points[points.length - 2] : sourceCenter; + endPos = isTargetRound + ? computeCircleEdgeIntersection( + { + x: targetNode.x, + y: targetNode.y, + width: targetNode.width ?? 100, + height: targetNode.height ?? 100, + }, + secondLastPoint, + targetCenter + ) + : intersection(targetNode, secondLastPoint, targetCenter); + points[points.length - 1] = endPos; + + return { + id: edge.id, + source: edge.start ?? '', + target: edge.end ?? '', + startX: startPos.x, + startY: startPos.y, + midX, + midY, + endX: endPos.x, + endY: endPos.y, + points, + sourceSection: sourceNode?.section, + targetSection: targetNode?.section, + sourceWidth: sourceNode?.width, + sourceHeight: sourceNode?.height, + targetWidth: targetNode?.width, + targetHeight: targetNode?.height, + }; + }); +} + +/** + * Validate layout data structure + * @param data - The data to validate + * @returns True if data is valid, throws error otherwise + */ +export function validateLayoutData(data: LayoutData): boolean { + if (!data) { + throw new Error('Layout data is required'); + } + + if (!data.config) { + throw new Error('Configuration is required in layout data'); + } + + if (!Array.isArray(data.nodes)) { + throw new Error('Nodes array is required in layout data'); + } + + if (!Array.isArray(data.edges)) { + throw new Error('Edges array is required in layout data'); + } + + return true; +} diff --git a/packages/mermaid-layout-tidy-tree/src/layouts.ts b/packages/mermaid-layout-tidy-tree/src/layouts.ts new file mode 100644 index 000000000..d5eac8992 --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/src/layouts.ts @@ -0,0 +1,13 @@ +import type { LayoutLoaderDefinition } from 'mermaid'; + +const loader = async () => await import(`./render.js`); + +const tidyTreeLayout: LayoutLoaderDefinition[] = [ + { + name: 'tidy-tree', + loader, + algorithm: 'tidy-tree', + }, +]; + +export default tidyTreeLayout; diff --git a/packages/mermaid-layout-tidy-tree/src/non-layered-tidy-tree-layout.d.ts b/packages/mermaid-layout-tidy-tree/src/non-layered-tidy-tree-layout.d.ts new file mode 100644 index 000000000..248b5c05f --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/src/non-layered-tidy-tree-layout.d.ts @@ -0,0 +1,18 @@ +declare module 'non-layered-tidy-tree-layout' { + export class BoundingBox { + constructor(gap: number, bottomPadding: number); + } + + export class Layout { + constructor(boundingBox: BoundingBox); + layout(data: any): { + result: any; + boundingBox: { + left: number; + right: number; + top: number; + bottom: number; + }; + }; + } +} diff --git a/packages/mermaid-layout-tidy-tree/src/render.ts b/packages/mermaid-layout-tidy-tree/src/render.ts new file mode 100644 index 000000000..4ce5e1deb --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/src/render.ts @@ -0,0 +1,180 @@ +import type { InternalHelpers, LayoutData, RenderOptions, SVG } from 'mermaid'; +import { executeTidyTreeLayout } from './layout.js'; + +interface NodeWithPosition { + id: string; + x?: number; + y?: number; + width?: number; + height?: number; + domId?: any; + [key: string]: any; +} + +/** + * Render function for bidirectional tidy-tree layout algorithm + * + * This follows the same pattern as ELK and dagre renderers: + * 1. Insert nodes into DOM to get their actual dimensions + * 2. Run the bidirectional tidy-tree layout algorithm to calculate positions + * 3. Position the nodes and edges based on layout results + * + * The bidirectional layout creates two trees that grow horizontally in opposite + * directions from a central root node: + * - Left tree: grows horizontally to the left (children: 1st, 3rd, 5th...) + * - Right tree: grows horizontally to the right (children: 2nd, 4th, 6th...) + */ +export const render = async ( + data4Layout: LayoutData, + svg: SVG, + { + insertCluster, + insertEdge, + insertEdgeLabel, + insertMarkers, + insertNode, + log, + positionEdgeLabel, + }: InternalHelpers, + { algorithm: _algorithm }: RenderOptions +) => { + const nodeDb: Record = {}; + const clusterDb: Record = {}; + + const element = svg.select('g'); + insertMarkers(element, data4Layout.markers, data4Layout.type, data4Layout.diagramId); + + const subGraphsEl = element.insert('g').attr('class', 'subgraphs'); + const edgePaths = element.insert('g').attr('class', 'edgePaths'); + const edgeLabels = element.insert('g').attr('class', 'edgeLabels'); + const nodes = element.insert('g').attr('class', 'nodes'); + // Step 1: Insert nodes into DOM to get their actual dimensions + log.debug('Inserting nodes into DOM for dimension calculation'); + + await Promise.all( + data4Layout.nodes.map(async (node) => { + if (node.isGroup) { + const clusterNode: NodeWithPosition = { + ...node, + id: node.id, + width: node.width, + height: node.height, + }; + clusterDb[node.id] = clusterNode; + nodeDb[node.id] = clusterNode; + + await insertCluster(subGraphsEl, node); + } else { + const nodeWithPosition: NodeWithPosition = { + ...node, + id: node.id, + width: node.width, + height: node.height, + }; + nodeDb[node.id] = nodeWithPosition; + + const nodeEl = await insertNode(nodes, node, { + config: data4Layout.config, + dir: data4Layout.direction || 'TB', + }); + + const boundingBox = nodeEl.node()!.getBBox(); + nodeWithPosition.width = boundingBox.width; + nodeWithPosition.height = boundingBox.height; + nodeWithPosition.domId = nodeEl; + + log.debug(`Node ${node.id} dimensions: ${boundingBox.width}x${boundingBox.height}`); + } + }) + ); + // Step 2: Run the bidirectional tidy-tree layout algorithm + log.debug('Running bidirectional tidy-tree layout algorithm'); + + const updatedLayoutData = { + ...data4Layout, + nodes: data4Layout.nodes.map((node) => { + const nodeWithDimensions = nodeDb[node.id]; + return { + ...node, + width: nodeWithDimensions.width ?? node.width ?? 100, + height: nodeWithDimensions.height ?? node.height ?? 50, + }; + }), + }; + + const layoutResult = await executeTidyTreeLayout(updatedLayoutData); + // Step 3: Position the nodes based on bidirectional layout results + log.debug('Positioning nodes based on bidirectional layout results'); + + layoutResult.nodes.forEach((positionedNode) => { + const node = nodeDb[positionedNode.id]; + if (node?.domId) { + // Position the node at the calculated coordinates from bidirectional layout + // The layout algorithm has already calculated positions for: + // - Root node at center (0, 0) + // - Left tree nodes with negative x coordinates (growing left) + // - Right tree nodes with positive x coordinates (growing right) + node.domId.attr('transform', `translate(${positionedNode.x}, ${positionedNode.y})`); + // Store the final position + node.x = positionedNode.x; + node.y = positionedNode.y; + // Step 3: Position the nodes based on bidirectional layout results + log.debug(`Positioned node ${node.id} at (${positionedNode.x}, ${positionedNode.y})`); + } + }); + + log.debug('Inserting and positioning edges'); + + await Promise.all( + data4Layout.edges.map(async (edge) => { + await insertEdgeLabel(edgeLabels, edge); + + const startNode = nodeDb[edge.start ?? '']; + const endNode = nodeDb[edge.end ?? '']; + + if (startNode && endNode) { + const positionedEdge = layoutResult.edges.find((e) => e.id === edge.id); + + if (positionedEdge) { + log.debug('APA01 positionedEdge', positionedEdge); + const edgeWithPath = { + ...edge, + points: positionedEdge.points, + }; + const paths = insertEdge( + edgePaths, + edgeWithPath, + clusterDb, + data4Layout.type, + startNode, + endNode, + data4Layout.diagramId + ); + + positionEdgeLabel(edgeWithPath, paths); + } else { + const edgeWithPath = { + ...edge, + points: [ + { x: startNode.x ?? 0, y: startNode.y ?? 0 }, + { x: endNode.x ?? 0, y: endNode.y ?? 0 }, + ], + }; + + const paths = insertEdge( + edgePaths, + edgeWithPath, + clusterDb, + data4Layout.type, + startNode, + endNode, + data4Layout.diagramId + ); + positionEdgeLabel(edgeWithPath, paths); + } + } + }) + ); + + log.debug('Bidirectional tidy-tree rendering completed'); +}; diff --git a/packages/mermaid-layout-tidy-tree/src/types.ts b/packages/mermaid-layout-tidy-tree/src/types.ts new file mode 100644 index 000000000..2015a4909 --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/src/types.ts @@ -0,0 +1,69 @@ +import type { LayoutData } from 'mermaid'; + +export type Node = LayoutData['nodes'][number]; +export type Edge = LayoutData['edges'][number]; + +/** + * Positioned node after layout calculation + */ +export interface PositionedNode { + id: string; + x: number; + y: number; + section?: 'root' | 'left' | 'right'; + width?: number; + height?: number; + originalNode?: Node; + [key: string]: unknown; +} + +/** + * Positioned edge after layout calculation + */ +export interface PositionedEdge { + id: string; + source: string; + target: string; + startX: number; + startY: number; + midX: number; + midY: number; + endX: number; + endY: number; + sourceSection?: 'root' | 'left' | 'right'; + targetSection?: 'root' | 'left' | 'right'; + sourceWidth?: number; + sourceHeight?: number; + targetWidth?: number; + targetHeight?: number; + [key: string]: unknown; +} + +/** + * Result of layout algorithm execution + */ +export interface LayoutResult { + nodes: PositionedNode[]; + edges: PositionedEdge[]; +} + +/** + * Tidy-tree node structure compatible with non-layered-tidy-tree-layout + */ +export interface TidyTreeNode { + id: string | number; + width: number; + height: number; + x?: number; + y?: number; + children?: TidyTreeNode[]; + _originalNode?: Node; +} + +/** + * Tidy-tree layout configuration + */ +export interface TidyTreeLayoutConfig { + gap: number; + bottomPadding: number; +} diff --git a/packages/mermaid-layout-tidy-tree/tsconfig.json b/packages/mermaid-layout-tidy-tree/tsconfig.json new file mode 100644 index 000000000..8f83e2bad --- /dev/null +++ b/packages/mermaid-layout-tidy-tree/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "types": ["vitest/importMeta", "vitest/globals"] + }, + "include": ["./src/**/*.ts", "./src/**/*.d.ts"], + "typeRoots": ["./src/types"] +} diff --git a/packages/mermaid-zenuml/package.json b/packages/mermaid-zenuml/package.json index cc9ce0d4a..fd2e58aa3 100644 --- a/packages/mermaid-zenuml/package.json +++ b/packages/mermaid-zenuml/package.json @@ -33,7 +33,7 @@ ], "license": "MIT", "dependencies": { - "@zenuml/core": "^3.35.2" + "@zenuml/core": "^3.41.6" }, "devDependencies": { "mermaid": "workspace:^" diff --git a/packages/mermaid/CHANGELOG.md b/packages/mermaid/CHANGELOG.md index df67f0cfd..52019d28c 100644 --- a/packages/mermaid/CHANGELOG.md +++ b/packages/mermaid/CHANGELOG.md @@ -1,5 +1,44 @@ # mermaid +## 11.12.1 + +### Patch Changes + +- [#7107](https://github.com/mermaid-js/mermaid/pull/7107) [`cbf8946`](https://github.com/mermaid-js/mermaid/commit/cbf89462acecac7a06f19843e8d48cb137df0753) Thanks [@shubhamparikh2704](https://github.com/shubhamparikh2704)! - fix: Updated the dependency dagre-d3-es to 7.0.13 to fix GHSA-cc8p-78qf-8p7q + +## 11.12.0 + +### Minor Changes + +- [#6921](https://github.com/mermaid-js/mermaid/pull/6921) [`764b315`](https://github.com/mermaid-js/mermaid/commit/764b315dc16d0359add7c6b8e3ef7592e9bdc09c) Thanks [@quilicicf](https://github.com/quilicicf)! - feat: Add IDs in architecture diagrams + +### Patch Changes + +- [#6950](https://github.com/mermaid-js/mermaid/pull/6950) [`a957908`](https://github.com/mermaid-js/mermaid/commit/a9579083bfba367a4f4678547ec37ed7b61b9f5b) Thanks [@shubhamparikh2704](https://github.com/shubhamparikh2704)! - chore: Fix mindmap rendering in docs and apply tidytree layout + +- [#6826](https://github.com/mermaid-js/mermaid/pull/6826) [`1d36810`](https://github.com/mermaid-js/mermaid/commit/1d3681053b9168354e48e5763023b6305cd1ca72) Thanks [@darshanr0107](https://github.com/darshanr0107)! - fix: Ensure edge label color is applied when using classDef with edge IDs + +- [#6945](https://github.com/mermaid-js/mermaid/pull/6945) [`d318f1a`](https://github.com/mermaid-js/mermaid/commit/d318f1a13cd7429334a29c70e449074ec1cb9f68) Thanks [@darshanr0107](https://github.com/darshanr0107)! - fix: Resolve gantt chart crash due to invalid array length + +- [#6918](https://github.com/mermaid-js/mermaid/pull/6918) [`cfe9238`](https://github.com/mermaid-js/mermaid/commit/cfe9238882cbe95416db1feea3112456a71b6aaf) Thanks [@shubhamparikh2704](https://github.com/shubhamparikh2704)! - chore: revert marked dependency from ^15.0.7 to ^16.0.0 + - Reverted marked package version to ^16.0.0 for better compatibility + - This is a dependency update that maintains API compatibility + - All tests pass with the updated version + +## 11.11.0 + +### Minor Changes + +- [#6704](https://github.com/mermaid-js/mermaid/pull/6704) [`012530e`](https://github.com/mermaid-js/mermaid/commit/012530e98e9b8b80962ab270b6bb3b6d9f6ada05) Thanks [@omkarht](https://github.com/omkarht)! - feat: Added support for new participant types (`actor`, `boundary`, `control`, `entity`, `database`, `collections`, `queue`) in `sequenceDiagram`. + +- [#6802](https://github.com/mermaid-js/mermaid/pull/6802) [`c8e5027`](https://github.com/mermaid-js/mermaid/commit/c8e50276e877c4de7593a09ec458c99353e65af8) Thanks [@darshanr0107](https://github.com/darshanr0107)! - feat: Update mindmap rendering to support multiple layouts, improved edge intersections, and new shapes + +### Patch Changes + +- [#6905](https://github.com/mermaid-js/mermaid/pull/6905) [`33bc4a0`](https://github.com/mermaid-js/mermaid/commit/33bc4a0b4e2ca6d937bb0a8c4e2081b1362b2800) Thanks [@darshanr0107](https://github.com/darshanr0107)! - fix: Render newlines as spaces in class diagrams + +- [#6886](https://github.com/mermaid-js/mermaid/pull/6886) [`e0b45c2`](https://github.com/mermaid-js/mermaid/commit/e0b45c2d2b41c2a9038bf87646fa3ccd7560eb20) Thanks [@darshanr0107](https://github.com/darshanr0107)! - fix: Handle arrows correctly when auto number is enabled + ## 11.10.0 ### Minor Changes @@ -154,7 +193,6 @@ ### Minor Changes - [#6408](https://github.com/mermaid-js/mermaid/pull/6408) [`ad65313`](https://github.com/mermaid-js/mermaid/commit/ad653138e16765d095613a6e5de86dc5e52ac8f0) Thanks [@ashishjain0512](https://github.com/ashishjain0512)! - fix: restore curve type configuration functionality for flowcharts. This fixes the issue where curve type settings were not being applied when configured through any of the following methods: - - Config - Init directive (%%{ init: { 'flowchart': { 'curve': '...' } } }%%) - LinkStyle command (linkStyle default interpolate ...) @@ -173,14 +211,12 @@ ### Minor Changes - [#6187](https://github.com/mermaid-js/mermaid/pull/6187) [`7809b5a`](https://github.com/mermaid-js/mermaid/commit/7809b5a93fae127f45727071f5ff14325222c518) Thanks [@ashishjain0512](https://github.com/ashishjain0512)! - Flowchart new syntax for node metadata bugs - - Incorrect label mapping for nodes when using `&` - Syntax error when `}` with trailing spaces before new line - [#6136](https://github.com/mermaid-js/mermaid/pull/6136) [`ec0d9c3`](https://github.com/mermaid-js/mermaid/commit/ec0d9c389aa6018043187654044c1e0b5aa4f600) Thanks [@knsv](https://github.com/knsv)! - Adding support for animation of flowchart edges - [#6373](https://github.com/mermaid-js/mermaid/pull/6373) [`05bdf0e`](https://github.com/mermaid-js/mermaid/commit/05bdf0e20e2629fe77513218fbd4e28e65f75882) Thanks [@ashishjain0512](https://github.com/ashishjain0512)! - Upgrade Requirement and ER diagram to use the common renderer flow - - Added support for directions - Added support for hand drawn look @@ -229,7 +265,6 @@ - [#5999](https://github.com/mermaid-js/mermaid/pull/5999) [`742ad7c`](https://github.com/mermaid-js/mermaid/commit/742ad7c130964df1fb5544e909d9556081285f68) Thanks [@knsv](https://github.com/knsv)! - Adding Kanban board, a new diagram type - [#5880](https://github.com/mermaid-js/mermaid/pull/5880) [`bdf145f`](https://github.com/mermaid-js/mermaid/commit/bdf145ffe362462176d9c1e68d5f3ff5c9d962b0) Thanks [@yari-dewalt](https://github.com/yari-dewalt)! - Class diagram changes: - - Updates the class diagram to the new unified way of rendering. - Includes a new "classBox" shape to be used in diagrams - Other updates such as: diff --git a/packages/mermaid/package.json b/packages/mermaid/package.json index 56446c34b..b02b26fc8 100644 --- a/packages/mermaid/package.json +++ b/packages/mermaid/package.json @@ -1,6 +1,6 @@ { "name": "mermaid", - "version": "11.10.0", + "version": "11.12.1", "description": "Markdown-ish syntax for generating flowcharts, mindmaps, sequence diagrams, class diagrams, gantt charts, git graphs and more.", "type": "module", "module": "./dist/mermaid.core.mjs", @@ -67,32 +67,32 @@ ] }, "dependencies": { - "@braintree/sanitize-url": "^7.0.4", - "@iconify/utils": "^2.1.33", + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "workspace:^", "@types/d3": "^7.4.3", - "cytoscape": "^3.29.3", + "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", - "dagre-d3-es": "7.0.11", - "dayjs": "^1.11.13", - "dompurify": "^3.2.5", - "katex": "^0.16.22", + "dagre-d3-es": "7.0.13", + "dayjs": "^1.11.19", + "dompurify": "^3.3.0", + "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.21", - "marked": "^16.0.0", + "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" }, "devDependencies": { - "@adobe/jsonschema2md": "^8.0.2", + "@adobe/jsonschema2md": "^8.0.7", "@iconify/types": "^2.0.0", "@types/cytoscape": "^3.21.9", - "@types/cytoscape-fcose": "^2.2.4", + "@types/cytoscape-fcose": "^2.2.5", "@types/d3-sankey": "^0.12.4", "@types/d3-scale": "^4.0.9", "@types/d3-scale-chromatic": "^3.1.0", @@ -101,34 +101,34 @@ "@types/jsdom": "^21.1.7", "@types/katex": "^0.16.7", "@types/lodash-es": "^4.17.12", - "@types/micromatch": "^4.0.9", + "@types/micromatch": "^4.0.10", "@types/stylis": "^4.2.7", "@types/uuid": "^10.0.0", "ajv": "^8.17.1", - "canvas": "^3.1.0", + "canvas": "^3.2.0", "chokidar": "3.6.0", - "concurrently": "^9.1.2", + "concurrently": "^9.2.1", "csstree-validator": "^4.0.1", - "globby": "^14.0.2", + "globby": "^14.1.0", "jison": "^0.4.18", - "js-base64": "^3.7.7", + "js-base64": "^3.7.8", "jsdom": "^26.1.0", "json-schema-to-typescript": "^15.0.4", "micromatch": "^4.0.8", "path-browserify": "^1.0.1", - "prettier": "^3.5.2", + "prettier": "^3.6.2", "remark": "^15.0.1", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", "rimraf": "^6.0.1", - "start-server-and-test": "^2.0.10", - "type-fest": "^4.35.0", - "typedoc": "^0.27.8", - "typedoc-plugin-markdown": "^4.4.2", + "start-server-and-test": "^2.1.2", + "type-fest": "^4.41.0", + "typedoc": "^0.28.14", + "typedoc-plugin-markdown": "^4.8.1", "typescript": "~5.7.3", "unist-util-flatmap": "^1.0.0", "unist-util-visit": "^5.0.0", - "vitepress": "^1.0.2", + "vitepress": "^1.6.4", "vitepress-plugin-search": "1.0.4-alpha.22" }, "files": [ diff --git a/packages/mermaid/scripts/docs.spec.ts b/packages/mermaid/scripts/docs.spec.ts index 68677d4c9..70923e226 100644 --- a/packages/mermaid/scripts/docs.spec.ts +++ b/packages/mermaid/scripts/docs.spec.ts @@ -171,7 +171,9 @@ This Markdown should be kept. expect(buildShapeDoc()).toMatchInlineSnapshot(` "| **Semantic Name** | **Shape Name** | **Short Name** | **Description** | **Alias Supported** | | --------------------------------- | ---------------------- | -------------- | ------------------------------ | ---------------------------------------------------------------- | + | Bang | Bang | \`bang\` | Bang | \`bang\` | | Card | Notched Rectangle | \`notch-rect\` | Represents a card | \`card\`, \`notched-rectangle\` | + | Cloud | Cloud | \`cloud\` | cloud | \`cloud\` | | Collate | Hourglass | \`hourglass\` | Represents a collate operation | \`collate\`, \`hourglass\` | | Com Link | Lightning Bolt | \`bolt\` | Communication link | \`com-link\`, \`lightning-bolt\` | | Comment | Curly Brace | \`brace\` | Adds a comment | \`brace-l\`, \`comment\` | diff --git a/packages/mermaid/src/config.spec.ts b/packages/mermaid/src/config.spec.ts index 000be1282..7fbae03af 100644 --- a/packages/mermaid/src/config.spec.ts +++ b/packages/mermaid/src/config.spec.ts @@ -78,3 +78,187 @@ describe('when working with site config', () => { expect(config_4.altFontFamily).toBeUndefined(); }); }); + +describe('getUserDefinedConfig', () => { + beforeEach(() => { + configApi.reset(); + }); + + it('should return empty object when no user config is defined', () => { + const userConfig = configApi.getUserDefinedConfig(); + expect(userConfig).toEqual({}); + }); + + it('should return config from initialize only', () => { + const initConfig: MermaidConfig = { theme: 'dark', fontFamily: 'Arial' }; + configApi.saveConfigFromInitialize(initConfig); + + const userConfig = configApi.getUserDefinedConfig(); + expect(userConfig).toEqual(initConfig); + }); + + it('should return config from directives only', () => { + const directive1: MermaidConfig = { layout: 'elk', fontSize: 14 }; + const directive2: MermaidConfig = { theme: 'forest' }; + + configApi.addDirective(directive1); + configApi.addDirective(directive2); + + expect(configApi.getUserDefinedConfig()).toMatchInlineSnapshot(` + { + "fontFamily": "Arial", + "fontSize": 14, + "layout": "elk", + "theme": "forest", + } + `); + }); + + it('should combine initialize config and directives', () => { + const initConfig: MermaidConfig = { theme: 'dark', fontFamily: 'Arial', layout: 'dagre' }; + const directive1: MermaidConfig = { layout: 'elk', fontSize: 14 }; + const directive2: MermaidConfig = { theme: 'forest' }; + + configApi.saveConfigFromInitialize(initConfig); + configApi.addDirective(directive1); + configApi.addDirective(directive2); + + const userConfig = configApi.getUserDefinedConfig(); + expect(userConfig).toMatchInlineSnapshot(` + { + "fontFamily": "Arial", + "fontSize": 14, + "layout": "elk", + "theme": "forest", + } + `); + }); + + it('should handle nested config objects properly', () => { + const initConfig: MermaidConfig = { + flowchart: { nodeSpacing: 50, rankSpacing: 100 }, + theme: 'default', + }; + const directive: MermaidConfig = { + flowchart: { nodeSpacing: 75, curve: 'basis' }, + mindmap: { padding: 20 }, + }; + + configApi.saveConfigFromInitialize(initConfig); + configApi.addDirective(directive); + + const userConfig = configApi.getUserDefinedConfig(); + expect(userConfig).toMatchInlineSnapshot(` + { + "flowchart": { + "curve": "basis", + "nodeSpacing": 75, + "rankSpacing": 100, + }, + "mindmap": { + "padding": 20, + }, + "theme": "default", + } + `); + }); + + it('should handle complex nested overrides', () => { + const initConfig: MermaidConfig = { + flowchart: { + nodeSpacing: 50, + rankSpacing: 100, + curve: 'linear', + }, + theme: 'default', + }; + const directive1: MermaidConfig = { + flowchart: { + nodeSpacing: 75, + }, + fontSize: 12, + }; + const directive2: MermaidConfig = { + flowchart: { + curve: 'basis', + nodeSpacing: 100, + }, + mindmap: { + padding: 15, + }, + }; + + configApi.saveConfigFromInitialize(initConfig); + configApi.addDirective(directive1); + configApi.addDirective(directive2); + + const userConfig = configApi.getUserDefinedConfig(); + expect(userConfig).toMatchInlineSnapshot(` + { + "flowchart": { + "curve": "basis", + "nodeSpacing": 100, + "rankSpacing": 100, + }, + "fontSize": 12, + "mindmap": { + "padding": 15, + }, + "theme": "default", + } + `); + }); + + it('should return independent copies (not references)', () => { + const initConfig: MermaidConfig = { theme: 'dark', flowchart: { nodeSpacing: 50 } }; + configApi.saveConfigFromInitialize(initConfig); + + const userConfig1 = configApi.getUserDefinedConfig(); + const userConfig2 = configApi.getUserDefinedConfig(); + + userConfig1.theme = 'neutral'; + userConfig1.flowchart!.nodeSpacing = 999; + + expect(userConfig2).toMatchInlineSnapshot(` + { + "flowchart": { + "nodeSpacing": 50, + }, + "theme": "dark", + } + `); + }); + + it('should handle edge cases with undefined values', () => { + const initConfig: MermaidConfig = { theme: 'dark', layout: undefined }; + const directive: MermaidConfig = { fontSize: 14, fontFamily: undefined }; + + configApi.saveConfigFromInitialize(initConfig); + configApi.addDirective(directive); + + expect(configApi.getUserDefinedConfig()).toMatchInlineSnapshot(` + { + "fontSize": 14, + "layout": undefined, + "theme": "dark", + } + `); + }); + + it('should retain config from initialize after reset', () => { + const initConfig: MermaidConfig = { theme: 'dark' }; + const directive: MermaidConfig = { layout: 'elk' }; + + configApi.saveConfigFromInitialize(initConfig); + configApi.addDirective(directive); + + expect(configApi.getUserDefinedConfig()).toMatchInlineSnapshot(` + { + "layout": "elk", + "theme": "dark", + } + `); + + configApi.reset(); + }); +}); diff --git a/packages/mermaid/src/config.ts b/packages/mermaid/src/config.ts index 9468a3e46..4fcb3224d 100644 --- a/packages/mermaid/src/config.ts +++ b/packages/mermaid/src/config.ts @@ -248,3 +248,17 @@ const checkConfig = (config: MermaidConfig) => { issueWarning('LAZY_LOAD_DEPRECATED'); } }; + +export const getUserDefinedConfig = (): MermaidConfig => { + let userConfig: MermaidConfig = {}; + + if (configFromInitialize) { + userConfig = assignWithDepth(userConfig, configFromInitialize); + } + + for (const d of directives) { + userConfig = assignWithDepth(userConfig, d); + } + + return userConfig; +}; diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index 70391f2e5..79fadd195 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -1075,6 +1075,10 @@ export interface ArchitectureDiagramConfig extends BaseDiagramConfig { export interface MindmapDiagramConfig extends BaseDiagramConfig { padding?: number; maxNodeWidth?: number; + /** + * Layout algorithm to use for positioning mindmap nodes + */ + layoutAlgorithm?: string; } /** * The object containing configurations specific for kanban diagrams diff --git a/packages/mermaid/src/diagram-api/comments.spec.ts b/packages/mermaid/src/diagram-api/comments.spec.ts index 57a7d4a34..febca83e9 100644 --- a/packages/mermaid/src/diagram-api/comments.spec.ts +++ b/packages/mermaid/src/diagram-api/comments.spec.ts @@ -1,5 +1,3 @@ -// tests to check that comments are removed - import { cleanupComments } from './comments.js'; import { describe, it, expect } from 'vitest'; @@ -10,12 +8,12 @@ describe('comments', () => { %% This is a comment %% This is another comment graph TD - A-->B + A-->B %% This is a comment `; expect(cleanupComments(text)).toMatchInlineSnapshot(` "graph TD - A-->B + A-->B " `); }); @@ -29,9 +27,9 @@ graph TD %%{ init: {'theme': 'space before init'}}%% %%{init: {'theme': 'space after ending'}}%% graph TD - A-->B + A-->B - B-->C + B-->C %% This is a comment `; expect(cleanupComments(text)).toMatchInlineSnapshot(` @@ -39,9 +37,9 @@ graph TD %%{ init: {'theme': 'space before init'}}%% %%{init: {'theme': 'space after ending'}}%% graph TD - A-->B + A-->B - B-->C + B-->C " `); }); @@ -50,14 +48,14 @@ graph TD const text = ` %% This is a comment graph TD - A-->B - %% This is a comment - C-->D + A-->B + %% This is a comment + C-->D `; expect(cleanupComments(text)).toMatchInlineSnapshot(` "graph TD - A-->B - C-->D + A-->B + C-->D " `); }); @@ -70,11 +68,11 @@ graph TD %% This is a comment graph TD - A-->B + A-->B `; expect(cleanupComments(text)).toMatchInlineSnapshot(` "graph TD - A-->B + A-->B " `); }); @@ -82,12 +80,12 @@ graph TD it('should remove comments at end of text with no newline', () => { const text = ` graph TD - A-->B + A-->B %% This is a comment`; expect(cleanupComments(text)).toMatchInlineSnapshot(` "graph TD - A-->B + A-->B " `); }); diff --git a/packages/mermaid/src/diagram-api/diagram-orchestration.ts b/packages/mermaid/src/diagram-api/diagram-orchestration.ts index 97b9852ff..fa90e1df9 100644 --- a/packages/mermaid/src/diagram-api/diagram-orchestration.ts +++ b/packages/mermaid/src/diagram-api/diagram-orchestration.ts @@ -72,7 +72,7 @@ export const addDiagrams = () => { } ); - if (includeLargeFeatures) { + if (injected.includeLargeFeatures) { registerLazyLoadedDiagrams(flowchartElk, mindmap, architecture); } diff --git a/packages/mermaid/src/diagram-api/types.ts b/packages/mermaid/src/diagram-api/types.ts index 56364e9c6..ed90eec57 100644 --- a/packages/mermaid/src/diagram-api/types.ts +++ b/packages/mermaid/src/diagram-api/types.ts @@ -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; } diff --git a/packages/mermaid/src/diagrams/architecture/architectureRenderer.ts b/packages/mermaid/src/diagrams/architecture/architectureRenderer.ts index b29567236..608b11816 100644 --- a/packages/mermaid/src/diagrams/architecture/architectureRenderer.ts +++ b/packages/mermaid/src/diagrams/architecture/architectureRenderer.ts @@ -1,6 +1,5 @@ -import type { Position } from 'cytoscape'; +import type { LayoutOptions, Position } from 'cytoscape'; import cytoscape from 'cytoscape'; -import type { FcoseLayoutOptions } from 'cytoscape-fcose'; import fcose from 'cytoscape-fcose'; import { select } from 'd3'; import type { DrawDefinition, SVG } from '../../diagram-api/types.js'; @@ -41,7 +40,7 @@ registerIconPacks([ icons: architectureIcons, }, ]); -cytoscape.use(fcose); +cytoscape.use(fcose as any); function addServices(services: ArchitectureService[], cy: cytoscape.Core, db: ArchitectureDB) { services.forEach((service) => { @@ -429,7 +428,7 @@ function layoutArchitecture( }, alignmentConstraint, relativePlacementConstraint, - } as FcoseLayoutOptions); + } as LayoutOptions); // Once the diagram has been generated and the service's position cords are set, adjust the XY edges to have a 90deg bend layout.one('layoutstop', () => { diff --git a/packages/mermaid/src/diagrams/architecture/svgDraw.spec.ts b/packages/mermaid/src/diagrams/architecture/svgDraw.spec.ts new file mode 100644 index 000000000..21d56b5af --- /dev/null +++ b/packages/mermaid/src/diagrams/architecture/svgDraw.spec.ts @@ -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 { + const diagram = await Diagram.fromText(diagramText, {}); + await draw('NOT_USED', 'svg', '1.0.0', diagram); + return ensureNodeFromSelector('#svg'); +} diff --git a/packages/mermaid/src/diagrams/architecture/svgDraw.ts b/packages/mermaid/src/diagrams/architecture/svgDraw.ts index 6e470caa2..b35e453e7 100644 --- a/packages/mermaid/src/diagrams/architecture/svgDraw.ts +++ b/packages/mermaid/src/diagrams/architecture/svgDraw.ts @@ -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); diff --git a/packages/mermaid/src/diagrams/c4/parser/c4Component.spec.js b/packages/mermaid/src/diagrams/c4/parser/c4Component.spec.js new file mode 100644 index 000000000..70d93d8be --- /dev/null +++ b/packages/mermaid/src/diagrams/c4/parser/c4Component.spec.js @@ -0,0 +1,58 @@ +import c4Db from '../c4Db.js'; +import c4 from './c4Diagram.jison'; +import { setConfig } from '../../../config.js'; + +setConfig({ + securityLevel: 'strict', +}); + +describe.each([ + ['Component', 'component'], + ['ComponentDb', 'component_db'], + ['ComponentQueue', 'component_queue'], + ['Component_Ext', 'external_component'], + ['ComponentDb_Ext', 'external_component_db'], + ['ComponentQueue_Ext', 'external_component_queue'], +])('parsing a C4 %s', function (macroName, elementName) { + beforeEach(function () { + c4.parser.yy = c4Db; + c4.parser.yy.clear(); + }); + + it('should parse a C4 diagram with one Component correctly', function () { + c4.parser.parse(`C4Component +title Component diagram for Internet Banking Component +${macroName}(ComponentAA, "Internet Banking Component", "Technology", "Allows customers to view information about their bank accounts, and make payments.")`); + + const yy = c4.parser.yy; + + const shapes = yy.getC4ShapeArray(); + expect(shapes.length).toBe(1); + const onlyShape = shapes[0]; + + expect(onlyShape).toMatchObject({ + alias: 'ComponentAA', + descr: { + text: 'Allows customers to view information about their bank accounts, and make payments.', + }, + label: { + text: 'Internet Banking Component', + }, + techn: { + text: 'Technology', + }, + typeC4Shape: { + text: elementName, + }, + }); + }); + + it('should handle a trailing whitespaces after Component', function () { + const whitespace = ' '; + const rendered = c4.parser.parse(`C4Component${whitespace} +title Component diagram for Internet Banking Component${whitespace} +${macroName}(ComponentAA, "Internet Banking Component", "Technology", "Allows customers to view information about their bank accounts, and make payments.")${whitespace}`); + + expect(rendered).toBe(true); + }); +}); diff --git a/packages/mermaid/src/diagrams/c4/parser/c4Diagram.jison b/packages/mermaid/src/diagrams/c4/parser/c4Diagram.jison index 63856f044..f0ce80d33 100644 --- a/packages/mermaid/src/diagrams/c4/parser/c4Diagram.jison +++ b/packages/mermaid/src/diagrams/c4/parser/c4Diagram.jison @@ -158,10 +158,10 @@ accDescr\s*"{"\s* { this.begin("acc_descr_multiline");} "UpdateRelStyle" { this.begin("update_rel_style"); return 'UPDATE_REL_STYLE';} "UpdateLayoutConfig" { this.begin("update_layout_config"); return 'UPDATE_LAYOUT_CONFIG';} -<> return "EOF_IN_STRUCT"; -[(][ ]*[,] { this.begin("attribute"); return "ATTRIBUTE_EMPTY";} -[(] { this.begin("attribute"); } -[)] { this.popState();this.popState();} +<> return "EOF_IN_STRUCT"; +[(][ ]*[,] { this.begin("attribute"); return "ATTRIBUTE_EMPTY";} +[(] { this.begin("attribute"); } +[)] { this.popState();this.popState();} ",," { return 'ATTRIBUTE_EMPTY';} "," { } diff --git a/packages/mermaid/src/diagrams/class/classDb.ts b/packages/mermaid/src/diagrams/class/classDb.ts index 82ddcf09b..704891644 100644 --- a/packages/mermaid/src/diagrams/class/classDb.ts +++ b/packages/mermaid/src/diagrams/class/classDb.ts @@ -17,6 +17,7 @@ import type { ClassRelation, ClassNode, ClassNote, + ClassNoteMap, ClassMap, NamespaceMap, NamespaceNode, @@ -33,15 +34,16 @@ const sanitizeText = (txt: string) => common.sanitizeText(txt, getConfig()); export class ClassDB implements DiagramDB { private relations: ClassRelation[] = []; - private classes = new Map(); + private classes: ClassMap = new Map(); private readonly styleClasses = new Map(); - private notes: ClassNote[] = []; + private notes: ClassNoteMap = new Map(); private interfaces: Interface[] = []; // private static classCounter = 0; private namespaces = new Map(); private namespaceCounter = 0; - private functions: any[] = []; + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + private functions: Function[] = []; constructor() { this.functions.push(this.setupToolTips.bind(this)); @@ -124,7 +126,7 @@ export class ClassDB implements DiagramDB { annotations: [], styles: [], domId: MERMAID_DOM_ID_PREFIX + name + '-' + classCounter, - } as ClassNode); + }); classCounter++; } @@ -155,12 +157,12 @@ export class ClassDB implements DiagramDB { public clear() { this.relations = []; - this.classes = new Map(); - this.notes = []; + this.classes = new Map(); + this.notes = new Map(); this.interfaces = []; this.functions = []; this.functions.push(this.setupToolTips.bind(this)); - this.namespaces = new Map(); + this.namespaces = new Map(); this.namespaceCounter = 0; this.direction = 'TB'; commonClear(); @@ -178,7 +180,12 @@ export class ClassDB implements DiagramDB { return this.relations; } - public getNotes() { + public getNote(id: string | number): ClassNote { + const key = typeof id === 'number' ? `note${id}` : id; + return this.notes.get(key)!; + } + + public getNotes(): ClassNoteMap { return this.notes; } @@ -279,16 +286,19 @@ export class ClassDB implements DiagramDB { } } - public addNote(text: string, className: string) { + public addNote(text: string, className: string): string { + const index = this.notes.size; const note = { - id: `note${this.notes.length}`, + id: `note${index}`, class: className, text: text, + index: index, }; - this.notes.push(note); + this.notes.set(note.id, note); + return note.id; } - public cleanupLabel(label: string) { + public cleanupLabel(label: string): string { if (label.startsWith(':')) { label = label.substring(1); } @@ -354,7 +364,7 @@ export class ClassDB implements DiagramDB { }); } - public getTooltip(id: string, namespace?: string) { + public getTooltip(id: string, namespace?: string): string | undefined { if (namespace && this.namespaces.has(namespace)) { return this.namespaces.get(namespace)!.classes.get(id)!.tooltip; } @@ -534,10 +544,11 @@ export class ClassDB implements DiagramDB { this.namespaces.set(id, { id: id, - classes: new Map(), - children: {}, + classes: new Map(), + notes: new Map(), + children: new Map(), domId: MERMAID_DOM_ID_PREFIX + id + '-' + this.namespaceCounter, - } as NamespaceNode); + }); this.namespaceCounter++; } @@ -555,16 +566,23 @@ export class ClassDB implements DiagramDB { * * @param id - ID of the namespace to add * @param classNames - IDs of the class to add + * @param noteNames - IDs of the notes to add * @public */ - public addClassesToNamespace(id: string, classNames: string[]) { + public addClassesToNamespace(id: string, classNames: string[], noteNames: string[]) { if (!this.namespaces.has(id)) { return; } for (const name of classNames) { const { className } = this.splitClassNameAndType(name); - this.classes.get(className)!.parent = id; - this.namespaces.get(id)!.classes.set(className, this.classes.get(className)!); + const classNode = this.getClass(className); + classNode.parent = id; + this.namespaces.get(id)!.classes.set(className, classNode); + } + for (const noteName of noteNames) { + const noteNode = this.getNote(noteName); + noteNode.parent = id; + this.namespaces.get(id)!.notes.set(noteName, noteNode); } } @@ -617,36 +635,32 @@ export class ClassDB implements DiagramDB { const edges: Edge[] = []; const config = getConfig(); - for (const namespaceKey of this.namespaces.keys()) { - const namespace = this.namespaces.get(namespaceKey); - if (namespace) { - const node: Node = { - id: namespace.id, - label: namespace.id, - isGroup: true, - padding: config.class!.padding ?? 16, - // parent node must be one of [rect, roundedWithTitle, noteGroup, divider] - shape: 'rect', - cssStyles: ['fill: none', 'stroke: black'], - look: config.look, - }; - nodes.push(node); - } + for (const namespace of this.namespaces.values()) { + const node: Node = { + id: namespace.id, + label: namespace.id, + isGroup: true, + padding: config.class!.padding ?? 16, + // parent node must be one of [rect, roundedWithTitle, noteGroup, divider] + shape: 'rect', + cssStyles: [], + look: config.look, + }; + nodes.push(node); } - for (const classKey of this.classes.keys()) { - const classNode = this.classes.get(classKey); - if (classNode) { - const node = classNode as unknown as Node; - node.parentId = classNode.parent; - node.look = config.look; - nodes.push(node); - } + for (const classNode of this.classes.values()) { + const node: Node = { + ...classNode, + type: undefined, + isGroup: false, + parentId: classNode.parent, + look: config.look, + }; + nodes.push(node); } - let cnt = 0; - for (const note of this.notes) { - cnt++; + for (const note of this.notes.values()) { const noteNode: Node = { id: note.id, label: note.text, @@ -660,14 +674,15 @@ export class ClassDB implements DiagramDB { `stroke: ${config.themeVariables.noteBorderColor}`, ], look: config.look, + parentId: note.parent, }; nodes.push(noteNode); - const noteClassId = this.classes.get(note.class)?.id ?? ''; + const noteClassId = this.classes.get(note.class)?.id; if (noteClassId) { const edge: Edge = { - id: `edgeNote${cnt}`, + id: `edgeNote${note.index}`, start: note.id, end: noteClassId, type: 'normal', @@ -697,7 +712,7 @@ export class ClassDB implements DiagramDB { nodes.push(interfaceNode); } - cnt = 0; + let cnt = 0; for (const classRelation of this.relations) { cnt++; const edge: Edge = { diff --git a/packages/mermaid/src/diagrams/class/classDiagram.spec.ts b/packages/mermaid/src/diagrams/class/classDiagram.spec.ts index 7c88f2e41..417609a50 100644 --- a/packages/mermaid/src/diagrams/class/classDiagram.spec.ts +++ b/packages/mermaid/src/diagrams/class/classDiagram.spec.ts @@ -417,7 +417,7 @@ class C13["With Città foreign language"] note "This is a keyword: ${keyword}. It truly is." `; parser.parse(str); - expect(classDb.getNotes()[0].text).toEqual(`This is a keyword: ${keyword}. It truly is.`); + expect(classDb.getNote(0).text).toEqual(`This is a keyword: ${keyword}. It truly is.`); }); it.each(keywords)( @@ -427,7 +427,7 @@ class C13["With Città foreign language"] note "${keyword}"`; parser.parse(str); - expect(classDb.getNotes()[0].text).toEqual(`${keyword}`); + expect(classDb.getNote(0).text).toEqual(`${keyword}`); } ); @@ -441,7 +441,7 @@ class C13["With Città foreign language"] `; parser.parse(str); - expect(classDb.getNotes()[0].text).toEqual(`This is a keyword: ${keyword}. It truly is.`); + expect(classDb.getNote(0).text).toEqual(`This is a keyword: ${keyword}. It truly is.`); }); it.each(keywords)( @@ -456,7 +456,7 @@ class C13["With Città foreign language"] `; parser.parse(str); - expect(classDb.getNotes()[0].text).toEqual(`${keyword}`); + expect(classDb.getNote(0).text).toEqual(`${keyword}`); } ); @@ -1070,6 +1070,14 @@ describe('given a class diagram with members and methods ', function () { parser.parse(str); }); + it('should handle an empty class body with {}', function () { + const str = 'classDiagram\nclass EmptyClass {}'; + parser.parse(str); + const actual = parser.yy.getClass('EmptyClass'); + expect(actual.label).toBe('EmptyClass'); + expect(actual.members.length).toBe(0); + expect(actual.methods.length).toBe(0); + }); }); }); diff --git a/packages/mermaid/src/diagrams/class/classRenderer-v2.ts b/packages/mermaid/src/diagrams/class/classRenderer-v2.ts index 0f02efa0d..f273e58a7 100644 --- a/packages/mermaid/src/diagrams/class/classRenderer-v2.ts +++ b/packages/mermaid/src/diagrams/class/classRenderer-v2.ts @@ -8,7 +8,7 @@ import utils, { getEdgeId } from '../../utils.js'; import { interpolateToCurve, getStylesFromArray } from '../../utils.js'; import { setupGraphViewbox } from '../../setupGraphViewbox.js'; import common from '../common/common.js'; -import type { ClassRelation, ClassNote, ClassMap, NamespaceMap } from './classTypes.js'; +import type { ClassRelation, ClassMap, ClassNoteMap, NamespaceMap } from './classTypes.js'; import type { EdgeData } from '../../types.js'; const sanitizeText = (txt: string) => common.sanitizeText(txt, getConfig()); @@ -65,6 +65,9 @@ export const addNamespaces = function ( g.setNode(vertex.id, node); addClasses(vertex.classes, g, _id, diagObj, vertex.id); + const classes: ClassMap = diagObj.db.getClasses(); + const relations: ClassRelation[] = diagObj.db.getRelations(); + addNotes(vertex.notes, g, relations.length + 1, classes, vertex.id); log.info('setNode', node); }); @@ -144,69 +147,74 @@ export const addClasses = function ( * @param classes - Classes */ export const addNotes = function ( - notes: ClassNote[], + notes: ClassNoteMap, g: graphlib.Graph, startEdgeId: number, - classes: ClassMap + classes: ClassMap, + parent?: string ) { log.info(notes); - notes.forEach(function (note, i) { - const vertex = note; + [...notes.values()] + .filter((note) => note.parent === parent) + .forEach(function (vertex) { + const cssNoteStr = ''; - const cssNoteStr = ''; + const styles = { labelStyle: '', style: '' }; - const styles = { labelStyle: '', style: '' }; + const vertexText = vertex.text; - const vertexText = vertex.text; + const radius = 0; + const shape = 'note'; + const node = { + labelStyle: styles.labelStyle, + shape: shape, + labelText: sanitizeText(vertexText), + noteData: vertex, + rx: radius, + ry: radius, + class: cssNoteStr, + style: styles.style, + id: vertex.id, + domId: vertex.id, + tooltip: '', + type: 'note', + // TODO V10: Flowchart ? Keeping flowchart for backwards compatibility. Remove in next major release + padding: getConfig().flowchart?.padding ?? getConfig().class?.padding, + }; + g.setNode(vertex.id, node); + log.info('setNode', node); - const radius = 0; - const shape = 'note'; - const node = { - labelStyle: styles.labelStyle, - shape: shape, - labelText: sanitizeText(vertexText), - noteData: vertex, - rx: radius, - ry: radius, - class: cssNoteStr, - style: styles.style, - id: vertex.id, - domId: vertex.id, - tooltip: '', - type: 'note', - // TODO V10: Flowchart ? Keeping flowchart for backwards compatibility. Remove in next major release - padding: getConfig().flowchart?.padding ?? getConfig().class?.padding, - }; - g.setNode(vertex.id, node); - log.info('setNode', node); + if (parent) { + g.setParent(vertex.id, parent); + } - if (!vertex.class || !classes.has(vertex.class)) { - return; - } - const edgeId = startEdgeId + i; + if (!vertex.class || !classes.has(vertex.class)) { + return; + } + const edgeId = startEdgeId + vertex.index; - const edgeData: EdgeData = { - id: `edgeNote${edgeId}`, - //Set relationship style and line type - classes: 'relation', - pattern: 'dotted', - // Set link type for rendering - arrowhead: 'none', - //Set edge extra labels - startLabelRight: '', - endLabelLeft: '', - //Set relation arrow types - arrowTypeStart: 'none', - arrowTypeEnd: 'none', - style: 'fill:none', - labelStyle: '', - curve: interpolateToCurve(conf.curve, curveLinear), - }; + const edgeData: EdgeData = { + id: `edgeNote${edgeId}`, + //Set relationship style and line type + classes: 'relation', + pattern: 'dotted', + // Set link type for rendering + arrowhead: 'none', + //Set edge extra labels + startLabelRight: '', + endLabelLeft: '', + //Set relation arrow types + arrowTypeStart: 'none', + arrowTypeEnd: 'none', + style: 'fill:none', + labelStyle: '', + curve: interpolateToCurve(conf.curve, curveLinear), + }; - // Add the edge to the graph - g.setEdge(vertex.id, vertex.class, edgeData, edgeId); - }); + // Add the edge to the graph + g.setEdge(vertex.id, vertex.class, edgeData, edgeId); + }); }; /** @@ -329,7 +337,7 @@ export const draw = async function (text: string, id: string, _version: string, const namespaces: NamespaceMap = diagObj.db.getNamespaces(); const classes: ClassMap = diagObj.db.getClasses(); const relations: ClassRelation[] = diagObj.db.getRelations(); - const notes: ClassNote[] = diagObj.db.getNotes(); + const notes: ClassNoteMap = diagObj.db.getNotes(); log.info(relations); addNamespaces(namespaces, g, id, diagObj); addClasses(classes, g, id, diagObj); diff --git a/packages/mermaid/src/diagrams/class/classRenderer.js b/packages/mermaid/src/diagrams/class/classRenderer.js index 27e525537..a16eee5d4 100644 --- a/packages/mermaid/src/diagrams/class/classRenderer.js +++ b/packages/mermaid/src/diagrams/class/classRenderer.js @@ -206,7 +206,7 @@ export const draw = function (text, id, _version, diagObj) { ); }); - const notes = diagObj.db.getNotes(); + const notes = diagObj.db.getNotes().values(); notes.forEach(function (note) { log.debug(`Adding note: ${JSON.stringify(note)}`); const node = svgDraw.drawNote(diagram, note, conf, diagObj); diff --git a/packages/mermaid/src/diagrams/class/classTypes.ts b/packages/mermaid/src/diagrams/class/classTypes.ts index 9d0d47569..e3351bb18 100644 --- a/packages/mermaid/src/diagrams/class/classTypes.ts +++ b/packages/mermaid/src/diagrams/class/classTypes.ts @@ -5,7 +5,7 @@ export interface ClassNode { id: string; type: string; label: string; - shape: string; + shape: 'classBox'; text: string; cssClasses: string; methods: ClassMember[]; @@ -149,6 +149,8 @@ export interface ClassNote { id: string; class: string; text: string; + index: number; + parent?: string; } export interface ClassRelation { @@ -177,6 +179,7 @@ export interface NamespaceNode { id: string; domId: string; classes: ClassMap; + notes: ClassNoteMap; children: NamespaceMap; } @@ -187,4 +190,5 @@ export interface StyleClass { } export type ClassMap = Map; +export type ClassNoteMap = Map; export type NamespaceMap = Map; diff --git a/packages/mermaid/src/diagrams/class/parser/classDiagram.jison b/packages/mermaid/src/diagrams/class/parser/classDiagram.jison index 0f971c8b9..657817a69 100644 --- a/packages/mermaid/src/diagrams/class/parser/classDiagram.jison +++ b/packages/mermaid/src/diagrams/class/parser/classDiagram.jison @@ -275,8 +275,8 @@ statement ; namespaceStatement - : namespaceIdentifier STRUCT_START classStatements STRUCT_STOP { yy.addClassesToNamespace($1, $3); } - | namespaceIdentifier STRUCT_START NEWLINE classStatements STRUCT_STOP { yy.addClassesToNamespace($1, $4); } + : namespaceIdentifier STRUCT_START classStatements STRUCT_STOP { yy.addClassesToNamespace($1, $3[0], $3[1]); } + | namespaceIdentifier STRUCT_START NEWLINE classStatements STRUCT_STOP { yy.addClassesToNamespace($1, $4[0], $4[1]); } ; namespaceIdentifier @@ -284,15 +284,19 @@ namespaceIdentifier ; classStatements - : classStatement {$$=[$1]} - | classStatement NEWLINE {$$=[$1]} - | classStatement NEWLINE classStatements {$3.unshift($1); $$=$3} + : classStatement {$$=[[$1], []]} + | classStatement NEWLINE {$$=[[$1], []]} + | classStatement NEWLINE classStatements {$3[0].unshift($1); $$=$3} + | noteStatement {$$=[[], [$1]]} + | noteStatement NEWLINE {$$=[[], [$1]]} + | noteStatement NEWLINE classStatements {$3[1].unshift($1); $$=$3} ; classStatement : classIdentifier | classIdentifier STYLE_SEPARATOR alphaNumToken {yy.setCssClass($1, $3);} | classIdentifier STRUCT_START members STRUCT_STOP {yy.addMembers($1,$3);} + | classIdentifier STRUCT_START STRUCT_STOP {} | classIdentifier STYLE_SEPARATOR alphaNumToken STRUCT_START members STRUCT_STOP {yy.setCssClass($1, $3);yy.addMembers($1,$5);} ; @@ -301,8 +305,15 @@ classIdentifier | CLASS className classLabel {$$=$2; yy.addClass($2);yy.setClassLabel($2, $3);} ; + +emptyBody + : + | SPACE emptyBody + | NEWLINE emptyBody + ; + annotationStatement - :ANNOTATION_START alphaNumToken ANNOTATION_END className { yy.addAnnotation($4,$2); } + : ANNOTATION_START alphaNumToken ANNOTATION_END className { yy.addAnnotation($4,$2); } ; members @@ -325,8 +336,8 @@ relationStatement ; noteStatement - : NOTE_FOR className noteText { yy.addNote($3, $2); } - | NOTE noteText { yy.addNote($2); } + : NOTE_FOR className noteText { $$ = yy.addNote($3, $2); } + | NOTE noteText { $$ = yy.addNote($2); } ; classDefStatement diff --git a/packages/mermaid/src/diagrams/class/styles.js b/packages/mermaid/src/diagrams/class/styles.js index ef22e28d1..5158dd738 100644 --- a/packages/mermaid/src/diagrams/class/styles.js +++ b/packages/mermaid/src/diagrams/class/styles.js @@ -13,6 +13,30 @@ const getStyles = (options) => } + .cluster-label text { + fill: ${options.titleColor}; + } + .cluster-label span { + color: ${options.titleColor}; + } + .cluster-label span p { + background-color: transparent; + } + + .cluster rect { + fill: ${options.clusterBkg}; + stroke: ${options.clusterBorder}; + stroke-width: 1px; + } + + .cluster text { + fill: ${options.titleColor}; + } + + .cluster span { + color: ${options.titleColor}; + } + .nodeLabel, .edgeLabel { color: ${options.classText}; } diff --git a/packages/mermaid/src/diagrams/common/common.spec.ts b/packages/mermaid/src/diagrams/common/common.spec.ts index 3c7e0fdb8..edaf0b6dd 100644 --- a/packages/mermaid/src/diagrams/common/common.spec.ts +++ b/packages/mermaid/src/diagrams/common/common.spec.ts @@ -70,6 +70,31 @@ describe('Sanitize text', () => { }); expect(result).not.toContain('javascript:alert(1)'); }); + + it('should allow HTML tags in sandbox mode', () => { + const htmlStr = '

This is a bold text

'; + const result = sanitizeText(htmlStr, { + securityLevel: 'sandbox', + flowchart: { htmlLabels: true }, + }); + expect(result).toContain('

'); + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain('

'); + }); + + it('should remove script tags in sandbox mode', () => { + const maliciousStr = '

Hello world

'; + const result = sanitizeText(maliciousStr, { + securityLevel: 'sandbox', + flowchart: { htmlLabels: true }, + }); + expect(result).not.toContain('